Skip to content

Commit

Permalink
completion: teach commands about files
Browse files Browse the repository at this point in the history
This is heavily based on Benjamin Tan's fish completions:
https://gist.github.com/bnjmnt4n/9f47082b8b6e6ed2b2a805a1516090c8

Some differences include:
- The end of a `--from`, `--to` ranges is also considered.
- `jj log` is not completed (yet). It has a different `--revisions` argument
  that requires some special handling.
  • Loading branch information
senekor committed Nov 20, 2024
1 parent b88157b commit f7c52c4
Show file tree
Hide file tree
Showing 13 changed files with 487 additions and 11 deletions.
4 changes: 3 additions & 1 deletion cli/src/commands/commit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.

use clap_complete::ArgValueCandidates;
use jj_lib::backend::Signature;
use jj_lib::object_id::ObjectId;
use jj_lib::repo::Repo;
Expand All @@ -20,6 +21,7 @@ use tracing::instrument;
use crate::cli_util::CommandHelper;
use crate::command_error::user_error;
use crate::command_error::CommandError;
use crate::complete;
use crate::description_util::description_template;
use crate::description_util::edit_description;
use crate::description_util::join_message_paragraphs;
Expand All @@ -40,7 +42,7 @@ pub(crate) struct CommitArgs {
#[arg(long = "message", short, value_name = "MESSAGE")]
message_paragraphs: Vec<String>,
/// Put these paths in the first commit
#[arg(value_hint = clap::ValueHint::AnyPath)]
#[arg(add = ArgValueCandidates::new(complete::modified_files))]
paths: Vec<String>,
/// Reset the author to the configured user
///
Expand Down
2 changes: 1 addition & 1 deletion cli/src/commands/diff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ pub(crate) struct DiffArgs {
#[arg(long, short, conflicts_with = "revision", add = ArgValueCandidates::new(complete::all_revisions))]
to: Option<RevisionArg>,
/// Restrict the diff to these paths
#[arg(value_hint = clap::ValueHint::AnyPath)]
#[arg(add = ArgValueCandidates::new(complete::modified_revision_or_range_files))]
paths: Vec<String>,
#[command(flatten)]
format: DiffFormatArgs,
Expand Down
2 changes: 1 addition & 1 deletion cli/src/commands/file/annotate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ use crate::ui::Ui;
#[derive(clap::Args, Clone, Debug)]
pub(crate) struct FileAnnotateArgs {
/// the file to annotate
#[arg(value_hint = clap::ValueHint::AnyPath)]
#[arg(add = ArgValueCandidates::new(complete::all_revision_files))]
path: String,
/// an optional revision to start at
#[arg(long, short, add = ArgValueCandidates::new(complete::all_revisions))]
Expand Down
2 changes: 1 addition & 1 deletion cli/src/commands/file/chmod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ pub(crate) struct FileChmodArgs {
)]
revision: RevisionArg,
/// Paths to change the executable bit for
#[arg(required = true, value_hint = clap::ValueHint::AnyPath)]
#[arg(required = true, add = ArgValueCandidates::new(complete::all_revision_files))]
paths: Vec<String>,
}

Expand Down
2 changes: 1 addition & 1 deletion cli/src/commands/file/show.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ pub(crate) struct FileShowArgs {
)]
revision: RevisionArg,
/// Paths to print
#[arg(required = true, value_hint = clap::ValueHint::FilePath)]
#[arg(required = true, add = ArgValueCandidates::new(complete::all_revision_files))]
paths: Vec<String>,
}

Expand Down
4 changes: 3 additions & 1 deletion cli/src/commands/file/untrack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

use std::io::Write;

use clap_complete::ArgValueCandidates;
use itertools::Itertools;
use jj_lib::merge::Merge;
use jj_lib::merged_tree::MergedTreeBuilder;
Expand All @@ -24,6 +25,7 @@ use tracing::instrument;
use crate::cli_util::CommandHelper;
use crate::command_error::user_error_with_hint;
use crate::command_error::CommandError;
use crate::complete;
use crate::ui::Ui;

