001package edu.pdx.cs.joy.grader;
002
003import com.google.common.annotations.VisibleForTesting;
004import com.sun.source.doctree.DocCommentTree;
005import com.sun.source.doctree.DocTree;
006import com.sun.source.doctree.ParamTree;
007import com.sun.source.doctree.ThrowsTree;
008import com.sun.source.util.DocTrees;
009import jdk.javadoc.doclet.Doclet;
010import jdk.javadoc.doclet.DocletEnvironment;
011import jdk.javadoc.doclet.Reporter;
012import org.jspecify.annotations.NonNull;
013
014import javax.lang.model.SourceVersion;
015import javax.lang.model.element.*;
016import javax.lang.model.type.TypeMirror;
017import javax.lang.model.util.ElementFilter;
018import javax.lang.model.util.Elements;
019import java.io.PrintWriter;
020import java.text.BreakIterator;
021import java.util.*;
022import java.util.stream.Collectors;
023import java.util.stream.Stream;
024
025/**
026 * This <A
027 * href="https://docs.oracle.com/javase/9/docs/api/jdk/javadoc/doclet/package-summary.html">doclet</A>
028 * extracts the API documentation (Javadocs)
029 * from a student's project submission and produces a text summary of
030 * them.  It is used for grading a student's Javadocs.
031 *
032 * @author David Whitlock
033 * @since Summer 2004 (rewritten for Java 9 API in Summer 2018)
034 */
035
036public class APIDocumentationDoclet implements Doclet {
037
038  private Reporter reporter;
039
040  @VisibleForTesting
041  static String removePackageNames(String name) {
042    return name.replaceAll("(\\w+\\.)*(\\w+)", "$2");
043  }
044
045  @VisibleForTesting
046  static String classHeader(ElementKind kind, String qualifiedName) {
047    return switch (kind) {
048      case ENUM -> "enum " + qualifiedName;
049      case RECORD -> "record " + qualifiedName;
050      default -> "class " + qualifiedName;
051    };
052  }
053
054  @Override
055  public void init(Locale locale, Reporter reporter) {
056    this.reporter = reporter;
057  }
058
059  @Override
060  public String getName() {
061    return "API Documentation Doclet";
062  }
063
064  @Override
065  public Set<? extends Option> getSupportedOptions() {
066    HashSet<Option> options = new HashSet<>();
067    options.add(new Option() {
068      @Override
069      public int getArgumentCount() {
070        return 0;
071      }
072
073      @Override
074      public String getDescription() {
075        return "Ignored option until HTML 5 is the default";
076      }
077
078      @Override
079      public Kind getKind() {
080        return Kind.OTHER;
081      }
082
083      @Override
084      public List<String> getNames() {
085        return List.of("-html5");
086      }
087
088      @Override
089      public String getParameters() {
090        return "";
091      }
092
093      @Override
094      public boolean process(String option, List<String> arguments) {
095        // Nothing to do
096        return true;
097      }
098    });
099    return options;
100  }
101
102  @Override
103  public SourceVersion getSupportedSourceVersion() {
104    return SourceVersion.RELEASE_9;
105  }
106
107  /**
108   * Indents a block of text a given amount.
109   */
110  @VisibleForTesting
111  static void indent(String text, final int indent,
112                             PrintWriter pw) {
113    StringBuilder sb = new StringBuilder();
114    for (int i = 0; i < indent; i++) {
115      sb.append(" ");
116    }
117    String spaces = sb.toString();
118
119    pw.print(spaces);
120
121    int currentLineLength = indent;
122
123    BreakIterator boundary = BreakIterator.getWordInstance();
124    boundary.setText(text);
125    int start = boundary.first();
126    for (int end = boundary.next(); end != BreakIterator.DONE;
127         start = end, end = boundary.next()) {
128
129      String word = text.substring(start, end);
130      if (word.length() > 1) {
131        word = word.trim();
132        if (start > 0 && text.charAt(start - 1) == '.') {
133          pw.print(" ");
134          currentLineLength++;
135        }
136      }
137
138      if (currentLineLength + word.length() > 72) {
139        pw.println("");
140        pw.print(spaces);
141        currentLineLength = indent;
142      }
143
144      if (word.length() > 0 && word.charAt(word.length() - 1) == '\n') {
145        pw.write(word, 0, word.length() - 1);
146        currentLineLength += word.length() - 1;
147
148      } else {
149        pw.print(word);
150        currentLineLength += word.length();
151      }
152    }
153
154    pw.println("");
155  }
156
157  @Override
158  public boolean run(DocletEnvironment environment) {
159    PrintWriter pw = new PrintWriter(System.out, true);
160
161    DocTrees docTrees = environment.getDocTrees();
162    for (TypeElement aClass : ElementFilter.typesIn(environment.getIncludedElements())) {
163      generateClassDocumentation(environment.getElementUtils(), docTrees, aClass, pw);
164    }
165    return true;
166  }
167
168  private void generateClassDocumentation(Elements elements, DocTrees docTrees, TypeElement aClass, PrintWriter pw) {
169    pw.println(classHeader(aClass.getKind(), aClass.getQualifiedName().toString()));
170
171    indent(getFullBodyComment(docTrees, aClass), 2, pw);
172    pw.println("");
173
174    Stream<? extends Element> constructors = getConstructors(elements, docTrees, aClass);
175    constructors.forEach(constructor -> {
176      generateMethodDocumentation(docTrees, (ExecutableElement) constructor, pw);
177    });
178
179    Stream<? extends Element> methods = getMethods(elements, docTrees, aClass);
180    methods.forEach(method -> {
181      generateMethodDocumentation(docTrees, (ExecutableElement) method, pw);
182    });
183
184  }
185
186  private @NonNull Stream<? extends Element> getMethods(Elements elements, DocTrees docTrees, TypeElement aClass) {
187    return getElementsOfKind(elements, docTrees, aClass, ElementKind.METHOD);
188  }
189
190  private @NonNull Stream<? extends Element> getElementsOfKind(Elements elements, DocTrees docTrees, TypeElement aClass, ElementKind kind) {
191    return aClass.getEnclosedElements().stream()
192      .filter(e -> e.getKind() == kind)
193      .filter(e -> shouldDocument(elements, docTrees, (ExecutableElement) e));
194  }
195
196  private @NonNull Stream<? extends Element> getConstructors(Elements elements, DocTrees docTrees, TypeElement aClass) {
197    return getElementsOfKind(elements, docTrees, aClass, ElementKind.CONSTRUCTOR);
198  }
199
200  private boolean shouldDocument(Elements elements, DocTrees docTrees, ExecutableElement executable) {
201    if (executable.getKind() == ElementKind.CONSTRUCTOR) {
202      return elements.getOrigin(executable) == Elements.Origin.EXPLICIT;
203    }
204
205    return docTrees.getPath(executable) != null;
206  }
207
208  private void generateMethodDocumentation(DocTrees docTrees, ExecutableElement method, PrintWriter pw) {
209    StringBuilder sb = new StringBuilder();
210    sb.append(joinObjectsToStrings(method.getModifiers(), " ")).append(" ");
211    appendTypeVariables(method, sb);
212    appendReturnType(method, sb);
213    sb.append(getMethodOrConstructorName(method));
214    sb.append("(");
215    sb.append(method.getParameters().stream().map(this::getParameterTypeAndName).collect(Collectors.joining(", ")));
216    sb.append(")");
217
218    indent(sb.toString(), 2, pw);
219
220    String comment = getFullBodyComment(docTrees, method);
221    if (comment != null && !comment.equals("")) {
222      indent(comment, 4, pw);
223    }
224    pw.println("");
225
226    DocCommentTree methodDocs = docTrees.getDocCommentTree(method);
227    if (methodDocs != null) {
228      List<? extends DocTree> blockTags = methodDocs.getBlockTags();
229      blockTags.stream().filter(d -> d.getKind() == DocTree.Kind.PARAM).forEach(parameter -> {
230        ParamTree paramTag = (ParamTree) parameter;
231        String paramDoc = paramTag.getName() + " - " + paramTag.getDescription();
232        indent(paramDoc, 4, pw);
233        pw.println("");
234      });
235
236      blockTags.stream().filter(d -> d.getKind() == DocTree.Kind.THROWS).forEach(exception -> {
237        ThrowsTree throwsTag = (ThrowsTree) exception;
238        String paramDoc = "throws " + throwsTag.getExceptionName() + " - " + throwsTag.getDescription();
239        indent(paramDoc, 4, pw);
240        pw.println("");
241      });
242    }
243
244  }
245
246  private String getParameterTypeAndName(VariableElement parameter) {
247    return removePackageNames(parameter.asType()) + " " + parameter.getSimpleName();
248  }
249
250  private String removePackageNames(TypeMirror type) {
251    return removePackageNames(type.toString());
252  }
253
254  private Name getMethodOrConstructorName(ExecutableElement method) {
255    if (method.getKind() == ElementKind.CONSTRUCTOR) {
256      return method.getEnclosingElement().getSimpleName();
257
258    } else {
259      return method.getSimpleName();
260    }
261  }
262
263  private void appendReturnType(Element element, StringBuilder sb) {
264    if (element instanceof ExecutableElement) {
265      ExecutableElement method = (ExecutableElement) element;
266      appendType(method.getReturnType(), sb).append(" ");
267    }
268  }
269
270  private StringBuilder appendType(TypeMirror type, StringBuilder sb) {
271    sb.append(removePackageNames(type));
272    return sb;
273  }
274
275  private void appendTypeVariables(Element constructor, StringBuilder sb) {
276    // Not sure how to do this yet
277  }
278
279  private String getFullBodyComment(DocTrees docTrees, Element element) {
280    DocCommentTree commentTree = docTrees.getDocCommentTree(element);
281    if (commentTree == null) {
282      return "";
283
284    } else {
285      return joinObjectsToStrings(commentTree.getFullBody(), "");
286    }
287  }
288
289  private String joinObjectsToStrings(Collection<?> objects, String delimiter) {
290    return objects.stream().map(Object::toString).collect(Collectors.joining(delimiter));
291  }
292}