Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 55 additions & 12 deletions src/catdata/cql/exp/AqlHelp.java
Original file line number Diff line number Diff line change
Expand Up @@ -368,9 +368,37 @@ public static void help(File dir, boolean run) {
Util.writeFile(search, new File(dir, "search.php").getAbsolutePath()); */
Map<Example, Set<AqlSyntax>> index = new THashMap<>();

// Examples for which no per-example page is generated below. Other pages (e.g. option pages)
// must not link to these names, or those links would 404.
Set<String> undocumentedExamples = Set.of("Stdlib", "TutorialTSP");

// Syntax pages are generated further below only for non-var expressions enumerated by
// TypingInversion, and never for comments. Precompute the exact set of page basenames
// (kind + keyword) that WILL be written, so the per-example keyword cross-links can be
// suppressed when no target page exists. Mirrors the filters of the syntax-page loop; keep
// the two in sync. This is the single source of truth for "does a syntax page exist".
Map<Kind, Set<Pair<AqlTyping, Exp<?>>>> z =
new TypingInversion().covisit(Unit.unit, x -> new AqlTyping());
Set<String> generatedSyntaxPages = new THashSet<>();
for (Kind k : Kind.class.getEnumConstants()) {
if (k.equals(Kind.COMMENT)) {
continue;
}
for (Pair<AqlTyping, Exp<?>> pair : z.get(k)) {
if (pair.second.isVar()) {
continue;
}
generatedSyntaxPages.add(pair.second.kind().toString() + pair.second.getKeyword());
}
}
// Keywords whose cross-link we dropped because no syntax page exists for them (var
// expressions, or keywords absent from TypingInversion's enumeration). Reported after the
// example loop so the docs gap stays visible instead of being silently hidden.
Set<String> suppressedKeywordLinks = new THashSet<>();

StringBuffer examples = new StringBuffer("<html><head>" + css + "</head><body>");
for (Example ex : Util.alphabetical(Examples.getExamples(Language.CQL))) {
if (ex.getName().equals("Stdlib") || ex.getName().equals("TutorialTSP")) {
if (undocumentedExamples.contains(ex.getName())) {
continue;
}
examples.append(
Expand All @@ -389,16 +417,23 @@ public static void help(File dir, boolean run) {
index.put(ex, set);
other.append("Keywords:<br/></br>");
for (Exp<?> e : prog.exps.values()) {
// Comments have no syntax page (see the Kind.COMMENT skip in the syntax loop), and their
// "keyword" is free-form body text, so linking them would emit a broken href.
if (e.kind().equals(Kind.COMMENT)) {
continue;
}
if (!set.contains(e.getSyntax())) {
set.add(e.getSyntax());
other.append(
"\t\t\t<a href=\""
+ e.kind()
+ e.getKeyword()
+ ".html"
+ "\" >"
+ e.getKeyword()
+ "</a><br />\n");
String page = e.kind().toString() + e.getKeyword();
if (generatedSyntaxPages.contains(page)) {
other.append(
"\t\t\t<a href=\"" + page + ".html" + "\" >" + e.getKeyword() + "</a><br />\n");
} else {
// No syntax page is generated for this keyword, so render it as plain text rather
// than emit a dead link.
other.append("\t\t\t" + e.getKeyword() + "<br />\n");
suppressedKeywordLinks.add(page);
}
}
for (String k : e.options().keySet()) {
if (opSeen.contains(k)) {
Expand Down Expand Up @@ -467,6 +502,14 @@ public static void help(File dir, boolean run) {
}
examples.append("\n</body></html>");

if (!suppressedKeywordLinks.isEmpty()) {
System.out.println(
"AqlHelp: suppressed "
+ suppressedKeywordLinks.size()
+ " keyword cross-link(s) with no syntax page (no doc page exists for these): "
+ Util.sep(Util.alphabetical(new ArrayList<>(suppressedKeywordLinks)), ", "));
}

StringBuffer logo = new StringBuffer("");
logo.append("<html><head>" + css + "</head><body>");
logo.append("\n<a href=\"syntax.html\" target=\"tree\">Syntax</a><br >");
Expand Down Expand Up @@ -500,8 +543,6 @@ public static void help(File dir, boolean run) {

StringBuffer syntax = new StringBuffer();
syntax.append("<html><head>" + css + "</head>\n\t");
Map<Kind, Set<Pair<AqlTyping, Exp<?>>>> z =
new TypingInversion().covisit(Unit.unit, x -> new AqlTyping());
Map<String, Set<Exp<?>>> opInv = Util.newSetsFor(AqlOptions.optionNames());

boolean isFirstK = true;
Expand Down Expand Up @@ -634,7 +675,9 @@ public static void help(File dir, boolean run) {
}
StringBuffer yyy = new StringBuffer();
for (Example example : Util.alphabetical(Examples.getExamples(Language.CQL))) {

if (undocumentedExamples.contains(example.getName())) {
continue;
}
if (example.getText().contains(ex.toString())) {
yyy.append(
"\n<a href=\""
Expand Down
66 changes: 66 additions & 0 deletions src/catdata/cql/exp/AqlHelpTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package catdata.cql.exp;

import static org.junit.jupiter.api.Assertions.assertTrue;

import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

/** Unit tests for the CQL HTML manual generator {@link AqlHelp}. */
class AqlHelpTest {

/** Matches the target of every {@code href="..."} / {@code src="..."} attribute. */
private static final Pattern LINK = Pattern.compile("(?:href|src)=\"([^\"]+)\"");

/**
* Regenerating the manual must not leave any local {@code href}/{@code src} pointing at a page
* that was never written — those are the 404s tracked in issue #110. External links ({@code
* http...}), parent-relative assets ({@code ../css/...}), empty targets, and pure anchors are out
* of scope and skipped.
*/
@Test
@Tag("integration")
void generatedManualHasNoBrokenLocalLinks(@TempDir Path dir) throws IOException {
// run = false: render the pages without executing examples, which is all link-checking needs.
AqlHelp.help(dir.toFile(), false);

List<String> generated = new ArrayList<>();
for (File f : dir.toFile().listFiles()) {
generated.add(f.getName());
}

List<String> broken = new ArrayList<>();
for (File f : dir.toFile().listFiles()) {
if (!f.getName().endsWith(".html")) {
continue;
}
String html = new String(Files.readAllBytes(f.toPath()), StandardCharsets.UTF_8);
Matcher m = LINK.matcher(html);
while (m.find()) {
String target = m.group(1);
if (target.isEmpty()
|| target.startsWith("http")
|| target.startsWith("..")
|| target.startsWith("#")) {
continue;
}
String base = target.split("#")[0].split("\\?")[0];
if (!generated.contains(base)) {
broken.add(f.getName() + " -> " + target);
}
}
}

assertTrue(broken.isEmpty(), "broken local links in generated manual: " + broken);
}
}
Loading