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 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: + *

+ * + *

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;