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(revisions: 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) = revisions {
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(stdout.lines().map(|line| {
267 let (bookmark, help) = split_help_text(line);
268
269 let local = !bookmark.contains('@');
270 let mine = prefix.as_ref().is_some_and(|p| bookmark.starts_with(p));
271
272 let display_order = match (local, mine) {
273 (true, true) => LOCAL_BOOKMARK_MINE,
274 (true, false) => LOCAL_BOOKMARK,
275 (false, true) => REMOTE_BOOKMARK_MINE,
276 (false, false) => REMOTE_BOOKMARK,
277 };
278 CompletionCandidate::new(bookmark)
279 .help(help)
280 .display_order(Some(display_order))
281 }));
282
283 // tags
284
285 // Tags cannot be filtered by revisions. In order to avoid suggesting
286 // immutable tags for mutable revision args, we skip tags entirely if
287 // revisions is set. This is not a big loss, since tags usually point
288 // to immutable revisions anyway.
289 if revisions.is_none() {
290 let output = jj
291 .build()
292 .arg("tag")
293 .arg("list")
294 .arg("--config")
295 .arg(BOOKMARK_HELP_TEMPLATE)
296 .arg("--template")
297 .arg(r#"name ++ bookmark_help() ++ "\n""#)
298 .output()
299 .map_err(user_error)?;
300 let stdout = String::from_utf8_lossy(&output.stdout);
301
302 candidates.extend(stdout.lines().map(|line| {
303 let (name, desc) = split_help_text(line);
304 CompletionCandidate::new(name)
305 .help(desc)
306 .display_order(Some(TAG))
307 }));
308 }
309
310 // change IDs
311
312 let revisions = revisions
313 .map(String::from)
314 .or_else(|| settings.get_string("revsets.short-prefixes").ok())
315 .or_else(|| settings.get_string("revsets.log").ok())
316 .unwrap_or_default();
317
318 let output = jj
319 .build()
320 .arg("log")
321 .arg("--no-graph")
322 .arg("--limit")
323 .arg("100")
324 .arg("--revisions")
325 .arg(revisions)
326 .arg("--template")
327 .arg(r#"change_id.shortest() ++ " " ++ if(description, description.first_line(), "(no description set)") ++ "\n""#)
328 .output()
329 .map_err(user_error)?;
330 let stdout = String::from_utf8_lossy(&output.stdout);
331
332 candidates.extend(stdout.lines().map(|line| {
333 let (id, desc) = split_help_text(line);
334 CompletionCandidate::new(id)
335 .help(desc)
336 .display_order(Some(CHANGE_ID))
337 }));
338
339 // revset aliases
340
341 let revset_aliases = load_revset_aliases(&Ui::null(), settings.config())?;
342 let mut symbol_names: Vec<_> = revset_aliases.symbol_names().collect();
343 symbol_names.sort();
344 candidates.extend(symbol_names.into_iter().map(|symbol| {
345 let (_, defn) = revset_aliases.get_symbol(symbol).unwrap();
346 CompletionCandidate::new(symbol)
347 .help(Some(defn.into()))
348 .display_order(Some(REVSET_ALIAS))
349 }));
350
351 Ok(candidates)
352 })
353}
354
355pub fn mutable_revisions() -> Vec<CompletionCandidate> {
356 revisions(Some("mutable()"))
357}
358
359pub fn all_revisions() -> Vec<CompletionCandidate> {
360 revisions(None)
361}
362
363pub fn operations() -> Vec<CompletionCandidate> {
364 with_jj(|jj, _| {
365 let output = jj
366 .build()
367 .arg("operation")
368 .arg("log")
369 .arg("--no-graph")
370 .arg("--limit")
371 .arg("100")
372 .arg("--template")
373 .arg(
374 r#"
375 separate(" ",
376 id.short(),
377 "(" ++ format_timestamp(time.end()) ++ ")",
378 description.first_line(),
379 ) ++ "\n""#,
380 )
381 .output()
382 .map_err(user_error)?;
383
384 Ok(String::from_utf8_lossy(&output.stdout)
385 .lines()
386 .map(|line| {
387 let (id, help) = split_help_text(line);
388 CompletionCandidate::new(id).help(help)
389 })
390 .collect())
391 })
392}
393
394pub fn workspaces() -> Vec<CompletionCandidate> {
395 with_jj(|jj, _| {
396 let output = jj
397 .build()
398 .arg("--config")
399 .arg(r#"templates.commit_summary='if(description, description.first_line(), "(no description set)")'"#)
400 .arg("workspace")
401 .arg("list")
402 .output()
403 .map_err(user_error)?;
404 let stdout = String::from_utf8_lossy(&output.stdout);
405
406 Ok(stdout
407 .lines()
408 .map(|line| {
409 let (name, desc) = line.split_once(": ").unwrap_or((line, ""));
410 CompletionCandidate::new(name).help(Some(desc.to_string().into()))
411 })
412 .collect())
413 })
414}
415
416fn config_keys_rec(
417 prefix: ConfigNamePathBuf,
418 properties: &serde_json::Map<String, serde_json::Value>,
419 acc: &mut Vec<CompletionCandidate>,
420 only_leaves: bool,
421 suffix: &str,
422) {
423 for (key, value) in properties {
424 let mut prefix = prefix.clone();
425 prefix.push(key);
426
427 let value = value.as_object().unwrap();
428 match value.get("type").and_then(|v| v.as_str()) {
429 Some("object") => {
430 if !only_leaves {
431 let help = value
432 .get("description")
433 .map(|desc| desc.as_str().unwrap().to_string().into());
434 let escaped_key = prefix.to_string();
435 acc.push(CompletionCandidate::new(escaped_key).help(help));
436 }
437 let Some(properties) = value.get("properties") else {
438 continue;
439 };
440 let properties = properties.as_object().unwrap();
441 config_keys_rec(prefix, properties, acc, only_leaves, suffix);
442 }
443 _ => {
444 let help = value
445 .get("description")
446 .map(|desc| desc.as_str().unwrap().to_string().into());
447 let escaped_key = format!("{prefix}{suffix}");
448 acc.push(CompletionCandidate::new(escaped_key).help(help));
449 }
450 }
451 }
452}
453
454fn json_keypath<'a>(
455 schema: &'a serde_json::Value,
456 keypath: &str,
457 separator: &str,
458) -> Option<&'a serde_json::Value> {
459 keypath
460 .split(separator)
461 .try_fold(schema, |value, step| value.get(step))
462}
463fn jsonschema_keypath<'a>(
464 schema: &'a serde_json::Value,
465 keypath: &ConfigNamePathBuf,
466) -> Option<&'a serde_json::Value> {
467 keypath.components().try_fold(schema, |value, step| {
468 let value = value.as_object()?;
469 if value.get("type")?.as_str()? != "object" {
470 return None;
471 }
472 let properties = value.get("properties")?.as_object()?;
473 properties.get(step.get())
474 })
475}
476
477fn config_values(path: &ConfigNamePathBuf) -> Option<Vec<String>> {
478 let schema: serde_json::Value = serde_json::from_str(CONFIG_SCHEMA).unwrap();
479
480 let mut config_entry = jsonschema_keypath(&schema, path)?;
481 if let Some(reference) = config_entry.get("$ref") {
482 let reference = reference.as_str()?.strip_prefix("#/")?;
483 config_entry = json_keypath(&schema, reference, "/")?;
484 };
485
486 if let Some(possibile_values) = config_entry.get("enum") {
487 return Some(
488 possibile_values
489 .as_array()?
490 .iter()
491 .filter_map(|val| val.as_str())
492 .map(ToOwned::to_owned)
493 .collect(),
494 );
495 }
496
497 Some(match config_entry.get("type")?.as_str()? {
498 "boolean" => vec!["false".into(), "true".into()],
499 _ => vec![],
500 })
501}
502
503fn config_keys_impl(only_leaves: bool, suffix: &str) -> Vec<CompletionCandidate> {
504 let schema: serde_json::Value = serde_json::from_str(CONFIG_SCHEMA).unwrap();
505 let schema = schema.as_object().unwrap();
506 let properties = schema["properties"].as_object().unwrap();
507
508 let mut candidates = Vec::new();
509 config_keys_rec(
510 ConfigNamePathBuf::root(),
511 properties,
512 &mut candidates,
513 only_leaves,
514 suffix,
515 );
516 candidates
517}
518
519pub fn config_keys() -> Vec<CompletionCandidate> {
520 config_keys_impl(false, "")
521}
522
523pub fn leaf_config_keys() -> Vec<CompletionCandidate> {
524 config_keys_impl(true, "")
525}
526
527pub fn leaf_config_key_value(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
528 let Some(current) = current.to_str() else {
529 return Vec::new();
530 };
531
532 if let Some((key, current_val)) = current.split_once('=') {
533 let Ok(key) = key.parse() else {
534 return Vec::new();
535 };
536 let possible_values = config_values(&key).unwrap_or_default();
537
538 possible_values
539 .into_iter()
540 .filter(|x| x.starts_with(current_val))
541 .map(|x| CompletionCandidate::new(format!("{key}={x}")))
542 .collect()
543 } else {
544 config_keys_impl(true, "=")
545 .into_iter()
546 .filter(|candidate| candidate.get_value().to_str().unwrap().starts_with(current))
547 .collect()
548 }
549}
550
551pub fn branch_name_equals_any_revision(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
552 let Some(current) = current.to_str() else {
553 return Vec::new();
554 };
555
556 let Some((branch_name, revision)) = current.split_once('=') else {
557 // Don't complete branch names since we want to create a new branch
558 return Vec::new();
559 };
560 all_revisions()
561 .into_iter()
562 .filter(|rev| {
563 rev.get_value()
564 .to_str()
565 .is_some_and(|s| s.starts_with(revision))
566 })
567 .map(|rev| rev.add_prefix(format!("{branch_name}=")))
568 .collect()
569}
570
571fn dir_prefix_from<'a>(path: &'a str, current: &str) -> Option<&'a str> {
572 path[current.len()..]
573 .split_once(std::path::MAIN_SEPARATOR)
574 .map(|(next, _)| path.split_at(current.len() + next.len() + 1).0)
575}
576
577fn current_prefix_to_fileset(current: &str) -> String {
578 let cur_esc = glob::Pattern::escape(current);
579 let dir_pat = format!("{cur_esc}*/**");
580 let path_pat = format!("{cur_esc}*");
581 format!("glob:{dir_pat:?} | glob:{path_pat:?}")
582}
583
584fn all_files_from_rev(rev: String, current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
585 let Some(current) = current.to_str() else {
586 return Vec::new();
587 };
588 with_jj(|jj, _| {
589 let mut child = jj
590 .build()
591 .arg("file")
592 .arg("list")
593 .arg("--revision")
594 .arg(rev)
595 .arg(current_prefix_to_fileset(current))
596 .stdout(std::process::Stdio::piped())
597 .stderr(std::process::Stdio::null())
598 .spawn()
599 .map_err(user_error)?;
600 let stdout = child.stdout.take().unwrap();
601
602 Ok(std::io::BufReader::new(stdout)
603 .lines()
604 .take(1_000)
605 .map_while(Result::ok)
606 .map(|path| {
607 if let Some(dir_path) = dir_prefix_from(&path, current) {
608 return CompletionCandidate::new(dir_path);
609 }
610 CompletionCandidate::new(path)
611 })
612 .dedup() // directories may occur multiple times
613 .collect())
614 })
615}
616
617fn modified_files_from_rev_with_jj_cmd(
618 rev: (String, Option<String>),
619 mut cmd: std::process::Command,
620 current: &std::ffi::OsStr,
621) -> Result<Vec<CompletionCandidate>, CommandError> {
622 let Some(current) = current.to_str() else {
623 return Ok(Vec::new());
624 };
625 cmd.arg("diff")
626 .arg("--summary")
627 .arg(current_prefix_to_fileset(current));
628 match rev {
629 (rev, None) => cmd.arg("--revisions").arg(rev),
630 (from, Some(to)) => cmd.arg("--from").arg(from).arg("--to").arg(to),
631 };
632 let output = cmd.output().map_err(user_error)?;
633 let stdout = String::from_utf8_lossy(&output.stdout);
634
635 let mut candidates = Vec::new();
636 // store renamed paths in a separate vector so we don't have to sort later
637 let mut renamed = Vec::new();
638
639 'line_loop: for line in stdout.lines() {
640 let (mode, path) = line
641 .split_once(' ')
642 .expect("diff --summary should contain a space between mode and path");
643
644 fn path_to_candidate(current: &str, mode: &str, p: impl AsRef<str>) -> CompletionCandidate {
645 let p = p.as_ref();
646 if let Some(dir_path) = dir_prefix_from(p, current) {
647 return CompletionCandidate::new(dir_path);
648 }
649
650 let help = match mode {
651 "M" => "Modified".into(),
652 "D" => "Deleted".into(),
653 "A" => "Added".into(),
654 "R" => "Renamed".into(),
655 "C" => "Copied".into(),
656 _ => format!("unknown mode: '{mode}'"),
657 };
658 CompletionCandidate::new(p).help(Some(help.into()))
659 }
660
661 // In case of a rename, one line of `diff --summary` results in
662 // two suggestions.
663 if mode == "R" {
664 'split_renamed_paths: {
665 let Some((prefix, rest)) = path.split_once('{') else {
666 break 'split_renamed_paths;
667 };
668 let Some((rename, suffix)) = rest.split_once('}') else {
669 break 'split_renamed_paths;
670 };
671 let Some((before, after)) = rename.split_once(" => ") else {
672 break 'split_renamed_paths;
673 };
674 let before = format!("{prefix}{before}{suffix}");
675 let after = format!("{prefix}{after}{suffix}");
676 candidates.push(path_to_candidate(current, mode, before));
677 renamed.push(path_to_candidate(current, mode, after));
678 continue 'line_loop;
679 };
680 }
681
682 candidates.push(path_to_candidate(current, mode, path));
683 }
684 candidates.extend(renamed);
685 candidates.dedup();
686
687 Ok(candidates)
688}
689
690fn modified_files_from_rev(
691 rev: (String, Option<String>),
692 current: &std::ffi::OsStr,
693) -> Vec<CompletionCandidate> {
694 with_jj(|jj, _| modified_files_from_rev_with_jj_cmd(rev, jj.build(), current))
695}
696
697fn conflicted_files_from_rev(rev: &str, current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
698 let Some(current) = current.to_str() else {
699 return Vec::new();
700 };
701 with_jj(|jj, _| {
702 let output = jj
703 .build()
704 .arg("resolve")
705 .arg("--list")
706 .arg("--revision")
707 .arg(rev)
708 .arg(current_prefix_to_fileset(current))
709 .output()
710 .map_err(user_error)?;
711 let stdout = String::from_utf8_lossy(&output.stdout);
712
713 Ok(stdout
714 .lines()
715 .map(|line| {
716 let path = line
717 .split_whitespace()
718 .next()
719 .expect("resolve --list should contain whitespace after path");
720
721 if let Some(dir_path) = dir_prefix_from(path, current) {
722 return CompletionCandidate::new(dir_path);
723 }
724 CompletionCandidate::new(path)
725 })
726 .dedup() // directories may occur multiple times
727 .collect())
728 })
729}
730
731pub fn modified_files(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
732 modified_files_from_rev(("@".into(), None), current)
733}
734
735pub fn all_revision_files(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
736 all_files_from_rev(parse::revision_or_wc(), current)
737}
738
739pub fn modified_revision_files(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
740 modified_files_from_rev((parse::revision_or_wc(), None), current)
741}
742
743pub fn modified_range_files(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
744 match parse::range() {
745 Some((from, to)) => modified_files_from_rev((from, Some(to)), current),
746 None => modified_files_from_rev(("@".into(), None), current),
747 }
748}
749
750pub fn modified_revision_or_range_files(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
751 if let Some(rev) = parse::revision() {
752 return modified_files_from_rev((rev, None), current);
753 }
754 modified_range_files(current)
755}
756
757pub fn revision_conflicted_files(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
758 conflicted_files_from_rev(&parse::revision_or_wc(), current)
759}
760
761/// Specific function for completing file paths for `jj squash`
762pub fn squash_revision_files(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
763 let rev = parse::squash_revision().unwrap_or_else(|| "@".into());
764 modified_files_from_rev((rev, None), current)
765}
766
767/// Specific function for completing file paths for `jj interdiff`
768pub fn interdiff_files(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
769 let Some((from, to)) = parse::range() else {
770 return Vec::new();
771 };
772 // Complete all modified files in "from" and "to". This will also suggest
773 // files that are the same in both, which is a false positive. This approach
774 // is more lightweight than actually doing a temporary rebase here.
775 with_jj(|jj, _| {
776 let mut res = modified_files_from_rev_with_jj_cmd((from, None), jj.build(), current)?;
777 res.extend(modified_files_from_rev_with_jj_cmd(
778 (to, None),
779 jj.build(),
780 current,
781 )?);
782 Ok(res)
783 })
784}
785
786/// Specific function for completing file paths for `jj log`
787pub fn log_files(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
788 let mut rev = parse::log_revisions().join(")|(");
789 if rev.is_empty() {
790 rev = "@".into();
791 } else {
792 rev = format!("latest(heads(({rev})))"); // limit to one
793 };
794 all_files_from_rev(rev, current)
795}
796
797/// Shell out to jj during dynamic completion generation
798///
799/// In case of errors, print them and early return an empty vector.
800fn with_jj<F>(completion_fn: F) -> Vec<CompletionCandidate>
801where
802 F: FnOnce(JjBuilder, &UserSettings) -> Result<Vec<CompletionCandidate>, CommandError>,
803{
804 get_jj_command()
805 .and_then(|(jj, settings)| completion_fn(jj, &settings))
806 .unwrap_or_else(|e| {
807 eprintln!("{}", e.error);
808 Vec::new()
809 })
810}
811
812/// Shell out to jj during dynamic completion generation
813///
814/// This is necessary because dynamic completion code needs to be aware of
815/// global configuration like custom storage backends. Dynamic completion
816/// code via clap_complete doesn't accept arguments, so they cannot be passed
817/// that way. Another solution would've been to use global mutable state, to
818/// give completion code access to custom backends. Shelling out was chosen as
819/// the preferred method, because it's more maintainable and the performance
820/// requirements of completions aren't very high.
821fn get_jj_command() -> Result<(JjBuilder, UserSettings), CommandError> {
822 let current_exe = std::env::current_exe().map_err(user_error)?;
823 let mut cmd_args = Vec::<String>::new();
824
825 // Snapshotting could make completions much slower in some situations
826 // and be undesired by the user.
827 cmd_args.push("--ignore-working-copy".into());
828 cmd_args.push("--color=never".into());
829 cmd_args.push("--no-pager".into());
830
831 // Parse some of the global args we care about for passing along to the
832 // child process. This shouldn't fail, since none of the global args are
833 // required.
834 let app = crate::commands::default_app();
835 let mut raw_config = config_from_environment(default_config_layers());
836 let ui = Ui::null();
837 let cwd = std::env::current_dir()
838 .and_then(dunce::canonicalize)
839 .map_err(user_error)?;
840 // No config migration for completion. Simply ignore deprecated variables.
841 let mut config_env = ConfigEnv::from_environment();
842 let maybe_cwd_workspace_loader = DefaultWorkspaceLoaderFactory.create(find_workspace_dir(&cwd));
843 let _ = config_env.reload_user_config(&mut raw_config);
844 if let Ok(loader) = &maybe_cwd_workspace_loader {
845 config_env.reset_repo_path(loader.repo_path());
846 let _ = config_env.reload_repo_config(&mut raw_config);
847 }
848 let mut config = config_env.resolve_config(&raw_config)?;
849 // skip 2 because of the clap_complete prelude: jj -- jj <actual args...>
850 let args = std::env::args_os().skip(2);
851 let args = expand_args(&ui, &app, args, &config)?;
852 let arg_matches = app
853 .clone()
854 .disable_version_flag(true)
855 .disable_help_flag(true)
856 .ignore_errors(true)
857 .try_get_matches_from(args)?;
858 let args: GlobalArgs = GlobalArgs::from_arg_matches(&arg_matches)?;
859
860 if let Some(repository) = args.repository {
861 // Try to update repo-specific config on a best-effort basis.
862 if let Ok(loader) = DefaultWorkspaceLoaderFactory.create(&cwd.join(&repository)) {
863 config_env.reset_repo_path(loader.repo_path());
864 let _ = config_env.reload_repo_config(&mut raw_config);
865 if let Ok(new_config) = config_env.resolve_config(&raw_config) {
866 config = new_config;
867 }
868 }
869 cmd_args.push("--repository".into());
870 cmd_args.push(repository);
871 }
872 if let Some(at_operation) = args.at_operation {
873 // We cannot assume that the value of at_operation is valid, because
874 // the user may be requesting completions precisely for this invalid
875 // operation ID. Additionally, the user may have mistyped the ID,
876 // in which case adding the argument blindly would break all other
877 // completions, even unrelated ones.
878 //
879 // To avoid this, we shell out to ourselves once with the argument
880 // and check the exit code. There is some performance overhead to this,
881 // but this code path is probably only executed in exceptional
882 // situations.
883 let mut canary_cmd = std::process::Command::new(¤t_exe);
884 canary_cmd.args(&cmd_args);
885 canary_cmd.arg("--at-operation");
886 canary_cmd.arg(&at_operation);
887 canary_cmd.arg("debug");
888 canary_cmd.arg("snapshot");
889
890 match canary_cmd.output() {
891 Ok(output) if output.status.success() => {
892 // Operation ID is valid, add it to the completion command.
893 cmd_args.push("--at-operation".into());
894 cmd_args.push(at_operation);
895 }
896 _ => {} // Invalid operation ID, ignore.
897 }
898 }
899 for (kind, value) in args.early_args.merged_config_args(&arg_matches) {
900 let arg = match kind {
901 ConfigArgKind::Item => format!("--config={value}"),
902 ConfigArgKind::Toml => format!("--config-toml={value}"),
903 ConfigArgKind::File => format!("--config-file={value}"),
904 };
905 cmd_args.push(arg);
906 }
907
908 let builder = JjBuilder {
909 cmd: current_exe,
910 args: cmd_args,
911 };
912 let settings = UserSettings::from_config(config)?;
913
914 Ok((builder, settings))
915}
916
917/// A helper struct to allow completion functions to call jj multiple times with
918/// different arguments.
919struct JjBuilder {
920 cmd: std::path::PathBuf,
921 args: Vec<String>,
922}
923
924impl JjBuilder {
925 fn build(&self) -> std::process::Command {
926 let mut cmd = std::process::Command::new(&self.cmd);
927 cmd.args(&self.args);
928 cmd
929 }
930}
931
932/// Functions for parsing revisions and revision ranges from the command line.
933/// Parsing is done on a best-effort basis and relies on the heuristic that
934/// most command line flags are consistent across different subcommands.
935///
936/// In some cases, this parsing will be incorrect, but it's not worth the effort
937/// to fix that. For example, if the user specifies any of the relevant flags
938/// multiple times, the parsing will pick any of the available ones, while the
939/// actual execution of the command would fail.
940mod parse {
941 pub(super) fn parse_flag<'a, I: Iterator<Item = String>>(
942 candidates: &'a [&str],
943 mut args: I,
944 ) -> impl Iterator<Item = String> + use<'a, I> {
945 std::iter::from_fn(move || {
946 for arg in args.by_ref() {
947 // -r REV syntax
948 if candidates.contains(&arg.as_ref()) {
949 match args.next() {
950 Some(val) if !val.starts_with('-') => return Some(val),
951 _ => return None,
952 }
953 }
954
955 // -r=REV syntax
956 if let Some(value) = candidates.iter().find_map(|candidate| {
957 let rest = arg.strip_prefix(candidate)?;
958 match rest.strip_prefix('=') {
959 Some(value) => Some(value),
960
961 // -rREV syntax
962 None if candidate.len() == 2 => Some(rest),
963
964 None => None,
965 }
966 }) {
967 return Some(value.into());
968 };
969 }
970 None
971 })
972 }
973
974 pub fn parse_revision_impl(args: impl Iterator<Item = String>) -> Option<String> {
975 parse_flag(&["-r", "--revision"], args).next()
976 }
977
978 pub fn revision() -> Option<String> {
979 parse_revision_impl(std::env::args())
980 }
981
982 pub fn revision_or_wc() -> String {
983 revision().unwrap_or_else(|| "@".into())
984 }
985
986 pub fn parse_range_impl<T>(args: impl Fn() -> T) -> Option<(String, String)>
987 where
988 T: Iterator<Item = String>,
989 {
990 let from = parse_flag(&["-f", "--from"], args()).next()?;
991 let to = parse_flag(&["-t", "--to"], args())
992 .next()
993 .unwrap_or_else(|| "@".into());
994
995 Some((from, to))
996 }
997
998 pub fn range() -> Option<(String, String)> {
999 parse_range_impl(std::env::args)
1000 }
1001
1002 // Special parse function only for `jj squash`. While squash has --from and
1003 // --to arguments, only files within --from should be completed, because
1004 // the files changed only in some other revision in the range between
1005 // --from and --to cannot be squashed into --to like that.
1006 pub fn squash_revision() -> Option<String> {
1007 if let Some(rev) = parse_flag(&["-r", "--revision"], std::env::args()).next() {
1008 return Some(rev);
1009 }
1010 parse_flag(&["-f", "--from"], std::env::args()).next()
1011 }
1012
1013 // Special parse function only for `jj log`. It has a --revisions flag,
1014 // instead of the usual --revision, and it can be supplied multiple times.
1015 pub fn log_revisions() -> Vec<String> {
1016 let candidates = &["-r", "--revisions"];
1017 parse_flag(candidates, std::env::args()).collect()
1018 }
1019}
1020
1021#[cfg(test)]
1022mod tests {
1023 use super::*;
1024
1025 #[test]
1026 fn test_config_keys() {
1027 // Just make sure the schema is parsed without failure.
1028 let _ = config_keys();
1029 }
1030
1031 #[test]
1032 fn test_parse_revision_impl() {
1033 let good_cases: &[&[&str]] = &[
1034 &["-r", "foo"],
1035 &["--revision", "foo"],
1036 &["-r=foo"],
1037 &["--revision=foo"],
1038 &["preceding_arg", "-r", "foo"],
1039 &["-r", "foo", "following_arg"],
1040 ];
1041 for case in good_cases {
1042 let args = case.iter().map(|s| s.to_string());
1043 assert_eq!(
1044 parse::parse_revision_impl(args),
1045 Some("foo".into()),
1046 "case: {case:?}",
1047 );
1048 }
1049 let bad_cases: &[&[&str]] = &[&[], &["-r"], &["foo"], &["-R", "foo"], &["-R=foo"]];
1050 for case in bad_cases {
1051 let args = case.iter().map(|s| s.to_string());
1052 assert_eq!(parse::parse_revision_impl(args), None, "case: {case:?}");
1053 }
1054 }
1055
1056 #[test]
1057 fn test_parse_range_impl() {
1058 let wc_cases: &[&[&str]] = &[
1059 &["-f", "foo"],
1060 &["--from", "foo"],
1061 &["-f=foo"],
1062 &["preceding_arg", "-f", "foo"],
1063 &["-f", "foo", "following_arg"],
1064 ];
1065 for case in wc_cases {
1066 let args = case.iter().map(|s| s.to_string());
1067 assert_eq!(
1068 parse::parse_range_impl(|| args.clone()),
1069 Some(("foo".into(), "@".into())),
1070 "case: {case:?}",
1071 );
1072 }
1073 let to_cases: &[&[&str]] = &[
1074 &["-f", "foo", "-t", "bar"],
1075 &["-f", "foo", "--to", "bar"],
1076 &["-f=foo", "-t=bar"],
1077 &["-t=bar", "-f=foo"],
1078 ];
1079 for case in to_cases {
1080 let args = case.iter().map(|s| s.to_string());
1081 assert_eq!(
1082 parse::parse_range_impl(|| args.clone()),
1083 Some(("foo".into(), "bar".into())),
1084 "case: {case:?}",
1085 );
1086 }
1087 let bad_cases: &[&[&str]] = &[&[], &["-f"], &["foo"], &["-R", "foo"], &["-R=foo"]];
1088 for case in bad_cases {
1089 let args = case.iter().map(|s| s.to_string());
1090 assert_eq!(
1091 parse::parse_range_impl(|| args.clone()),
1092 None,
1093 "case: {case:?}"
1094 );
1095 }
1096 }
1097
1098 #[test]
1099 fn test_parse_multiple_flags() {
1100 let candidates = &["-r", "--revisions"];
1101 let args = &[
1102 "unrelated_arg_at_the_beginning",
1103 "-r",
1104 "1",
1105 "--revisions",
1106 "2",
1107 "-r=3",
1108 "--revisions=4",
1109 "unrelated_arg_in_the_middle",
1110 "-r5",
1111 "unrelated_arg_at_the_end",
1112 ];
1113 let flags: Vec<_> =
1114 parse::parse_flag(candidates, args.iter().map(|a| a.to_string())).collect();
1115 let expected = ["1", "2", "3", "4", "5"];
1116 assert_eq!(flags, expected);
1117 }
1118}