diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..79ba41f --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index faca40a..b34aaf3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/src/commands/conformance.rs b/src/commands/conformance.rs index 7c8b33c..1a27ca2 100644 --- a/src/commands/conformance.rs +++ b/src/commands/conformance.rs @@ -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 { 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::>()) - .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 { + 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> { @@ -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!( diff --git a/src/commands/generate.rs b/src/commands/generate.rs index bd65d35..d33a8a2 100644 --- a/src/commands/generate.rs +++ b/src/commands/generate.rs @@ -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 { @@ -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); diff --git a/src/ui.rs b/src/ui.rs index 2bafc4f..5bb1af9 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -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::>() + .join("/") +} + /// `"→"` normally, `"->"` under `--plain`. Used for schema/version mappings. pub fn arrow() -> &'static str { if plain() { @@ -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}")); @@ -304,3 +321,16 @@ fn print_level(rows: &[Vec], 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), ""); + } +} diff --git a/tests/cli/add.rs b/tests/cli/add.rs index 110d34a..9b42d94 100644 --- a/tests/cli/add.rs +++ b/tests/cli/add.rs @@ -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) diff --git a/tests/cli/doc.rs b/tests/cli/doc.rs index e40627d..bf18165 100644 --- a/tests/cli/doc.rs +++ b/tests/cli/doc.rs @@ -602,12 +602,14 @@ 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)); @@ -615,6 +617,12 @@ fn opened(log: &Path) -> String { 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() { @@ -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)] @@ -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)] @@ -691,7 +699,7 @@ fn open_uses_existing_output_without_rendering_and_respects_out() { .stderr(predicate::str::contains("Documented").not()); assert_eq!(read(&index), ""); - assert_eq!(opened(&log), index.display().to_string()); + assert_eq!(opened(&log), canonical(&index)); } #[cfg(unix)] @@ -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")) ); }