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