diff --git a/src/share/classes/jdk/codetools/apidiff/Options.java b/src/share/classes/jdk/codetools/apidiff/Options.java index 424e03f..646945f 100644 --- a/src/share/classes/jdk/codetools/apidiff/Options.java +++ b/src/share/classes/jdk/codetools/apidiff/Options.java @@ -171,6 +171,7 @@ public String toString() { Boolean compareDocComments; Boolean compareApiDescriptions; Boolean compareApiDescriptionsAsText; + Boolean showUnchanged; String jdkDocs; // output options @@ -181,7 +182,6 @@ public String toString() { Path mainStylesheet; List extraStylesheets; List resourceFiles; - boolean showEqual; /** * The position of additional text to be included in the report. @@ -336,6 +336,16 @@ void process(String opt, String arg, Options options) throws BadOption { } }, + /** + * {@code --show-unchanged} boolean-value + */ + SHOW_UNCHANGED("--show-unchanged", "opt.arg.boolean") { + @Override + void process(String opt, String arg, Options options) throws BadOption { + options.showUnchanged = asBoolean(arg); + } + }, + /** * {@code --enable-preview}. * @@ -909,6 +919,15 @@ public boolean compareApiDescriptionsAsText() { return compareApiDescriptionsAsText; } + /** + * Returns whether unchanged API elements should be unconditionally shown. + * + * @return {@code true} if unchanged API elements should be unconditionally shown + */ + public boolean showUnchanged() { + return showUnchanged; + } + /** * Returns whether documentation comments should be compared. * @@ -981,10 +1000,6 @@ public List getResourceFiles() { return (resourceFiles == null) ? Collections.emptyList() : resourceFiles; } - public boolean showEqual() { - return showEqual; - } - /** * Returns the value of a "hidden" option, set by {@code -XD} or * {@code -XD=}. @@ -1082,6 +1097,10 @@ void validate() { compareDocComments = !compareApiDescriptions; } + if (showUnchanged == null) { + showUnchanged = false; + } + if (resourceFiles != null) { for (var resFile : resourceFiles) { if (resFile.isAbsolute() && !Files.exists(resFile)) { diff --git a/src/share/classes/jdk/codetools/apidiff/report/html/ModulePageReporter.java b/src/share/classes/jdk/codetools/apidiff/report/html/ModulePageReporter.java index 5fb0426..b00cf2d 100644 --- a/src/share/classes/jdk/codetools/apidiff/report/html/ModulePageReporter.java +++ b/src/share/classes/jdk/codetools/apidiff/report/html/ModulePageReporter.java @@ -30,6 +30,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Optional; import java.util.Set; import java.util.TreeMap; import java.util.TreeSet; @@ -37,6 +38,7 @@ import java.util.function.Function; import java.util.function.Predicate; import java.util.stream.Collectors; +import java.util.stream.Stream; import javax.lang.model.element.Element; import javax.lang.model.element.ModuleElement; import javax.lang.model.element.ModuleElement.Directive; @@ -163,7 +165,7 @@ protected List buildEnclosedElements() { private void addDirectives(List contents, String headingKey, Predicate> filter, - BiFunction, APIMap, Content> f) { + BiFunction, APIMap, ContentAndResultKind> f) { Map, APIMap> dMaps = new TreeMap<>(RelativePosition.elementKeyIndexComparator); // apiMaps will only contain maps for directives which should be compared and displayed; // i.e. they have already been filtered according to accessKind.allDirectiveDetails @@ -179,27 +181,44 @@ private void addDirectives(List contents, } } - if (!dMaps.isEmpty()) { + List converted = + dMaps.entrySet() + .stream() + .map(e -> f.apply(e.getKey(), e.getValue())) + .toList(); + + if (!converted.isEmpty()) { + boolean allUnchanged = converted.stream() + .allMatch(c -> c.resultKind() == ResultKind.SAME); HtmlTree section = HtmlTree.SECTION().setClass("enclosed"); section.add(HtmlTree.H2(Text.of(msgs.getString(headingKey)))); HtmlTree ul = HtmlTree.UL(); - dMaps.forEach((rp, apiMap) -> ul.add(HtmlTree.LI(f.apply(rp, apiMap)))); + for (ContentAndResultKind c : converted) { + HtmlTree item = HtmlTree.LI(c.content()); + if (!allUnchanged && c.resultKind() == ResultKind.SAME) { + item.setClass("unchanged"); + } + ul.add(item); + } section.add(ul); + if (allUnchanged) { + section = HtmlTree.DIV(section).setClass("unchanged"); + } contents.add(section); } } - private Content buildExports(RelativePosition rPos, APIMap apiMap) { + private ContentAndResultKind buildExports(RelativePosition rPos, APIMap apiMap) { return buildExportsOpensProvides(rPos, apiMap, Keywords.EXPORTS, Keywords.TO, ExportsDirective::getPackage, ExportsDirective::getTargetModules); } - private Content buildOpens(RelativePosition rPos, APIMap apiMap) { + private ContentAndResultKind buildOpens(RelativePosition rPos, APIMap apiMap) { return buildExportsOpensProvides(rPos, apiMap, Keywords.OPENS, Keywords.TO, OpensDirective::getPackage, OpensDirective::getTargetModules); } - private Content buildProvides(RelativePosition rPos, APIMap apiMap) { + private ContentAndResultKind buildProvides(RelativePosition rPos, APIMap apiMap) { // ProvidesDirective is unusual in that part of it (i.e. the implementations) // is not part of the public API, and should only be displayed if allDirectiveDetails // is true. @@ -208,13 +227,13 @@ private Content buildProvides(RelativePosition rPos, APIMap allDirectiveDetails ? pd.getImplementations() : Collections.emptyList()); } - private Content buildUses(RelativePosition rPos, APIMap apiMap) { + private ContentAndResultKind buildUses(RelativePosition rPos, APIMap apiMap) { return buildExportsOpensProvides(rPos, apiMap, Keywords.USES, Content.empty, UsesDirective::getService, d -> Collections.emptyList()); } private - Content buildExportsOpensProvides(RelativePosition rPos, APIMap apiMap, + ContentAndResultKind buildExportsOpensProvides(RelativePosition rPos, APIMap apiMap, Content directiveKeyword, Content sublistKeyword, Function getPrimaryElement, Function> getSecondaryElements) { @@ -261,13 +280,14 @@ Content buildExportsOpensProvides(RelativePosition rPos, APIMap apiMap, } } - // TODO: for now, this is stylistically similar to buildEnclosedElement, // but arguably a better way would be to move code for the check or cross into // the enclosing loop that builds the list. - return HtmlTree.SPAN(getResultGlyph(rPos), Text.SPACE) - .add(HtmlTree.SPAN(contents).setClass("signature")); + ResultKind result = getResultKind(rPos); + + return new ContentAndResultKind(HtmlTree.SPAN(result.getContent(), Text.SPACE) + .add(HtmlTree.SPAN(contents).setClass("signature")), result); } private Content getName(ElementKey ek) { @@ -288,7 +308,7 @@ private CharSequence getQualifiedName(ElementKey ek) { } } - private Content buildRequires(RelativePosition rPos, APIMap apiMap) { + private ContentAndResultKind buildRequires(RelativePosition rPos, APIMap apiMap) { List contents = new ArrayList<>(); contents.add(Keywords.REQUIRES); @@ -337,11 +357,12 @@ private Content buildRequires(RelativePosition rPos, APIMap infoBox = new ArrayList<>(); + infoBox.add(new RawHtml(infoText)); + if (kind == InfoTextKind.HEADER && !parent.options.showUnchanged()) { + String showUnchangedCheckbox = + """ + + + Show unchanged + + """; + infoBox.add(new RawHtml(showUnchangedCheckbox)); + } + contents.add(HtmlTree.DIV(infoBox.toArray(Content[]::new)).setClass("info")); Text index = Text.of(parent.indexPageReporter.getName()); HtmlTree ul = HtmlTree.UL(); ul.add(HtmlTree.LI((pageKey == null) ? index : HtmlTree.A(links.getPath("index.html").getPath(), index))); @@ -536,7 +567,7 @@ protected Content buildPageHeading() { */ protected Content buildPageElement() { Position pagePos = Position.of(pageKey); - List prelude = List.of(getResultGlyph(pagePos), buildMissingInfo(pagePos), buildNotes(pageKey)); + List prelude = List.of(PageReporter.this.getResultKind(pagePos).getContent(), buildMissingInfo(pagePos), buildNotes(pageKey)); Content signature = buildSignature(); return HtmlTree.DIV().setClass("element").add(prelude).add(signature); } @@ -750,15 +781,27 @@ protected void addEnclosedElements(List list, String titleKey, Predicat .filter(filter) .collect(Collectors.toCollection(TreeSet::new)); - if (!enclosed.isEmpty()) { + List converted = + enclosed.stream() + .map(eKey -> buildEnclosedElement(eKey)) + .toList(); + + if (!converted.isEmpty()) { + boolean allUnchanged = converted.stream().allMatch(c -> c.resultKind() == ResultKind.SAME); HtmlTree section = HtmlTree.SECTION().setClass("enclosed"); section.add(HtmlTree.H2(Text.of(msgs.getString(titleKey)))); HtmlTree ul = HtmlTree.UL(); - for (ElementKey eKey : enclosed) { - HtmlTree li = HtmlTree.LI(buildEnclosedElement(eKey)); + for (ContentAndResultKind c : converted) { + HtmlTree li = HtmlTree.LI(c.content()); + if (!allUnchanged && c.resultKind() == ResultKind.SAME) { + li.setClass("unchanged"); + } ul.add(li); } section.add(ul); + if (allUnchanged) { + section = HtmlTree.DIV(section).setClass("unchanged"); + } list.add(section); } } @@ -772,12 +815,13 @@ protected void addEnclosedElements(List list, String titleKey, Predicat * * @return the content */ - protected Content buildEnclosedElement(ElementKey eKey) { + protected ContentAndResultKind buildEnclosedElement(ElementKey eKey) { // The enclosed element may be on a different page, so use the appropriate page reporter PageReporter r = parent.getPageReporter(eKey); - return HtmlTree.SPAN(r.getResultGlyph(eKey), + ResultKind result = r.getResultKind(eKey); + return new ContentAndResultKind(HtmlTree.SPAN(result.getContent(), Text.SPACE, - links.createLink(eKey)); + links.createLink(eKey)), result); } protected void addDocFiles(List list) { @@ -792,7 +836,7 @@ protected void addDocFiles(List list) { HtmlTree ul = HtmlTree.UL(); for (RelativePosition p : docFiles) { DocFilesBuilder b = new DocFilesBuilder(p); - HtmlTree li = HtmlTree.LI(getResultGlyph(p), buildMissingInfo(p)); + HtmlTree li = HtmlTree.LI(PageReporter.this.getResultKind(p).getContent(), buildMissingInfo(p)); String name = p.index; if (name.endsWith(".html")) { b.buildFile(); @@ -834,6 +878,7 @@ private String getChecksum(byte[] bytes) { */ protected Content buildMissingInfo(Position pos) { if (missing.containsKey(pos)) { + ResultKind result = PageReporter.this.getResultKind(pos); // TODO: use an L10N-friendly builder, or use an API list builder, building Content? String onlyIn = apiMaps.get(pos).keySet().stream() .map(a -> a.name) @@ -846,7 +891,7 @@ protected Content buildMissingInfo(Position pos) { .map(a -> a.name) .collect(Collectors.joining(", ")); String info = msgs.getString("element.onlyInMissingIn", onlyIn, missingIn); - return HtmlTree.SPAN(Text.of(info)).setClass("missing"); + return HtmlTree.SPAN(Text.of(info)).setClass(result.getMissingCaptionClass() != null ? "missing " + result.getMissingCaptionClass() : "missing"); } else { return Content.empty; } @@ -863,50 +908,19 @@ protected Content buildResultTable() { return section; } - // The following names are intended to be "semantic" or "abstract" names, - // distinct from the concrete representations used in the generated documentation. - // The names are intentionally different from any corresponding entity names. - - /** - * Used when two elements compare as equal. - */ - // possible alternatives: Entity.CHECK - private static final Content SAME = HtmlTree.SPAN(Entity.EQUALS).setClass("same"); - /** - * Used when two elements compare as not equal. - */ - // possible alternatives: Entity.CROSS - private static final Content DIFFERENT = HtmlTree.SPAN(Entity.NE).setClass("diff"); - /** - * Used when an element does not appear in all instances of the APIs being compared. - * See also {@link #ADDED}, {@link #REMOVED}. - */ - private static final Content PARTIAL = HtmlTree.SPAN(Entity.CIRCLED_DIGIT_ONE).setClass("partial"); - /** - * Used in a 2-way comparison when it is determined that an element has been added. - */ - // possible alternatives: '>' (for example, as used in text diff tools) or other right-pointing arrows - private static final Content ADDED = HtmlTree.SPAN(Entity.PLUS).setClass("partial"); - /** - * Used in a 2-way comparison when it is determined that an element has been removed. - */ - // possible alternatives: '<' (for example, as used in text diff tools) or other left-pointing arrows - private static final Content REMOVED = HtmlTree.SPAN(Entity.MINUS).setClass("partial"); - - - protected Content getResultGlyph(ElementKey eKey) { + protected ResultKind getResultKind(ElementKey eKey) { Position pos = Position.of(eKey); - return getResultGlyph(pos, apiMaps.get(pos)); + return getResultKind(pos, apiMaps.get(pos)); } - protected Content getResultGlyph(Position pos) { - return getResultGlyph(pos, apiMaps.get(pos)); + protected ResultKind getResultKind(Position pos) { + return getResultKind(pos, apiMaps.get(pos)); } - protected Content getResultGlyph(Position pos, APIMap map) { + protected ResultKind getResultKind(Position pos, APIMap map) { if (map == null) { // TODO... - return Text.of("?"); + return ResultKind.UNKNOWN; } if (map.size() == 1) { API api = map.keySet().iterator().next(); @@ -918,27 +932,27 @@ protected Content getResultGlyph(Position pos, APIMap map) { API oldAPI = iter.next(); API newAPI = iter.next(); if (api == oldAPI) { // and not in new API - return REMOVED; + return ResultKind.REMOVED; } else if (api == newAPI) { // and not in old API - return ADDED; + return ResultKind.ADDED; } else { // should not happen? - return PARTIAL; + return ResultKind.PARTIAL; } } - return PARTIAL; + return ResultKind.PARTIAL; } Boolean eq = results.get(pos); - return (eq == null) ? PARTIAL : eq ? SAME : DIFFERENT; + return (eq == null) ? ResultKind.PARTIAL : eq ? ResultKind.SAME : ResultKind.DIFFERENT; } // TODO: improve abstraction; these args are typically reversed - protected Content getResultGlyph(APIMap map, Position pos) { + protected ResultKind getResultKind(APIMap map, Position pos) { if (map.size() == 1) { - return PARTIAL; + return ResultKind.PARTIAL; } Boolean eq = results.get(pos); - return (eq == null) ? PARTIAL : eq ? SAME : DIFFERENT; + return (eq == null) ? ResultKind.PARTIAL : eq ? ResultKind.SAME : ResultKind.DIFFERENT; } protected APIMap getElementMap(ElementKey eKey) { @@ -1498,7 +1512,7 @@ private Content buildBody() { body.add(buildHeader()); HtmlTree main = HtmlTree.MAIN(); main.add(buildPageHeading()); - main.add(HtmlTree.SPAN(getResultGlyph(fPos), buildMissingInfo(fPos)).setClass("doc-files")); + main.add(HtmlTree.SPAN(getResultKind(fPos).getContent(), buildMissingInfo(fPos)).setClass("doc-files")); main.add(buildDocComments(fPos)); main.add(buildAPIDescriptions(fPos)); // main.add(buildEnclosedElements()); @@ -1594,4 +1608,58 @@ private Content build(String name, Map map) { } } + public enum ResultKind { + UNKNOWN(Text.of("?"), null), + // The following names are intended to be "semantic" or "abstract" names, + // distinct from the concrete representations used in the generated documentation. + // The names are intentionally different from any corresponding entity names. + /** + * Used when two elements compare as equal. + */ + // possible alternatives: Entity.CHECK + SAME(HtmlTree.SPAN(Entity.EQUALS).setClass("same"), null), + + /** + * Used when two elements compare as not equal. + */ + // possible alternatives: Entity.CROSS + DIFFERENT(HtmlTree.SPAN(Entity.NE).setClass("diff"), null), + + /** + * Used when an element does not appear in all instances of the APIs being compared. + * See also {@link #ADDED}, {@link #REMOVED}. + */ + PARTIAL(HtmlTree.SPAN(Entity.CIRCLED_DIGIT_ONE).setClass("partial"), null), + + /** + * Used in a 2-way comparison when it is determined that an element has been added. + */ + // possible alternatives: '>' (for example, as used in text diff tools) or other right-pointing arrows + ADDED(HtmlTree.SPAN(Entity.PLUS).setClass("add"), "missing-caption-add"), + + /** + * Used in a 2-way comparison when it is determined that an element has been removed. + */ + // possible alternatives: '<' (for example, as used in text diff tools) or other left-pointing arrows + REMOVED(HtmlTree.SPAN(Entity.MINUS).setClass("remove"), "missing-caption-remove"), + ; + + private final Content content; + private final String missingCaptionClass; + + private ResultKind(Content content, String missingCaptionClass) { + this.content = content; + this.missingCaptionClass = missingCaptionClass; + } + + public Content getContent() { + return content; + } + + public String getMissingCaptionClass() { + return missingCaptionClass; + } + } + + protected record ContentAndResultKind(Content content, ResultKind resultKind) {} } diff --git a/src/share/classes/jdk/codetools/apidiff/report/html/TypePageReporter.java b/src/share/classes/jdk/codetools/apidiff/report/html/TypePageReporter.java index c51bf2b..f470d8e 100644 --- a/src/share/classes/jdk/codetools/apidiff/report/html/TypePageReporter.java +++ b/src/share/classes/jdk/codetools/apidiff/report/html/TypePageReporter.java @@ -32,6 +32,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Optional; import java.util.Set; import java.util.TreeMap; import java.util.TreeSet; @@ -378,7 +379,7 @@ protected List buildEnclosedElements() { * @return the content */ @Override - protected Content buildEnclosedElement(ElementKey eKey) { + protected ContentAndResultKind buildEnclosedElement(ElementKey eKey) { return switch (eKey.kind) { case TYPE -> super.buildEnclosedElement(eKey); case EXECUTABLE -> new ExecutableBuilder((ExecutableElementKey) eKey).build(); @@ -552,8 +553,10 @@ private class ExecutableBuilder { this.ePos = ePos; } - Content build() { - Content eq = getResultGlyph(ePos); + ContentAndResultKind build() { + ResultKind result = getResultKind(ePos); + + Content eq = result.getContent(); // TODO: could move to final field APIMap eMap = getElementMap(ePos); @@ -613,10 +616,10 @@ Content build() { Content docComments = buildDocComments(ePos); Content apiDescriptions = buildAPIDescriptions(ePos); - return HtmlTree.DIV(eq, buildMissingInfo(ePos), buildNotes(ePos), + return new ContentAndResultKind(HtmlTree.DIV(eq, buildMissingInfo(ePos), buildNotes(ePos), signature, docComments, apiDescriptions) .setClass("element") - .set(HtmlAttr.ID, links.getId(ePos)); + .set(HtmlAttr.ID, links.getId(ePos)), result); } private Content buildParameters(Position ePos) { @@ -752,8 +755,9 @@ private class VariableBuilder { vPos = Position.of(vKey); } - private Content build() { - Content eq = getResultGlyph(vPos); + private ContentAndResultKind build() { + ResultKind result = getResultKind(vPos); + Content eq = result.getContent(); APIMap vMap = getElementMap(vKey); // by design, they should all have the same ElementKind, @@ -787,10 +791,10 @@ private Content build() { Content docComments = buildDocComments(vPos); Content apiDescriptions = buildAPIDescriptions(vPos); - return HtmlTree.DIV(eq, buildMissingInfo(vPos), buildNotes(vKey), + return new ContentAndResultKind(HtmlTree.DIV(eq, buildMissingInfo(vPos), buildNotes(vKey), signature, docComments, apiDescriptions) .setClass("element") - .set(HtmlAttr.ID, links.getId(vKey)); + .set(HtmlAttr.ID, links.getId(vKey)), result); } private Content buildValue(APIMap vMap) { @@ -986,7 +990,7 @@ Content build() { } HtmlTree section = HtmlTree.SECTION(HtmlTree.H2(Text.of(msgs.getString("serial.serialized-form")))).setClass("serial-form"); - section.add(getResultGlyph(sfPos)).add(buildMissingInfo(sfPos)); + section.add(getResultKind(sfPos).getContent()).add(buildMissingInfo(sfPos)); addSerialVersionUID(section); addSerializedFields(section); addSerializationMethods(section); @@ -1000,7 +1004,7 @@ private void addSerialVersionUID(HtmlTree section) { // TODO: weave in the text from serialized-form.html section.add(HtmlTree.H3(Text.of("serialVersionUID"))); - section.add(getResultGlyph(uPos)); + section.add(getResultKind(uPos).getContent()); if (differentValues.containsKey(uPos)) { APIMap alternatives = APIMap.of(); values.forEach((api, v) -> alternatives.put(api, Text.of(String.valueOf(v)))); @@ -1057,7 +1061,7 @@ private void addSerializedFields(HtmlTree tree) { private Content buildSerializedField(RelativePosition fPos) { @SuppressWarnings("unchecked") APIMap fMap = (APIMap) apiMaps.get(fPos); - Content glyph = getResultGlyph(fPos); + Content glyph = getResultKind(fPos).getContent(); Content type; APIMap types; @@ -1110,7 +1114,11 @@ private void addSerializationMethods(HtmlTree tree) { section.add(HtmlTree.H3(Text.of(msgs.getString("serial.serialization-methods")))); HtmlTree ul = HtmlTree.UL(); for (Position pos : methods) { - HtmlTree li = HtmlTree.LI(buildSerializedMethod(pos)); + ContentAndResultKind content = buildSerializedMethod(pos); + HtmlTree li = HtmlTree.LI(content.content()); + if (content.resultKind() == ResultKind.SAME) { + li.setClass("unchanged"); + } ul.add(li); } section.add(ul); @@ -1118,7 +1126,7 @@ private void addSerializationMethods(HtmlTree tree) { } } - private Content buildSerializedMethod(Position mPos) { + private ContentAndResultKind buildSerializedMethod(Position mPos) { return new ExecutableBuilder(mPos).build(); } diff --git a/src/share/classes/jdk/codetools/apidiff/report/html/resources/apidiff.css b/src/share/classes/jdk/codetools/apidiff/report/html/resources/apidiff.css index 847622b..8059ed7 100644 --- a/src/share/classes/jdk/codetools/apidiff/report/html/resources/apidiff.css +++ b/src/share/classes/jdk/codetools/apidiff/report/html/resources/apidiff.css @@ -179,7 +179,9 @@ div.signature { .doc-files span.diff, .element span.diff, .enclosed span.diff, .serial-form span.diff, .doc-files span.same, .element span.same, .enclosed span.same, .serial-form span.same, -.doc-files span.partial, .element span.partial, .enclosed span.partial, .serial-form span.partial { +.doc-files span.partial, .element span.partial, .enclosed span.partial, .serial-form span.partial, +.doc-files span.add, .element span.add, .enclosed span.add, .serial-form span.add, +.doc-files span.remove, .element span.remove, .enclosed span.remove, .serial-form span.remove { display: inline-block; width: 2em; margin-right: 0.5ex; @@ -188,18 +190,40 @@ div.signature { } .doc-files span.diff, .element span.diff, .enclosed span.diff, .serial-form span.diff { - background: #fdd; - color: #800; + background: #e0e0e0; + color: #000; } .doc-files span.same, .element span.same, .enclosed span.same, .serial-form span.same { - background: #dfd; - color: #080; + background: #ffffff; + color: #000; } .doc-files span.partial, .element span.partial, .enclosed span.partial, .serial-form span.partial { background: #ddf; - color: #008; + color: #000; +} + +.doc-files span.add, .element span.add, .enclosed span.add, .serial-form span.add { + background: #cfc; + color: #000; +} + +.doc-files span.remove, .element span.remove, .enclosed span.remove, .serial-form span.remove { + background: #fcc; + color: #000; +} + +.doc-files span.missing-caption-add, .element span.missing-caption-add, +.enclosed span.missing-caption-add, .serial-form span.missing-caption-add { + background: #cfc; + color: #000; +} + +.doc-files span.missing-caption-remove, .element span.missing-caption-remove, +.enclosed span.missing-caption-remove, .serial-form span.missing-caption-remove { + background: #fcc; + color: #000; } .doc-files span.missing, .element span.missing, .enclosed span.missing, .serial-form span.missing, @@ -473,4 +497,9 @@ table.summary tfoot th { border-top: 1px solid lightgrey; } + .unchanged { + } + .hidden { + display: none; + } diff --git a/src/share/classes/jdk/codetools/apidiff/report/html/resources/report.properties b/src/share/classes/jdk/codetools/apidiff/report/html/resources/report.properties index cf2aba0..8f1369a 100644 --- a/src/share/classes/jdk/codetools/apidiff/report/html/resources/report.properties +++ b/src/share/classes/jdk/codetools/apidiff/report/html/resources/report.properties @@ -123,7 +123,7 @@ htmldiffs.not-in-only-in=\ Not in {0}; only in {1} htmldiffs.only-in-not-in=\ - Only in {0}; not i {1} + Only in {0}; not in {1} notes.heading=\ Notes diff --git a/src/share/classes/jdk/codetools/apidiff/resources/help.properties b/src/share/classes/jdk/codetools/apidiff/resources/help.properties index 98de44c..f87e19f 100644 --- a/src/share/classes/jdk/codetools/apidiff/resources/help.properties +++ b/src/share/classes/jdk/codetools/apidiff/resources/help.properties @@ -127,6 +127,9 @@ opt.desc.release=\ opt.desc.resource-files=\ Specifies resource files to be copied from an API directory +opt.desc.show-unchanged=\ + Unchanged elements should be unconditionally shown in the resulting diff + opt.desc.source=\ Specifies the version of the platform for an API. diff --git a/src/share/doc/apidiff.md b/src/share/doc/apidiff.md index 34f0f5b..aeef5fc 100644 --- a/src/share/doc/apidiff.md +++ b/src/share/doc/apidiff.md @@ -287,6 +287,11 @@ to repeat these options for each API to be compared.

This option may be useful when comparing HTML documentation that depend on some non-HTML resource files.

+`--show-unchanged` *boolean* +: If true, unchanged elements will be show unconditionally. When false or + missing, the user viewing the diff will have an option to show or hide the + unchanged elements. + ### Other Options `--help`, `-help`, `-h`, `-?` diff --git a/test/junit/apitest/UnchangedTest.java b/test/junit/apitest/UnchangedTest.java new file mode 100644 index 0000000..3d8619f --- /dev/null +++ b/test/junit/apitest/UnchangedTest.java @@ -0,0 +1,511 @@ +/* + * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package apitest; + +import apitest.lib.APITester; +import com.sun.source.doctree.DocCommentTree; +import com.sun.source.doctree.DocTree; +import com.sun.source.doctree.EndElementTree; +import com.sun.source.doctree.StartElementTree; +import com.sun.source.doctree.TextTree; +import com.sun.source.util.DocTreeScanner; +import com.sun.source.util.DocTrees; +import com.sun.source.util.JavacTask; +import java.io.InputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.io.Reader; +import java.io.Writer; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.Stack; +import javax.tools.FileObject; +import javax.tools.ToolProvider; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import toolbox.ModuleBuilder; + +/** + * Tests the output for unchanged elements. + */ +public class UnchangedTest extends APITester { + + @Test + public void testAllUnchanged() throws IOException { + Path base = getScratchDir(); + log.println(base); + + List options = new ArrayList<>(); + + Path originalDir = base.resolve("original").resolve("src"); + Path updatedDir = base.resolve("updated").resolve("src"); + + for (Path target : new Path[] {originalDir, updatedDir}) { + ModuleBuilder m = + new ModuleBuilder(tb, "m") + .exports("p").exports("p2") + .opens("p").opens("p2") + .provides("java.lang.Runnable", "p.C1", "p.C2") + .provides("java.lang.FunctionalInterface", "p2.C") + .uses("java.lang.Runnable") + .uses("java.lang.FunctionalInterface") + .requiresTransitive("java.compiler") + .requiresTransitive("jdk.compiler"); + + m.classes(""" + package p; + /** class documentation */ + public class C1 implements Runnable { + /** test field documentation1 */ + public final int F1; + /** test field documentation2 */ + public final int F2; + /** test constructor documentation1 */ + public C1() {} + /** test constructor documentation2 */ + public C1(int i) {} + /** test method documentation */ + public void test() { } + /** run method documentation */ + public void run() { } + } + """); + + m.classes(""" + package p; + /** class documentation */ + public class C2 implements Runnable { + /** test method documentation */ + public void test() { } + /** run method documentation */ + public void run() { } + } + """); + + m.classes(""" + package p2; + /** class documentation */ + public class C implements FunctionalInterface { + /** test method documentation */ + public void test() { } + /** run method documentation */ + public void run() { } + } + """); + + m.write(target); + + new ModuleBuilder(tb, "m2").write(target); + + options.addAll(List.of( + "--api", target.getParent().getFileName().toString(), + "--module-source-path", target.toString())); + } + + options.addAll(List.of( + "--include", "m/**", + "--include", "m2/**", + "-d", base.resolve("out").toString(), + "--verbose", "missing")); + + log.println("Options: " + options); + + run(options); + + { + List unchangedTexts = + gatherUnchangedTexts(base, "out/index.html"); + List expectedUnchangedTextsModule = + List.of(""" + Modules + m m2 + """); + Assertions.assertEquals(expectedUnchangedTextsModule, unchangedTexts); + } + + { + List unchangedTexts = + gatherUnchangedTexts(base, "out/m/module-summary.html"); + List expectedUnchangedTextsModule = + List.of(""" + Exports + exports p exports p2 + """, + """ + Opens + opens p opens p2 + """, + """ + Requires + requires transitive java.compiler requires transitive jdk.compiler + """, + """ + Packages + p p2 + """); + Assertions.assertEquals(expectedUnchangedTextsModule, unchangedTexts); + } + + { + List unchangedTexts = + gatherUnchangedTexts(base, "out/m/p/package-summary.html"); + List expectedUnchangedTextsModule = + List.of(""" + Types + C1 C2 + """); + Assertions.assertEquals(expectedUnchangedTextsModule, unchangedTexts); + } + + { + List unchangedTexts = + gatherUnchangedTexts(base, "out/m/p/C1.html"); + List expectedUnchangedTextsModule = + List.of(""" + Fields + public final int F1 + public final int F2 + """, + """ + Constructors + public C1() + public C1(int i) + """, + """ + Methods + public void run() + public void test() + """); + Assertions.assertEquals(expectedUnchangedTextsModule, unchangedTexts); + } + } + + @Test + public void testSomeUnchanged() throws IOException { + //one method in C1 with a changed javadoc, and module info changed: + Path base = getScratchDir(); + log.println(base); + + List options = new ArrayList<>(); + + Path originalDir = base.resolve("original").resolve("src"); + Path updatedDir = base.resolve("updated").resolve("src"); + + for (Path target : new Path[] {originalDir, updatedDir}) { + ModuleBuilder m; + if (target == originalDir) { + m = + new ModuleBuilder(tb, "m") + .exports("p").exports("p2").exports("p3") + .opens("p").opens("p2").opens("p3") + .provides("java.lang.Runnable", "p.C1", "p.C2") + .provides("java.lang.FunctionalInterface", "p2.C") + .uses("java.lang.Runnable") + .uses("java.lang.FunctionalInterface") + .requiresTransitive("java.compiler") + .requiresTransitive("jdk.compiler"); + } else { + m = + new ModuleBuilder(tb, "m") + .exports("p").exports("p3").exports("p4") + .opens("p").opens("p3").opens("p4") + .provides("java.lang.Runnable", "p.C1", "p4.C") + .provides("java.lang.FunctionalInterface", "p2.C") + .provides("java.io.Serializable", "p4.C") + .uses("java.io.Serializable") + .uses("java.lang.FunctionalInterface") + .requiresTransitive("java.compiler") + .requiresTransitive("java.desktop"); + } + + if (target == originalDir) { + m.classes(""" + package p; + /** class documentation */ + public class C1 implements Runnable { + /** test field documentation1 */ + public final int F1; + /** test field documentation2 */ + public final int F2; + /** test constructor documentation1 */ + public C1() {} + /** test constructor documentation2 */ + public C1(int i) {} + /** test method documentation */ + public void test() { } + /** run method documentation */ + public void run() { } + } + """); + } else { + m.classes(""" + package p; + /** class documentation */ + public class C1 implements Runnable { + /** test field documentation1 - updated */ + public final int F1; + /** test field documentation2 */ + public final int F2; + /** test constructor documentation1 - updated */ + public C1() {} + /** test constructor documentation2 */ + public C1(int i) {} + /** test method documentation - updated */ + public void test() { } + /** run method documentation */ + public void run() { } + } + """); + } + + m.classes(""" + package p; + /** class documentation */ + public class C2 implements Runnable { + /** test method documentation */ + public void test() { } + /** run method documentation */ + public void run() { } + } + """); + + m.classes(""" + package p2; + /** class documentation */ + public class C implements FunctionalInterface { + /** test method documentation */ + public void test() { } + /** run method documentation */ + public void run() { } + } + """); + + m.classes(""" + package p3; + /** class documentation */ + public class C { + } + """); + + if (target == updatedDir) { + m.classes(""" + package p4; + /** class documentation */ + public class C implements java.io.Serializable, Runnable { + /** test method documentation */ + public void test() { } + /** run method documentation */ + public void run() { } + } + """); + } + + m.write(target); + + new ModuleBuilder(tb, "m2").write(target); + + options.addAll(List.of( + "--api", target.getParent().getFileName().toString(), + "--module-source-path", target.toString())); + } + + options.addAll(List.of( + "--include", "m/**", + "--include", "m2/**", + "-d", base.resolve("out").toString(), + "--verbose", "missing")); + + log.println("Options: " + options); + + run(options); + + { + List unchangedTexts = + gatherUnchangedTexts(base, "out/index.html"); + List expectedUnchangedTextsModule = + List.of(" m2"); + Assertions.assertEquals(expectedUnchangedTextsModule, unchangedTexts); + } + + { + List unchangedTexts = + gatherUnchangedTexts(base, "out/m/module-summary.html"); + List expectedUnchangedTextsModule = + List.of(" exports p", " exports p3", + " opens p", " opens p3", + " requires transitive java.compiler", + " p3"); + Assertions.assertEquals(expectedUnchangedTextsModule, unchangedTexts); + } + + { + List unchangedTexts = + gatherUnchangedTexts(base, "out/m/p/package-summary.html"); + List expectedUnchangedTextsModule = + List.of(" C2"); + Assertions.assertEquals(expectedUnchangedTextsModule, unchangedTexts); + } + + { + List unchangedTexts = + gatherUnchangedTexts(base, "out/m/p/C1.html"); + List expectedUnchangedTextsModule = + List.of("public final int F2\n", + "public C1(int i)\n", + "public void run()\n"); + Assertions.assertEquals(expectedUnchangedTextsModule, unchangedTexts); + } + } + + private List gatherUnchangedTexts(Path base, String path) throws IOException { + DocCommentTree html = parseHTML(base, path); + List unchangedTexts = new ArrayList<>(); + + new DocTreeScanner<>() { + //HTML elements which may hold the unchanged class: + private static final Set ELEMENTS_WITH_UNCHANGED_CLASS = + Set.of("span", "div", "li"); + private final Stack nestedElements = new Stack<>(); + private boolean unchanged; + private final StringBuilder unchangedText = new StringBuilder(); + + @Override + public Object visitStartElement(StartElementTree node, Object p) { + String name = node.getName().toString(); + + if (ELEMENTS_WITH_UNCHANGED_CLASS.contains(name)) { + nestedElements.push(new ElementDesc(name, unchanged)); + + for (DocTree t : node.getAttributes()) { + String treeText = t.toString(); + if (treeText.contains("class=") && treeText.contains("unchanged")) { + unchanged = true; + } + } + } + return null; + } + + @Override + public Object visitEndElement(EndElementTree node, Object p) { + String name = node.getName().toString(); + + if (ELEMENTS_WITH_UNCHANGED_CLASS.contains(name)) { + ElementDesc removed = nestedElements.pop(); + + if (!removed.name().equals(name)) { + throw new IllegalStateException("Unexpected name!"); + } + + boolean wasUnchanged = unchanged; + + unchanged = removed.previousUnchanged(); + + if (wasUnchanged && !unchanged) { + String text = unchangedText.toString(); + + unchangedTexts.add(text.replaceAll("\n+", "\n")); + unchangedText.delete(0, unchangedText.length()); + } + } + return null; + } + + @Override + public Object visitText(TextTree node, Object p) { + if (unchanged) { + unchangedText.append(node.getBody()); + } + return null; + } + + record ElementDesc(String name, boolean previousUnchanged) {} + }.scan(html, null); + + return unchangedTexts; + } + + private DocCommentTree parseHTML(Path base, String path) throws IOException { + String[] pathElements = path.split("/"); + Path fileToTest = base; + for (String el : pathElements) { + fileToTest = fileToTest.resolve(el); + } + String content = Files.readString(fileToTest); + JavacTask task = (JavacTask) ToolProvider.getSystemJavaCompiler().getTask(null, null, null, null, null, null); + DocTrees trees = DocTrees.instance(task); + DocCommentTree html = trees.getDocCommentTree(new FileObject() { + @Override + public URI toUri() { + throw new UnsupportedOperationException(); + } + + @Override + public String getName() { + return "test.html"; + } + + @Override + public InputStream openInputStream() throws IOException { + throw new UnsupportedOperationException(); + } + + @Override + public OutputStream openOutputStream() throws IOException { + throw new UnsupportedOperationException(); + } + + @Override + public Reader openReader(boolean ignoreEncodingErrors) throws IOException { + throw new UnsupportedOperationException(); + } + + @Override + public CharSequence getCharContent(boolean ignoreEncodingErrors) throws IOException { + return content; + } + + @Override + public Writer openWriter() throws IOException { + throw new UnsupportedOperationException(); + } + + @Override + public long getLastModified() { + throw new UnsupportedOperationException(); + } + + @Override + public boolean delete() { + throw new UnsupportedOperationException(); + } + + }); + return html; + } +}