diff --git a/README.md b/README.md index 8e9f3ca..a59608d 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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`. diff --git a/build.gradle b/build.gradle index ab2278b..4da423e 100644 --- a/build.gradle +++ b/build.gradle @@ -15,8 +15,9 @@ plugins { } group = 'com.darkcollective.relix' -// A release is built with -PcliVersion=; anything else is a snapshot. -version = findProperty('cliVersion') ?: '0.1.0-SNAPSHOT' +// A release is built with -PcliVersion=; 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. // diff --git a/docs/guide/command-line/commands/doc.md b/docs/guide/command-line/commands/doc.md index e08bb14..e4c0165 100644 --- a/docs/guide/command-line/commands/doc.md +++ b/docs/guide/command-line/commands/doc.md @@ -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). | diff --git a/docs/guide/command-line/commands/relix.md b/docs/guide/command-line/commands/relix.md index d8ba5ea..3758fb2 100644 --- a/docs/guide/command-line/commands/relix.md +++ b/docs/guide/command-line/commands/relix.md @@ -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. | diff --git a/src/main/java/com/darkcollective/relix/cli/command/DocCommand.java b/src/main/java/com/darkcollective/relix/cli/command/DocCommand.java index f1dc6cc..3b4a7b0 100644 --- a/src/main/java/com/darkcollective/relix/cli/command/DocCommand.java +++ b/src/main/java/com/darkcollective/relix/cli/command/DocCommand.java @@ -42,7 +42,7 @@ *

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. * *

Pages are found by {@code relix-docs}' {@link ReferenceLookup}, over the language pages @@ -67,7 +67,7 @@ final class DocCommand implements Callable { 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.") @@ -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> 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 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" : "") @@ -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 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 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()); } diff --git a/src/main/java/com/darkcollective/relix/cli/command/GlobalOptions.java b/src/main/java/com/darkcollective/relix/cli/command/GlobalOptions.java index 1974246..a44d21c 100644 --- a/src/main/java/com/darkcollective/relix/cli/command/GlobalOptions.java +++ b/src/main/java/com/darkcollective/relix/cli/command/GlobalOptions.java @@ -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, diff --git a/src/main/java/com/darkcollective/relix/cli/io/Host.java b/src/main/java/com/darkcollective/relix/cli/io/Host.java index cf7d7b6..5d160f8 100644 --- a/src/main/java/com/darkcollective/relix/cli/io/Host.java +++ b/src/main/java/com/darkcollective/relix/cli/io/Host.java @@ -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; @@ -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. * diff --git a/src/test/java/com/darkcollective/relix/cli/command/ManagementCommandsTest.java b/src/test/java/com/darkcollective/relix/cli/command/ManagementCommandsTest.java index bf546ee..f179858 100644 --- a/src/test/java/com/darkcollective/relix/cli/command/ManagementCommandsTest.java +++ b/src/test/java/com/darkcollective/relix/cli/command/ManagementCommandsTest.java @@ -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; @@ -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() { diff --git a/src/test/java/com/darkcollective/relix/cli/io/HostTest.java b/src/test/java/com/darkcollective/relix/cli/io/HostTest.java new file mode 100644 index 0000000..f408171 --- /dev/null +++ b/src/test/java/com/darkcollective/relix/cli/io/HostTest.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.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(); + } +}