Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

37 changes: 34 additions & 3 deletions cli/src/cli/complete_word.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,14 +59,37 @@ pub fn candidates(
cword: usize,
shell: &str,
) -> miette::Result<Vec<(String, String)>> {
Ok(answer(spec, words, cword, shell)?.candidates)
}

/// The reference implementation's candidates and whether they came from its path fallback.
///
/// `candidates` keeps returning the concrete paths the CLI has always printed. Conformance
/// needs the extra bit because a portable corpus can say "files belong here" but cannot pin
/// whichever files happen to be in the checkout running it.
#[derive(Debug, PartialEq, Eq)]
pub struct CandidateAnswer {
/// The concrete values the CLI would print for the shell.
pub candidates: Vec<(String, String)>,
/// Whether the CLI generated those values by scanning the filesystem.
pub files: bool,
}

/// Complete a partial command line while preserving path-fallback metadata.
pub fn answer(
spec: &Spec,
words: &[String],
cword: usize,
shell: &str,
) -> miette::Result<CandidateAnswer> {
CompleteWord {
words: words.to_vec(),
file: None,
spec: None,
cword: Some(cword),
shell: shell.to_string(),
}
.complete_word(spec)
.complete_word_answer(spec)
}

impl CompleteWord {
Expand Down Expand Up @@ -111,6 +134,10 @@ impl CompleteWord {
}

pub fn complete_word(&self, spec: &Spec) -> miette::Result<Vec<(String, String)>> {
Ok(self.complete_word_answer(spec)?.candidates)
}

fn complete_word_answer(&self, spec: &Spec) -> miette::Result<CandidateAnswer> {
let cword = self.cword.unwrap_or(self.words.len().max(1) - 1);
let ctoken = self.words.get(cword).cloned().unwrap_or_default();
let words: Vec<_> = self.words.iter().take(cword).cloned().collect();
Expand Down Expand Up @@ -233,13 +260,17 @@ impl CompleteWord {
// flag. Past a `--` a dash-prefixed word is not a flag but a value, so a path like
// `-input` still gets completed there.
let looks_like_a_flag = flags_possible && ctoken.starts_with('-');
if choices.is_empty() && !looks_like_a_flag && !has_explicit_choices {
let files = choices.is_empty() && !looks_like_a_flag && !has_explicit_choices;
if files {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let files = self.complete_path(&cwd, &ctoken, |_| true);
choices = files.into_iter().map(|n| (n, String::new())).collect();
}
trace!("choices: {}", choices.iter().map(|(c, _)| c).join(", "));
Ok(choices)
Ok(CandidateAnswer {
candidates: choices,
files,
})
}

fn complete_subcommands(&self, cmd: &SpecCommand, ctoken: &str) -> Vec<(String, String)> {
Expand Down
4 changes: 3 additions & 1 deletion cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ use miette::Result;
///
/// Re-exported rather than the whole `cli` module: the conformance comparison needs this one
/// answer, and nothing else in here is a promise to anybody.
pub use cli::complete_word::candidates as complete_candidates;
pub use cli::complete_word::{
answer as complete_answer, candidates as complete_candidates, CandidateAnswer,
};
pub use cli::Cli;

mod cli;
Expand Down
1 change: 1 addition & 0 deletions conformance/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
usage-argv = { workspace = true, features = ["spec", "complete", "diagnostics"] }
usage-config = { workspace = true }
usage-cli = { workspace = true }
usage-lib = { workspace = true }

[dev-dependencies]
Expand Down
208 changes: 208 additions & 0 deletions conformance/src/complete.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
//! The completion corpus: its format, a loader, and the runner.
//!
//! The argv corpus pins what a command line *binds*; `render` pins what a spec *reads as*. This
//! one pins what could go where the cursor is.
//!
//! # Why it exists
//!
//! Completion was the one area with three implementations and no shared fixture.
//! `argv/src/complete.rs` has its own unit tests, `cli/src/cli/complete_word.rs` has its own, and
//! a Go implementation landed with a third set. Three sets of tests written against three
//! readings of the same rules is the arrangement that produced every drift this project has had
//! to chase: the help renderers agreed on mise and differed on five of the other six jdx CLIs
//! until one fixture held them together.
//!
//! It also closes two thirds of PLAN.md's "not covered by the corpus yet" — completion parsing,
//! which is `parse_partial` over deliberately incomplete input, and restart tokens, which only
//! matter at a cursor. Mounts stay uncovered on purpose: resolving one *runs a command*, which a
//! corpus cannot do hermetically.
//!
//! # Every expectation here was measured first
//!
//! Two of the first vectors written asserted behaviour neither implementation has — a restart
//! token offering itself, and an attached `--format=j` completing its value. Both were plausible
//! and both were invented. The corpus is the definition of correct, so a vector may not assert a
//! rule on one author's opinion: what goes in is measured on both implementations, and where they
//! agree on something that looks wrong, the vector says so rather than pinning it silently.

use std::collections::BTreeSet;
use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};
use usage::Spec;
use usage_argv::complete::{complete, split, Shell};

use crate::tables;

/// One `corpus/complete/*.json` file: a themed group of vectors.
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct VectorFile {
/// What this file covers, e.g. `"positions"`.
pub section: String,
/// What the group establishes, and anything a reader needs in order to judge whether these
/// expectations are the right ones.
pub about: String,
pub vectors: Vec<Vector>,
}

/// A single case: ask `spec` what completes at the cursor in `line` and you must get `expect`.
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Vector {
/// Stable identifier, unique across the corpus. Failures quote it, so renaming one breaks
/// anybody tracking known failures.
pub id: String,
/// What this vector pins down, in one sentence.
pub doc: String,
/// A complete spec, as KDL.
pub spec: String,
/// The command line as typed, program name included.
pub line: String,
/// Where the cursor is, as a byte offset into `line`. Defaults to its end, which is where a
/// shell asks from; a vector completing mid-line says so.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cursor: Option<usize>,
pub expect: Expect,
/// Whether `usage-cli`, the reference implementation, agrees with `expect`.
#[serde(default)]
pub reference: Reference,
}

impl Vector {
fn cursor(&self) -> usize {
self.cursor.unwrap_or(self.line.len())
}
}

/// What completion must produce.
#[derive(Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct Expect {
/// The words offered. Order-insensitive unless `ordered`: which order a shell shows them in
/// is the shell's business, and two implementations sorting differently is not a
/// disagreement about what completes.
#[serde(default)]
pub candidates: Vec<String>,
/// Whether the answer defers to the shell's own path completion. A vector cannot state what
/// the filesystem holds without becoming a test of the machine it runs on, so it states that
/// paths belong here and stops.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub files: bool,
/// Whether the order of `candidates` is itself the claim.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub ordered: bool,
}

/// Whether the reference implementation matches a vector's expectation.
#[derive(Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Reference {
/// `usage-cli` produces exactly `expect`.
#[default]
Agrees,
/// It produces something else. The note says what, and why the corpus keeps its own
/// expectation regardless.
Diverges(String),
}

/// What one implementation offered.
#[derive(Debug, PartialEq, Eq)]
pub struct Offered {
pub candidates: Vec<String>,
pub files: bool,
}

impl Offered {
/// Whether this satisfies a vector. Sorted unless the vector claims an order, so a failure
/// is about the set rather than about two implementations' sort stability.
pub fn matches(&self, expect: &Expect) -> bool {
if self.files != expect.files {
return false;
}
if expect.ordered {
return self.candidates == expect.candidates;
}
let mine: BTreeSet<&str> = self.candidates.iter().map(String::as_str).collect();
let theirs: BTreeSet<&str> = expect.candidates.iter().map(String::as_str).collect();
mine == theirs
}
}

/// What `usage-argv` offers for a vector.
///
/// Its tables are built from the vector's KDL by `tables::build_spec`, which is the same bridge
/// the render corpus uses: the alternative is a Rust type per vector, and a corpus a reader
/// cannot extend is not one.
pub fn run(vector: &Vector) -> Result<Offered, String> {
let spec: Spec = vector
.spec
.parse()
.map_err(|e| format!("the spec would not load: {e}"))?;
let tables = tables::build_spec(&spec);
// Bash because the split has to be *some* shell and this one has no quoting rules of its
// own to fold in; which shell renders the answer is `complete::render`'s business, tested
// separately.
let at = split(&vector.line, vector.cursor(), Shell::Bash);
let answer = complete(tables, &at);
Ok(Offered {
candidates: answer.candidates.iter().map(|c| c.value.clone()).collect(),
files: answer.files.is_some(),
})
}

/// What the reference `usage-cli` implementation offers for a vector.
pub fn reference(vector: &Vector) -> Result<Offered, String> {
let spec: Spec = vector
.spec
.parse()
.map_err(|e| format!("the spec would not load: {e}"))?;
let at = split(&vector.line, vector.cursor(), Shell::Bash);
let answer = usage_cli::complete_answer(&spec, &at.words, at.cword, "bash")
.map_err(|e| format!("the reference would not complete it: {e}"))?;
Ok(Offered {
candidates: if answer.files {
Vec::new()
} else {
answer
.candidates
.into_iter()
.map(|(value, _)| value)
.collect()
},
files: answer.files,
})
}

/// Every vector in the corpus, in file order.
pub fn load() -> Vec<(String, Vector)> {
let mut out = Vec::new();
let mut files: Vec<PathBuf> = std::fs::read_dir(dir())
.expect("the completion corpus should be readable")
.map(|entry| {
entry
.expect("a completion corpus directory entry should be readable")
.path()
})
.filter(|p| p.extension().is_some_and(|e| e == "json"))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
.collect();
files.sort();
for path in files {
let text = std::fs::read_to_string(&path).expect("a corpus file should be readable");
let file: VectorFile = serde_json::from_str(&text)
.unwrap_or_else(|e| panic!("{} is not a valid corpus file: {e}", path.display()));
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default()
.to_string();
for vector in file.vectors {
out.push((name.clone(), vector));
}
}
out
}

fn dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../corpus/complete")
}
1 change: 1 addition & 0 deletions conformance/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};

pub mod argv;
pub mod complete;
pub mod config;
pub mod reference;
pub mod render;
Expand Down
Loading