From 6e413ed106509cca12fbbaf3d77badcc56e55e5e Mon Sep 17 00:00:00 2001 From: David Tooke Date: Thu, 8 Oct 2026 12:10:38 -0400 Subject: [PATCH 1/5] Add the report commands: check, explain, optimize, trace, bundle, ir, provenance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each view of a script is now a command that writes one thing to stdout. They read scripts as run does, with the catalog and -i inputs, through one loop (ScriptRunner) that takes each script through its session, its inputs and analysis and hands it to the command's view; run's rows are one such view. - check: nothing on stdout, diagnostics on stderr, exit 0 or 3, so `find … | xargs relix check` is a lint pass. --json writes every script's diagnostics as one document instead. - explain: each query's physical plan; --json writes one object per query per line. - optimize: the script with each query statement replaced by its rewritten query, every other statement and comment kept as written, so it works as a filter. A rewrite the engine cannot render as Relix that parses back is left as written, with a warning. --report prints the rules and the trees. - trace: the event feed of each query, rows discarded, each query's feed ending in a ROWS event. - bundle: the playground's JSON bundle, still produced for a script that does not analyse. - ir: the IR report of the whole script. - provenance: annotated rows, --semiring and --weight; a table on a terminal and JSON in a pipe. `run --trace[=FILE]` writes the event feed to stderr, or FILE, while the rows go to stdout. A file is named only with `=`, so `relix --trace q.relix` traces q.relix rather than writing over it. The example scripts the tests run every command over are in src/test/resources/examples. Part of #11. --- .gitattributes | 2 + .../com/darkcollective/relix/cli/Main.java | 27 +- .../relix/cli/command/BundleCommand.java | 107 +++++ .../relix/cli/command/CheckCommand.java | 117 +++++ .../relix/cli/command/ExplainCommand.java | 91 ++++ .../relix/cli/command/IrCommand.java | 47 ++ .../relix/cli/command/OptimizeCommand.java | 123 +++++ .../relix/cli/command/ProvenanceCommand.java | 112 +++++ .../relix/cli/command/RelixCommand.java | 59 ++- .../relix/cli/command/ResultRows.java | 141 ++++++ .../relix/cli/command/RunCommand.java | 5 +- .../relix/cli/command/ScriptCommand.java | 70 +++ .../relix/cli/command/ScriptRunner.java | 226 +++++---- .../relix/cli/command/TraceCommand.java | 72 +++ .../relix/cli/command/TraceOption.java | 68 +++ .../darkcollective/relix/cli/io/RowSink.java | 16 + .../relix/cli/io/ScriptText.java | 283 +++++++++++ .../relix/cli/report/EventFeed.java | 147 ++++++ .../darkcollective/relix/cli/Examples.java | 75 +++ .../relix/cli/command/ReportCommandsTest.java | 448 ++++++++++++++++++ .../resources/examples/introspection.relix | 64 +++ src/test/resources/examples/league.relix | 158 ++++++ src/test/resources/examples/library.relix | 99 ++++ src/test/resources/examples/pokemon.relix | 242 ++++++++++ 24 files changed, 2693 insertions(+), 106 deletions(-) create mode 100644 src/main/java/com/darkcollective/relix/cli/command/BundleCommand.java create mode 100644 src/main/java/com/darkcollective/relix/cli/command/CheckCommand.java create mode 100644 src/main/java/com/darkcollective/relix/cli/command/ExplainCommand.java create mode 100644 src/main/java/com/darkcollective/relix/cli/command/IrCommand.java create mode 100644 src/main/java/com/darkcollective/relix/cli/command/OptimizeCommand.java create mode 100644 src/main/java/com/darkcollective/relix/cli/command/ProvenanceCommand.java create mode 100644 src/main/java/com/darkcollective/relix/cli/command/ResultRows.java create mode 100644 src/main/java/com/darkcollective/relix/cli/command/ScriptCommand.java create mode 100644 src/main/java/com/darkcollective/relix/cli/command/TraceCommand.java create mode 100644 src/main/java/com/darkcollective/relix/cli/command/TraceOption.java create mode 100644 src/main/java/com/darkcollective/relix/cli/io/ScriptText.java create mode 100644 src/main/java/com/darkcollective/relix/cli/report/EventFeed.java create mode 100644 src/test/java/com/darkcollective/relix/cli/Examples.java create mode 100644 src/test/java/com/darkcollective/relix/cli/command/ReportCommandsTest.java create mode 100644 src/test/resources/examples/introspection.relix create mode 100644 src/test/resources/examples/league.relix create mode 100644 src/test/resources/examples/library.relix create mode 100644 src/test/resources/examples/pokemon.relix diff --git a/.gitattributes b/.gitattributes index 4a97af3..958cc52 100644 --- a/.gitattributes +++ b/.gitattributes @@ -7,6 +7,8 @@ gradlew text eol=lf *.bat text eol=crlf *.cmd text eol=crlf *.ps1 text eol=crlf +# Relix scripts: the tests compare what fmt and optimize write with them byte for byte. +*.relix text eol=lf # Binary files. *.jar binary diff --git a/src/main/java/com/darkcollective/relix/cli/Main.java b/src/main/java/com/darkcollective/relix/cli/Main.java index 750c7f0..e4d6a2e 100644 --- a/src/main/java/com/darkcollective/relix/cli/Main.java +++ b/src/main/java/com/darkcollective/relix/cli/Main.java @@ -32,6 +32,9 @@ */ public final class Main { + /** {@code run}'s option whose file is attached with {@code =} only. */ + private static final String TRACE = "--trace"; + private Main() { } @@ -97,12 +100,34 @@ public static int run(Host host, Interruption interruption, String... args) { err.flush(); return code.status(); }); - int status = command.execute(args); + int status = command.execute(attachOptionalValues(args)); out.flush(); err.flush(); return status; } + /** + * The command line with a bare {@code --trace} given an empty value, so that the word + * after it is never taken for its file: {@code relix --trace q.relix} traces + * {@code q.relix}, and a file is named only as {@code --trace=FILE}. Nothing after + * {@code --} is touched. + * + * @param args the command line + * @return the command line to parse + */ + static String[] attachOptionalValues(String[] args) { + String[] attached = args.clone(); + for (int i = 0; i < attached.length; i++) { + if (attached[i].equals("--")) { + break; + } + if (attached[i].equals(TRACE)) { + attached[i] = TRACE + "="; + } + } + return attached; + } + /** * The exit code for a failure that escaped a command. * diff --git a/src/main/java/com/darkcollective/relix/cli/command/BundleCommand.java b/src/main/java/com/darkcollective/relix/cli/command/BundleCommand.java new file mode 100644 index 0000000..e62cfa4 --- /dev/null +++ b/src/main/java/com/darkcollective/relix/cli/command/BundleCommand.java @@ -0,0 +1,107 @@ +/* + * 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.command; + +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.report.QueryBundleJson; +import com.darkcollective.relix.embed.Diagnostic; +import com.darkcollective.relix.embed.Relation; +import com.darkcollective.relix.embed.RelixException; +import com.darkcollective.relix.semantic.SemanticError; +import picocli.CommandLine.Command; + +import java.util.ArrayList; +import java.util.List; + +/** + * {@code relix bundle}: the playground's JSON bundle of each script, its diagnostics and, + * per query, the logical plan, the rewrites, the physical plan and the planner's events + * (design §3). + * + *

