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