diff --git a/docs/design.md b/docs/design.md index 61ad2c2..7fb8d72 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 | @@ -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/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/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/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/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/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 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 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 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(); - } -}