/// Stop tracking specified paths in the working copy
Expand All @@ -33,7 +35,7 @@ pub(crate) struct FileUntrackArgs {
///
/// The paths could be ignored via a .gitignore or .git/info/exclude (in
/// colocated repos).
#[arg(required = true, value_hint = clap::ValueHint::AnyPath)]
#[arg(required = true, add = ArgValueCandidates::new(complete::all_revision_files))]
paths: Vec<String>,
}

Expand Down
2 changes: 1 addition & 1 deletion cli/src/commands/interdiff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ pub(crate) struct InterdiffArgs {
#[arg(long, short, add = ArgValueCandidates::new(complete::all_revisions))]
to: Option<RevisionArg>,
/// Restrict the diff to these paths
#[arg(value_hint = clap::ValueHint::AnyPath)]
#[arg(add = ArgValueCandidates::new(complete::interdiff_files))]
paths: Vec<String>,
#[command(flatten)]
format: DiffFormatArgs,
Expand Down
2 changes: 1 addition & 1 deletion cli/src/commands/resolve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ pub(crate) struct ResolveArgs {
/// will attempt to resolve the first conflict we can find. You can use
/// the `--list` argument to find paths to use here.
// TODO: Find the conflict we can resolve even if it's not the first one.
#[arg(value_hint = clap::ValueHint::AnyPath)]
#[arg(add = ArgValueCandidates::new(complete::revision_conflicted_files))]
paths: Vec<String>,
}

Expand Down
2 changes: 1 addition & 1 deletion cli/src/commands/restore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ use crate::ui::Ui;
#[derive(clap::Args, Clone, Debug)]
pub(crate) struct RestoreArgs {
/// Restore only these paths (instead of all paths)
#[arg(value_hint = clap::ValueHint::AnyPath)]
#[arg(add = ArgValueCandidates::new(complete::modified_range_files))]
paths: Vec<String>,
/// Revision to restore from (source)
#[arg(long, short, add = ArgValueCandidates::new(complete::all_revisions))]
Expand Down
2 changes: 1 addition & 1 deletion cli/src/commands/split.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ pub(crate) struct SplitArgs {
#[arg(long, short, alias = "siblings")]
parallel: bool,
/// Put these paths in the first commit
#[arg(value_hint = clap::ValueHint::AnyPath)]
#[arg(add = ArgValueCandidates::new(complete::modified_revision_files))]
paths: Vec<String>,
}

Expand Down
5 changes: 4 additions & 1 deletion cli/src/commands/squash.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,10 @@ pub(crate) struct SquashArgs {
#[arg(long, value_name = "NAME")]
tool: Option<String>,
/// Move only changes to these paths (instead of all paths)
#[arg(conflicts_with_all = ["interactive", "tool"], value_hint = clap::ValueHint::AnyPath)]
#[arg(
conflicts_with_all = ["interactive", "tool"],
add = ArgValueCandidates::new(complete::squash_revision_files),
)]
paths: Vec<String>,
/// The source revision will not be abandoned
#[arg(long, short)]
Expand Down
257 changes: 257 additions & 0 deletions cli/src/complete.rs
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,263 @@ pub fn leaf_config_keys() -> Vec<CompletionCandidate> {
config_keys_impl(true)
}

/// 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 {
fn parse_flag(candidates: &[&str], mut args: impl Iterator<Item = String>) -> Option<String> {
for arg in args.by_ref() {
// -r REV syntax
if candidates.contains(&arg.as_ref()) {
return args.next();
}

// -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(value.into());
};
}
None
}

fn parse_revision_impl(args: impl Iterator<Item = String>) -> Option<String> {
parse_flag(&["-r", "--revision"], args)
}

#[test]
fn test_parse_revision_impl() {
let good_cases: &[&[&str]] = &[
&["-r", "foo"],
&["--revision", "foo"],
&["-r=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_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_revision_impl(args), None, "case: {case:?}");
}
}

pub fn revision() -> Option<String> {
parse_revision_impl(std::env::args())
}

pub fn revision_or_wc() -> String {
revision().unwrap_or_else(|| "@".into())
}

fn parse_range_impl<T>(args: impl Fn() -> T) -> Option<(String, String)>
where
T: Iterator<Item = String>,
{
let from = parse_flag(&["-f", "--from"], args())?;
let to = parse_flag(&["-t", "--to"], args()).unwrap_or_else(|| "@".into());

Some((from, to))
}

#[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_range_impl(|| args.clone()),
Some(("foo".into(), "@".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_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_range_impl(|| args.clone()), None, "case: {case:?}");
}
}

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<String> {
if let Some(rev) = parse_flag(&["-r", "--revision"], std::env::args()) {
return Some(rev);
}
parse_flag(&["-f", "--from"], std::env::args())
}
}

