Skip to main content

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" " ++
50coalesce(
51    if(!present, "(deleted bookmark)"),
52    if(!normal_target, "(conflicted bookmark)"),
53    if(!normal_target.description(), "(no description set)"),
54    normal_target.description().first_line(),
55)
56'''"#;
57const TAG_HELP_TEMPLATE: &str = r#"template-aliases.'tag_help()'='''
58" " ++
59coalesce(
60    if(!present, "(deleted tag)"),
61    if(!normal_target, "(conflicted tag)"),
62    if(!normal_target.description(), "(no description set)"),
63    normal_target.description().first_line(),
64)
65'''"#;
66
67/// A helper function for various completer functions. It returns
68/// (candidate, help) assuming they are separated by a space.
69fn split_help_text(line: &str) -> (&str, Option<StyledStr>) {
70    match line.split_once(' ') {
71        Some((name, help)) => (name, Some(help.to_string().into())),
72        None => (line, None),
73    }
74}
75
76pub fn local_bookmarks() -> Vec<CompletionCandidate> {
77    with_jj(|jj, _| {
78        let output = jj
79            .build()
80            .arg("bookmark")
81            .arg("list")
82            .arg("--config")
83            .arg(BOOKMARK_HELP_TEMPLATE)
84            .arg("--template")
85            .arg(r#"if(!remote, name ++ bookmark_help()) ++ "\n""#)
86            .output()
87            .map_err(user_error)?;
88
89        Ok(String::from_utf8_lossy(&output.stdout)
90            .lines()
91            .map(split_help_text)
92            .map(|(name, help)| CompletionCandidate::new(name).help(help))
93            .collect())
94    })
95}
96
97pub fn tracked_bookmarks() -> Vec<CompletionCandidate> {
98    with_jj(|jj, _| {
99        let output = jj
100            .build()
101            .arg("bookmark")
102            .arg("list")
103            .arg("--tracked")
104            .arg("--config")
105            .arg(BOOKMARK_HELP_TEMPLATE)
106            .arg("--template")
107            .arg(r#"if(remote, name ++ '@' ++ remote ++ bookmark_help() ++ "\n")"#)
108            .output()
109            .map_err(user_error)?;
110
111        Ok(String::from_utf8_lossy(&output.stdout)
112            .lines()
113            .map(split_help_text)
114            .filter_map(|(symbol, help)| Some((symbol.split_once('@')?, help)))
115            // There may be multiple remote bookmarks to untrack. Just pick the
116            // first one for help text.
117            .dedup_by(|((name1, _), _), ((name2, _), _)| name1 == name2)
118            .map(|((name, _remote), help)| CompletionCandidate::new(name).help(help))
119            .collect())
120    })
121}
122
123pub fn untracked_bookmarks() -> Vec<CompletionCandidate> {
124    with_jj(|jj, _settings| {
125        let remotes = jj
126            .build()
127            .arg("git")
128            .arg("remote")
129            .arg("list")
130            .output()
131            .map_err(user_error)?;
132        let remotes = String::from_utf8_lossy(&remotes.stdout);
133        let remotes = remotes
134            .lines()
135            .filter_map(|l| l.split_whitespace().next())
136            .collect_vec();
137
138        let bookmark_table = jj
139            .build()
140            .arg("bookmark")
141            .arg("list")
142            .arg("--all-remotes")
143            .arg("--config")
144            .arg(BOOKMARK_HELP_TEMPLATE)
145            .arg("--template")
146            .arg(
147                r#"
148                if(remote != "git",
149                    if(!remote, name) ++ "\t" ++
150                    if(remote, name ++ "@" ++ remote) ++ "\t" ++
151                    if(tracked, "tracked") ++ "\t" ++
152                    bookmark_help() ++ "\n"
153                )"#,
154            )
155            .output()
156            .map_err(user_error)?;
157        let bookmark_table = String::from_utf8_lossy(&bookmark_table.stdout);
158
159        let mut possible_bookmarks_to_track = Vec::new();
160        let mut already_tracked_bookmarks = HashSet::new();
161
162        for line in bookmark_table.lines() {
163            let [local, remote, tracked, help] =
164                line.split('\t').collect_array().unwrap_or_default();
165
166            if !local.is_empty() {
167                possible_bookmarks_to_track.extend(
168                    remotes
169                        .iter()
170                        .map(|remote| (format!("{local}@{remote}"), help)),
171                );
172            } else if tracked.is_empty() {
173                possible_bookmarks_to_track.push((remote.to_owned(), help));
174            } else {
175                already_tracked_bookmarks.insert(remote);
176            }
177        }
178        possible_bookmarks_to_track
179            .retain(|(bookmark, _help)| !already_tracked_bookmarks.contains(&bookmark.as_str()));
180
181        Ok(possible_bookmarks_to_track
182            .iter()
183            .filter_map(|(symbol, help)| Some((symbol.split_once('@')?, help)))
184            // There may be multiple remote bookmarks to track. Just pick the
185            // first one for help text.
186            .dedup_by(|((name1, _), _), ((name2, _), _)| name1 == name2)
187            .map(|((name, _remote), help)| {
188                CompletionCandidate::new(name).help(Some(help.to_string().into()))
189            })
190            .collect())
191    })
192}
193
194pub fn bookmarks() -> Vec<CompletionCandidate> {
195    with_jj(|jj, _settings| {
196        let output = jj
197            .build()
198            .arg("bookmark")
199            .arg("list")
200            .arg("--all-remotes")
201            .arg("--config")
202            .arg(BOOKMARK_HELP_TEMPLATE)
203            .arg("--template")
204            .arg(
205                // only provide help for local refs, remote could be ambiguous
206                r#"name ++ if(remote, "@" ++ remote, bookmark_help()) ++ "\n""#,
207            )
208            .output()
209            .map_err(user_error)?;
210        let stdout = String::from_utf8_lossy(&output.stdout);
211
212        Ok((&stdout
213            .lines()
214            .map(split_help_text)
215            .chunk_by(|(name, _)| name.split_once('@').map(|t| t.0).unwrap_or(name)))
216            .into_iter()
217            .map(|(bookmark, mut refs)| {
218                let help = refs.find_map(|(_, help)| help);
219                let local = help.is_some();
220                let display_order = match local {
221                    true => 0,
222                    false => 1,
223                };
224                CompletionCandidate::new(bookmark)
225                    .help(help)
226                    .display_order(Some(display_order))
227            })
228            .collect())
229    })
230}
231
232pub fn local_tags() -> Vec<CompletionCandidate> {
233    with_jj(|jj, _| {
234        let output = jj
235            .build()
236            .arg("tag")
237            .arg("list")
238            .arg("--config")
239            .arg(TAG_HELP_TEMPLATE)
240            .arg("--template")
241            .arg(r#"if(!remote, name ++ tag_help()) ++ "\n""#)
242            .output()
243            .map_err(user_error)?;
244
245        Ok(String::from_utf8_lossy(&output.stdout)
246            .lines()
247            .map(split_help_text)
248            .map(|(name, help)| CompletionCandidate::new(name).help(help))
249            .collect())
250    })
251}
252
253pub fn git_remotes() -> Vec<CompletionCandidate> {
254    with_jj(|jj, _| {
255        let output = jj
256            .build()
257            .arg("git")
258            .arg("remote")
259            .arg("list")
260            .output()
261            .map_err(user_error)?;
262
263        let stdout = String::from_utf8_lossy(&output.stdout);
264
265        Ok(stdout
266            .lines()
267            .filter_map(|line| line.split_once(' ').map(|(name, _url)| name))
268            .map(CompletionCandidate::new)
269            .collect())
270    })
271}
272
273pub fn template_aliases() -> Vec<CompletionCandidate> {
274    with_jj(|_, settings| {
275        let Ok(template_aliases) = load_template_aliases(&Ui::null(), settings.config()) else {
276            return Ok(Vec::new());
277        };
278        Ok(template_aliases
279            .symbol_names()
280            .map(CompletionCandidate::new)
281            .sorted()
282            .collect())
283    })
284}
285
286pub fn aliases() -> Vec<CompletionCandidate> {
287    with_jj(|_, settings| {
288        Ok(settings
289            .table_keys("aliases")
290            // This is opinionated, but many people probably have several
291            // single- or two-letter aliases they use all the time. These
292            // aliases don't need to be completed and they would only clutter
293            // the output of `jj <TAB>`.
294            .filter(|alias| alias.len() > 2)
295            .map(CompletionCandidate::new)
296            .collect())
297    })
298}
299
300fn revisions(match_prefix: &str, revset_filter: Option<&str>) -> Vec<CompletionCandidate> {
301    with_jj(|jj, settings| {
302        // display order
303        const LOCAL_BOOKMARK: usize = 0;
304        const TAG: usize = 1;
305        const CHANGE_ID: usize = 2;
306        const REMOTE_BOOKMARK: usize = 3;
307        const REVSET_ALIAS: usize = 4;
308
309        let mut candidates = Vec::new();
310
311        // bookmarks
312
313        let mut cmd = jj.build();
314        cmd.arg("bookmark")
315            .arg("list")
316            .arg("--all-remotes")
317            .arg("--config")
318            .arg(BOOKMARK_HELP_TEMPLATE)
319            .arg("--template")
320            .arg(
321                r#"if(remote != "git", name ++ if(remote, "@" ++ remote) ++ bookmark_help() ++ "\n")"#,
322            );
323        if let Some(revs) = revset_filter {
324            cmd.arg("--revisions").arg(revs);
325        }
326        let output = cmd.output().map_err(user_error)?;
327        let stdout = String::from_utf8_lossy(&output.stdout);
328
329        candidates.extend(
330            stdout
331                .lines()
332                .map(split_help_text)
333                .filter(|(bookmark, _)| bookmark.starts_with(match_prefix))
334                .map(|(bookmark, help)| {
335                    let local = !bookmark.contains('@');
336                    let display_order = match local {
337                        true => LOCAL_BOOKMARK,
338                        false => REMOTE_BOOKMARK,
339                    };
340                    CompletionCandidate::new(bookmark)
341                        .help(help)
342                        .display_order(Some(display_order))
343                }),
344        );
345
346        // tags
347
348        // Tags cannot be filtered by revisions. In order to avoid suggesting
349        // immutable tags for mutable revision args, we skip tags entirely if
350        // revset_filter is set. This is not a big loss, since tags usually point
351        // to immutable revisions anyway.
352        if revset_filter.is_none() {
353            let output = jj
354                .build()
355                .arg("tag")
356                .arg("list")
357                .arg("--config")
358                .arg(BOOKMARK_HELP_TEMPLATE)
359                .arg("--template")
360                .arg(r#"name ++ bookmark_help() ++ "\n""#)
361                .arg(format!("glob:{}*", globset::escape(match_prefix)))
362                .output()
363                .map_err(user_error)?;
364            let stdout = String::from_utf8_lossy(&output.stdout);
365
366            candidates.extend(stdout.lines().map(|line| {
367                let (name, desc) = split_help_text(line);
368                CompletionCandidate::new(name)
369                    .help(desc)
370                    .display_order(Some(TAG))
371            }));
372        }
373
374        // change IDs
375
376        let revisions = revset_filter
377            .map(String::from)
378            .or_else(|| settings.get_string("revsets.short-prefixes").ok())
379            .or_else(|| settings.get_string("revsets.log").ok())
380            .unwrap_or_default();
381
382        let output = jj
383            .build()
384            .arg("log")
385            .arg("--no-graph")
386            .arg("--limit")
387            .arg("100")
388            .arg("--revisions")
389            .arg(revisions)
390            .arg("--template")
391            .arg(
392                r#"
393                join(" ",
394                    separate("/",
395                        change_id.shortest(),
396                        if(hidden || divergent, change_offset),
397                    ),
398                    if(description, description.first_line(), "(no description set)"),
399                ) ++ "\n""#,
400            )
401            .output()
402            .map_err(user_error)?;
403        let stdout = String::from_utf8_lossy(&output.stdout);
404
405        candidates.extend(
406            stdout
407                .lines()
408                .map(split_help_text)
409                .filter(|(id, _)| id.starts_with(match_prefix))
410                .map(|(id, desc)| {
411                    CompletionCandidate::new(id)
412                        .help(desc)
413                        .display_order(Some(CHANGE_ID))
414                }),
415        );
416
417        // revset aliases
418
419        let revset_aliases = load_revset_aliases(&Ui::null(), settings.config())?;
420        let symbol_names = revset_aliases
421            .symbol_names()
422            .sorted_unstable()
423            .collect_vec();
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 config_keys_to_unset() -> Vec<CompletionCandidate> {
759    let Ok(config_level_flag) = std::env::args()
760        .filter(|arg| matches!(arg.as_str(), "--user" | "--repo" | "--workspace"))
761        .at_most_one()
762    else {
763        return Vec::new();
764    };
765
766    with_jj(|jj, _| {
767        const TEMPLATE: &str = r#"name ++ "\t" ++ source ++ "\t" ++ stringify(value).replace(regex:'\n\s*', " ") ++ "\n""#;
768        let list_output = jj
769            .build()
770            .args(["config", "list"])
771            // Only suggest unsetting overridden config options if the corresponding level is
772            // already specified.
773            .args(
774                config_level_flag
775                    .is_some()
776                    .then_some("--include-overridden"),
777            )
778            .args(config_level_flag)
779            .args(["--template", TEMPLATE])
780            .output()
781            .map_err(user_error)?;
782        Ok(String::from_utf8_lossy(&list_output.stdout)
783            .lines()
784            .filter_map(|line| line.split('\t').collect_tuple())
785            .filter(|(_, source, _)| matches!(*source, "user" | "repo" | "workspace"))
786            .map(|(name, source, value)| {
787                CompletionCandidate::new(name)
788                    .tag(Some(source.to_string().into()))
789                    .help(Some(format!("{source}: {value}").into()))
790            })
791            .collect())
792    })
793}
794
795pub fn branch_name_equals_any_revision(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
796    let Some(current) = current.to_str() else {
797        return Vec::new();
798    };
799
800    let Some((branch_name, revision)) = current.split_once('=') else {
801        // Don't complete branch names since we want to create a new branch
802        return Vec::new();
803    };
804    revset_expression(revision.as_ref(), None)
805        .into_iter()
806        .map(|rev| rev.add_prefix(format!("{branch_name}=")))
807        .collect()
808}
809
810fn path_completion_candidate_from(
811    current_prefix: &str,
812    normalized_prefix_path: &Path,
813    path: &Path,
814    mode: Option<clap::builder::StyledStr>,
815) -> Option<CompletionCandidate> {
816    let normalized_prefix = match normalized_prefix_path.to_str()? {
817        "." => "", // `.` cannot be normalized further, but doesn't prefix `path`.
818        normalized_prefix => normalized_prefix,
819    };
820
821    let path = slash_path(path);
822    let mut remainder = path.to_str()?.strip_prefix(normalized_prefix)?;
823
824    // Trailing slash might have been normalized away in which case we need to strip
825    // the leading slash in the remainder away, or else the slash would appear
826    // twice.
827    if current_prefix.ends_with(std::path::is_separator) {
828        remainder = remainder.strip_prefix('/').unwrap_or(remainder);
829    }
830
831    match remainder.split_inclusive('/').at_most_one() {
832        // Completed component is the final component in `path`, so we're completing the file to
833        // which `mode` refers.
834        Ok(file_completion) => Some(
835            CompletionCandidate::new(format!(
836                "{current_prefix}{}",
837                file_completion.unwrap_or_default()
838            ))
839            .help(mode),
840        ),
841
842        // Omit `mode` when completing only up to the next directory.
843        Err(mut components) => Some(CompletionCandidate::new(format!(
844            "{current_prefix}{}",
845            components.next().unwrap()
846        ))),
847    }
848}
849
850fn current_prefix_to_fileset(current: &str) -> String {
851    let cur_esc = globset::escape(current);
852    let dir_pat = format!("{cur_esc}*/**");
853    let path_pat = format!("{cur_esc}*");
854    format!("glob:{dir_pat:?} | glob:{path_pat:?}")
855}
856
857fn all_files_from_rev(rev: String, current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
858    let Some(current) = current.to_str() else {
859        return Vec::new();
860    };
861
862    let normalized_prefix = normalize_path(Path::new(current));
863    let normalized_prefix = slash_path(&normalized_prefix);
864
865    with_jj(|jj, _| {
866        let mut child = jj
867            .build()
868            .arg("file")
869            .arg("list")
870            .arg("--revision")
871            .arg(rev)
872            .arg("--template")
873            .arg(r#"path.display() ++ "\n""#)
874            .arg(current_prefix_to_fileset(current))
875            .stdout(std::process::Stdio::piped())
876            .stderr(std::process::Stdio::null())
877            .spawn()
878            .map_err(user_error)?;
879        let stdout = child.stdout.take().unwrap();
880
881        Ok(std::io::BufReader::new(stdout)
882            .lines()
883            .take(1_000)
884            .map_while(Result::ok)
885            .filter_map(|path| {
886                path_completion_candidate_from(current, &normalized_prefix, Path::new(&path), None)
887            })
888            .dedup() // directories may occur multiple times
889            .collect())
890    })
891}
892
893fn modified_files_from_rev_with_jj_cmd(
894    rev: (String, Option<String>),
895    mut cmd: std::process::Command,
896    current: &std::ffi::OsStr,
897) -> Result<Vec<CompletionCandidate>, CommandError> {
898    let Some(current) = current.to_str() else {
899        return Ok(Vec::new());
900    };
901
902    let normalized_prefix = normalize_path(Path::new(current));
903    let normalized_prefix = slash_path(&normalized_prefix);
904
905    // In case of a rename, one entry of `diff` results in two suggestions.
906    let template = indoc! {r#"
907        concat(
908          status ++ ' ' ++ path.display() ++ "\n",
909          if(status == 'renamed', 'renamed.source ' ++ source.path().display() ++ "\n"),
910        )
911    "#};
912    cmd.arg("diff")
913        .args(["--template", template])
914        .arg(current_prefix_to_fileset(current));
915    match rev {
916        (rev, None) => cmd.arg("--revisions").arg(rev),
917        (from, Some(to)) => cmd.arg("--from").arg(from).arg("--to").arg(to),
918    };
919    let output = cmd.output().map_err(user_error)?;
920    let stdout = String::from_utf8_lossy(&output.stdout);
921
922    let mut include_renames = false;
923    let mut candidates: Vec<_> = stdout
924        .lines()
925        .filter_map(|line| line.split_once(' '))
926        .filter_map(|(mode, path)| {
927            let mode = match mode {
928                "modified" => "Modified".into(),
929                "removed" => "Deleted".into(),
930                "added" => "Added".into(),
931                "renamed" => "Renamed".into(),
932                "renamed.source" => {
933                    include_renames = true;
934                    "Renamed".into()
935                }
936                "copied" => "Copied".into(),
937                _ => format!("unknown mode: '{mode}'").into(),
938            };
939            path_completion_candidate_from(current, &normalized_prefix, Path::new(path), Some(mode))
940        })
941        .collect();
942
943    if include_renames {
944        candidates.sort_unstable_by(|a, b| Path::new(a.get_value()).cmp(Path::new(b.get_value())));
945    }
946    candidates.dedup();
947
948    Ok(candidates)
949}
950
951fn modified_files_from_rev(
952    rev: (String, Option<String>),
953    current: &std::ffi::OsStr,
954) -> Vec<CompletionCandidate> {
955    with_jj(|jj, _| modified_files_from_rev_with_jj_cmd(rev, jj.build(), current))
956}
957
958fn conflicted_files_from_rev(rev: &str, current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
959    let Some(current) = current.to_str() else {
960        return Vec::new();
961    };
962
963    let normalized_prefix = normalize_path(Path::new(current));
964    let normalized_prefix = slash_path(&normalized_prefix);
965
966    with_jj(|jj, _| {
967        let output = jj
968            .build()
969            .arg("resolve")
970            .arg("--list")
971            .arg("--revision")
972            .arg(rev)
973            .arg(current_prefix_to_fileset(current))
974            .output()
975            .map_err(user_error)?;
976        let stdout = String::from_utf8_lossy(&output.stdout);
977
978        Ok(stdout
979            .lines()
980            .filter_map(|line| {
981                let path = line
982                    .split_whitespace()
983                    .next()
984                    .expect("resolve --list should contain whitespace after path");
985
986                path_completion_candidate_from(current, &normalized_prefix, Path::new(path), None)
987            })
988            .dedup() // directories may occur multiple times
989            .collect())
990    })
991}
992
993pub fn modified_files(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
994    modified_files_from_rev(("@".into(), None), current)
995}
996
997pub fn all_revision_files(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
998    all_files_from_rev(parse::revision_or_wc(), current)
999}
1000
1001pub fn modified_revision_files(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
1002    modified_files_from_rev((parse::revision_or_wc(), None), current)
1003}
1004
1005pub fn modified_range_files(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
1006    match parse::range() {
1007        Some((from, to)) => modified_files_from_rev((from, Some(to)), current),
1008        None => modified_files_from_rev(("@".into(), None), current),
1009    }
1010}
1011
1012/// Completes files in `@` *or* the `--from` revision (not the diff between
1013/// `--from` and `@`)
1014pub fn modified_from_files(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
1015    modified_files_from_rev((parse::from_or_wc(), None), current)
1016}
1017
1018pub fn modified_revision_or_range_files(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
1019    if let Some(rev) = parse::revision() {
1020        return modified_files_from_rev((rev, None), current);
1021    }
1022    modified_range_files(current)
1023}
1024
1025pub fn modified_changes_in_or_range_files(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
1026    if let Some(rev) = parse::changes_in() {
1027        return modified_files_from_rev((rev, None), current);
1028    }
1029    modified_range_files(current)
1030}
1031
1032pub fn revision_conflicted_files(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
1033    conflicted_files_from_rev(&parse::revision_or_wc(), current)
1034}
1035
1036/// Specific function for completing file paths for `jj squash`
1037pub fn squash_revision_files(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
1038    let rev = parse::squash_revision().unwrap_or_else(|| "@".into());
1039    modified_files_from_rev((rev, None), current)
1040}
1041
1042/// Specific function for completing file paths for `jj interdiff`
1043pub fn interdiff_files(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
1044    let Some((from, to)) = parse::range() else {
1045        return Vec::new();
1046    };
1047    // Complete all modified files in "from" and "to". This will also suggest
1048    // files that are the same in both, which is a false positive. This approach
1049    // is more lightweight than actually doing a temporary rebase here.
1050    with_jj(|jj, _| {
1051        let mut res = modified_files_from_rev_with_jj_cmd((from, None), jj.build(), current)?;
1052        res.extend(modified_files_from_rev_with_jj_cmd(
1053            (to, None),
1054            jj.build(),
1055            current,
1056        )?);
1057        Ok(res)
1058    })
1059}
1060
1061/// Specific function for completing file paths for `jj log`
1062pub fn log_files(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
1063    let mut rev = parse::log_revisions().join(")|(");
1064    if rev.is_empty() {
1065        rev = "@".into();
1066    } else {
1067        rev = format!("latest(heads(({rev})))"); // limit to one
1068    }
1069    all_files_from_rev(rev, current)
1070}
1071
1072/// Shell out to jj during dynamic completion generation
1073///
1074/// In case of errors, print them and early return an empty vector.
1075fn with_jj<F>(completion_fn: F) -> Vec<CompletionCandidate>
1076where
1077    F: FnOnce(JjBuilder, &UserSettings) -> Result<Vec<CompletionCandidate>, CommandError>,
1078{
1079    get_jj_command()
1080        .and_then(|(jj, settings)| completion_fn(jj, &settings))
1081        .unwrap_or_else(|e| {
1082            eprintln!("{}", e.error);
1083            Vec::new()
1084        })
1085}
1086
1087/// Shell out to jj during dynamic completion generation
1088///
1089/// This is necessary because dynamic completion code needs to be aware of
1090/// global configuration like custom storage backends. Dynamic completion
1091/// code via clap_complete doesn't accept arguments, so they cannot be passed
1092/// that way. Another solution would've been to use global mutable state, to
1093/// give completion code access to custom backends. Shelling out was chosen as
1094/// the preferred method, because it's more maintainable and the performance
1095/// requirements of completions aren't very high.
1096fn get_jj_command() -> Result<(JjBuilder, UserSettings), CommandError> {
1097    let current_exe = std::env::current_exe().map_err(user_error)?;
1098    let mut cmd_args = Vec::<String>::new();
1099
1100    // Snapshotting could make completions much slower in some situations
1101    // and be undesired by the user.
1102    cmd_args.push("--ignore-working-copy".into());
1103    cmd_args.push("--color=never".into());
1104    cmd_args.push("--no-pager".into());
1105
1106    // Parse some of the global args we care about for passing along to the
1107    // child process. This shouldn't fail, since none of the global args are
1108    // required.
1109    let app = crate::commands::default_app();
1110    let mut raw_config = config_from_environment(default_config_layers());
1111    let ui = Ui::null();
1112    let cwd = std::env::current_dir()
1113        .and_then(dunce::canonicalize)
1114        .map_err(user_error)?;
1115    // No config migration for completion. Simply ignore deprecated variables.
1116    let mut config_env = ConfigEnv::from_environment();
1117    let maybe_cwd_workspace_loader = DefaultWorkspaceLoaderFactory.create(find_workspace_dir(&cwd));
1118    config_env.reload_user_config(&mut raw_config).ok();
1119    if let Ok(loader) = &maybe_cwd_workspace_loader {
1120        config_env.reset_repo_path(loader.repo_path());
1121        config_env.reload_repo_config(&ui, &mut raw_config).ok();
1122        config_env.reset_workspace_path(loader.workspace_root());
1123        config_env
1124            .reload_workspace_config(&ui, &mut raw_config)
1125            .ok();
1126    }
1127    let mut config = config_env.resolve_config(&raw_config)?;
1128    // skip 2 because of the clap_complete prelude: jj -- jj <actual args...>
1129    let args = std::env::args_os().skip(2);
1130    let args = expand_args(&ui, &app, args, &config)?;
1131    let arg_matches = app
1132        .clone()
1133        .disable_version_flag(true)
1134        .disable_help_flag(true)
1135        .ignore_errors(true)
1136        .try_get_matches_from(args)?;
1137    let args: GlobalArgs = GlobalArgs::from_arg_matches(&arg_matches)?;
1138
1139    if let Some(repository) = args.repository {
1140        // Try to update repo-specific config on a best-effort basis.
1141        if let Ok(loader) = DefaultWorkspaceLoaderFactory.create(&cwd.join(&repository)) {
1142            config_env.reset_repo_path(loader.repo_path());
1143            config_env.reload_repo_config(&ui, &mut raw_config).ok();
1144            config_env.reset_workspace_path(loader.workspace_root());
1145            config_env
1146                .reload_workspace_config(&ui, &mut raw_config)
1147                .ok();
1148            if let Ok(new_config) = config_env.resolve_config(&raw_config) {
1149                config = new_config;
1150            }
1151        }
1152        cmd_args.push("--repository".into());
1153        cmd_args.push(repository);
1154    }
1155    if let Some(at_operation) = args.at_operation {
1156        // We cannot assume that the value of at_operation is valid, because
1157        // the user may be requesting completions precisely for this invalid
1158        // operation ID. Additionally, the user may have mistyped the ID,
1159        // in which case adding the argument blindly would break all other
1160        // completions, even unrelated ones.
1161        //
1162        // To avoid this, we shell out to ourselves once with the argument
1163        // and check the exit code. There is some performance overhead to this,
1164        // but this code path is probably only executed in exceptional
1165        // situations.
1166        let mut canary_cmd = std::process::Command::new(&current_exe);
1167        canary_cmd.args(&cmd_args);
1168        canary_cmd.arg("--at-operation");
1169        canary_cmd.arg(&at_operation);
1170        canary_cmd.arg("debug");
1171        canary_cmd.arg("snapshot");
1172
1173        match canary_cmd.output() {
1174            Ok(output) if output.status.success() => {
1175                // Operation ID is valid, add it to the completion command.
1176                cmd_args.push("--at-operation".into());
1177                cmd_args.push(at_operation);
1178            }
1179            _ => {} // Invalid operation ID, ignore.
1180        }
1181    }
1182    for (kind, value) in args.early_args.merged_config_args(&arg_matches) {
1183        let arg = match kind {
1184            ConfigArgKind::Item => format!("--config={value}"),
1185            ConfigArgKind::File => format!("--config-file={value}"),
1186        };
1187        cmd_args.push(arg);
1188    }
1189
1190    let builder = JjBuilder {
1191        cmd: current_exe,
1192        args: cmd_args,
1193    };
1194    let settings = UserSettings::from_config(config)?;
1195
1196    Ok((builder, settings))
1197}
1198
1199/// A helper struct to allow completion functions to call jj multiple times with
1200/// different arguments.
1201struct JjBuilder {
1202    cmd: std::path::PathBuf,
1203    args: Vec<String>,
1204}
1205
1206impl JjBuilder {
1207    fn build(&self) -> std::process::Command {
1208        let mut cmd = std::process::Command::new(&self.cmd);
1209        cmd.args(&self.args);
1210        cmd
1211    }
1212}
1213
1214/// Functions for parsing revisions and revision ranges from the command line.
1215/// Parsing is done on a best-effort basis and relies on the heuristic that
1216/// most command line flags are consistent across different subcommands.
1217///
1218/// In some cases, this parsing will be incorrect, but it's not worth the effort
1219/// to fix that. For example, if the user specifies any of the relevant flags
1220/// multiple times, the parsing will pick any of the available ones, while the
1221/// actual execution of the command would fail.
1222mod parse {
1223    pub(super) fn parse_flag(
1224        candidates: &[&str],
1225        mut args: impl Iterator<Item = String>,
1226    ) -> impl Iterator<Item = String> {
1227        std::iter::from_fn(move || {
1228            for arg in args.by_ref() {
1229                // -r REV syntax
1230                if candidates.contains(&arg.as_ref()) {
1231                    match args.next() {
1232                        Some(val) if !val.starts_with('-') => {
1233                            return Some(strip_shell_quotes(&val).into());
1234                        }
1235                        _ => return None,
1236                    }
1237                }
1238
1239                // -r=REV syntax
1240                if let Some(value) = candidates.iter().find_map(|candidate| {
1241                    let rest = arg.strip_prefix(candidate)?;
1242                    match rest.strip_prefix('=') {
1243                        Some(value) => Some(value),
1244
1245                        // -rREV syntax
1246                        None if candidate.len() == 2 => Some(rest),
1247
1248                        None => None,
1249                    }
1250                }) {
1251                    return Some(strip_shell_quotes(value).into());
1252                }
1253            }
1254            None
1255        })
1256    }
1257
1258    pub fn parse_revision_impl(args: impl Iterator<Item = String>) -> Option<String> {
1259        parse_flag(&["-r", "--revision"], args).next()
1260    }
1261
1262    pub fn revision() -> Option<String> {
1263        parse_revision_impl(std::env::args())
1264    }
1265
1266    pub fn parse_changes_in_impl(args: impl Iterator<Item = String>) -> Option<String> {
1267        parse_flag(&["-c", "--changes-in"], args).next()
1268    }
1269
1270    pub fn changes_in() -> Option<String> {
1271        parse_changes_in_impl(std::env::args())
1272    }
1273
1274    pub fn revision_or_wc() -> String {
1275        revision().unwrap_or_else(|| "@".into())
1276    }
1277
1278    pub fn from_or_wc() -> String {
1279        parse_flag(&["-f", "--from"], std::env::args())
1280            .next()
1281            .unwrap_or_else(|| "@".into())
1282    }
1283
1284    pub fn parse_range_impl<T>(args: impl Fn() -> T) -> Option<(String, String)>
1285    where
1286        T: Iterator<Item = String>,
1287    {
1288        let from = parse_flag(&["-f", "--from"], args()).next()?;
1289        let to = parse_flag(&["-t", "--to"], args())
1290            .next()
1291            .unwrap_or_else(|| "@".into());
1292
1293        Some((from, to))
1294    }
1295
1296    pub fn range() -> Option<(String, String)> {
1297        parse_range_impl(std::env::args)
1298    }
1299
1300    // Special parse function only for `jj squash`. While squash has --from and
1301    // --to arguments, only files within --from should be completed, because
1302    // the files changed only in some other revision in the range between
1303    // --from and --to cannot be squashed into --to like that.
1304    pub fn squash_revision() -> Option<String> {
1305        if let Some(rev) = parse_flag(&["-r", "--revision"], std::env::args()).next() {
1306            return Some(rev);
1307        }
1308        parse_flag(&["-f", "--from"], std::env::args()).next()
1309    }
1310
1311    // Special parse function only for `jj log`. It has a --revisions flag,
1312    // instead of the usual --revision, and it can be supplied multiple times.
1313    pub fn log_revisions() -> Vec<String> {
1314        let candidates = &["-r", "--revisions"];
1315        parse_flag(candidates, std::env::args()).collect()
1316    }
1317
1318    fn strip_shell_quotes(s: &str) -> &str {
1319        if s.len() >= 2
1320            && (s.starts_with('"') && s.ends_with('"') || s.starts_with('\'') && s.ends_with('\''))
1321        {
1322            &s[1..s.len() - 1]
1323        } else {
1324            s
1325        }
1326    }
1327}
1328
1329#[cfg(test)]
1330mod tests {
1331    use super::*;
1332
1333    #[test]
1334    fn test_split_revset_trailing_name() {
1335        assert_eq!(split_revset_trailing_name(""), Some(("", "")));
1336        assert_eq!(split_revset_trailing_name(" "), Some((" ", "")));
1337        assert_eq!(split_revset_trailing_name("foo"), Some(("", "foo")));
1338        assert_eq!(split_revset_trailing_name(" foo"), Some((" ", "foo")));
1339        assert_eq!(split_revset_trailing_name("foo "), None);
1340        assert_eq!(split_revset_trailing_name("foo_"), Some(("", "foo_")));
1341        assert_eq!(split_revset_trailing_name("foo/"), Some(("", "foo/")));
1342        assert_eq!(split_revset_trailing_name("foo/b"), Some(("", "foo/b")));
1343
1344        assert_eq!(split_revset_trailing_name("foo-"), Some(("", "foo-")));
1345        assert_eq!(split_revset_trailing_name("foo+"), Some(("", "foo+")));
1346        assert_eq!(
1347            split_revset_trailing_name("foo-bar-"),
1348            Some(("", "foo-bar-"))
1349        );
1350        assert_eq!(
1351            split_revset_trailing_name("foo-bar-b"),
1352            Some(("", "foo-bar-b"))
1353        );
1354
1355        assert_eq!(split_revset_trailing_name("foo."), Some(("", "foo.")));
1356        assert_eq!(split_revset_trailing_name("foo..b"), Some(("foo..", "b")));
1357        assert_eq!(split_revset_trailing_name("..foo"), Some(("..", "foo")));
1358
1359        assert_eq!(split_revset_trailing_name("foo(bar"), Some(("foo(", "bar")));
1360        assert_eq!(split_revset_trailing_name("foo(bar)"), None);
1361        assert_eq!(split_revset_trailing_name("(f"), Some(("(", "f")));
1362
1363        assert_eq!(split_revset_trailing_name("foo@"), Some(("", "foo@")));
1364        assert_eq!(split_revset_trailing_name("foo@b"), Some(("", "foo@b")));
1365        assert_eq!(split_revset_trailing_name("..foo@"), Some(("..", "foo@")));
1366        assert_eq!(
1367            split_revset_trailing_name("::F(foo@origin.1..bar@origin."),
1368            Some(("::F(foo@origin.1..", "bar@origin."))
1369        );
1370    }
1371
1372    #[test]
1373    fn test_split_revset_trailing_name_with_trailing_operator() {
1374        assert_eq!(split_revset_trailing_name("foo|"), Some(("foo|", "")));
1375        assert_eq!(split_revset_trailing_name("foo | "), Some(("foo | ", "")));
1376        assert_eq!(split_revset_trailing_name("foo&"), Some(("foo&", "")));
1377        assert_eq!(split_revset_trailing_name("foo~"), Some(("foo~", "")));
1378
1379        assert_eq!(split_revset_trailing_name(".."), Some(("..", "")));
1380        assert_eq!(split_revset_trailing_name("foo.."), Some(("foo..", "")));
1381        assert_eq!(split_revset_trailing_name("::"), Some(("::", "")));
1382        assert_eq!(split_revset_trailing_name("foo::"), Some(("foo::", "")));
1383
1384        assert_eq!(split_revset_trailing_name("("), Some(("(", "")));
1385        assert_eq!(split_revset_trailing_name("foo("), Some(("foo(", "")));
1386        assert_eq!(split_revset_trailing_name("foo()"), None);
1387        assert_eq!(split_revset_trailing_name("foo(bar)"), None);
1388    }
1389
1390    #[test]
1391    fn test_config_keys() {
1392        // Just make sure the schema is parsed without failure.
1393        config_keys();
1394    }
1395
1396    #[test]
1397    fn test_parse_revision_impl() {
1398        let good_cases: &[&[&str]] = &[
1399            &["-r", "foo"],
1400            &["-r", "'foo'"],
1401            &["-r", "\"foo\""],
1402            &["-rfoo"],
1403            &["-r'foo'"],
1404            &["-r\"foo\""],
1405            &["--revision", "foo"],
1406            &["-r=foo"],
1407            &["-r='foo'"],
1408            &["-r=\"foo\""],
1409            &["--revision=foo"],
1410            &["--revision='foo'"],
1411            &["--revision=\"foo\""],
1412            &["preceding_arg", "-r", "foo"],
1413            &["-r", "foo", "following_arg"],
1414        ];
1415        for case in good_cases {
1416            let args = case.iter().map(|s| s.to_string());
1417            assert_eq!(
1418                parse::parse_revision_impl(args),
1419                Some("foo".into()),
1420                "case: {case:?}",
1421            );
1422        }
1423        let bad_cases: &[&[&str]] = &[&[], &["-r"], &["foo"], &["-R", "foo"], &["-R=foo"]];
1424        for case in bad_cases {
1425            let args = case.iter().map(|s| s.to_string());
1426            assert_eq!(parse::parse_revision_impl(args), None, "case: {case:?}");
1427        }
1428    }
1429
1430    #[test]
1431    fn test_parse_changes_in_impl() {
1432        let good_cases: &[&[&str]] = &[
1433            &["-c", "foo"],
1434            &["--changes-in", "foo"],
1435            &["-cfoo"],
1436            &["--changes-in=foo"],
1437        ];
1438        for case in good_cases {
1439            let args = case.iter().map(|s| s.to_string());
1440            assert_eq!(
1441                parse::parse_changes_in_impl(args),
1442                Some("foo".into()),
1443                "case: {case:?}",
1444            );
1445        }
1446        let bad_cases: &[&[&str]] = &[&[], &["-c"], &["-r"], &["foo"]];
1447        for case in bad_cases {
1448            let args = case.iter().map(|s| s.to_string());
1449            assert_eq!(parse::parse_revision_impl(args), None, "case: {case:?}");
1450        }
1451    }
1452
1453    #[test]
1454    fn test_parse_range_impl() {
1455        let wc_cases: &[&[&str]] = &[
1456            &["-f", "foo"],
1457            &["--from", "foo"],
1458            &["-f=foo"],
1459            &["preceding_arg", "-f", "foo"],
1460            &["-f", "foo", "following_arg"],
1461        ];
1462        for case in wc_cases {
1463            let args = case.iter().map(|s| s.to_string());
1464            assert_eq!(
1465                parse::parse_range_impl(|| args.clone()),
1466                Some(("foo".into(), "@".into())),
1467                "case: {case:?}",
1468            );
1469        }
1470        let to_cases: &[&[&str]] = &[
1471            &["-f", "foo", "-t", "bar"],
1472            &["-f", "foo", "--to", "bar"],
1473            &["-f=foo", "-t=bar"],
1474            &["-t=bar", "-f=foo"],
1475        ];
1476        for case in to_cases {
1477            let args = case.iter().map(|s| s.to_string());
1478            assert_eq!(
1479                parse::parse_range_impl(|| args.clone()),
1480                Some(("foo".into(), "bar".into())),
1481                "case: {case:?}",
1482            );
1483        }
1484        let bad_cases: &[&[&str]] = &[&[], &["-f"], &["foo"], &["-R", "foo"], &["-R=foo"]];
1485        for case in bad_cases {
1486            let args = case.iter().map(|s| s.to_string());
1487            assert_eq!(
1488                parse::parse_range_impl(|| args.clone()),
1489                None,
1490                "case: {case:?}"
1491            );
1492        }
1493    }
1494
1495    #[test]
1496    fn test_parse_multiple_flags() {
1497        let candidates = &["-r", "--revisions"];
1498        let args = &[
1499            "unrelated_arg_at_the_beginning",
1500            "-r",
1501            "1",
1502            "--revisions",
1503            "2",
1504            "-r=3",
1505            "--revisions=4",
1506            "unrelated_arg_in_the_middle",
1507            "-r5",
1508            "unrelated_arg_at_the_end",
1509        ];
1510        let flags: Vec<_> =
1511            parse::parse_flag(candidates, args.iter().map(|a| a.to_string())).collect();
1512        let expected = ["1", "2", "3", "4", "5"];
1513        assert_eq!(flags, expected);
1514    }
1515}