diff --git a/Cargo.lock b/Cargo.lock index e11b76ff0126c..62f8eb8dcd48b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1736,6 +1736,7 @@ version = "0.75.0" dependencies = [ "cow-utils", "oxc-miette", + "percent-encoding", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 45ffcfa754f27..28721a0f8c2b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -206,6 +206,7 @@ nonmax = "0.5.5" num-bigint = "0.4.6" num-traits = "0.2.19" papaya = "0.2.1" +percent-encoding = "2.3.1" petgraph = { version = "0.8.2", default-features = false } phf = "0.12.0" phf_codegen = "0.12.0" diff --git a/crates/oxc_diagnostics/Cargo.toml b/crates/oxc_diagnostics/Cargo.toml index 9cb2cedcaba60..885847a56ed96 100644 --- a/crates/oxc_diagnostics/Cargo.toml +++ b/crates/oxc_diagnostics/Cargo.toml @@ -21,3 +21,4 @@ doctest = false [dependencies] cow-utils = { workspace = true } miette = { workspace = true } +percent-encoding = { workspace = true } diff --git a/crates/oxc_diagnostics/src/service.rs b/crates/oxc_diagnostics/src/service.rs index 7cd6e7590433a..35bda164883e6 100644 --- a/crates/oxc_diagnostics/src/service.rs +++ b/crates/oxc_diagnostics/src/service.rs @@ -1,4 +1,5 @@ use std::{ + borrow::Cow, io::{ErrorKind, Write}, path::{Path, PathBuf}, sync::{Arc, mpsc}, @@ -6,6 +7,9 @@ use std::{ use cow_utils::CowUtils; use miette::LabeledSpan; +use percent_encoding::AsciiSet; +#[cfg(not(windows))] +use std::fs::canonicalize as strict_canonicalize; use crate::{ Error, NamedSource, OxcDiagnostic, Severity, @@ -128,20 +132,28 @@ impl DiagnosticService { /// Wrap [diagnostics] with the source code and path, converting them into [Error]s. /// /// [diagnostics]: OxcDiagnostic - pub fn wrap_diagnostics>( + pub fn wrap_diagnostics, P: AsRef>( + cwd: C, path: P, source_text: &str, source_start: u32, diagnostics: Vec, - ) -> (PathBuf, Vec) { - let path = path.as_ref(); - let path_display = path.to_string_lossy(); - // replace windows \ path separator with posix style one - // reflects what eslint is outputting - let path_display = path_display.cow_replace('\\', "/"); + ) -> Vec { + // TODO: This causes snapshots to fail when running tests through a JetBrains terminal. + let is_jetbrains = + std::env::var("TERMINAL_EMULATOR").is_ok_and(|x| x.eq("JetBrains-JediTerm")); + + let path_ref = path.as_ref(); + let path_display = if is_jetbrains { from_file_path(path_ref) } else { None } + .unwrap_or_else(|| { + let relative_path = + path_ref.strip_prefix(cwd).unwrap_or(path_ref).to_string_lossy(); + let normalized_path = relative_path.cow_replace('\\', "/"); + normalized_path.to_string() + }); let source = Arc::new(NamedSource::new(path_display, source_text.to_owned())); - let diagnostics = diagnostics + diagnostics .into_iter() .map(|diagnostic| { if source_start == 0 { @@ -166,8 +178,7 @@ impl DiagnosticService { } } }) - .collect(); - (path.to_path_buf(), diagnostics) + .collect() } /// # Panics @@ -260,3 +271,153 @@ impl DiagnosticService { } } } + +// The following from_file_path and strict_canonicalize implementations are from tower-lsp-community/tower-lsp-server +// available under the MIT License or Apache 2.0 License. +// +// Copyright (c) 2023 Eyal Kalderon +// https://github.com/tower-lsp-community/tower-lsp-server/blob/85506ddcbd108c514438e0b62e0eb858c812adcf/src/uri_ext.rs + +const ASCII_SET: AsciiSet = + // RFC3986 allows only alphanumeric characters, `-`, `.`, `_`, and `~` in the path. + percent_encoding::NON_ALPHANUMERIC + .remove(b'-') + .remove(b'.') + .remove(b'_') + .remove(b'~') + // we do not want path separators to be percent-encoded + .remove(b'/'); + +fn from_file_path>(path: A) -> Option { + let path = path.as_ref(); + + let fragment = if path.is_absolute() { + Cow::Borrowed(path) + } else { + match strict_canonicalize(path) { + Ok(path) => Cow::Owned(path), + Err(_) => return None, + } + }; + + if cfg!(windows) { + // we want to write a triple-slash path for Windows paths + // it's a shorthand for `file://localhost/C:/Windows` with the `localhost` omitted. + let mut components = fragment.components(); + let drive = components.next(); + + Some(format!( + "file:///{:?}:/{}", + drive.unwrap().as_os_str().to_string_lossy(), + percent_encoding::utf8_percent_encode( + // Skip the drive character. + &components.collect::().to_string_lossy().cow_replace('\\', "/"), + &ASCII_SET + ) + )) + } else { + Some(format!( + "file://{}", + percent_encoding::utf8_percent_encode(&fragment.to_string_lossy(), &ASCII_SET) + )) + } +} + +/// On Windows, rewrites the wide path prefix `\\?\C:` to `C:` +/// Source: https://stackoverflow.com/a/70970317 +#[inline] +#[cfg(windows)] +fn strict_canonicalize>(path: P) -> std::io::Result { + use std::io; + + fn impl_(path: PathBuf) -> std::io::Result { + let head = path.components().next().ok_or(io::Error::other("empty path"))?; + let disk_; + let head = if let std::path::Component::Prefix(prefix) = head { + if let std::path::Prefix::VerbatimDisk(disk) = prefix.kind() { + disk_ = format!("{}:", disk as char); + Path::new(&disk_) + .components() + .next() + .ok_or(io::Error::other("failed to parse disk component"))? + } else { + head + } + } else { + head + }; + Ok(std::iter::once(head).chain(path.components().skip(1)).collect()) + } + + let canon = std::fs::canonicalize(path)?; + impl_(canon) +} + +#[cfg(test)] +mod tests { + use crate::service::from_file_path; + use std::path::PathBuf; + + fn with_schema(path: &str) -> String { + const EXPECTED_SCHEMA: &str = if cfg!(windows) { "file:///" } else { "file://" }; + format!("{EXPECTED_SCHEMA}{path}") + } + + #[test] + #[cfg(windows)] + fn test_idempotent_canonicalization() { + let lhs = strict_canonicalize(Path::new(".")).unwrap(); + let rhs = strict_canonicalize(&lhs).unwrap(); + assert_eq!(lhs, rhs); + } + + #[test] + #[cfg(unix)] + fn test_path_to_uri() { + let paths = [ + PathBuf::from("/some/path/to/file.txt"), + PathBuf::from("/some/path/to/file with spaces.txt"), + PathBuf::from("/some/path/[[...rest]]/file.txt"), + PathBuf::from("/some/path/to/файл.txt"), + PathBuf::from("/some/path/to/文件.txt"), + ]; + + let expected = [ + with_schema("/some/path/to/file.txt"), + with_schema("/some/path/to/file%20with%20spaces.txt"), + with_schema("/some/path/%5B%5B...rest%5D%5D/file.txt"), + with_schema("/some/path/to/%D1%84%D0%B0%D0%B9%D0%BB.txt"), + with_schema("/some/path/to/%E6%96%87%E4%BB%B6.txt"), + ]; + + for (path, expected) in paths.iter().zip(expected) { + let uri = from_file_path(path).unwrap(); + assert_eq!(uri.to_string(), expected); + } + } + + #[test] + #[cfg(windows)] + fn test_path_to_uri_windows() { + let paths = [ + PathBuf::from("C:\\some\\path\\to\\file.txt"), + PathBuf::from("C:\\some\\path\\to\\file with spaces.txt"), + PathBuf::from("C:\\some\\path\\[[...rest]]\\file.txt"), + PathBuf::from("C:\\some\\path\\to\\файл.txt"), + PathBuf::from("C:\\some\\path\\to\\文件.txt"), + ]; + + let expected = [ + with_schema("C:/some/path/to/file.txt"), + with_schema("C:/some/path/to/file%20with%20spaces.txt"), + with_schema("C:/some/path/%5B%5B...rest%5D%5D/file.txt"), + with_schema("C:/some/path/to/%D1%84%D0%B0%D0%B9%D0%BB.txt"), + with_schema("C:/some/path/to/%E6%96%87%E4%BB%B6.txt"), + ]; + + for (path, expected) in paths.iter().zip(expected) { + let uri = Uri::from_file_path(path).unwrap(); + assert_eq!(uri.to_string(), expected); + } + } +} diff --git a/crates/oxc_linter/src/service/runtime.rs b/crates/oxc_linter/src/service/runtime.rs index e022fe09800ab..066cdc5eecfe1 100644 --- a/crates/oxc_linter/src/service/runtime.rs +++ b/crates/oxc_linter/src/service/runtime.rs @@ -550,14 +550,14 @@ impl<'l> Runtime<'l> { if !messages.is_empty() { let errors = messages.into_iter().map(Into::into).collect(); - let path = path.strip_prefix(&me.cwd).unwrap_or(path); let diagnostics = DiagnosticService::wrap_diagnostics( + &me.cwd, path, dep.source_text, section.source.start, errors, ); - tx_error.send(Some(diagnostics)).unwrap(); + tx_error.send(Some((path.to_path_buf(), diagnostics))).unwrap(); } } // If the new source text is owned, that means it was modified,