From 97909aa373ca9db5c77818ff9bc157a5504c65cd Mon Sep 17 00:00:00 2001 From: David Tooke Date: Thu, 8 Oct 2026 15:59:42 -0400 Subject: [PATCH 1/4] Re-enable the test that a blocking operator over an unbounded input is refused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DarkCollective/relix-core#98 made stream() refuse τ and γ over an unbounded Relix.input with UnboundedRelationException, so the test #34 disabled now runs, for τ and for γ, and checks the error names the input as unbounded. δ is not among them: it streams by design. --- .../darkcollective/relix/cli/command/InputTest.java | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/test/java/com/darkcollective/relix/cli/command/InputTest.java b/src/test/java/com/darkcollective/relix/cli/command/InputTest.java index 48c3701..ad7143e 100644 --- a/src/test/java/com/darkcollective/relix/cli/command/InputTest.java +++ b/src/test/java/com/darkcollective/relix/cli/command/InputTest.java @@ -16,7 +16,6 @@ package com.darkcollective.relix.cli.command; import com.darkcollective.relix.cli.Cli; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -292,16 +291,18 @@ void badModifiers() { @DisplayName("an unbounded input") class Unbounded { - @Test - @Disabled("the engine's plan-time check refuses a blocking operator over an unbounded " - + "generator, but not yet over an unbounded Relix.input") + @ParameterizedTest(name = "{0}") + @CsvSource(delimiter = ';', value = { + "τ id (r)", + "γ customer, COUNT(id) → n (r)"}) @DisplayName("a blocking operator over it is refused before it starts") - void blockingRefused() { + void blockingRefused(String query) { var result = Cli.in(dir).piped().stdin(NDJSON) - .run("-i", "r=ndjson:-", "--unbounded=r", "--infer-rows=1", "-e", "τ id (r)"); + .run("-i", "r=ndjson:-", "--unbounded=r", "--infer-rows=1", "-e", query); assertThat(result.status()).as(result.toString()).isEqualTo(3); assertThat(result.out()).isEmpty(); + assertThat(result.err()).as(result.toString()).contains("unbounded"); } @Test From b98124c61d95365f894d2c214e6c0b7fcf2a2ab3 Mon Sep 17 00:00:00 2001 From: David Tooke Date: Thu, 8 Oct 2026 16:01:23 -0400 Subject: [PATCH 2/4] Print relix fmt's output with the engine's script printer DarkCollective/relix-core#99 made ScriptPrinter keep a script's comments and print the ASCII spelling, so fmt no longer scans for comments itself or respells the glyphs token by token. It prints ScriptPrinter.print(Relix.parse(text, source), spelling) and aligns the inline tables' columns, as before. A statement with a comment inside it is now formatted too, with the comment kept. The engine's printer drops a blank line between a comment and the statement or namespace after it; a disabled test records that until the engine keeps it. --- docs/design.md | 2 +- .../relix/cli/command/FmtCommand.java | 8 +- .../relix/cli/io/ScriptText.java | 58 ++---- .../relix/cli/render/ScriptFormatter.java | 197 ++---------------- .../relix/cli/command/FmtTest.java | 22 +- 5 files changed, 49 insertions(+), 238 deletions(-) diff --git a/docs/design.md b/docs/design.md index 61ad2c2..8bcd6af 100644 --- a/docs/design.md +++ b/docs/design.md @@ -327,7 +327,7 @@ Everything goes through `com.darkcollective.relix.embed`: | queries | `script(text)`, `relation(expression)` | | rows | `Relation.stream()` (closed on `EPIPE`/`SIGINT`) | | `explain`, `optimize`, `trace`, `bundle`, `ir`, `provenance` | `explain`/`explainJson`, `optimized().render()` + `rewrites()`, `stream(listener)`, `renderJson`, `model().ir()`, `provenance(semiring[, weight])` | -| `fmt` | the statement printer behind `Relix.definitions()` | +| `fmt` | `ScriptPrinter.print(Script, Spelling)`, which keeps comments | | `catalog ls/schema` | `relation("relix.relations")` etc. | | `doc` | the reference lookup in `relix-docs` (R5): language pages and the installed functions' pages, one index | diff --git a/src/main/java/com/darkcollective/relix/cli/command/FmtCommand.java b/src/main/java/com/darkcollective/relix/cli/command/FmtCommand.java index 0e5aba4..a5af49a 100644 --- a/src/main/java/com/darkcollective/relix/cli/command/FmtCommand.java +++ b/src/main/java/com/darkcollective/relix/cli/command/FmtCommand.java @@ -15,6 +15,7 @@ */ package com.darkcollective.relix.cli.command; +import com.darkcollective.relix.ast.Spelling; import com.darkcollective.relix.cli.CommandFailure; import com.darkcollective.relix.cli.ExitCode; import com.darkcollective.relix.cli.io.Reporter; @@ -41,7 +42,7 @@ /** * {@code relix fmt}: prints scripts canonically (design §3), as {@code gofmt} does Go. * - *

Each script is printed by the engine's statement printer, with its comments kept + *

