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
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ Its documentation is the command-line section of the Relix programming guide, in

## Status

Under development. There is no release yet, and the command line described above is
the design being built, not a shipped interface.
Released. 0.1.x are stable releases; 1.0.0 is being prepared through release candidates
(1.0.0-rcN), which GitHub lists as pre-releases.

## Building

Expand Down Expand Up @@ -54,7 +54,9 @@ scoop bucket add relix https://github.com/DarkCollective/scoop-relix
scoop install relix
```

Or download the archive for your platform from the
Homebrew and Scoop install the latest stable release; a release candidate leaves the tap
and the bucket alone. To try one, or to install without a package manager, download the
archive for your platform from the
[releases](https://github.com/DarkCollective/relix-cli/releases), check it against
`checksums.txt`, unpack it and put its `bin/` on your `PATH`.

Expand Down
5 changes: 3 additions & 2 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ plugins {
}

group = 'com.darkcollective.relix'
// A release is built with -PcliVersion=<version>; anything else is a snapshot.
version = findProperty('cliVersion') ?: '0.1.0-SNAPSHOT'
// A release is built with -PcliVersion=<version>; anything else is a snapshot of the line
// being worked towards (1.0.0, which sorts above 0.1.x and below its own release candidates).
version = findProperty('cliVersion') ?: '1.0.0-SNAPSHOT'

// The engine pin: the one place the engine's version is named.
//
Expand Down
2 changes: 1 addition & 1 deletion docs/guide/command-line/commands/doc.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ relix doc [OPTIONS] [TOPIC]

| Option | Description |
|---|---|
| `--function` | Look TOPIC up among the functions only, or list only them. |
| `--function` | Look TOPIC up among the functions only, or list only them. TOPIC may also be a function category (string, json), which lists its functions. |
| `--markdown` | Print the page's markdown as it is, not rendered. |
| `--no-pager` | Write the page to standard output even on a terminal. |
| `-o`, `--output=FORMAT` | The rows' format: table, tsv, csv, ndjson, json or markdown (default: $RELIX_OUTPUT, else relixrc's, else table on a terminal, tsv in a pipe). |
Expand Down
2 changes: 1 addition & 1 deletion docs/guide/command-line/commands/relix.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ Every command takes these, before or after its name.
| `--max-materialized-rows=N` | Fail a query in which one blocking operator buffers more than N rows. |
| `--max-processed-rows=N` | Fail a query that processes more than N rows in all. |
| `--timeout=DURATION` | Fail a query that runs longer than DURATION: 500ms, 30s, 5m, 1h, or ISO-8601 (PT30S). |
| `--sandbox=FILE` | Run under the sandbox FILE describes. |
| `--sandbox=FILE` | Run under the sandbox FILE describes: a JSON file, relative paths in it resolving against its own directory, with optional keys "declarations" (a .relix file of the only external sources, connections and imports scripts may use) and "limits" (maxInputChars, maxOutputRows, maxMaterializedRows, maxFixpointRounds, maxProcessedRows, timeoutMillis). An unknown key is an error. Where FILE and a --max-* option or --timeout both limit, the smaller applies. |
| `--remote` | Permit http(s) file locations. |
| `--allow-download` | Permit fetching a missing JDBC driver or connector during the run. |
| `-h`, `--help` | Show this help message and exit. |
Expand Down
24 changes: 20 additions & 4 deletions src/main/java/com/darkcollective/relix/cli/command/DocCommand.java
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
* <p>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
* {@code relix doc --function fix} is the function. With {@code --function}, a category name lists its functions. The page is rendered for a terminal
* and, on one, shown through {@code $PAGER}. With no topic, the pages are listed, as rows.
*
* <p>Pages are found by {@code relix-docs}' {@link ReferenceLookup}, over the language pages
Expand All @@ -67,7 +67,7 @@ final class DocCommand implements Callable<Integer> {
String topic;

@Option(names = "--function",
description = "Look TOPIC up among the functions only, or list only them.")
description = "Look TOPIC up among the functions only, or list only them. TOPIC may also be a function category (string, json), which lists its functions.")
boolean function;

@Option(names = "--markdown", description = "Print the page's markdown as it is, not rendered.")
Expand All @@ -87,6 +87,17 @@ public Integer call() {
list(invocation, index);
return ExitCode.SUCCESS.status();
}
if (function && index.function(topic).isEmpty()) {
// A function category, such as string or json, lists its functions. The
// categories are those the installed libraries declare, not a list kept here.
Optional<List<ReferencePage>> category = index.functionsByCategory().entrySet().stream()
.filter(entry -> entry.getKey().equalsIgnoreCase(topic))
.map(java.util.Map.Entry::getValue).findFirst();
if (category.isPresent()) {
listing(invocation, category.get());
return ExitCode.SUCCESS.status();
}
}
Optional<ReferencePage> found = function ? index.function(topic) : index.entry(topic);
ReferencePage page = found.orElseThrow(() -> new CommandFailure(ExitCode.USAGE,
"no reference page for '" + topic + "'" + (function ? " among the functions" : "")
Expand All @@ -107,13 +118,18 @@ public Integer call() {

/** The language pages, in the reference's order, unless --function; then the functions'. */
private void list(Invocation invocation, ReferenceLookup index) {
Listing listing = new Listing("topic", ScalarType.STRING, "category", ScalarType.STRING,
"title", ScalarType.STRING, "summary", ScalarType.STRING);
List<ReferencePage> pages = new ArrayList<>();
if (!function) {
pages.addAll(RelixDocs.referencePages());
}
index.functionsByCategory().values().forEach(pages::addAll);
listing(invocation, pages);
}

/** The given pages as the listing's rows. */
private void listing(Invocation invocation, List<ReferencePage> pages) {
Listing listing = new Listing("topic", ScalarType.STRING, "category", ScalarType.STRING,
"title", ScalarType.STRING, "summary", ScalarType.STRING);
for (ReferencePage page : pages) {
listing.add(page.symbol(), page.category(), page.title(), page.summary());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,14 @@ final class GlobalOptions {
Duration timeout;

@Option(names = "--sandbox", paramLabel = "FILE", scope = ScopeType.INHERIT,
description = "Run under the sandbox FILE describes.")
description = {
"Run under the sandbox FILE describes: a JSON file, relative paths in it",
"resolving against its own directory, with optional keys \"declarations\"",
"(a .relix file of the only external sources, connections and imports scripts",
"may use) and \"limits\" (maxInputChars, maxOutputRows, maxMaterializedRows,",
"maxFixpointRounds, maxProcessedRows, timeoutMillis). An unknown key is an",
"error. Where FILE and a --max-* option or --timeout both limit, the smaller",
"applies."})
Path sandbox;

@Option(names = "--remote", scope = ScopeType.INHERIT,
Expand Down
37 changes: 31 additions & 6 deletions src/main/java/com/darkcollective/relix/cli/io/Host.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/
package com.darkcollective.relix.cli.io;

import java.io.Console;
import java.io.FileDescriptor;
import java.io.FileOutputStream;
import java.io.InputStream;
Expand Down Expand Up @@ -67,18 +68,42 @@ public record Host(InputStream in, OutputStream out, PrintStream err,
* @return the real streams, environment and directories
*/
public static Host system() {
// On Java 21 a null console is the pipe check: System.console() is null when
// standard input or output is redirected, and it cannot tell which. Both are taken
// for pipes then, so `echo … | relix` writes tsv to a terminal; pass -o to choose.
// Java 22 returns a console for a pipe too, and this has to move to
// Console.isTerminal() with it.
boolean terminal = System.console() != null;
// Java 21: System.console() is null when standard input or output is redirected, and
// it cannot tell which, so both are taken for pipes then and `echo … | relix` writes
// tsv to a terminal; pass -o to choose. Java 22 returns a console for a pipe too and
// adds Console.isTerminal(), which is asked for when it exists. The command is built
// on 21, so the method is reached reflectively.
boolean terminal = terminal(System.console() != null, isTerminal(System.console()));
return new Host(System.in, new FileOutputStream(FileDescriptor.out), System.err,
System.getenv(),
Path.of(System.getProperty("user.dir")), Path.of(System.getProperty("user.home")),
terminal, terminal);
}

/**
* Whether the process is on a terminal.
*
* @param console the JVM's console, or null when it has none
* @param verdict what {@code Console.isTerminal()} says of it, or null on a JVM that
* has no such method (before 22), where a console is all there is to go on
* @return false without a console; otherwise the verdict, else true
*/
static boolean terminal(boolean console, Boolean verdict) {
return console && (verdict == null || verdict);
}

/** {@code Console.isTerminal()} of the JVM's console, or null before Java 22. */
private static Boolean isTerminal(Console console) {
if (console == null) {
return null;
}
try {
return (Boolean) Console.class.getMethod("isTerminal").invoke(console);
} catch (ReflectiveOperationException e) {
return null;
}
}

/**
* An environment variable, when it is set and not empty.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@
package com.darkcollective.relix.cli.command;

import com.darkcollective.relix.cli.Cli;
import com.darkcollective.relix.docs.ReferenceLookup;
import com.darkcollective.relix.docs.RelixDocs;
import com.darkcollective.relix.function.FunctionCatalog;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
Expand Down Expand Up @@ -226,6 +228,22 @@ void functions() {
assertThat(result.out().lines().skip(1)).isNotEmpty().allMatch(l -> l.split("\t")[1].equals("function"));
}

@Test
@DisplayName("--function with a function category lists that category's functions, "
+ "whatever categories the installed engine has")
void category() {
var categories = ReferenceLookup.of(FunctionCatalog.discover()).functionsByCategory();
assertThat(categories).isNotEmpty();
categories.forEach((name, pages) -> {
var result = cli().piped().run("doc", "--function", name);

assertThat(result.status()).as(name).isZero();
assertThat(result.out().lines().skip(1).map(l -> l.split("\t")[0]).toList())
.as(name).containsExactlyInAnyOrderElementsOf(
pages.stream().map(page -> page.symbol()).toList());
});
}

@Test
@DisplayName("resolves every key of every language page")
void everyKey() {
Expand Down
47 changes: 47 additions & 0 deletions src/test/java/com/darkcollective/relix/cli/io/HostTest.java
Original file line number Diff line number Diff line change
@@ -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.io;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

/** How the process decides that it is on a terminal. */
@DisplayName("The host's terminal test")
class HostTest {

@Test
@DisplayName("is false without a console, whatever isTerminal would say")
void noConsole() {
assertThat(Host.terminal(false, true)).isFalse();
assertThat(Host.terminal(false, null)).isFalse();
}

@Test
@DisplayName("is the console's own answer where the JVM has Console.isTerminal (22+), "
+ "so a pipe is not a terminal although it has a console")
void verdict() {
assertThat(Host.terminal(true, false)).isFalse();
assertThat(Host.terminal(true, true)).isTrue();
}

@Test
@DisplayName("is the console's presence on a JVM without isTerminal (21)")
void noVerdict() {
assertThat(Host.terminal(true, null)).isTrue();
}
}
Loading