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/.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/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/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/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/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/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..6fa99a8 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,13 @@
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.HelpCommand;
import picocli.CommandLine.IVersionProvider;
import picocli.CommandLine.Mixin;
+import java.util.List;
import java.util.Objects;
import java.util.concurrent.Callable;
@@ -42,7 +45,11 @@
"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,
+ FmtCommand.class, CatalogCommand.class, DriversCommand.class, ConnectorsCommand.class,
+ DocCommand.class, HelpCommand.class, VersionCommand.class, CompletionCommand.class},
exitCodeListHeading = "%nExit status:%n",
exitCodeList = {
" 0:success",
@@ -68,6 +75,9 @@ public final class RelixCommand implements Callable {
@Mixin
OutputOptions output;
+ @Mixin
+ TraceOption trace;
+
private final Host host;
private final Interruption interruption;
@@ -84,7 +94,7 @@ public RelixCommand(Host host, Interruption interruption) {
@Override
public Integer call() {
- return run(scripts, inputs, output);
+ return run(scripts, inputs, output, trace);
}
/**
@@ -93,16 +103,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/command/VersionCommand.java b/src/main/java/com/darkcollective/relix/cli/command/VersionCommand.java
new file mode 100644
index 0000000..1d725fb
--- /dev/null
+++ b/src/main/java/com/darkcollective/relix/cli/command/VersionCommand.java
@@ -0,0 +1,46 @@
+/*
+ * 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 picocli.CommandLine;
+import picocli.CommandLine.Command;
+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, 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,
+ usageHelpAutoWidth = true,
+ description = "Prints the versions of relix and of the Relix engine it runs.")
+final class VersionCommand implements Callable {
+
+ @Spec
+ CommandSpec spec;
+
+ @Override
+ public Integer call() {
+ CommandLine root = spec.root().commandLine();
+ root.printVersionHelp(root.getOut());
+ 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/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 extends Row> 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/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/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:
+ *
+ * - A comment between statements stays between them, on a line of its own, or after
+ * the statement it followed on the same line.
+ * - A statement with a comment inside it is left exactly as written, because printing
+ * it would lose the comment.
+ * - A blank line between two things stays one blank line; more become one.
+ *
+ *
+ * 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/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..0f8815b
--- /dev/null
+++ b/src/test/java/com/darkcollective/relix/cli/Examples.java
@@ -0,0 +1,76 @@
+/*
+ * 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. 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");
+
+ 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/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);
+ }
+ }
+}
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..2f75f96
--- /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+\\)\\R").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/command/ReportCommandsTest.java b/src/test/java/com/darkcollective/relix/cli/command/ReportCommandsTest.java
new file mode 100644
index 0000000..77ecacc
--- /dev/null
+++ b/src/test/java/com/darkcollective/relix/cli/command/ReportCommandsTest.java
@@ -0,0 +1,459 @@
+/*
+ * 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'");
+ }
+ }
+
+ @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 {
+
+ @Test
+ @DisplayName("is silent and exits 0 over scripts that analyse")
+ void clean() {
+ var result = cli().run("check", "introspection.relix", "league.relix", "library.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() {
+ // 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("introspection.relix: : the engine cannot write its rewrite");
+ assertThat(result.out()).contains("query { π depends_on (σ dependent = \"Report\" (Lineage)) };");
+ }
+
+ @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/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();
+ }
+}
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;