on master help sign in
Make CLI commands discoverable with help and search (#3)
34050d1iko
CLAUDE.md+3−2
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
4
4
5## What this is
5## What this is
6
6
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).
8
8
9## Commands
9## Commands
10
10
@@ -28,7 +28,6 @@ There is no devShell in `flake.nix`; `cargo`/`rustc` come from the system. The N
28`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.
28`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.
29
29
30Known drift to be aware of (not to copy):
30Known drift to be aware of (not to copy):
31−- `README.md` links to `SPEC.md`; the file is `specs/main.md`.
32- `§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.
31- `§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.
33
32
34## Architecture
33## Architecture
@@ -43,6 +42,8 @@ Language pipeline, all in `src/`:
43- `builtins.rs`: native implementations of the names `config.j` declares without defining.
42- `builtins.rs`: native implementations of the names `config.j` declares without defining.
44- `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`.
43- `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`.
45
44
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+
46Repository side:
47Repository side:
47- `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.
48- `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.
48- `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.
49- `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`, … —
27written *in the language* in `config.j`, and you extend it by writing more
27written *in the language* in `config.j`, and you extend it by writing more
28functions.
28functions.
29
29
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+
30---
51---
31
52
32## Why `j`?
53## Why `j`?
@@ -70,10 +91,11 @@ You'll want the reference `config.j` (see next section) and, for `review`/`difft
70
91
71## Configuration
92## Configuration
72
93
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`).
74An expression can only use a name this file declares or defines. The reference
95An 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
96+[`config.j`](config.j) ships with the binary. Commands that execute against a
76−identity:
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:
77
99
78```
100```
79user = { name = "Your Name", email = "you@example.com" }
101user = { name = "Your Name", email = "you@example.com" }
@@ -424,11 +446,13 @@ Then `j release` works like any built-in.
424
446
425## Reserved commands
447## Reserved commands
426
448
427−A handful of commands are built in (not expressions): they create or connect
449+A handful of commands are built in (not expressions): they provide help,
428−repositories, move refs, and walk the operation log. None take flags.
450+create or connect repositories, move refs, and walk the operation log.
429
451
430| command | what it does |
452| command | what it does |
431|---|---|
453|---|---|
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 |
432| `j init` | create a colocated `.jj`/`.git` repository here (adopting an existing `.git`) |
456| `j init` | create a colocated `.jj`/`.git` repository here (adopting an existing `.git`) |
433| `j clone URL [DIR]` | clone `URL` into `DIR` (default: the repository's name, as `git clone` picks it) |
457| `j clone URL [DIR]` | clone `URL` into `DIR` (default: the repository's name, as `git clone` picks it) |
434| `j remote URL` | set `origin`'s URL |
458| `j remote URL` | set `origin`'s URL |
@@ -437,7 +461,10 @@ repositories, move refs, and walk the operation log. None take flags.
437| `j undo` / `j redo` | walk the operation log |
461| `j undo` / `j redo` | walk the operation log |
438| `j ops` | print the operation log |
462| `j ops` | print the operation log |
439
463
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
440−Everything else is an expression in the language.
467+with a language comment. Everything else is an expression in the language.
441
468
442---
469---
443
470
@@ -449,7 +476,7 @@ Everything else is an expression in the language.
449| **[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. |
476| **[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. |
450| **[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. |
477| **[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. |
451| **[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. |
478| **[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. |
453| **[config.j](config.j)** | The reference configuration: the entire base vocabulary, written in the language. |
480| **[config.j](config.j)** | The reference configuration: the entire base vocabulary, written in the language. |
454
481
455---
482---
@@ -457,11 +484,11 @@ Everything else is an expression in the language.
457## Project layout
484## Project layout
458
485
459```
486```
460−config.j the reference base vocabulary (read on every run)
487+config.j the reference base vocabulary
461src/ the j implementation (Rust, on jj-lib)
488src/ the j implementation (Rust, on jj-lib)
462tests/ unit, property, and CLI end-to-end tests
489tests/ unit, property, and CLI end-to-end tests
463docs/ language.md, base.md, comparison.md
490docs/ language.md, base.md, comparison.md
464−SPEC.md the specification
491+specs/ the language, CLI, repository, and tree specifications
465flake.nix Nix package: one binary, j, with difftastic as a runtime dep
492flake.nix Nix package: one binary, j, with difftastic as a runtime dep
466```
493```
467
494
config.j+16
@@ -354,6 +354,8 @@ immutable = ancestorsOf trunk
354-- Focusing. `goto` is the only place a revset must have exactly one member.
354-- Focusing. `goto` is the only place a revset must have exactly one member.
355------------------------------------------------------------------------------
355------------------------------------------------------------------------------
356
356
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.
357goto : Revset -> Edit
359goto : Revset -> Edit
358goto = \rs repo ->
360goto = \rs repo ->
359 let is = rs repo
361 let is = rs repo
@@ -433,10 +435,12 @@ rewrite = \f repo ->
433------------------------------------------------------------------------------
435------------------------------------------------------------------------------
434
436
435-- Set the message of the focused commit.
437-- Set the message of the focused commit.
438+-- Example: j 'describe "fix login"'
436describe : Text -> Edit
439describe : Text -> Edit
437describe = \m -> mapRoot (\c -> c { message = m })
440describe = \m -> mapRoot (\c -> c { message = m })
438
441
439-- Start an empty child of the focus and move to it.
442-- Start an empty child of the focus and move to it.
443+-- Examples: j new; j 'describe "next change" . new'
440new : Edit
444new : Edit
441new = \repo -> addChild (newCommit repo.root.files) repo
445new = \repo -> addChild (newCommit repo.root.files) repo
442
446
@@ -444,6 +448,7 @@ new = \repo -> addChild (newCommit repo.root.files) repo
444-- new empty commit on that parent becomes the focus (the parent may be
448-- new empty commit on that parent becomes the focus (the parent may be
445-- immutable, and the focus never is). Refused for a commit the remote has a
449-- immutable, and the focus never is). Refused for a commit the remote has a
446-- name for: push something else under that name first, or forget the name.
450-- name for: push something else under that name first, or forget the name.
451+-- Example: j abandon; preview first with j 'tree . abandon'.
447abandon : Edit
452abandon : Edit
448abandon = \repo ->
453abandon = \repo ->
449 let c = repo.root
454 let c = repo.root
@@ -453,17 +458,20 @@ abandon = \repo ->
453 else new (remove (eachChild (replayOnto p) repo))
458 else new (remove (eachChild (replayOnto p) repo))
454
459
455-- Push the files matching m from the focus down into its parent.
460-- Push the files matching m from the focus down into its parent.
461+-- Example: j 'contract (ext "rs")'
456contract : Fileset -> Edit
462contract : Fileset -> Edit
457contract = \m repo -> let a = repo.root
463contract = \m repo -> let a = repo.root
458 in at parents (rewrite (\q -> q { files = select m a.files q.files })) repo
464 in at parents (rewrite (\q -> q { files = select m a.files q.files })) repo
459
465
460-- Fold the focus entirely into its parent; a new empty commit becomes the focus.
466-- 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'.
461squash : Edit
468squash : Edit
462squash = abandon . contract everything
469squash = abandon . contract everything
463
470
464-- Push the files matching m from the focus up into a new child, which becomes
471-- Push the files matching m from the focus up into a new child, which becomes
465-- the focus and inherits the old focus's children. Labels stay with the
472-- the focus and inherits the old focus's children. Labels stay with the
466-- original commit, since they are the remote's.
473-- original commit, since they are the remote's.
474+-- Example: j 'split (ext "rs")'; use (under ./src) to select a directory.
467split : Fileset -> Edit
475split : Fileset -> Edit
468split = \m repo ->
476split = \m repo ->
469 let a = repo.root
477 let a = repo.root
@@ -473,6 +481,7 @@ split = \m repo ->
473 in by x.id (repo { root = a', children = [{ root = x, children = repo.children }] })
481 in by x.id (repo { root = a', children = [{ root = x, children = repo.children }] })
474
482
475-- Move the focused subtree onto another commit. Returns to the moved commit.
483-- Move the focused subtree onto another commit. Returns to the moved commit.
484+-- Example: j 'rebase trunk'; preview with j 'tree . validate . rebase trunk'.
476rebase : Revset -> Edit
485rebase : Revset -> Edit
477rebase = \rs repo ->
486rebase = \rs repo ->
478 let d = (goto rs repo).root
487 let d = (goto rs repo).root
@@ -483,6 +492,7 @@ rebase = \rs repo ->
483 in by me (attach x.subtree (by d.id x.rest))
492 in by me (attach x.subtree (by d.id x.rest))
484
493
485-- Apply the focused commit's change onto another commit, as a new child there.
494-- Apply the focused commit's change onto another commit, as a new child there.
495+-- Example: j 'pick %main'
486pick : Revset -> Edit
496pick : Revset -> Edit
487pick = \rs repo ->
497pick = \rs repo ->
488 let ch = changeOf repo
498 let ch = changeOf repo
@@ -490,6 +500,7 @@ pick = \rs repo ->
490 in at rs (addChild (newCommit (replay t.files ch))) repo
500 in at rs (addChild (newCommit (replay t.files ch))) repo
491
501
492-- A new child of the focus that undoes the change of the given commit.
502-- A new child of the focus that undoes the change of the given commit.
503+-- Example: j 'backout %feature'
493backout : Revset -> Edit
504backout : Revset -> Edit
494backout = \rs repo ->
505backout = \rs repo ->
495 let ch = changeOf (goto rs repo)
506 let ch = changeOf (goto rs repo)
@@ -542,6 +553,7 @@ touched : Change -> [Path]
542touched = touchedPaths
553touched = touchedPaths
543
554
544-- Paths whose content differs between the focus and its parent.
555-- Paths whose content differs between the focus and its parent.
556+-- Example: j changed
545changed : Repo -> [Path]
557changed : Repo -> [Path]
546changed = \repo -> touched (changeOf repo)
558changed = \repo -> touched (changeOf repo)
547
559
@@ -556,12 +568,14 @@ diffs = \repo ->
556-- readable in the terminal. A focus that changes nothing reads "no changes";
568-- readable in the terminal. A focus that changes nothing reads "no changes";
557-- a difft that cannot be run (not on PATH) is a crash saying so, and
569-- a difft that cannot be run (not on PATH) is a crash saying so, and
558-- otherwise whatever difft prints, even nothing, is the page.
570-- otherwise whatever difft prints, even nothing, is the page.
571+-- Example: j review (requires difftastic's difft on PATH).
559review : Repo -> Text
572review : Repo -> Text
560review = \repo ->
573review = \repo ->
561 let ds = diffs repo
574 let ds = diffs repo
562 in if null ds then "no changes\n" else concat (map (\d -> d.diff) ds)
575 in if null ds then "no changes\n" else concat (map (\d -> d.diff) ds)
563
576
564-- A summary of the focus.
577-- A summary of the focus.
578+-- Example: j status
565status : Repo -> { id : Id, message : Text, labels : [Label], changed : [Path], conflicts : [Path] }
579status : Repo -> { id : Id, message : Text, labels : [Label], changed : [Path], conflicts : [Path] }
566status = \repo ->
580status = \repo ->
567 { id = repo.root.id
581 { id = repo.root.id
@@ -571,6 +585,7 @@ status = \repo ->
571 , conflicts = conflicted repo.root.files }
585 , conflicts = conflicted repo.root.files }
572
586
573-- A summary of every commit, top first, in preorder.
587-- A summary of every commit, top first, in preorder.
588+-- Example: j log
574log : Repo -> [{ id : Id, message : Text, labels : [Label], focus : Bool }]
589log : Repo -> [{ id : Id, message : Text, labels : [Label], focus : Bool }]
575log = \repo ->
590log = \repo ->
576 map (\c -> { id = c.id
591 map (\c -> { id = c.id
@@ -583,6 +598,7 @@ log = \repo ->
583-- (The parentheses are required: `f { … }` would be a record update of f.)
598-- (The parentheses are required: `f { … }` would be a record update of f.)
584-- The default `tree` shows every commit, sorted by time (parents always above
599-- The default `tree` shows every commit, sorted by time (parents always above
585-- their children); `treeCompact` folds uninteresting runs and far subtrees.
600-- their children); `treeCompact` folds uninteresting runs and far subtrees.
601+-- Examples: j tree; j 'tree . squash' previews an edit without saving it.
586tree : Repo -> Text
602tree : Repo -> Text
587tree = treeWith ({ detail = 1, margin = false, elide = false, icons = false, color = "auto", lanes = 4, author = false, date = false, files = false })
603tree = treeWith ({ detail = 1, margin = false, elide = false, icons = false, color = "auto", lanes = 4, author = false, date = false, files = false })
588
604
docs/language.md+2−1
@@ -3,7 +3,8 @@
3`j` is a small, pure, functional language. Every `j` command evaluates one
3`j` is a small, pure, functional language. Every `j` command evaluates one
4expression in this language against the current repository. The language is
4expression in this language against the current repository. The language is
5the whole interface: there are no subcommands beyond a handful of reserved
5the 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.
7
8
8This document describes the language itself — its values, syntax, semantics,
9This document describes the language itself — its values, syntax, semantics,
9and the builtins — with examples throughout. Every example shows an expression
10and 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
9command is declared or defined in one user-owned file, `~/.config/j/config.j`,
9command is declared or defined in one user-owned file, `~/.config/j/config.j`,
10written in the language. The binary provides the language (its syntax, its
10written in the language. The binary provides the language (its syntax, its
11operators and their fixities, its primitive kinds), the implementations of the
11operators 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.
13`j` does not require the `jj` binary; it uses jj's on-disk formats through
13`j` does not require the `jj` binary; it uses jj's on-disk formats through
14jj-lib.
14jj-lib.
15
15
@@ -41,9 +41,14 @@ echo EXPRESSION | j
41 unquoted.
41 unquoted.
423. If arguments are present and rule 1 also applies, exit 2 with
423. If arguments are present and rule 1 also applies, exit 2 with
43 `j: expression given both as arguments and on stdin`.
43 `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.
44+4. If neither is present, or the input is only whitespace, print a concise
45−5. `j` accepts no flags. Any argument, including ones beginning with `-`, is
45+ usage and getting-started guide to stderr and exit 2. The guide points to
46− part of the expression.
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.
476. The expression is text: an argument that is not valid UTF-8 is a usage
526. The expression is text: an argument that is not valid UTF-8 is a usage
48 error (exit 2) naming it, and so is an expression on standard input that
53 error (exit 2) naming it, and so is an expression on standard input that
49 is not.
54 is not.
@@ -52,10 +57,13 @@ echo EXPRESSION | j
52
57
53Before the expression is parsed, its text is trimmed of leading and trailing
58Before the expression is parsed, its text is trimmed of leading and trailing
54whitespace and split on whitespace into words. If the first word is one of the
59whitespace 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`.
56
62
57| command | operation |
63| command | operation |
58|---|---|
64|---|---|
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 |
59| `init` | create a repository in the current directory; §7.8 |
67| `init` | create a repository in the current directory; §7.8 |
60| `clone URL [DIR]` | clone `URL` into `DIR` (default: the repository's name in `URL`); §7.8 |
68| `clone URL [DIR]` | clone `URL` into `DIR` (default: the repository's name in `URL`); §7.8 |
61| `remote URL` | set the URL of the remote `origin`, creating it if absent; §7.8 |
69| `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.
65| `redo` | reverse the newest undo; §7.7 |
73| `redo` | reverse the newest undo; §7.7 |
66| `ops` | print the operation log; §7.7 |
74| `ops` | print the operation log; §7.7 |
67
75
68−For every reserved command except `push`, the remaining words are literal
76+For every reserved command except `push` and `commands`, the remaining words
69−arguments (no quoting, no escaping); a wrong number is a usage error (exit 2)
77+are literal arguments (no quoting, no escaping); a wrong number is a usage
70−and the language is not involved. For `push`, the remainder of the text is an
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
71−expression, evaluated as in §1.2 steps 2–7 but against the repository as
80+remainder of the text is an expression, evaluated as in §1.2 steps 2–7 but
72−recorded, without snapshotting the working directory; its value must be a list
81+against the repository as recorded, without snapshotting the working directory;
73−of push records (§7.6).
82+its value must be a list of push records (§7.6).
74
83
75Matching is on the first word only, so an expression whose first token is one
84Matching is on the first word only, so an expression whose first token is one
76of these words cannot be written on the command line. `(push)` or `x fetch`
85of these words cannot be written on the command line. `(push)` or `x fetch`
77are ordinary expressions. `config.j` may define functions with these names;
86are ordinary expressions. `config.j` may define functions with these names;
78they are reachable only inside larger expressions.
87they are reachable only inside larger expressions.
79
88
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+
80### 1.2 Evaluating the expression
116### 1.2 Evaluating the expression
81
117
821. Locate the repository (§7.1). If none, exit 2.
1181. Locate the repository (§7.1). If none, exit 2.
832. Parse the expression (§3). On parse error, exit 3.
1192. Parse the expression (§3). On parse error, exit 3.
843. Load and validate `config.j` (§6), except for its `Id` literals. If it is
1203. 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.
864. Build the current repository value `r` (§7.2). This includes snapshotting
1234. Build the current repository value `r` (§7.2). This includes snapshotting
87 the working directory into the focused commit (§7.4).
124 the working directory into the focused commit (§7.4).
885. Resolve every `Id` literal in `config.j` and in the expression (§4.10).
1255. 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,
785### 6.1 Location and loading
822### 6.1 Location and loading
786
823
787`j` reads `$XDG_CONFIG_HOME/j/config.j`, defaulting to `~/.config/j/config.j`.
824`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
825+Expression evaluation, `push`, and `fetch` load the configuration; `init` and
789−`ops`: expression evaluation and `push` need its definitions, `init` and
790`clone` need `user`. When there is no repository yet (`init`, `clone`), only
826`clone` need `user`. When there is no repository yet (`init`, `clone`), only
791`user` is evaluated; `Id` literals are not resolved and no other definition
827`user` is evaluated; `Id` literals are not resolved and no other definition
792−is evaluated. If it does not exist, `j` exits 3 with
828+is evaluated. `remote`, `undo`, `redo`, and `ops` do not load configuration.
793−`j: no config at <path>; the reference config.j is installed at <share-path>`.
829+When a command needs configuration and none exists, the binary writes its
794−The binary is distributed with the reference `config.j` (§9) at
830+embedded reference `config.j` (§9) to that path as an editable starting point.
795−`<prefix>/share/j/config.j`; `j` never writes to the config path itself.
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.
796
839
797### 6.2 Validation
840### 6.2 Validation
798
841
@@ -1280,7 +1323,8 @@ operation is marked. Rendered in the binary like `tree`.
1280duration.
1323duration.
1281A second `j` exits 2 with `j: another j is running in this repository`.
1324A second `j` exits 2 with `j: another j is running in this repository`.
1282Display-only runs are not distinguishable in advance, so they take the lock
1325Display-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.
1284The lock prevents `j` from ever producing two commits with one change id.
1328The lock prevents `j` from ever producing two commits with one change id.
1285Other tools operating concurrently are outside `j`'s control; if they produce
1329Other tools operating concurrently are outside `j`'s control; if they produce
1286that state, §7.2 applies.
1330that state, §7.2 applies.
@@ -2268,5 +2312,6 @@ and are read-only); hunk-level `split`; conflict resolution inside the
2268language (blobs are opaque); workspaces other than the default; remotes other
2312language (blobs are opaque); workspaces other than the default; remotes other
2269than `origin`; tags; submodules and LFS; ignore rules beyond `.gitignore`;
2313than `origin`; tags; submodules and LFS; ignore rules beyond `.gitignore`;
2270changing file modes or symlinks from the language; repairing a repository in
2314changing 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
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
2272−or any flag at all; static typing. None of these are precluded by the design.
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 {
1423 let root = find_repo_root().ok_or_else(|| {
1423 let root = find_repo_root().ok_or_else(|| {
1424 (
1424 (
1425 2,
1425 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(),
1427 )
1427 )
1428 })?;
1428 })?;
1429 let settings = user_settings(cfg);
1429 let settings = user_settings(cfg);
src/main.rs+92−12
@@ -1,5 +1,7 @@
1//! j — a functional-programming-centered CLI for jj repositories (§1).
1//! j — a functional-programming-centered CLI for jj repositories (§1).
2
2
3+mod help;
4+
3use j::config::{self, Config};
5use j::config::{self, Config};
4use j::jj::JjBackend;
6use j::jj::JjBackend;
5use j::parse::parse_expr;
7use j::parse::parse_expr;
@@ -9,7 +11,12 @@ use std::io::{IsTerminal, Read};
9use std::process::ExitCode;
11use std::process::ExitCode;
10use std::rc::Rc;
12use std::rc::Rc;
11
13
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";
13
20
14fn err(status: u8, msg: impl std::fmt::Display) -> ExitCode {
21fn err(status: u8, msg: impl std::fmt::Display) -> ExitCode {
15 j::report!("j: {}", msg);
22 j::report!("j: {}", msg);
@@ -101,7 +108,64 @@ fn write_default_config(path: &str) -> std::io::Result<()> {
101 Ok(())
108 Ok(())
102}
109}
103
110
111+const RESERVED: &[&str] = &[
104−const RESERVED: &[&str] = &["init", "clone", "remote", "fetch", "push", "undo", "redo", "ops"];
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+}
105
169
106fn main() -> ExitCode {
170fn main() -> ExitCode {
107 // evaluation builds deep continuation chains (trampolined, but the final
171 // evaluation builds deep continuation chains (trampolined, but the final
@@ -134,7 +198,11 @@ fn stdin_is_ready(ms: i32) -> bool {
134fn run() -> ExitCode {
198fn run() -> ExitCode {
135 // §1: an expression is text, so an argument that is not UTF-8 is a
199 // §1: an expression is text, so an argument that is not UTF-8 is a
136 // usage error (`std::env::args` would panic on it)
200 // 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+ {
138 Ok(args) => args,
206 Ok(args) => args,
139 Err(a) => return err(2, format!("argument is not valid UTF-8: {:?}", a)),
207 Err(a) => return err(2, format!("argument is not valid UTF-8: {:?}", a)),
140 };
208 };
@@ -175,7 +243,14 @@ fn run() -> ExitCode {
175
243
176 // §1.1: reserved commands
244 // §1.1: reserved commands
177 let trimmed = text.trim();
245 let trimmed = text.trim();
246+ if trimmed.is_empty() {
247+ j::report!("{}", USAGE);
248+ return ExitCode::from(2);
249+ }
178 let first = trimmed.split_whitespace().next().unwrap_or("");
250 let first = trimmed.split_whitespace().next().unwrap_or("");
251+ if matches!(first, "--help" | "-h") {
252+ return run_help(trimmed);
253+ }
179 if RESERVED.contains(&first) {
254 if RESERVED.contains(&first) {
180 return run_reserved(first, trimmed);
255 return run_reserved(first, trimmed);
181 }
256 }
@@ -186,9 +261,11 @@ fn run() -> ExitCode {
186fn run_reserved(cmd: &str, text: &str) -> ExitCode {
261fn run_reserved(cmd: &str, text: &str) -> ExitCode {
187 let words: Vec<&str> = text.split_whitespace().collect();
262 let words: Vec<&str> = text.split_whitespace().collect();
188 match cmd {
263 match cmd {
264+ "help" => run_help(text),
265+ "commands" => run_commands(text),
189 "init" => {
266 "init" => {
190 if words.len() != 1 {
267 if words.len() != 1 {
191− return err(2, "usage: j init");
268+ return command_usage("init", "init");
192 }
269 }
193 let (cfg, _) = or_exit(load_config_file());
270 let (cfg, _) = or_exit(load_config_file());
194 match j::jj::cmd_init(&cfg) {
271 match j::jj::cmd_init(&cfg) {
@@ -198,7 +275,7 @@ fn run_reserved(cmd: &str, text: &str) -> ExitCode {
198 }
275 }
199 "clone" => {
276 "clone" => {
200 if words.len() != 2 && words.len() != 3 {
277 if words.len() != 2 && words.len() != 3 {
201− return err(2, "usage: j clone URL [DIR]");
278+ return command_usage("clone URL [DIR]", "clone");
202 }
279 }
203 let dir = if words.len() == 3 {
280 let dir = if words.len() == 3 {
204 words[2].to_string()
281 words[2].to_string()
@@ -206,7 +283,10 @@ fn run_reserved(cmd: &str, text: &str) -> ExitCode {
206 match default_clone_dir(words[1]) {
283 match default_clone_dir(words[1]) {
207 Some(dir) => dir,
284 Some(dir) => dir,
208 None => {
285 None => {
286+ let msg = format!(
209− let msg = format!("`{}` names no directory to clone into; give one: j clone URL DIR", words[1]);
287+ "`{}` names no directory to clone into; give one: j clone URL DIR",
288+ words[1]
289+ );
210 return err(2, msg);
290 return err(2, msg);
211 }
291 }
212 }
292 }
@@ -219,7 +299,7 @@ fn run_reserved(cmd: &str, text: &str) -> ExitCode {
219 }
299 }
220 "remote" => {
300 "remote" => {
221 if words.len() != 2 {
301 if words.len() != 2 {
222− return err(2, "usage: j remote URL");
302+ return command_usage("remote URL", "remote");
223 }
303 }
224 let backend = or_exit(open_repo());
304 let backend = or_exit(open_repo());
225 match backend.cmd_remote(words[1]) {
305 match backend.cmd_remote(words[1]) {
@@ -229,7 +309,7 @@ fn run_reserved(cmd: &str, text: &str) -> ExitCode {
229 }
309 }
230 "fetch" => {
310 "fetch" => {
231 if words.len() != 1 {
311 if words.len() != 1 {
232− return err(2, "usage: j fetch");
312+ return command_usage("fetch", "fetch");
233 }
313 }
234 let (cfg, _) = or_exit(load_config_file());
314 let (cfg, _) = or_exit(load_config_file());
235 let backend = or_exit(open_repo_locked(&cfg));
315 let backend = or_exit(open_repo_locked(&cfg));
@@ -241,7 +321,7 @@ fn run_reserved(cmd: &str, text: &str) -> ExitCode {
241 "push" => {
321 "push" => {
242 let rest = text.trim_start_matches("push").trim();
322 let rest = text.trim_start_matches("push").trim();
243 if rest.is_empty() {
323 if rest.is_empty() {
244− return err(2, "usage: j push EXPR");
324+ return command_usage("push EXPR", "push");
245 }
325 }
246 let (mut cfg, _) = or_exit(load_config_file());
326 let (mut cfg, _) = or_exit(load_config_file());
247 let backend = or_exit(open_repo_locked(&cfg));
327 let backend = or_exit(open_repo_locked(&cfg));
@@ -253,7 +333,7 @@ fn run_reserved(cmd: &str, text: &str) -> ExitCode {
253 }
333 }
254 "undo" => {
334 "undo" => {
255 if words.len() != 1 {
335 if words.len() != 1 {
256− return err(2, "usage: j undo");
336+ return command_usage("undo", "undo");
257 }
337 }
258 let backend = or_exit(open_repo_noconfig());
338 let backend = or_exit(open_repo_noconfig());
259 match backend.cmd_undo(false) {
339 match backend.cmd_undo(false) {
@@ -263,7 +343,7 @@ fn run_reserved(cmd: &str, text: &str) -> ExitCode {
263 }
343 }
264 "redo" => {
344 "redo" => {
265 if words.len() != 1 {
345 if words.len() != 1 {
266− return err(2, "usage: j redo");
346+ return command_usage("redo", "redo");
267 }
347 }
268 let backend = or_exit(open_repo_noconfig());
348 let backend = or_exit(open_repo_noconfig());
269 match backend.cmd_undo(true) {
349 match backend.cmd_undo(true) {
@@ -273,7 +353,7 @@ fn run_reserved(cmd: &str, text: &str) -> ExitCode {
273 }
353 }
274 "ops" => {
354 "ops" => {
275 if words.len() != 1 {
355 if words.len() != 1 {
276− return err(2, "usage: j ops");
356+ return command_usage("ops", "ops");
277 }
357 }
278 let backend = or_exit(open_repo_noconfig());
358 let backend = or_exit(open_repo_noconfig());
279 match backend.cmd_ops() {
359 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+}