Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
27 changes: 26 additions & 1 deletion src/main/java/com/darkcollective/relix/cli/Main.java
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
}

Expand Down Expand Up @@ -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.
*
Expand Down
107 changes: 107 additions & 0 deletions src/main/java/com/darkcollective/relix/cli/command/BundleCommand.java
Original file line number Diff line number Diff line change
@@ -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).
*
* <p>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<Relation> queries = queries(script);
List<QueryBundleJson.Query> bundled = new ArrayList<>();
for (Relation relation : queries) {
Relation optimized = relation.optimized();
String label = relation.label().orElse("<expression>");
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<SemanticError> 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<Relation> 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());
}
}
117 changes: 117 additions & 0 deletions src/main/java/com/darkcollective/relix/cli/command/CheckCommand.java
Original file line number Diff line number Diff line change
@@ -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<Placed> 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<Placed> 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";
}
}
Loading
Loading