From 6ec17e53f90e4acb584f842a94340968cadbf479 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Mon, 29 Jun 2026 12:54:34 +0100 Subject: [PATCH 1/2] test(docs): compile published Java snippets to catch API drift DocumentationExamplesTest renders hand-kept Java copies of examples, but nothing compiles the literal docs/*.md fences, so a published snippet can drift from the API while the build stays green. DocumentationSnippetCompileTest walks docs/**/*.md and, for each java fence carrying an opt-in marker comment , wraps it per mode (method / members / class) and compiles it in-memory via ToolProvider.getSystemJavaCompiler() against the test classpath. Compiler errors fail the test and name the snippet id + file; a second test guards marker well-formedness, and the run asserts at least one snippet is found so a moved docs folder cannot make the guard pass vacuously. Markers are HTML comments, invisible on GitHub; teaching fragments stay unmarked. Marks the five self-contained snippets in first-document.md (2) and business-templates.md (3). Complements DocumentationExamplesTest, which renders example copies; this guard pins the published markdown to the API. --- docs/first-document.md | 2 + docs/templates/business-templates.md | 3 + .../DocumentationSnippetCompileTest.java | 338 ++++++++++++++++++ 3 files changed, 343 insertions(+) create mode 100644 src/test/java/com/demcha/documentation/DocumentationSnippetCompileTest.java diff --git a/docs/first-document.md b/docs/first-document.md index 56666d72..7204295c 100644 --- a/docs/first-document.md +++ b/docs/first-document.md @@ -12,6 +12,7 @@ with a page flow, and render. No coordinates, no manual page breaks. Open a session for a file path, add one page flow, render. The engine handles placement and pagination. + ```java import com.demcha.compose.GraphCompose; import com.demcha.compose.document.api.DocumentPageSize; @@ -41,6 +42,7 @@ modules, sections, paragraphs, lists, tables, and rows are added top to bottom. The same Flow model scales to a multi-section document. There are still no coordinates and no manual page breaks — just structure in reading order. + ```java import com.demcha.compose.GraphCompose; import com.demcha.compose.document.api.DocumentPageSize; diff --git a/docs/templates/business-templates.md b/docs/templates/business-templates.md index 29c843ac..b3b5be9d 100644 --- a/docs/templates/business-templates.md +++ b/docs/templates/business-templates.md @@ -27,6 +27,7 @@ decides file vs stream vs bytes. The caller does. ## Invoice + ```java import com.demcha.compose.GraphCompose; import com.demcha.compose.document.api.DocumentPageSize; @@ -86,6 +87,7 @@ Same shape, different spec. Use a proposal when the artifact is sales or project scope rather than billing. The timeline takes a three-argument `timelineItem(phase, duration, details)`. + ```java import com.demcha.compose.GraphCompose; import com.demcha.compose.document.api.DocumentPageSize; @@ -139,6 +141,7 @@ In production the spec usually comes from application data and the document is streamed to the caller's stream. The template composes the same way before any output method; create one session per request. + ```java import com.demcha.compose.GraphCompose; import com.demcha.compose.document.api.DocumentSession; diff --git a/src/test/java/com/demcha/documentation/DocumentationSnippetCompileTest.java b/src/test/java/com/demcha/documentation/DocumentationSnippetCompileTest.java new file mode 100644 index 00000000..011b1585 --- /dev/null +++ b/src/test/java/com/demcha/documentation/DocumentationSnippetCompileTest.java @@ -0,0 +1,338 @@ +package com.demcha.documentation; + +import org.junit.jupiter.api.Test; + +import javax.tools.Diagnostic; +import javax.tools.DiagnosticCollector; +import javax.tools.JavaCompiler; +import javax.tools.JavaFileObject; +import javax.tools.SimpleJavaFileObject; +import javax.tools.StandardJavaFileManager; +import javax.tools.StandardLocation; +import javax.tools.ToolProvider; +import java.io.IOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Compiles the Java snippets published in {@code docs/} so that an API change + * which breaks a documented snippet fails the build instead of silently rotting + * the public docs. + * + *

Complements {@code DocumentationExamplesTest} (which renders hand-kept Java + * copies of representative examples): this guard reads the literal markdown + * fences, so the published page itself cannot drift from the API. + * + *

The guard is opt-in: only a fenced {@code java} block + * immediately preceded by an invisible marker comment is compiled — + * + *

