on master help sign in
Make CLI commands discoverable with help and search (#3)
commit34050d140995d536b2c10e1e98000e006b2b3734 authoriko <mail@iko.soy> dateSat 10 Oct 2026, 19:09 (3 days ago) parent7d45adf
9 files changed+946−46
CLAUDE.md+3−2 README.md+36−9 config.j+16 docs/language.md+2−1 specs/main.md+66−21 src/help.rs+520 src/jj.rs+1−1 src/main.rs+92−12 tests/help.rs+210
CLAUDE.md+3−2
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
44
55## What this is
66
7−`j` is a CLI for Jujutsu (jj) repositories whose interface is a small, pure, dynamically typed functional language. A repository is a value (`Repo`, a zipper over the commit tree), a command is a function `Repo -> Repo`, and the whole user-facing vocabulary (`new`, `describe`, `squash`, `tree`, …) is written *in the language* in `config.j`. The Rust binary supplies only the language, the builtins `config.j` declares, eight reserved commands, and the bridge to jj's on-disk format via `jj-lib` (no `jj` binary needed at runtime).
7+`j` is a CLI for Jujutsu (jj) repositories whose interface is a small, pure, dynamically typed functional language. A repository is a value (`Repo`, a zipper over the commit tree), a command is a function `Repo -> Repo`, and the whole user-facing vocabulary (`new`, `describe`, `squash`, `tree`, …) is written *in the language* in `config.j`. The Rust binary supplies only the language, the builtins `config.j` declares, ten reserved commands, and the bridge to jj's on-disk format via `jj-lib` (no `jj` binary needed at runtime).
88
99## Commands
1010
@@ -28,7 +28,6 @@ There is no devShell in `flake.nix`; `cargo`/`rustc` come from the system. The N
2828`specs/main.md` is the specification the implementation was written from, and the code cites it everywhere as `§N.M` (e.g. `§7.5` = persistence). When changing behaviour, find the section first, and keep code, spec, and tests agreeing. `specs/tree.md` is a newer, complete replacement for `§7.11` (the "rails" tree layout) and is what `render.rs` implements.
2929
3030Known drift to be aware of (not to copy):
31−- `README.md` links to `SPEC.md`; the file is `specs/main.md`.
3231- `§9` of the spec is a verbatim copy of `config.j`, but `config.j` is ahead of it (`touchedPaths`, the extra `treeWith` options, `treeCompact`, `treeData`). **`config.j` is the source of truth**; `docs/base.md` lags the same way.
3332
3433## Architecture
@@ -43,6 +42,8 @@ Language pipeline, all in `src/`:
4342- `builtins.rs`: native implementations of the names `config.j` declares without defining.
4443- `config.rs`: parses and validates `config.j`, orders definitions by dependency, resolves `@id` literals. Four definitions are read by the binary itself: `user`, `tree`, `immutable`, `labelled`.
4544
45+`help [NAME]` and `commands [QUERY]` are reserved discovery commands; `--help` and `-h` alias `help` only as the first word. They never open a repository or evaluate config definitions. General and reserved-command help work without config; the custom vocabulary reads config without writing it, using the embedded reference only when no file exists. Keep these paths usable outside a repository and when general help is needed to recover from a broken config.
46+
4647Repository side:
4748- `domain.rs`: the `Backend` trait — everything repository-shaped the interpreter needs — plus `MemBackend` (the test double required by spec §10) and snapshot helpers shared by both backends.
4849- `jj.rs`: `JjBackend` on `jj-lib`/`gix`: locating the repo, building the `Repo` value, `persist`, the lock, and the reserved commands (`init`, `clone`, `remote`, `fetch`, `push`, `undo`, `redo`, `ops`). Reserved commands are matched on the first word in `main.rs` before any parsing and are not part of the language.
README.md+36−9
@@ -27,6 +27,27 @@ The whole vocabulary — `new`, `describe`, `squash`, `tree`, `rebase`, … —
2727written *in the language* in `config.j`, and you extend it by writing more
2828functions.
2929
30+## Find a command
31+
32+```
33+j help -- a quick start, grouped commands, and examples
34+j commands -- browse the vocabulary in your config
35+j commands rebase -- search names, signatures, and descriptions
36+j help describe -- see a command's signature, documentation, and examples
37+j help push -- learn a reserved command's usage
38+```
39+
40+`j --help` and `j -h` also open help. Help and command discovery work outside
41+a repository and never change your files or configuration. The catalog includes
42+your own definitions, even ones without a signature; when no config exists, it
43+uses the embedded reference config. General help and help for reserved commands
44+are available even if your config is broken.
45+
46+To start working, run `j init` in a directory or `j clone URL`, then try
47+`j status` and `j tree`. Compose commands right to left:
48+`j 'describe "first change" . new'` creates a commit, then describes it.
49+Preview an edit with `j 'tree . validate . squash'`.
50+
3051---
3152
3253## Why `j`?
@@ -70,10 +91,11 @@ You'll want the reference `config.j` (see next section) and, for `review`/`difft
7091
7192## Configuration
7293
73−`j` reads `~/.config/j/config.j` on every run (or `$XDG_CONFIG_HOME/j/config.j`).
94+Expressions read `~/.config/j/config.j` (or `$XDG_CONFIG_HOME/j/config.j`).
7495An expression can only use a name this file declares or defines. The reference
75−[`config.j`](config.j) ships with the binary — copy it and edit at least the
76−identity:
96+[`config.j`](config.j) ships with the binary. Commands that execute against a
97+repository create an editable copy there when it is missing; help and discovery
98+use the embedded copy without writing it. Edit at least the identity:
7799
78100```
79101user = { name = "Your Name", email = "you@example.com" }
@@ -424,11 +446,13 @@ Then `j release` works like any built-in.
424446
425447## Reserved commands
426448
427−A handful of commands are built in (not expressions): they create or connect
428−repositories, move refs, and walk the operation log. None take flags.
449+A handful of commands are built in (not expressions): they provide help,
450+create or connect repositories, move refs, and walk the operation log.
429451
430452| command | what it does |
431453|---|---|
454+| `j help [NAME]` | show a quick start or help for a reserved command or config name |
455+| `j commands [QUERY]` | list the current vocabulary, optionally searching names, signatures, and comments |
432456| `j init` | create a colocated `.jj`/`.git` repository here (adopting an existing `.git`) |
433457| `j clone URL [DIR]` | clone `URL` into `DIR` (default: the repository's name, as `git clone` picks it) |
434458| `j remote URL` | set `origin`'s URL |
@@ -437,7 +461,10 @@ repositories, move refs, and walk the operation log. None take flags.
437461| `j undo` / `j redo` | walk the operation log |
438462| `j ops` | print the operation log |
439463
440−Everything else is an expression in the language.
464+`--help` and `-h` are aliases for `help` only as the first word. For example,
465+`j --help describe` opens help for `describe`; use `j help new` to learn about
466+`new`. There are no general command flags: `j 'new --help'` remains an expression
467+with a language comment. Everything else is an expression in the language.
441468
442469---
443470
@@ -449,7 +476,7 @@ Everything else is an expression in the language.
449476| **[docs/language.md](docs/language.md)** | The `j` language in detail: values, syntax, semantics, builtins, contracts, `show`, the `Repo` zipper, and many examples. Start here to learn the language. |
450477| **[docs/base.md](docs/base.md)** | The complete base vocabulary: every builtin and every `config.j` type and function, grouped logically, each with a description and examples, plus an alphabetical index. |
451478| **[docs/comparison.md](docs/comparison.md)** | A structured three-way comparison of git, jj, and `j`: concept mappings, side-by-side command tables, and a full workflow in each. |
452−| **[SPEC.md](SPEC.md)** | The normative specification of the language, the CLI, and repository semantics. |
479+| **[specs/main.md](specs/main.md)** | The normative specification of the language, the CLI, and repository semantics. |
453480| **[config.j](config.j)** | The reference configuration: the entire base vocabulary, written in the language. |
454481
455482---
@@ -457,11 +484,11 @@ Everything else is an expression in the language.
457484## Project layout
458485
459486```
460−config.j the reference base vocabulary (read on every run)
487+config.j the reference base vocabulary
461488src/ the j implementation (Rust, on jj-lib)
462489tests/ unit, property, and CLI end-to-end tests
463490docs/ language.md, base.md, comparison.md
464−SPEC.md the specification
491+specs/ the language, CLI, repository, and tree specifications
465492flake.nix Nix package: one binary, j, with difftastic as a runtime dep
466493```
467494
config.j+16
@@ -354,6 +354,8 @@ immutable = ancestorsOf trunk
354354-- Focusing. `goto` is the only place a revset must have exactly one member.
355355------------------------------------------------------------------------------
356356
357+-- Move to the single commit selected by a revset.
358+-- Example: j 'goto %feature'; use j 'new . goto trunk' to start on the main line.
357359goto : Revset -> Edit
358360goto = \rs repo ->
359361 let is = rs repo
@@ -433,10 +435,12 @@ rewrite = \f repo ->
433435------------------------------------------------------------------------------
434436
435437-- Set the message of the focused commit.
438+-- Example: j 'describe "fix login"'
436439describe : Text -> Edit
437440describe = \m -> mapRoot (\c -> c { message = m })
438441
439442-- Start an empty child of the focus and move to it.
443+-- Examples: j new; j 'describe "next change" . new'
440444new : Edit
441445new = \repo -> addChild (newCommit repo.root.files) repo
442446
@@ -444,6 +448,7 @@ new = \repo -> addChild (newCommit repo.root.files) repo
444448-- new empty commit on that parent becomes the focus (the parent may be
445449-- immutable, and the focus never is). Refused for a commit the remote has a
446450-- name for: push something else under that name first, or forget the name.
451+-- Example: j abandon; preview first with j 'tree . abandon'.
447452abandon : Edit
448453abandon = \repo ->
449454 let c = repo.root
@@ -453,17 +458,20 @@ abandon = \repo ->
453458 else new (remove (eachChild (replayOnto p) repo))
454459
455460-- Push the files matching m from the focus down into its parent.
461+-- Example: j 'contract (ext "rs")'
456462contract : Fileset -> Edit
457463contract = \m repo -> let a = repo.root
458464 in at parents (rewrite (\q -> q { files = select m a.files q.files })) repo
459465
460466-- Fold the focus entirely into its parent; a new empty commit becomes the focus.
467+-- Example: j squash; preview first with j 'tree . validate . squash'.
461468squash : Edit
462469squash = abandon . contract everything
463470
464471-- Push the files matching m from the focus up into a new child, which becomes
465472-- the focus and inherits the old focus's children. Labels stay with the
466473-- original commit, since they are the remote's.
474+-- Example: j 'split (ext "rs")'; use (under ./src) to select a directory.
467475split : Fileset -> Edit
468476split = \m repo ->
469477 let a = repo.root
@@ -473,6 +481,7 @@ split = \m repo ->
473481 in by x.id (repo { root = a', children = [{ root = x, children = repo.children }] })
474482
475483-- Move the focused subtree onto another commit. Returns to the moved commit.
484+-- Example: j 'rebase trunk'; preview with j 'tree . validate . rebase trunk'.
476485rebase : Revset -> Edit
477486rebase = \rs repo ->
478487 let d = (goto rs repo).root
@@ -483,6 +492,7 @@ rebase = \rs repo ->
483492 in by me (attach x.subtree (by d.id x.rest))
484493
485494-- Apply the focused commit's change onto another commit, as a new child there.
495+-- Example: j 'pick %main'
486496pick : Revset -> Edit
487497pick = \rs repo ->
488498 let ch = changeOf repo
@@ -490,6 +500,7 @@ pick = \rs repo ->
490500 in at rs (addChild (newCommit (replay t.files ch))) repo
491501
492502-- A new child of the focus that undoes the change of the given commit.
503+-- Example: j 'backout %feature'
493504backout : Revset -> Edit
494505backout = \rs repo ->
495506 let ch = changeOf (goto rs repo)
@@ -542,6 +553,7 @@ touched : Change -> [Path]
542553touched = touchedPaths
543554
544555-- Paths whose content differs between the focus and its parent.
556+-- Example: j changed
545557changed : Repo -> [Path]
546558changed = \repo -> touched (changeOf repo)
547559
@@ -556,12 +568,14 @@ diffs = \repo ->
556568-- readable in the terminal. A focus that changes nothing reads "no changes";
557569-- a difft that cannot be run (not on PATH) is a crash saying so, and
558570-- otherwise whatever difft prints, even nothing, is the page.
571+-- Example: j review (requires difftastic's difft on PATH).
559572review : Repo -> Text
560573review = \repo ->
561574 let ds = diffs repo
562575 in if null ds then "no changes\n" else concat (map (\d -> d.diff) ds)
563576
564577-- A summary of the focus.
578+-- Example: j status
565579status : Repo -> { id : Id, message : Text, labels : [Label], changed : [Path], conflicts : [Path] }
566580status = \repo ->
567581 { id = repo.root.id
@@ -571,6 +585,7 @@ status = \repo ->
571585 , conflicts = conflicted repo.root.files }
572586
573587-- A summary of every commit, top first, in preorder.
588+-- Example: j log
574589log : Repo -> [{ id : Id, message : Text, labels : [Label], focus : Bool }]
575590log = \repo ->
576591 map (\c -> { id = c.id
@@ -583,6 +598,7 @@ log = \repo ->
583598-- (The parentheses are required: `f { … }` would be a record update of f.)
584599-- The default `tree` shows every commit, sorted by time (parents always above
585600-- their children); `treeCompact` folds uninteresting runs and far subtrees.
601+-- Examples: j tree; j 'tree . squash' previews an edit without saving it.
586602tree : Repo -> Text
587603tree = treeWith ({ detail = 1, margin = false, elide = false, icons = false, color = "auto", lanes = 4, author = false, date = false, files = false })
588604
docs/language.md+2−1
@@ -3,7 +3,8 @@
33`j` is a small, pure, functional language. Every `j` command evaluates one
44expression in this language against the current repository. The language is
55the whole interface: there are no subcommands beyond a handful of reserved
6−words, and no flags.
6+words, and no general flags. As the first word, `--help` and `-h` are aliases
7+for `help`; use `j help` for a command guide or `j commands` to browse names.
78
89This document describes the language itself — its values, syntax, semantics,
910and the builtins — with examples throughout. Every example shows an expression
specs/main.md+66−21
@@ -9,7 +9,7 @@ Nothing is implicit beyond the language itself. Every shape, builtin, and
99command is declared or defined in one user-owned file, `~/.config/j/config.j`,
1010written in the language. The binary provides the language (its syntax, its
1111operators and their fixities, its primitive kinds), the implementations of the
12−declared builtins, eight reserved words, and the bridge to jj repositories.
12+declared builtins, ten reserved words, and the bridge to jj repositories.
1313`j` does not require the `jj` binary; it uses jj's on-disk formats through
1414jj-lib.
1515
@@ -41,9 +41,14 @@ echo EXPRESSION | j
4141 unquoted.
42423. If arguments are present and rule 1 also applies, exit 2 with
4343 `j: expression given both as arguments and on stdin`.
44−4. If neither is present, print a one-line usage message to stderr and exit 2.
45−5. `j` accepts no flags. Any argument, including ones beginning with `-`, is
46− part of the expression.
44+4. If neither is present, or the input is only whitespace, print a concise
45+ usage and getting-started guide to stderr and exit 2. The guide points to
46+ `j help`, `j commands`, and commands for starting or inspecting a repository.
47+ It does not read or create configuration or open a repository.
48+5. `--help` and `-h` are aliases for the reserved command `help` only when
49+ they are the first whitespace-separated word (§1.1). There are no other
50+ flags or trailing help flags: `j 'new --help'` is an expression whose
51+ `--help` is a language comment, and `j '"--help"'` prints text.
47526. The expression is text: an argument that is not valid UTF-8 is a usage
4853 error (exit 2) naming it, and so is an expression on standard input that
4954 is not.
@@ -52,10 +57,13 @@ echo EXPRESSION | j
5257
5358Before the expression is parsed, its text is trimmed of leading and trailing
5459whitespace and split on whitespace into words. If the first word is one of the
55−eight reserved words below, the whole text is a reserved command.
60+ten reserved words below, the whole text is a reserved command. A first word
61+of `--help` or `-h` is treated as `help`.
5662
5763| command | operation |
5864|---|---|
65+| `help [NAME]` | show the command guide, or help for a reserved command or config name; §1.1.1 |
66+| `commands [QUERY]` | list the vocabulary, optionally searching names, signatures, and comments; §1.1.1 |
5967| `init` | create a repository in the current directory; §7.8 |
6068| `clone URL [DIR]` | clone `URL` into `DIR` (default: the repository's name in `URL`); §7.8 |
6169| `remote URL` | set the URL of the remote `origin`, creating it if absent; §7.8 |
@@ -65,24 +73,53 @@ eight reserved words below, the whole text is a reserved command.
6573| `redo` | reverse the newest undo; §7.7 |
6674| `ops` | print the operation log; §7.7 |
6775
68−For every reserved command except `push`, the remaining words are literal
69−arguments (no quoting, no escaping); a wrong number is a usage error (exit 2)
70−and the language is not involved. For `push`, the remainder of the text is an
71−expression, evaluated as in §1.2 steps 2–7 but against the repository as
72−recorded, without snapshotting the working directory; its value must be a list
73−of push records (§7.6).
76+For every reserved command except `push` and `commands`, the remaining words
77+are literal arguments (no quoting, no escaping); a wrong number is a usage
78+error (exit 2) and the language is not involved. `commands` takes the remaining
79+trimmed text as its optional search query, including spaces. For `push`, the
80+remainder of the text is an expression, evaluated as in §1.2 steps 2–7 but
81+against the repository as recorded, without snapshotting the working directory;
82+its value must be a list of push records (§7.6).
7483
7584Matching is on the first word only, so an expression whose first token is one
7685of these words cannot be written on the command line. `(push)` or `x fetch`
7786are ordinary expressions. `config.j` may define functions with these names;
7887they are reachable only inside larger expressions.
7988
89+#### 1.1.1 Help and command discovery
90+
91+`help` without a name prints a getting-started guide to stdout: repository
92+setup, common commands grouped by task, examples, expression quoting and
93+composition, dry runs, and how to find more commands. `help NAME` prints help
94+for that reserved command or for a name in the current configuration, including
95+its signature when present and available documentation. `--help [NAME]` and
96+`-h [NAME]` are equivalent to `help [NAME]`. These successful requests exit 0.
97+An unknown name is a usage error (exit 2) with guidance to use `j commands`.
98+
99+`commands` lists the reserved commands and the vocabulary actually declared or
100+defined in the current configuration, including custom definitions without
101+signatures. `commands QUERY` searches the catalog case-insensitively by name,
102+signature, and documentation comments. The catalog does not present an
103+undeclared builtin as available merely because the binary implements it.
104+
105+General help and help for reserved commands do not depend on configuration and
106+remain available if it is unreadable or invalid. Discovery of configuration
107+names reads the configured file (§6.1) without creating or modifying it. If the
108+file is missing, it uses the embedded reference configuration; other read or
109+parse errors exit 3 rather than silently substituting a different catalog.
110+The config is never evaluated for discovery, so its definitions can refer to
111+repository state without preventing their documentation from being read.
112+
113+Neither command locates or opens a repository, takes a repository lock,
114+resolves commit ids, snapshots the working directory, or records operations.
115+
80116### 1.2 Evaluating the expression
81117
821181. Locate the repository (§7.1). If none, exit 2.
831192. Parse the expression (§3). On parse error, exit 3.
841203. Load and validate `config.j` (§6), except for its `Id` literals. If it is
85− missing or invalid, exit 3.
121+ missing, create the reference configuration (§6.1); if loading or
122+ validation fails, exit 3.
861234. Build the current repository value `r` (§7.2). This includes snapshotting
87124 the working directory into the focused commit (§7.4).
881255. Resolve every `Id` literal in `config.j` and in the expression (§4.10).
@@ -785,14 +822,20 @@ Since `show` returns a `Text` and a top-level `Text` displays raw,
785822### 6.1 Location and loading
786823
787824`j` reads `$XDG_CONFIG_HOME/j/config.j`, defaulting to `~/.config/j/config.j`.
788−It is read by every invocation except `remote`, `fetch`, `undo`, `redo`, and
789−`ops`: expression evaluation and `push` need its definitions, `init` and
825+Expression evaluation, `push`, and `fetch` load the configuration; `init` and
790826`clone` need `user`. When there is no repository yet (`init`, `clone`), only
791827`user` is evaluated; `Id` literals are not resolved and no other definition
792−is evaluated. If it does not exist, `j` exits 3 with
793−`j: no config at <path>; the reference config.j is installed at <share-path>`.
794−The binary is distributed with the reference `config.j` (§9) at
795−`<prefix>/share/j/config.j`; `j` never writes to the config path itself.
828+is evaluated. `remote`, `undo`, `redo`, and `ops` do not load configuration.
829+When a command needs configuration and none exists, the binary writes its
830+embedded reference `config.j` (§9) to that path as an editable starting point.
831+An existing user configuration is not updated when the binary changes. The
832+reference file is also distributed at `<prefix>/share/j/config.j`.
833+
834+Help and command discovery are read-only exceptions (§1.1.1): general help and
835+reserved-command help need no config, while the catalog and help for config
836+names read it without evaluation. If it is missing they use the embedded
837+reference in memory without writing a file. The empty-invocation guide does
838+not read or write configuration either.
796839
797840### 6.2 Validation
798841
@@ -1280,7 +1323,8 @@ operation is marked. Rendered in the binary like `tree`.
12801323duration.
12811324A second `j` exits 2 with `j: another j is running in this repository`.
12821325Display-only runs are not distinguishable in advance, so they take the lock
1283−too; only reserved commands that need no repository (`init`, `clone`) do not.
1326+too; reserved commands that need no repository (`help`, `commands`, `init`,
1327+`clone`) do not.
12841328The lock prevents `j` from ever producing two commits with one change id.
12851329Other tools operating concurrently are outside `j`'s control; if they produce
12861330that state, §7.2 applies.
@@ -2268,5 +2312,6 @@ and are read-only); hunk-level `split`; conflict resolution inside the
22682312language (blobs are opaque); workspaces other than the default; remotes other
22692313than `origin`; tags; submodules and LFS; ignore rules beyond `.gitignore`;
22702314changing file modes or symlinks from the language; repairing a repository in
2271−which one change id has two visible commits (refused; §7.2); a `--help` flag
2272−or any flag at all; static typing. None of these are precluded by the design.
2315+which one change id has two visible commits (refused; §7.2); general command
2316+flags beyond the first-word `--help` and `-h` aliases; static typing. None of
2317+these are precluded by the design.
src/help.rsnew file+520
@@ -0,0 +1,520 @@
1+//! Read-only command discovery, built from the user's unevaluated config.
2+
3+use j::ast::{Item, TypeExpr};
4+use j::config;
5+use j::lex::{lex, Tok};
6+use j::parse::parse_config;
7+use std::collections::BTreeMap;
8+use std::rc::Rc;
9+
10+pub const OVERVIEW: &str = r#"j — a functional CLI for Jujutsu repositories
11+
12+Usage:
13+ j EXPRESSION… Evaluate an expression
14+ printf '%s\n' 'tree' | j Read an expression from stdin
15+ j help [NAME] Explain a command or config name
16+ j commands [QUERY] Search names, signatures, and documentation
17+
18+Start a repository:
19+ j init Create a repository in the current directory
20+ j clone URL [DIR] Clone a repository
21+
22+Inspect (default config):
23+ j tree Show the history and current focus
24+ j status Show the focused commit and changed paths
25+ j review Show changes with difftastic (difft on PATH)
26+ j log List commits
27+
28+Edit (default config):
29+ j 'describe "add a feature"' Set the focused commit's message
30+ j new Start an empty child and move to it
31+ j prev Move to the parent commit
32+ j 'new . goto trunk' Start a new commit on the main line
33+ j squash Fold the focus into its parent
34+ j 'rebase trunk' Move the focused subtree onto the main line
35+
36+Preview and recover:
37+ j 'tree . squash' Preview a squash without saving the edit
38+ j undo Undo the last operation
39+ j redo Reapply an undone operation
40+ j ops Show the operation log
41+
42+Sync:
43+ j remote URL Set the origin remote
44+ j fetch Fetch from origin
45+ j 'push (label "feature" here)' Publish the focus as a remote bookmark
46+ j push relabel Update existing remote bookmarks
47+
48+Expressions compose right to left: `describe "wip" . new` runs new first.
49+A function result receives the current repository. A Repo result saves the
50+edit; other results print. Quote the whole expression with shell single
51+quotes to preserve text's double quotes and operators such as *, <, and >.
52+
53+Your config.j defines the vocabulary: edits (Repo -> Repo), inspections,
54+revsets, filesets, types, and custom names. Search with `j commands rebase`
55+or `j commands Edit`; use `j help squash` or `j help commands` for details.
56+"#;
57+
58+struct Reserved {
59+ name: &'static str,
60+ usage: &'static str,
61+ summary: &'static str,
62+ detail: &'static str,
63+}
64+
65+const RESERVED: &[Reserved] = &[
66+ Reserved {
67+ name: "clone",
68+ usage: "j clone URL [DIR]",
69+ summary: "Clone a repository.",
70+ detail: "Clone URL into DIR, or into a directory named after the repository.\n\nExample:\n j clone https://example.com/team/project.git project\n",
71+ },
72+ Reserved {
73+ name: "commands",
74+ usage: "j commands [QUERY]",
75+ summary: "List and search the commands and config vocabulary.",
76+ detail: "List CLI commands and the names declared or defined in config.j, including\ncustom definitions, builtins, operators, and types. QUERY matches names,\nsignatures, and source comments, ignoring case.\n\nThe config is parsed and validated without evaluating definitions or opening\na repository. No config file is created; when absent, defaults are shown.\n\nExamples:\n j commands\n j commands rebase\n j commands Edit\n j commands 'remote bookmark'\n j help squash\n",
77+ },
78+ Reserved {
79+ name: "fetch",
80+ usage: "j fetch",
81+ summary: "Fetch commits and bookmarks from origin.",
82+ detail: "Fetch from the origin remote and import its commits and bookmarks.\nSet the remote with `j remote URL`.\n\nExample:\n j fetch\n",
83+ },
84+ Reserved {
85+ name: "help",
86+ usage: "j help [NAME]",
87+ summary: "Show getting-started help or explain a name.",
88+ detail: "With no NAME, show common commands and the expression model. With NAME,\nshow CLI usage or a config name's signature, documentation, and source line.\nConfig help reads declarations without evaluating them or opening a repository.\n\nExamples:\n j help\n j help push\n j help squash\n j help Edit\n j help '(.)'\n\nUse `j commands QUERY` to find names by purpose or signature.\n",
89+ },
90+ Reserved {
91+ name: "init",
92+ usage: "j init",
93+ summary: "Create a repository in the current directory.",
94+ detail: "Create a colocated .jj/.git repository and a working-copy commit. An\nexisting .git repository is adopted. Set your identity in config.j's `user`\ndefinition before creating commits.\n\nExample:\n mkdir project\n cd project\n j init\n",
95+ },
96+ Reserved {
97+ name: "ops",
98+ usage: "j ops",
99+ summary: "Show the operation log.",
100+ detail: "Print the operation history, newest first, with the current operation\nmarked by *. Undo and redo operations are labelled. This reads repository\nhistory without loading config.j.\n\nExample:\n j ops\n\nRelated: j help undo, j help redo\n",
101+ },
102+ Reserved {
103+ name: "push",
104+ usage: "j push EXPRESSION",
105+ summary: "Publish or delete bookmarks on origin.",
106+ detail: "Evaluate EXPRESSION to a list of Push and Drop records, then update origin.\nA function result receives the recorded repository; working-copy changes\nare not snapshotted by push. The default config provides label, unlabel,\nrename, and relabel helpers.\n\nExamples (default config):\n j 'push (label \"feature\" here)'\n j push relabel\n j 'push (unlabel \"feature\")'\n j 'push (rename \"old\" \"new\")'\n\nThe unlabel example deletes the remote bookmark.\n",
107+ },
108+ Reserved {
109+ name: "redo",
110+ usage: "j redo",
111+ summary: "Reapply an undone operation.",
112+ detail: "Reapply the operation removed by undo. Refuses if there is nothing to redo\nor if restoring the working copy would overwrite unsnapshotted changes.\n\nExample:\n j redo\n\nRelated: j help undo, j help ops\n",
113+ },
114+ Reserved {
115+ name: "remote",
116+ usage: "j remote URL",
117+ summary: "Set the origin remote URL.",
118+ detail: "Add origin or replace its URL. Fetch and push use this remote.\n\nExample:\n j remote https://example.com/team/project.git\n j fetch\n",
119+ },
120+ Reserved {
121+ name: "undo",
122+ usage: "j undo",
123+ summary: "Undo the last operation.",
124+ detail: "Restore the repository state before the last operation. Repeat to walk\nback through history. Refuses if restoring the working copy would overwrite\nunsnapshotted changes. Undo does not reverse changes already sent to a remote.\n\nExample:\n j undo\n\nRelated: j help redo, j help ops\n",
125+ },
126+];
127+
128+pub fn reserved_help(name: &str) -> Option<String> {
129+ RESERVED
130+ .iter()
131+ .find(|cmd| cmd.name == name)
132+ .map(|cmd| format!("Usage: {}\n\n{}\n\n{}", cmd.usage, cmd.summary, cmd.detail))
133+}
134+
135+#[derive(Clone, Copy)]
136+enum Kind {
137+ Definition,
138+ Builtin,
139+ Type,
140+}
141+
142+struct Entry {
143+ name: String,
144+ kind: Kind,
145+ signature: Option<TypeExpr>,
146+ line: usize,
147+ docs: Vec<String>,
148+}
149+
150+impl Entry {
151+ fn declaration(&self) -> String {
152+ let name = if config::is_reserved_builtin(&self.name)
153+ && !self.name.chars().next().unwrap_or('_').is_alphabetic()
154+ {
155+ format!("({})", self.name)
156+ } else {
157+ self.name.clone()
158+ };
159+ match (&self.signature, self.kind) {
160+ (Some(ty), Kind::Type) => format!("{} = {}", name, type_text(ty)),
161+ (Some(ty), _) => format!("{} : {}", name, type_text(ty)),
162+ (None, _) => format!("{} (definition; no signature)", name),
163+ }
164+ }
165+}
166+
167+pub fn command_list(src: &str, query: &str) -> Result<String, String> {
168+ let entries = entries(src)?;
169+ let query = query.trim().to_lowercase();
170+ let matches = |text: &str| text.to_lowercase().contains(&query);
171+ let mut out = String::new();
172+ let mut count = 0;
173+ for cmd in RESERVED {
174+ if matches(&format!("{} {} {}", cmd.usage, cmd.summary, cmd.detail)) {
175+ if count == 0 {
176+ out.push_str("CLI commands:\n");
177+ }
178+ out.push_str(&format!(" {:<24} {}\n", cmd.usage, cmd.summary));
179+ count += 1;
180+ }
181+ }
182+ let mut config_count = 0;
183+ for entry in entries {
184+ let declaration = entry.declaration();
185+ if !matches(&format!("{} {}", declaration, entry.docs.join(" "))) {
186+ continue;
187+ }
188+ if config_count == 0 {
189+ if count > 0 {
190+ out.push('\n');
191+ }
192+ out.push_str("Config vocabulary (definitions, builtins, operators, and types):\n");
193+ }
194+ out.push_str(&format!(" {}\n", declaration));
195+ if let Some(doc) = entry.docs.first() {
196+ out.push_str(&format!(" {}\n", truncate(doc, 110)));
197+ }
198+ config_count += 1;
199+ }
200+ if count + config_count == 0 {
201+ out.push_str("No matching commands or config names. Try `j commands` to list all names.\n");
202+ } else {
203+ out.push_str("\nUse `j help NAME` for details; `j commands QUERY` to narrow the list.\n");
204+ }
205+ Ok(out)
206+}
207+
208+pub fn command_help(src: &str, name: &str) -> Result<Option<String>, String> {
209+ let name = name.trim();
210+ let name = name
211+ .strip_prefix('(')
212+ .and_then(|n| n.strip_suffix(')'))
213+ .unwrap_or(name)
214+ .trim();
215+ let entry = entries(src)?.into_iter().find(|entry| entry.name == name);
216+ Ok(entry.map(|entry| {
217+ let kind = match entry.kind {
218+ Kind::Definition => "Config definition",
219+ Kind::Builtin => "Builtin declared in config",
220+ Kind::Type => "Type declared in config",
221+ };
222+ let mut out = format!(
223+ "{}\n\n{}\nSource: line {}\n",
224+ entry.declaration(),
225+ kind,
226+ entry.line
227+ );
228+ if !entry.docs.is_empty() {
229+ out.push('\n');
230+ for doc in entry.docs.iter().take(24) {
231+ out.push_str(doc);
232+ out.push('\n');
233+ }
234+ if entry.docs.len() > 24 {
235+ out.push_str("… See the source for the remaining comments.\n");
236+ }
237+ }
238+ out
239+ }))
240+}
241+
242+fn entries(src: &str) -> Result<Vec<Entry>, String> {
243+ let cfg = config::load_config(src).map_err(|err| err.message())?;
244+ let items = parse_config(src, Rc::new(config::reserved_set()))
245+ .map_err(|err| format!("line {}: {}", err.line, err.msg))?;
246+ let tokens = lex(src).map_err(|err| format!("line {}: {}", err.line, err.msg))?;
247+ let starts: Vec<_> = tokens
248+ .iter()
249+ .enumerate()
250+ .filter(|(_, token)| token.col == 1 && !matches!(token.tok, Tok::Newline | Tok::Eof))
251+ .map(|(index, _)| index)
252+ .collect();
253+ let comments = comments(src, &tokens);
254+ let mut entries = BTreeMap::<String, Entry>::new();
255+ for (index, item) in items.into_iter().enumerate() {
256+ let first = &tokens[starts[index]];
257+ let end = starts.get(index + 1).copied().unwrap_or(tokens.len());
258+ let last = tokens[starts[index]..end]
259+ .iter()
260+ .rev()
261+ .find(|token| !matches!(token.tok, Tok::Newline | Tok::Eof))
262+ .unwrap();
263+ let next_start = starts
264+ .get(index + 1)
265+ .map(|&i| tokens[i].start)
266+ .unwrap_or(src.len());
267+ let docs = item_docs(src, first.line, last.end, next_start, &comments);
268+ let (name, signature, kind) = match item {
269+ Item::TypeDecl(name, ty) => (name, Some(ty), Kind::Type),
270+ Item::Signature(name, ty, _) => {
271+ let kind = if cfg.builtin_names.contains(&name) {
272+ Kind::Builtin
273+ } else {
274+ Kind::Definition
275+ };
276+ (name, Some(ty), kind)
277+ }
278+ Item::Definition(name, _, _) => (name, None, Kind::Definition),
279+ };
280+ let entry = entries.entry(name.clone()).or_insert(Entry {
281+ name,
282+ signature,
283+ kind,
284+ line: first.line,
285+ docs: Vec::new(),
286+ });
287+ for doc in docs {
288+ if !entry.docs.contains(&doc) {
289+ entry.docs.push(doc);
290+ }
291+ }
292+ }
293+ let mut entries: Vec<_> = entries.into_values().collect();
294+ entries.sort_by_cached_key(|entry| (entry.name.to_lowercase(), entry.name.clone()));
295+ Ok(entries)
296+}
297+
298+fn type_text(ty: &TypeExpr) -> String {
299+ match ty {
300+ TypeExpr::Con(name) => name.clone(),
301+ TypeExpr::List(item) => format!("[{}]", type_text(item)),
302+ TypeExpr::Record(fields) => format!(
303+ "{{ {} }}",
304+ fields
305+ .iter()
306+ .map(|(name, ty)| format!("{} : {}", name, type_text(ty)))
307+ .collect::<Vec<_>>()
308+ .join(", ")
309+ ),
310+ TypeExpr::Fun(arg, result) => {
311+ let arg_text = type_text(arg);
312+ let arg_text = if matches!(arg.as_ref(), TypeExpr::Fun(_, _)) {
313+ format!("({})", arg_text)
314+ } else {
315+ arg_text
316+ };
317+ format!("{} -> {}", arg_text, type_text(result))
318+ }
319+ }
320+}
321+
322+fn truncate(text: &str, length: usize) -> String {
323+ let mut chars = text.chars();
324+ let prefix: String = chars.by_ref().take(length).collect();
325+ if chars.next().is_some() {
326+ format!("{}…", prefix)
327+ } else {
328+ prefix
329+ }
330+}
331+
332+struct Comment {
333+ start: usize,
334+ first_line: usize,
335+ last_line: usize,
336+ standalone: bool,
337+ continuation: bool,
338+ text: Vec<String>,
339+}
340+
341+/// Comments occupy gaps between lexer tokens, so text such as "-- not a doc"
342+/// inside string literals cannot be mistaken for documentation.
343+fn comments(src: &str, tokens: &[j::lex::SpTok]) -> Vec<Comment> {
344+ let mut comments = Vec::<Comment>::new();
345+ let mut cursor = 0;
346+ for token in tokens
347+ .iter()
348+ .filter(|token| !matches!(token.tok, Tok::Newline))
349+ {
350+ let mut rest = &src[cursor..token.start];
351+ while !rest.trim_start().is_empty() {
352+ rest = rest.trim_start();
353+ let start = token.start - rest.len();
354+ let (raw, consumed) = if let Some(body) = rest.strip_prefix("--") {
355+ let length = body.find('\n').unwrap_or(body.len());
356+ (&body[..length], length + 2)
357+ } else if rest.starts_with("{-") {
358+ let mut depth = 1;
359+ let mut offset = 2;
360+ while depth > 0 {
361+ if rest[offset..].starts_with("{-") {
362+ depth += 1;
363+ offset += 2;
364+ } else if rest[offset..].starts_with("-}") {
365+ depth -= 1;
366+ offset += 2;
367+ } else {
368+ offset += rest[offset..].chars().next().unwrap().len_utf8();
369+ }
370+ }
371+ (&rest[2..offset - 2], offset)
372+ } else {
373+ break;
374+ };
375+ let first_line = src[..start].bytes().filter(|&b| b == b'\n').count() + 1;
376+ let last_line = first_line + rest[..consumed].bytes().filter(|&b| b == b'\n').count();
377+ let line_start = src[..start].rfind('\n').map_or(0, |i| i + 1);
378+ let standalone = src[line_start..start].trim().is_empty();
379+ let continuation = standalone
380+ && start > line_start
381+ && comments.last().is_some_and(|previous| {
382+ previous.last_line + 1 == first_line
383+ && (!previous.standalone || previous.continuation)
384+ });
385+ comments.push(Comment {
386+ start,
387+ first_line,
388+ last_line,
389+ standalone,
390+ continuation,
391+ text: raw.lines().map(|line| line.trim().to_string()).collect(),
392+ });
393+ rest = &rest[consumed..];
394+ }
395+ cursor = token.end;
396+ }
397+ comments
398+}
399+
400+fn item_docs(
401+ src: &str,
402+ first_line: usize,
403+ end: usize,
404+ next_start: usize,
405+ comments: &[Comment],
406+) -> Vec<String> {
407+ let mut preceding = Vec::new();
408+ let mut line = first_line.saturating_sub(1);
409+ while let Some(comment) = comments
410+ .iter()
411+ .rev()
412+ .find(|comment| comment.last_line == line && comment.standalone && !comment.continuation)
413+ {
414+ if comment
415+ .text
416+ .iter()
417+ .all(|text| text.chars().all(|c| "-=_ ".contains(c)))
418+ {
419+ break;
420+ }
421+ preceding.push(comment);
422+ line = comment.first_line.saturating_sub(1);
423+ }
424+ preceding.reverse();
425+ let last_line = src[..end].bytes().filter(|&b| b == b'\n').count() + 1;
426+ let inline = comments.iter().filter(|comment| {
427+ comment.first_line >= first_line
428+ && comment.start < next_start
429+ && (comment.first_line <= last_line || comment.continuation)
430+ });
431+ preceding
432+ .into_iter()
433+ .chain(inline)
434+ .flat_map(|comment| comment.text.iter())
435+ .filter(|text| !text.is_empty() && !text.chars().all(|c| "-=_ ".contains(c)))
436+ .cloned()
437+ .collect()
438+}
439+
440+#[cfg(test)]
441+mod tests {
442+ use super::*;
443+
444+ const BASE: &str = "user = {}\nimmutable = []\ntree = 0\nlabelled = 0\n";
445+
446+ #[test]
447+ fn lists_custom_names_types_and_docs_without_evaluation() {
448+ let src = format!("{BASE}-- A custom greeting.\nhello = crash \"not evaluated\"\nAlias = Text -- a useful type\n");
449+ let list = command_list(&src, "CUSTOM GREETING").unwrap();
450+ assert!(list.contains("hello (definition; no signature)"));
451+ assert!(!list.contains("immutable"));
452+ let help = command_help(&src, "Alias").unwrap().unwrap();
453+ assert!(help.contains("Alias = Text"));
454+ assert!(help.contains("a useful type"));
455+ assert!(help.contains("Source: line 7"));
456+ }
457+
458+ #[test]
459+ fn signatures_keep_function_argument_parentheses() {
460+ let src = format!("{BASE}(.) : (b -> c) -> (a -> b) -> a -> c -- compose\n");
461+ let help = command_help(&src, "(.)").unwrap().unwrap();
462+ assert!(help.contains("(.) : (b -> c) -> (a -> b) -> a -> c"));
463+ assert!(help.contains("Builtin declared in config"));
464+ assert!(help.contains("compose"));
465+ }
466+
467+ #[test]
468+ fn comments_handle_strings_blocks_and_multiline_types() {
469+ let src = format!("{BASE}message = \"-- not a doc\"\n\n{{- Useful\n block docs. -}}\nShape = {{ value : Text\n , count : Int }} -- shape details\n -- continued details\n\n-- Different docs.\nother = 1\n");
470+ let message = command_help(&src, "message").unwrap().unwrap();
471+ assert!(!message.contains("not a doc"));
472+ let shape = command_help(&src, "Shape").unwrap().unwrap();
473+ assert!(shape.contains("Useful\nblock docs."));
474+ assert!(shape.contains("shape details\ncontinued details"));
475+ assert!(!shape.contains("Different docs"));
476+ assert!(shape.contains("Source: line 9"));
477+ }
478+
479+ #[test]
480+ fn inline_continuations_belong_to_the_preceding_declaration() {
481+ let src = format!("{BASE}first = 1 -- first comment\n -- first continuation\nsecond = 2\n\n -- third comment\nthird = 3\n");
482+ let first = command_help(&src, "first").unwrap().unwrap();
483+ assert!(first.contains("first comment\nfirst continuation"));
484+ let second = command_help(&src, "second").unwrap().unwrap();
485+ assert!(!second.contains("first continuation"));
486+ assert!(!second.contains("third comment"));
487+ assert!(command_help(&src, "third")
488+ .unwrap()
489+ .unwrap()
490+ .contains("third comment"));
491+ }
492+
493+ #[test]
494+ fn validates_config_and_does_not_resolve_ids() {
495+ assert!(command_list("broken =", "").is_err());
496+ assert!(command_help("x = 1", "x")
497+ .unwrap_err()
498+ .contains("must define"));
499+ let src = format!("{BASE}revision = @zzzz\n");
500+ assert!(command_help(&src, "revision").unwrap().is_some());
501+ assert!(command_help(&src, "missing").unwrap().is_none());
502+ assert!(command_list(&src, "no-such-query")
503+ .unwrap()
504+ .contains("No matching"));
505+ }
506+
507+ #[test]
508+ fn default_config_documents_real_names() {
509+ let src = include_str!("../config.j");
510+ let help = command_help(src, "squash").unwrap().unwrap();
511+ assert!(help.contains("squash : Edit"));
512+ assert!(help.contains("Fold the focus entirely into its parent"));
513+ let list = command_list(src, "edit").unwrap();
514+ assert!(list.contains("squash : Edit"));
515+ assert!(list.contains("describe : Text -> Edit"));
516+ assert!(reserved_help("push")
517+ .unwrap()
518+ .contains("j 'push (label \"feature\" here)'"));
519+ }
520+}
src/jj.rs+1−1
@@ -1423,7 +1423,7 @@ impl JjBackend {
14231423 let root = find_repo_root().ok_or_else(|| {
14241424 (
14251425 2,
1426− "no jj repository found in this directory or any parent".to_string(),
1426+ "no jj repository found in this directory or any parent; run `j init` here, `j clone URL`, or `j help`".to_string(),
14271427 )
14281428 })?;
14291429 let settings = user_settings(cfg);
src/main.rs+92−12
@@ -1,5 +1,7 @@
11//! j — a functional-programming-centered CLI for jj repositories (§1).
22
3+mod help;
4+
35use j::config::{self, Config};
46use j::jj::JjBackend;
57use j::parse::parse_expr;
@@ -9,7 +11,12 @@ use std::io::{IsTerminal, Read};
911use std::process::ExitCode;
1012use std::rc::Rc;
1113
12−const USAGE: &str = "usage: j EXPRESSION… (or echo EXPRESSION | j)";
14+const USAGE: &str = "usage: j EXPRESSION… (or echo EXPRESSION | j)
15+
16+Start a repository: j init or j clone URL
17+Inspect your work: j status or j tree
18+Discover commands: j help or j commands
19+Command details: j help NAME";
1320
1421fn err(status: u8, msg: impl std::fmt::Display) -> ExitCode {
1522 j::report!("j: {}", msg);
@@ -101,7 +108,64 @@ fn write_default_config(path: &str) -> std::io::Result<()> {
101108 Ok(())
102109}
103110
104−const RESERVED: &[&str] = &["init", "clone", "remote", "fetch", "push", "undo", "redo", "ops"];
111+const RESERVED: &[&str] = &[
112+ "init", "clone", "remote", "fetch", "push", "undo", "redo", "ops", "help", "commands",
113+];
114+
115+/// Discovery reads the user's vocabulary without creating or evaluating it.
116+/// A missing file uses the embedded reference; other read errors stay visible.
117+fn read_help_config() -> Result<(String, String), u8> {
118+ let path = config_path();
119+ match std::fs::read_to_string(&path) {
120+ Ok(src) => Ok((src, path)),
121+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok((
122+ DEFAULT_CONFIG.to_string(),
123+ format!("embedded default config.j (no user config at {})", path),
124+ )),
125+ Err(e) => Err(fail(3, format!("cannot read config at {}: {}", path, e))),
126+ }
127+}
128+
129+fn run_help(text: &str) -> ExitCode {
130+ let words: Vec<&str> = text.split_whitespace().collect();
131+ match words.as_slice() {
132+ [_] => print_output(help::OVERVIEW),
133+ [_, name] => {
134+ if let Some(output) = help::reserved_help(name) {
135+ return print_output(&output);
136+ }
137+ let (src, path) = or_exit(read_help_config());
138+ match help::command_help(&src, name) {
139+ Ok(Some(output)) => print_output(&format!("{}\nConfig: {}\n", output, path)),
140+ Ok(None) => err(
141+ 2,
142+ format!("unknown help topic `{}`; run `j commands` to browse or `j commands {}` to search", name, name),
143+ ),
144+ Err(message) => err(3, format!("{}: {}", path, message)),
145+ }
146+ }
147+ _ => err(
148+ 2,
149+ "usage: j help [NAME]; run `j commands` to search available names",
150+ ),
151+ }
152+}
153+
154+fn run_commands(text: &str) -> ExitCode {
155+ let query = text.strip_prefix("commands").unwrap_or("").trim();
156+ let (src, path) = or_exit(read_help_config());
157+ match help::command_list(&src, query) {
158+ Ok(output) => print_output(&format!("{}\nConfig: {}\n", output, path)),
159+ Err(message) => err(3, format!("{}: {}", path, message)),
160+ }
161+}
162+
163+fn command_usage(usage: &str, command: &str) -> ExitCode {
164+ err(
165+ 2,
166+ format!("usage: j {}; see `j help {}` for examples", usage, command),
167+ )
168+}
105169
106170fn main() -> ExitCode {
107171 // evaluation builds deep continuation chains (trampolined, but the final
@@ -134,7 +198,11 @@ fn stdin_is_ready(ms: i32) -> bool {
134198fn run() -> ExitCode {
135199 // §1: an expression is text, so an argument that is not UTF-8 is a
136200 // usage error (`std::env::args` would panic on it)
137− let args: Vec<String> = match std::env::args_os().skip(1).map(|a| a.into_string()).collect() {
201+ let args: Vec<String> = match std::env::args_os()
202+ .skip(1)
203+ .map(|a| a.into_string())
204+ .collect()
205+ {
138206 Ok(args) => args,
139207 Err(a) => return err(2, format!("argument is not valid UTF-8: {:?}", a)),
140208 };
@@ -175,7 +243,14 @@ fn run() -> ExitCode {
175243
176244 // §1.1: reserved commands
177245 let trimmed = text.trim();
246+ if trimmed.is_empty() {
247+ j::report!("{}", USAGE);
248+ return ExitCode::from(2);
249+ }
178250 let first = trimmed.split_whitespace().next().unwrap_or("");
251+ if matches!(first, "--help" | "-h") {
252+ return run_help(trimmed);
253+ }
179254 if RESERVED.contains(&first) {
180255 return run_reserved(first, trimmed);
181256 }
@@ -186,9 +261,11 @@ fn run() -> ExitCode {
186261fn run_reserved(cmd: &str, text: &str) -> ExitCode {
187262 let words: Vec<&str> = text.split_whitespace().collect();
188263 match cmd {
264+ "help" => run_help(text),
265+ "commands" => run_commands(text),
189266 "init" => {
190267 if words.len() != 1 {
191− return err(2, "usage: j init");
268+ return command_usage("init", "init");
192269 }
193270 let (cfg, _) = or_exit(load_config_file());
194271 match j::jj::cmd_init(&cfg) {
@@ -198,7 +275,7 @@ fn run_reserved(cmd: &str, text: &str) -> ExitCode {
198275 }
199276 "clone" => {
200277 if words.len() != 2 && words.len() != 3 {
201− return err(2, "usage: j clone URL [DIR]");
278+ return command_usage("clone URL [DIR]", "clone");
202279 }
203280 let dir = if words.len() == 3 {
204281 words[2].to_string()
@@ -206,7 +283,10 @@ fn run_reserved(cmd: &str, text: &str) -> ExitCode {
206283 match default_clone_dir(words[1]) {
207284 Some(dir) => dir,
208285 None => {
209− let msg = format!("`{}` names no directory to clone into; give one: j clone URL DIR", words[1]);
286+ let msg = format!(
287+ "`{}` names no directory to clone into; give one: j clone URL DIR",
288+ words[1]
289+ );
210290 return err(2, msg);
211291 }
212292 }
@@ -219,7 +299,7 @@ fn run_reserved(cmd: &str, text: &str) -> ExitCode {
219299 }
220300 "remote" => {
221301 if words.len() != 2 {
222− return err(2, "usage: j remote URL");
302+ return command_usage("remote URL", "remote");
223303 }
224304 let backend = or_exit(open_repo());
225305 match backend.cmd_remote(words[1]) {
@@ -229,7 +309,7 @@ fn run_reserved(cmd: &str, text: &str) -> ExitCode {
229309 }
230310 "fetch" => {
231311 if words.len() != 1 {
232− return err(2, "usage: j fetch");
312+ return command_usage("fetch", "fetch");
233313 }
234314 let (cfg, _) = or_exit(load_config_file());
235315 let backend = or_exit(open_repo_locked(&cfg));
@@ -241,7 +321,7 @@ fn run_reserved(cmd: &str, text: &str) -> ExitCode {
241321 "push" => {
242322 let rest = text.trim_start_matches("push").trim();
243323 if rest.is_empty() {
244− return err(2, "usage: j push EXPR");
324+ return command_usage("push EXPR", "push");
245325 }
246326 let (mut cfg, _) = or_exit(load_config_file());
247327 let backend = or_exit(open_repo_locked(&cfg));
@@ -253,7 +333,7 @@ fn run_reserved(cmd: &str, text: &str) -> ExitCode {
253333 }
254334 "undo" => {
255335 if words.len() != 1 {
256− return err(2, "usage: j undo");
336+ return command_usage("undo", "undo");
257337 }
258338 let backend = or_exit(open_repo_noconfig());
259339 match backend.cmd_undo(false) {
@@ -263,7 +343,7 @@ fn run_reserved(cmd: &str, text: &str) -> ExitCode {
263343 }
264344 "redo" => {
265345 if words.len() != 1 {
266− return err(2, "usage: j redo");
346+ return command_usage("redo", "redo");
267347 }
268348 let backend = or_exit(open_repo_noconfig());
269349 match backend.cmd_undo(true) {
@@ -273,7 +353,7 @@ fn run_reserved(cmd: &str, text: &str) -> ExitCode {
273353 }
274354 "ops" => {
275355 if words.len() != 1 {
276− return err(2, "usage: j ops");
356+ return command_usage("ops", "ops");
277357 }
278358 let backend = or_exit(open_repo_noconfig());
279359 match backend.cmd_ops() {
tests/help.rsnew file+210
@@ -0,0 +1,210 @@
1+//! Discovery works before a repository exists and never executes the vocabulary.
2+
3+use std::process::{Command, Output, Stdio};
4+
5+struct HelpCli {
6+ dir: tempfile::TempDir,
7+ config: tempfile::TempDir,
8+}
9+
10+impl HelpCli {
11+ fn new() -> Self {
12+ Self {
13+ dir: tempfile::tempdir().unwrap(),
14+ config: tempfile::tempdir().unwrap(),
15+ }
16+ }
17+
18+ fn command(&self) -> Command {
19+ let mut command = Command::new(env!("CARGO_BIN_EXE_j"));
20+ command
21+ .current_dir(self.dir.path())
22+ .env("XDG_CONFIG_HOME", self.config.path())
23+ .env("NO_COLOR", "1")
24+ .stdin(Stdio::null())
25+ .stdout(Stdio::piped())
26+ .stderr(Stdio::piped());
27+ command
28+ }
29+
30+ fn run(&self, args: &[&str]) -> Output {
31+ self.command().args(args).output().unwrap()
32+ }
33+
34+ fn write_config(&self, source: &str) {
35+ std::fs::create_dir_all(self.config.path().join("j")).unwrap();
36+ std::fs::write(self.config.path().join("j/config.j"), source).unwrap();
37+ }
38+}
39+
40+fn stdout(output: Output) -> String {
41+ assert!(
42+ output.status.success(),
43+ "{}",
44+ String::from_utf8_lossy(&output.stderr)
45+ );
46+ assert!(output.stderr.is_empty());
47+ String::from_utf8(output.stdout).unwrap()
48+}
49+
50+#[test]
51+fn help_and_catalog_work_without_creating_config_or_repository() {
52+ let cli = HelpCli::new();
53+ let overview = stdout(cli.run(&["help"]));
54+ for alias in ["--help", "-h"] {
55+ assert_eq!(stdout(cli.run(&[alias])), overview);
56+ }
57+ assert!(overview.contains("j commands"));
58+ assert!(overview.contains("j help"));
59+ assert!(overview.contains("describe"));
60+ let commands = stdout(cli.run(&["commands"]));
61+ assert!(commands.contains("describe"));
62+ assert!(commands.contains("fetch"));
63+ assert!(commands.contains("embedded default"));
64+ let topic = stdout(cli.run(&["help", "describe"]));
65+ assert!(topic.contains("Text -> Edit"), "{}", topic);
66+ assert_eq!(stdout(cli.run(&["--help", "describe"])), topic);
67+ assert_eq!(
68+ stdout(cli.run(&["help", "(+)"])),
69+ stdout(cli.run(&["help", "+"]))
70+ );
71+ assert_eq!(std::fs::read_dir(cli.dir.path()).unwrap().count(), 0);
72+ assert_eq!(std::fs::read_dir(cli.config.path()).unwrap().count(), 0);
73+}
74+
75+#[test]
76+fn overview_and_reserved_topics_survive_broken_config() {
77+ let cli = HelpCli::new();
78+ cli.write_config("broken =\n");
79+ stdout(cli.run(&["help"]));
80+ for name in [
81+ "init", "clone", "remote", "fetch", "push", "undo", "redo", "ops", "help", "commands",
82+ ] {
83+ assert!(stdout(cli.run(&["help", name])).contains(name));
84+ }
85+ for args in [&["commands"][..], &["help", "describe"]] {
86+ let output = cli.run(args);
87+ assert_eq!(output.status.code(), Some(3));
88+ assert!(String::from_utf8_lossy(&output.stderr).contains("config.j"));
89+ assert!(output.stdout.is_empty());
90+ }
91+ assert_eq!(
92+ std::fs::read_to_string(cli.config.path().join("j/config.j")).unwrap(),
93+ "broken =\n"
94+ );
95+}
96+
97+#[test]
98+fn catalog_uses_actual_config_and_searches_custom_documentation() {
99+ let cli = HelpCli::new();
100+ // Deliberately omit reference commands and declare functions that must
101+ // never be evaluated just to learn what is available.
102+ cli.write_config("crash : Text -> a\nuser = crash \"must not run\"\ntree = 0\nimmutable = []\nlabelled = \\name -> []\n\n-- Publish the release notes.\nship = crash \"must not run\"\n\n-- Transform a callback.\ntransform : (Text -> Text) -> Text -> Text\ntransform = \\f value -> f value\n");
103+ let commands = stdout(cli.run(&["commands"]));
104+ assert!(commands.contains("ship"));
105+ assert!(commands.contains("transform"));
106+ assert!(!commands.contains("squash"));
107+ assert!(!commands.contains("touchedPaths"));
108+ let search = stdout(cli.run(&["commands", "RELEASE"]));
109+ assert!(search.contains("ship"), "{}", search);
110+ assert!(!search.contains("transform"));
111+ let topic = stdout(cli.run(&["help", "ship"]));
112+ assert!(topic.contains("Publish the release notes."), "{}", topic);
113+ assert!(topic.contains("config.j"));
114+ let signature = stdout(cli.run(&["help", "transform"]));
115+ assert!(
116+ signature.contains("(Text -> Text) -> Text -> Text"),
117+ "{}",
118+ signature
119+ );
120+ assert_eq!(std::fs::read_dir(cli.dir.path()).unwrap().count(), 0);
121+}
122+
123+#[test]
124+fn discovery_errors_offer_a_next_step_and_keep_usage_exit_codes() {
125+ let cli = HelpCli::new();
126+ for args in [
127+ &[][..],
128+ &[" \n "],
129+ &["help", "missing-name"],
130+ &["help", "one", "two"],
131+ &["push"],
132+ ] {
133+ let output = cli.run(args);
134+ assert_eq!(output.status.code(), Some(2), "{:?}", args);
135+ assert!(output.stdout.is_empty());
136+ let error = String::from_utf8_lossy(&output.stderr);
137+ assert!(
138+ error.contains("j help") || error.contains("j commands"),
139+ "{}",
140+ error
141+ );
142+ }
143+ let empty = stdout(cli.run(&["commands", "no-such-command-xyz"]));
144+ assert!(empty.to_lowercase().contains("no "), "{}", empty);
145+ assert!(empty.contains("j commands"), "{}", empty);
146+ assert_eq!(std::fs::read_dir(cli.config.path()).unwrap().count(), 0);
147+}
148+
149+#[test]
150+fn piped_help_and_expression_comments_preserve_input_rules() {
151+ use std::io::Write;
152+ let cli = HelpCli::new();
153+ let mut child = cli.command().stdin(Stdio::piped()).spawn().unwrap();
154+ child
155+ .stdin
156+ .take()
157+ .unwrap()
158+ .write_all(b"help push\n")
159+ .unwrap();
160+ assert_eq!(
161+ stdout(child.wait_with_output().unwrap()),
162+ stdout(cli.run(&["help", "push"]))
163+ );
164+
165+ cli.write_config(include_str!("../config.j"));
166+ stdout(cli.run(&["init"]));
167+ assert_eq!(stdout(cli.run(&["\"--help\""])), "--help\n");
168+ assert_eq!(stdout(cli.run(&["(help) or 42"])), "42\n");
169+ assert_eq!(stdout(cli.run(&["(commands) or 42"])), "42\n");
170+ assert_eq!(stdout(cli.run(&["42 --help"])), "42\n");
171+}
172+
173+#[test]
174+fn help_does_not_take_repository_lock_or_snapshot_changes() {
175+ use std::os::fd::AsRawFd;
176+ let cli = HelpCli::new();
177+ cli.write_config(include_str!("../config.j"));
178+ stdout(cli.run(&["init"]));
179+ let before = stdout(cli.run(&["ops"]));
180+ std::fs::write(cli.dir.path().join("work.txt"), "unsaved work\n").unwrap();
181+ let lock = std::fs::OpenOptions::new()
182+ .create(true)
183+ .truncate(false)
184+ .write(true)
185+ .open(cli.dir.path().join(".jj/j.lock"))
186+ .unwrap();
187+ assert_eq!(
188+ unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
189+ 0
190+ );
191+ stdout(cli.run(&["help"]));
192+ stdout(cli.run(&["help", "new"]));
193+ stdout(cli.run(&["commands", "Edit"]));
194+ drop(lock);
195+ assert_eq!(stdout(cli.run(&["ops"])), before);
196+ assert_eq!(
197+ std::fs::read_to_string(cli.dir.path().join("work.txt")).unwrap(),
198+ "unsaved work\n"
199+ );
200+}
201+
202+#[test]
203+fn help_read_failures_do_not_fall_back_to_reference_commands() {
204+ let cli = HelpCli::new();
205+ std::fs::create_dir_all(cli.config.path().join("j/config.j")).unwrap();
206+ let output = cli.run(&["commands"]);
207+ assert_eq!(output.status.code(), Some(3));
208+ assert!(String::from_utf8_lossy(&output.stderr).contains("cannot read config"));
209+ stdout(cli.run(&["help", "clone"]));
210+}