1//! Read-only command discovery, built from the user's unevaluated config.
3use j::ast::{Item, TypeExpr};
6use j::parse::parse_config;
7use std::collections::BTreeMap;
10pub const OVERVIEW: &str = r#"j — a functional CLI for Jujutsu repositories
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
19 j init Create a repository in the current directory
20 j clone URL [DIR] Clone a repository
22Inspect (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)
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
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
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
48Expressions compose right to left: `describe "wip" . new` runs new first.
49A function result receives the current repository. A Repo result saves the
50edit; other results print. Quote the whole expression with shell single
51quotes to preserve text's double quotes and operators such as *, <, and >.
53Your config.j defines the vocabulary: edits (Repo -> Repo), inspections,
54revsets, filesets, types, and custom names. Search with `j commands rebase`
55or `j commands Edit`; use `j help squash` or `j help commands` for details.
61 summary: &'static str,
65const RESERVED: &[Reserved] = &[
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",
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",
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",
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",
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",
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",
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",
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",
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",
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",
128pub fn reserved_help(name: &str) -> Option<String> {
131 .find(|cmd| cmd.name == name)
132 .map(|cmd| format!("Usage: {}\n\n{}\n\n{}", cmd.usage, cmd.summary, cmd.detail))
135#[derive(Clone, Copy)]
145 signature: Option<TypeExpr>,
151 fn declaration(&self) -> String {
152 let name = if config::is_reserved_builtin(&self.name)
153 && !self.name.chars().next().unwrap_or('_').is_alphabetic()
155 format!("({})", self.name)
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),
167pub 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();
173 for cmd in RESERVED {
174 if matches(&format!("{} {} {}", cmd.usage, cmd.summary, cmd.detail)) {
176 out.push_str("CLI commands:\n");
178 out.push_str(&format!(" {:<24} {}\n", cmd.usage, cmd.summary));
182 let mut config_count = 0;
183 for entry in entries {
184 let declaration = entry.declaration();
185 if !matches(&format!("{} {}", declaration, entry.docs.join(" "))) {
188 if config_count == 0 {
192 out.push_str("Config vocabulary (definitions, builtins, operators, and types):\n");
194 out.push_str(&format!(" {}\n", declaration));
195 if let Some(doc) = entry.docs.first() {
196 out.push_str(&format!(" {}\n", truncate(doc, 110)));
200 if count + config_count == 0 {
201 out.push_str("No matching commands or config names. Try `j commands` to list all names.\n");
203 out.push_str("\nUse `j help NAME` for details; `j commands QUERY` to narrow the list.\n");
208pub fn command_help(src: &str, name: &str) -> Result<Option<String>, String> {
209 let name = name.trim();
212 .and_then(|n| n.strip_suffix(')'))
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",
222 let mut out = format!(
223 "{}\n\n{}\nSource: line {}\n",
228 if !entry.docs.is_empty() {
230 for doc in entry.docs.iter().take(24) {
234 if entry.docs.len() > 24 {
235 out.push_str("… See the source for the remaining comments.\n");
242fn 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
250 .filter(|(_, token)| token.col == 1 && !matches!(token.tok, Tok::Newline | Tok::Eof))
251 .map(|(index, _)| index)
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]
261 .find(|token| !matches!(token.tok, Tok::Newline | Tok::Eof))
263 let next_start = starts
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) {
276 (name, Some(ty), kind)
278 Item::Definition(name, _, _) => (name, None, Kind::Definition),
280 let entry = entries.entry(name.clone()).or_insert(Entry {
288 if !entry.docs.contains(&doc) {
289 entry.docs.push(doc);
293 let mut entries: Vec<_> = entries.into_values().collect();
294 entries.sort_by_cached_key(|entry| (entry.name.to_lowercase(), entry.name.clone()));
298fn type_text(ty: &TypeExpr) -> String {
300 TypeExpr::Con(name) => name.clone(),
301 TypeExpr::List(item) => format!("[{}]", type_text(item)),
302 TypeExpr::Record(fields) => format!(
306 .map(|(name, ty)| format!("{} : {}", name, type_text(ty)))
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)
317 format!("{} -> {}", arg_text, type_text(result))
322fn 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)
341/// Comments occupy gaps between lexer tokens, so text such as "-- not a doc"
342/// inside string literals cannot be mistaken for documentation.
343fn comments(src: &str, tokens: &[j::lex::SpTok]) -> Vec<Comment> {
344 let mut comments = Vec::<Comment>::new();
348 .filter(|token| !matches!(token.tok, Tok::Newline))
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("{-") {
361 if rest[offset..].starts_with("{-") {
364 } else if rest[offset..].starts_with("-}") {
368 offset += rest[offset..].chars().next().unwrap().len_utf8();
371 (&rest[2..offset - 2], offset)
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)
385 comments.push(Comment {
391 text: raw.lines().map(|line| line.trim().to_string()).collect(),
393 rest = &rest[consumed..];
405 comments: &[Comment],
407 let mut preceding = Vec::new();
408 let mut line = first_line.saturating_sub(1);
409 while let Some(comment) = comments
412 .find(|comment| comment.last_line == line && comment.standalone && !comment.continuation)
417 .all(|text| text.chars().all(|c| "-=_ ".contains(c)))
421 preceding.push(comment);
422 line = comment.first_line.saturating_sub(1);
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)
434 .flat_map(|comment| comment.text.iter())
435 .filter(|text| !text.is_empty() && !text.chars().all(|c| "-=_ ".contains(c)))
444 const BASE: &str = "user = {}\nimmutable = []\ntree = 0\nlabelled = 0\n";
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"));
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"));
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"));
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")
490 .contains("third comment"));
494 fn validates_config_and_does_not_resolve_ids() {
495 assert!(command_list("broken =", "").is_err());
496 assert!(command_help("x = 1", "x")
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")
504 .contains("No matching"));
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")
518 .contains("j 'push (label \"feature\" here)'"));
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list