// Copyright 2024 The Jujutsu Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. use std::collections::HashSet; use std::io::BufRead as _; use std::path::Path; use clap::FromArgMatches as _; use clap::builder::StyledStr; use clap_complete::CompletionCandidate; use indoc::indoc; use itertools::Itertools as _; use jj_lib::config::ConfigNamePathBuf; use jj_lib::file_util::normalize_path; use jj_lib::file_util::slash_path; use jj_lib::settings::UserSettings; use jj_lib::workspace::DefaultWorkspaceLoaderFactory; use jj_lib::workspace::WorkspaceLoaderFactory as _; use crate::cli_util::GlobalArgs; use crate::cli_util::expand_args; use crate::cli_util::find_workspace_dir; use crate::cli_util::load_revset_aliases; use crate::cli_util::load_template_aliases; use crate::command_error::CommandError; use crate::command_error::user_error; use crate::config::CONFIG_SCHEMA; use crate::config::ConfigArgKind; use crate::config::ConfigEnv; use crate::config::config_from_environment; use crate::config::default_config_layers; use crate::merge_tools::ExternalMergeTool; use crate::merge_tools::configured_merge_tools; use crate::merge_tools::get_external_tool_config; use crate::ui::Ui; const BOOKMARK_HELP_TEMPLATE: &str = r#"template-aliases.'bookmark_help()'=''' " " ++ coalesce( if(!present, "(deleted bookmark)"), if(!normal_target, "(conflicted bookmark)"), if(!normal_target.description(), "(no description set)"), normal_target.description().first_line(), ) '''"#; const TAG_HELP_TEMPLATE: &str = r#"template-aliases.'tag_help()'=''' " " ++ coalesce( if(!present, "(deleted tag)"), if(!normal_target, "(conflicted tag)"), if(!normal_target.description(), "(no description set)"), normal_target.description().first_line(), ) '''"#; /// A helper function for various completer functions. It returns /// (candidate, help) assuming they are separated by a space. fn split_help_text(line: &str) -> (&str, Option) { match line.split_once(' ') { Some((name, help)) => (name, Some(help.to_string().into())), None => (line, None), } } pub fn local_bookmarks() -> Vec { with_jj(|jj, _| { let output = jj .build() .arg("bookmark") .arg("list") .arg("--config") .arg(BOOKMARK_HELP_TEMPLATE) .arg("--template") .arg(r#"if(!remote, name ++ bookmark_help()) ++ "\n""#) .output() .map_err(user_error)?; Ok(String::from_utf8_lossy(&output.stdout) .lines() .map(split_help_text) .map(|(name, help)| CompletionCandidate::new(name).help(help)) .collect()) }) } pub fn tracked_bookmarks() -> Vec { with_jj(|jj, _| { let output = jj .build() .arg("bookmark") .arg("list") .arg("--tracked") .arg("--config") .arg(BOOKMARK_HELP_TEMPLATE) .arg("--template") .arg(r#"if(remote, name ++ '@' ++ remote ++ bookmark_help() ++ "\n")"#) .output() .map_err(user_error)?; Ok(String::from_utf8_lossy(&output.stdout) .lines() .map(split_help_text) .filter_map(|(symbol, help)| Some((symbol.split_once('@')?, help))) // There may be multiple remote bookmarks to untrack. Just pick the // first one for help text. .dedup_by(|((name1, _), _), ((name2, _), _)| name1 == name2) .map(|((name, _remote), help)| CompletionCandidate::new(name).help(help)) .collect()) }) } pub fn untracked_bookmarks() -> Vec { with_jj(|jj, _settings| { let remotes = jj .build() .arg("git") .arg("remote") .arg("list") .output() .map_err(user_error)?; let remotes = String::from_utf8_lossy(&remotes.stdout); let remotes = remotes .lines() .filter_map(|l| l.split_whitespace().next()) .collect_vec(); let bookmark_table = jj .build() .arg("bookmark") .arg("list") .arg("--all-remotes") .arg("--config") .arg(BOOKMARK_HELP_TEMPLATE) .arg("--template") .arg( r#" if(remote != "git", if(!remote, name) ++ "\t" ++ if(remote, name ++ "@" ++ remote) ++ "\t" ++ if(tracked, "tracked") ++ "\t" ++ bookmark_help() ++ "\n" )"#, ) .output() .map_err(user_error)?; let bookmark_table = String::from_utf8_lossy(&bookmark_table.stdout); let mut possible_bookmarks_to_track = Vec::new(); let mut already_tracked_bookmarks = HashSet::new(); for line in bookmark_table.lines() { let [local, remote, tracked, help] = line.split('\t').collect_array().unwrap_or_default(); if !local.is_empty() { possible_bookmarks_to_track.extend( remotes .iter() .map(|remote| (format!("{local}@{remote}"), help)), ); } else if tracked.is_empty() { possible_bookmarks_to_track.push((remote.to_owned(), help)); } else { already_tracked_bookmarks.insert(remote); } } possible_bookmarks_to_track .retain(|(bookmark, _help)| !already_tracked_bookmarks.contains(&bookmark.as_str())); Ok(possible_bookmarks_to_track .iter() .filter_map(|(symbol, help)| Some((symbol.split_once('@')?, help))) // There may be multiple remote bookmarks to track. Just pick the // first one for help text. .dedup_by(|((name1, _), _), ((name2, _), _)| name1 == name2) .map(|((name, _remote), help)| { CompletionCandidate::new(name).help(Some(help.to_string().into())) }) .collect()) }) } pub fn bookmarks() -> Vec { with_jj(|jj, _settings| { let output = jj .build() .arg("bookmark") .arg("list") .arg("--all-remotes") .arg("--config") .arg(BOOKMARK_HELP_TEMPLATE) .arg("--template") .arg( // only provide help for local refs, remote could be ambiguous r#"name ++ if(remote, "@" ++ remote, bookmark_help()) ++ "\n""#, ) .output() .map_err(user_error)?; let stdout = String::from_utf8_lossy(&output.stdout); Ok((&stdout .lines() .map(split_help_text) .chunk_by(|(name, _)| name.split_once('@').map(|t| t.0).unwrap_or(name))) .into_iter() .map(|(bookmark, mut refs)| { let help = refs.find_map(|(_, help)| help); let local = help.is_some(); let display_order = match local { true => 0, false => 1, }; CompletionCandidate::new(bookmark) .help(help) .display_order(Some(display_order)) }) .collect()) }) } pub fn local_tags() -> Vec { with_jj(|jj, _| { let output = jj .build() .arg("tag") .arg("list") .arg("--config") .arg(TAG_HELP_TEMPLATE) .arg("--template") .arg(r#"if(!remote, name ++ tag_help()) ++ "\n""#) .output() .map_err(user_error)?; Ok(String::from_utf8_lossy(&output.stdout) .lines() .map(split_help_text) .map(|(name, help)| CompletionCandidate::new(name).help(help)) .collect()) }) } pub fn git_remotes() -> Vec { with_jj(|jj, _| { let output = jj .build() .arg("git") .arg("remote") .arg("list") .output() .map_err(user_error)?; let stdout = String::from_utf8_lossy(&output.stdout); Ok(stdout .lines() .filter_map(|line| line.split_once(' ').map(|(name, _url)| name)) .map(CompletionCandidate::new) .collect()) }) } pub fn template_aliases() -> Vec { with_jj(|_, settings| { let Ok(template_aliases) = load_template_aliases(&Ui::null(), settings.config()) else { return Ok(Vec::new()); }; Ok(template_aliases .symbol_names() .map(|name| { let doc = template_aliases.get_symbol(name).unwrap().2; CompletionCandidate::new(name).help(doc.map(|doc| doc.to_owned().into())) }) .sorted() .collect()) }) } pub fn aliases() -> Vec { with_jj(|_, settings| { Ok(settings .table_keys("aliases") // This is opinionated, but many people probably have several // single- or two-letter aliases they use all the time. These // aliases don't need to be completed and they would only clutter // the output of `jj `. .filter(|alias| alias.len() > 2) .map(|alias| { CompletionCandidate::new(alias).help( settings .get_string(["aliases", alias, "doc"]) .ok() .map(|doc| doc.into()), ) }) .collect()) }) } fn revisions(match_prefix: &str, revset_filter: Option<&str>) -> Vec { with_jj(|jj, settings| { // display order const LOCAL_BOOKMARK: usize = 0; const TAG: usize = 1; const CHANGE_ID: usize = 2; const WORKSPACE: usize = 3; const REMOTE_BOOKMARK: usize = 4; const REVSET_ALIAS: usize = 5; let mut candidates = Vec::new(); // bookmarks let mut cmd = jj.build(); cmd.arg("bookmark") .arg("list") .arg("--all-remotes") .arg("--config") .arg(BOOKMARK_HELP_TEMPLATE) .arg("--template") .arg( r#"if(remote != "git", name ++ if(remote, "@" ++ remote) ++ bookmark_help() ++ "\n")"#, ); if let Some(revs) = revset_filter { cmd.arg("--revisions").arg(revs); } let output = cmd.output().map_err(user_error)?; let stdout = String::from_utf8_lossy(&output.stdout); candidates.extend( stdout .lines() .map(split_help_text) .filter(|(bookmark, _)| bookmark.starts_with(match_prefix)) .map(|(bookmark, help)| { let local = !bookmark.contains('@'); let display_order = match local { true => LOCAL_BOOKMARK, false => REMOTE_BOOKMARK, }; CompletionCandidate::new(bookmark) .help(help) .display_order(Some(display_order)) }), ); // tags // Tags cannot be filtered by revisions. In order to avoid suggesting // immutable tags for mutable revision args, we skip tags entirely if // revset_filter is set. This is not a big loss, since tags usually point // to immutable revisions anyway. if revset_filter.is_none() { let output = jj .build() .arg("tag") .arg("list") .arg("--config") .arg(BOOKMARK_HELP_TEMPLATE) .arg("--template") .arg(r#"name ++ bookmark_help() ++ "\n""#) .arg(format!("glob:{}*", globset::escape(match_prefix))) .output() .map_err(user_error)?; let stdout = String::from_utf8_lossy(&output.stdout); candidates.extend(stdout.lines().map(|line| { let (name, desc) = split_help_text(line); CompletionCandidate::new(name) .help(desc) .display_order(Some(TAG)) })); } // workspace names let output = jj .build() .arg("workspace") .arg("list") .arg("--template") .arg(r#"name ++ "\n""#) .output() .map_err(user_error)?; let stdout = String::from_utf8_lossy(&output.stdout); // If there's only one workspace, the user can just use `@`, and they may even // be confused if they see `default@` as an option. if stdout.lines().count() > 1 { candidates.extend(stdout.lines().filter_map(|name| { let symbol = format!("{name}@"); if symbol.starts_with(match_prefix) { Some( CompletionCandidate::new(symbol) .help(Some( format!("The working copy for workspace `{name}`").into(), )) .display_order(Some(WORKSPACE)), ) } else { None } })); } // change IDs let revisions = revset_filter .map(String::from) .or_else(|| settings.get_string("revsets.short-prefixes").ok()) .or_else(|| settings.get_string("revsets.log").ok()) .unwrap_or_default(); let output = jj .build() .arg("log") .arg("--no-graph") .arg("--limit") .arg("100") .arg("--revisions") .arg(revisions) .arg("--template") .arg( r#" join(" ", separate("/", change_id.shortest(), if(hidden || divergent, change_offset), ), if(description, description.first_line(), "(no description set)"), ) ++ "\n""#, ) .output() .map_err(user_error)?; let stdout = String::from_utf8_lossy(&output.stdout); candidates.extend( stdout .lines() .map(split_help_text) .filter(|(id, _)| id.starts_with(match_prefix)) .map(|(id, desc)| { CompletionCandidate::new(id) .help(desc) .display_order(Some(CHANGE_ID)) }), ); // revset aliases let revset_aliases = load_revset_aliases(&Ui::null(), settings.config())?; let symbol_names = revset_aliases .symbol_names() .sorted_unstable() .collect_vec(); candidates.extend( symbol_names .into_iter() .filter(|symbol| symbol.starts_with(match_prefix)) .map(|symbol| { let (_, defn, doc) = revset_aliases.get_symbol(symbol).unwrap(); // Prefer TOML `.doc` over definition text let help: String = doc.map(|s| s.to_owned()).unwrap_or_else(|| defn.clone()); CompletionCandidate::new(symbol) .help(Some(help.into())) .display_order(Some(REVSET_ALIAS)) }), ); Ok(candidates) }) } fn revset_expression( current: &std::ffi::OsStr, revset_filter: Option<&str>, ) -> Vec { let Some(current) = current.to_str() else { return Vec::new(); }; let (prepend, match_prefix) = split_revset_trailing_name(current).unwrap_or(("", current)); let candidates = revisions(match_prefix, revset_filter); if prepend.is_empty() { candidates } else { candidates .into_iter() .map(|candidate| candidate.add_prefix(prepend)) .collect() } } pub fn revset_expression_all(current: &std::ffi::OsStr) -> Vec { revset_expression(current, None) } pub fn revset_expression_mutable(current: &std::ffi::OsStr) -> Vec { revset_expression(current, Some("mutable()")) } pub fn revset_expression_mutable_conflicts(current: &std::ffi::OsStr) -> Vec { revset_expression(current, Some("mutable() & conflicts()")) } /// Identifies if an incomplete expression ends with a name, or may be continued /// with a name. /// /// If the expression ends with an name or a partial name, returns a tuple that /// splits the string at the point the name starts. /// If the expression is empty or ends with a prefix or infix operator that /// could plausibly be followed by a name, returns a tuple where the first /// item is the entire input string, and the second item is empty. /// Otherwise, returns `None`. /// /// The input expression may be incomplete (e.g. missing closing parentheses), /// and the ability to reject invalid expressions is limited. fn split_revset_trailing_name(incomplete_revset_str: &str) -> Option<(&str, &str)> { let final_part = incomplete_revset_str .rsplit_once([':', '~', '|', '&', '(', ',']) .map(|(_, rest)| rest) .unwrap_or(incomplete_revset_str); let final_part = final_part .rsplit_once("..") .map(|(_, rest)| rest) .unwrap_or(final_part) .trim_ascii_start(); let re = regex::Regex::new(r"^(?:[\p{XID_CONTINUE}_/]+[@.+-])*[\p{XID_CONTINUE}_/]*$").unwrap(); re.is_match(final_part) .then(|| incomplete_revset_str.split_at(incomplete_revset_str.len() - final_part.len())) } pub fn operations() -> Vec { with_jj(|jj, _| { let output = jj .build() .arg("operation") .arg("log") .arg("--no-graph") .arg("--limit") .arg("100") .arg("--template") .arg( r#" separate(" ", id.short(), "(" ++ format_timestamp(time.end()) ++ ")", description.first_line(), ) ++ "\n""#, ) .output() .map_err(user_error)?; Ok(String::from_utf8_lossy(&output.stdout) .lines() .map(|line| { let (id, help) = split_help_text(line); CompletionCandidate::new(id).help(help) }) .collect()) }) } pub fn workspaces() -> Vec { let template = indoc! {r#" name ++ "\t" ++ if( target.description(), target.description().first_line(), "(no description set)" ) ++ "\n" "#}; with_jj(|jj, _| { let output = jj .build() .arg("workspace") .arg("list") .arg("--template") .arg(template) .output() .map_err(user_error)?; let stdout = String::from_utf8_lossy(&output.stdout); Ok(stdout .lines() .filter_map(|line| { let res = line.split_once('\t').map(|(name, desc)| { CompletionCandidate::new(name).help(Some(desc.to_string().into())) }); if res.is_none() { eprintln!("Error parsing line {line}"); } res }) .collect()) }) } fn merge_tools_filtered_by( settings: &UserSettings, condition: impl Fn(ExternalMergeTool) -> bool, ) -> impl Iterator { configured_merge_tools(settings).filter(move |name| { let Ok(Some(tool)) = get_external_tool_config(settings, name) else { return false; }; condition(tool) }) } pub fn merge_editors() -> Vec { with_jj(|_, settings| { Ok([":builtin", ":ours", ":theirs"] .into_iter() .chain(merge_tools_filtered_by(settings, |tool| { !tool.merge_args.is_empty() })) .map(CompletionCandidate::new) .collect()) }) } /// Approximate list of known diff editors pub fn diff_editors() -> Vec { with_jj(|_, settings| { Ok(std::iter::once(":builtin") .chain(merge_tools_filtered_by( settings, // The args are empty only if `edit-args` are explicitly set to // `[]` in TOML. If they are not specified, the default // `["$left", "$right"]` value would be used. |tool| !tool.edit_args.is_empty(), )) .map(CompletionCandidate::new) .collect()) }) } /// Approximate list of known diff tools pub fn diff_formatters() -> Vec { let builtin_format_kinds = crate::diff_util::all_builtin_diff_format_names(); with_jj(|_, settings| { Ok(builtin_format_kinds .iter() .map(|s| s.as_str()) .chain(merge_tools_filtered_by( settings, // The args are empty only if `diff-args` are explicitly set to // `[]` in TOML. If they are not specified, the default // `["$left", "$right"]` value would be used. |tool| !tool.diff_args.is_empty(), )) .map(CompletionCandidate::new) .collect()) }) } fn config_keys_rec( prefix: ConfigNamePathBuf, properties: &serde_json::Map, acc: &mut Vec, only_leaves: bool, suffix: &str, ) { for (key, value) in properties { let mut prefix = prefix.clone(); prefix.push(key); let value = value.as_object().unwrap(); match value.get("type").and_then(|v| v.as_str()) { Some("object") => { if !only_leaves { let help = value .get("description") .map(|desc| desc.as_str().unwrap().to_string().into()); let escaped_key = prefix.to_string(); acc.push(CompletionCandidate::new(escaped_key).help(help)); } let Some(properties) = value.get("properties") else { continue; }; let properties = properties.as_object().unwrap(); config_keys_rec(prefix, properties, acc, only_leaves, suffix); } _ => { let help = value .get("description") .map(|desc| desc.as_str().unwrap().to_string().into()); let escaped_key = format!("{prefix}{suffix}"); acc.push(CompletionCandidate::new(escaped_key).help(help)); } } } } fn json_keypath<'a>( schema: &'a serde_json::Value, keypath: &str, separator: &str, ) -> Option<&'a serde_json::Value> { keypath .split(separator) .try_fold(schema, |value, step| value.get(step)) } fn jsonschema_keypath<'a>( schema: &'a serde_json::Value, keypath: &ConfigNamePathBuf, ) -> Option<&'a serde_json::Value> { keypath.components().try_fold(schema, |value, step| { let value = value.as_object()?; if value.get("type")?.as_str()? != "object" { return None; } let properties = value.get("properties")?.as_object()?; properties.get(step.get()) }) } fn config_values(path: &ConfigNamePathBuf) -> Option> { let schema: serde_json::Value = serde_json::from_str(CONFIG_SCHEMA).unwrap(); let mut config_entry = jsonschema_keypath(&schema, path)?; if let Some(reference) = config_entry.get("$ref") { let reference = reference.as_str()?.strip_prefix("#/")?; config_entry = json_keypath(&schema, reference, "/")?; } if let Some(possible_values) = config_entry.get("enum") { return Some( possible_values .as_array()? .iter() .filter_map(|val| val.as_str()) .map(ToOwned::to_owned) .collect(), ); } Some(match config_entry.get("type")?.as_str()? { "boolean" => vec!["false".into(), "true".into()], _ => vec![], }) } fn config_keys_impl(only_leaves: bool, suffix: &str) -> Vec { let schema: serde_json::Value = serde_json::from_str(CONFIG_SCHEMA).unwrap(); let schema = schema.as_object().unwrap(); let properties = schema["properties"].as_object().unwrap(); let mut candidates = Vec::new(); config_keys_rec( ConfigNamePathBuf::root(), properties, &mut candidates, only_leaves, suffix, ); candidates } pub fn config_keys() -> Vec { config_keys_impl(false, "") } pub fn leaf_config_keys() -> Vec { config_keys_impl(true, "") } pub fn leaf_config_key_value(current: &std::ffi::OsStr) -> Vec { let Some(current) = current.to_str() else { return Vec::new(); }; if let Some((key, current_val)) = current.split_once('=') { let Ok(key) = key.parse() else { return Vec::new(); }; let possible_values = config_values(&key).unwrap_or_default(); possible_values .into_iter() .filter(|x| x.starts_with(current_val)) .map(|x| CompletionCandidate::new(format!("{key}={x}"))) .collect() } else { config_keys_impl(true, "=") .into_iter() .filter(|candidate| candidate.get_value().to_str().unwrap().starts_with(current)) .collect() } } pub fn config_keys_to_unset() -> Vec { let Ok(config_level_flag) = std::env::args() .filter(|arg| matches!(arg.as_str(), "--user" | "--repo" | "--workspace")) .at_most_one() else { return Vec::new(); }; with_jj(|jj, _| { const TEMPLATE: &str = r#"name ++ "\t" ++ source ++ "\t" ++ stringify(value).replace(regex:'\n\s*', " ") ++ "\n""#; let list_output = jj .build() .args(["config", "list"]) // Only suggest unsetting overridden config options if the corresponding level is // already specified. .args( config_level_flag .is_some() .then_some("--include-overridden"), ) .args(config_level_flag) .args(["--template", TEMPLATE]) .output() .map_err(user_error)?; Ok(String::from_utf8_lossy(&list_output.stdout) .lines() .filter_map(|line| line.split('\t').collect_tuple()) .filter(|(_, source, _)| matches!(*source, "user" | "repo" | "workspace")) .map(|(name, source, value)| { CompletionCandidate::new(name) .tag(Some(source.to_string().into())) .help(Some(format!("{source}: {value}").into())) }) .collect()) }) } pub fn branch_name_equals_any_revision(current: &std::ffi::OsStr) -> Vec { let Some(current) = current.to_str() else { return Vec::new(); }; let Some((branch_name, revision)) = current.split_once('=') else { // Don't complete branch names since we want to create a new branch return Vec::new(); }; revset_expression(revision.as_ref(), None) .into_iter() .map(|rev| rev.add_prefix(format!("{branch_name}="))) .collect() } fn path_completion_candidate_from( current_prefix: &str, normalized_prefix_path: &Path, path: &Path, mode: Option, ) -> Option { let normalized_prefix = match normalized_prefix_path.to_str()? { "." => "", // `.` cannot be normalized further, but doesn't prefix `path`. normalized_prefix => normalized_prefix, }; let path = slash_path(path); let mut remainder = path.to_str()?.strip_prefix(normalized_prefix)?; // Trailing slash might have been normalized away in which case we need to strip // the leading slash in the remainder away, or else the slash would appear // twice. if current_prefix.ends_with(std::path::is_separator) { remainder = remainder.strip_prefix('/').unwrap_or(remainder); } match remainder.split_inclusive('/').at_most_one() { // Completed component is the final component in `path`, so we're completing the file to // which `mode` refers. Ok(file_completion) => Some( CompletionCandidate::new(format!( "{current_prefix}{}", file_completion.unwrap_or_default() )) .help(mode), ), // Omit `mode` when completing only up to the next directory. Err(mut components) => Some(CompletionCandidate::new(format!( "{current_prefix}{}", components.next().unwrap() ))), } } fn current_prefix_to_fileset(current: &str) -> String { let cur_esc = globset::escape(current); let dir_pat = format!("{cur_esc}*/**"); let path_pat = format!("{cur_esc}*"); format!("glob:{dir_pat:?} | glob:{path_pat:?}") } fn all_files_from_rev(rev: String, current: &std::ffi::OsStr) -> Vec { let Some(current) = current.to_str() else { return Vec::new(); }; let normalized_prefix = normalize_path(Path::new(current)); let normalized_prefix = slash_path(&normalized_prefix); with_jj(|jj, _| { let mut child = jj .build() .arg("file") .arg("list") .arg("--revision") .arg(rev) .arg("--template") .arg(r#"path.display() ++ "\n""#) .arg(current_prefix_to_fileset(current)) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::null()) .spawn() .map_err(user_error)?; let stdout = child.stdout.take().unwrap(); Ok(std::io::BufReader::new(stdout) .lines() .take(1_000) .map_while(Result::ok) .filter_map(|path| { path_completion_candidate_from(current, &normalized_prefix, Path::new(&path), None) }) .dedup() // directories may occur multiple times .collect()) }) } #[derive(Clone, Debug)] enum DiffSelection { Revisions(Vec), Range { from: String, to: String }, } impl DiffSelection { fn revision(revision: String) -> Self { Self::Revisions(vec![revision]) } } fn modified_files_from_selection_with_jj_cmd( selection: &DiffSelection, mut cmd: std::process::Command, current: &std::ffi::OsStr, ) -> Result, CommandError> { let Some(current) = current.to_str() else { return Ok(Vec::new()); }; let normalized_prefix = normalize_path(Path::new(current)); let normalized_prefix = slash_path(&normalized_prefix); // In case of a rename, one entry of `diff` results in two suggestions. let template = indoc! {r#" concat( status ++ ' ' ++ path.display() ++ "\n", if(status == 'renamed', 'renamed.source ' ++ source.path().display() ++ "\n"), ) "#}; cmd.arg("diff").args(["--template", template]); match selection { DiffSelection::Revisions(revisions) => { for revision in revisions { cmd.arg("--revisions").arg(revision); } } DiffSelection::Range { from, to } => { cmd.arg("--from").arg(from).arg("--to").arg(to); } } cmd.arg(current_prefix_to_fileset(current)); let output = cmd.output().map_err(user_error)?; let stdout = String::from_utf8_lossy(&output.stdout); let mut include_renames = false; let mut candidates: Vec<_> = stdout .lines() .filter_map(|line| line.split_once(' ')) .filter_map(|(mode, path)| { let mode = match mode { "modified" => "Modified".into(), "removed" => "Deleted".into(), "added" => "Added".into(), "renamed" => "Renamed".into(), "renamed.source" => { include_renames = true; "Renamed".into() } "copied" => "Copied".into(), _ => format!("unknown mode: '{mode}'").into(), }; path_completion_candidate_from(current, &normalized_prefix, Path::new(path), Some(mode)) }) .collect(); if include_renames { candidates.sort_unstable_by(|a, b| Path::new(a.get_value()).cmp(Path::new(b.get_value()))); } candidates.dedup(); Ok(candidates) } fn modified_files_from_rev_with_jj_cmd( rev: String, cmd: std::process::Command, current: &std::ffi::OsStr, ) -> Result, CommandError> { let selection = DiffSelection::revision(rev); modified_files_from_selection_with_jj_cmd(&selection, cmd, current) } fn modified_files_from_selection( selection: DiffSelection, current: &std::ffi::OsStr, ) -> Vec { with_jj(|jj, _| modified_files_from_selection_with_jj_cmd(&selection, jj.build(), current)) } fn modified_files_from_rev(rev: String, current: &std::ffi::OsStr) -> Vec { modified_files_from_selection(DiffSelection::revision(rev), current) } fn conflicted_files_from_rev(rev: &str, current: &std::ffi::OsStr) -> Vec { let Some(current) = current.to_str() else { return Vec::new(); }; let normalized_prefix = normalize_path(Path::new(current)); let normalized_prefix = slash_path(&normalized_prefix); with_jj(|jj, _| { let output = jj .build() .arg("resolve") .arg("--list") .arg("--revision") .arg(rev) .arg(current_prefix_to_fileset(current)) .output() .map_err(user_error)?; let stdout = String::from_utf8_lossy(&output.stdout); Ok(stdout .lines() .filter_map(|line| { let path = line .split_whitespace() .next() .expect("resolve --list should contain whitespace after path"); path_completion_candidate_from(current, &normalized_prefix, Path::new(path), None) }) .dedup() // directories may occur multiple times .collect()) }) } pub fn modified_files(current: &std::ffi::OsStr) -> Vec { modified_files_from_rev("@".into(), current) } pub fn all_revision_files(current: &std::ffi::OsStr) -> Vec { all_files_from_rev(parse::revision_or_wc(), current) } pub fn modified_revision_files(current: &std::ffi::OsStr) -> Vec { modified_files_from_rev(parse::revision_or_wc(), current) } pub fn modified_range_files(current: &std::ffi::OsStr) -> Vec { match parse::range() { Some((from, to)) => { modified_files_from_selection(DiffSelection::Range { from, to }, current) } None => modified_files_from_rev("@".into(), current), } } /// Completes files in `@` *or* the `--from` revision (not the diff between /// `--from` and `@`) pub fn modified_from_files(current: &std::ffi::OsStr) -> Vec { modified_files_from_rev(parse::from_or_wc(), current) } pub fn modified_revision_or_range_files(current: &std::ffi::OsStr) -> Vec { let revisions = parse::revisions(); if revisions.is_empty() { return modified_range_files(current); } modified_files_from_selection(DiffSelection::Revisions(revisions), current) } pub fn modified_changes_in_or_range_files(current: &std::ffi::OsStr) -> Vec { if let Some(rev) = parse::changes_in() { return modified_files_from_rev(rev, current); } modified_range_files(current) } pub fn revision_conflicted_files(current: &std::ffi::OsStr) -> Vec { conflicted_files_from_rev(&parse::revision_or_wc(), current) } /// Specific function for completing file paths for `jj squash` pub fn squash_revision_files(current: &std::ffi::OsStr) -> Vec { let rev = parse::squash_revision().unwrap_or_else(|| "@".into()); modified_files_from_rev(rev, current) } /// Specific function for completing file paths for `jj interdiff` pub fn interdiff_files(current: &std::ffi::OsStr) -> Vec { let Some((from, to)) = parse::range() else { return Vec::new(); }; // Complete all modified files in "from" and "to". This will also suggest // files that are the same in both, which is a false positive. This approach // is more lightweight than actually doing a temporary rebase here. with_jj(|jj, _| { let mut res = modified_files_from_rev_with_jj_cmd(from, jj.build(), current)?; res.extend(modified_files_from_rev_with_jj_cmd( to, jj.build(), current, )?); Ok(res) }) } /// Specific function for completing file paths for `jj log` pub fn log_files(current: &std::ffi::OsStr) -> Vec { let mut rev = parse::revisions().join(")|("); if rev.is_empty() { rev = "@".into(); } else { rev = format!("latest(heads(({rev})))"); // limit to one } all_files_from_rev(rev, current) } /// Shell out to jj during dynamic completion generation /// /// In case of errors, print them and early return an empty vector. fn with_jj(completion_fn: F) -> Vec where F: FnOnce(JjBuilder, &UserSettings) -> Result, CommandError>, { get_jj_command() .and_then(|(jj, settings)| completion_fn(jj, &settings)) .unwrap_or_else(|e| { eprintln!("{}", e.error); Vec::new() }) } /// Shell out to jj during dynamic completion generation /// /// This is necessary because dynamic completion code needs to be aware of /// global configuration like custom storage backends. Dynamic completion /// code via clap_complete doesn't accept arguments, so they cannot be passed /// that way. Another solution would've been to use global mutable state, to /// give completion code access to custom backends. Shelling out was chosen as /// the preferred method, because it's more maintainable and the performance /// requirements of completions aren't very high. fn get_jj_command() -> Result<(JjBuilder, UserSettings), CommandError> { let current_exe = std::env::current_exe().map_err(user_error)?; let mut cmd_args = Vec::::new(); // Snapshotting could make completions much slower in some situations // and be undesired by the user. cmd_args.push("--ignore-working-copy".into()); cmd_args.push("--color=never".into()); cmd_args.push("--no-pager".into()); // Parse some of the global args we care about for passing along to the // child process. This shouldn't fail, since none of the global args are // required. let app = crate::commands::default_app(); let mut raw_config = config_from_environment(default_config_layers()); let ui = Ui::null(); let cwd = std::env::current_dir() .and_then(dunce::canonicalize) .map_err(user_error)?; // No config migration for completion. Simply ignore deprecated variables. let mut config_env = ConfigEnv::from_environment(); let maybe_cwd_workspace_loader = DefaultWorkspaceLoaderFactory.create(find_workspace_dir(&cwd)); config_env.reload_system_config(&mut raw_config).ok(); config_env.reload_user_config(&mut raw_config).ok(); if let Ok(loader) = &maybe_cwd_workspace_loader { config_env.reset_repo_path(loader.repo_path()); config_env.reload_repo_config(&ui, &mut raw_config).ok(); config_env.reset_workspace_path(loader.workspace_root()); config_env .reload_workspace_config(&ui, &mut raw_config) .ok(); } let mut config = config_env.resolve_config(&raw_config)?; // skip 2 because of the clap_complete prelude: jj -- jj let args = std::env::args_os().skip(2); let args = expand_args(&ui, &app, args, &config)?; let arg_matches = app .clone() .disable_version_flag(true) .disable_help_flag(true) .ignore_errors(true) .try_get_matches_from(args)?; let args: GlobalArgs = GlobalArgs::from_arg_matches(&arg_matches)?; if let Some(repository) = args.repository { // Try to update repo-specific config on a best-effort basis. if let Ok(loader) = DefaultWorkspaceLoaderFactory.create(&cwd.join(&repository)) { config_env.reset_repo_path(loader.repo_path()); config_env.reload_repo_config(&ui, &mut raw_config).ok(); config_env.reset_workspace_path(loader.workspace_root()); config_env .reload_workspace_config(&ui, &mut raw_config) .ok(); if let Ok(new_config) = config_env.resolve_config(&raw_config) { config = new_config; } } cmd_args.push("--repository".into()); cmd_args.push(repository); } if let Some(at_operation) = args.at_operation { // We cannot assume that the value of at_operation is valid, because // the user may be requesting completions precisely for this invalid // operation ID. Additionally, the user may have mistyped the ID, // in which case adding the argument blindly would break all other // completions, even unrelated ones. // // To avoid this, we shell out to ourselves once with the argument // and check the exit code. There is some performance overhead to this, // but this code path is probably only executed in exceptional // situations. let mut canary_cmd = std::process::Command::new(¤t_exe); canary_cmd.args(&cmd_args); canary_cmd.arg("--at-operation"); canary_cmd.arg(&at_operation); canary_cmd.arg("debug"); canary_cmd.arg("snapshot"); match canary_cmd.output() { Ok(output) if output.status.success() => { // Operation ID is valid, add it to the completion command. cmd_args.push("--at-operation".into()); cmd_args.push(at_operation); } _ => {} // Invalid operation ID, ignore. } } for (kind, value) in args.early_args.merged_config_args(&arg_matches) { let arg = match kind { ConfigArgKind::Item => format!("--config={value}"), ConfigArgKind::File => format!("--config-file={value}"), }; cmd_args.push(arg); } let builder = JjBuilder { cmd: current_exe, args: cmd_args, }; let settings = UserSettings::from_config(config)?; Ok((builder, settings)) } /// A helper struct to allow completion functions to call jj multiple times with /// different arguments. struct JjBuilder { cmd: std::path::PathBuf, args: Vec, } impl JjBuilder { fn build(&self) -> std::process::Command { let mut cmd = std::process::Command::new(&self.cmd); cmd.args(&self.args); cmd } } /// Functions for parsing revisions and revision ranges from the command line. /// Parsing is done on a best-effort basis and relies on the heuristic that /// most command line flags are consistent across different subcommands. /// /// In some cases, this parsing will be incorrect, but it's not worth the effort /// to fix that. For example, if the user specifies any of the relevant flags /// multiple times, the parsing will pick any of the available ones, while the /// actual execution of the command would fail. mod parse { pub(super) fn parse_flag( candidates: &[&str], mut args: impl Iterator, ) -> impl Iterator { std::iter::from_fn(move || { for arg in args.by_ref() { // -r REV syntax if candidates.contains(&arg.as_ref()) { match args.next() { Some(val) if !val.starts_with('-') => { return Some(strip_shell_quotes(&val).into()); } _ => return None, } } // -r=REV syntax if let Some(value) = candidates.iter().find_map(|candidate| { let rest = arg.strip_prefix(candidate)?; match rest.strip_prefix('=') { Some(value) => Some(value), // -rREV syntax None if candidate.len() == 2 => Some(rest), None => None, } }) { return Some(strip_shell_quotes(value).into()); } } None }) } pub fn parse_revision_impl(args: impl Iterator) -> Option { parse_flag(&["-r", "--revision"], args).next() } pub fn revision() -> Option { parse_revision_impl(std::env::args()) } pub fn revisions() -> Vec { let candidates = &["-r", "--revision", "--revisions"]; parse_flag(candidates, std::env::args()).collect() } pub fn parse_changes_in_impl(args: impl Iterator) -> Option { parse_flag(&["-c", "--changes-in"], args).next() } pub fn changes_in() -> Option { parse_changes_in_impl(std::env::args()) } pub fn revision_or_wc() -> String { revision().unwrap_or_else(|| "@".into()) } pub fn from_or_wc() -> String { parse_flag(&["-f", "--from"], std::env::args()) .next() .unwrap_or_else(|| "@".into()) } pub fn parse_range_impl(args: impl Fn() -> T) -> Option<(String, String)> where T: Iterator, { let from = parse_flag(&["-f", "--from"], args()).next(); let to = parse_flag(&["-t", "--to"], args()).next(); if from.is_none() && to.is_none() { return None; } Some(( from.unwrap_or_else(|| "@".into()), to.unwrap_or_else(|| "@".into()), )) } pub fn range() -> Option<(String, String)> { parse_range_impl(std::env::args) } // Special parse function only for `jj squash`. While squash has --from and // --to arguments, only files within --from should be completed, because // the files changed only in some other revision in the range between // --from and --to cannot be squashed into --to like that. pub fn squash_revision() -> Option { if let Some(rev) = parse_flag(&["-r", "--revision"], std::env::args()).next() { return Some(rev); } parse_flag(&["-f", "--from"], std::env::args()).next() } fn strip_shell_quotes(s: &str) -> &str { if s.len() >= 2 && (s.starts_with('"') && s.ends_with('"') || s.starts_with('\'') && s.ends_with('\'')) { &s[1..s.len() - 1] } else { s } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_split_revset_trailing_name() { assert_eq!(split_revset_trailing_name(""), Some(("", ""))); assert_eq!(split_revset_trailing_name(" "), Some((" ", ""))); assert_eq!(split_revset_trailing_name("foo"), Some(("", "foo"))); assert_eq!(split_revset_trailing_name(" foo"), Some((" ", "foo"))); assert_eq!(split_revset_trailing_name("foo "), None); assert_eq!(split_revset_trailing_name("foo_"), Some(("", "foo_"))); assert_eq!(split_revset_trailing_name("foo/"), Some(("", "foo/"))); assert_eq!(split_revset_trailing_name("foo/b"), Some(("", "foo/b"))); assert_eq!(split_revset_trailing_name("foo-"), Some(("", "foo-"))); assert_eq!(split_revset_trailing_name("foo+"), Some(("", "foo+"))); assert_eq!( split_revset_trailing_name("foo-bar-"), Some(("", "foo-bar-")) ); assert_eq!( split_revset_trailing_name("foo-bar-b"), Some(("", "foo-bar-b")) ); assert_eq!(split_revset_trailing_name("foo."), Some(("", "foo."))); assert_eq!(split_revset_trailing_name("foo..b"), Some(("foo..", "b"))); assert_eq!(split_revset_trailing_name("..foo"), Some(("..", "foo"))); assert_eq!(split_revset_trailing_name("foo(bar"), Some(("foo(", "bar"))); assert_eq!(split_revset_trailing_name("foo(bar)"), None); assert_eq!(split_revset_trailing_name("(f"), Some(("(", "f"))); assert_eq!(split_revset_trailing_name("foo@"), Some(("", "foo@"))); assert_eq!(split_revset_trailing_name("foo@b"), Some(("", "foo@b"))); assert_eq!(split_revset_trailing_name("..foo@"), Some(("..", "foo@"))); assert_eq!( split_revset_trailing_name("::F(foo@origin.1..bar@origin."), Some(("::F(foo@origin.1..", "bar@origin.")) ); } #[test] fn test_split_revset_trailing_name_with_trailing_operator() { assert_eq!(split_revset_trailing_name("foo|"), Some(("foo|", ""))); assert_eq!(split_revset_trailing_name("foo | "), Some(("foo | ", ""))); assert_eq!(split_revset_trailing_name("foo&"), Some(("foo&", ""))); assert_eq!(split_revset_trailing_name("foo~"), Some(("foo~", ""))); assert_eq!(split_revset_trailing_name(".."), Some(("..", ""))); assert_eq!(split_revset_trailing_name("foo.."), Some(("foo..", ""))); assert_eq!(split_revset_trailing_name("::"), Some(("::", ""))); assert_eq!(split_revset_trailing_name("foo::"), Some(("foo::", ""))); assert_eq!(split_revset_trailing_name("("), Some(("(", ""))); assert_eq!(split_revset_trailing_name("foo("), Some(("foo(", ""))); assert_eq!(split_revset_trailing_name("foo()"), None); assert_eq!(split_revset_trailing_name("foo(bar)"), None); } #[test] fn test_config_keys() { // Just make sure the schema is parsed without failure. config_keys(); } #[test] fn test_parse_revision_impl() { let good_cases: &[&[&str]] = &[ &["-r", "foo"], &["-r", "'foo'"], &["-r", "\"foo\""], &["-rfoo"], &["-r'foo'"], &["-r\"foo\""], &["--revision", "foo"], &["-r=foo"], &["-r='foo'"], &["-r=\"foo\""], &["--revision=foo"], &["--revision='foo'"], &["--revision=\"foo\""], &["preceding_arg", "-r", "foo"], &["-r", "foo", "following_arg"], ]; for case in good_cases { let args = case.iter().map(|s| s.to_string()); assert_eq!( parse::parse_revision_impl(args), Some("foo".into()), "case: {case:?}", ); } let bad_cases: &[&[&str]] = &[&[], &["-r"], &["foo"], &["-R", "foo"], &["-R=foo"]]; for case in bad_cases { let args = case.iter().map(|s| s.to_string()); assert_eq!(parse::parse_revision_impl(args), None, "case: {case:?}"); } } #[test] fn test_parse_changes_in_impl() { let good_cases: &[&[&str]] = &[ &["-c", "foo"], &["--changes-in", "foo"], &["-cfoo"], &["--changes-in=foo"], ]; for case in good_cases { let args = case.iter().map(|s| s.to_string()); assert_eq!( parse::parse_changes_in_impl(args), Some("foo".into()), "case: {case:?}", ); } let bad_cases: &[&[&str]] = &[&[], &["-c"], &["-r"], &["foo"]]; for case in bad_cases { let args = case.iter().map(|s| s.to_string()); assert_eq!(parse::parse_revision_impl(args), None, "case: {case:?}"); } } #[test] fn test_parse_range_impl() { let wc_cases: &[&[&str]] = &[ &["-f", "foo"], &["--from", "foo"], &["-f=foo"], &["preceding_arg", "-f", "foo"], &["-f", "foo", "following_arg"], ]; for case in wc_cases { let args = case.iter().map(|s| s.to_string()); assert_eq!( parse::parse_range_impl(|| args.clone()), Some(("foo".into(), "@".into())), "case: {case:?}", ); } let from_working_copy_cases: &[&[&str]] = &[&["-t", "bar"], &["--to", "bar"], &["-t=bar"], &["--to=bar"]]; for case in from_working_copy_cases { let args = case.iter().map(|s| s.to_string()); assert_eq!( parse::parse_range_impl(|| args.clone()), Some(("@".into(), "bar".into())), "case: {case:?}", ); } let to_cases: &[&[&str]] = &[ &["-f", "foo", "-t", "bar"], &["-f", "foo", "--to", "bar"], &["-f=foo", "-t=bar"], &["-t=bar", "-f=foo"], ]; for case in to_cases { let args = case.iter().map(|s| s.to_string()); assert_eq!( parse::parse_range_impl(|| args.clone()), Some(("foo".into(), "bar".into())), "case: {case:?}", ); } let bad_cases: &[&[&str]] = &[&[], &["-f"], &["foo"], &["-R", "foo"], &["-R=foo"]]; for case in bad_cases { let args = case.iter().map(|s| s.to_string()); assert_eq!( parse::parse_range_impl(|| args.clone()), None, "case: {case:?}" ); } } #[test] fn test_parse_multiple_flags() { let candidates = &["-r", "--revisions"]; let args = &[ "unrelated_arg_at_the_beginning", "-r", "1", "--revisions", "2", "-r=3", "--revisions=4", "unrelated_arg_in_the_middle", "-r5", "unrelated_arg_at_the_end", ]; let flags: Vec<_> = parse::parse_flag(candidates, args.iter().map(|a| a.to_string())).collect(); let expected = ["1", "2", "3", "4", "5"]; assert_eq!(flags, expected); } }