A script that does not analyse still has a bundle, holding its diagnostics: a tool + * reading it wants to know what is wrong, and the exit status is still 3. A query whose + * physical plan cannot be made is bundled without one, and the reason goes to standard + * error. + */ +@Command(name = "bundle", + mixinStandardHelpOptions = true, + sortOptions = false, + usageHelpAutoWidth = true, + description = { + "Prints each script's JSON bundle: diagnostics and, per query, the logical plan,", + "the rewrites, the physical plan and the planner's events. A script that does", + "not analyse still has one, holding its diagnostics."}) +final class BundleCommand extends ScriptCommand { + + @Override + ScriptRunner.View view(Invocation invocation) { + RowSink out = stdout(invocation); + Reporter reporter = invocation.reporter(); + return new ScriptRunner.View() { + @Override + public ExitCode show(ScriptRunner.Script script) { + List queries = queries(script); + List bundled = new ArrayList<>(); + for (Relation relation : queries) { + Relation optimized = relation.optimized(); + String label = relation.label().orElse(""); + String physical; + try { + physical = optimized.explainJson(); + } catch (RelixException e) { + reporter.error(script.source().name() + ": " + label + ": no physical plan: " + + e.getMessage()); + physical = null; + } + bundled.add(new QueryBundleJson.Query(label, relation.renderJson(), + optimized.rewrites(), physical, optimized.events())); + } + List diagnostics = script.diagnostics().stream() + .map(d -> error(script, d)) + .toList(); + out.text(QueryBundleJson.generate(queries.isEmpty() + ? script.session().model() : queries.getFirst().model(), diagnostics, bundled) + "\n"); + return ExitCode.SUCCESS; + } + + @Override + public boolean showsBroken() { + return true; + } + }; + } + + /** A broken script's queries cannot all be built; its bundle then holds none. */ + private static List queries(ScriptRunner.Script script) { + if (!script.broken()) { + return script.queries(); + } + try { + return script.queries(); + } catch (RelixException e) { + return List.of(); + } + } + + /** A diagnostic back in the analyser's own shape, placed where the user wrote it. */ + private static SemanticError error(ScriptRunner.Script script, Diagnostic diagnostic) { + Reporter.Place place = script.source().place(diagnostic.location()); + return new SemanticError(place.file(), place.line(), place.column(), diagnostic.message(), + diagnostic.severity()); + } +} diff --git a/src/main/java/com/darkcollective/relix/cli/command/CheckCommand.java b/src/main/java/com/darkcollective/relix/cli/command/CheckCommand.java new file mode 100644 index 0000000..1126bed --- /dev/null +++ b/src/main/java/com/darkcollective/relix/cli/command/CheckCommand.java @@ -0,0 +1,117 @@ +/* + * 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.command; + +import com.darkcollective.relix.cli.ExitCode; +import com.darkcollective.relix.cli.io.Reporter; +import com.darkcollective.relix.embed.Diagnostic; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; +import tools.jackson.core.JsonGenerator; +import tools.jackson.core.ObjectWriteContext; +import tools.jackson.core.json.JsonFactory; + +import java.io.StringWriter; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/** + * {@code relix check}: analyses scripts without running them, as a lint pass (design + * §3.4, §3.5). Nothing goes to standard output: the diagnostics go to standard error, or, + * with {@code --json}, to standard output as one document. + */ +@Command(name = "check", + mixinStandardHelpOptions = true, + sortOptions = false, + usageHelpAutoWidth = true, + description = { + "Analyses scripts without running them, and reports what is wrong with them", + "on standard error as FILE:LINE:COL: severity: message.", + " find . -name '*.relix' -print0 | xargs -0 relix check"}, + exitCodeListHeading = "%nExit status:%n", + exitCodeList = {" 0:every script analyses", " 3:a script does not parse or analyse"}) +final class CheckCommand extends ScriptCommand { + + private static final JsonFactory JSON = new JsonFactory(); + + @Option(names = "--json", + description = "Write the diagnostics to standard output as one JSON document instead.") + boolean json; + + @Override + ScriptRunner.View view(Invocation invocation) { + return new ScriptRunner.View() { + + private final List found = new ArrayList<>(); + + @Override + public ExitCode show(ScriptRunner.Script script) { + for (Diagnostic diagnostic : script.diagnostics()) { + found.add(new Placed(script.source().place(diagnostic.location()), diagnostic)); + } + return ExitCode.SUCCESS; + } + + @Override + public boolean showsBroken() { + return true; + } + + @Override + public boolean reportsDiagnostics() { + return !json; + } + + @Override + public ExitCode finish(ExitCode result) { + if (json) { + stdout(invocation).text(document(found)); + } + return result; + } + }; + } + + /** A diagnostic and where it falls, in the terms the user wrote. */ + private record Placed(Reporter.Place place, Diagnostic diagnostic) { + } + + /** + * {@code {"diagnostics": [{"file", "line", "column", "severity", "message"}, …]}}, the + * line and column 0 when unknown. + */ + private static String document(List found) { + StringWriter out = new StringWriter(); + try (JsonGenerator w = JSON.createGenerator(ObjectWriteContext.empty(), out)) { + w.writeStartObject(); + w.writeName("diagnostics").writeStartArray(); + for (Placed placed : found) { + w.writeStartObject() + .writeName("file").writeString(placed.place().file()) + .writeName("line").writeNumber(placed.place().line()) + .writeName("column").writeNumber(placed.place().column()) + .writeName("severity").writeString( + placed.diagnostic().severity().name().toLowerCase(Locale.ROOT)) + .writeName("message").writeString(placed.diagnostic().message()) + .writeEndObject(); + } + w.writeEndArray(); + w.writeEndObject(); + } + return out + "\n"; + } +} diff --git a/src/main/java/com/darkcollective/relix/cli/command/ExplainCommand.java b/src/main/java/com/darkcollective/relix/cli/command/ExplainCommand.java new file mode 100644 index 0000000..75fb584 --- /dev/null +++ b/src/main/java/com/darkcollective/relix/cli/command/ExplainCommand.java @@ -0,0 +1,91 @@ +/* + * 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.command; + +import com.darkcollective.relix.cli.ExitCode; +import com.darkcollective.relix.cli.io.RowSink; +import com.darkcollective.relix.embed.Relation; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; +import tools.jackson.core.JsonGenerator; +import tools.jackson.core.ObjectWriteContext; +import tools.jackson.core.json.JsonFactory; + +import java.io.StringWriter; +import java.util.List; + +/** + * {@code relix explain}: each query's physical plan, the tree the engine would run + * (design §3). + * + *

As text, a script with several queries prints each plan under a {@code -- query NAME} + * line. With {@code --json}, each query is one line of its own, {@code {"script", "query", + * "plan"}}, so a stream of plans reads as NDJSON. + */ +@Command(name = "explain", + mixinStandardHelpOptions = true, + sortOptions = false, + usageHelpAutoWidth = true, + description = "Prints the physical plan of each query: how the engine would run it.") +final class ExplainCommand extends ScriptCommand { + + private static final JsonFactory JSON = new JsonFactory(); + + @Option(names = "--json", + description = "Write one JSON object per query, {\"script\", \"query\", \"plan\"}, one per line.") + boolean json; + + @Option(names = "--query", paramLabel = "NAME", + description = "Explain only the query NAME.") + String query; + + @Override + ScriptRunner.View view(Invocation invocation) { + RowSink out = stdout(invocation); + return script -> { + List queries = script.queries(query); + StringBuilder text = new StringBuilder(); + for (Relation relation : queries) { + if (json) { + text.append(line(script.source().name(), relation)).append('\n'); + } else { + if (queries.size() > 1) { + text.append("-- query ").append(relation.label().orElse("")).append('\n'); + } + text.append(relation.explain().stripTrailing()).append('\n'); + if (queries.size() > 1 && relation != queries.getLast()) { + text.append('\n'); + } + } + } + out.text(text.toString()); + return ExitCode.SUCCESS; + }; + } + + private static String line(String script, Relation relation) { + StringWriter out = new StringWriter(); + try (JsonGenerator w = JSON.createGenerator(ObjectWriteContext.empty(), out)) { + w.writeStartObject(); + w.writeName("script").writeString(script); + w.writeName("query"); + relation.label().ifPresentOrElse(w::writeString, w::writeNull); + w.writeName("plan").writeRawValue(relation.explainJson()); + w.writeEndObject(); + } + return out.toString(); + } +} diff --git a/src/main/java/com/darkcollective/relix/cli/command/IrCommand.java b/src/main/java/com/darkcollective/relix/cli/command/IrCommand.java new file mode 100644 index 0000000..a438f98 --- /dev/null +++ b/src/main/java/com/darkcollective/relix/cli/command/IrCommand.java @@ -0,0 +1,47 @@ +/* + * 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.command; + +import com.darkcollective.relix.cli.ExitCode; +import com.darkcollective.relix.cli.io.RowSink; +import com.darkcollective.relix.embed.Relation; +import picocli.CommandLine.Command; + +import java.util.List; + +/** + * {@code relix ir}: the IR report of each script, the symbols, views and queries the + * analyser made of it, with their headings (design §3). + */ +@Command(name = "ir", + mixinStandardHelpOptions = true, + sortOptions = false, + usageHelpAutoWidth = true, + description = "Prints the IR report: the symbols, views and queries the analyser made of each script.") +final class IrCommand extends ScriptCommand { + + @Override + ScriptRunner.View view(Invocation invocation) { + RowSink out = stdout(invocation); + return script -> { + // A relation's model is the whole script, its queries included; the session's + // is its declarations alone. + List queries = script.queries(); + out.text((queries.isEmpty() ? script.session().model() : queries.getFirst().model()).ir()); + return ExitCode.SUCCESS; + }; + } +} diff --git a/src/main/java/com/darkcollective/relix/cli/command/OptimizeCommand.java b/src/main/java/com/darkcollective/relix/cli/command/OptimizeCommand.java new file mode 100644 index 0000000..4c3e662 --- /dev/null +++ b/src/main/java/com/darkcollective/relix/cli/command/OptimizeCommand.java @@ -0,0 +1,123 @@ +/* + * 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.command; + +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; + +import java.util.ArrayList; +import java.util.List; + +/** + * {@code relix optimize}: the script, with each query rewritten as the engine's optimiser + * rewrites it, so that it works as a filter: {@code relix optimize slow.relix > fast.relix} + * (design §3). + * + *

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 + * which rules fired, and each query before and after. + */ +@Command(name = "optimize", + mixinStandardHelpOptions = true, + sortOptions = false, + usageHelpAutoWidth = true, + description = { + "Prints the script with each query rewritten by the optimiser, so that it works", + "as a filter: relix optimize slow.relix > fast.relix"}) +final class OptimizeCommand extends ScriptCommand { + + @Option(names = "--report", + description = "Print the rules that fired and each query before and after, instead.") + boolean report; + + @Override + ScriptRunner.View view(Invocation invocation) { + RowSink out = stdout(invocation); + return script -> { + List queries = script.queries(); + if (report) { + out.text(OptimizationReport.generate(queries.isEmpty() + ? script.session().model() : queries.getFirst().model(), entries(queries))); + } else { + out.text(rewritten(script, queries, invocation.reporter())); + } + return ExitCode.SUCCESS; + }; + } + + private static List entries(List queries) { + List entries = new ArrayList<>(); + for (int i = 0; i < queries.size(); i++) { + Relation written = queries.get(i); + Relation optimized = written.optimized(); + entries.add(new OptimizationReport.Entry(written.label().orElse(""), + written.node(), optimized.node(), optimized.rewrites())); + } + return entries; + } + + /** The text with each query statement replaced by its rewritten query, in order. */ + private static String rewritten(ScriptRunner.Script script, List queries, Reporter reporter) { + String text = script.source().text(); + List statements = ScriptText.of(text).statements().stream() + .filter(piece -> piece.statement() instanceof QueryStatement) + .toList(); + if (statements.size() != queries.size()) { + throw new CommandFailure(ExitCode.INTERNAL, "the script has " + statements.size() + + " query statements, but the engine returned " + queries.size() + " queries"); + } + StringBuilder out = new StringBuilder(); + 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)); + } + 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/command/ProvenanceCommand.java b/src/main/java/com/darkcollective/relix/cli/command/ProvenanceCommand.java new file mode 100644 index 0000000..069fa93 --- /dev/null +++ b/src/main/java/com/darkcollective/relix/cli/command/ProvenanceCommand.java @@ -0,0 +1,112 @@ +/* + * 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.command; + +import com.darkcollective.relix.cli.CommandFailure; +import com.darkcollective.relix.cli.ExitCode; +import com.darkcollective.relix.cli.io.RowSink; +import com.darkcollective.relix.cli.report.ProvenanceOutput; +import com.darkcollective.relix.embed.Relation; +import com.darkcollective.relix.processor.provenance.AnnotatedRelation; +import com.darkcollective.relix.provenance.Semiring; +import com.darkcollective.relix.provenance.Semirings; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.Locale; + +/** + * {@code relix provenance}: each query's rows, each annotated with where it came from in a + * semiring of the user's choice (design §3). + * + *

The annotation is never a data column. On a terminal it is a trailing {@code [prov]} + * column under a footer naming the semiring; in a pipe, or with {@code -o json}, it is a + * JSON document in which each tuple carries its {@code row} and its {@code provenance} + * under separate keys. + */ +@Command(name = "provenance", + mixinStandardHelpOptions = true, + sortOptions = false, + usageHelpAutoWidth = true, + description = { + "Runs each query and prints its rows, each annotated with where it came from", + "in a provenance semiring."}) +final class ProvenanceCommand extends ScriptCommand { + + @Option(names = "--semiring", paramLabel = "NAME", defaultValue = "counting", + description = { + "The semiring: boolean, counting, tropical, security, lineage, cheapest-route,", + "or one an installed library offers (default: ${DEFAULT-VALUE})."}) + String semiring; + + @Option(names = "--weight", paramLabel = "COLUMN", + description = { + "The column a weighted closure takes each edge's weight from, such as", + "--semiring tropical --weight cost for shortest paths."}) + String weight; + + @Option(names = {"-o", "--output"}, paramLabel = "FORMAT", + description = "table or json (default: table on a terminal, json in a pipe).") + String format; + + @Option(names = "--query", paramLabel = "NAME", + description = "Annotate only the query NAME.") + String query; + + @Override + ScriptRunner.View view(Invocation invocation) { + Semiring chosen = Semirings.byName(semiring).orElseThrow(() -> new CommandFailure(ExitCode.USAGE, + "--semiring: '" + semiring + "' is not one of " + String.join(", ", Semirings.names()))); + boolean json = json(invocation); + RowSink out = stdout(invocation); + return script -> { + StringWriter text = new StringWriter(); + PrintWriter writer = new PrintWriter(text); + for (Relation relation : script.queries(query)) { + write(writer, json, relation, chosen); + } + writer.flush(); + out.text(text.toString()); + return ExitCode.SUCCESS; + }; + } + + private boolean json(Invocation invocation) { + if (format == null) { + return !invocation.host().stdoutIsTerminal(); + } + return switch (format.strip().toLowerCase(Locale.ROOT)) { + case "table" -> false; + case "json" -> true; + default -> throw new CommandFailure(ExitCode.USAGE, "-o: '" + format + "' is not one of table, json"); + }; + } + + /** Captures the semiring's annotation type. */ + private void write(PrintWriter out, boolean json, Relation relation, Semiring semiring) { + String label = relation.label().orElse(""); + AnnotatedRelation annotated = weight == null + ? relation.provenance(semiring) + : relation.provenance(semiring, weight); + if (json) { + ProvenanceOutput.writeJson(out, label, annotated, this.semiring); + } else { + ProvenanceOutput.writeHuman(out, label, annotated, this.semiring); + } + } +} diff --git a/src/main/java/com/darkcollective/relix/cli/command/RelixCommand.java b/src/main/java/com/darkcollective/relix/cli/command/RelixCommand.java index f22da77..50b0f04 100644 --- a/src/main/java/com/darkcollective/relix/cli/command/RelixCommand.java +++ b/src/main/java/com/darkcollective/relix/cli/command/RelixCommand.java @@ -20,10 +20,12 @@ import com.darkcollective.relix.cli.io.Host; import com.darkcollective.relix.cli.io.Interruption; import com.darkcollective.relix.cli.io.ScriptSource; +import com.darkcollective.relix.cli.report.EventFeed; import picocli.CommandLine.Command; import picocli.CommandLine.IVersionProvider; import picocli.CommandLine.Mixin; +import java.util.List; import java.util.Objects; import java.util.concurrent.Callable; @@ -42,7 +44,10 @@ "Runs Relix relational-algebra scripts as a stage of a Unix pipeline.", "Rows go to standard output; everything else goes to standard error.", ""}, - subcommands = {RunCommand.class, CatalogCommand.class}, + subcommands = { + RunCommand.class, CheckCommand.class, ExplainCommand.class, OptimizeCommand.class, + TraceCommand.class, BundleCommand.class, IrCommand.class, ProvenanceCommand.class, + CatalogCommand.class}, exitCodeListHeading = "%nExit status:%n", exitCodeList = { " 0:success", @@ -68,6 +73,9 @@ public final class RelixCommand implements Callable { @Mixin OutputOptions output; + @Mixin + TraceOption trace; + private final Host host; private final Interruption interruption; @@ -84,7 +92,7 @@ public RelixCommand(Host host, Interruption interruption) { @Override public Integer call() { - return run(scripts, inputs, output); + return run(scripts, inputs, output, trace); } /** @@ -93,16 +101,53 @@ public Integer call() { * @param scripts where the scripts come from * @param inputs the data bound to relation names * @param output where their rows go + * @param trace where their event feed goes * @return the exit status */ - int run(ScriptOptions scripts, InputOptions inputs, OutputOptions output) { + int run(ScriptOptions scripts, InputOptions inputs, OutputOptions output, TraceOption trace) { Invocation invocation = invocation(true); OutputOptions.Output settled = output.settle(host, invocation.relixrc().get(Relixrc.OUTPUT)); InputOptions.Inputs bound = inputs.settle(invocation.directory(), invocation.remote()); - return new ScriptRunner(invocation, bound, settled, interruption) - .run(ScriptSource.resolve(scripts.expressions, scripts.files, host, invocation.directory(), - bound.readStdin())) - .status(); + List sources = sources(invocation, scripts, bound); + try (EventFeed feed = trace.open(host, invocation.directory())) { + return runner(invocation, bound) + .run(sources, new ResultRows(invocation, settled, interruption, feed)) + .status(); + } + } + + /** + * The scripts a command reads. + * + * @param invocation the run + * @param scripts where they come from + * @param inputs the data bound to relation names, which may take standard input + * @return the scripts, in order + */ + List sources(Invocation invocation, ScriptOptions scripts, InputOptions.Inputs inputs) { + return ScriptSource.resolve(scripts.expressions, scripts.files, host, invocation.directory(), + inputs.readStdin()); + } + + /** + * What takes a command's scripts through the catalog, the inputs and analysis. + * + * @param invocation the run + * @param inputs the data bound to relation names + * @return the runner + */ + ScriptRunner runner(Invocation invocation, InputOptions.Inputs inputs) { + return new ScriptRunner(invocation, inputs, interruption); + } + + /** The process the command runs in. */ + Host host() { + return host; + } + + /** What an interrupt stops. */ + Interruption interruption() { + return interruption; } /** diff --git a/src/main/java/com/darkcollective/relix/cli/command/ResultRows.java b/src/main/java/com/darkcollective/relix/cli/command/ResultRows.java new file mode 100644 index 0000000..49499c5 --- /dev/null +++ b/src/main/java/com/darkcollective/relix/cli/command/ResultRows.java @@ -0,0 +1,141 @@ +/* + * 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.command; + +import com.darkcollective.relix.cli.ExitCode; +import com.darkcollective.relix.cli.io.Interruption; +import com.darkcollective.relix.cli.io.Reporter; +import com.darkcollective.relix.cli.io.RowSink; +import com.darkcollective.relix.cli.report.EventFeed; +import com.darkcollective.relix.embed.Relation; +import com.darkcollective.relix.embed.Rows; +import com.darkcollective.relix.embed.Tuple; +import com.darkcollective.relix.events.QueryEvent; + +import java.time.Duration; +import java.util.List; +import java.util.stream.Stream; + +/** + * What {@code relix run} shows of a script: its queries' rows, on standard output. + * + *

A table or Markdown prints every query, each under its label. A machine format + * prints one result set, because a CSV with two headers is not a CSV: the script's last + * query, or the one {@code --query} names; {@code --all} with ndjson prints every one, + * tagged. A query not printed is not run. + * + *

With {@code --trace}, each query's event feed is written beside its rows, to standard + * error or a file, as the query runs. + */ +final class ResultRows implements ScriptRunner.View { + + private static final String TRUNCATED = "TRUNCATED"; + + private final OutputOptions.Output output; + private final Interruption interruption; + private final Reporter reporter; + private final RowSink sink; + private final EventFeed trace; + private long printed; + + /** + * The rows view. + * + * @param invocation the run + * @param output where the rows go and in what shape + * @param interruption what an interrupt stops + * @param trace where the event feed goes, or {@code null} for nowhere + */ + ResultRows(Invocation invocation, OutputOptions.Output output, Interruption interruption, EventFeed trace) { + this.output = output; + this.interruption = interruption; + this.reporter = invocation.reporter(); + this.sink = new RowSink(invocation.host().out(), output.lineBuffered()); + this.trace = trace; + } + + @Override + public ExitCode show(ScriptRunner.Script script) { + for (Relation relation : chosen(script)) { + print(script, relation); + } + return ExitCode.SUCCESS; + } + + @Override + public ExitCode finish(ExitCode result) { + if (result == ExitCode.SUCCESS && output.assertion().failedBy(printed)) { + return ExitCode.ASSERTION; + } + return result; + } + + /** The queries to print, in order. */ + private List chosen(ScriptRunner.Script script) { + List queries = script.queries(output.query()); + if (output.query() != null || output.every() || queries.isEmpty()) { + return queries; + } + return List.of(queries.getLast()); + } + + private void print(ScriptRunner.Script script, Relation relation) { + String name = script.source().name(); + long start = System.nanoTime(); + String label = relation.label().orElse(""); + String query = label.isEmpty() ? "query" : label; + var encoder = output.format().encoder(label, relation.schema(), output.options()); + boolean truncated; + long count; + if (output.format().streams() || trace != null) { + // A traced table is collected from the stream, which is what reports events. + boolean[] cut = {false}; + try (Stream rows = relation.stream(event -> { + cut[0] |= isTruncation(event); + if (trace != null) { + trace.event(event, query); + } + })) { + interruption.stream(rows); + Stream written = output.format().streams() ? rows : rows.toList().stream(); + count = sink.write(written, encoder); + } finally { + interruption.stream(null); + } + truncated = cut[0]; + } else { + // A table aligns its columns over every row, so it collects them first, and a + // result that never ends is refused rather than collected forever. + Rows rows = relation.run(); + count = sink.write(rows.rows().stream(), encoder); + truncated = rows.truncated(); + } + printed += count; + Duration elapsed = Duration.ofNanos(System.nanoTime() - start); + if (trace != null) { + trace.delivered(query, count, elapsed); + } + if (truncated) { + reporter.error(name + ": " + (label.isEmpty() ? "a query" : label) + + ": the sandbox cut the result at " + count + " rows"); + } + reporter.timing(name + ": " + query, elapsed); + } + + private static boolean isTruncation(QueryEvent event) { + return event.stage() == QueryEvent.Stage.EXECUTE && TRUNCATED.equals(event.code()); + } +} diff --git a/src/main/java/com/darkcollective/relix/cli/command/RunCommand.java b/src/main/java/com/darkcollective/relix/cli/command/RunCommand.java index 16f28fa..79aba41 100644 --- a/src/main/java/com/darkcollective/relix/cli/command/RunCommand.java +++ b/src/main/java/com/darkcollective/relix/cli/command/RunCommand.java @@ -44,8 +44,11 @@ final class RunCommand implements Callable { @Mixin OutputOptions output; + @Mixin + TraceOption trace; + @Override public Integer call() { - return parent.run(scripts, inputs, output); + return parent.run(scripts, inputs, output, trace); } } diff --git a/src/main/java/com/darkcollective/relix/cli/command/ScriptCommand.java b/src/main/java/com/darkcollective/relix/cli/command/ScriptCommand.java new file mode 100644 index 0000000..a8b270d --- /dev/null +++ b/src/main/java/com/darkcollective/relix/cli/command/ScriptCommand.java @@ -0,0 +1,70 @@ +/* + * 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.command; + +import com.darkcollective.relix.cli.io.RowSink; +import com.darkcollective.relix.cli.io.ScriptSource; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.ParentCommand; + +import java.util.List; +import java.util.concurrent.Callable; + +/** + * A command that shows something of each script it reads other than its rows: a plan, + * a rewrite, a report (design §3). The scripts come and are analysed as {@code run}'s + * do, with the catalog and the {@code -i} inputs; what is shown is the command's + * {@link ScriptRunner.View}. + */ +abstract class ScriptCommand implements Callable { + + @ParentCommand + RelixCommand root; + + @Mixin + ScriptOptions scripts; + + @Mixin + InputOptions inputs; + + @Override + public Integer call() { + Invocation invocation = root.invocation(true); + InputOptions.Inputs bound = inputs.settle(invocation.directory(), invocation.remote()); + ScriptRunner.View view = view(invocation); + List sources = root.sources(invocation, scripts, bound); + return root.runner(invocation, bound).run(sources, view).status(); + } + + /** + * What this command shows of each script. Its options are checked here, before any + * script is read. + * + * @param invocation the run + * @return the view + */ + abstract ScriptRunner.View view(Invocation invocation); + + /** + * Standard output, for a whole text at a time. + * + * @param invocation the run + * @return the sink + */ + static RowSink stdout(Invocation invocation) { + return new RowSink(invocation.host().out(), false); + } +} diff --git a/src/main/java/com/darkcollective/relix/cli/command/ScriptRunner.java b/src/main/java/com/darkcollective/relix/cli/command/ScriptRunner.java index a175922..59c28f3 100644 --- a/src/main/java/com/darkcollective/relix/cli/command/ScriptRunner.java +++ b/src/main/java/com/darkcollective/relix/cli/command/ScriptRunner.java @@ -21,37 +21,30 @@ import com.darkcollective.relix.cli.io.InputBinding; import com.darkcollective.relix.cli.io.Interruption; import com.darkcollective.relix.cli.io.Reporter; -import com.darkcollective.relix.cli.io.RowSink; import com.darkcollective.relix.cli.io.ScriptSource; import com.darkcollective.relix.embed.Diagnostic; import com.darkcollective.relix.embed.Relation; import com.darkcollective.relix.embed.Relix; import com.darkcollective.relix.embed.RelixException; -import com.darkcollective.relix.embed.Rows; -import com.darkcollective.relix.embed.Tuple; -import com.darkcollective.relix.events.QueryEvent; import java.nio.file.Path; import java.time.Duration; import java.util.List; -import java.util.stream.Stream; /** - * Runs scripts, each in a session of its own, and writes their rows to standard output. + * Takes scripts through what every command that reads Relix does before it shows anything: + * each in a session of its own, with the catalog, then the inputs, then the script + * analysed. What is then shown of a script is a {@link View}'s: rows for {@code run}, a + * plan for {@code explain}, and so on. * *

Each session first defines the catalog (design §5), then declares the inputs - * {@code -i} binds, then runs the script, so that each of these can replace a name the one - * before it declared. An input that cannot be read fails the script as execution does. + * {@code -i} binds, then analyses the script, so that each of these can replace a name the + * one before it declared. An input that cannot be read fails the script as execution does. * - *

A script is checked before it runs, and every diagnostic is reported, so a script - * with three mistakes reports three; one with an error does not run. Each script is - * independent: a failure in one does not stop the next, and the run exits with the first - * failure's code. - * - *

A table or Markdown prints every query, each under its label. A machine format - * prints one result set, because a CSV with two headers is not a CSV: the script's last - * query, or the one {@code --query} names; {@code --all} with ndjson prints every one, - * tagged. A query not printed is not run. + *

Every diagnostic is reported, so a script with three mistakes reports three; one with + * an error is not shown, unless the view shows broken scripts too, as the bundle does. Each + * script is independent: a failure in one does not stop the next, and the run exits with + * the first failure's code. * *

A script that fails for want of a name only an untrusted {@code .relix/} declares * exits 5, the environment's code, with the command that trusts it. @@ -61,40 +54,127 @@ */ final class ScriptRunner { - private static final String TRUNCATED = "TRUNCATED"; + /** What a command shows of each script. */ + interface View { + + /** + * Shows one analysed script. + * + * @param script the script, its session and its diagnostics + * @return the script's exit code + */ + ExitCode show(Script script); + + /** + * Whether a script with errors is still shown. The script's exit code is then 3 + * whatever {@link #show} returns. + * + * @return {@code true} for a view that describes a broken script too + */ + default boolean showsBroken() { + return false; + } + + /** + * Whether diagnostics are reported on standard error; not by a view that writes + * them to standard output itself. + * + * @return {@code true} to report them + */ + default boolean reportsDiagnostics() { + return true; + } + + /** + * Ends the run, once every script has been shown. + * + * @param result the exit code of the scripts, the first failure's + * @return the run's exit code + */ + default ExitCode finish(ExitCode result) { + return result; + } + } + + /** + * One script, analysed: its session holds the catalog and the inputs. + * + * @param source where it came from + * @param session its session + * @param diagnostics what analysing it found + */ + record Script(ScriptSource source, Relix session, List diagnostics) { + + /** + * Whether analysing it found an error. + * + * @return {@code true} when a diagnostic is an error + */ + boolean broken() { + return diagnostics.stream().anyMatch(Diagnostic::isError); + } + + /** + * Its queries, in order. + * + * @return one relation per {@code query} statement + */ + List queries() { + return session.script(source.text()); + } + + /** + * The queries a view shows: the one {@code --query} names, or every one. + * + * @param name the query to show, or {@code null} for all + * @return the queries + * @throws CommandFailure with {@link ExitCode#USAGE} when no query has that name + */ + List queries(String name) { + List queries = queries(); + if (name == null) { + return queries; + } + List named = queries.stream() + .filter(q -> q.label().filter(name::equals).isPresent()) + .toList(); + if (named.isEmpty()) { + throw new CommandFailure(ExitCode.USAGE, source.name() + ": no query named '" + name + "'"); + } + return named; + } + } private final Invocation invocation; private final InputOptions.Inputs inputs; - private final OutputOptions.Output output; private final Interruption interruption; - private final RowSink sink; - private long printed; - ScriptRunner(Invocation invocation, InputOptions.Inputs inputs, OutputOptions.Output output, - Interruption interruption) { + ScriptRunner(Invocation invocation, InputOptions.Inputs inputs, Interruption interruption) { this.invocation = invocation; this.inputs = inputs; - this.output = output; this.interruption = interruption; - this.sink = new RowSink(invocation.host().out(), output.lineBuffered()); } - ExitCode run(List sources) { + /** + * Shows every script through a view. + * + * @param sources the scripts, in order + * @param view what to show of each + * @return the run's exit code + */ + ExitCode run(List sources, View view) { invocation.loadInstalledDrivers(); ExitCode result = ExitCode.SUCCESS; for (ScriptSource source : sources) { - ExitCode code = run(source); + ExitCode code = run(source, view); if (result == ExitCode.SUCCESS) { result = code; } } - if (result == ExitCode.SUCCESS && output.assertion().failedBy(printed)) { - return ExitCode.ASSERTION; - } - return result; + return view.finish(result); } - private ExitCode run(ScriptSource source) { + private ExitCode run(ScriptSource source, View view) { Reporter reporter = invocation.reporter(); try (Relix session = invocation.session(source.directory()).build()) { interruption.session(session); @@ -107,29 +187,31 @@ private ExitCode run(ScriptSource source) { long start = System.nanoTime(); List diagnostics = session.validate(source.text()); reporter.timing(source.name() + ": analysed", Duration.ofNanos(System.nanoTime() - start)); - boolean errors = false; - for (Diagnostic diagnostic : diagnostics) { - reporter.diagnostic(source.place(diagnostic.location()), diagnostic); - errors |= diagnostic.isError(); - } - if (errors) { - return withheld(diagnostics) ? ExitCode.ENVIRONMENT : ExitCode.ANALYSIS; - } - List queries = chosen(session.script(source.text())); - if (queries == null) { - reporter.error(source.name() + ": no query named '" + output.query() + "'"); - return ExitCode.USAGE; + if (view.reportsDiagnostics()) { + for (Diagnostic diagnostic : diagnostics) { + reporter.diagnostic(source.place(diagnostic.location()), diagnostic); + } } - for (Relation relation : queries) { - print(source, relation); + Script script = new Script(source, session, diagnostics); + if (script.broken()) { + ExitCode code = withheld(diagnostics) ? ExitCode.ENVIRONMENT : ExitCode.ANALYSIS; + if (view.showsBroken()) { + view.show(script); + } + return code; } - return ExitCode.SUCCESS; + return view.show(script); } catch (RuntimeException e) { // Closing the session under a running query fails it in whatever way it fails; // once interrupted, every such failure is the interrupt's. if (interruption.interrupted()) { throw new CommandFailure(ExitCode.INTERRUPTED, null); } + if (e instanceof CommandFailure failure && failure.exitCode() == ExitCode.USAGE + && failure.getMessage() != null) { + reporter.error(failure.getMessage()); + return ExitCode.USAGE; + } if (e instanceof RelixException failure) { reporter.error(source.name() + ": " + failure.getMessage()); return ExitCode.of(failure); @@ -199,54 +281,4 @@ private boolean declareInputs(ScriptSource source, Relix session) { } return true; } - - /** The queries to print, in order, or {@code null} when the one named is not there. */ - private List chosen(List queries) { - if (output.query() != null) { - List named = queries.stream() - .filter(q -> q.label().filter(output.query()::equals).isPresent()) - .toList(); - return named.isEmpty() ? null : named; - } - if (output.every() || queries.isEmpty()) { - return queries; - } - return List.of(queries.getLast()); - } - - private void print(ScriptSource source, Relation relation) { - Reporter reporter = invocation.reporter(); - long start = System.nanoTime(); - String label = relation.label().orElse(""); - var encoder = output.format().encoder(label, relation.schema(), output.options()); - boolean truncated; - long count; - if (output.format().streams()) { - boolean[] cut = {false}; - try (Stream rows = relation.stream(event -> cut[0] |= isTruncation(event))) { - interruption.stream(rows); - count = sink.write(rows, encoder); - } finally { - interruption.stream(null); - } - truncated = cut[0]; - } else { - // A table aligns its columns over every row, so it collects them first, and a - // result that never ends is refused rather than collected forever. - Rows rows = relation.run(); - count = sink.write(rows.rows().stream(), encoder); - truncated = rows.truncated(); - } - printed += count; - if (truncated) { - reporter.error(source.name() + ": " + (label.isEmpty() ? "a query" : label) - + ": the sandbox cut the result at " + count + " rows"); - } - reporter.timing(source.name() + ": " + (label.isEmpty() ? "query" : label), - Duration.ofNanos(System.nanoTime() - start)); - } - - private static boolean isTruncation(QueryEvent event) { - return event.stage() == QueryEvent.Stage.EXECUTE && TRUNCATED.equals(event.code()); - } } diff --git a/src/main/java/com/darkcollective/relix/cli/command/TraceCommand.java b/src/main/java/com/darkcollective/relix/cli/command/TraceCommand.java new file mode 100644 index 0000000..4ab5392 --- /dev/null +++ b/src/main/java/com/darkcollective/relix/cli/command/TraceCommand.java @@ -0,0 +1,72 @@ +/* + * 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.command; + +import com.darkcollective.relix.cli.ExitCode; +import com.darkcollective.relix.cli.io.Interruption; +import com.darkcollective.relix.cli.report.EventFeed; +import com.darkcollective.relix.embed.Relation; +import com.darkcollective.relix.embed.Tuple; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; + +import java.time.Duration; +import java.util.stream.Stream; + +/** + * {@code relix trace}: runs each query for its event feed, what the engine rewrote, + * planned and ran, and discards its rows (design §3). {@code relix run --trace} writes the + * same feed beside the rows. + * + *

The rows are pulled and dropped one at a time, never collected, so tracing a large + * result costs no more memory than running it. + */ +@Command(name = "trace", + mixinStandardHelpOptions = true, + sortOptions = false, + usageHelpAutoWidth = true, + description = { + "Runs each query and prints its event feed, what the engine rewrote, planned", + "and ran, discarding the rows. relix run --trace keeps the rows too."}) +final class TraceCommand extends ScriptCommand { + + @Option(names = "--query", paramLabel = "NAME", + description = "Trace only the query NAME.") + String query; + + @Override + ScriptRunner.View view(Invocation invocation) { + EventFeed feed = EventFeed.stdout(invocation.host().out()); + Interruption interruption = root.interruption(); + return script -> { + for (Relation relation : script.queries(query)) { + String label = relation.label().orElse("query"); + long start = System.nanoTime(); + long delivered = 0; + try (Stream rows = relation.stream(event -> feed.event(event, label))) { + interruption.stream(rows); + for (var it = rows.iterator(); it.hasNext(); it.next()) { + delivered++; + } + } finally { + interruption.stream(null); + } + feed.delivered(label, delivered, Duration.ofNanos(System.nanoTime() - start)); + } + return ExitCode.SUCCESS; + }; + } +} diff --git a/src/main/java/com/darkcollective/relix/cli/command/TraceOption.java b/src/main/java/com/darkcollective/relix/cli/command/TraceOption.java new file mode 100644 index 0000000..c382107 --- /dev/null +++ b/src/main/java/com/darkcollective/relix/cli/command/TraceOption.java @@ -0,0 +1,68 @@ +/* + * 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.command; + +import com.darkcollective.relix.cli.CommandFailure; +import com.darkcollective.relix.cli.ExitCode; +import com.darkcollective.relix.cli.io.Host; +import com.darkcollective.relix.cli.report.EventFeed; +import picocli.CommandLine.Option; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * {@code run --trace[=FILE]}: the event feed, written beside the rows (design §4). + * + *

The file is attached with {@code =} only, so that {@code relix --trace q.relix} traces + * {@code q.relix} rather than writing its trace over it; {@code Main} makes a bare + * {@code --trace} an empty value before the line is parsed. + */ +final class TraceOption { + + /** The option's name. */ + static final String NAME = "--trace"; + + @Option(names = NAME, paramLabel = "FILE", + description = { + "Write the event feed (what was rewritten, planned and run) to standard", + "error, or to FILE with --trace=FILE, while the rows go to standard output."}) + String trace; + + /** + * Where the feed goes, if anywhere. + * + * @param host the process, for standard error + * @param directory where a relative FILE is + * @return the feed, or {@code null} without {@code --trace}; a file is opened here, and + * closing the feed closes it + */ + EventFeed open(Host host, Path directory) { + if (trace == null) { + return null; + } + if (trace.isEmpty()) { + return EventFeed.stderr(host.err()); + } + Path file = directory.resolve(trace); + try { + return EventFeed.file(Files.newOutputStream(file)); + } catch (IOException e) { + throw new CommandFailure(ExitCode.USAGE, NAME + "=" + trace + ": " + e.getMessage()); + } + } +} diff --git a/src/main/java/com/darkcollective/relix/cli/io/RowSink.java b/src/main/java/com/darkcollective/relix/cli/io/RowSink.java index 6ce3381..ff090b3 100644 --- a/src/main/java/com/darkcollective/relix/cli/io/RowSink.java +++ b/src/main/java/com/darkcollective/relix/cli/io/RowSink.java @@ -90,6 +90,22 @@ public long write(Stream rows, OutputFormat.Encoder encoder) { return count; } + /** + * Writes a whole text, such as a report or a plan, and flushes it. + * + * @param text the text + * @throws CommandFailure with {@link ExitCode#PIPE_CLOSED} when standard output has + * gone away + */ + public void text(String text) { + try { + write(text); + out.flush(); + } catch (IOException e) { + throw new CommandFailure(ExitCode.PIPE_CLOSED, null); + } + } + private void write(String text) throws IOException { if (!text.isEmpty()) { out.write(text.getBytes(StandardCharsets.UTF_8)); diff --git a/src/main/java/com/darkcollective/relix/cli/io/ScriptText.java b/src/main/java/com/darkcollective/relix/cli/io/ScriptText.java new file mode 100644 index 0000000..978b48b --- /dev/null +++ b/src/main/java/com/darkcollective/relix/cli/io/ScriptText.java @@ -0,0 +1,283 @@ +/* + * 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.io; + +import com.darkcollective.relix.ast.SourceLocation; +import com.darkcollective.relix.embed.Relix; +import com.darkcollective.relix.lang.ast.Script; +import com.darkcollective.relix.lang.ast.Statement; + +import java.util.ArrayList; +import java.util.List; +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. + * + *

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 + * 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. + */ +public final class ScriptText { + + /** + * A stretch of the text, from {@code start} up to but not including {@code end}. + * + * @param start the offset of its first character + * @param end the offset just after its last + */ + public record Span(int start, int end) { + + /** + * The text this stretch covers. + * + * @param text the whole text + * @return the stretch of it + */ + public String of(String text) { + return text.substring(start, end); + } + } + + /** + * One statement and where it is written. + * + * @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) { + + /** Checks every component and copies the comments. */ + 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) { + this.text = text; + this.script = script; + this.pieces = List.copyOf(pieces); + } + + /** + * Parses a script and places its statements in its text. + * + * @param text the script + * @return the script and its layout + * @throws com.darkcollective.relix.embed.RelixException when the text does not parse + */ + public static ScriptText of(String text) { + Script script = Relix.parse(text); + int[] lines = lineStarts(text); + List pieces = new ArrayList<>(); + for (Statement statement : script.statements()) { + 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)); + } + return new ScriptText(text, script, pieces); + } + + /** {@return the text} */ + 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); + for (int i = 0; i < text.length(); i++) { + if (text.charAt(i) == '\n') { + starts.add(i + 1); + } + } + return starts.stream().mapToInt(Integer::intValue).toArray(); + } + + private static int offset(SourceLocation location, int[] lines, String text) { + int line = Math.max(1, Math.min(location.line(), lines.length)); + return Math.min(text.length(), lines[line - 1] + Math.max(0, location.column() - 1)); + } + + /** + * 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). + */ + private static final class Scan { + + 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) { + this.text = text; + this.position = start; + } + + /** Reads up to and including the semicolon that ends the statement. */ + void toEndOfStatement() { + while (position < text.length()) { + if (stack.getLast() == Context.TOP && text.charAt(position) == ';') { + position++; + return; + } + step(); + } + } + + /** 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(); + if (comment(context)) { + return; + } + switch (c) { + case '"' -> quoted('"'); + case '\'' -> { + // A quote is a string in an expression; in a table it is a cell's text. + if (context == Context.BRACE) { + quoted('\''); + } else { + position++; + } + } + case '`' -> { + if (context == Context.BRACKET) { + position++; + } else { + quoted('`'); + } + } + case '{' -> { + if (context != Context.BRACKET) { + stack.add(Context.BRACE); + } + position++; + } + case '}' -> { + if (context == Context.BRACE) { + stack.removeLast(); + } + position++; + } + case '[' -> { + // Brackets inside an expression are an array, not a table. + if (context != Context.BRACE) { + stack.add(Context.BRACKET); + } + position++; + } + case ']' -> { + if (context == Context.BRACKET) { + stack.removeLast(); + } + position++; + } + default -> position++; + } + } + + /** Reads a comment starting here, if one does. */ + private boolean comment(Context context) { + if (!text.startsWith("--", position) && !text.startsWith("/*", position)) { + return false; + } + 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; + } else { + int end = text.indexOf("*/", position + 2); + position = end < 0 ? text.length() : end + 2; + } + comments.add(new Span(start, position)); + return true; + } + + private boolean atLineStart() { + for (int i = position - 1; i >= 0; i--) { + char c = text.charAt(i); + if (c == '\n') { + return true; + } + if (c != ' ' && c != '\t' && c != '\r') { + return false; + } + } + return true; + } + + private void quoted(char quote) { + position++; + while (position < text.length() && text.charAt(position) != quote) { + if (text.charAt(position) == '\\' && quote != '`') { + position++; + } + position++; + } + position = Math.min(text.length(), position + 1); + } + } +} diff --git a/src/main/java/com/darkcollective/relix/cli/report/EventFeed.java b/src/main/java/com/darkcollective/relix/cli/report/EventFeed.java new file mode 100644 index 0000000..e20d2ef --- /dev/null +++ b/src/main/java/com/darkcollective/relix/cli/report/EventFeed.java @@ -0,0 +1,147 @@ +/* + * 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.report; + +import com.darkcollective.relix.cli.CommandFailure; +import com.darkcollective.relix.cli.ExitCode; +import com.darkcollective.relix.events.EventMetrics; +import com.darkcollective.relix.events.QueryEvent; + +import java.io.OutputStream; +import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Locale; +import java.util.Objects; + +/** + * The event feed: what the engine reports while it rewrites, plans and runs a query, one + * line per event, written as each event happens. + * + *

A line reads {@code [STAGE ] target CODE description}, with the time the event + * measured after it when it measured one. An event that names no target names the query + * it belongs to. A query's feed ends with a {@code ROWS} event of the engine's own shape, + * saying how many rows it delivered and how long that took. + * + *

{@code relix trace} writes the feed to standard output, and {@code relix run --trace} + * to standard error or a file, beside the rows. + */ +public final class EventFeed implements AutoCloseable { + + private final PrintWriter out; + private final boolean stdout; + private final boolean owned; + + private EventFeed(OutputStream out, boolean stdout, boolean owned) { + this.out = new PrintWriter(Objects.requireNonNull(out, "out"), false, StandardCharsets.UTF_8); + this.stdout = stdout; + this.owned = owned; + } + + /** + * A feed on standard output, whose reader going away ends the run with + * {@link ExitCode#PIPE_CLOSED}. + * + * @param out standard output + * @return the feed + */ + public static EventFeed stdout(OutputStream out) { + return new EventFeed(out, true, false); + } + + /** + * A feed on standard error, which closing the feed leaves open. + * + * @param err standard error + * @return the feed + */ + public static EventFeed stderr(OutputStream err) { + return new EventFeed(err, false, false); + } + + /** + * A feed written to a file, which closing the feed closes. + * + * @param file the file, open + * @return the feed + */ + public static EventFeed file(OutputStream file) { + return new EventFeed(file, false, true); + } + + /** + * Writes one event. + * + * @param event the event + * @param query the label of the query it belongs to, for an event that names no target + */ + public void event(QueryEvent event, String query) { + write(line(event.target().isPresent() ? event : event.withTarget(query))); + } + + /** + * Writes the event that ends a query's feed. + * + * @param query the query's label + * @param delivered how many rows it delivered + * @param elapsed how long it took + */ + public void delivered(String query, long delivered, Duration elapsed) { + event(QueryEvent.of(QueryEvent.Stage.EXECUTE, "ROWS", + "query delivered " + delivered + " row" + (delivered == 1 ? "" : "s"), query) + .withMetrics(EventMetrics.of(delivered, elapsed)), query); + } + + /** + * One event as a line of the feed, without its line break. + * + * @param event the event + * @return the line + */ + public static String line(QueryEvent event) { + String target = event.target().map(t -> " " + t).orElse(""); + return String.format(Locale.ROOT, "[%-8s]%s %s %s%s", + event.stage(), target, event.code(), event.description(), elapsed(event)); + } + + /** + * The time an event measured, as a trailing {@code (12.3 ms)}, or nothing for an + * event that measured none. It is rendered from the event's metrics rather than its + * description, which stays free of wall-clock readings a test could not assert on. + */ + private static String elapsed(QueryEvent event) { + return event.metrics().duration() + .map(d -> String.format(Locale.ROOT, " (%.1f ms)", d.toNanos() / 1_000_000.0)) + .orElse(""); + } + + /** Flushes the feed, and closes it when it is a file's. */ + @Override + public void close() { + out.flush(); + if (owned) { + out.close(); + } + } + + private void write(String line) { + out.print(line + "\n"); + out.flush(); + if (stdout && out.checkError()) { + throw new CommandFailure(ExitCode.PIPE_CLOSED, null); + } + } +} diff --git a/src/test/java/com/darkcollective/relix/cli/Examples.java b/src/test/java/com/darkcollective/relix/cli/Examples.java new file mode 100644 index 0000000..a4b261c --- /dev/null +++ b/src/test/java/com/darkcollective/relix/cli/Examples.java @@ -0,0 +1,75 @@ +/* + * 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; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** + * The example scripts under {@code src/test/resources/examples}: real scripts, with + * comments, inline tables, views and several queries each, that need nothing but + * themselves to run. + */ +public final class Examples { + + /** Every example, by file name. */ + public static final List NAMES = + List.of("introspection.relix", "league.relix", "library.relix", "pokemon.relix"); + + private Examples() { + } + + /** + * One example's text. + * + * @param name its file name + * @return the script + */ + public static String text(String name) { + try (InputStream in = Examples.class.getResourceAsStream("/examples/" + name)) { + if (in == null) { + throw new IllegalArgumentException("no example " + name); + } + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + /** + * Copies every example into a directory. + * + * @param directory where to put them + * @return their paths, in {@link #NAMES}' order + */ + public static List copyTo(Path directory) { + List copies = new ArrayList<>(); + for (String name : NAMES) { + try { + copies.add(Files.writeString(directory.resolve(name), text(name), StandardCharsets.UTF_8)); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + return copies; + } +} diff --git a/src/test/java/com/darkcollective/relix/cli/command/ReportCommandsTest.java b/src/test/java/com/darkcollective/relix/cli/command/ReportCommandsTest.java new file mode 100644 index 0000000..d30b894 --- /dev/null +++ b/src/test/java/com/darkcollective/relix/cli/command/ReportCommandsTest.java @@ -0,0 +1,448 @@ +/* + * 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.command; + +import com.darkcollective.relix.cli.Cli; +import com.darkcollective.relix.cli.Examples; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.FieldSource; +import org.junit.jupiter.params.provider.ValueSource; +import tools.jackson.core.JsonParser; +import tools.jackson.core.JsonToken; +import tools.jackson.core.ObjectReadContext; +import tools.jackson.core.json.JsonFactory; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The report commands, {@code check}, {@code explain}, {@code optimize}, {@code trace}, + * {@code bundle}, {@code ir} and {@code provenance}, and {@code run --trace} (design §3, + * §4), end to end through {@code Main} over the example scripts. + */ +@DisplayName("Report commands") +class ReportCommandsTest { + + static final List EXAMPLES = Examples.NAMES; + + /** A script whose second statement names a relation nothing declares. */ + private static final String BROKEN = """ + N := [ + | x | + |---| + | 1 | + ]; + Bad := { σ x > 1 (Missing) }; + query Bad; + """; + + private static final JsonFactory JSON = new JsonFactory(); + + @TempDir + Path dir; + + @BeforeEach + void examples() throws IOException { + Examples.copyTo(dir); + Files.writeString(dir.resolve("broken.relix"), BROKEN, StandardCharsets.UTF_8); + } + + private Cli cli() { + return Cli.in(dir); + } + + /** Whether {@code text} is one JSON value and nothing else. */ + private static boolean isJson(String text) { + try (JsonParser parser = JSON.createParser(ObjectReadContext.empty(), text)) { + parser.nextToken(); + parser.skipChildren(); + return parser.nextToken() == null; + } catch (RuntimeException e) { + return false; + } + } + + @Nested + @DisplayName("every report command") + class Every { + + @ParameterizedTest(name = "{0}") + @ValueSource(strings = {"explain", "optimize", "trace", "bundle", "ir", "provenance"}) + @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(); + } + } + } + + @ParameterizedTest(name = "{0}") + @ValueSource(strings = {"check", "explain", "optimize", "trace", "ir", "provenance"}) + @DisplayName("prints nothing for a script that does not analyse, reports it, and exits 3") + void broken(String command) { + var result = cli().run(command, "broken.relix"); + + assertThat(result.status()).as(result.toString()).isEqualTo(3); + assertThat(result.out()).isEmpty(); + assertThat(result.err()).contains("broken.relix:6:").contains("Missing"); + } + + @ParameterizedTest(name = "{0}") + @ValueSource(strings = {"check", "explain", "optimize", "trace", "bundle", "ir", "provenance"}) + @DisplayName("reads -e and standard input as run does") + void sources(String command) { + var fromE = cli().run(command, "-e", "λ 2 (relix.functions)"); + var fromStdin = cli().stdin("query { λ 2 (relix.functions) };").run(command); + + assertThat(fromE.status()).as(fromE.toString()).isZero(); + assertThat(fromStdin.status()).as(fromStdin.toString()).isZero(); + } + + @ParameterizedTest(name = "{0}") + @ValueSource(strings = {"explain", "trace", "provenance"}) + @DisplayName("--query names one query, and an unknown one is a usage error") + void query(String command) { + var one = cli().run(command, "--query", "LondonBorrowers", "library.relix"); + var unknown = cli().run(command, "--query", "Nope", "library.relix"); + + assertThat(one.status()).as(one.toString()).isZero(); + assertThat(unknown.status()).isEqualTo(2); + assertThat(unknown.err()).contains("library.relix: no query named 'Nope'"); + } + } + + @Nested + @DisplayName("check") + class Check { + + @Test + @DisplayName("is silent and exits 0 over scripts that analyse") + void clean() { + var result = cli().run(new String[] {"check", "introspection.relix", "league.relix", + "library.relix", "pokemon.relix"}); + + assertThat(result.status()).as(result.toString()).isZero(); + assertThat(result.out()).isEmpty(); + assertThat(result.err()).isEmpty(); + } + + @Test + @DisplayName("as xargs runs it, exits 3 when one file of many is broken, naming only that file") + void xargs() { + var result = cli().run("check", "league.relix", "broken.relix", "library.relix"); + + assertThat(result.status()).isEqualTo(3); + assertThat(result.out()).isEmpty(); + assertThat(result.err()).isEqualTo("broken.relix:6:19: error: Undefined relation: 'Missing'\n"); + } + + @Test + @DisplayName("--json writes every script's diagnostics to stdout as one document, and nothing to stderr") + void json() { + var result = cli().run("check", "--json", "league.relix", "broken.relix"); + + assertThat(result.status()).isEqualTo(3); + assertThat(result.err()).isEmpty(); + assertThat(isJson(result.out())).as(result.out()).isTrue(); + assertThat(result.out()).isEqualTo("{\"diagnostics\":[{\"file\":\"broken.relix\",\"line\":6," + + "\"column\":19,\"severity\":\"error\",\"message\":\"Undefined relation: 'Missing'\"}]}\n"); + } + + @Test + @DisplayName("--json over a clean script is an empty list") + void jsonClean() { + var result = cli().run("check", "--json", "league.relix"); + + assertThat(result.status()).isZero(); + assertThat(result.out()).isEqualTo("{\"diagnostics\":[]}\n"); + } + + @Test + @DisplayName("places a diagnostic in an -e fragment") + void expression() { + var result = cli().run("check", "-e", "σ x > 1 (Missing)"); + + assertThat(result.status()).isEqualTo(3); + assertThat(result.err()).startsWith("-e:1:"); + } + } + + @Nested + @DisplayName("explain") + class Explain { + + @Test + @DisplayName("prints one query's plan as the engine draws it") + void one() { + var result = cli().run("explain", "--query", "LondonBorrowers", "library.relix"); + + assertThat(result.out()).startsWith("Join SEMI/HASH").contains("Scan Members").contains("Scan Loans") + .doesNotContain("-- query"); + } + + @Test + @DisplayName("heads each plan with its query when there are several") + void several() { + var result = cli().run("explain", "library.relix"); + + assertThat(result.out()).contains("-- query OnLoanDetails\n").contains("-- query LondonBorrowers\n"); + } + + @Test + @DisplayName("--json writes one object per query per line") + void json() { + var result = cli().run("explain", "--json", "library.relix"); + + List lines = result.out().lines().toList(); + assertThat(lines).hasSize(7).allSatisfy(line -> assertThat(isJson(line)).as(line).isTrue()); + assertThat(lines.getLast()).startsWith("{\"script\":\"library.relix\",\"query\":\"LondonBorrowers\"," + + "\"plan\":{\"op\":\"Join\""); + } + } + + @Nested + @DisplayName("optimize") + class Optimize { + + @ParameterizedTest(name = "{0}") + @FieldSource("com.darkcollective.relix.cli.command.ReportCommandsTest#EXAMPLES") + @DisplayName("writes a script that returns what the original does") + void sameRows(String example) throws IOException { + var optimized = cli().run("optimize", example); + Files.writeString(dir.resolve("optimized.relix"), optimized.out(), StandardCharsets.UTF_8); + + var before = cli().run("-o", "ndjson", "--all", example); + var after = cli().run("-o", "ndjson", "--all", "optimized.relix"); + + assertThat(optimized.status()).as(optimized.toString()).isZero(); + assertThat(after.status()).as(after.toString()).isZero(); + assertThat(untagged(after.out())).isEqualTo(untagged(before.out())); + } + + /** The rows without the label each is tagged with, which a rewritten query may lose. */ + private static String untagged(String ndjson) { + return ndjson.replaceAll("\\{\"_query\":\"[^\"]*\",", "{"); + } + + @Test + @DisplayName("rewrites the query statements, and keeps every other statement and comment as written") + void keepsTheRest() { + var result = cli().run("optimize", "library.relix"); + String original = Examples.text("library.relix"); + + assertThat(result.out()).contains("query { ρ LondonBorrowers (").doesNotContain("query LondonBorrowers;"); + String declarations = original.substring(0, original.indexOf("query OnLoanDetails;")); + assertThat(result.out()).startsWith(declarations); + } + + @Test + @DisplayName("leaves a query as written, with a warning, when the engine cannot write its rewrite back") + void unwritable() { + var result = cli().run("optimize", "pokemon.relix"); + + assertThat(result.status()).isZero(); + assertThat(result.err()).contains("pokemon.relix: PichuFuture: the engine cannot write its rewrite"); + assertThat(result.out()).contains("query PichuFuture;"); + } + + @Test + @DisplayName("works as a filter on standard input") + void filter() { + var result = cli().stdin("N := [\n| x |\n|---|\n| 1 |\n];\nquery { π x (σ x > 0 (N)) };\n") + .run("optimize"); + + assertThat(result.status()).as(result.toString()).isZero(); + assertThat(result.out()).startsWith("N := [\n").contains("query { ").endsWith(" };\n"); + } + + @Test + @DisplayName("--report prints the rules that fired and the trees before and after") + void report() { + var result = cli().run("optimize", "--report", "library.relix"); + + assertThat(result.out()).contains("RELIX OPTIMIZER namespace=library") + .contains("INLINE-001").contains("── LondonBorrowers").contains("before:").contains("after:"); + } + } + + @Nested + @DisplayName("trace") + class Trace { + + @Test + @DisplayName("prints each query's events and ends each with the rows it delivered, not the rows") + void feed() { + var result = cli().run("trace", "--query", "LondonBorrowers", "library.relix"); + + assertThat(result.out()).contains("[OPTIMIZE] query[1] INLINE-001 view 'LondonBorrowers' inlined") + .contains("[EXECUTE ] LondonBorrowers ROWS query delivered 3 rows") + .doesNotContain("Alice"); + } + + @Test + @DisplayName("ends the feed of every query of the script") + void everyQuery() { + var result = cli().run("trace", "library.relix"); + + assertThat(result.out().lines().filter(l -> l.contains(" ROWS "))).hasSize(7); + } + } + + @Nested + @DisplayName("run --trace") + class RunTrace { + + @Test + @DisplayName("writes the rows to stdout and the events to stderr in one run") + void stderr() { + var result = cli().piped().run("--trace", "--query", "LondonBorrowers", "library.relix"); + + assertThat(result.status()).as(result.toString()).isZero(); + assertThat(result.out()).isEqualTo("member_id\tname\tcity\tjoined\n1\tAlice\tLondon\t2019\n" + + "3\tCarol\tLondon\t2021\n5\tEve\tLondon\t2022\n"); + assertThat(result.err()).contains("[PLAN ] LondonBorrowers JOIN") + .contains("ROWS query delivered 3 rows"); + } + + @Test + @DisplayName("traces a table too") + void table() { + var result = cli().run("run", "--trace", "--query", "LondonBorrowers", "library.relix"); + + assertThat(result.out()).contains("Alice").contains("(3 rows)"); + assertThat(result.err()).contains("ROWS query delivered 3 rows"); + } + + @Test + @DisplayName("--trace=FILE writes the events to FILE, and a bare --trace never takes the script for its file") + void file() throws IOException { + var result = cli().piped().run("run", "--trace=events.log", "--query", "LondonBorrowers", + "library.relix"); + + assertThat(result.status()).as(result.toString()).isZero(); + assertThat(result.out()).contains("Alice"); + assertThat(result.err()).isEmpty(); + assertThat(Files.readString(dir.resolve("events.log"))).contains("ROWS query delivered 3 rows"); + assertThat(Files.readString(dir.resolve("library.relix"))).isEqualTo(Examples.text("library.relix")); + } + + @Test + @DisplayName("is run's alone: trace has no --trace") + void runOnly() { + var result = cli().run("explain", "--trace", "library.relix"); + + assertThat(result.status()).isEqualTo(2); + } + } + + @Nested + @DisplayName("bundle") + class Bundle { + + @Test + @DisplayName("is one JSON document per script, with each query's plans") + void document() { + var result = cli().run("bundle", "library.relix"); + + assertThat(isJson(result.out())).isTrue(); + assertThat(result.out()).startsWith("{\"namespace\":\"library\",\"diagnostics\":[],\"queries\":[") + .contains("\"label\":\"LondonBorrowers\"").contains("\"physicalPlan\":{\"op\":"); + } + + @Test + @DisplayName("is still produced for a script that does not analyse, carrying its diagnostics, and exits 3") + void broken() { + var result = cli().run("bundle", "broken.relix"); + + assertThat(result.status()).isEqualTo(3); + assertThat(isJson(result.out())).as(result.out()).isTrue(); + assertThat(result.out()).contains("\"diagnostics\":[{\"severity\":\"error\"," + + "\"message\":\"Undefined relation: 'Missing'\",\"file\":\"broken.relix\",\"line\":6,\"column\":19}]"); + } + } + + @Nested + @DisplayName("ir") + class Ir { + + @Test + @DisplayName("prints the IR report of the whole script") + void report() { + var result = cli().run("ir", "library.relix"); + + assertThat(result.out()).contains("RELIX IR namespace=library").contains("LondonBorrowers"); + } + } + + @Nested + @DisplayName("provenance") + class Provenance { + + @Test + @DisplayName("annotates each row in a [prov] column on a terminal, counting by default") + void table() { + var result = cli().run("provenance", "--query", "LondonBorrowers", "library.relix"); + + assertThat(result.out()).contains("[prov]").contains("(3 tuples; provenance semiring: counting)"); + } + + @Test + @DisplayName("writes JSON in a pipe, with the annotation beside each row, never in it") + void json() { + var result = cli().piped().run("provenance", "--semiring", "lineage", "--query", "LondonBorrowers", + "library.relix"); + + assertThat(isJson(result.out())).as(result.out()).isTrue(); + assertThat(result.out()).contains("\"semiring\": \"lineage\"") + .contains("{\"row\": {\"member_id\": 1, \"name\": \"Alice\"").contains("}, \"provenance\": {"); + } + + @Test + @DisplayName("-o table and -o json choose; another format is a usage error") + void format() { + assertThat(cli().piped().run("provenance", "-o", "table", "--query", "LondonBorrowers", + "library.relix").out()).contains("[prov]"); + assertThat(cli().run("provenance", "-o", "json", "--query", "LondonBorrowers", + "library.relix").out()).startsWith("{"); + assertThat(cli().run("provenance", "-o", "csv", "library.relix").status()).isEqualTo(2); + } + + @Test + @DisplayName("an unknown semiring is a usage error that lists the ones there are") + void unknownSemiring() { + var result = cli().run("provenance", "--semiring", "nope", "library.relix"); + + assertThat(result.status()).isEqualTo(2); + assertThat(result.err()).contains("'nope' is not one of").contains("counting").contains("lineage"); + } + } +} diff --git a/src/test/resources/examples/introspection.relix b/src/test/resources/examples/introspection.relix new file mode 100644 index 0000000..3593147 --- /dev/null +++ b/src/test/resources/examples/introspection.relix @@ -0,0 +1,64 @@ +-- ─── Introspection ─────────────────────────────────────────────────────────── +-- relix describes itself. The reserved `relix` namespace exposes read-only +-- catalog relations you can query like any other relation (no new syntax): +-- +-- relix.relations every relation (name, kind, namespace, materialization) +-- relix.columns every column (relation, column, type, ordinal) +-- relix.functions every function (name, category, arity, return_type, …) +-- relix.dependencies lineage edges (dependent, depends_on) +-- relix.plan the logical expression tree, one row per IR node +-- (query, node_id, parent_id, ordinal, op, label, …) +-- +-- Run: relix --exec examples/introspection.relix +-- See: relix help catalog + +namespace shop; + +-- ─── A small schema to introspect ───────────────────────────────────────────── + +Customers := [| customer_id | name | city | + | 1 | Alice | London | + | 2 | Bob | Leeds | + | 3 | Carol | London | + ]; + +Orders := [| order_id | customer_id | amount | status | + | 1 | 1 | 120 | completed | + | 2 | 1 | 80 | completed | + | 3 | 2 | 50 | pending | + | 4 | 3 | 200 | completed | + ]; + +-- A little chain of views, so lineage has something to walk. +Completed := { σ status = "completed" (Orders) }; +PerCustomer := { γ customer_id, SUM(amount) → spent (Completed) }; +TopSpenders := { τ spent DESC (PerCustomer) }; +Report := { π name, spent (TopSpenders ⋈ Customers) }; + +-- ─── Introspection queries ──────────────────────────────────────────────────── + +-- 1. The catalogue of relations in this script. +query { τ kind, name (relix.relations) }; + +-- 2. Every column of every relation — the information_schema.columns analogue. +query { τ relation, ordinal (relix.columns) }; + +-- 3. Which functions are safe to fold / cache (pure AND deterministic)? +query { τ name (σ pure = "true" ∧ deterministic = "true" (relix.functions)) }; + +-- 4. Direct lineage edges: which relation reads which. +query { relix.dependencies }; + +-- 5. Transitive lineage — everything `Report` (transitively) reads from. +-- relix.dependencies holds the direct edges; CLOSURE makes them transitive +-- (the recursive-CTE problem, in one line). +Lineage := { CLOSURE dependent, depends_on (relix.dependencies) }; +query { π depends_on (σ dependent = "Report" (Lineage)) }; + +-- 6. IR size per view — which queries are the most complex? +-- relix.plan turns each view's expression tree into queryable rows. +query { γ query, COUNT(node_id) → nodes (relix.plan) }; + +-- 7. The expression tree of `Report`, in node order — the queryable form of the +-- ASCII `:tree`. parent_id wires each node to its parent (NULL at the root). +query { τ node_id (π node_id, parent_id, op, label (σ query = "Report" (relix.plan))) }; diff --git a/src/test/resources/examples/league.relix b/src/test/resources/examples/league.relix new file mode 100644 index 0000000..2d80940 --- /dev/null +++ b/src/test/resources/examples/league.relix @@ -0,0 +1,158 @@ +-- ─── Five-a-Side League ────────────────────────────────────────────────────── +-- A fictional five-a-side football league, three rounds played so far. +-- Run: relix --exec examples/league.relix + +namespace league; + +-- ─── Master data ───────────────────────────────────────────────────────────── + +Teams := [| team_id | name | city | founded | + | 1 | Red Rockets | London | 2010 | + | 2 | Blue Bolts | Manchester | 2008 | + | 3 | Green Giants | Birmingham | 2015 | + | 4 | Yellow Yellows | Leeds | 2012 | + | 5 | Purple Pythons | Bristol | 2018 | + ]; + +Players := [| player_id | name | team_id | position | age | + | 1 | Sam Shaw | 1 | striker | 28 | + | 2 | Tom Turner | 1 | keeper | 31 | + | 3 | Lena Lopez | 2 | striker | 25 | + | 4 | Kai Kim | 2 | mid | 27 | + | 5 | Ravi Patel | 3 | striker | 24 | + | 6 | Mia Moore | 3 | mid | 29 | + | 7 | Joe Jones | 4 | striker | 33 | + | 8 | Ana Anand | 4 | keeper | 26 | + | 9 | Ben Baker | 5 | striker | 22 | + | 10 | Zoe Zhang | 5 | mid | 30 | + ]; + +-- Each match records home and away teams, goals scored, and the round number +Matches := [| match_id | home_id | away_id | home_goals | away_goals | round | + | 1 | 1 | 2 | 3 | 1 | 1 | + | 2 | 3 | 4 | 0 | 2 | 1 | + | 3 | 5 | 1 | 1 | 1 | 1 | + | 4 | 2 | 3 | 2 | 2 | 2 | + | 5 | 4 | 5 | 3 | 0 | 2 | + | 6 | 1 | 3 | 2 | 0 | 2 | + | 7 | 2 | 4 | 1 | 1 | 3 | + | 8 | 5 | 3 | 2 | 3 | 3 | + | 9 | 1 | 4 | 4 | 2 | 3 | + ]; + +Goals := [| goal_id | match_id | player_id | minute | + | 1 | 1 | 1 | 12 | + | 2 | 1 | 1 | 34 | + | 3 | 1 | 4 | 45 | + | 4 | 1 | 1 | 67 | + | 5 | 2 | 7 | 23 | + | 6 | 2 | 7 | 55 | + | 7 | 3 | 9 | 30 | + | 8 | 3 | 1 | 72 | + | 9 | 4 | 3 | 18 | + | 10 | 4 | 3 | 60 | + | 11 | 4 | 5 | 40 | + | 12 | 4 | 6 | 80 | + | 13 | 5 | 7 | 15 | + | 14 | 5 | 7 | 44 | + | 15 | 5 | 7 | 70 | + | 16 | 6 | 1 | 25 | + | 17 | 6 | 2 | 58 | -- keepers get on the scoresheet too + | 18 | 7 | 3 | 37 | + | 19 | 7 | 7 | 62 | + | 20 | 8 | 9 | 10 | + | 21 | 8 | 5 | 33 | + | 22 | 8 | 6 | 50 | + | 23 | 8 | 9 | 62 | + | 24 | 8 | 5 | 88 | + | 25 | 9 | 1 | 8 | + | 26 | 9 | 1 | 29 | + | 27 | 9 | 7 | 41 | + | 28 | 9 | 1 | 63 | + | 29 | 9 | 1 | 77 | + | 30 | 9 | 8 | 55 | -- and so does the other one + ]; + +-- ─── Derived relations ─────────────────────────────────────────────────────── + +-- Rename match columns so we can distinguish home vs away in joins +HomeView := { ρ HomeView(match_id, team_id, goals_for, goals_against, round) ( + π match_id, home_id, home_goals, away_goals, round (Matches) +) }; + +AwayView := { ρ AwayView(match_id, team_id, goals_for, goals_against, round) ( + π match_id, away_id, away_goals, home_goals, round (Matches) +) }; + +-- All results from each team's perspective +AllResults := { HomeView ∪ AwayView }; + +-- ─── Queries ───────────────────────────────────────────────────────────────── + +-- Top scorers (players with at least 2 goals), sorted descending +GoalTally := { γ player_id, COUNT(goal_id) → goals (Goals) }; +TopScorers := { + τ goals DESC ( + π name, goals ( + σ goals >= 2 (GoalTally ⋈ Players) + ) + ) +}; + +-- Rename GoalTally so scorer_id ≠ player_id; this lets the semi/anti-join +-- predicate resolve the two sides unambiguously (qualifier stripping is safe +-- only when the column names differ between left and right). +ScoredBy := { ρ ScoredBy(scorer_id, goals) (GoalTally) }; + +-- Strikers who have scored at least one goal (semi-join) +StrikersScoredAGoal := { + σ position = "striker" (Players) + ⋉ Players.player_id = ScoredBy.scorer_id + ScoredBy +}; + +-- Players who have NOT scored yet (anti-join) +NotYetScored := { + Players ▷ Players.player_id = ScoredBy.scorer_id ScoredBy +}; + +-- Goals per team (Goals → Players gives team_id; join Teams for names) +-- Rename Teams.name → team_name to avoid clashing with Players.name in the join +TeamInfo := { ρ TeamInfo(team_id, team_name, city, founded) (Teams) }; + +TeamGoals := { + γ team_id, COUNT(goal_id) → team_goals ( + Goals ⋈ Players + ) +}; +TeamGoalsNamed := { π team_name, team_goals (TeamGoals ⋈ TeamInfo) }; + +-- League standings: union home + away perspectives, then aggregate +-- (HomeView ∪ AwayView already unified above as AllResults) +TeamRecord := { + γ team_id, COUNT(match_id) → played, + SUM(goals_for) → scored, SUM(goals_against) → conceded + (AllResults) +}; +Standings := { + τ scored DESC ( + π team_name, played, scored, conceded (TeamInfo ⋈ TeamRecord) + ) +}; + +-- Teams that have played in round 3 (semi-join; home_id ≠ team_id — no clash) +Round3Teams := { π home_id (σ round = 3 (Matches)) }; +PlayedRound3 := { + Teams ⋉ Teams.team_id = Round3Teams.home_id Round3Teams +}; + +-- Distinct cities represented in the league +LeagueCities := { δ (π city (Teams)) }; + +query TopScorers; +query StrikersScoredAGoal; +query NotYetScored; +query TeamGoalsNamed; +query Standings; +query PlayedRound3; +query LeagueCities; diff --git a/src/test/resources/examples/library.relix b/src/test/resources/examples/library.relix new file mode 100644 index 0000000..1c894db --- /dev/null +++ b/src/test/resources/examples/library.relix @@ -0,0 +1,99 @@ +-- ─── Library ──────────────────────────────────────────────────────────────── +-- A small book-lending library. All data is inline — no external sources. +-- Run: relix --exec examples/library.relix + +namespace library; + +-- ─── Catalogue ─────────────────────────────────────────────────────────────── + +Books := [| book_id | title | author | genre | year | pages | + | 1 | Dune | Frank Herbert | sci-fi | 1965 | 688 | + | 2 | Foundation | Isaac Asimov | sci-fi | 1951 | 244 | + | 3 | Neuromancer | William Gibson | sci-fi | 1984 | 271 | + | 4 | Shogun | James Clavell | fiction | 1975 | 1210 | + | 5 | The Name of the Rose | Umberto Eco | mystery | 1980 | 502 | + | 6 | Hyperion | Dan Simmons | sci-fi | 1989 | 482 | + | 7 | Foucault's Pendulum | Umberto Eco | fiction | 1988 | 641 | + | 8 | The Left Hand of Darkness| Ursula K. Le Guin | sci-fi | 1969 | 286 | + | 9 | Snow Crash | Neal Stephenson | sci-fi | 1992 | 440 | + ]; + +Members := [| member_id | name | city | joined | + | 1 | Alice | London | 2019 | + | 2 | Bob | Manchester | 2020 | + | 3 | Carol | London | 2021 | + | 4 | Dave | Bristol | 2020 | + | 5 | Eve | London | 2022 | + ]; + +-- Each loan records who borrowed which book; status: "out" or "returned" +Loans := [| loan_id | book_id | member_id | status | + | 1 | 1 | 1 | returned | + | 2 | 2 | 1 | out | + | 3 | 3 | 2 | returned | + | 4 | 1 | 3 | out | + | 5 | 4 | 2 | returned | + | 6 | 5 | 4 | returned | + | 7 | 6 | 3 | returned | + | 8 | 2 | 5 | out | + | 9 | 7 | 1 | returned | + | 10 | 8 | 4 | out | + ]; + +-- ─── Queries ───────────────────────────────────────────────────────────────── + +-- Books currently on loan (not yet returned) +OnLoan := { σ status = "out" (Loans) }; + +-- Full details of overdue books with borrower names +OnLoanDetails := { + π title, name, city ( + (OnLoan ⋈ Books) ⋈ Members + ) +}; + +-- Books that have never been borrowed (anti-join). +-- Book 9 is in the catalogue but in no loan, so it is the one row this returns — +-- an anti-join that always came back empty would demonstrate nothing. +NeverBorrowed := { Books ▷ Books.book_id = Loans.book_id Loans }; + +-- Prolific readers: members who have borrowed more than once +LoanCounts := { γ member_id, COUNT(loan_id) → times_borrowed (Loans) }; +ProlificReaders := { + π name, times_borrowed ( + σ times_borrowed > 1 (LoanCounts ⋈ Members) + ) +}; + +-- Sci-fi books sorted by year, most recent first +SciFiByYear := { + τ year DESC ( + σ genre = "sci-fi" (Books) + ) +}; + +-- Pages per genre — genres with their total and average page counts +GenreStats := { + γ genre, COUNT(book_id) → book_count, SUM(pages) → total_pages (Books) +}; + +-- Authors with multiple books in the catalogue +AuthorTally := { γ author, COUNT(book_id) → books_in_catalogue (Books) }; +ProlificAuthors := { σ books_in_catalogue > 1 (AuthorTally) }; + +-- London members who currently have a book out (semi-join). +-- The condition names the two sides of *this* join — the left is the filtered +-- Members, not the Loans the right side was derived from. +LondonBorrowers := { + σ city = "London" (Members) ⋉ + Members.member_id = OnLoan.member_id + OnLoan +}; + +query OnLoanDetails; +query NeverBorrowed; +query ProlificReaders; +query SciFiByYear; +query GenreStats; +query ProlificAuthors; +query LondonBorrowers; diff --git a/src/test/resources/examples/pokemon.relix b/src/test/resources/examples/pokemon.relix new file mode 100644 index 0000000..c6bfd08 --- /dev/null +++ b/src/test/resources/examples/pokemon.relix @@ -0,0 +1,242 @@ +-- ─── Pokémon: evolution graphs and a salary-capped draft ───────────────────── +-- Live data from PokeAPI (https://pokeapi.co): public, no key, read-only. +-- Run: relix --exec examples/pokemon.relix +-- +-- Two halves: +-- 1. Evolution as a graph: flatten PokeAPI's recursive evolution chains +-- into an edge list, then ask CLOSURE and PATH the fun questions. +-- 2. Team building: draft six Pokémon with OPTIMIZE under a salary cap, +-- then plan practice battles with COVER. +-- +-- Every source is one GET per run. PokeAPI is a free community service, so +-- be kind to it: run this now and then rather than in a tight loop. + +namespace pokemon; + +-- ═══ 1. Evolution as a graph ═════════════════════════════════════════════════ + +-- An evolution chain is a recursive document: +-- +-- { "chain": { "species": { "name": "bulbasaur" }, +-- "evolves_to": [ { "species": { "name": "ivysaur" }, +-- "evolution_details": [ { "trigger": { "name": "level-up" }, +-- "min_level": 16, "item": null, … } ], +-- "evolves_to": [ { … venusaur … } ] } ] } } +-- +-- extract: json("$.chain") makes the root of each chain the single row, and +-- evolves_to stays an ANY column: a nested array we unnest with μ. +-- (Chain ids come from /pokemon-species/ → evolution_chain.url.) + +source BulbasaurChain from http { + url: "https://pokeapi.co/api/v2/evolution-chain/1", extract: json("$.chain"), + schema: { species: STRING at "$.species.name", evolves_to: ANY } }; +source CharmanderChain from http { + url: "https://pokeapi.co/api/v2/evolution-chain/2", extract: json("$.chain"), + schema: { species: STRING at "$.species.name", evolves_to: ANY } }; +source SquirtleChain from http { + url: "https://pokeapi.co/api/v2/evolution-chain/3", extract: json("$.chain"), + schema: { species: STRING at "$.species.name", evolves_to: ANY } }; +source PichuChain from http { + url: "https://pokeapi.co/api/v2/evolution-chain/10", extract: json("$.chain"), + schema: { species: STRING at "$.species.name", evolves_to: ANY } }; +source EeveeChain from http { + url: "https://pokeapi.co/api/v2/evolution-chain/67", extract: json("$.chain"), + schema: { species: STRING at "$.species.name", evolves_to: ANY } }; + +Chains := { BulbasaurChain ⊎ CharmanderChain ⊎ SquirtleChain ⊎ PichuChain ⊎ EeveeChain }; + +-- ─── Flatten the tree, one level at a time ────────────────────────────────── + +-- Stage 2: one row per child of a chain's root (Eevee alone has eight). +StageTwo := { + π species, + evolves_to.species.name → evolves_into, + evolves_to.evolution_details → how, + evolves_to.evolves_to → later + (μ evolves_to (Chains)) +}; + +-- Stage 3: one row per grandchild (Ivysaur → Venusaur, Pikachu → Raichu, …). +StageThree := { + π evolves_into → species, + later.species.name → evolves_into, + later.evolution_details → how, + later.evolves_to → later + (μ later (StageTwo)) +}; + +Evolutions := { StageTwo ⊎ StageThree }; + +-- The edge list every graph operator below reads. +Evolves := { δ (π species, evolves_into (Evolutions)) }; + +-- One row per *way* to evolve. evolution_details is an array because some +-- evolutions have several (Leafeon: a Moss Rock or a Leaf Stone, by game). +Methods := { + π species, evolves_into, + how.trigger.name → trigger, + how.min_level → min_level, + how.item.name → item, + how.min_happiness → min_happiness, + how.time_of_day → time_of_day + (μ how (Evolutions)) +}; + +-- ─── Questions ────────────────────────────────────────────────────────────── + +-- Eevee's options: which stone, which friendship level, which time of day. +EeveeOptions := { τ evolves_into (σ species = "eevee" (Methods)) }; + +-- The stone shop: every evolution item, and what each one unlocks. +StoneShop := { + τ evolutions DESC ( + γ item, COUNT(*) → evolutions, COLLECT(evolves_into) → unlocks + (σ item IS NOT NULL (Methods))) +}; + +-- Everything a Pokémon can *eventually* become, however many steps away. +EventuallyBecomes := { CLOSURE species, evolves_into (Evolves) }; +PichuFuture := { σ species = "pichu" (EventuallyBecomes) }; + +-- The same question with a distance: how many evolutions to get there? +StepsAway := { τ steps DESC, species (PATH species, evolves_into HOPS 1 TO 2 AS steps (Evolves)) }; + +-- Final forms: they evolve from something and into nothing. +FinalForms := { τ evolves_into (π evolves_into (Evolves) − π species (Evolves)) }; + +-- Families: read the edges both ways (↔) and everyone in a chain is +-- connected to everyone else. Jolteon's relatives are the whole Eeveelution +-- family: Eevee, all eight evolutions, and Jolteon itself (there and back +-- again is a path too). +Relatives := { CLOSURE species ↔ evolves_into (Evolves) }; +JolteonsFamily := { τ evolves_into (σ species = "jolteon" (Relatives)) }; + +query EeveeOptions; +query StoneShop; +query PichuFuture; +query StepsAway; +query FinalForms; +query JolteonsFamily; + +-- ═══ 2. Team building ════════════════════════════════════════════════════════ + +-- The candidates. A Pokémon's base_experience (what you earn for defeating +-- it) doubles as its "salary": strong Pokémon are expensive. stats is an +-- array of six { "base_stat": 35, "stat": { "name": "hp" } } entries; +-- types holds one or two { "slot": 1, "type": { "name": "electric" } }. + +source Pikachu from http { url: "https://pokeapi.co/api/v2/pokemon/pikachu", + schema: { name: STRING, cost: NUMBER at "$.base_experience", stats: ANY, + type1: STRING at "$.types[0].type.name", type2: STRING at "$.types[1].type.name" } }; +source Charizard from http { url: "https://pokeapi.co/api/v2/pokemon/charizard", + schema: { name: STRING, cost: NUMBER at "$.base_experience", stats: ANY, + type1: STRING at "$.types[0].type.name", type2: STRING at "$.types[1].type.name" } }; +source Blastoise from http { url: "https://pokeapi.co/api/v2/pokemon/blastoise", + schema: { name: STRING, cost: NUMBER at "$.base_experience", stats: ANY, + type1: STRING at "$.types[0].type.name", type2: STRING at "$.types[1].type.name" } }; +source Venusaur from http { url: "https://pokeapi.co/api/v2/pokemon/venusaur", + schema: { name: STRING, cost: NUMBER at "$.base_experience", stats: ANY, + type1: STRING at "$.types[0].type.name", type2: STRING at "$.types[1].type.name" } }; +source Gengar from http { url: "https://pokeapi.co/api/v2/pokemon/gengar", + schema: { name: STRING, cost: NUMBER at "$.base_experience", stats: ANY, + type1: STRING at "$.types[0].type.name", type2: STRING at "$.types[1].type.name" } }; +source Snorlax from http { url: "https://pokeapi.co/api/v2/pokemon/snorlax", + schema: { name: STRING, cost: NUMBER at "$.base_experience", stats: ANY, + type1: STRING at "$.types[0].type.name", type2: STRING at "$.types[1].type.name" } }; +source Dragonite from http { url: "https://pokeapi.co/api/v2/pokemon/dragonite", + schema: { name: STRING, cost: NUMBER at "$.base_experience", stats: ANY, + type1: STRING at "$.types[0].type.name", type2: STRING at "$.types[1].type.name" } }; +source Mewtwo from http { url: "https://pokeapi.co/api/v2/pokemon/mewtwo", + schema: { name: STRING, cost: NUMBER at "$.base_experience", stats: ANY, + type1: STRING at "$.types[0].type.name", type2: STRING at "$.types[1].type.name" } }; +source Gyarados from http { url: "https://pokeapi.co/api/v2/pokemon/gyarados", + schema: { name: STRING, cost: NUMBER at "$.base_experience", stats: ANY, + type1: STRING at "$.types[0].type.name", type2: STRING at "$.types[1].type.name" } }; +source Alakazam from http { url: "https://pokeapi.co/api/v2/pokemon/alakazam", + schema: { name: STRING, cost: NUMBER at "$.base_experience", stats: ANY, + type1: STRING at "$.types[0].type.name", type2: STRING at "$.types[1].type.name" } }; +source Machamp from http { url: "https://pokeapi.co/api/v2/pokemon/machamp", + schema: { name: STRING, cost: NUMBER at "$.base_experience", stats: ANY, + type1: STRING at "$.types[0].type.name", type2: STRING at "$.types[1].type.name" } }; +source Lapras from http { url: "https://pokeapi.co/api/v2/pokemon/lapras", + schema: { name: STRING, cost: NUMBER at "$.base_experience", stats: ANY, + type1: STRING at "$.types[0].type.name", type2: STRING at "$.types[1].type.name" } }; + +Candidates := { + Pikachu ⊎ Charizard ⊎ Blastoise ⊎ Venusaur ⊎ Gengar ⊎ Snorlax + ⊎ Dragonite ⊎ Mewtwo ⊎ Gyarados ⊎ Alakazam ⊎ Machamp ⊎ Lapras +}; + +-- ─── Scouting report ──────────────────────────────────────────────────────── + +-- One row per (Pokémon, stat). +StatLines := { π name, stats.stat.name → stat, stats.base_stat → base (μ stats (Candidates)) }; + +-- Base-stat total, the usual one-number summary of a Pokémon. +Totals := { γ name, SUM(base) → bst (StatLines) }; + +Roster := { τ bst DESC ((π name, cost, type1, type2 (Candidates)) ⋈ Totals) }; + +-- Who leads the pool in each stat? ARGMAX(rank, yield) names the row with the +-- highest rank, with no self-join needed. +StatLeaders := { γ stat, MAX(base) → best, ARGMAX(base, name) → leader (StatLines) }; + +query Roster; +query StatLeaders; + +-- ─── The draft: six Pokémon under a salary cap ────────────────────────────── + +-- Take the most total stats, with exactly six picks and at most 1400 in +-- salaries. Without the cap it is simply the six highest totals. With it, +-- Mewtwo, the most expensive, has to be worth whoever it pushes out. +Draft := { + OPTIMIZE MAXIMIZE SUM(bst) + SUBJECT TO SUM(1) = 6 AND SUM(cost) <= 1400 + (Roster) +}; +DraftedTeam := { τ bst DESC (Draft) }; + +query DraftedTeam; + +-- Which battle types does the drafted team have no same-type attack for? +-- The type index lists 'stellar' and 'unknown' too, which are not battle types. +source TypeIndex from http { + url: "https://pokeapi.co/api/v2/type?limit=100", + extract: json("$.results[*]"), + schema: { type_name: STRING at "$.name" } +}; + +BattleTypes := { σ type_name NOT IN {"stellar", "unknown"} (TypeIndex) }; +TeamTypes := { π type1 (Draft) ∪ π type2 (σ type2 IS NOT NULL (Draft)) }; +Gaps := { τ type_name (BattleTypes − TeamTypes) }; + +query Gaps; + +-- ─── Practice schedule ────────────────────────────────────────────────────── + +-- Every lead × weather × terrain combination is 6 × 4 × 4 = 96 practice +-- battles. COVER 2 keeps only enough of them that every *pair* still meets +-- at least once: each lead in every weather, each lead on every terrain, +-- and every weather on every terrain. +Weather := [ +| weather | +|-----------| +| sun | +| rain | +| sandstorm | +| snow | +]; + +Terrain := [ +| terrain | +|----------| +| electric | +| grassy | +| misty | +| psychic | +]; + +Leads := { π name → lead (Draft) }; +Sparring := { τ lead, weather (COVER 2 (Leads × Weather × Terrain)) }; + +query Sparring; From a4a5d00c76dc564fbed3151837ce60a6d70ee280 Mon Sep 17 00:00:00 2001 From: David Tooke Date: Thu, 8 Oct 2026 12:14:16 -0400 Subject: [PATCH 2/5] Add relix fmt: print scripts canonically, keeping their comments `relix fmt [SCRIPT...]` prints each script with the engine's statement printer, the one behind Relix.definitions(), and standard input when no file is named. `-w` rewrites the files that change in place, and `--check` changes nothing, names each file that would change, and exits 1 if any would. `--glyphs` (the default) and `--keywords` choose the operator spelling. The keyword spelling is read from the reference's spellings page. The printer drops comments, and DarkCollective/relix-core#99 asks for that and the spelling choice in the engine. Until then fmt keeps them itself: - a comment between statements stays where it was, on its own line or after the statement it followed; - a statement with a comment inside it is left exactly as written; - blank lines between things collapse to one. The comments are found by ScriptText's scan, which follows the language's two lexers: `--` and `/* */` outside strings, and only at the start of a line inside an inline table, whose rule and cells may hold `--`. Inline tables' columns are aligned. A script that does not parse is reported as FILE:LINE:COL, left alone, and the run exits 3. Over every example script, fmt is idempotent, parses back to the same statements in either spelling, and keeps every comment in order. Part of #12. --- .../relix/cli/command/FmtCommand.java | 158 +++++++++ .../relix/cli/command/RelixCommand.java | 2 +- .../relix/cli/render/ScriptFormatter.java | 319 ++++++++++++++++++ .../relix/cli/command/FmtTest.java | 298 ++++++++++++++++ 4 files changed, 776 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/darkcollective/relix/cli/command/FmtCommand.java create mode 100644 src/main/java/com/darkcollective/relix/cli/render/ScriptFormatter.java create mode 100644 src/test/java/com/darkcollective/relix/cli/command/FmtTest.java diff --git a/src/main/java/com/darkcollective/relix/cli/command/FmtCommand.java b/src/main/java/com/darkcollective/relix/cli/command/FmtCommand.java new file mode 100644 index 0000000..0e5aba4 --- /dev/null +++ b/src/main/java/com/darkcollective/relix/cli/command/FmtCommand.java @@ -0,0 +1,158 @@ +/* + * 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.command; + +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.ScriptSource; +import com.darkcollective.relix.cli.render.ScriptFormatter; +import com.darkcollective.relix.lang.ast.ScriptParseException; +import picocli.CommandLine.ArgGroup; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; +import picocli.CommandLine.Parameters; +import picocli.CommandLine.ParentCommand; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; + +/** + * {@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 + * (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. + * + *

A script that does not parse is reported as {@code FILE:LINE:COL: error: message}, + * left alone, and makes the run exit 3. Nothing is analysed or run: a script naming a + * relation nothing declares is formatted all the same. + */ +@Command(name = "fmt", + mixinStandardHelpOptions = true, + sortOptions = false, + usageHelpAutoWidth = true, + description = { + "Prints scripts canonically, keeping their comments. With no SCRIPT, formats", + "standard input."}, + exitCodeListHeading = "%nExit status:%n", + exitCodeList = { + " 0:success; with --check, no file would change", + " 1:with --check, a file would change", + " 3:a script does not parse"}) +final class FmtCommand implements Callable { + + @ParentCommand + RelixCommand root; + + @Option(names = "-w", description = "Rewrite each file that changes in place, instead of printing it.") + boolean write; + + @Option(names = "--check", + description = "Change nothing; print the name of each file that would change, and exit 1 if any would.") + boolean check; + + @ArgGroup(exclusive = true) + SpellingChoice spelling = new SpellingChoice(); + + @Parameters(paramLabel = "SCRIPT", arity = "0..*", + description = "Script files; - is standard input, which is also read when none is named.") + List files = new ArrayList<>(); + + /** {@code --glyphs} or {@code --keywords}. */ + static final class SpellingChoice { + + @Option(names = "--glyphs", description = "Write operators as glyphs: σ, ⋈, ∧ (the default).") + boolean glyphs; + + @Option(names = "--keywords", description = "Write operators as ASCII keywords: SELECT, JOIN, AND.") + boolean keywords; + } + + @Override + public Integer call() { + if (write && check) { + 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; + 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"); + } + Reporter reporter = invocation.reporter(); + RowSink out = new RowSink(invocation.host().out(), false); + ExitCode result = ExitCode.SUCCESS; + boolean changes = false; + for (ScriptSource source : sources) { + String formatted; + try { + formatted = ScriptFormatter.format(source.text(), chosen); + } catch (ScriptParseException e) { + reporter.error(new Reporter.Place(source.name(), e.line(), e.column()) + ": error: " + + e.description()); + result = ExitCode.ANALYSIS; + continue; + } + boolean changed = !formatted.equals(source.text()); + changes |= changed; + if (check) { + if (changed) { + out.text(source.name() + "\n"); + } + } else if (write) { + if (changed) { + rewrite(invocation.directory().resolve(source.name()), formatted); + } + } else { + out.text(formatted); + } + } + if (result == ExitCode.SUCCESS && check && changes) { + result = ExitCode.ASSERTION; + } + return result.status(); + } + + /** The scripts named, or standard input. */ + private List sources(Invocation invocation) { + if (files.isEmpty() && invocation.host().stdinIsTerminal()) { + throw new CommandFailure(ExitCode.USAGE, null); + } + return ScriptSource.resolve(List.of(), files.isEmpty() ? List.of("-") : files, + invocation.host(), invocation.directory(), false); + } + + private static void rewrite(Path file, String text) { + try { + Files.writeString(file, text, StandardCharsets.UTF_8); + } catch (NoSuchFileException e) { + throw new CommandFailure(ExitCode.USAGE, file + ": no such file"); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/src/main/java/com/darkcollective/relix/cli/command/RelixCommand.java b/src/main/java/com/darkcollective/relix/cli/command/RelixCommand.java index 50b0f04..924f929 100644 --- a/src/main/java/com/darkcollective/relix/cli/command/RelixCommand.java +++ b/src/main/java/com/darkcollective/relix/cli/command/RelixCommand.java @@ -47,7 +47,7 @@ subcommands = { RunCommand.class, CheckCommand.class, ExplainCommand.class, OptimizeCommand.class, TraceCommand.class, BundleCommand.class, IrCommand.class, ProvenanceCommand.class, - CatalogCommand.class}, + FmtCommand.class, CatalogCommand.class}, exitCodeListHeading = "%nExit status:%n", exitCodeList = { " 0:success", diff --git a/src/main/java/com/darkcollective/relix/cli/render/ScriptFormatter.java b/src/main/java/com/darkcollective/relix/cli/render/ScriptFormatter.java new file mode 100644 index 0000000..4545ec4 --- /dev/null +++ b/src/main/java/com/darkcollective/relix/cli/render/ScriptFormatter.java @@ -0,0 +1,319 @@ +/* + * 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.cli.io.ScriptText; +import com.darkcollective.relix.docs.RelixDocs; +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. + * + *

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 + * 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() { + } + + /** + * Formats a script. + * + * @param text the script + * @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(); + } + + /** + * Rewrites the printer's glyphs as the keywords {@code language/spellings.md} pairs with + * them, outside string literals and delimited names. + */ + 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() { + } + + static String align(String printed) { + if (!printed.contains("\n|")) { + return printed; + } + List lines = new ArrayList<>(List.of(printed.split("\n", -1))); + int i = 0; + while (i < lines.size()) { + if (!isRow(lines.get(i))) { + i++; + continue; + } + int end = i; + while (end < lines.size() && isRow(lines.get(end))) { + end++; + } + align(lines.subList(i, end)); + i = end; + } + return String.join("\n", lines); + } + + private static boolean isRow(String line) { + return line.length() >= 2 && line.startsWith("|") && line.endsWith("|"); + } + + private static void align(List rows) { + List> cells = new ArrayList<>(); + List rules = new ArrayList<>(); + List widths = new ArrayList<>(); + for (String row : rows) { + List split = cells(row); + boolean rule = split.stream().allMatch(Tables::isRule); + cells.add(split); + rules.add(rule); + for (int c = 0; c < split.size(); c++) { + int width = rule ? 3 : width(split.get(c)); + if (c == widths.size()) { + widths.add(width); + } else { + widths.set(c, Math.max(widths.get(c), width)); + } + } + } + for (int r = 0; r < rows.size(); r++) { + StringBuilder line = new StringBuilder("|"); + List split = cells.get(r); + for (int c = 0; c < split.size(); c++) { + String cell = split.get(c); + if (rules.get(r)) { + line.append("-".repeat(widths.get(c) + 2)).append('|'); + } else { + line.append(' ').append(cell).append(" ".repeat(widths.get(c) - width(cell))).append(" |"); + } + } + rows.set(r, line.toString()); + } + } + + /** A row's cells, trimmed, split at the pipes that are not in a string or escaped. */ + private static List cells(String row) { + List cells = new ArrayList<>(); + StringBuilder cell = new StringBuilder(); + boolean quoted = false; + for (int i = 1; i < row.length(); i++) { + char c = row.charAt(i); + if (c == '\\' && i + 1 < row.length()) { + cell.append(c).append(row.charAt(++i)); + } else if (c == '"') { + quoted = !quoted; + cell.append(c); + } else if (c == '|' && !quoted) { + cells.add(cell.toString().strip()); + cell.setLength(0); + } else { + cell.append(c); + } + } + return cells; + } + + private static boolean isRule(String cell) { + return !cell.isEmpty() && cell.chars().allMatch(ch -> ch == '-' || ch == ':'); + } + + private static int width(String cell) { + return cell.codePointCount(0, cell.length()); + } + } +} diff --git a/src/test/java/com/darkcollective/relix/cli/command/FmtTest.java b/src/test/java/com/darkcollective/relix/cli/command/FmtTest.java new file mode 100644 index 0000000..d5451b2 --- /dev/null +++ b/src/test/java/com/darkcollective/relix/cli/command/FmtTest.java @@ -0,0 +1,298 @@ +/* + * 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.command; + +import com.darkcollective.relix.cli.Cli; +import com.darkcollective.relix.cli.Examples; +import com.darkcollective.relix.embed.Relix; +import com.darkcollective.relix.lang.ast.ScriptPrinter; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.FieldSource; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * {@code relix fmt} (design §3), end to end through {@code Main}: over every example + * script, formatting is idempotent, keeps the statements and keeps the comments. + */ +@DisplayName("relix fmt") +class FmtTest { + + static final List EXAMPLES = Examples.NAMES; + + /** A line comment or a block comment, as the examples write them. */ + private static final Pattern COMMENT = Pattern.compile("--[^\\n]*|/\\*.*?\\*/", Pattern.DOTALL); + + @TempDir + Path dir; + + private String fmt(String text, String... options) { + String[] args = new String[options.length + 1]; + args[0] = "fmt"; + System.arraycopy(options, 0, args, 1, options.length); + var result = Cli.in(dir).stdin(text).run(args); + assertThat(result.status()).as(result.toString()).isZero(); + assertThat(result.err()).isEmpty(); + return result.out(); + } + + /** The statements a text parses to, each as the printer writes it, which ignores where they were. */ + private static List statements(String text) { + return Relix.parse(text).statements().stream().map(ScriptPrinter::print).toList(); + } + + /** The comments in a text, leaving out its inline tables' rows, whose rules are not comments. */ + private static List comments(String text) { + String code = text.lines().filter(line -> !line.strip().startsWith("|")) + .collect(java.util.stream.Collectors.joining("\n")); + Matcher m = COMMENT.matcher(code); + List found = new java.util.ArrayList<>(); + while (m.find()) { + found.add(m.group().stripTrailing()); + } + return found; + } + + @Nested + @DisplayName("over every example") + class OverTheExamples { + + @ParameterizedTest(name = "{0}") + @FieldSource("com.darkcollective.relix.cli.command.FmtTest#EXAMPLES") + @DisplayName("is idempotent") + void idempotent(String example) { + String once = fmt(Examples.text(example)); + + assertThat(fmt(once)).isEqualTo(once); + } + + @ParameterizedTest(name = "{0}") + @FieldSource("com.darkcollective.relix.cli.command.FmtTest#EXAMPLES") + @DisplayName("parses to the same statements") + void sameStatements(String example) { + String original = Examples.text(example); + + assertThat(statements(fmt(original))).isEqualTo(statements(original)); + assertThat(Relix.parse(fmt(original)).namespace()).isEqualTo(Relix.parse(original).namespace()); + } + + @ParameterizedTest(name = "{0}") + @FieldSource("com.darkcollective.relix.cli.command.FmtTest#EXAMPLES") + @DisplayName("keeps every comment, in order") + void keepsComments(String example) { + String original = Examples.text(example); + + assertThat(comments(fmt(original))).isEqualTo(comments(original)); + } + + @ParameterizedTest(name = "{0}") + @FieldSource("com.darkcollective.relix.cli.command.FmtTest#EXAMPLES") + @DisplayName("--keywords parses to the same statements, and is idempotent") + void keywords(String example) { + String original = Examples.text(example); + String keywords = fmt(original, "--keywords"); + + assertThat(statements(keywords)).isEqualTo(statements(original)); + assertThat(fmt(keywords, "--keywords")).isEqualTo(keywords); + assertThat(comments(keywords)).isEqualTo(comments(original)); + } + + @ParameterizedTest(name = "{0}") + @FieldSource("com.darkcollective.relix.cli.command.FmtTest#EXAMPLES") + @DisplayName("--glyphs turns --keywords' output back into the default's") + void glyphs(String example) { + String original = Examples.text(example); + + assertThat(fmt(fmt(original, "--keywords"), "--glyphs")).isEqualTo(fmt(original)); + } + } + + @Nested + @DisplayName("the layout") + class Layout { + + @Test + @DisplayName("prints each statement canonically, keeping the comments and blank lines around it") + void canonical() { + String text = """ + -- header + + namespace demo; -- the namespace + + + N := [| x | + | 2 | + | 10 |]; + /* a view */ + Big := { + σ x > 1 + (N) + }; -- trailing + query Big; + """; + + assertThat(fmt(text)).isEqualTo(""" + -- header + + namespace demo; -- the namespace + + N := [ + | x | + |-----| + | 2 | + | 10 | + ]; + /* a view */ + Big := { σ x > 1 (N) }; -- trailing + query Big; + """); + } + + @Test + @DisplayName("leaves a statement with a comment inside it as written") + 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"); + } + + @Test + @DisplayName("does not take -- in a string, a table cell or a table's rule for a comment") + void notComments() { + String text = "T := [\n| a | b |\n|---|---|\n| 1 | x -- y |\n];\nQ := { σ b = \"--\" (T) };\n"; + + String formatted = fmt(text); + + assertThat(formatted).contains("| 1 | x -- y |").contains("Q := { σ b = \"--\" (T) };"); + assertThat(statements(formatted)).isEqualTo(statements(text)); + } + + @Test + @DisplayName("--keywords writes the ASCII spelling, and leaves glyphs in strings alone") + void keywords() { + String formatted = fmt("Q := { π a (σ a ≠ \"σ\" ∧ a ≥ 1 (T ⋈ U)) };\n", "--keywords"); + + assertThat(formatted).isEqualTo("Q := { PROJECT a (SELECT (a != \"σ\") AND (a >= 1) ((T) JOIN (U))) };\n"); + } + + @Test + @DisplayName("an empty script stays empty, and a comment alone stays") + void empty() { + assertThat(fmt("")).isEmpty(); + assertThat(fmt(" -- just this\n\n")).isEqualTo("-- just this\n"); + } + } + + @Nested + @DisplayName("files") + class Files_ { + + private Path write(String name, String text) throws IOException { + return Files.writeString(dir.resolve(name), text, StandardCharsets.UTF_8); + } + + @Test + @DisplayName("prints each file's formatted text to stdout, changing none") + void prints() throws IOException { + write("a.relix", "query { N } ;\n"); + write("b.relix", "query { M };\n"); + + var result = Cli.in(dir).run("fmt", "a.relix", "b.relix"); + + assertThat(result.status()).isZero(); + assertThat(result.out()).isEqualTo("query { N };\nquery { M };\n"); + assertThat(Files.readString(dir.resolve("a.relix"))).isEqualTo("query { N } ;\n"); + } + + @Test + @DisplayName("-w rewrites the files that change, and only those") + void write() throws IOException { + Path changed = write("a.relix", "query { N } ;\n"); + Path same = write("b.relix", "query { M };\n"); + var before = Files.getLastModifiedTime(same); + + var result = Cli.in(dir).run("fmt", "-w", "a.relix", "b.relix"); + + assertThat(result.status()).as(result.toString()).isZero(); + assertThat(result.out()).isEmpty(); + assertThat(Files.readString(changed)).isEqualTo("query { N };\n"); + assertThat(Files.getLastModifiedTime(same)).isEqualTo(before); + } + + @Test + @DisplayName("--check exits 0 when nothing would change") + void checkClean() throws IOException { + write("b.relix", "query { M };\n"); + + var result = Cli.in(dir).run("fmt", "--check", "b.relix"); + + assertThat(result.status()).isZero(); + assertThat(result.out()).isEmpty(); + } + + @Test + @DisplayName("--check names each file that would change, changes none, and exits 1") + void checkDirty() throws IOException { + write("a.relix", "query { N } ;\n"); + write("b.relix", "query { M };\n"); + + var result = Cli.in(dir).run("fmt", "--check", "a.relix", "b.relix"); + + assertThat(result.status()).isEqualTo(1); + assertThat(result.out()).isEqualTo("a.relix\n"); + assertThat(Files.readString(dir.resolve("a.relix"))).isEqualTo("query { N } ;\n"); + } + + @Test + @DisplayName("a file that does not parse is reported at its place, left alone, and exits 3") + void unparsable() throws IOException { + write("bad.relix", "N := [\n| x |\n];\nquery { σ (N };\n"); + write("b.relix", "query { M };\n"); + + var result = Cli.in(dir).run("fmt", "-w", "bad.relix", "b.relix"); + + assertThat(result.status()).isEqualTo(3); + assertThat(result.err()).startsWith("relix: bad.relix:4:"); + assertThat(Files.readString(dir.resolve("b.relix"))).isEqualTo("query { M };\n"); + } + + @Test + @DisplayName("-w with standard input, and -w with --check, are usage errors") + void usage() { + assertThat(Cli.in(dir).stdin("query { N };").run("fmt", "-w").status()).isEqualTo(2); + assertThat(Cli.in(dir).run("fmt", "-w", "--check", "a.relix").status()).isEqualTo(2); + assertThat(Cli.in(dir).run("fmt", "--glyphs", "--keywords", "a.relix").status()).isEqualTo(2); + } + + @Test + @DisplayName("with nothing named and a terminal on stdin, prints usage and exits 2") + void nothing() { + assertThat(Cli.in(dir).run("fmt").status()).isEqualTo(2); + } + } +} From 63adde5a837df2c8cccabdadd614fde6f5b02c6a Mon Sep 17 00:00:00 2001 From: David Tooke Date: Thu, 8 Oct 2026 12:21:10 -0400 Subject: [PATCH 3/5] Add the management commands: drivers, connectors, doc, help, version, completion - `drivers ls` lists the JDBC drivers relix can install, with each one's scheme and whether it is installed. `drivers install NAME` downloads and installs one. Together they replace the run-time --download-drivers. - `connectors ls` lists the connectors installed, and `--available` adds the ones the hosted catalog offers. `connectors install TYPE` installs one. - `doc [TOPIC]` shows a reference page, found by glyph, keyword or name, rendered for a terminal and paged through $PAGER on one. `--markdown` prints the page as it is. With no topic it lists the pages as rows. A language keyword keeps a name it shares with a function (`doc fix` is the operator), and `--function` finds the function. - `help [COMMAND]` is picocli's, and `version` prints the front-end and engine versions. - `completion bash|zsh|fish|powershell` prints a completion script built from the command model. bash's is picocli's, which runs under zsh too. fish and PowerShell get theirs here. The listings print through the run's renderers. The index doc looks pages up in is built here, over relix-docs' language pages and the installed functions' pages, until DarkCollective/relix-core#95 puts it in relix-docs. It holds every key of relix-console's index except the REPL's page about itself. Each completion script is syntax-checked by its shell when that shell is installed. CI installs zsh and fish on Linux for this; the runners already have bash and PowerShell. Part of #13. --- .github/workflows/ci.yml | 5 + .../relix/cli/command/Completion.java | 217 ++++++++++++ .../relix/cli/command/CompletionCommand.java | 63 ++++ .../relix/cli/command/ConnectorsCommand.java | 140 ++++++++ .../relix/cli/command/DocCommand.java | 111 +++++++ .../relix/cli/command/DriversCommand.java | 155 +++++++++ .../relix/cli/command/RelixCommand.java | 4 +- .../relix/cli/command/VersionCommand.java | 44 +++ .../darkcollective/relix/cli/io/Pager.java | 114 +++++++ .../darkcollective/relix/cli/io/Reporter.java | 11 + .../relix/cli/render/DocIndex.java | 169 ++++++++++ .../cli/command/ManagementCommandsTest.java | 314 ++++++++++++++++++ .../relix/cli/io/PagerTest.java | 53 +++ .../relix/cli/render/DocIndexTest.java | 101 ++++++ 14 files changed, 1500 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/darkcollective/relix/cli/command/Completion.java create mode 100644 src/main/java/com/darkcollective/relix/cli/command/CompletionCommand.java create mode 100644 src/main/java/com/darkcollective/relix/cli/command/ConnectorsCommand.java create mode 100644 src/main/java/com/darkcollective/relix/cli/command/DocCommand.java create mode 100644 src/main/java/com/darkcollective/relix/cli/command/DriversCommand.java create mode 100644 src/main/java/com/darkcollective/relix/cli/command/VersionCommand.java create mode 100644 src/main/java/com/darkcollective/relix/cli/io/Pager.java create mode 100644 src/main/java/com/darkcollective/relix/cli/render/DocIndex.java create mode 100644 src/test/java/com/darkcollective/relix/cli/command/ManagementCommandsTest.java create mode 100644 src/test/java/com/darkcollective/relix/cli/io/PagerTest.java create mode 100644 src/test/java/com/darkcollective/relix/cli/render/DocIndexTest.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23fc1f3..dd9945a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,11 @@ jobs: java-version: '21' # Also validates the checked-in wrapper jar against Gradle's published checksums. - uses: gradle/actions/setup-gradle@v6 + # The completion scripts are syntax-checked by each shell that is installed; the + # runners have bash and PowerShell, and Linux gets zsh and fish here. + - name: Install zsh and fish + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y zsh fish - run: ./gradlew clean build - name: Upload test and coverage reports if: always() diff --git a/src/main/java/com/darkcollective/relix/cli/command/Completion.java b/src/main/java/com/darkcollective/relix/cli/command/Completion.java new file mode 100644 index 0000000..59992bc --- /dev/null +++ b/src/main/java/com/darkcollective/relix/cli/command/Completion.java @@ -0,0 +1,217 @@ +/* + * 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.command; + +import picocli.AutoComplete; +import picocli.CommandLine; +import picocli.CommandLine.Model.CommandSpec; +import picocli.CommandLine.Model.OptionSpec; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * Completion scripts for the command, generated from its picocli model, so that they + * cannot drift from the options it parses (design §7.3). + * + *

bash's is picocli's own, which also runs under zsh through {@code bashcompinit}. + * fish and PowerShell have none from picocli, so theirs are written here: each completes + * the commands, then each command's subcommands and options. + */ +final class Completion { + + /** The shells there is a script for. */ + enum Shell { + BASH, ZSH, FISH, POWERSHELL; + + static Shell of(String name) { + try { + return valueOf(name.strip().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + return null; + } + } + } + + private Completion() { + } + + /** + * The completion script for a shell. + * + * @param shell the shell + * @param root the root command + * @return the script + */ + static String script(Shell shell, CommandLine root) { + String name = root.getCommandName(); + return switch (shell) { + case BASH, ZSH -> AutoComplete.bash(name, root); + case FISH -> fish(name, root.getCommandSpec()); + case POWERSHELL -> powershell(name, root.getCommandSpec()); + }; + } + + // ------------------------------------------------------------------------- + // fish + // ------------------------------------------------------------------------- + + private static String fish(String name, CommandSpec root) { + StringBuilder out = new StringBuilder(); + out.append("# fish completion for ").append(name).append(", generated from its command model.\n"); + out.append("# Install: ").append(name).append(" completion fish > ~/.config/fish/completions/") + .append(name).append(".fish\n\n"); + out.append("complete -c ").append(name).append(" -f\n"); + fish(out, name, root, List.of()); + return out.toString(); + } + + private static void fish(StringBuilder out, String name, CommandSpec command, List path) { + String condition = fishCondition(command, path); + String prefix = "complete -c " + name + " -n " + fishQuote(condition); + for (Map.Entry sub : subcommands(command).entrySet()) { + out.append(prefix).append(" -a ").append(fishQuote(sub.getKey())) + .append(" -d ").append(fishQuote(summary(sub.getValue().usageMessage().description()))) + .append('\n'); + } + for (OptionSpec option : command.options()) { + if (option.hidden()) { + continue; + } + StringBuilder line = new StringBuilder(prefix); + for (String optionName : option.names()) { + if (optionName.startsWith("--")) { + line.append(" -l ").append(fishQuote(optionName.substring(2))); + } else if (optionName.length() == 2) { + line.append(" -s ").append(fishQuote(optionName.substring(1))); + } + } + if (option.arity().max() > 0) { + line.append(" -r"); + } + line.append(" -d ").append(fishQuote(summary(option.description()))); + out.append(line).append('\n'); + } + if (!command.positionalParameters().isEmpty()) { + out.append(prefix).append(" -F\n"); + } + for (Map.Entry sub : subcommands(command).entrySet()) { + List deeper = new ArrayList<>(path); + deeper.add(sub.getKey()); + fish(out, name, sub.getValue(), deeper); + } + } + + /** Where in the command line a command's completions apply. */ + private static String fishCondition(CommandSpec command, List path) { + List parts = new ArrayList<>(); + if (path.isEmpty()) { + parts.add("__fish_use_subcommand"); + } else { + for (String step : path) { + parts.add("__fish_seen_subcommand_from " + step); + } + if (!command.subcommands().isEmpty()) { + parts.add("not __fish_seen_subcommand_from " + String.join(" ", subcommands(command).keySet())); + } + } + return String.join("; and ", parts); + } + + private static String fishQuote(String text) { + return "'" + text.replace("\\", "\\\\").replace("'", "\\'") + "'"; + } + + // ------------------------------------------------------------------------- + // PowerShell + // ------------------------------------------------------------------------- + + private static String powershell(String name, CommandSpec root) { + Map> words = new LinkedHashMap<>(); + powershell(words, root, ""); + StringBuilder out = new StringBuilder(); + out.append("# PowerShell completion for ").append(name).append(", generated from its command model.\n"); + out.append("# Install: ").append(name).append(" completion powershell >> $PROFILE\n\n"); + out.append("Register-ArgumentCompleter -Native -CommandName ").append(psQuote(name)) + .append(" -ScriptBlock {\n"); + out.append(" param($wordToComplete, $commandAst, $cursorPosition)\n"); + out.append(" $words = @{\n"); + for (Map.Entry> entry : words.entrySet()) { + out.append(" ").append(psQuote(entry.getKey())).append(" = @("); + List quoted = entry.getValue().stream().map(Completion::psQuote).toList(); + out.append(String.join(", ", quoted)).append(")\n"); + } + out.append(" }\n"); + out.append(" $path = ''\n"); + out.append(" foreach ($element in $commandAst.CommandElements | Select-Object -Skip 1) {\n"); + out.append(" if ($element.Extent.StartOffset -ge $cursorPosition) { break }\n"); + out.append(" $candidate = ($path + ' ' + $element.ToString()).Trim()\n"); + out.append(" if ($words.ContainsKey($candidate)) { $path = $candidate }\n"); + out.append(" }\n"); + out.append(" $words[$path] | Where-Object { $_ -like \"$wordToComplete*\" } | ForEach-Object {\n"); + out.append(" [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)\n"); + out.append(" }\n"); + out.append("}\n"); + return out.toString(); + } + + private static void powershell(Map> words, CommandSpec command, String path) { + List here = new ArrayList<>(subcommands(command).keySet()); + for (OptionSpec option : command.options()) { + if (!option.hidden()) { + here.addAll(List.of(option.names())); + } + } + words.put(path, here); + for (Map.Entry sub : subcommands(command).entrySet()) { + powershell(words, sub.getValue(), (path + " " + sub.getKey()).strip()); + } + } + + private static String psQuote(String text) { + return "'" + text.replace("'", "''") + "'"; + } + + // ------------------------------------------------------------------------- + + /** A command's subcommands by their names, aliases left out. */ + private static Map subcommands(CommandSpec command) { + Map named = new LinkedHashMap<>(); + command.subcommands().forEach((key, sub) -> { + if (key.equals(sub.getCommandName())) { + named.put(key, sub.getCommandSpec()); + } + }); + return named; + } + + /** + * The first line of a description that has any text, without picocli's line breaks + * ({@code %n}) and with its variables removed. + */ + private static String summary(String[] description) { + for (String line : String.join("%n", description).split("%n")) { + String text = line.replaceAll("(? ~/.config/fish/completions/relix.fish", + " powershell: relix completion powershell | Out-String | Invoke-Expression"}) +final class CompletionCommand implements Callable { + + @ParentCommand + RelixCommand root; + + @Spec + CommandSpec spec; + + @Parameters(paramLabel = "SHELL", description = "bash, zsh, fish or powershell.") + String shell; + + @Override + public Integer call() { + Completion.Shell chosen = Completion.Shell.of(shell); + if (chosen == null) { + throw new CommandFailure(ExitCode.USAGE, "'" + shell + "' is not one of bash, zsh, fish, powershell"); + } + new RowSink(root.host().out(), false).text(Completion.script(chosen, spec.root().commandLine())); + return ExitCode.SUCCESS.status(); + } +} diff --git a/src/main/java/com/darkcollective/relix/cli/command/ConnectorsCommand.java b/src/main/java/com/darkcollective/relix/cli/command/ConnectorsCommand.java new file mode 100644 index 0000000..76d7deb --- /dev/null +++ b/src/main/java/com/darkcollective/relix/cli/command/ConnectorsCommand.java @@ -0,0 +1,140 @@ +/* + * 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.command; + +import com.darkcollective.relix.cli.CommandFailure; +import com.darkcollective.relix.cli.ExitCode; +import com.darkcollective.relix.cli.drivers.DownloadProgress; +import com.darkcollective.relix.processor.connector.ConnectorProvisioner; +import com.darkcollective.relix.symbol.ScalarType; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; +import picocli.CommandLine.Parameters; +import picocli.CommandLine.ParentCommand; + +import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; +import java.util.Set; +import java.util.TreeSet; +import java.util.concurrent.Callable; + +/** + * {@code relix connectors}: the connectors installed, those that can be downloaded, and + * installing one (design §3). Installing is how a connector arrives ahead of a run; + * during a run, {@code --allow-download} permits fetching one the run needs. + */ +@Command(name = "connectors", + mixinStandardHelpOptions = true, + sortOptions = false, + usageHelpAutoWidth = true, + description = { + "Lists and installs connector plugins, such as mongodb. A source of a type no", + "installed connector reads needs one, installed here ahead of a run, or fetched", + "during one with --allow-download."}, + subcommands = {ConnectorsCommand.Ls.class, ConnectorsCommand.Install.class}) +final class ConnectorsCommand implements Callable { + + @ParentCommand + RelixCommand root; + + @Override + public Integer call() { + throw new CommandFailure(ExitCode.USAGE, null); + } + + private static ConnectorProvisioner provisioner(Invocation invocation, boolean download) { + PrintWriter progress = new PrintWriter(invocation.host().err(), true, StandardCharsets.UTF_8); + return ConnectorProvisioner.create(download, DownloadProgress.reporting(progress)); + } + + /** {@code relix connectors ls}. */ + @Command(name = "ls", + mixinStandardHelpOptions = true, + sortOptions = false, + usageHelpAutoWidth = true, + description = { + "Lists the connectors installed: those shipped with relix and the plugins", + "installed in ~/.relix/connectors. --available adds the ones the hosted", + "catalog offers, which it fetches."}) + static final class Ls implements Callable { + + @ParentCommand + ConnectorsCommand connectors; + + @Option(names = "--available", + description = "Also list the connectors that can be installed, from the hosted catalog.") + boolean available; + + @Mixin + FormatOptions formatting; + + @Override + public Integer call() { + Invocation invocation = connectors.root.invocation(false); + Set installed = ConnectorProvisioner.installedTypes(); + Set types = new TreeSet<>(installed); + if (available) { + types.addAll(provisioner(invocation, false).catalogTypes()); + } + Listing listing = new Listing("type", ScalarType.STRING, "installed", ScalarType.BOOLEAN); + for (String type : types) { + listing.add(type, installed.contains(type)); + } + listing.print(invocation, invocation.format(formatting), "connectors"); + return ExitCode.SUCCESS.status(); + } + } + + /** {@code relix connectors install TYPE}. */ + @Command(name = "install", + mixinStandardHelpOptions = true, + sortOptions = false, + usageHelpAutoWidth = true, + description = "Downloads the connector plugin TYPE, checks its checksums, and installs it.", + exitCodeListHeading = "%nExit status:%n", + exitCodeList = { + " 0:installed, or already installed", + " 2:the catalog has no connector of that type", + " 5:the download failed"}) + static final class Install implements Callable { + + @ParentCommand + ConnectorsCommand connectors; + + @Parameters(paramLabel = "TYPE", description = "A connector type, such as mongodb.") + String type; + + @Override + public Integer call() { + Invocation invocation = connectors.root.invocation(false); + if (ConnectorProvisioner.installedTypes().contains(type)) { + invocation.reporter().info("the " + type + " connector is already installed"); + return ExitCode.SUCCESS.status(); + } + ConnectorProvisioner.Result result = provisioner(invocation, true).provision(type); + return switch (result.outcome()) { + case PROVISIONED -> { + invocation.reporter().info("installed the " + type + " connector into " + + ConnectorProvisioner.defaultDirectory() + ": " + result.message()); + yield ExitCode.SUCCESS.status(); + } + case UNKNOWN -> throw new CommandFailure(ExitCode.USAGE, result.message()); + case DISABLED, FAILED -> throw new CommandFailure(ExitCode.ENVIRONMENT, result.message()); + }; + } + } +} diff --git a/src/main/java/com/darkcollective/relix/cli/command/DocCommand.java b/src/main/java/com/darkcollective/relix/cli/command/DocCommand.java new file mode 100644 index 0000000..d5ccd3a --- /dev/null +++ b/src/main/java/com/darkcollective/relix/cli/command/DocCommand.java @@ -0,0 +1,111 @@ +/* + * 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.command; + +import com.darkcollective.relix.cli.CommandFailure; +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.ReferencePage; +import com.darkcollective.relix.symbol.ScalarType; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; +import picocli.CommandLine.Parameters; +import picocli.CommandLine.ParentCommand; + +import java.util.Optional; +import java.util.concurrent.Callable; + +/** + * {@code relix doc [TOPIC]}: a page of the language reference (design §3). + * + *

A topic is found by glyph, keyword or name: {@code σ}, {@code select} and + * {@code selection} are one page. A language keyword keeps a name it shares with a + * 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. + */ +@Command(name = "doc", + mixinStandardHelpOptions = true, + sortOptions = false, + usageHelpAutoWidth = true, + description = { + "Shows the reference page for TOPIC, found by glyph, keyword or name (σ, select,", + "selection), through $PAGER on a terminal. With no TOPIC, lists the pages."}, + exitCodeListHeading = "%nExit status:%n", + exitCodeList = {" 0:success", " 2:no page has that topic"}) +final class DocCommand implements Callable { + + @ParentCommand + RelixCommand root; + + @Parameters(paramLabel = "TOPIC", arity = "0..1", + description = "A glyph, keyword or name, such as σ, select, join or Len.") + String topic; + + @Option(names = "--function", + description = "Look TOPIC up among the functions only, or list only them.") + boolean function; + + @Option(names = "--markdown", description = "Print the page's markdown as it is, not rendered.") + boolean markdown; + + @Option(names = "--no-pager", description = "Write the page to standard output even on a terminal.") + boolean noPager; + + @Mixin + FormatOptions formatting; + + @Override + public Integer call() { + Invocation invocation = root.invocation(false); + DocIndex index = DocIndex.installed(); + if (topic == null) { + list(invocation, index); + return ExitCode.SUCCESS.status(); + } + Optional found = function ? index.function(topic) : index.lookup(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, + "the reference lists " + page.path() + " but has no page there")); + RowSink out = new RowSink(invocation.host().out(), false); + if (!markdown) { + text = DocRenderer.render(text, page.path()); + } + if (noPager) { + out.text(text); + } else { + Pager.show(invocation.host(), text, out); + } + return ExitCode.SUCCESS.status(); + } + + private void list(Invocation invocation, DocIndex 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()); + } + } + listing.print(invocation, invocation.format(formatting), "reference"); + } +} diff --git a/src/main/java/com/darkcollective/relix/cli/command/DriversCommand.java b/src/main/java/com/darkcollective/relix/cli/command/DriversCommand.java new file mode 100644 index 0000000..b333693 --- /dev/null +++ b/src/main/java/com/darkcollective/relix/cli/command/DriversCommand.java @@ -0,0 +1,155 @@ +/* + * 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.command; + +import com.darkcollective.relix.cli.CommandFailure; +import com.darkcollective.relix.cli.ExitCode; +import com.darkcollective.relix.cli.drivers.DownloadProgress; +import com.darkcollective.relix.connectors.std.DriverCatalog; +import com.darkcollective.relix.connectors.std.DriverProvisioner; +import com.darkcollective.relix.symbol.ScalarType; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Parameters; +import picocli.CommandLine.ParentCommand; + +import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.util.Locale; +import java.util.concurrent.Callable; +import java.util.stream.Collectors; + +/** + * {@code relix drivers}: the JDBC drivers relix can download, and installing one + * (design §3). Installing is how a driver arrives ahead of a run; during a run, + * {@code --allow-download} permits fetching one the run needs. + */ +@Command(name = "drivers", + mixinStandardHelpOptions = true, + sortOptions = false, + usageHelpAutoWidth = true, + description = { + "Lists and installs JDBC drivers. A connection to a database needs its driver,", + "installed here ahead of a run, or fetched during one with --allow-download."}, + subcommands = {DriversCommand.Ls.class, DriversCommand.Install.class}) +final class DriversCommand implements Callable { + + /** A URL of a scheme's, for asking whether a driver accepts that scheme. */ + private static final String PROBE = "//localhost/"; + + @ParentCommand + RelixCommand root; + + @Override + public Integer call() { + throw new CommandFailure(ExitCode.USAGE, null); + } + + /** Whether a driver that accepts a JDBC scheme is registered. */ + static boolean registered(String scheme) { + try { + return DriverManager.getDriver(scheme + PROBE) != null; + } catch (SQLException none) { + return false; + } + } + + /** The short name of a scheme: {@code postgresql} for {@code jdbc:postgresql:}. */ + static String shortName(String scheme) { + String name = scheme.toLowerCase(Locale.ROOT); + name = name.startsWith("jdbc:") ? name.substring(5) : name; + return name.endsWith(":") ? name.substring(0, name.length() - 1) : name; + } + + /** {@code relix drivers ls}. */ + @Command(name = "ls", + mixinStandardHelpOptions = true, + sortOptions = false, + usageHelpAutoWidth = true, + description = { + "Lists the drivers relix can install: name, the JDBC scheme each serves,", + "and whether one is installed."}) + static final class Ls implements Callable { + + @ParentCommand + DriversCommand drivers; + + @Mixin + FormatOptions formatting; + + @Override + public Integer call() { + Invocation invocation = drivers.root.invocation(false); + invocation.loadInstalledDrivers(); + Listing listing = new Listing("name", ScalarType.STRING, "scheme", ScalarType.STRING, + "installed", ScalarType.BOOLEAN); + for (DriverCatalog.Entry entry : DriverCatalog.load().entries()) { + listing.add(shortName(entry.scheme()), entry.scheme(), registered(entry.scheme())); + } + listing.print(invocation, invocation.format(formatting), "drivers"); + return ExitCode.SUCCESS.status(); + } + } + + /** {@code relix drivers install NAME}. */ + @Command(name = "install", + mixinStandardHelpOptions = true, + sortOptions = false, + usageHelpAutoWidth = true, + description = "Downloads the driver NAME, checks its checksum, and installs it.", + exitCodeListHeading = "%nExit status:%n", + exitCodeList = { + " 0:installed, or already installed", + " 2:no driver has that name", + " 5:the download failed"}) + static final class Install implements Callable { + + @ParentCommand + DriversCommand drivers; + + @Parameters(paramLabel = "NAME", description = "A driver relix drivers ls lists, such as postgresql.") + String name; + + @Override + public Integer call() { + Invocation invocation = drivers.root.invocation(false); + DriverCatalog catalog = DriverCatalog.load(); + DriverCatalog.Entry entry = catalog.entries().stream() + .filter(e -> shortName(e.scheme()).equalsIgnoreCase(name) || e.name().equalsIgnoreCase(name)) + .findFirst() + .orElseThrow(() -> new CommandFailure(ExitCode.USAGE, "no driver named '" + name + "'; " + + "relix can install " + catalog.entries().stream() + .map(e -> shortName(e.scheme())).collect(Collectors.joining(", ")))); + invocation.loadInstalledDrivers(); + if (registered(entry.scheme())) { + invocation.reporter().info(entry.name() + " is already installed"); + return ExitCode.SUCCESS.status(); + } + PrintWriter progress = new PrintWriter(invocation.host().err(), true, StandardCharsets.UTF_8); + DriverProvisioner.Result result = DriverProvisioner.create(true, DownloadProgress.reporting(progress)) + .provision(entry.scheme() + PROBE); + return switch (result.outcome()) { + case PROVISIONED, ALREADY_AVAILABLE -> { + invocation.reporter().info("installed " + entry.name() + ": " + result.message()); + yield ExitCode.SUCCESS.status(); + } + case UNKNOWN, DISABLED, FAILED -> throw new CommandFailure(ExitCode.ENVIRONMENT, result.message()); + }; + } + } +} diff --git a/src/main/java/com/darkcollective/relix/cli/command/RelixCommand.java b/src/main/java/com/darkcollective/relix/cli/command/RelixCommand.java index 924f929..6fa99a8 100644 --- a/src/main/java/com/darkcollective/relix/cli/command/RelixCommand.java +++ b/src/main/java/com/darkcollective/relix/cli/command/RelixCommand.java @@ -22,6 +22,7 @@ import com.darkcollective.relix.cli.io.ScriptSource; import com.darkcollective.relix.cli.report.EventFeed; import picocli.CommandLine.Command; +import picocli.CommandLine.HelpCommand; import picocli.CommandLine.IVersionProvider; import picocli.CommandLine.Mixin; @@ -47,7 +48,8 @@ subcommands = { RunCommand.class, CheckCommand.class, ExplainCommand.class, OptimizeCommand.class, TraceCommand.class, BundleCommand.class, IrCommand.class, ProvenanceCommand.class, - FmtCommand.class, CatalogCommand.class}, + FmtCommand.class, CatalogCommand.class, DriversCommand.class, ConnectorsCommand.class, + DocCommand.class, HelpCommand.class, VersionCommand.class, CompletionCommand.class}, exitCodeListHeading = "%nExit status:%n", exitCodeList = { " 0:success", diff --git a/src/main/java/com/darkcollective/relix/cli/command/VersionCommand.java b/src/main/java/com/darkcollective/relix/cli/command/VersionCommand.java new file mode 100644 index 0000000..9dd4669 --- /dev/null +++ b/src/main/java/com/darkcollective/relix/cli/command/VersionCommand.java @@ -0,0 +1,44 @@ +/* + * 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.command; + +import com.darkcollective.relix.cli.ExitCode; +import com.darkcollective.relix.cli.Main; +import com.darkcollective.relix.cli.io.RowSink; +import picocli.CommandLine.Command; +import picocli.CommandLine.ParentCommand; + +import java.util.concurrent.Callable; + +/** + * {@code relix version}: the command's version and the engine's, as {@code relix --version} + * prints them. + */ +@Command(name = "version", + mixinStandardHelpOptions = true, + usageHelpAutoWidth = true, + description = "Prints the versions of relix and of the Relix engine it runs.") +final class VersionCommand implements Callable { + + @ParentCommand + RelixCommand root; + + @Override + public Integer call() { + new RowSink(root.host().out(), false).text(Main.versionLine() + "\n"); + return ExitCode.SUCCESS.status(); + } +} diff --git a/src/main/java/com/darkcollective/relix/cli/io/Pager.java b/src/main/java/com/darkcollective/relix/cli/io/Pager.java new file mode 100644 index 0000000..ef045be --- /dev/null +++ b/src/main/java/com/darkcollective/relix/cli/io/Pager.java @@ -0,0 +1,114 @@ +/* + * 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.io; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Shows a long text a screen at a time, through {@code $PAGER}, when standard output is a + * terminal, as {@code git} and {@code man} do. + * + *

The pager is {@code $PAGER}, else {@code less}, run by the shell; {@code less} is given + * {@code LESS=FRX} unless {@code $LESS} is set, so a page that fits the screen is printed + * and left there. On Windows there is a pager only when {@code $PAGER} names one. An empty + * {@code $PAGER} or {@code cat} means none, and so does a pager that will not start: the + * text is then written to standard output as it is. + */ +public final class Pager { + + /** The variable naming the pager. */ + public static final String PAGER_VARIABLE = "PAGER"; + + private Pager() { + } + + /** + * Shows a text, through the pager when there is one. + * + * @param host the process + * @param text the text + * @param sink standard output, for when there is no pager + */ + public static void show(Host host, String text, RowSink sink) { + Optional> command = paged(host) ? command(host.environment(), isWindows()) : Optional.empty(); + if (command.isEmpty() || !run(command.get(), host.environment(), text)) { + sink.text(text); + } + } + + /** + * Whether a host's standard output can be paged: it is a terminal, and it is this + * process's own, which is where a pager writes. + */ + private static boolean paged(Host host) { + return host.stdoutIsTerminal() && host.out() instanceof FileOutputStream; + } + + /** + * The pager to run, as a command line, if any. + * + * @param environment the process environment + * @param windows whether this is Windows + * @return the command, or empty for none + */ + public static Optional> command(Map environment, boolean windows) { + String pager = environment.get(PAGER_VARIABLE); + if (pager == null) { + return windows ? Optional.empty() : Optional.of(List.of("sh", "-c", "less")); + } + if (pager.isBlank() || pager.strip().equals("cat")) { + return Optional.empty(); + } + return Optional.of(windows ? List.of("cmd", "/c", pager) : List.of("sh", "-c", pager)); + } + + private static boolean run(List command, Map environment, String text) { + ProcessBuilder builder = new ProcessBuilder(command) + .redirectOutput(ProcessBuilder.Redirect.INHERIT) + .redirectError(ProcessBuilder.Redirect.INHERIT); + if (!environment.containsKey("LESS")) { + builder.environment().put("LESS", "FRX"); + } + Process process; + try { + process = builder.start(); + } catch (IOException e) { + return false; + } + try (OutputStream in = process.getOutputStream()) { + in.write(text.getBytes(StandardCharsets.UTF_8)); + } catch (IOException e) { + // The reader quit before the end, as a pager may. + } + try { + process.waitFor(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return true; + } + + private static boolean isWindows() { + return File.separatorChar == '\\'; + } +} diff --git a/src/main/java/com/darkcollective/relix/cli/io/Reporter.java b/src/main/java/com/darkcollective/relix/cli/io/Reporter.java index 0e2fc7c..0b9dfa9 100644 --- a/src/main/java/com/darkcollective/relix/cli/io/Reporter.java +++ b/src/main/java/com/darkcollective/relix/cli/io/Reporter.java @@ -93,6 +93,17 @@ public void warning(String message) { } } + /** + * Prints what a command did, such as what it installed, unless only errors are wanted. + * + * @param message what was done + */ + public void info(String message) { + if (verbosity != Verbosity.QUIET) { + err.print("relix: " + message + "\n"); + } + } + /** * Prints a notice when {@code -v} is given. * diff --git a/src/main/java/com/darkcollective/relix/cli/render/DocIndex.java b/src/main/java/com/darkcollective/relix/cli/render/DocIndex.java new file mode 100644 index 0000000..3d61718 --- /dev/null +++ b/src/main/java/com/darkcollective/relix/cli/render/DocIndex.java @@ -0,0 +1,169 @@ +/* + * 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/command/ManagementCommandsTest.java b/src/test/java/com/darkcollective/relix/cli/command/ManagementCommandsTest.java new file mode 100644 index 0000000..93fd40b --- /dev/null +++ b/src/test/java/com/darkcollective/relix/cli/command/ManagementCommandsTest.java @@ -0,0 +1,314 @@ +/* + * 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.command; + +import com.darkcollective.relix.cli.Cli; +import com.darkcollective.relix.docs.RelixDocs; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +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.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** + * The management commands, {@code drivers}, {@code connectors}, {@code doc}, {@code help}, + * {@code version} and {@code completion} (design §3), end to end through {@code Main}. + * + *

Nothing here downloads: installing a driver or connector that is not installed would + * reach the network, so only the paths that do not are run. + */ +@DisplayName("Management commands") +class ManagementCommandsTest { + + @TempDir + Path dir; + + private Cli cli() { + return Cli.in(dir); + } + + @Nested + @DisplayName("version") + class Version { + + @Test + @DisplayName("prints relix's version and the engine's, as --version does") + void version() { + var result = cli().run("version"); + + assertThat(result.status()).isZero(); + assertThat(result.out()).matches("relix \\S+ \\(engine \\S+\\)\\n").isEqualTo(cli().run("--version").out()); + } + } + + @Nested + @DisplayName("help") + class Help { + + @Test + @DisplayName("prints a command's usage to stdout") + void command() { + var result = cli().run("help", "fmt"); + + assertThat(result.status()).isZero(); + assertThat(result.out()).startsWith("Usage: relix fmt").contains("--keywords"); + } + + @Test + @DisplayName("prints the main usage, listing every command, with no COMMAND") + void main() { + var result = cli().run("help"); + + assertThat(result.status()).isZero(); + assertThat(result.out()).startsWith("Usage: relix"); + for (String command : List.of("run", "check", "explain", "optimize", "trace", "bundle", "ir", + "provenance", "fmt", "catalog", "drivers", "connectors", "doc", "help", "version", "completion")) { + assertThat(result.out()).as(command).containsPattern("(?m)^ " + command + "\\b"); + } + } + } + + @Nested + @DisplayName("drivers") + class Drivers { + + @Test + @DisplayName("ls lists the drivers relix can install, as rows") + void ls() { + var result = cli().piped().run("drivers", "ls"); + + assertThat(result.status()).as(result.toString()).isZero(); + List lines = result.out().lines().toList(); + assertThat(lines.getFirst()).isEqualTo("name\tscheme\tinstalled"); + assertThat(lines).anyMatch(l -> l.startsWith("postgresql\tjdbc:postgresql:\t")) + .anyMatch(l -> l.startsWith("mysql\tjdbc:mysql:\t")); + } + + @Test + @DisplayName("ls writes ndjson with -o") + void ndjson() { + var result = cli().run("drivers", "ls", "-o", "ndjson"); + + assertThat(result.out()).contains("{\"name\":\"postgresql\",\"scheme\":\"jdbc:postgresql:\",\"installed\":"); + } + + @Test + @DisplayName("install of a driver relix does not know is a usage error naming those it does") + void unknown() { + var result = cli().run("drivers", "install", "nope"); + + assertThat(result.status()).isEqualTo(2); + assertThat(result.err()).isEqualTo("relix: no driver named 'nope'; relix can install postgresql, mysql\n"); + } + + @Test + @DisplayName("with no subcommand prints usage and exits 2") + void bare() { + assertThat(cli().run("drivers").status()).isEqualTo(2); + } + } + + @Nested + @DisplayName("connectors") + class Connectors { + + @Test + @DisplayName("ls lists the connectors installed, as rows") + void ls() { + var result = cli().piped().run("connectors", "ls"); + + assertThat(result.status()).isZero(); + assertThat(result.out()).startsWith("type\tinstalled\n").contains("csv\ttrue\n"); + } + + @Test + @DisplayName("install of one already installed says so and downloads nothing") + void installed() { + var result = cli().run("connectors", "install", "csv"); + + assertThat(result.status()).isZero(); + assertThat(result.out()).isEmpty(); + assertThat(result.err()).isEqualTo("relix: the csv connector is already installed\n"); + } + } + + @Nested + @DisplayName("doc") + class Doc { + + @Test + @DisplayName("shows one page for a glyph, its keyword and its name") + void spellings() { + var glyph = cli().run("doc", "σ"); + + assertThat(glyph.status()).isZero(); + assertThat(glyph.out()).contains("Selection (σ / SELECT)"); + assertThat(cli().run("doc", "select").out()).isEqualTo(glyph.out()); + assertThat(cli().run("doc", "Selection").out()).isEqualTo(glyph.out()); + } + + @Test + @DisplayName("--markdown prints the page as the reference holds it") + void markdown() { + var result = cli().run("doc", "--markdown", "σ"); + + assertThat(result.out()).isEqualTo(RelixDocs.referencePage("operators/select.md").orElseThrow()); + } + + @Test + @DisplayName("gives a shared name to the operator, and --function finds the function") + void function() { + assertThat(cli().run("doc", "fix").out()).contains("(FIX)"); + assertThat(cli().run("doc", "--function", "fix").out()).contains("Fix ("); + } + + @Test + @DisplayName("an unknown topic is a usage error") + void unknown() { + var result = cli().run("doc", "no-such-topic"); + + assertThat(result.status()).isEqualTo(2); + assertThat(result.err()).isEqualTo("relix: no reference page for 'no-such-topic'; relix doc lists them\n"); + } + + @Test + @DisplayName("with no topic lists the pages as rows, language pages then functions") + void list() { + var result = cli().piped().run("doc"); + List lines = result.out().lines().toList(); + + assertThat(lines.getFirst()).isEqualTo("topic\tcategory\ttitle\tsummary"); + assertThat(lines).anyMatch(l -> l.startsWith("σ\toperator\tSelection\t")) + .anyMatch(l -> l.startsWith("Fix\tfunction\tFix\t")); + assertThat(lines.size() - 1).isGreaterThan(RelixDocs.referencePages().size()); + } + + @Test + @DisplayName("--function lists only the functions") + void functions() { + var result = cli().piped().run("doc", "--function"); + + assertThat(result.out().lines().skip(1)).isNotEmpty().allMatch(l -> l.split("\t")[1].equals("function")); + } + + @Test + @DisplayName("resolves every key of every language page") + void everyKey() { + List missing = new ArrayList<>(); + for (var page : RelixDocs.referencePages()) { + for (String key : page.keys()) { + var result = cli().run("doc", "--markdown", key); + if (result.status() != 0 || result.out().isEmpty()) { + missing.add(page.path() + ": " + key); + } + } + } + assertThat(missing).isEmpty(); + } + } + + @Nested + @DisplayName("completion") + class Completion_ { + + @ParameterizedTest(name = "{0}") + @ValueSource(strings = {"bash", "zsh", "fish", "powershell"}) + @DisplayName("prints a script that completes every command") + void script(String shell) { + var result = cli().run("completion", shell); + + assertThat(result.status()).as(result.toString()).isZero(); + for (String command : List.of("run", "check", "fmt", "catalog", "drivers", "doc", "completion")) { + assertThat(result.out()).as(command).contains(command); + } + assertThat(result.out()).contains("keywords").contains("trace"); + } + + @Test + @DisplayName("an unknown shell is a usage error") + void unknown() { + var result = cli().run("completion", "tcsh"); + + assertThat(result.status()).isEqualTo(2); + assertThat(result.err()).contains("'tcsh' is not one of bash, zsh, fish, powershell"); + } + + @ParameterizedTest(name = "{0}") + @ValueSource(strings = {"bash", "zsh", "fish"}) + @DisabledOnOs(OS.WINDOWS) + @DisplayName("is read without error by its shell, where the shell is installed") + void posixSyntax(String shell) throws Exception { + Path script = write(shell); + List check = shell.equals("fish") + ? List.of("fish", "--no-execute", script.toString()) + : List.of(shell, "-n", script.toString()); + + assertSyntax(shell, check); + } + + @Test + @DisplayName("is read without error by PowerShell, where it is installed") + void powershellSyntax() throws Exception { + Path script = write("powershell"); + String parse = "$errors = $null; [System.Management.Automation.Language.Parser]::ParseFile('" + + script.toString().replace("'", "''") + "', [ref]$null, [ref]$errors) | Out-Null; " + + "if ($errors.Count -gt 0) { $errors | ForEach-Object { Write-Error $_.Message }; exit 1 }"; + + assertSyntax("pwsh", List.of("pwsh", "-NoProfile", "-NonInteractive", "-Command", parse)); + } + + private Path write(String shell) throws IOException { + var result = cli().run("completion", shell); + return Files.writeString(dir.resolve("relix." + shell), result.out(), StandardCharsets.UTF_8); + } + + private void assertSyntax(String shell, List command) throws Exception { + assumeTrue(available(shell), shell + " is not installed"); + Process process = new ProcessBuilder(command).redirectErrorStream(true).start(); + String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + assertThat(process.waitFor(60, TimeUnit.SECONDS)).isTrue(); + assertThat(process.exitValue()).as(output).isZero(); + } + + private static boolean available(String shell) { + try { + Process process = new ProcessBuilder(shell, shell.equals("pwsh") ? "-Version" : "--version") + .redirectErrorStream(true).start(); + process.getInputStream().readAllBytes(); + return process.waitFor(30, TimeUnit.SECONDS) && process.exitValue() == 0; + } catch (IOException e) { + return false; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + } + } +} diff --git a/src/test/java/com/darkcollective/relix/cli/io/PagerTest.java b/src/test/java/com/darkcollective/relix/cli/io/PagerTest.java new file mode 100644 index 0000000..9617d52 --- /dev/null +++ b/src/test/java/com/darkcollective/relix/cli/io/PagerTest.java @@ -0,0 +1,53 @@ +/* + * 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.io; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +/** Which pager a long page goes through. */ +@DisplayName("The pager") +class PagerTest { + + @Test + @DisplayName("is $PAGER, run by the shell") + void pager() { + assertThat(Pager.command(Map.of("PAGER", "most -s"), false)) + .contains(List.of("sh", "-c", "most -s")); + assertThat(Pager.command(Map.of("PAGER", "more"), true)) + .contains(List.of("cmd", "/c", "more")); + } + + @Test + @DisplayName("is less when $PAGER is not set, except on Windows") + void defaults() { + assertThat(Pager.command(Map.of(), false)).contains(List.of("sh", "-c", "less")); + assertThat(Pager.command(Map.of(), true)).isEmpty(); + } + + @Test + @DisplayName("is none when $PAGER is empty or cat") + void none() { + assertThat(Pager.command(Map.of("PAGER", ""), false)).isEqualTo(Optional.empty()); + assertThat(Pager.command(Map.of("PAGER", "cat"), false)).isEmpty(); + } +} diff --git a/src/test/java/com/darkcollective/relix/cli/render/DocIndexTest.java b/src/test/java/com/darkcollective/relix/cli/render/DocIndexTest.java new file mode 100644 index 0000000..32bb020 --- /dev/null +++ b/src/test/java/com/darkcollective/relix/cli/render/DocIndexTest.java @@ -0,0 +1,101 @@ +/* + * 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(); + } +} From eabb9ae587f75f21971ef612c3c13ed2f4bad2cb Mon Sep 17 00:00:00 2001 From: David Tooke Date: Thu, 8 Oct 2026 12:29:30 -0400 Subject: [PATCH 4/5] fixup! Add the management commands: drivers, connectors, doc, help, version, completion relix version printed its line with \n, and --version with the platform's line separator, so on Windows the two differed. version now prints through the same version help. --- .../relix/cli/command/VersionCommand.java | 18 ++++++++++-------- .../cli/command/ManagementCommandsTest.java | 2 +- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/darkcollective/relix/cli/command/VersionCommand.java b/src/main/java/com/darkcollective/relix/cli/command/VersionCommand.java index 9dd4669..1d725fb 100644 --- a/src/main/java/com/darkcollective/relix/cli/command/VersionCommand.java +++ b/src/main/java/com/darkcollective/relix/cli/command/VersionCommand.java @@ -16,16 +16,17 @@ package com.darkcollective.relix.cli.command; import com.darkcollective.relix.cli.ExitCode; -import com.darkcollective.relix.cli.Main; -import com.darkcollective.relix.cli.io.RowSink; +import picocli.CommandLine; import picocli.CommandLine.Command; -import picocli.CommandLine.ParentCommand; +import picocli.CommandLine.Model.CommandSpec; +import picocli.CommandLine.Spec; import java.util.concurrent.Callable; /** - * {@code relix version}: the command's version and the engine's, as {@code relix --version} - * prints them. + * {@code relix version}: the command's version and the engine's, printed by the same + * version help as {@code relix --version}, so the two are the same text, line ending + * included. */ @Command(name = "version", mixinStandardHelpOptions = true, @@ -33,12 +34,13 @@ description = "Prints the versions of relix and of the Relix engine it runs.") final class VersionCommand implements Callable { - @ParentCommand - RelixCommand root; + @Spec + CommandSpec spec; @Override public Integer call() { - new RowSink(root.host().out(), false).text(Main.versionLine() + "\n"); + CommandLine root = spec.root().commandLine(); + root.printVersionHelp(root.getOut()); return ExitCode.SUCCESS.status(); } } diff --git a/src/test/java/com/darkcollective/relix/cli/command/ManagementCommandsTest.java b/src/test/java/com/darkcollective/relix/cli/command/ManagementCommandsTest.java index 93fd40b..2f75f96 100644 --- a/src/test/java/com/darkcollective/relix/cli/command/ManagementCommandsTest.java +++ b/src/test/java/com/darkcollective/relix/cli/command/ManagementCommandsTest.java @@ -64,7 +64,7 @@ void version() { var result = cli().run("version"); assertThat(result.status()).isZero(); - assertThat(result.out()).matches("relix \\S+ \\(engine \\S+\\)\\n").isEqualTo(cli().run("--version").out()); + assertThat(result.out()).matches("relix \\S+ \\(engine \\S+\\)\\R").isEqualTo(cli().run("--version").out()); } } From b5b10d229573c7400caa2cf5994bfd3f464f8fa8 Mon Sep 17 00:00:00 2001 From: David Tooke Date: Thu, 8 Oct 2026 12:35:03 -0400 Subject: [PATCH 5/5] fixup! Add the report commands: check, explain, optimize, trace, bundle, ir, provenance pokemon.relix read its data from PokeAPI over HTTP, so the tests over it failed whenever the service did (macOS CI: EOF reached while reading). The examples are now the three that need nothing but themselves, a test holds them to that, and introspection.relix's closure query covers a rewrite render() cannot write back. --- .../darkcollective/relix/cli/Examples.java | 5 +- .../relix/cli/command/ReportCommandsTest.java | 21 +- src/test/resources/examples/pokemon.relix | 242 ------------------ 3 files changed, 19 insertions(+), 249 deletions(-) delete mode 100644 src/test/resources/examples/pokemon.relix diff --git a/src/test/java/com/darkcollective/relix/cli/Examples.java b/src/test/java/com/darkcollective/relix/cli/Examples.java index a4b261c..0f8815b 100644 --- a/src/test/java/com/darkcollective/relix/cli/Examples.java +++ b/src/test/java/com/darkcollective/relix/cli/Examples.java @@ -27,13 +27,14 @@ /** * The example scripts under {@code src/test/resources/examples}: real scripts, with * comments, inline tables, views and several queries each, that need nothing but - * themselves to run. + * themselves to run. None reaches the network: a test over a live service fails when the + * service does. */ public final class Examples { /** Every example, by file name. */ public static final List NAMES = - List.of("introspection.relix", "league.relix", "library.relix", "pokemon.relix"); + List.of("introspection.relix", "league.relix", "library.relix"); private Examples() { } 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 d30b894..77ecacc 100644 --- a/src/test/java/com/darkcollective/relix/cli/command/ReportCommandsTest.java +++ b/src/test/java/com/darkcollective/relix/cli/command/ReportCommandsTest.java @@ -140,6 +140,16 @@ void query(String command) { } } + @Test + @DisplayName("the examples declare nothing that reaches the network") + void offline() { + for (String example : EXAMPLES) { + assertThat(Examples.text(example)).as(example) + .doesNotContainPattern("(?m)^[^-\\n]*\\bfrom\\s+(http|jdbc)\\b") + .doesNotContainPattern("(?m)^\\s*connection\\b"); + } + } + @Nested @DisplayName("check") class Check { @@ -147,8 +157,7 @@ class Check { @Test @DisplayName("is silent and exits 0 over scripts that analyse") void clean() { - var result = cli().run(new String[] {"check", "introspection.relix", "league.relix", - "library.relix", "pokemon.relix"}); + var result = cli().run("check", "introspection.relix", "league.relix", "library.relix"); assertThat(result.status()).as(result.toString()).isZero(); assertThat(result.out()).isEmpty(); @@ -267,11 +276,13 @@ void keepsTheRest() { @Test @DisplayName("leaves a query as written, with a warning, when the engine cannot write its rewrite back") void unwritable() { - var result = cli().run("optimize", "pokemon.relix"); + // 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). + var result = cli().run("optimize", "introspection.relix"); assertThat(result.status()).isZero(); - assertThat(result.err()).contains("pokemon.relix: PichuFuture: the engine cannot write its rewrite"); - assertThat(result.out()).contains("query PichuFuture;"); + assertThat(result.err()).contains("introspection.relix: : the engine cannot write its rewrite"); + assertThat(result.out()).contains("query { π depends_on (σ dependent = \"Report\" (Lineage)) };"); } @Test diff --git a/src/test/resources/examples/pokemon.relix b/src/test/resources/examples/pokemon.relix deleted file mode 100644 index c6bfd08..0000000 --- a/src/test/resources/examples/pokemon.relix +++ /dev/null @@ -1,242 +0,0 @@ --- ─── Pokémon: evolution graphs and a salary-capped draft ───────────────────── --- Live data from PokeAPI (https://pokeapi.co): public, no key, read-only. --- Run: relix --exec examples/pokemon.relix --- --- Two halves: --- 1. Evolution as a graph: flatten PokeAPI's recursive evolution chains --- into an edge list, then ask CLOSURE and PATH the fun questions. --- 2. Team building: draft six Pokémon with OPTIMIZE under a salary cap, --- then plan practice battles with COVER. --- --- Every source is one GET per run. PokeAPI is a free community service, so --- be kind to it: run this now and then rather than in a tight loop. - -namespace pokemon; - --- ═══ 1. Evolution as a graph ═════════════════════════════════════════════════ - --- An evolution chain is a recursive document: --- --- { "chain": { "species": { "name": "bulbasaur" }, --- "evolves_to": [ { "species": { "name": "ivysaur" }, --- "evolution_details": [ { "trigger": { "name": "level-up" }, --- "min_level": 16, "item": null, … } ], --- "evolves_to": [ { … venusaur … } ] } ] } } --- --- extract: json("$.chain") makes the root of each chain the single row, and --- evolves_to stays an ANY column: a nested array we unnest with μ. --- (Chain ids come from /pokemon-species/ → evolution_chain.url.) - -source BulbasaurChain from http { - url: "https://pokeapi.co/api/v2/evolution-chain/1", extract: json("$.chain"), - schema: { species: STRING at "$.species.name", evolves_to: ANY } }; -source CharmanderChain from http { - url: "https://pokeapi.co/api/v2/evolution-chain/2", extract: json("$.chain"), - schema: { species: STRING at "$.species.name", evolves_to: ANY } }; -source SquirtleChain from http { - url: "https://pokeapi.co/api/v2/evolution-chain/3", extract: json("$.chain"), - schema: { species: STRING at "$.species.name", evolves_to: ANY } }; -source PichuChain from http { - url: "https://pokeapi.co/api/v2/evolution-chain/10", extract: json("$.chain"), - schema: { species: STRING at "$.species.name", evolves_to: ANY } }; -source EeveeChain from http { - url: "https://pokeapi.co/api/v2/evolution-chain/67", extract: json("$.chain"), - schema: { species: STRING at "$.species.name", evolves_to: ANY } }; - -Chains := { BulbasaurChain ⊎ CharmanderChain ⊎ SquirtleChain ⊎ PichuChain ⊎ EeveeChain }; - --- ─── Flatten the tree, one level at a time ────────────────────────────────── - --- Stage 2: one row per child of a chain's root (Eevee alone has eight). -StageTwo := { - π species, - evolves_to.species.name → evolves_into, - evolves_to.evolution_details → how, - evolves_to.evolves_to → later - (μ evolves_to (Chains)) -}; - --- Stage 3: one row per grandchild (Ivysaur → Venusaur, Pikachu → Raichu, …). -StageThree := { - π evolves_into → species, - later.species.name → evolves_into, - later.evolution_details → how, - later.evolves_to → later - (μ later (StageTwo)) -}; - -Evolutions := { StageTwo ⊎ StageThree }; - --- The edge list every graph operator below reads. -Evolves := { δ (π species, evolves_into (Evolutions)) }; - --- One row per *way* to evolve. evolution_details is an array because some --- evolutions have several (Leafeon: a Moss Rock or a Leaf Stone, by game). -Methods := { - π species, evolves_into, - how.trigger.name → trigger, - how.min_level → min_level, - how.item.name → item, - how.min_happiness → min_happiness, - how.time_of_day → time_of_day - (μ how (Evolutions)) -}; - --- ─── Questions ────────────────────────────────────────────────────────────── - --- Eevee's options: which stone, which friendship level, which time of day. -EeveeOptions := { τ evolves_into (σ species = "eevee" (Methods)) }; - --- The stone shop: every evolution item, and what each one unlocks. -StoneShop := { - τ evolutions DESC ( - γ item, COUNT(*) → evolutions, COLLECT(evolves_into) → unlocks - (σ item IS NOT NULL (Methods))) -}; - --- Everything a Pokémon can *eventually* become, however many steps away. -EventuallyBecomes := { CLOSURE species, evolves_into (Evolves) }; -PichuFuture := { σ species = "pichu" (EventuallyBecomes) }; - --- The same question with a distance: how many evolutions to get there? -StepsAway := { τ steps DESC, species (PATH species, evolves_into HOPS 1 TO 2 AS steps (Evolves)) }; - --- Final forms: they evolve from something and into nothing. -FinalForms := { τ evolves_into (π evolves_into (Evolves) − π species (Evolves)) }; - --- Families: read the edges both ways (↔) and everyone in a chain is --- connected to everyone else. Jolteon's relatives are the whole Eeveelution --- family: Eevee, all eight evolutions, and Jolteon itself (there and back --- again is a path too). -Relatives := { CLOSURE species ↔ evolves_into (Evolves) }; -JolteonsFamily := { τ evolves_into (σ species = "jolteon" (Relatives)) }; - -query EeveeOptions; -query StoneShop; -query PichuFuture; -query StepsAway; -query FinalForms; -query JolteonsFamily; - --- ═══ 2. Team building ════════════════════════════════════════════════════════ - --- The candidates. A Pokémon's base_experience (what you earn for defeating --- it) doubles as its "salary": strong Pokémon are expensive. stats is an --- array of six { "base_stat": 35, "stat": { "name": "hp" } } entries; --- types holds one or two { "slot": 1, "type": { "name": "electric" } }. - -source Pikachu from http { url: "https://pokeapi.co/api/v2/pokemon/pikachu", - schema: { name: STRING, cost: NUMBER at "$.base_experience", stats: ANY, - type1: STRING at "$.types[0].type.name", type2: STRING at "$.types[1].type.name" } }; -source Charizard from http { url: "https://pokeapi.co/api/v2/pokemon/charizard", - schema: { name: STRING, cost: NUMBER at "$.base_experience", stats: ANY, - type1: STRING at "$.types[0].type.name", type2: STRING at "$.types[1].type.name" } }; -source Blastoise from http { url: "https://pokeapi.co/api/v2/pokemon/blastoise", - schema: { name: STRING, cost: NUMBER at "$.base_experience", stats: ANY, - type1: STRING at "$.types[0].type.name", type2: STRING at "$.types[1].type.name" } }; -source Venusaur from http { url: "https://pokeapi.co/api/v2/pokemon/venusaur", - schema: { name: STRING, cost: NUMBER at "$.base_experience", stats: ANY, - type1: STRING at "$.types[0].type.name", type2: STRING at "$.types[1].type.name" } }; -source Gengar from http { url: "https://pokeapi.co/api/v2/pokemon/gengar", - schema: { name: STRING, cost: NUMBER at "$.base_experience", stats: ANY, - type1: STRING at "$.types[0].type.name", type2: STRING at "$.types[1].type.name" } }; -source Snorlax from http { url: "https://pokeapi.co/api/v2/pokemon/snorlax", - schema: { name: STRING, cost: NUMBER at "$.base_experience", stats: ANY, - type1: STRING at "$.types[0].type.name", type2: STRING at "$.types[1].type.name" } }; -source Dragonite from http { url: "https://pokeapi.co/api/v2/pokemon/dragonite", - schema: { name: STRING, cost: NUMBER at "$.base_experience", stats: ANY, - type1: STRING at "$.types[0].type.name", type2: STRING at "$.types[1].type.name" } }; -source Mewtwo from http { url: "https://pokeapi.co/api/v2/pokemon/mewtwo", - schema: { name: STRING, cost: NUMBER at "$.base_experience", stats: ANY, - type1: STRING at "$.types[0].type.name", type2: STRING at "$.types[1].type.name" } }; -source Gyarados from http { url: "https://pokeapi.co/api/v2/pokemon/gyarados", - schema: { name: STRING, cost: NUMBER at "$.base_experience", stats: ANY, - type1: STRING at "$.types[0].type.name", type2: STRING at "$.types[1].type.name" } }; -source Alakazam from http { url: "https://pokeapi.co/api/v2/pokemon/alakazam", - schema: { name: STRING, cost: NUMBER at "$.base_experience", stats: ANY, - type1: STRING at "$.types[0].type.name", type2: STRING at "$.types[1].type.name" } }; -source Machamp from http { url: "https://pokeapi.co/api/v2/pokemon/machamp", - schema: { name: STRING, cost: NUMBER at "$.base_experience", stats: ANY, - type1: STRING at "$.types[0].type.name", type2: STRING at "$.types[1].type.name" } }; -source Lapras from http { url: "https://pokeapi.co/api/v2/pokemon/lapras", - schema: { name: STRING, cost: NUMBER at "$.base_experience", stats: ANY, - type1: STRING at "$.types[0].type.name", type2: STRING at "$.types[1].type.name" } }; - -Candidates := { - Pikachu ⊎ Charizard ⊎ Blastoise ⊎ Venusaur ⊎ Gengar ⊎ Snorlax - ⊎ Dragonite ⊎ Mewtwo ⊎ Gyarados ⊎ Alakazam ⊎ Machamp ⊎ Lapras -}; - --- ─── Scouting report ──────────────────────────────────────────────────────── - --- One row per (Pokémon, stat). -StatLines := { π name, stats.stat.name → stat, stats.base_stat → base (μ stats (Candidates)) }; - --- Base-stat total, the usual one-number summary of a Pokémon. -Totals := { γ name, SUM(base) → bst (StatLines) }; - -Roster := { τ bst DESC ((π name, cost, type1, type2 (Candidates)) ⋈ Totals) }; - --- Who leads the pool in each stat? ARGMAX(rank, yield) names the row with the --- highest rank, with no self-join needed. -StatLeaders := { γ stat, MAX(base) → best, ARGMAX(base, name) → leader (StatLines) }; - -query Roster; -query StatLeaders; - --- ─── The draft: six Pokémon under a salary cap ────────────────────────────── - --- Take the most total stats, with exactly six picks and at most 1400 in --- salaries. Without the cap it is simply the six highest totals. With it, --- Mewtwo, the most expensive, has to be worth whoever it pushes out. -Draft := { - OPTIMIZE MAXIMIZE SUM(bst) - SUBJECT TO SUM(1) = 6 AND SUM(cost) <= 1400 - (Roster) -}; -DraftedTeam := { τ bst DESC (Draft) }; - -query DraftedTeam; - --- Which battle types does the drafted team have no same-type attack for? --- The type index lists 'stellar' and 'unknown' too, which are not battle types. -source TypeIndex from http { - url: "https://pokeapi.co/api/v2/type?limit=100", - extract: json("$.results[*]"), - schema: { type_name: STRING at "$.name" } -}; - -BattleTypes := { σ type_name NOT IN {"stellar", "unknown"} (TypeIndex) }; -TeamTypes := { π type1 (Draft) ∪ π type2 (σ type2 IS NOT NULL (Draft)) }; -Gaps := { τ type_name (BattleTypes − TeamTypes) }; - -query Gaps; - --- ─── Practice schedule ────────────────────────────────────────────────────── - --- Every lead × weather × terrain combination is 6 × 4 × 4 = 96 practice --- battles. COVER 2 keeps only enough of them that every *pair* still meets --- at least once: each lead in every weather, each lead on every terrain, --- and every weather on every terrain. -Weather := [ -| weather | -|-----------| -| sun | -| rain | -| sandstorm | -| snow | -]; - -Terrain := [ -| terrain | -|----------| -| electric | -| grassy | -| misty | -| psychic | -]; - -Leads := { π name → lead (Draft) }; -Sparring := { τ lead, weather (COVER 2 (Leads × Weather × Terrain)) }; - -query Sparring;