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
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Check text files out with LF on every platform (Windows included):
# fixtures and golden files are compared byte for byte.
* text=auto eol=lf
24 changes: 24 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,30 @@ jobs:
- name: Run tests
run: cargo test --verbose

# The same build and tests on the other platforms (core#9): paths, line
# endings and executable lookup are where they're expected to differ.
test_cross_platform:
name: Test (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os:
- windows-latest
- macos-latest
- ubuntu-24.04-arm
steps:
- uses: actions/checkout@v7
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-wasip1
- uses: Swatinem/rust-cache@v2
- name: Build
run: cargo build --verbose
- name: Run tests
run: cargo test --verbose

lint:
name: Lint
runs-on: ubuntu-latest
Expand Down
30 changes: 28 additions & 2 deletions src/commands/conformance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -335,20 +335,37 @@ fn indent(text: &str) -> String {

/// A program as a shell would find it: a path as given, or a bare name on
/// `PATH`. The implementation runs with a cleared environment, so the name
/// is resolved here.
/// is resolved here. On Windows a name without an extension is also tried
/// with each of `PATHEXT`'s (`node` finds `node.exe`), as `cmd` would.
fn resolve(program: &str) -> Result<PathBuf> {
let path = Path::new(program);
if path.components().count() > 1 || path.is_absolute() {
return Ok(path.to_path_buf());
}
let names = executable_names(program);
std::env::var_os("PATH")
.into_iter()
.flat_map(|paths| std::env::split_paths(&paths).collect::<Vec<_>>())
.map(|dir| dir.join(program))
.flat_map(|dir| names.iter().map(move |n| dir.join(n)))
.find(|candidate| candidate.is_file())
.ok_or_else(|| miette!("`{program}` isn't on PATH: give its path"))
}

/// The file names `program` may have on `PATH`: itself, and on Windows (when
/// it has no extension) itself with each extension in `PATHEXT`.
fn executable_names(program: &str) -> Vec<String> {
let mut names = vec![program.to_string()];
if cfg!(windows) && Path::new(program).extension().is_none() {
let exts = std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".into());
names.extend(
exts.split(';')
.filter(|e| !e.is_empty())
.map(|e| format!("{program}{e}")),
);
}
names
}

/// The encodings in a runtime-style corpus: its profiles (all, or those
/// named), each with the cases of the features its facts are proven from.
fn from_corpus(dir: &Path, names: &[String]) -> Result<Vec<Subject>> {
Expand Down Expand Up @@ -494,6 +511,15 @@ fn unquote(s: &str) -> String {
mod tests {
use super::*;

/// A bare name is found on `PATH`, on Windows without its `.exe`.
#[test]
fn bare_names_resolve_on_path() {
let program = if cfg!(windows) { "cmd" } else { "sh" };
let found = resolve(program).expect("on PATH");
assert!(found.is_file(), "{}", found.display());
assert!(resolve("no-such-program-comline-test").is_err());
}

#[test]
fn vectors_sit_beside_the_profile() {
assert_eq!(
Expand Down
4 changes: 2 additions & 2 deletions src/commands/generate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,7 @@ fn generate_once(work_dir: &Path, overrides: &Overrides, dry_run: bool, list: bo
// can't show - the one thing `--list` has that
// the default doesn't.
let src_name = format!("{namespace}.{SCHEMA_EXTENSION}");
let shown = dest.strip_prefix(work_dir).unwrap_or(&dest).display();
let shown = ui::shown_path(work_dir, &dest);
let tag = if multi {
format!("[{}] ", gv.package_version)
} else {
Expand All @@ -426,7 +426,7 @@ fn generate_once(work_dir: &Path, overrides: &Overrides, dry_run: bool, list: bo
write_file(&dest, &f.contents)?;
}
if list {
let shown = dest.strip_prefix(work_dir).unwrap_or(&dest).display();
let shown = ui::shown_path(work_dir, &dest);
ui::detail(format!(" {} {} {shown}", f.path, ui::arrow()));
}
destinations.push(dest);
Expand Down
42 changes: 36 additions & 6 deletions src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,25 @@ pub fn at_path(p: &Path) -> String {
format!(" (in {shown})")
}

/// `path` as shown in output: relative to `base` when under it, with `/`
/// between its parts on every platform (as paths are written in
/// `comline.toml`), or as given otherwise. Without this a relative path
/// built from `out` and a layout reads `generated\rust/api.rs` on Windows.
pub fn shown_path(base: &Path, path: &Path) -> String {
match path.strip_prefix(base) {
Ok(rel) => slashed(rel),
Err(_) => path.display().to_string(),
}
}

/// A relative path's parts joined with `/`.
fn slashed(rel: &Path) -> String {
rel.components()
.map(|c| c.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/")
}

/// `"→"` normally, `"->"` under `--plain`. Used for schema/version mappings.
pub fn arrow() -> &'static str {
if plain() {
Expand Down Expand Up @@ -215,12 +234,10 @@ pub fn tree(base: &Path, paths: &[PathBuf]) {
}

let root = common_root(paths);
let shown_root = root.strip_prefix(base).unwrap_or(&root);
let mut label = if shown_root.as_os_str().is_empty() {
".".to_string()
} else {
shown_root.display().to_string()
};
let mut label = shown_path(base, &root);
if label.is_empty() {
label = ".".to_string();
}
label.push('/');
detail(format!(" {label}"));

Expand Down Expand Up @@ -304,3 +321,16 @@ fn print_level(rows: &[Vec<String>], prefix: &[String], indent: &str) {
}
}
}

#[cfg(test)]
mod path_tests {
use super::*;

#[test]
fn shown_paths_use_forward_slashes_under_the_base() {
let base = Path::new("project");
let path = base.join("generated").join("rust").join("api.rs");
assert_eq!(shown_path(base, &path), "generated/rust/api.rs");
assert_eq!(shown_path(base, base), "");
}
}
8 changes: 7 additions & 1 deletion tests/cli/add.rs
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,13 @@ fn adds_a_git_dependency_at_a_pinned_commit() {
git(&repo, &["add", "."]);
git(&repo, &["commit", "--quiet", "-m", "net"]);
let commit = git(&repo, &["rev-parse", "HEAD"]);
let uri = format!("file://{}", repo.display());
// A file URL: `/`-separated, and `file:///C:/...` on Windows.
let slashed = repo.display().to_string().replace('\\', "/");
let uri = if slashed.starts_with('/') {
format!("file://{slashed}")
} else {
format!("file:///{slashed}")
};

comline_cmd()
.current_dir(&project)
Expand Down
20 changes: 14 additions & 6 deletions tests/cli/doc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -602,19 +602,27 @@ fn recording_browser(dir: &Path) -> (std::path::PathBuf, std::path::PathBuf) {
}

/// What the recording browser was asked to open; it runs detached, so wait for it.
/// Canonical, as `doc open` passes it: on macOS the temp dir is a symlink
/// (`/var` -> `/private/var`), so compare against `canonical(..)`.
#[cfg(unix)]
fn opened(log: &Path) -> String {
fn opened(log: &Path) -> std::path::PathBuf {
for _ in 0..100 {
if let Ok(text) = fs::read_to_string(log) {
if text.ends_with('\n') {
return text.trim().to_string();
return canonical(Path::new(text.trim()));
}
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
panic!("nothing opened {}", log.display());
}

#[cfg(unix)]
fn canonical(path: &Path) -> std::path::PathBuf {
path.canonicalize()
.unwrap_or_else(|e| panic!("{}: {e}", path.display()))
}

#[cfg(unix)]
#[test]
fn open_renders_a_missing_output_first_and_opens_it() {
Expand All @@ -635,7 +643,7 @@ fn open_renders_a_missing_output_first_and_opens_it() {
.stderr(predicate::str::contains(index.display().to_string()));

assert!(index.exists());
assert_eq!(opened(&log), index.display().to_string());
assert_eq!(opened(&log), canonical(&index));
}

#[cfg(unix)]
Expand All @@ -660,7 +668,7 @@ fn open_prefers_rendered_html_when_no_format_is_configured() {
.assert()
.success()
.stderr(predicate::str::contains(index.display().to_string()));
assert_eq!(opened(&log), index.display().to_string());
assert_eq!(opened(&log), canonical(&index));
}

#[cfg(unix)]
Expand Down Expand Up @@ -691,7 +699,7 @@ fn open_uses_existing_output_without_rendering_and_respects_out() {
.stderr(predicate::str::contains("Documented").not());

assert_eq!(read(&index), "<!-- kept -->");
assert_eq!(opened(&log), index.display().to_string());
assert_eq!(opened(&log), canonical(&index));
}

#[cfg(unix)]
Expand All @@ -709,7 +717,7 @@ fn doc_open_flag_renders_then_opens_the_first_format() {
.success();
assert_eq!(
opened(&log),
shop.join("docs/api/html/index.html").display().to_string()
canonical(&shop.join("docs/api/html/index.html"))
);
}

Expand Down
Loading