just playing with tangled
1// Copyright 2024 The Jujutsu Authors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::io::BufRead as _;
16
17use clap::builder::StyledStr;
18use clap::FromArgMatches as _;
19use clap_complete::CompletionCandidate;
20use itertools::Itertools as _;
21use jj_lib::config::ConfigNamePathBuf;
22use jj_lib::settings::UserSettings;
23use jj_lib::workspace::DefaultWorkspaceLoaderFactory;
24use jj_lib::workspace::WorkspaceLoaderFactory as _;
25
26use crate::cli_util::expand_args;
27use crate::cli_util::find_workspace_dir;
28use crate::cli_util::load_template_aliases;
29use crate::cli_util::GlobalArgs;
30use crate::command_error::user_error;
31use crate::command_error::CommandError;
32use crate::config::config_from_environment;
33use crate::config::default_config_layers;
34use crate::config::ConfigArgKind;
35use crate::config::ConfigEnv;
36use crate::config::CONFIG_SCHEMA;
37use crate::revset_util::load_revset_aliases;
38use crate::ui::Ui;
39
40const BOOKMARK_HELP_TEMPLATE: &str = r#"template-aliases.'bookmark_help()'='''
41" " ++
42if(normal_target,
43 if(normal_target.description(),
44 normal_target.description().first_line(),
45 "(no description set)",
46 ),
47 "(conflicted bookmark)",
48)
49'''"#;
50
51/// A helper function for various completer functions. It returns
52/// (candidate, help) assuming they are separated by a space.
53fn split_help_text(line: &str) -> (&str, Option<StyledStr>) {
54 match line.split_once(' ') {
55 Some((name, help)) => (name, Some(help.to_string().into())),
56 None => (line, None),
57 }
58}
59
60pub fn local_bookmarks() -> Vec<CompletionCandidate> {
61 with_jj(|jj, _| {
62 let output = jj
63 .build()
64 .arg("bookmark")
65 .arg("list")
66 .arg("--config")
67 .arg(BOOKMARK_HELP_TEMPLATE)
68 .arg("--template")
69 .arg(r#"if(!remote, name ++ bookmark_help()) ++ "\n""#)
70 .output()
71 .map_err(user_error)?;
72
73 Ok(String::from_utf8_lossy(&output.stdout)
74 .lines()
75 .map(split_help_text)
76 .map(|(name, help)| CompletionCandidate::new(name).help(help))
77 .collect())
78 })
79}
80
81pub fn tracked_bookmarks() -> Vec<CompletionCandidate> {
82 with_jj(|jj, _| {
83 let output = jj
84 .build()
85 .arg("bookmark")
86 .arg("list")
87 .arg("--tracked")
88 .arg("--config")
89 .arg(BOOKMARK_HELP_TEMPLATE)
90 .arg("--template")
91 .arg(r#"if(remote, name ++ '@' ++ remote ++ bookmark_help() ++ "\n")"#)
92 .output()
93 .map_err(user_error)?;
94
95 Ok(String::from_utf8_lossy(&output.stdout)
96 .lines()
97 .map(split_help_text)
98 .map(|(name, help)| CompletionCandidate::new(name).help(help))
99 .collect())
100 })
101}
102
103pub fn untracked_bookmarks() -> Vec<CompletionCandidate> {
104 with_jj(|jj, settings| {
105 let output = jj
106 .build()
107 .arg("bookmark")
108 .arg("list")
109 .arg("--all-remotes")
110 .arg("--config")
111 .arg(BOOKMARK_HELP_TEMPLATE)
112 .arg("--template")
113 .arg(
114 r#"if(remote && !tracked && remote != "git",
115 name ++ '@' ++ remote ++ bookmark_help() ++ "\n"
116 )"#,
117 )
118 .output()
119 .map_err(user_error)?;
120
121 let prefix = settings.get_string("git.push-bookmark-prefix").ok();
122
123 Ok(String::from_utf8_lossy(&output.stdout)
124 .lines()
125 .map(|line| {
126 let (name, help) = split_help_text(line);
127
128 let display_order = match prefix.as_ref() {
129 // own bookmarks are more interesting
130 Some(prefix) if name.starts_with(prefix) => 0,
131 _ => 1,
132 };
133 CompletionCandidate::new(name)
134 .help(help)
135 .display_order(Some(display_order))
136 })
137 .collect())
138 })
139}
140
141pub fn bookmarks() -> Vec<CompletionCandidate> {
142 with_jj(|jj, settings| {
143 let output = jj
144 .build()
145 .arg("bookmark")
146 .arg("list")
147 .arg("--all-remotes")
148 .arg("--config")
149 .arg(BOOKMARK_HELP_TEMPLATE)
150 .arg("--template")
151 .arg(
152 // only provide help for local refs, remote could be ambiguous
153 r#"name ++ if(remote, "@" ++ remote, bookmark_help()) ++ "\n""#,
154 )
155 .output()
156 .map_err(user_error)?;
157 let stdout = String::from_utf8_lossy(&output.stdout);
158
159 let prefix = settings.get_string("git.push-bookmark-prefix").ok();
160
161 Ok((&stdout
162 .lines()
163 .map(split_help_text)
164 .chunk_by(|(name, _)| name.split_once('@').map(|t| t.0).unwrap_or(name)))
165 .into_iter()
166 .map(|(bookmark, mut refs)| {
167 let help = refs.find_map(|(_, help)| help);
168
169 let local = help.is_some();
170 let mine = prefix.as_ref().is_some_and(|p| bookmark.starts_with(p));
171
172 let display_order = match (local, mine) {
173 (true, true) => 0,
174 (true, false) => 1,
175 (false, true) => 2,
176 (false, false) => 3,
177 };
178 CompletionCandidate::new(bookmark)
179 .help(help)
180 .display_order(Some(display_order))
181 })
182 .collect())
183 })
184}
185
186pub fn git_remotes() -> Vec<CompletionCandidate> {
187 with_jj(|jj, _| {
188 let output = jj
189 .build()
190 .arg("git")
191 .arg("remote")
192 .arg("list")
193 .output()
194 .map_err(user_error)?;
195
196 let stdout = String::from_utf8_lossy(&output.stdout);
197
198 Ok(stdout
199 .lines()
200 .filter_map(|line| line.split_once(' ').map(|(name, _url)| name))
201 .map(CompletionCandidate::new)
202 .collect())
203 })
204}
205
206pub fn template_aliases() -> Vec<CompletionCandidate> {
207 with_jj(|_, settings| {
208 let Ok(template_aliases) = load_template_aliases(&Ui::null(), settings.config()) else {
209 return Ok(Vec::new());
210 };
211 Ok(template_aliases
212 .symbol_names()
213 .map(CompletionCandidate::new)
214 .sorted()
215 .collect())
216 })
217}
218
219pub fn aliases() -> Vec<CompletionCandidate> {
220 with_jj(|_, settings| {
221 Ok(settings
222 .table_keys("aliases")
223 // This is opinionated, but many people probably have several
224 // single- or two-letter aliases they use all the time. These
225 // aliases don't need to be completed and they would only clutter
226 // the output of `jj <TAB>`.
227 .filter(|alias| alias.len() > 2)
228 .map(CompletionCandidate::new)
229 .collect())
230 })
231}
232
233fn revisions(match_prefix: &str, revset_filter: Option<&str>) -> Vec<CompletionCandidate> {
234 with_jj(|jj, settings| {
235 // display order
236 const LOCAL_BOOKMARK_MINE: usize = 0;
237 const LOCAL_BOOKMARK: usize = 1;
238 const TAG: usize = 2;
239 const CHANGE_ID: usize = 3;
240 const REMOTE_BOOKMARK_MINE: usize = 4;
241 const REMOTE_BOOKMARK: usize = 5;
242 const REVSET_ALIAS: usize = 6;
243
244 let mut candidates = Vec::new();
245
246 // bookmarks
247
248 let prefix = settings.get_string("git.push-bookmark-prefix").ok();
249
250 let mut cmd = jj.build();
251 cmd.arg("bookmark")
252 .arg("list")
253 .arg("--all-remotes")
254 .arg("--config")
255 .arg(BOOKMARK_HELP_TEMPLATE)
256 .arg("--template")
257 .arg(
258 r#"if(remote != "git", name ++ if(remote, "@" ++ remote) ++ bookmark_help() ++ "\n")"#,
259 );
260 if let Some(revs) = revset_filter {
261 cmd.arg("--revisions").arg(revs);
262 }
263 let output = cmd.output().map_err(user_error)?;
264 let stdout = String::from_utf8_lossy(&output.stdout);
265
266 candidates.extend(
267 stdout
268 .lines()
269 .map(split_help_text)
270 .filter(|(bookmark, _)| bookmark.starts_with(match_prefix))
271 .map(|(bookmark, help)| {
272 let local = !bookmark.contains('@');
273 let mine = prefix.as_ref().is_some_and(|p| bookmark.starts_with(p));
274
275 let display_order = match (local, mine) {
276 (true, true) => LOCAL_BOOKMARK_MINE,
277 (true, false) => LOCAL_BOOKMARK,
278 (false, true) => REMOTE_BOOKMARK_MINE,
279 (false, false) => REMOTE_BOOKMARK,
280 };
281 CompletionCandidate::new(bookmark)
282 .help(help)
283 .display_order(Some(display_order))
284 }),
285 );
286
287 // tags
288
289 // Tags cannot be filtered by revisions. In order to avoid suggesting
290 // immutable tags for mutable revision args, we skip tags entirely if
291 // revset_filter is set. This is not a big loss, since tags usually point
292 // to immutable revisions anyway.
293 if revset_filter.is_none() {
294 let output = jj
295 .build()
296 .arg("tag")
297 .arg("list")
298 .arg("--config")
299 .arg(BOOKMARK_HELP_TEMPLATE)
300 .arg("--template")
301 .arg(r#"name ++ bookmark_help() ++ "\n""#)
302 .arg(format!("glob:{}*", glob::Pattern::escape(match_prefix)))
303 .output()
304 .map_err(user_error)?;
305 let stdout = String::from_utf8_lossy(&output.stdout);
306
307 candidates.extend(stdout.lines().map(|line| {
308 let (name, desc) = split_help_text(line);
309 CompletionCandidate::new(name)
310 .help(desc)
311 .display_order(Some(TAG))
312 }));
313 }
314
315 // change IDs
316
317 let revisions = revset_filter
318 .map(String::from)
319 .or_else(|| settings.get_string("revsets.short-prefixes").ok())
320 .or_else(|| settings.get_string("revsets.log").ok())
321 .unwrap_or_default();
322
323 let output = jj
324 .build()
325 .arg("log")
326 .arg("--no-graph")
327 .arg("--limit")
328 .arg("100")
329 .arg("--revisions")
330 .arg(revisions)
331 .arg("--template")
332 .arg(r#"change_id.shortest() ++ " " ++ if(description, description.first_line(), "(no description set)") ++ "\n""#)
333 .output()
334 .map_err(user_error)?;
335 let stdout = String::from_utf8_lossy(&output.stdout);
336
337 candidates.extend(
338 stdout
339 .lines()
340 .map(split_help_text)
341 .filter(|(id, _)| id.starts_with(match_prefix))
342 .map(|(id, desc)| {
343 CompletionCandidate::new(id)
344 .help(desc)
345 .display_order(Some(CHANGE_ID))
346 }),
347 );
348
349 // revset aliases
350
351 let revset_aliases = load_revset_aliases(&Ui::null(), settings.config())?;
352 let mut symbol_names: Vec<_> = revset_aliases.symbol_names().collect();
353 symbol_names.sort();
354 candidates.extend(
355 symbol_names
356 .into_iter()
357 .filter(|symbol| symbol.starts_with(match_prefix))
358 .map(|symbol| {
359 let (_, defn) = revset_aliases.get_symbol(symbol).unwrap();
360 CompletionCandidate::new(symbol)
361 .help(Some(defn.into()))
362 .display_order(Some(REVSET_ALIAS))
363 }),
364 );
365
366 Ok(candidates)
367 })
368}
369
370fn revset_expression(
371 current: &std::ffi::OsStr,
372 revset_filter: Option<&str>,
373) -> Vec<CompletionCandidate> {
374 let Some(current) = current.to_str() else {
375 return Vec::new();
376 };
377 let (prepend, match_prefix) = split_revset_trailing_name(current).unwrap_or(("", current));
378 let candidates = revisions(match_prefix, revset_filter);
379 if prepend.is_empty() {
380 candidates
381 } else {
382 candidates
383 .into_iter()
384 .map(|candidate| candidate.add_prefix(prepend))
385 .collect()
386 }
387}
388
389pub fn revset_expression_all(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
390 revset_expression(current, None)
391}
392
393pub fn revset_expression_mutable(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
394 revset_expression(current, Some("mutable()"))
395}
396
397/// Identifies if an incomplete expression ends with a name, or may be continued
398/// with a name.
399///
400/// If the expression ends with an name or a partial name, returns a tuple that
401/// splits the string at the point the name starts.
402/// If the expression is empty or ends with a prefix or infix operator that
403/// could plausibly be followed by a name, returns a tuple where the first
404/// item is the entire input string, and the second item is empty.
405/// Otherwise, returns `None`.
406///
407/// The input expression may be incomplete (e.g. missing closing parentheses),
408/// and the ability to reject invalid expressions is limited.
409fn split_revset_trailing_name(incomplete_revset_str: &str) -> Option<(&str, &str)> {
410 let final_part = incomplete_revset_str
411 .rsplit_once([':', '~', '|', '&', '(', ','])
412 .map(|(_, rest)| rest)
413 .unwrap_or(incomplete_revset_str);
414 let final_part = final_part
415 .rsplit_once("..")
416 .map(|(_, rest)| rest)
417 .unwrap_or(final_part)
418 .trim_ascii_start();
419
420 let re = regex::Regex::new(r"^(?:[\p{XID_CONTINUE}_/]+[@.+-])*[\p{XID_CONTINUE}_/]*$").unwrap();
421 re.is_match(final_part)
422 .then(|| incomplete_revset_str.split_at(incomplete_revset_str.len() - final_part.len()))
423}
424
425pub fn operations() -> Vec<CompletionCandidate> {
426 with_jj(|jj, _| {
427 let output = jj
428 .build()
429 .arg("operation")
430 .arg("log")
431 .arg("--no-graph")
432 .arg("--limit")
433 .arg("100")
434 .arg("--template")
435 .arg(
436 r#"
437 separate(" ",
438 id.short(),
439 "(" ++ format_timestamp(time.end()) ++ ")",
440 description.first_line(),
441 ) ++ "\n""#,
442 )
443 .output()
444 .map_err(user_error)?;
445
446 Ok(String::from_utf8_lossy(&output.stdout)
447 .lines()
448 .map(|line| {
449 let (id, help) = split_help_text(line);
450 CompletionCandidate::new(id).help(help)
451 })
452 .collect())
453 })
454}
455
456pub fn workspaces() -> Vec<CompletionCandidate> {
457 with_jj(|jj, _| {
458 let output = jj
459 .build()
460 .arg("--config")
461 .arg(r#"templates.commit_summary='if(description, description.first_line(), "(no description set)")'"#)
462 .arg("workspace")
463 .arg("list")
464 .output()
465 .map_err(user_error)?;
466 let stdout = String::from_utf8_lossy(&output.stdout);
467
468 Ok(stdout
469 .lines()
470 .map(|line| {
471 let (name, desc) = line.split_once(": ").unwrap_or((line, ""));
472 CompletionCandidate::new(name).help(Some(desc.to_string().into()))
473 })
474 .collect())
475 })
476}
477
478fn config_keys_rec(
479 prefix: ConfigNamePathBuf,
480 properties: &serde_json::Map<String, serde_json::Value>,
481 acc: &mut Vec<CompletionCandidate>,
482 only_leaves: bool,
483 suffix: &str,
484) {
485 for (key, value) in properties {
486 let mut prefix = prefix.clone();
487 prefix.push(key);
488
489 let value = value.as_object().unwrap();
490 match value.get("type").and_then(|v| v.as_str()) {
491 Some("object") => {
492 if !only_leaves {
493 let help = value
494 .get("description")
495 .map(|desc| desc.as_str().unwrap().to_string().into());
496 let escaped_key = prefix.to_string();
497 acc.push(CompletionCandidate::new(escaped_key).help(help));
498 }
499 let Some(properties) = value.get("properties") else {
500 continue;
501 };
502 let properties = properties.as_object().unwrap();
503 config_keys_rec(prefix, properties, acc, only_leaves, suffix);
504 }
505 _ => {
506 let help = value
507 .get("description")
508 .map(|desc| desc.as_str().unwrap().to_string().into());
509 let escaped_key = format!("{prefix}{suffix}");
510 acc.push(CompletionCandidate::new(escaped_key).help(help));
511 }
512 }
513 }
514}
515
516fn json_keypath<'a>(
517 schema: &'a serde_json::Value,
518 keypath: &str,
519 separator: &str,
520) -> Option<&'a serde_json::Value> {
521 keypath
522 .split(separator)
523 .try_fold(schema, |value, step| value.get(step))
524}
525fn jsonschema_keypath<'a>(
526 schema: &'a serde_json::Value,
527 keypath: &ConfigNamePathBuf,
528) -> Option<&'a serde_json::Value> {
529 keypath.components().try_fold(schema, |value, step| {
530 let value = value.as_object()?;
531 if value.get("type")?.as_str()? != "object" {
532 return None;
533 }
534 let properties = value.get("properties")?.as_object()?;
535 properties.get(step.get())
536 })
537}
538
539fn config_values(path: &ConfigNamePathBuf) -> Option<Vec<String>> {
540 let schema: serde_json::Value = serde_json::from_str(CONFIG_SCHEMA).unwrap();
541
542 let mut config_entry = jsonschema_keypath(&schema, path)?;
543 if let Some(reference) = config_entry.get("$ref") {
544 let reference = reference.as_str()?.strip_prefix("#/")?;
545 config_entry = json_keypath(&schema, reference, "/")?;
546 };
547
548 if let Some(possible_values) = config_entry.get("enum") {
549 return Some(
550 possible_values
551 .as_array()?
552 .iter()
553 .filter_map(|val| val.as_str())
554 .map(ToOwned::to_owned)
555 .collect(),
556 );
557 }
558
559 Some(match config_entry.get("type")?.as_str()? {
560 "boolean" => vec!["false".into(), "true".into()],
561 _ => vec![],
562 })
563}
564
565fn config_keys_impl(only_leaves: bool, suffix: &str) -> Vec<CompletionCandidate> {
566 let schema: serde_json::Value = serde_json::from_str(CONFIG_SCHEMA).unwrap();
567 let schema = schema.as_object().unwrap();
568 let properties = schema["properties"].as_object().unwrap();
569
570 let mut candidates = Vec::new();
571 config_keys_rec(
572 ConfigNamePathBuf::root(),
573 properties,
574 &mut candidates,
575 only_leaves,
576 suffix,
577 );
578 candidates
579}
580
581pub fn config_keys() -> Vec<CompletionCandidate> {
582 config_keys_impl(false, "")
583}
584
585pub fn leaf_config_keys() -> Vec<CompletionCandidate> {
586 config_keys_impl(true, "")
587}
588
589pub fn leaf_config_key_value(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
590 let Some(current) = current.to_str() else {
591 return Vec::new();
592 };
593
594 if let Some((key, current_val)) = current.split_once('=') {
595 let Ok(key) = key.parse() else {
596 return Vec::new();
597 };
598 let possible_values = config_values(&key).unwrap_or_default();
599
600 possible_values
601 .into_iter()
602 .filter(|x| x.starts_with(current_val))
603 .map(|x| CompletionCandidate::new(format!("{key}={x}")))
604 .collect()
605 } else {
606 config_keys_impl(true, "=")
607 .into_iter()
608 .filter(|candidate| candidate.get_value().to_str().unwrap().starts_with(current))
609 .collect()
610 }
611}
612
613pub fn branch_name_equals_any_revision(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
614 let Some(current) = current.to_str() else {
615 return Vec::new();
616 };
617
618 let Some((branch_name, revision)) = current.split_once('=') else {
619 // Don't complete branch names since we want to create a new branch
620 return Vec::new();
621 };
622 revset_expression(revision.as_ref(), None)
623 .into_iter()
624 .map(|rev| rev.add_prefix(format!("{branch_name}=")))
625 .collect()
626}
627
628fn dir_prefix_from<'a>(path: &'a str, current: &str) -> Option<&'a str> {
629 path[current.len()..]
630 .split_once(std::path::MAIN_SEPARATOR)
631 .map(|(next, _)| path.split_at(current.len() + next.len() + 1).0)
632}
633
634fn current_prefix_to_fileset(current: &str) -> String {
635 let cur_esc = glob::Pattern::escape(current);
636 let dir_pat = format!("{cur_esc}*/**");
637 let path_pat = format!("{cur_esc}*");
638 format!("glob:{dir_pat:?} | glob:{path_pat:?}")
639}
640
641fn all_files_from_rev(rev: String, current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
642 let Some(current) = current.to_str() else {
643 return Vec::new();
644 };
645 with_jj(|jj, _| {
646 let mut child = jj
647 .build()
648 .arg("file")
649 .arg("list")
650 .arg("--revision")
651 .arg(rev)
652 .arg(current_prefix_to_fileset(current))
653 .stdout(std::process::Stdio::piped())
654 .stderr(std::process::Stdio::null())
655 .spawn()
656 .map_err(user_error)?;
657 let stdout = child.stdout.take().unwrap();
658
659 Ok(std::io::BufReader::new(stdout)
660 .lines()
661 .take(1_000)
662 .map_while(Result::ok)
663 .map(|path| {
664 if let Some(dir_path) = dir_prefix_from(&path, current) {
665 return CompletionCandidate::new(dir_path);
666 }
667 CompletionCandidate::new(path)
668 })
669 .dedup() // directories may occur multiple times
670 .collect())
671 })
672}
673
674fn modified_files_from_rev_with_jj_cmd(
675 rev: (String, Option<String>),
676 mut cmd: std::process::Command,
677 current: &std::ffi::OsStr,
678) -> Result<Vec<CompletionCandidate>, CommandError> {
679 let Some(current) = current.to_str() else {
680 return Ok(Vec::new());
681 };
682 cmd.arg("diff")
683 .arg("--summary")
684 .arg(current_prefix_to_fileset(current));
685 match rev {
686 (rev, None) => cmd.arg("--revisions").arg(rev),
687 (from, Some(to)) => cmd.arg("--from").arg(from).arg("--to").arg(to),
688 };
689 let output = cmd.output().map_err(user_error)?;
690 let stdout = String::from_utf8_lossy(&output.stdout);
691
692 let mut candidates = Vec::new();
693 // store renamed paths in a separate vector so we don't have to sort later
694 let mut renamed = Vec::new();
695
696 'line_loop: for line in stdout.lines() {
697 let (mode, path) = line
698 .split_once(' ')
699 .expect("diff --summary should contain a space between mode and path");
700
701 fn path_to_candidate(current: &str, mode: &str, p: impl AsRef<str>) -> CompletionCandidate {
702 let p = p.as_ref();
703 if let Some(dir_path) = dir_prefix_from(p, current) {
704 return CompletionCandidate::new(dir_path);
705 }
706
707 let help = match mode {
708 "M" => "Modified".into(),
709 "D" => "Deleted".into(),
710 "A" => "Added".into(),
711 "R" => "Renamed".into(),
712 "C" => "Copied".into(),
713 _ => format!("unknown mode: '{mode}'"),
714 };
715 CompletionCandidate::new(p).help(Some(help.into()))
716 }
717
718 // In case of a rename, one line of `diff --summary` results in
719 // two suggestions.
720 if mode == "R" {
721 'split_renamed_paths: {
722 let Some((prefix, rest)) = path.split_once('{') else {
723 break 'split_renamed_paths;
724 };
725 let Some((rename, suffix)) = rest.split_once('}') else {
726 break 'split_renamed_paths;
727 };
728 let Some((before, after)) = rename.split_once(" => ") else {
729 break 'split_renamed_paths;
730 };
731 let before = format!("{prefix}{before}{suffix}");
732 let after = format!("{prefix}{after}{suffix}");
733 candidates.push(path_to_candidate(current, mode, before));
734 renamed.push(path_to_candidate(current, mode, after));
735 continue 'line_loop;
736 };
737 }
738
739 candidates.push(path_to_candidate(current, mode, path));
740 }
741 candidates.extend(renamed);
742 candidates.dedup();
743
744 Ok(candidates)
745}
746
747fn modified_files_from_rev(
748 rev: (String, Option<String>),
749 current: &std::ffi::OsStr,
750) -> Vec<CompletionCandidate> {
751 with_jj(|jj, _| modified_files_from_rev_with_jj_cmd(rev, jj.build(), current))
752}
753
754fn conflicted_files_from_rev(rev: &str, current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
755 let Some(current) = current.to_str() else {
756 return Vec::new();
757 };
758 with_jj(|jj, _| {
759 let output = jj
760 .build()
761 .arg("resolve")
762 .arg("--list")
763 .arg("--revision")
764 .arg(rev)
765 .arg(current_prefix_to_fileset(current))
766 .output()
767 .map_err(user_error)?;
768 let stdout = String::from_utf8_lossy(&output.stdout);
769
770 Ok(stdout
771 .lines()
772 .map(|line| {
773 let path = line
774 .split_whitespace()
775 .next()
776 .expect("resolve --list should contain whitespace after path");
777
778 if let Some(dir_path) = dir_prefix_from(path, current) {
779 return CompletionCandidate::new(dir_path);
780 }
781 CompletionCandidate::new(path)
782 })
783 .dedup() // directories may occur multiple times
784 .collect())
785 })
786}
787
788pub fn modified_files(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
789 modified_files_from_rev(("@".into(), None), current)
790}
791
792pub fn all_revision_files(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
793 all_files_from_rev(parse::revision_or_wc(), current)
794}
795
796pub fn modified_revision_files(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
797 modified_files_from_rev((parse::revision_or_wc(), None), current)
798}
799
800pub fn modified_range_files(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
801 match parse::range() {
802 Some((from, to)) => modified_files_from_rev((from, Some(to)), current),
803 None => modified_files_from_rev(("@".into(), None), current),
804 }
805}
806
807pub fn modified_revision_or_range_files(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
808 if let Some(rev) = parse::revision() {
809 return modified_files_from_rev((rev, None), current);
810 }
811 modified_range_files(current)
812}
813
814pub fn revision_conflicted_files(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
815 conflicted_files_from_rev(&parse::revision_or_wc(), current)
816}
817
818/// Specific function for completing file paths for `jj squash`
819pub fn squash_revision_files(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
820 let rev = parse::squash_revision().unwrap_or_else(|| "@".into());
821 modified_files_from_rev((rev, None), current)
822}
823
824/// Specific function for completing file paths for `jj interdiff`
825pub fn interdiff_files(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
826 let Some((from, to)) = parse::range() else {
827 return Vec::new();
828 };
829 // Complete all modified files in "from" and "to". This will also suggest
830 // files that are the same in both, which is a false positive. This approach
831 // is more lightweight than actually doing a temporary rebase here.
832 with_jj(|jj, _| {
833 let mut res = modified_files_from_rev_with_jj_cmd((from, None), jj.build(), current)?;
834 res.extend(modified_files_from_rev_with_jj_cmd(
835 (to, None),
836 jj.build(),
837 current,
838 )?);
839 Ok(res)
840 })
841}
842
843/// Specific function for completing file paths for `jj log`
844pub fn log_files(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
845 let mut rev = parse::log_revisions().join(")|(");
846 if rev.is_empty() {
847 rev = "@".into();
848 } else {
849 rev = format!("latest(heads(({rev})))"); // limit to one
850 };
851 all_files_from_rev(rev, current)
852}
853
854/// Shell out to jj during dynamic completion generation
855///
856/// In case of errors, print them and early return an empty vector.
857fn with_jj<F>(completion_fn: F) -> Vec<CompletionCandidate>
858where
859 F: FnOnce(JjBuilder, &UserSettings) -> Result<Vec<CompletionCandidate>, CommandError>,
860{
861 get_jj_command()
862 .and_then(|(jj, settings)| completion_fn(jj, &settings))
863 .unwrap_or_else(|e| {
864 eprintln!("{}", e.error);
865 Vec::new()
866 })
867}
868
869/// Shell out to jj during dynamic completion generation
870///
871/// This is necessary because dynamic completion code needs to be aware of
872/// global configuration like custom storage backends. Dynamic completion
873/// code via clap_complete doesn't accept arguments, so they cannot be passed
874/// that way. Another solution would've been to use global mutable state, to
875/// give completion code access to custom backends. Shelling out was chosen as
876/// the preferred method, because it's more maintainable and the performance
877/// requirements of completions aren't very high.
878fn get_jj_command() -> Result<(JjBuilder, UserSettings), CommandError> {
879 let current_exe = std::env::current_exe().map_err(user_error)?;
880 let mut cmd_args = Vec::<String>::new();
881
882 // Snapshotting could make completions much slower in some situations
883 // and be undesired by the user.
884 cmd_args.push("--ignore-working-copy".into());
885 cmd_args.push("--color=never".into());
886 cmd_args.push("--no-pager".into());
887
888 // Parse some of the global args we care about for passing along to the
889 // child process. This shouldn't fail, since none of the global args are
890 // required.
891 let app = crate::commands::default_app();
892 let mut raw_config = config_from_environment(default_config_layers());
893 let ui = Ui::null();
894 let cwd = std::env::current_dir()
895 .and_then(dunce::canonicalize)
896 .map_err(user_error)?;
897 // No config migration for completion. Simply ignore deprecated variables.
898 let mut config_env = ConfigEnv::from_environment(&ui);
899 let maybe_cwd_workspace_loader = DefaultWorkspaceLoaderFactory.create(find_workspace_dir(&cwd));
900 let _ = config_env.reload_user_config(&mut raw_config);
901 if let Ok(loader) = &maybe_cwd_workspace_loader {
902 config_env.reset_repo_path(loader.repo_path());
903 let _ = config_env.reload_repo_config(&mut raw_config);
904 }
905 let mut config = config_env.resolve_config(&raw_config)?;
906 // skip 2 because of the clap_complete prelude: jj -- jj <actual args...>
907 let args = std::env::args_os().skip(2);
908 let args = expand_args(&ui, &app, args, &config)?;
909 let arg_matches = app
910 .clone()
911 .disable_version_flag(true)
912 .disable_help_flag(true)
913 .ignore_errors(true)
914 .try_get_matches_from(args)?;
915 let args: GlobalArgs = GlobalArgs::from_arg_matches(&arg_matches)?;
916
917 if let Some(repository) = args.repository {
918 // Try to update repo-specific config on a best-effort basis.
919 if let Ok(loader) = DefaultWorkspaceLoaderFactory.create(&cwd.join(&repository)) {
920 config_env.reset_repo_path(loader.repo_path());
921 let _ = config_env.reload_repo_config(&mut raw_config);
922 if let Ok(new_config) = config_env.resolve_config(&raw_config) {
923 config = new_config;
924 }
925 }
926 cmd_args.push("--repository".into());
927 cmd_args.push(repository);
928 }
929 if let Some(at_operation) = args.at_operation {
930 // We cannot assume that the value of at_operation is valid, because
931 // the user may be requesting completions precisely for this invalid
932 // operation ID. Additionally, the user may have mistyped the ID,
933 // in which case adding the argument blindly would break all other
934 // completions, even unrelated ones.
935 //
936 // To avoid this, we shell out to ourselves once with the argument
937 // and check the exit code. There is some performance overhead to this,
938 // but this code path is probably only executed in exceptional
939 // situations.
940 let mut canary_cmd = std::process::Command::new(¤t_exe);
941 canary_cmd.args(&cmd_args);
942 canary_cmd.arg("--at-operation");
943 canary_cmd.arg(&at_operation);
944 canary_cmd.arg("debug");
945 canary_cmd.arg("snapshot");
946
947 match canary_cmd.output() {
948 Ok(output) if output.status.success() => {
949 // Operation ID is valid, add it to the completion command.
950 cmd_args.push("--at-operation".into());
951 cmd_args.push(at_operation);
952 }
953 _ => {} // Invalid operation ID, ignore.
954 }
955 }
956 for (kind, value) in args.early_args.merged_config_args(&arg_matches) {
957 let arg = match kind {
958 ConfigArgKind::Item => format!("--config={value}"),
959 ConfigArgKind::Toml => format!("--config-toml={value}"),
960 ConfigArgKind::File => format!("--config-file={value}"),
961 };
962 cmd_args.push(arg);
963 }
964
965 let builder = JjBuilder {
966 cmd: current_exe,
967 args: cmd_args,
968 };
969 let settings = UserSettings::from_config(config)?;
970
971 Ok((builder, settings))
972}
973
974/// A helper struct to allow completion functions to call jj multiple times with
975/// different arguments.
976struct JjBuilder {
977 cmd: std::path::PathBuf,
978 args: Vec<String>,
979}
980
981impl JjBuilder {
982 fn build(&self) -> std::process::Command {
983 let mut cmd = std::process::Command::new(&self.cmd);
984 cmd.args(&self.args);
985 cmd
986 }
987}
988
989/// Functions for parsing revisions and revision ranges from the command line.
990/// Parsing is done on a best-effort basis and relies on the heuristic that
991/// most command line flags are consistent across different subcommands.
992///
993/// In some cases, this parsing will be incorrect, but it's not worth the effort
994/// to fix that. For example, if the user specifies any of the relevant flags
995/// multiple times, the parsing will pick any of the available ones, while the
996/// actual execution of the command would fail.
997mod parse {
998 pub(super) fn parse_flag<'a, I: Iterator<Item = String>>(
999 candidates: &'a [&str],
1000 mut args: I,
1001 ) -> impl Iterator<Item = String> + use<'a, I> {
1002 std::iter::from_fn(move || {
1003 for arg in args.by_ref() {
1004 // -r REV syntax
1005 if candidates.contains(&arg.as_ref()) {
1006 match args.next() {
1007 Some(val) if !val.starts_with('-') => return Some(val),
1008 _ => return None,
1009 }
1010 }
1011
1012 // -r=REV syntax
1013 if let Some(value) = candidates.iter().find_map(|candidate| {
1014 let rest = arg.strip_prefix(candidate)?;
1015 match rest.strip_prefix('=') {
1016 Some(value) => Some(value),
1017
1018 // -rREV syntax
1019 None if candidate.len() == 2 => Some(rest),
1020
1021 None => None,
1022 }
1023 }) {
1024 return Some(value.into());
1025 };
1026 }
1027 None
1028 })
1029 }
1030
1031 pub fn parse_revision_impl(args: impl Iterator<Item = String>) -> Option<String> {
1032 parse_flag(&["-r", "--revision"], args).next()
1033 }
1034
1035 pub fn revision() -> Option<String> {
1036 parse_revision_impl(std::env::args())
1037 }
1038
1039 pub fn revision_or_wc() -> String {
1040 revision().unwrap_or_else(|| "@".into())
1041 }
1042
1043 pub fn parse_range_impl<T>(args: impl Fn() -> T) -> Option<(String, String)>
1044 where
1045 T: Iterator<Item = String>,
1046 {
1047 let from = parse_flag(&["-f", "--from"], args()).next()?;
1048 let to = parse_flag(&["-t", "--to"], args())
1049 .next()
1050 .unwrap_or_else(|| "@".into());
1051
1052 Some((from, to))
1053 }
1054
1055 pub fn range() -> Option<(String, String)> {
1056 parse_range_impl(std::env::args)
1057 }
1058
1059 // Special parse function only for `jj squash`. While squash has --from and
1060 // --to arguments, only files within --from should be completed, because
1061 // the files changed only in some other revision in the range between
1062 // --from and --to cannot be squashed into --to like that.
1063 pub fn squash_revision() -> Option<String> {
1064 if let Some(rev) = parse_flag(&["-r", "--revision"], std::env::args()).next() {
1065 return Some(rev);
1066 }
1067 parse_flag(&["-f", "--from"], std::env::args()).next()
1068 }
1069
1070 // Special parse function only for `jj log`. It has a --revisions flag,
1071 // instead of the usual --revision, and it can be supplied multiple times.
1072 pub fn log_revisions() -> Vec<String> {
1073 let candidates = &["-r", "--revisions"];
1074 parse_flag(candidates, std::env::args()).collect()
1075 }
1076}
1077
1078#[cfg(test)]
1079mod tests {
1080 use super::*;
1081
1082 #[test]
1083 fn test_split_revset_trailing_name() {
1084 assert_eq!(split_revset_trailing_name(""), Some(("", "")));
1085 assert_eq!(split_revset_trailing_name(" "), Some((" ", "")));
1086 assert_eq!(split_revset_trailing_name("foo"), Some(("", "foo")));
1087 assert_eq!(split_revset_trailing_name(" foo"), Some((" ", "foo")));
1088 assert_eq!(split_revset_trailing_name("foo "), None);
1089 assert_eq!(split_revset_trailing_name("foo_"), Some(("", "foo_")));
1090 assert_eq!(split_revset_trailing_name("foo/"), Some(("", "foo/")));
1091 assert_eq!(split_revset_trailing_name("foo/b"), Some(("", "foo/b")));
1092
1093 assert_eq!(split_revset_trailing_name("foo-"), Some(("", "foo-")));
1094 assert_eq!(split_revset_trailing_name("foo+"), Some(("", "foo+")));
1095 assert_eq!(
1096 split_revset_trailing_name("foo-bar-"),
1097 Some(("", "foo-bar-"))
1098 );
1099 assert_eq!(
1100 split_revset_trailing_name("foo-bar-b"),
1101 Some(("", "foo-bar-b"))
1102 );
1103
1104 assert_eq!(split_revset_trailing_name("foo."), Some(("", "foo.")));
1105 assert_eq!(split_revset_trailing_name("foo..b"), Some(("foo..", "b")));
1106 assert_eq!(split_revset_trailing_name("..foo"), Some(("..", "foo")));
1107
1108 assert_eq!(split_revset_trailing_name("foo(bar"), Some(("foo(", "bar")));
1109 assert_eq!(split_revset_trailing_name("foo(bar)"), None);
1110 assert_eq!(split_revset_trailing_name("(f"), Some(("(", "f")));
1111
1112 assert_eq!(split_revset_trailing_name("foo@"), Some(("", "foo@")));
1113 assert_eq!(split_revset_trailing_name("foo@b"), Some(("", "foo@b")));
1114 assert_eq!(split_revset_trailing_name("..foo@"), Some(("..", "foo@")));
1115 assert_eq!(
1116 split_revset_trailing_name("::F(foo@origin.1..bar@origin."),
1117 Some(("::F(foo@origin.1..", "bar@origin."))
1118 );
1119 }
1120
1121 #[test]
1122 fn test_split_revset_trailing_name_with_trailing_operator() {
1123 assert_eq!(split_revset_trailing_name("foo|"), Some(("foo|", "")));
1124 assert_eq!(split_revset_trailing_name("foo | "), Some(("foo | ", "")));
1125 assert_eq!(split_revset_trailing_name("foo&"), Some(("foo&", "")));
1126 assert_eq!(split_revset_trailing_name("foo~"), Some(("foo~", "")));
1127
1128 assert_eq!(split_revset_trailing_name(".."), Some(("..", "")));
1129 assert_eq!(split_revset_trailing_name("foo.."), Some(("foo..", "")));
1130 assert_eq!(split_revset_trailing_name("::"), Some(("::", "")));
1131 assert_eq!(split_revset_trailing_name("foo::"), Some(("foo::", "")));
1132
1133 assert_eq!(split_revset_trailing_name("("), Some(("(", "")));
1134 assert_eq!(split_revset_trailing_name("foo("), Some(("foo(", "")));
1135 assert_eq!(split_revset_trailing_name("foo()"), None);
1136 assert_eq!(split_revset_trailing_name("foo(bar)"), None);
1137 }
1138
1139 #[test]
1140 fn test_split_revset_trailing_name_with_modifier() {
1141 assert_eq!(split_revset_trailing_name("all:"), Some(("all:", "")));
1142 assert_eq!(split_revset_trailing_name("all: "), Some(("all: ", "")));
1143 assert_eq!(split_revset_trailing_name("all:f"), Some(("all:", "f")));
1144 assert_eq!(split_revset_trailing_name("all: f"), Some(("all: ", "f")));
1145 }
1146
1147 #[test]
1148 fn test_config_keys() {
1149 // Just make sure the schema is parsed without failure.
1150 let _ = config_keys();
1151 }
1152
1153 #[test]
1154 fn test_parse_revision_impl() {
1155 let good_cases: &[&[&str]] = &[
1156 &["-r", "foo"],
1157 &["--revision", "foo"],
1158 &["-r=foo"],
1159 &["--revision=foo"],
1160 &["preceding_arg", "-r", "foo"],
1161 &["-r", "foo", "following_arg"],
1162 ];
1163 for case in good_cases {
1164 let args = case.iter().map(|s| s.to_string());
1165 assert_eq!(
1166 parse::parse_revision_impl(args),
1167 Some("foo".into()),
1168 "case: {case:?}",
1169 );
1170 }
1171 let bad_cases: &[&[&str]] = &[&[], &["-r"], &["foo"], &["-R", "foo"], &["-R=foo"]];
1172 for case in bad_cases {
1173 let args = case.iter().map(|s| s.to_string());
1174 assert_eq!(parse::parse_revision_impl(args), None, "case: {case:?}");
1175 }
1176 }
1177
1178 #[test]
1179 fn test_parse_range_impl() {
1180 let wc_cases: &[&[&str]] = &[
1181 &["-f", "foo"],
1182 &["--from", "foo"],
1183 &["-f=foo"],
1184 &["preceding_arg", "-f", "foo"],
1185 &["-f", "foo", "following_arg"],
1186 ];
1187 for case in wc_cases {
1188 let args = case.iter().map(|s| s.to_string());
1189 assert_eq!(
1190 parse::parse_range_impl(|| args.clone()),
1191 Some(("foo".into(), "@".into())),
1192 "case: {case:?}",
1193 );
1194 }
1195 let to_cases: &[&[&str]] = &[
1196 &["-f", "foo", "-t", "bar"],
1197 &["-f", "foo", "--to", "bar"],
1198 &["-f=foo", "-t=bar"],
1199 &["-t=bar", "-f=foo"],
1200 ];
1201 for case in to_cases {
1202 let args = case.iter().map(|s| s.to_string());
1203 assert_eq!(
1204 parse::parse_range_impl(|| args.clone()),
1205 Some(("foo".into(), "bar".into())),
1206 "case: {case:?}",
1207 );
1208 }
1209 let bad_cases: &[&[&str]] = &[&[], &["-f"], &["foo"], &["-R", "foo"], &["-R=foo"]];
1210 for case in bad_cases {
1211 let args = case.iter().map(|s| s.to_string());
1212 assert_eq!(
1213 parse::parse_range_impl(|| args.clone()),
1214 None,
1215 "case: {case:?}"
1216 );
1217 }
1218 }
1219
1220 #[test]
1221 fn test_parse_multiple_flags() {
1222 let candidates = &["-r", "--revisions"];
1223 let args = &[
1224 "unrelated_arg_at_the_beginning",
1225 "-r",
1226 "1",
1227 "--revisions",
1228 "2",
1229 "-r=3",
1230 "--revisions=4",
1231 "unrelated_arg_in_the_middle",
1232 "-r5",
1233 "unrelated_arg_at_the_end",
1234 ];
1235 let flags: Vec<_> =
1236 parse::parse_flag(candidates, args.iter().map(|a| a.to_string())).collect();
1237 let expected = ["1", "2", "3", "4", "5"];
1238 assert_eq!(flags, expected);
1239 }
1240}