jj_cli/
complete.rs

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