Each script is printed by the engine's script printer, with its comments kept * (see {@link ScriptFormatter}). With no option the formatted scripts go to standard * output; {@code -w} rewrites each file that changes in place, and {@code --check} changes * nothing, names each file that would change, and exits 1 if any would, for CI. @@ -97,8 +98,7 @@ public Integer call() { throw new CommandFailure(ExitCode.USAGE, "-w and --check cannot be given together"); } Invocation invocation = root.invocation(false); - ScriptFormatter.Spelling chosen = spelling.keywords - ? ScriptFormatter.Spelling.KEYWORDS : ScriptFormatter.Spelling.GLYPHS; + Spelling chosen = spelling.keywords ? Spelling.KEYWORDS : Spelling.GLYPHS; List sources = sources(invocation); if (write && sources.stream().anyMatch(s -> s.name().equals(ScriptSource.STDIN))) { throw new CommandFailure(ExitCode.USAGE, "-w rewrites files; standard input has none to rewrite"); @@ -110,7 +110,7 @@ public Integer call() { for (ScriptSource source : sources) { String formatted; try { - formatted = ScriptFormatter.format(source.text(), chosen); + formatted = ScriptFormatter.format(source.text(), source.name(), chosen); } catch (ScriptParseException e) { reporter.error(new Reporter.Place(source.name(), e.line(), e.column()) + ": error: " + e.description()); diff --git a/src/main/java/com/darkcollective/relix/cli/io/ScriptText.java b/src/main/java/com/darkcollective/relix/cli/io/ScriptText.java index 978b48b..11fc694 100644 --- a/src/main/java/com/darkcollective/relix/cli/io/ScriptText.java +++ b/src/main/java/com/darkcollective/relix/cli/io/ScriptText.java @@ -25,12 +25,11 @@ import java.util.Objects; /** - * A script's text together with what it parses to: where in the text each statement is, - * and where its comments are. + * A script's text together with what it parses to: where in the text each statement is. * *

