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..e9d60f2a --- /dev/null +++ b/src/test/java/com/demcha/documentation/DocumentationSnippetCompileTest.java @@ -0,0 +1,378 @@ +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. Leading {@code import} + * lines are lifted above the wrapper. + * + *

+ *
{@code mode=method}
statements; wrapped in a {@code void} method + * that {@code throws Exception}.
+ *
{@code mode=members}
field/method declarations; inserted as class + * 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 { + + 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 JAVA_FENCE = + Pattern.compile("^```java\\s*$"); + private static final Set SUPPORTED_MODES = Set.of("method", "members"); + + @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(); + + assertThat(compile(examples)) + .describedAs("Every marked Java snippet under docs/ must compile against the current API") + .isEmpty(); + } + + @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("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(); + } + + @Test + 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); + 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)); + 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)) { + 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 + 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()) { + 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() || !JAVA_FENCE.matcher(lines.get(i).trim()).matches()) { + 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 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) { + + static String unitNameFor(String id) { + return "DocExample_" + id.replaceAll("[^A-Za-z0-9]", "_"); + } + + 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 (!inBody && IMPORT_LINE.matcher(line).matches()) { + imports.add(line.trim()); + continue; + } + if (!inBody && !line.isBlank()) { + inBody = true; + } + 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; + } + } +}