{@code
+ * 
+ * ```java
+ * ...
+ * ```
+ * }
+ * + * The marker is an HTML comment, so it renders to nothing on GitHub and keeps + * the published page clean. Teaching fragments that intentionally reference + * symbols defined only in prose (a bare {@code invoice} variable, pseudo-code) + * carry no marker and are left untouched, which keeps the guard free of false + * positives. + * + *

Each marked block is wrapped into a compilation unit according to its + * {@code mode} and compiled in-memory against the test runtime classpath (the + * same canonical classes and dependencies the rest of the suite sees). Compiler + * errors fail the test; warnings are ignored. + * + *

+ *
{@code mode=method}
statements; wrapped in a {@code void} method + * that {@code throws Exception}. Leading {@code import} lines are lifted above + * the wrapper.
+ *
{@code mode=members}
field/method declarations; inserted as class + * members. Leading imports are lifted.
+ *
{@code mode=class}
a complete compilation unit; compiled + * verbatim.
+ *
+ */ +class DocumentationSnippetCompileTest { + + private static final Path PROJECT_ROOT = Path.of("").toAbsolutePath().normalize(); + private static final Path DOCS_ROOT = PROJECT_ROOT.resolve("docs"); + + private static final Pattern MARKER = + Pattern.compile("^\\s*$"); + private static final Pattern IMPORT_LINE = + Pattern.compile("^\\s*import\\s+(?:static\\s+)?[\\w.]+(?:\\.\\*)?\\s*;\\s*$"); + private static final Pattern TYPE_NAME = + Pattern.compile("\\b(?:class|interface|record|enum)\\s+(\\w+)"); + private static final Set SUPPORTED_MODES = Set.of("method", "members", "class"); + + @Test + void publishedJavaSnippetsShouldCompile() throws IOException { + List examples = collectExamples(); + + // The scan must find work — a silent zero would let the guard pass while + // covering nothing (e.g. a moved docs folder or a broken marker regex). + assertThat(examples) + .describedAs("No doc-example markers found under %s — the guard would cover nothing", DOCS_ROOT) + .isNotEmpty(); + + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + assertThat(compiler) + .describedAs("A JDK compiler is required to compile doc snippets; run the build on a JDK, not a JRE") + .isNotNull(); + + DiagnosticCollector diagnostics = new DiagnosticCollector<>(); + StandardJavaFileManager fileManager = + compiler.getStandardFileManager(diagnostics, null, StandardCharsets.UTF_8); + + Path classOutput = Files.createTempDirectory("doc-snippets-classes"); + Map byUnitName = new LinkedHashMap<>(); + List units = new ArrayList<>(); + for (Example example : examples) { + String unitName = example.unitName(); + byUnitName.put(unitName, example); + units.add(new StringSource(unitName, example.toCompilationUnit())); + } + + try { + fileManager.setLocation(StandardLocation.CLASS_OUTPUT, List.of(classOutput.toFile())); + List options = List.of( + "-proc:none", + "-classpath", System.getProperty("java.class.path")); + compiler.getTask(null, fileManager, diagnostics, options, null, units).call(); + } finally { + fileManager.close(); + deleteRecursively(classOutput); + } + + List errors = new ArrayList<>(); + for (Diagnostic diagnostic : diagnostics.getDiagnostics()) { + if (diagnostic.getKind() != Diagnostic.Kind.ERROR) { + continue; + } + Example example = exampleFor(diagnostic, byUnitName); + String origin = example == null + ? "(unknown unit)" + : example.id + " — " + relative(example.file); + errors.add("[%s] %s".formatted(origin, diagnostic.getMessage(null).replaceAll("\\s+", " ").trim())); + } + + assertThat(errors) + .describedAs("Every marked Java snippet under docs/ must compile against the current API") + .isEmpty(); + } + + @Test + void docExampleMarkersShouldBeWellFormed() throws IOException { + List problems = new ArrayList<>(); + Map idToFile = new LinkedHashMap<>(); + + for (Path doc : markdownFiles()) { + List lines = Files.readAllLines(doc, StandardCharsets.UTF_8); + String rel = relative(doc); + for (int i = 0; i < lines.size(); i++) { + Matcher marker = MARKER.matcher(lines.get(i).trim()); + if (!marker.matches()) { + continue; + } + Map attributes = parseAttributes(marker.group(1)); + String id = attributes.get("id"); + String mode = attributes.get("mode"); + + if (id == null || id.isBlank()) { + problems.add("%s:%d — doc-example marker is missing an id".formatted(rel, i + 1)); + } else if (idToFile.containsKey(id)) { + problems.add("%s:%d — duplicate doc-example id '%s' (also in %s)" + .formatted(rel, i + 1, id, idToFile.get(id))); + } else { + idToFile.put(id, rel + ":" + (i + 1)); + } + + if (mode == null || !SUPPORTED_MODES.contains(mode)) { + problems.add("%s:%d — doc-example '%s' has unsupported mode '%s' (use %s)" + .formatted(rel, i + 1, id, mode, SUPPORTED_MODES)); + } + + if (fenceAfter(lines, i) == null) { + problems.add("%s:%d — doc-example '%s' is not followed by a java fence" + .formatted(rel, i + 1, id)); + } + } + } + + assertThat(problems) + .describedAs("doc-example markers must be well-formed (unique id, supported mode, followed by a java fence)") + .isEmpty(); + } + + private List collectExamples() throws IOException { + List examples = new ArrayList<>(); + for (Path doc : markdownFiles()) { + List lines = Files.readAllLines(doc, StandardCharsets.UTF_8); + for (int i = 0; i < lines.size(); i++) { + Matcher marker = MARKER.matcher(lines.get(i).trim()); + if (!marker.matches()) { + continue; + } + Map attributes = parseAttributes(marker.group(1)); + String id = attributes.get("id"); + String mode = attributes.get("mode"); + if (id == null || id.isBlank() || mode == null || !SUPPORTED_MODES.contains(mode)) { + continue; // structural problems are reported by docExampleMarkersShouldBeWellFormed + } + String fence = fenceAfter(lines, i); + if (fence != null) { + examples.add(new Example(id, mode, fence, doc)); + } + } + } + return examples; + } + + private List markdownFiles() throws IOException { + if (!Files.isDirectory(DOCS_ROOT)) { + return List.of(); + } + try (Stream paths = Files.walk(DOCS_ROOT)) { + return paths.filter(Files::isRegularFile) + .filter(path -> path.toString().endsWith(".md")) + .sorted() + .toList(); + } + } + + /** Returns the body of the next {@code java} fence after {@code markerIndex}, or null. */ + private static String fenceAfter(List lines, int markerIndex) { + int i = markerIndex + 1; + while (i < lines.size() && lines.get(i).isBlank()) { + i++; + } + if (i >= lines.size() || !lines.get(i).trim().startsWith("```java")) { + return null; + } + StringBuilder body = new StringBuilder(); + for (int j = i + 1; j < lines.size(); j++) { + if (lines.get(j).trim().equals("```")) { + return body.toString(); + } + body.append(lines.get(j)).append('\n'); + } + return null; // unterminated fence + } + + private static Map parseAttributes(String raw) { + Map attributes = new LinkedHashMap<>(); + for (String token : raw.trim().split("\\s+")) { + int eq = token.indexOf('='); + if (eq > 0) { + attributes.put(token.substring(0, eq), token.substring(eq + 1)); + } + } + return attributes; + } + + private static Example exampleFor(Diagnostic diagnostic, Map byUnitName) { + JavaFileObject source = diagnostic.getSource(); + if (source == null) { + return null; + } + String name = source.getName(); + for (Map.Entry entry : byUnitName.entrySet()) { + if (name.contains(entry.getKey())) { + return entry.getValue(); + } + } + return null; + } + + private static String relative(Path path) { + return PROJECT_ROOT.relativize(path).toString().replace('\\', '/'); + } + + private static void deleteRecursively(Path root) { + try (Stream paths = Files.walk(root)) { + paths.sorted((a, b) -> b.getNameCount() - a.getNameCount()).forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (IOException ignored) { + // best-effort temp cleanup + } + }); + } catch (IOException ignored) { + // best-effort temp cleanup + } + } + + private record Example(String id, String mode, String fence, Path file) { + + String unitName() { + if (mode.equals("class")) { + Matcher typeName = TYPE_NAME.matcher(fence); + if (typeName.find()) { + return typeName.group(1); + } + } + return "DocExample_" + id.replaceAll("[^A-Za-z0-9]", "_"); + } + + String toCompilationUnit() { + if (mode.equals("class")) { + return fence; + } + + List imports = new ArrayList<>(); + StringBuilder body = new StringBuilder(); + for (String line : fence.split("\\n", -1)) { + if (IMPORT_LINE.matcher(line).matches()) { + imports.add(line.trim()); + } else { + body.append(line).append('\n'); + } + } + + StringBuilder unit = new StringBuilder(); + for (String anImport : imports) { + unit.append(anImport).append('\n'); + } + unit.append('\n'); + unit.append("public final class ").append(unitName()).append(" {\n"); + if (mode.equals("method")) { + unit.append(" @SuppressWarnings({\"unused\", \"try\"})\n"); + unit.append(" void __example() throws Exception {\n"); + unit.append(body); + unit.append(" }\n"); + } else { // members + unit.append(body); + } + unit.append("}\n"); + return unit.toString(); + } + } + + private static final class StringSource extends SimpleJavaFileObject { + private final String code; + + StringSource(String unitName, String code) { + super(URI.create("string:///" + unitName.replace('.', '/') + Kind.SOURCE.extension), Kind.SOURCE); + this.code = code; + } + + @Override + public CharSequence getCharContent(boolean ignoreEncodingErrors) { + return code; + } + } +} From f8d9670f464d829ab58eb498c0a6b69449a7a5b0 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Mon, 29 Jun 2026 13:21:37 +0100 Subject: [PATCH 2/2] test(docs): self-test the snippet guard and tighten its parsing Make DocumentationSnippetCompileTest verify its own failure path so a regression in the parse/wrap/compile pipeline cannot pass vacuously: - compilerReportsErrorForBrokenSnippet feeds a snippet that references a missing symbol and asserts the error is surfaced and attributed to its id. - knownCanonicalTypeResolvesOnTestClasspath compiles a trivial canonical call, so a classpath failure is distinguishable from a documentation break. Also harden the mechanics: attribute diagnostics to snippets by file-object identity instead of a substring match; lift only the leading run of imports (an import-shaped line inside a body stays put); match the java fence exactly (no `javascript`); create the compiler output dir inside the try; guard sanitized unit-name uniqueness. Drop the unused class mode. --- .../DocumentationSnippetCompileTest.java | 198 +++++++++++------- 1 file changed, 119 insertions(+), 79 deletions(-) diff --git a/src/test/java/com/demcha/documentation/DocumentationSnippetCompileTest.java b/src/test/java/com/demcha/documentation/DocumentationSnippetCompileTest.java index 011b1585..e9d60f2a 100644 --- a/src/test/java/com/demcha/documentation/DocumentationSnippetCompileTest.java +++ b/src/test/java/com/demcha/documentation/DocumentationSnippetCompileTest.java @@ -54,17 +54,21 @@ *

Each marked block is wrapped into a compilation unit according to its * {@code mode} and compiled in-memory against the test runtime classpath (the * same canonical classes and dependencies the rest of the suite sees). Compiler - * errors fail the test; warnings are ignored. + * errors fail the test; warnings are ignored. Leading {@code import} + * lines are lifted above the wrapper. * *

*
{@code mode=method}
statements; wrapped in a {@code void} method - * that {@code throws Exception}. Leading {@code import} lines are lifted above - * the wrapper.
+ * that {@code throws Exception}. *
{@code mode=members}
field/method declarations; inserted as class - * members. Leading imports are lifted.
- *
{@code mode=class}
a complete compilation unit; compiled - * verbatim.
+ * members. *
+ * + *

The guard self-tests both directions: {@link #compilerReportsErrorForBrokenSnippet()} + * proves a broken snippet is actually surfaced as a failure, and + * {@link #knownCanonicalTypeResolvesOnTestClasspath()} proves the compile classpath + * resolves canonical types — so a classpath regression is distinguishable from a + * real doc break. */ class DocumentationSnippetCompileTest { @@ -75,9 +79,9 @@ class DocumentationSnippetCompileTest { Pattern.compile("^\\s*$"); private static final Pattern IMPORT_LINE = Pattern.compile("^\\s*import\\s+(?:static\\s+)?[\\w.]+(?:\\.\\*)?\\s*;\\s*$"); - private static final Pattern TYPE_NAME = - Pattern.compile("\\b(?:class|interface|record|enum)\\s+(\\w+)"); - private static final Set SUPPORTED_MODES = Set.of("method", "members", "class"); + private static final Pattern JAVA_FENCE = + Pattern.compile("^```java\\s*$"); + private static final Set SUPPORTED_MODES = Set.of("method", "members"); @Test void publishedJavaSnippetsShouldCompile() throws IOException { @@ -89,49 +93,44 @@ void publishedJavaSnippetsShouldCompile() throws IOException { .describedAs("No doc-example markers found under %s — the guard would cover nothing", DOCS_ROOT) .isNotEmpty(); - JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); - assertThat(compiler) - .describedAs("A JDK compiler is required to compile doc snippets; run the build on a JDK, not a JRE") - .isNotNull(); - - DiagnosticCollector diagnostics = new DiagnosticCollector<>(); - StandardJavaFileManager fileManager = - compiler.getStandardFileManager(diagnostics, null, StandardCharsets.UTF_8); - - Path classOutput = Files.createTempDirectory("doc-snippets-classes"); - Map byUnitName = new LinkedHashMap<>(); - List units = new ArrayList<>(); - for (Example example : examples) { - String unitName = example.unitName(); - byUnitName.put(unitName, example); - units.add(new StringSource(unitName, example.toCompilationUnit())); - } - - try { - fileManager.setLocation(StandardLocation.CLASS_OUTPUT, List.of(classOutput.toFile())); - List options = List.of( - "-proc:none", - "-classpath", System.getProperty("java.class.path")); - compiler.getTask(null, fileManager, diagnostics, options, null, units).call(); - } finally { - fileManager.close(); - deleteRecursively(classOutput); - } + assertThat(compile(examples)) + .describedAs("Every marked Java snippet under docs/ must compile against the current API") + .isEmpty(); + } - List errors = new ArrayList<>(); - for (Diagnostic diagnostic : diagnostics.getDiagnostics()) { - if (diagnostic.getKind() != Diagnostic.Kind.ERROR) { - continue; - } - Example example = exampleFor(diagnostic, byUnitName); - String origin = example == null - ? "(unknown unit)" - : example.id + " — " + relative(example.file); - errors.add("[%s] %s".formatted(origin, diagnostic.getMessage(null).replaceAll("\\s+", " ").trim())); - } + @Test + void compilerReportsErrorForBrokenSnippet() throws IOException { + // Drives the full mechanism (wrap -> compile -> collect -> attribute) on a + // snippet that references a symbol that does not exist. Proves the guard + // actually fails — and names the offending snippet — instead of passing + // vacuously if any stage regressed. + Example broken = new Example( + "synthetic-broken-snippet", "method", + "thisMethodDoesNotExistOnAnyType();\n", + PROJECT_ROOT.resolve("docs/(synthetic).md")); + + List errors = compile(List.of(broken)); assertThat(errors) - .describedAs("Every marked Java snippet under docs/ must compile against the current API") + .describedAs("A snippet referencing a missing symbol must be reported as an error") + .isNotEmpty() + .allSatisfy(error -> assertThat(error).contains("synthetic-broken-snippet")); + } + + @Test + void knownCanonicalTypeResolvesOnTestClasspath() throws IOException { + // A trivial snippet that imports and calls a known canonical type. If this + // fails, the compile classpath is not resolving the library — a classpath / + // Surefire booter problem, NOT a documentation defect. Keeping it separate + // makes that distinction unambiguous when the suite goes red. + Example probe = new Example( + "synthetic-classpath-probe", "method", + "import com.demcha.compose.GraphCompose;\nGraphCompose.document();\n", + PROJECT_ROOT.resolve("docs/(synthetic).md")); + + assertThat(compile(List.of(probe))) + .describedAs("A known canonical type must resolve on the test classpath; " + + "a failure here is a classpath problem, not a docs problem") .isEmpty(); } @@ -139,6 +138,7 @@ void publishedJavaSnippetsShouldCompile() throws IOException { void docExampleMarkersShouldBeWellFormed() throws IOException { List problems = new ArrayList<>(); Map idToFile = new LinkedHashMap<>(); + Map unitNameToId = new LinkedHashMap<>(); for (Path doc : markdownFiles()) { List lines = Files.readAllLines(doc, StandardCharsets.UTF_8); @@ -159,6 +159,12 @@ void docExampleMarkersShouldBeWellFormed() throws IOException { .formatted(rel, i + 1, id, idToFile.get(id))); } else { idToFile.put(id, rel + ":" + (i + 1)); + String unitName = Example.unitNameFor(id); + String clash = unitNameToId.put(unitName, id); + if (clash != null) { + problems.add("%s:%d — doc-example id '%s' sanitizes to the same unit name as '%s'" + .formatted(rel, i + 1, id, clash)); + } } if (mode == null || !SUPPORTED_MODES.contains(mode)) { @@ -174,10 +180,58 @@ void docExampleMarkersShouldBeWellFormed() throws IOException { } assertThat(problems) - .describedAs("doc-example markers must be well-formed (unique id, supported mode, followed by a java fence)") + .describedAs("doc-example markers must be well-formed (unique id + unit name, supported mode, followed by a java fence)") .isEmpty(); } + /** Compiles the given examples in-memory and returns one string per compiler error, attributed to its example. */ + private static List compile(List examples) throws IOException { + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + assertThat(compiler) + .describedAs("A JDK compiler is required to compile doc snippets; run the build on a JDK, not a JRE") + .isNotNull(); + + DiagnosticCollector diagnostics = new DiagnosticCollector<>(); + StandardJavaFileManager fileManager = + compiler.getStandardFileManager(diagnostics, null, StandardCharsets.UTF_8); + + Map bySource = new LinkedHashMap<>(); + List units = new ArrayList<>(); + for (Example example : examples) { + JavaFileObject source = new StringSource(example.unitName(), example.toCompilationUnit()); + bySource.put(source, example); + units.add(source); + } + + Path classOutput = null; + try { + classOutput = Files.createTempDirectory("doc-snippets-classes"); + fileManager.setLocation(StandardLocation.CLASS_OUTPUT, List.of(classOutput.toFile())); + List options = List.of( + "-proc:none", + "-classpath", System.getProperty("java.class.path")); + compiler.getTask(null, fileManager, diagnostics, options, null, units).call(); + } finally { + fileManager.close(); + if (classOutput != null) { + deleteRecursively(classOutput); + } + } + + List errors = new ArrayList<>(); + for (Diagnostic diagnostic : diagnostics.getDiagnostics()) { + if (diagnostic.getKind() != Diagnostic.Kind.ERROR) { + continue; + } + Example example = bySource.get(diagnostic.getSource()); + String origin = example == null + ? "(unknown unit)" + : example.id + " — " + relative(example.file); + errors.add("[%s] %s".formatted(origin, diagnostic.getMessage(null).replaceAll("\\s+", " ").trim())); + } + return errors; + } + private List collectExamples() throws IOException { List examples = new ArrayList<>(); for (Path doc : markdownFiles()) { @@ -220,7 +274,7 @@ private static String fenceAfter(List lines, int markerIndex) { while (i < lines.size() && lines.get(i).isBlank()) { i++; } - if (i >= lines.size() || !lines.get(i).trim().startsWith("```java")) { + if (i >= lines.size() || !JAVA_FENCE.matcher(lines.get(i).trim()).matches()) { return null; } StringBuilder body = new StringBuilder(); @@ -244,20 +298,6 @@ private static Map parseAttributes(String raw) { return attributes; } - private static Example exampleFor(Diagnostic diagnostic, Map byUnitName) { - JavaFileObject source = diagnostic.getSource(); - if (source == null) { - return null; - } - String name = source.getName(); - for (Map.Entry entry : byUnitName.entrySet()) { - if (name.contains(entry.getKey())) { - return entry.getValue(); - } - } - return null; - } - private static String relative(Path path) { return PROJECT_ROOT.relativize(path).toString().replace('\\', '/'); } @@ -278,29 +318,29 @@ private static void deleteRecursively(Path root) { private record Example(String id, String mode, String fence, Path file) { - String unitName() { - if (mode.equals("class")) { - Matcher typeName = TYPE_NAME.matcher(fence); - if (typeName.find()) { - return typeName.group(1); - } - } + static String unitNameFor(String id) { return "DocExample_" + id.replaceAll("[^A-Za-z0-9]", "_"); } - String toCompilationUnit() { - if (mode.equals("class")) { - return fence; - } + String unitName() { + return unitNameFor(id); + } + String toCompilationUnit() { + // Lift only the leading run of import lines; an import-shaped line that + // appears after real code (e.g. inside a text block) stays in the body. List imports = new ArrayList<>(); StringBuilder body = new StringBuilder(); + boolean inBody = false; for (String line : fence.split("\\n", -1)) { - if (IMPORT_LINE.matcher(line).matches()) { + if (!inBody && IMPORT_LINE.matcher(line).matches()) { imports.add(line.trim()); - } else { - body.append(line).append('\n'); + continue; + } + if (!inBody && !line.isBlank()) { + inBody = true; } + body.append(line).append('\n'); } StringBuilder unit = new StringBuilder();