001/*
002 * JDrupes MDoclet
003 * Copyright 2013 Raffael Herzog
004 * Copyright (C) 2017 Michael N. Lipp
005 * 
006 * This program is free software; you can redistribute it and/or modify it 
007 * under the terms of the GNU General Public License as published by 
008 * the Free Software Foundation; either version 3 of the License, or 
009 * (at your option) any later version.
010 * 
011 * This program is distributed in the hope that it will be useful, but 
012 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
013 * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License 
014 * for more details.
015 * 
016 * You should have received a copy of the GNU General Public License along 
017 * with this program; if not, see <http://www.gnu.org/licenses/>.
018 */
019package org.jdrupes.mdoclet;
020
021import java.io.File;
022import java.io.FileOutputStream;
023import java.io.IOException;
024import java.io.InputStream;
025import java.io.OutputStream;
026import java.lang.reflect.Field;
027import java.nio.file.Files;
028import java.nio.file.StandardCopyOption;
029import java.util.ArrayList;
030import java.util.Arrays;
031import java.util.HashMap;
032import java.util.HashSet;
033import java.util.List;
034import java.util.Map;
035import java.util.Set;
036import java.util.regex.Pattern;
037
038import org.jdrupes.mdoclet.renderers.ParamTagRenderer;
039import org.jdrupes.mdoclet.renderers.SeeTagRenderer;
040import org.jdrupes.mdoclet.renderers.SimpleTagRenderer;
041import org.jdrupes.mdoclet.renderers.TagRenderer;
042import org.jdrupes.mdoclet.renderers.ThrowsTagRenderer;
043
044import com.sun.javadoc.AnnotationTypeDoc;
045import com.sun.javadoc.ClassDoc;
046import com.sun.javadoc.Doc;
047import com.sun.javadoc.DocErrorReporter;
048import com.sun.javadoc.Doclet;
049import com.sun.javadoc.LanguageVersion;
050import com.sun.javadoc.MemberDoc;
051import com.sun.javadoc.PackageDoc;
052import com.sun.javadoc.RootDoc;
053import com.sun.javadoc.SourcePosition;
054import com.sun.javadoc.Tag;
055import com.sun.tools.doclets.standard.Standard;
056import com.sun.tools.javadoc.Main;
057
058
059/**
060 * The Doclet implementation. It converts the Markdown from the JavaDoc comments and tags
061 * to HTML and sets a resulting JavaDoc comment using
062 * {@link Doc#setRawCommentText(String)}. It then passes the `RootDoc` to the standard
063 * Doclet.
064 *
065 * @see "[The Doclet Specification](http://docs.oracle.com/javase/1.5.0/docs/guide/javadoc/doclet/spec/index.html)"
066 */
067public class MDoclet extends Doclet implements DocErrorReporter {
068
069    public static final String HIGHLIGHT_JS_HTML =
070            "<script type=\"text/javascript\" charset=\"utf-8\" "
071            + "src=\"" + "{@docRoot}/highlight.pack.js" + "\"></script>\n"
072            + "<script type=\"text/javascript\"><!--\n"
073            + "var cssId = 'highlightCss';\n"
074            + "if (!document.getElementById(cssId))\n"
075            + "{\n"
076            + "    var head  = document.getElementsByTagName('head')[0];\n"
077            + "    var link  = document.createElement('link');\n"
078            + "    link.id   = cssId;\n"
079            + "    link.rel  = 'stylesheet';\n"
080            + "    link.type = 'text/css';\n"
081            + "    link.charset = 'utf-8';\n"
082            + "    link.href = '{@docRoot}/highlight.css';\n"
083            + "    link.media = 'all';\n"
084            + "    head.appendChild(link);\n"
085            + "}"
086            + "hljs.initHighlightingOnLoad();\n"
087            + "//--></script>";
088    private static final Pattern LINE_START = Pattern.compile("^ ", Pattern.MULTILINE);
089
090    private final Map<String, TagRenderer<?>> tagRenderers = new HashMap<>();
091
092    private final Set<PackageDoc> packages = new HashSet<>();
093    private final Options options;
094    private final RootDoc rootDoc;
095    private MarkdownProcessor processor = null;
096
097    private boolean error = false;
098
099    /**
100     * Construct a new doclet.
101     *
102     * @param options The command line options.
103     * @param rootDoc The root document.
104     */
105    public MDoclet(Options options, RootDoc rootDoc) {
106        this.options = options;
107        this.rootDoc = rootDoc;
108        tagRenderers.put("@author", SimpleTagRenderer.INSTANCE);
109        tagRenderers.put("@version", SimpleTagRenderer.INSTANCE);
110        tagRenderers.put("@return", SimpleTagRenderer.INSTANCE);
111        tagRenderers.put("@deprecated", SimpleTagRenderer.INSTANCE);
112        tagRenderers.put("@since", SimpleTagRenderer.INSTANCE);
113        tagRenderers.put("@param", ParamTagRenderer.INSTANCE);
114        tagRenderers.put("@throws", ThrowsTagRenderer.INSTANCE);
115        tagRenderers.put("@see", SeeTagRenderer.INSTANCE);
116        for (String tag: options.getMarkedDownTags()) {
117                tagRenderers.put("@" + tag, SimpleTagRenderer.INSTANCE);
118        }
119    }
120
121    /**
122     * As specified by the Doclet specification.
123     *
124     * @return Java 1.5.
125     *
126     * @see com.sun.javadoc.Doclet#languageVersion()
127     */
128    public static LanguageVersion languageVersion() {
129        return LanguageVersion.JAVA_1_5;
130    }
131
132    /**
133     * As specified by the Doclet specification.
134     *
135     * @param option The option name.
136     *
137     * @return The length of the option.
138     *
139     * @see com.sun.javadoc.Doclet#optionLength(String)
140     */
141    public static int optionLength(String option) {
142        return Options.optionLength(option);
143    }
144
145    /**
146     * As specified by the Doclet specification.
147     *
148     * @param options       The command line options.
149     * @param errorReporter An error reporter to print errors.
150     *
151     * @return `true`, if the options are valid.
152     */
153    public static boolean validOptions(String[][] options, DocErrorReporter errorReporter) {
154        return Options.validOptions(options, errorReporter);
155    }
156
157    /**
158     * As specified by the Doclet specification.
159     *
160     * @param rootDoc The root doc.
161     *
162     * @return `true`, if process was successful.
163     *
164     * @see com.sun.javadoc.Doclet#start(RootDoc)
165     */
166    public static boolean start(RootDoc rootDoc) {
167        Options options = new Options();
168        String[][] forwardedOptions = options.load(rootDoc.options(), rootDoc);
169        if ( forwardedOptions == null ) {
170            return false;
171        }
172        MDoclet doclet = new MDoclet(options, rootDoc);
173        doclet.process();
174        if ( doclet.isError() ) {
175            return false;
176        }
177        RootDocWrapper rootDocWrapper = new RootDocWrapper(rootDoc, forwardedOptions);
178        if ( options.isHighlightEnabled() ) {
179            // find the footer option
180            int i = 0;
181            for ( ; i < rootDocWrapper.options().length; i++ ) {
182                if ( rootDocWrapper.options()[i][0].equals("-footer") ) {
183                    rootDocWrapper.options()[i][1] += HIGHLIGHT_JS_HTML;
184                    break;
185                }
186            }
187            if ( i >= rootDocWrapper.options().length ) {
188                rootDocWrapper.appendOption("-footer", HIGHLIGHT_JS_HTML);
189            }
190            if (Standard.optionLength("--allow-script-in-comments") == 1) {
191                if (rootDocWrapper.findOption("--allow-script-in-comments") == null) {
192                        rootDocWrapper.appendOption("--allow-script-in-comments");
193                }
194            }
195        }
196        return Standard.start(rootDocWrapper) && doclet.postProcess();
197    }
198
199    /**
200     * Removes all tag renderers.
201     */
202    public void clearTagRenderers() {
203        tagRenderers.clear();
204    }
205
206    /**
207     * Adds a tag renderer for the specified {@link com.sun.javadoc.Tag#kind() kind}.
208     *
209     * @param kind        The kind of the tag the renderer renders.
210     * @param renderer    The tag renderer.
211     */
212    public void addTagRenderer(String kind, TagRenderer<?> renderer) {
213        tagRenderers.put(kind, renderer);
214    }
215
216    /**
217     * Removes a tag renderer for the specified {@link com.sun.javadoc.Tag#kind() kind}.
218     *
219     * @param kind        The kind of the tag.
220     */
221    public void removeTagRenderer(String kind) {
222        tagRenderers.remove(kind);
223    }
224
225    /**
226     * Get the options.
227     *
228     * @return The options.
229     */
230    public Options getOptions() {
231        return options;
232    }
233
234    /**
235     * Get the root doc.
236     *
237     * @return The root doc.
238     */
239    public RootDoc getRootDoc() {
240        return rootDoc;
241    }
242
243    /**
244     * Process the documentation tree. If any errors occur during processing,
245     * {@link #isError()} will return `true` afterwards.
246     */
247    public void process() {
248        processor = options.getProcessor();
249        try {
250                processor.start(options.getProcessorOptions());
251        } catch (Throwable e) {
252                printError(e.getMessage());
253                return;
254        }
255        processOverview();
256        for ( ClassDoc doc : rootDoc.classes() ) {
257            packages.add(doc.containingPackage());
258            processClass(doc);
259        }
260        for ( PackageDoc doc : packages ) {
261            processPackage(doc);
262        }
263    }
264
265    /**
266     * Called after the standard Doclet *successfully* did its work.
267     *
268     * @return `true` if postprocessing succeeded.
269     */
270    public boolean postProcess() {
271        boolean success = true;
272        if ( options.isHighlightEnabled() ) {
273            success &= copyResource("highlight.pack.js", 
274                        "highlight.pack.js", "highlight.js");
275            success &= copyResource("highlight-LICENSE.txt", 
276                        "highlight-LICENSE.txt", "highlight.js license");
277            success &= copyResource("highlight-styles/" + options.getHighlightStyle() + ".css", 
278                        "highlight.css", "highlight.js style '" + options.getHighlightStyle() + "'");
279        }
280        return success;
281    }
282
283    private boolean copyResource(String resource, String destination, String description) {
284        try (
285                InputStream in = MDoclet.class.getResourceAsStream(resource);
286                OutputStream out = new FileOutputStream
287                                (new File(options.getDestinationDir(), destination))
288        )
289        {
290                Files.copy(in, options.getDestinationDir().toPath().resolve(destination),
291                                StandardCopyOption.REPLACE_EXISTING);
292            return true;
293        }
294        catch ( IOException e ) {
295            printError("Error writing " + description + ": " + e.getLocalizedMessage());
296            return false;
297        }
298    }
299
300    /**
301     * Check whether any errors occurred during processing of the documentation tree.
302     *
303     * @return `true` if there were errors processing the documentation tree.
304     */
305    public boolean isError() {
306        return error;
307    }
308
309    /**
310     * Process the overview file, if specified.
311     */
312    protected void processOverview() {
313        if ( options.getOverviewFile() != null ) {
314            try {
315                rootDoc.setRawCommentText(new String(Files.readAllBytes
316                                (options.getOverviewFile().toPath()), options.getEncoding()));
317                defaultProcess(rootDoc, false);
318            }
319            catch ( IOException e ) {
320                printError("Error loading overview from " + options.getOverviewFile() + ": " + e.getLocalizedMessage());
321                rootDoc.setRawCommentText("");
322            }
323        }
324    }
325
326    /**
327     * Process the class documentation.
328     *
329     * @param doc   The class documentation.
330     */
331    protected void processClass(ClassDoc doc) {
332        defaultProcess(doc, true);
333        for ( MemberDoc member : doc.fields() ) {
334            processMember(member);
335        }
336        for ( MemberDoc member : doc.constructors() ) {
337            processMember(member);
338        }
339        for ( MemberDoc member : doc.methods() ) {
340            processMember(member);
341        }
342        if ( doc instanceof AnnotationTypeDoc ) {
343            for ( MemberDoc member : ((AnnotationTypeDoc)doc).elements() ) {
344                processMember(member);
345            }
346        }
347    }
348
349    /**
350     * Process the member documentation.
351     *
352     * @param doc    The member documentation.
353     */
354    protected void processMember(MemberDoc doc) {
355        defaultProcess(doc, true);
356    }
357
358    /**
359     * Process the package documentation.
360     *
361     * @param doc    The package documentation.
362     */
363    protected void processPackage(PackageDoc doc) {
364        // (#1) Set foundDoc to false if possible.
365        // foundDoc will be set to true when setRawCommentText() is called, if the method
366        // is called again, JavaDoc will issue a warning about multiple sources for the
367        // package documentation. If there actually *are* multiple sources, the warning
368        // has already been issued at this point, we will, however, use it to set the
369        // resulting HTML. So, we're setting it back to false here, to suppress the
370        // warning.
371        try {
372            Field foundDoc = doc.getClass().getDeclaredField("foundDoc");
373            foundDoc.setAccessible(true);
374            foundDoc.set(doc, false);
375        }
376        catch ( Exception e ) {
377            printWarning(doc.position(), 
378                        "Cannot suppress warning about multiple package sources: " + e);
379        }
380        defaultProcess(doc, true);
381    }
382
383    /**
384     * Default processing of any documentation node.
385     *
386     * @param doc              The documentation.
387     * @param fixLeadingSpaces `true` if leading spaces should be fixed.
388     */
389    protected void defaultProcess(Doc doc, boolean fixLeadingSpaces) {
390        try {
391            StringBuilder buf = new StringBuilder();
392            buf.append(toHtml(doc.commentText(), fixLeadingSpaces));
393            buf.append('\n');
394            for ( Tag tag : doc.tags() ) {
395                processTag(tag, buf);
396                buf.append('\n');
397            }
398            doc.setRawCommentText(buf.toString());
399        }
400        catch ( Throwable e ) {
401            if ( doc instanceof RootDoc ) {
402                printError(new SourcePosition() {
403                    @Override
404                    public File file() {
405                        return options.getOverviewFile();
406                    }
407                    @Override
408                    public int line() {
409                        return 0;
410                    }
411                    @Override
412                    public int column() {
413                        return 0;
414                    }
415                }, e.getMessage());
416            }
417            else {
418                printError(doc.position(), e.getMessage());
419            }
420        }
421    }
422
423    /**
424     * Process a tag.
425     *
426     * @param tag      The tag.
427     * @param target   The target string builder.
428     */
429    @SuppressWarnings("unchecked")
430    protected void processTag(Tag tag, StringBuilder target) {
431        TagRenderer<Tag> renderer = (TagRenderer<Tag>)tagRenderers.get(tag.kind());
432        if ( renderer == null ) {
433            renderer = TagRenderer.VERBATIM;
434        }
435        renderer.render(tag, target, this);
436    }
437
438    /**
439     * Clears the processor.
440     */
441    public void clearProcessor() {
442        processor = null;
443    }
444    
445    /**
446     * Convert the given markup to HTML according to the {@link Options}.
447     *
448     * @param markup    The Markdown source.
449     *
450     * @return The resulting HTML.
451     */
452    public String toHtml(String markup) {
453        return toHtml(markup, true);
454    }
455
456    /**
457     * Converts Markdown source to HTML according to the options object. If
458     * `fixLeadingSpaces` is `true`, exactly one leading whitespace character ('\\u0020')
459     * will be removed, if it exists.
460     *
461     * @param markup           The Markdown source.
462     * @param fixLeadingSpaces `true` if leading spaces should be fixed.
463     *
464     * @return The resulting HTML.
465     */
466    public String toHtml(String markup, boolean fixLeadingSpaces) {
467        if ( fixLeadingSpaces ) {
468            markup = LINE_START.matcher(markup).replaceAll("");
469        }
470        List<String> tags = new ArrayList<>();
471        String html = processor.toHtml(Tags.extractInlineTags(markup, tags));
472        return Tags.insertInlineTags(html, tags);
473    }
474
475    /**
476     * Indicate that an error occurred. This method will also be called by
477     * {@link #printError(String)} and
478     * {@link #printError(com.sun.javadoc.SourcePosition, String)}.
479     */
480    public void error() {
481        error = true;
482    }
483
484    @Override
485    public void printError(String msg) {
486        error();
487        rootDoc.printError(msg);
488    }
489
490    @Override
491    public void printError(SourcePosition pos, String msg) {
492        error();
493        rootDoc.printError(pos, msg);
494    }
495
496    @Override
497    public void printWarning(String msg) {
498        rootDoc.printWarning(msg);
499    }
500
501    @Override
502    public void printWarning(SourcePosition pos,
503                             String msg)
504    {
505        rootDoc.printWarning(pos, msg);
506    }
507
508    @Override
509    public void printNotice(String msg) {
510        rootDoc.printNotice(msg);
511    }
512
513    @Override
514    public void printNotice(SourcePosition pos, String msg) {
515        rootDoc.printNotice(pos, msg);
516    }
517
518    /**
519     * Returns a prefix for relative URLs from a documentation element relative to the
520     * given package. This prefix can be used to refer to the root URL of the
521     * documentation:
522     *
523     * ```java
524     * doc = "<script type=\"text/javascript\" src=\""
525     *     + rootUrlPrefix(classDoc.containingPackage()) + "highlight.js"
526     *     + "\"></script>";
527     * ```
528     *
529     * @param doc    The package containing the element from where to reference the root.
530     *
531     * @return A URL prefix for URLs referring to the doc root.
532     */
533    public String rootUrlPrefix(PackageDoc doc) {
534        if ( doc == null || doc.name().isEmpty() ) {
535            return "";
536        }
537        else {
538            StringBuilder buf = new StringBuilder();
539            buf.append("../");
540            for ( int i = 0; i < doc.name().length(); i++ ) {
541                if ( doc.name().charAt(i) == '.' ) {
542                    buf.append("../");
543                }
544            }
545            return buf.toString();
546        }
547    }
548
549    /**
550     * Just a main method for debugging.
551     *
552     * @param args The command line arguments.
553     *
554     * @throws Exception If anything goes wrong.
555     */
556    public static void main(String[] args) throws Exception {
557        args = Arrays.copyOf(args, args.length + 2);
558        args[args.length - 2] = "-doclet";
559        args[args.length - 1] = MDoclet.class.getName();
560        Main.main(args);
561    }
562
563}