The parser gives each statement the place it starts; this finds where it ends, at the - * semicolon that closes it, and finds the comments. Those are the language's, read as its - * two lexers read them: {@code --} to the end of the line and {@code /* *}{@code /}, outside + * semicolon that closes it, passing over the comments a semicolon may be in. Those are the + * language's, read as its two lexers read them: {@code --} to the end of the line and {@code /* *}{@code /}, outside * a string. Within an inline table only a line that begins with one is a comment, since a * cell may hold {@code --} as data, and a table's {@code |---|} rule is not one. */ @@ -60,25 +59,21 @@ public String of(String text) { * * @param statement what it parses to * @param span its text, from its first character to the semicolon that ends it - * @param comments the comments within that text */ - public record Piece(Statement statement, Span span, List comments) { + public record Piece(Statement statement, Span span) { - /** Checks every component and copies the comments. */ + /** Checks every component. */ public Piece { Objects.requireNonNull(statement, "statement"); Objects.requireNonNull(span, "span"); - comments = List.copyOf(comments); } } private final String text; - private final Script script; private final List pieces; - private ScriptText(String text, Script script, List pieces) { + private ScriptText(String text, List pieces) { this.text = text; - this.script = script; this.pieces = List.copyOf(pieces); } @@ -97,9 +92,9 @@ public static ScriptText of(String text) { int start = offset(statement.location(), lines, text); Scan scan = new Scan(text, start); scan.toEndOfStatement(); - pieces.add(new Piece(statement, new Span(start, scan.position), scan.comments)); + pieces.add(new Piece(statement, new Span(start, scan.position))); } - return new ScriptText(text, script, pieces); + return new ScriptText(text, pieces); } /** {@return the text} */ @@ -107,30 +102,11 @@ public String text() { return text; } - /** {@return what the text parses to} */ - public Script script() { - return script; - } - /** {@return each statement and where it is written, in order} */ public List statements() { return pieces; } - /** - * The comments in a stretch of the text that holds nothing else but white space, such as - * the stretch between two statements. - * - * @param from where the stretch starts - * @param to where it ends - * @return its comments, in order - */ - public List comments(int from, int to) { - Scan scan = new Scan(text.substring(0, to), from); - scan.toEnd(); - return scan.comments; - } - private static int[] lineStarts(String text) { List starts = new ArrayList<>(); starts.add(0); @@ -148,9 +124,9 @@ private static int offset(SourceLocation location, int[] lines, String text) { } /** - * Reads the text as the lexers do, as far as finding comments and the end of a - * statement needs: through strings, delimited names, braces (an expression body) and - * brackets (an inline table). + * Reads the text as the lexers do, as far as finding the end of a statement needs: + * through comments, strings, delimited names, braces (an expression body) and brackets + * (an inline table). */ private static final class Scan { @@ -158,7 +134,6 @@ private enum Context { TOP, BRACE, BRACKET } private final String text; private final List stack = new ArrayList<>(List.of(Context.TOP)); - private final List comments = new ArrayList<>(); private int position; Scan(String text, int start) { @@ -177,13 +152,6 @@ void toEndOfStatement() { } } - /** Reads to the end of the text. */ - void toEnd() { - while (position < text.length()) { - step(); - } - } - private void step() { char c = text.charAt(position); Context context = stack.getLast(); @@ -236,7 +204,7 @@ private void step() { } } - /** Reads a comment starting here, if one does. */ + /** Reads past a comment starting here, if one does. */ private boolean comment(Context context) { if (!text.startsWith("--", position) && !text.startsWith("/*", position)) { return false; @@ -244,7 +212,6 @@ private boolean comment(Context context) { if (context == Context.BRACKET && !atLineStart()) { return false; } - int start = position; if (text.startsWith("--", position)) { int end = text.indexOf('\n', position); position = end < 0 ? text.length() : end; @@ -252,7 +219,6 @@ private boolean comment(Context context) { int end = text.indexOf("*/", position + 2); position = end < 0 ? text.length() : end + 2; } - comments.add(new Span(start, position)); return true; } diff --git a/src/main/java/com/darkcollective/relix/cli/render/ScriptFormatter.java b/src/main/java/com/darkcollective/relix/cli/render/ScriptFormatter.java index 4545ec4..583559c 100644 --- a/src/main/java/com/darkcollective/relix/cli/render/ScriptFormatter.java +++ b/src/main/java/com/darkcollective/relix/cli/render/ScriptFormatter.java @@ -15,49 +15,24 @@ */ package com.darkcollective.relix.cli.render; -import com.darkcollective.relix.cli.io.ScriptText; -import com.darkcollective.relix.docs.RelixDocs; +import com.darkcollective.relix.ast.Spelling; +import com.darkcollective.relix.embed.Relix; import com.darkcollective.relix.lang.ast.ScriptPrinter; import java.util.ArrayList; -import java.util.LinkedHashMap; import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.regex.Matcher; -import java.util.regex.Pattern; /** - * Prints a script canonically: each statement as the engine's statement printer writes - * it, the printer behind {@code Relix.definitions()}, with the script's comments kept. + * Prints a script canonically: as the engine's script printer writes it, which keeps the + * script's comments, before, inside and after each statement, and its blank lines + * (DarkCollective/relix-core#99), with the columns of its inline tables aligned. * - *

The printer drops comments, since a comment is not in the tree it prints from - * (DarkCollective/relix-core#99), so they are put back from the text: - *

- * - *

An inline table's columns are aligned. Operators are written as glyphs, the - * printer's spelling, or with {@link Spelling#KEYWORDS} as the ASCII keywords the - * reference's spellings page pairs with them. Formatting is idempotent: formatting its + *

Operators are written as glyphs, the printer's default, or with + * {@link Spelling#KEYWORDS} as ASCII keywords. Formatting is idempotent: formatting its * own output changes nothing. */ public final class ScriptFormatter { - /** How operators are written. */ - public enum Spelling { - /** {@code σ}, {@code ⋈}, {@code ∧}: the printer's own. */ - GLYPHS, - /** {@code SELECT}, {@code JOIN}, {@code AND}. */ - KEYWORDS - } - - private static final Pattern NAMESPACE = Pattern.compile("\\bnamespace\\b"); - private ScriptFormatter() { } @@ -65,164 +40,20 @@ private ScriptFormatter() { * Formats a script. * * @param text the script + * @param source the name to report a parse error against * @param spelling how operators are written * @return the script, formatted, ending in one line break * @throws com.darkcollective.relix.lang.ast.ScriptParseException when it does not parse */ - public static String format(String text, Spelling spelling) { - ScriptText layout = ScriptText.of(text); - List items = new ArrayList<>(); - List statements = layout.statements(); - int preamble = statements.isEmpty() ? text.length() : statements.getFirst().span().start(); - layout.script().namespace().ifPresent(name -> - items.add(new Item(namespaceAt(text, layout.comments(0, preamble), preamble), - "namespace " + name + ";"))); - int at = 0; - for (ScriptText.Piece piece : statements) { - comments(text, layout.comments(at, piece.span().start()), items); - items.add(new Item(piece.span().start(), piece.span().end(), statement(text, piece, spelling))); - at = piece.span().end(); - } - comments(text, layout.comments(at, text.length()), items); - items.sort((a, b) -> Integer.compare(a.start(), b.start())); - return join(text, items); - } - - /** A thing to print, and where it was in the text. */ - private record Item(int start, int end, String text) { - Item(ScriptText.Span span, String text) { - this(span.start(), span.end(), text); - } - } - - private static void comments(String text, List comments, List items) { - for (ScriptText.Span comment : comments) { - items.add(new Item(comment, comment.of(text).stripTrailing())); - } - } - - /** Where the namespace declaration is, among the comments before the first statement. */ - private static ScriptText.Span namespaceAt(String text, List comments, int end) { - StringBuilder blanked = new StringBuilder(text.substring(0, end)); - for (ScriptText.Span comment : comments) { - for (int i = comment.start(); i < comment.end(); i++) { - blanked.setCharAt(i, ' '); - } - } - Matcher m = NAMESPACE.matcher(blanked); - int start = m.find() ? m.start() : 0; - int semicolon = blanked.indexOf(";", start); - return new ScriptText.Span(start, semicolon < 0 ? end : semicolon + 1); - } - - private static String statement(String text, ScriptText.Piece piece, Spelling spelling) { - if (!piece.comments().isEmpty()) { - return piece.span().of(text).strip(); - } - String printed = ScriptPrinter.print(piece.statement()); - if (spelling == Spelling.KEYWORDS) { - printed = Respelling.keywords(printed); - } - return Tables.align(printed); - } - - /** - * The items, each separated from the one before as it was in the text: by a space when - * it followed on the same line, a line break, or one blank line for one or more. - */ - private static String join(String text, List items) { - StringBuilder out = new StringBuilder(); - Item previous = null; - for (Item item : items) { - if (previous != null) { - String between = text.substring(Math.min(previous.end(), item.start()), item.start()); - long breaks = between.chars().filter(c -> c == '\n').count(); - out.append(breaks == 0 ? " " : breaks == 1 ? "\n" : "\n\n"); - } - out.append(item.text()); - previous = item; - } - return out.isEmpty() ? "" : out.append('\n').toString(); + public static String format(String text, String source, Spelling spelling) { + return Tables.align(ScriptPrinter.print(Relix.parse(text, source), spelling)); } /** - * Rewrites the printer's glyphs as the keywords {@code language/spellings.md} pairs with - * them, outside string literals and delimited names. + * Aligns the columns of the inline tables in a printed script: each run of rows the + * printer writes on the lines after a table's opening {@code [}, so that a comment + * whose lines look like rows is left as written. */ - private static final class Respelling { - - private static final Pattern ROW = Pattern.compile("^\\|\\s*`([^`]+)`\\s*\\|\\s*`((?:[^`\\\\]|\\\\.)+)`\\s*\\|"); - - private static final Map KEYWORDS = read(); - - private Respelling() { - } - - static String keywords(String printed) { - StringBuilder out = new StringBuilder(printed.length() + 16); - int i = 0; - while (i < printed.length()) { - char c = printed.charAt(i); - if (c == '"' || c == '\'' || c == '`') { - int end = closing(printed, i, c); - out.append(printed, i, end); - i = end; - continue; - } - if (c == '[' && i + 1 < printed.length() && printed.charAt(i + 1) == '\n') { - // An inline table: its cells are data, not operators. - int end = printed.indexOf("\n]", i); - end = end < 0 ? printed.length() : end + 2; - out.append(printed, i, end); - i = end; - continue; - } - String glyph = String.valueOf(c); - String keyword = KEYWORDS.get(glyph); - if (keyword == null) { - out.append(c); - } else { - if (!out.isEmpty() && isWordChar(out.charAt(out.length() - 1)) && isWordChar(keyword.charAt(0))) { - out.append(' '); - } - out.append(keyword); - if (i + 1 < printed.length() && isWordChar(printed.charAt(i + 1)) - && isWordChar(keyword.charAt(keyword.length() - 1))) { - out.append(' '); - } - } - i++; - } - return out.toString(); - } - - private static int closing(String text, int open, char quote) { - int i = open + 1; - while (i < text.length() && text.charAt(i) != quote) { - i += text.charAt(i) == '\\' && quote != '`' ? 2 : 1; - } - return Math.min(text.length(), i + 1); - } - - private static boolean isWordChar(char c) { - return Character.isLetterOrDigit(c) || c == '_'; - } - - /** Each row of the spellings tables that pairs one glyph with one ASCII spelling. */ - private static Map read() { - Optional page = RelixDocs.referencePage("language/spellings.md"); - Map keywords = new LinkedHashMap<>(); - for (String line : page.orElseThrow().split("\\R")) { - Matcher m = ROW.matcher(line); - if (m.find() && m.group(1).length() == 1 && m.group(1).charAt(0) > 0x7f) { - keywords.put(m.group(1), m.group(2).replace("\\|", "|")); - } - } - return Map.copyOf(keywords); - } - } - - /** Aligns the columns of the inline tables in a printed statement. */ private static final class Tables { private Tables() { @@ -235,7 +66,7 @@ static String align(String printed) { List lines = new ArrayList<>(List.of(printed.split("\n", -1))); int i = 0; while (i < lines.size()) { - if (!isRow(lines.get(i))) { + if (!isRow(lines.get(i)) || i == 0 || !lines.get(i - 1).endsWith("[")) { i++; continue; } diff --git a/src/test/java/com/darkcollective/relix/cli/command/FmtTest.java b/src/test/java/com/darkcollective/relix/cli/command/FmtTest.java index d5451b2..ac7f1f9 100644 --- a/src/test/java/com/darkcollective/relix/cli/command/FmtTest.java +++ b/src/test/java/com/darkcollective/relix/cli/command/FmtTest.java @@ -19,6 +19,7 @@ import com.darkcollective.relix.cli.Examples; import com.darkcollective.relix.embed.Relix; import com.darkcollective.relix.lang.ast.ScriptPrinter; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -141,7 +142,6 @@ class Layout { void canonical() { String text = """ -- header - namespace demo; -- the namespace @@ -158,7 +158,6 @@ void canonical() { assertThat(fmt(text)).isEqualTo(""" -- header - namespace demo; -- the namespace N := [ @@ -174,11 +173,26 @@ void canonical() { } @Test - @DisplayName("leaves a statement with a comment inside it as written") + @Disabled("the engine's ScriptPrinter drops a blank line between a comment and the " + + "statement or namespace after it (DarkCollective/relix-core#99 follow-up)") + @DisplayName("keeps a blank line between a comment and what follows it") + void blankAfterComment() { + String text = "-- header\n\nnamespace demo;\n-- about N\n\nquery N;\n"; + + assertThat(fmt(text)).isEqualTo(text); + } + + @Test + @DisplayName("formats a statement with a comment inside an expression, keeping the comment") void innerComment() { String text = "Big := {\n σ x > 1 -- only the big ones\n (N)\n};\nquery Big ;\n"; - assertThat(fmt(text)).isEqualTo("Big := {\n σ x > 1 -- only the big ones\n (N)\n};\nquery Big;\n"); + String formatted = fmt(text); + + assertThat(formatted).doesNotContain(" σ").endsWith("\nquery Big;\n"); + assertThat(comments(formatted)).containsExactly("-- only the big ones"); + assertThat(statements(formatted)).isEqualTo(statements(text)); + assertThat(fmt(formatted)).isEqualTo(formatted); } @Test From d8a338e2b4893145595f0454d8a1d9d77099f3a2 Mon Sep 17 00:00:00 2001 From: David Tooke Date: Thu, 8 Oct 2026 16:02:05 -0400 Subject: [PATCH 3/4] Print every relix optimize rewrite, now that each one parses DarkCollective/relix-core#100 made render() of an optimized relation always parse back, a seeded closure included, so optimize no longer parses each rewrite, leaves a query it cannot write as written, or warns about it. The introspection example's closure query is now rewritten, and the test checks the output with relix check; the examples' optimize runs write nothing to stderr. --- .../relix/cli/command/OptimizeCommand.java | 31 ++++--------------- .../relix/cli/command/ReportCommandsTest.java | 21 ++++++------- 2 files changed, 16 insertions(+), 36 deletions(-) diff --git a/src/main/java/com/darkcollective/relix/cli/command/OptimizeCommand.java b/src/main/java/com/darkcollective/relix/cli/command/OptimizeCommand.java index 4c3e662..41811b9 100644 --- a/src/main/java/com/darkcollective/relix/cli/command/OptimizeCommand.java +++ b/src/main/java/com/darkcollective/relix/cli/command/OptimizeCommand.java @@ -17,14 +17,11 @@ import com.darkcollective.relix.cli.CommandFailure; import com.darkcollective.relix.cli.ExitCode; -import com.darkcollective.relix.cli.io.Reporter; import com.darkcollective.relix.cli.io.RowSink; import com.darkcollective.relix.cli.io.ScriptText; import com.darkcollective.relix.cli.report.OptimizationReport; import com.darkcollective.relix.embed.Relation; -import com.darkcollective.relix.embed.Relix; import com.darkcollective.relix.lang.ast.QueryStatement; -import com.darkcollective.relix.lang.ast.ScriptParseException; import picocli.CommandLine.Command; import picocli.CommandLine.Option; @@ -38,9 +35,9 @@ * *

Only the {@code query} statements change, each to {@code query { … };} around its * rewritten expression, in which the views it reads are inlined. Every other statement, - * and every comment, is left as written. A rewrite the engine cannot write back as text - * that parses is not printed: that query is left as written too, with a warning, so the - * output is always a script that runs. With {@code --report}, what is printed instead is + * and every comment, is left as written. The engine writes every rewrite back as Relix + * that parses (DarkCollective/relix-core#100), so the output is a script that runs. With + * {@code --report}, what is printed instead is * which rules fired, and each query before and after. */ @Command(name = "optimize", @@ -65,7 +62,7 @@ ScriptRunner.View view(Invocation invocation) { out.text(OptimizationReport.generate(queries.isEmpty() ? script.session().model() : queries.getFirst().model(), entries(queries))); } else { - out.text(rewritten(script, queries, invocation.reporter())); + out.text(rewritten(script, queries)); } return ExitCode.SUCCESS; }; @@ -83,7 +80,7 @@ private static List entries(List queries) { } /** The text with each query statement replaced by its rewritten query, in order. */ - private static String rewritten(ScriptRunner.Script script, List queries, Reporter reporter) { + private static String rewritten(ScriptRunner.Script script, List queries) { String text = script.source().text(); List statements = ScriptText.of(text).statements().stream() .filter(piece -> piece.statement() instanceof QueryStatement) @@ -96,28 +93,12 @@ private static String rewritten(ScriptRunner.Script script, List queri int at = 0; for (int i = 0; i < statements.size(); i++) { ScriptText.Span span = statements.get(i).span(); - Relation query = queries.get(i); - String rewritten = "query { " + query.optimized().render() + " };"; out.append(text, at, span.start()); - if (parses(rewritten)) { - out.append(rewritten); - } else { - reporter.warning(script.source().name() + ": " + query.label().orElse("a query") - + ": the engine cannot write its rewrite as Relix that parses; left as written"); - out.append(span.of(text)); - } + out.append("query { ").append(queries.get(i).optimized().render()).append(" };"); at = span.end(); } out.append(text, at, text.length()); return out.toString(); } - private static boolean parses(String statement) { - try { - Relix.parse(statement); - return true; - } catch (ScriptParseException e) { - return false; - } - } } diff --git a/src/test/java/com/darkcollective/relix/cli/command/ReportCommandsTest.java b/src/test/java/com/darkcollective/relix/cli/command/ReportCommandsTest.java index 77ecacc..e097d05 100644 --- a/src/test/java/com/darkcollective/relix/cli/command/ReportCommandsTest.java +++ b/src/test/java/com/darkcollective/relix/cli/command/ReportCommandsTest.java @@ -94,14 +94,11 @@ class Every { @DisplayName("writes its report of each example to stdout, and nothing to stderr") void overTheExamples(String command) { for (String example : EXAMPLES) { - // optimize warns about the rewrites the engine cannot write back. var result = cli().piped().run(command, example); assertThat(result.status()).as(example + "\n" + result).isZero(); assertThat(result.out()).as(example).isNotBlank(); - if (!command.equals("optimize")) { - assertThat(result.err()).as(example).isEmpty(); - } + assertThat(result.err()).as(example).isEmpty(); } } @@ -274,15 +271,17 @@ void keepsTheRest() { } @Test - @DisplayName("leaves a query as written, with a warning, when the engine cannot write its rewrite back") - void unwritable() { - // The selection is pushed into the closure as its seed, which render() writes in - // a spelling the parser does not have (DarkCollective/relix-core#100). + @DisplayName("writes back a rewrite that seeds a closure, as a script that runs") + void seededClosure() throws IOException { + // The selection is pushed into the closure as its seed, which render() writes + // back as a selection over the closure (DarkCollective/relix-core#100). var result = cli().run("optimize", "introspection.relix"); + Files.writeString(dir.resolve("optimized.relix"), result.out(), StandardCharsets.UTF_8); - assertThat(result.status()).isZero(); - assertThat(result.err()).contains("introspection.relix: : the engine cannot write its rewrite"); - assertThat(result.out()).contains("query { π depends_on (σ dependent = \"Report\" (Lineage)) };"); + assertThat(result.status()).as(result.toString()).isZero(); + assertThat(result.err()).isEmpty(); + assertThat(result.out()).doesNotContain("query { π depends_on (σ dependent = \"Report\" (Lineage)) };"); + assertThat(cli().run("check", "optimized.relix").status()).isZero(); } @Test From ef5ffec66141edb50d0f81898d8ec23d20200bc1 Mon Sep 17 00:00:00 2001 From: David Tooke Date: Thu, 8 Oct 2026 16:03:07 -0400 Subject: [PATCH 4/4] Look relix doc's pages up in relix-docs' ReferenceLookup DarkCollective/relix-core#95 put the reference index in relix-docs: ReferenceLookup indexes the language pages and the installed functions' pages, and a language keyword keeps a name it shares with a function. That is what DocIndex built here, so DocIndex and its test go and doc uses ReferenceLookup. relix-cli has no pages of its own, so it passes none. The listing gives the functions grouped by category, as ReferenceLookup offers them, rather than in the order the libraries list them. --- docs/design.md | 3 +- .../relix/cli/command/DocCommand.java | 29 ++- .../relix/cli/render/DocIndex.java | 169 ------------------ .../relix/cli/render/DocIndexTest.java | 101 ----------- 4 files changed, 22 insertions(+), 280 deletions(-) delete mode 100644 src/main/java/com/darkcollective/relix/cli/render/DocIndex.java delete mode 100644 src/test/java/com/darkcollective/relix/cli/render/DocIndexTest.java diff --git a/docs/design.md b/docs/design.md index 8bcd6af..7fb8d72 100644 --- a/docs/design.md +++ b/docs/design.md @@ -460,7 +460,8 @@ keyword or name, across the language pages and the pages of the installed functi a language keyword keeping a shared name (`fix` is the operator; `Fix` the function is found as a function). Every program that shows the reference needs that same index, so it belongs beside the pages it indexes rather than in each front end. Filed as -DarkCollective/relix-core#95. +DarkCollective/relix-core#95, and done: `relix doc` looks pages up in relix-docs' +`ReferenceLookup`. ## 10. Decisions diff --git a/src/main/java/com/darkcollective/relix/cli/command/DocCommand.java b/src/main/java/com/darkcollective/relix/cli/command/DocCommand.java index d5ccd3a..f1dc6cc 100644 --- a/src/main/java/com/darkcollective/relix/cli/command/DocCommand.java +++ b/src/main/java/com/darkcollective/relix/cli/command/DocCommand.java @@ -19,9 +19,11 @@ import com.darkcollective.relix.cli.ExitCode; import com.darkcollective.relix.cli.io.Pager; import com.darkcollective.relix.cli.io.RowSink; -import com.darkcollective.relix.cli.render.DocIndex; import com.darkcollective.relix.cli.render.DocRenderer; +import com.darkcollective.relix.docs.ReferenceLookup; import com.darkcollective.relix.docs.ReferencePage; +import com.darkcollective.relix.docs.RelixDocs; +import com.darkcollective.relix.function.FunctionCatalog; import com.darkcollective.relix.symbol.ScalarType; import picocli.CommandLine.Command; import picocli.CommandLine.Mixin; @@ -29,6 +31,8 @@ import picocli.CommandLine.Parameters; import picocli.CommandLine.ParentCommand; +import java.util.ArrayList; +import java.util.List; import java.util.Optional; import java.util.concurrent.Callable; @@ -40,6 +44,9 @@ * function, so {@code relix doc fix} is the recursion operator and * {@code relix doc --function fix} is the function. The page is rendered for a terminal * and, on one, shown through {@code $PAGER}. With no topic, the pages are listed, as rows. + * + *

Pages are found by {@code relix-docs}' {@link ReferenceLookup}, over the language pages + * and the installed functions' pages (DarkCollective/relix-core#95). */ @Command(name = "doc", mixinStandardHelpOptions = true, @@ -75,16 +82,16 @@ final class DocCommand implements Callable { @Override public Integer call() { Invocation invocation = root.invocation(false); - DocIndex index = DocIndex.installed(); + ReferenceLookup index = ReferenceLookup.of(FunctionCatalog.discover()); if (topic == null) { list(invocation, index); return ExitCode.SUCCESS.status(); } - Optional found = function ? index.function(topic) : index.lookup(topic); + Optional found = function ? index.function(topic) : index.entry(topic); ReferencePage page = found.orElseThrow(() -> new CommandFailure(ExitCode.USAGE, "no reference page for '" + topic + "'" + (function ? " among the functions" : "") + "; relix doc" + (function ? " --function" : "") + " lists them")); - String text = index.markdown(page).orElseThrow(() -> new CommandFailure(ExitCode.INTERNAL, + String text = index.page(page).orElseThrow(() -> new CommandFailure(ExitCode.INTERNAL, "the reference lists " + page.path() + " but has no page there")); RowSink out = new RowSink(invocation.host().out(), false); if (!markdown) { @@ -98,13 +105,17 @@ public Integer call() { return ExitCode.SUCCESS.status(); } - private void list(Invocation invocation, DocIndex index) { + /** The language pages, in the reference's order, unless --function; then the functions'. */ + private void list(Invocation invocation, ReferenceLookup index) { Listing listing = new Listing("topic", ScalarType.STRING, "category", ScalarType.STRING, "title", ScalarType.STRING, "summary", ScalarType.STRING); - for (ReferencePage page : index.pages()) { - if (!function || page.category().equals(DocIndex.FUNCTION)) { - listing.add(page.symbol(), page.category(), page.title(), page.summary()); - } + List pages = new ArrayList<>(); + if (!function) { + pages.addAll(RelixDocs.referencePages()); + } + index.functionsByCategory().values().forEach(pages::addAll); + for (ReferencePage page : pages) { + listing.add(page.symbol(), page.category(), page.title(), page.summary()); } listing.print(invocation, invocation.format(formatting), "reference"); } diff --git a/src/main/java/com/darkcollective/relix/cli/render/DocIndex.java b/src/main/java/com/darkcollective/relix/cli/render/DocIndex.java deleted file mode 100644 index 3d61718..0000000 --- a/src/main/java/com/darkcollective/relix/cli/render/DocIndex.java +++ /dev/null @@ -1,169 +0,0 @@ -/* - * Copyright 2026 Darkcollective, LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.darkcollective.relix.cli.render; - -import com.darkcollective.relix.docs.ReferencePage; -import com.darkcollective.relix.docs.RelixDocs; -import com.darkcollective.relix.function.FunctionCatalog; -import com.darkcollective.relix.function.ScalarFunction; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -/** - * The reference pages {@code relix doc} can show, and how each is found: the language - * pages {@code relix-docs} carries, then a page per documented function of every installed - * library. - * - *

A page is found by any of its keys, case-insensitively: {@code σ}, {@code select} - * and {@code selection} are one page. A language page keeps a key it shares with a - * function, so {@code fix} is the recursion operator; the function is still found by - * asking for a function ({@link #function(String)}). - * - *

This index belongs in {@code relix-docs}, beside the pages it indexes, and moves - * there with DarkCollective/relix-core#95; it is the same index the REPL's front end - * builds. - */ -public final class DocIndex { - - /** The category a function's page is listed under. */ - public static final String FUNCTION = "function"; - - /** {@code # Name: Len (string length)}: a function page's own one-line summary. */ - private static final Pattern TITLE = - Pattern.compile("^#\\s*Name:\\s*(\\S+)\\s*\\(([^)]*)\\)", Pattern.MULTILINE); - - private final FunctionCatalog functions; - private final List pages = new ArrayList<>(); - private final Map byKey = new HashMap<>(); - private final Map byFunction = new LinkedHashMap<>(); - - private DocIndex(FunctionCatalog functions) { - this.functions = Objects.requireNonNull(functions, "functions"); - for (ReferencePage page : RelixDocs.referencePages()) { - add(page); - } - for (ScalarFunction function : functions.scalars()) { - String docKey = function.signature().docKey().orElse(null); - if (docKey == null) { - continue; - } - Optional markdown = functions.documentation(docKey); - if (markdown.isEmpty()) { - continue; - } - String name = function.signature().name(); - ReferencePage page = new ReferencePage(docKey, FUNCTION, name, name, - summary(markdown.get(), name), List.of(normalise(name))); - byFunction.putIfAbsent(normalise(name), page); - add(page); - } - } - - /** - * The index over the language pages and the functions of every installed library. - * - * @return the index - */ - public static DocIndex installed() { - return of(FunctionCatalog.discover()); - } - - /** - * The index over the language pages and the functions of {@code functions}. - * - * @param functions the libraries whose functions' pages are indexed - * @return the index - */ - public static DocIndex of(FunctionCatalog functions) { - return new DocIndex(functions); - } - - /** - * Every page, language pages first, in the reference's own order, then the functions - * in the order their libraries offer them. - * - * @return the pages - */ - public List pages() { - return List.copyOf(pages); - } - - /** - * The page found by a key: a glyph, a keyword or a name. - * - * @param key what to look up, in any case - * @return the page - */ - public Optional lookup(String key) { - return Optional.ofNullable(byKey.get(normalise(key))); - } - - /** - * A function's page, by its name, even where a language page has that key. - * - * @param name the function's name, in any case - * @return its page - */ - public Optional function(String name) { - return Optional.ofNullable(byFunction.get(normalise(name))); - } - - /** - * A page's markdown, from whichever source holds it. - * - * @param page a page of this index - * @return its markdown - */ - public Optional markdown(ReferencePage page) { - return page.category().equals(FUNCTION) - ? functions.documentation(page.path()) - : RelixDocs.referencePage(page.path()); - } - - /** Adds a page under each of its keys that no earlier page has claimed. */ - private void add(ReferencePage page) { - pages.add(page); - for (String key : page.keys()) { - byKey.putIfAbsent(normalise(key), page); - } - } - - /** - * The parenthetical of a page's {@code # Name:} heading, capitalised, or the - * function's name when it has none. - */ - private static String summary(String markdown, String name) { - Matcher m = TITLE.matcher(markdown); - if (!m.find() || m.group(2).isBlank()) { - return name; - } - String summary = m.group(2).strip(); - return Character.toUpperCase(summary.charAt(0)) + summary.substring(1); - } - - private static String normalise(String key) { - return key.strip().toLowerCase(Locale.ROOT); - } -} diff --git a/src/test/java/com/darkcollective/relix/cli/render/DocIndexTest.java b/src/test/java/com/darkcollective/relix/cli/render/DocIndexTest.java deleted file mode 100644 index 32bb020..0000000 --- a/src/test/java/com/darkcollective/relix/cli/render/DocIndexTest.java +++ /dev/null @@ -1,101 +0,0 @@ -/* - * Copyright 2026 Darkcollective, LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.darkcollective.relix.cli.render; - -import com.darkcollective.relix.docs.ReferencePage; -import com.darkcollective.relix.docs.RelixDocs; -import com.darkcollective.relix.function.FunctionCatalog; -import com.darkcollective.relix.function.ScalarFunction; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import java.util.ArrayList; -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * The index {@code relix doc} looks pages up in: the same keys as the REPL's index in - * {@code relix-console}, which is {@code relix-docs}' language pages and every documented - * function, less the REPL's own page about itself. - */ -@DisplayName("The reference index") -class DocIndexTest { - - private final DocIndex index = DocIndex.installed(); - - @Test - @DisplayName("finds a page by every key of every language page") - void languageKeys() { - List missing = new ArrayList<>(); - for (ReferencePage page : RelixDocs.referencePages()) { - for (String key : page.keys()) { - if (index.lookup(key).flatMap(index::markdown).isEmpty()) { - missing.add(page.path() + ": " + key); - } - } - } - assertThat(missing).isEmpty(); - } - - @Test - @DisplayName("finds every documented function, by name, in either case") - void functions() { - FunctionCatalog catalog = FunctionCatalog.discover(); - List documented = new ArrayList<>(); - for (ScalarFunction function : catalog.scalars()) { - function.signature().docKey().filter(key -> catalog.documentation(key).isPresent()) - .ifPresent(key -> documented.add(function.signature().name())); - } - - assertThat(documented).isNotEmpty().allSatisfy(name -> { - assertThat(index.function(name)).as(name).isPresent(); - assertThat(index.function(name.toUpperCase()).flatMap(index::markdown)).as(name).isPresent(); - assertThat(index.lookup(name)).as(name).isPresent(); - }); - } - - @Test - @DisplayName("finds one page by glyph, keyword and name") - void spellings() { - assertThat(index.lookup("σ")).isPresent() - .isEqualTo(index.lookup("select")).isEqualTo(index.lookup("SELECTION")); - } - - @Test - @DisplayName("gives a shared name to the language keyword, and still finds the function as a function") - void collision() { - assertThat(index.lookup("fix").orElseThrow().category()).isNotEqualTo(DocIndex.FUNCTION); - assertThat(index.function("fix").orElseThrow().category()).isEqualTo(DocIndex.FUNCTION); - assertThat(index.function("fix").flatMap(index::markdown).orElseThrow()).contains("Fix"); - } - - @Test - @DisplayName("summarises a function page by its heading's parenthetical") - void summary() { - ReferencePage fix = index.function("fix").orElseThrow(); - - assertThat(fix.summary()).isNotBlank().isNotEqualTo(fix.title()); - assertThat(Character.isUpperCase(fix.summary().charAt(0))).isTrue(); - } - - @Test - @DisplayName("finds nothing for a key no page has") - void unknown() { - assertThat(index.lookup("no-such-topic")).isEmpty(); - assertThat(index.function("select")).isEmpty(); - } -}