fn all_files_from_rev(rev: String) -> Vec<CompletionCandidate> {
with_jj(|jj, _| {
let output = jj
.build()
.arg("file")
.arg("list")
.arg("--revision")
.arg(rev)
.output()
.map_err(user_error)?;
let stdout = String::from_utf8_lossy(&output.stdout);

Ok(stdout.lines().map(CompletionCandidate::new).collect())
})
}

fn modified_files_from_rev_with_jj_cmd(
rev: (String, Option<String>),
mut cmd: std::process::Command,
) -> Result<Vec<CompletionCandidate>, CommandError> {
cmd.arg("diff").arg("--summary");
match rev {
(rev, None) => cmd.arg("--revision").arg(rev),
(from, Some(to)) => cmd.arg("--from").arg(from).arg("--to").arg(to),
};
let output = cmd.output().map_err(user_error)?;
let stdout = String::from_utf8_lossy(&output.stdout);

Ok(stdout
.lines()
.map(|line| {
let (mode, path) = line
.split_once(' ')
.expect("diff --summary should contain a space between mode and path");
let help = match mode {
"M" => "Modified".into(),
"D" => "Deleted".into(),
"A" => "Added".into(),
"R" => "Renamed".into(),
"C" => "Copied".into(),
_ => format!("unknown mode: '{mode}'"),
};
CompletionCandidate::new(path).help(Some(help.into()))
})
.collect())
}

fn modified_files_from_rev(rev: (String, Option<String>)) -> Vec<CompletionCandidate> {
with_jj(|jj, _| modified_files_from_rev_with_jj_cmd(rev, jj.build()))
}

fn conflicted_files_from_rev(rev: &str) -> Vec<CompletionCandidate> {
with_jj(|jj, _| {
let output = jj
.build()
.arg("resolve")
.arg("--list")
.arg("--revision")
.arg(rev)
.output()
.map_err(user_error)?;
let stdout = String::from_utf8_lossy(&output.stdout);

Ok(stdout
.lines()
.filter_map(|line| line.split_whitespace().next())
.map(CompletionCandidate::new)
.collect())
})
}

pub fn modified_files() -> Vec<CompletionCandidate> {
modified_files_from_rev(("@".into(), None))
}

pub fn all_revision_files() -> Vec<CompletionCandidate> {
all_files_from_rev(parse::revision_or_wc())
}

pub fn modified_revision_files() -> Vec<CompletionCandidate> {
modified_files_from_rev((parse::revision_or_wc(), None))
}

pub fn modified_range_files() -> Vec<CompletionCandidate> {
match parse::range() {
Some((from, to)) => modified_files_from_rev((from, Some(to))),
None => modified_files_from_rev(("@".into(), None)),
}
}

pub fn modified_revision_or_range_files() -> Vec<CompletionCandidate> {
if let Some(rev) = parse::revision() {
return modified_files_from_rev((rev, None));
}
modified_range_files()
}

pub fn revision_conflicted_files() -> Vec<CompletionCandidate> {
conflicted_files_from_rev(&parse::revision_or_wc())
}

/// Specific function for completing file paths for `jj squash`
pub fn squash_revision_files() -> Vec<CompletionCandidate> {
let rev = parse::squash_revision().unwrap_or_else(|| "@".into());
modified_files_from_rev((rev, None))
}

/// Specific function for completing file paths for `jj interdiff`
pub fn interdiff_files() -> Vec<CompletionCandidate> {
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, None), jj.build())?;
res.extend(modified_files_from_rev_with_jj_cmd((to, None), jj.build())?);
Ok(res)
})
}

/// Shell out to jj during dynamic completion generation
///
/// In case of errors, print them and early return an empty vector.
Expand Down
Loading

0 comments on commit f7c52c4

Please sign in to comment.