@@ -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+}