Skip to content

Commit 37584ee

Browse files
committed
fix(oxc_linter): Make linter file paths clickable within JetBrains terminals
JetBrains terminals don't work reliably with relative paths to hyperlink to the file. Outputting the full file URL instead works consistently.
1 parent 190e390 commit 37584ee

File tree

5 files changed

+176
-12
lines changed

5 files changed

+176
-12
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ nonmax = "0.5.5"
204204
num-bigint = "0.4.6"
205205
num-traits = "0.2.19"
206206
papaya = "0.2.1"
207+
percent-encoding = "2.3.1"
207208
petgraph = { version = "0.8.2", default-features = false }
208209
phf = "0.12.0"
209210
phf_codegen = "0.12.0"

crates/oxc_diagnostics/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,4 @@ doctest = false
2121
[dependencies]
2222
cow-utils = { workspace = true }
2323
miette = { workspace = true }
24+
percent-encoding = { workspace = true }

crates/oxc_diagnostics/src/service.rs

Lines changed: 171 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
use std::{
2+
borrow::Cow,
23
io::{ErrorKind, Write},
34
path::{Path, PathBuf},
45
sync::{Arc, mpsc},
56
};
67

78
use cow_utils::CowUtils;
89
use miette::LabeledSpan;
10+
use percent_encoding::AsciiSet;
11+
#[cfg(not(windows))]
12+
use std::fs::canonicalize as strict_canonicalize;
913

1014
use crate::{
1115
Error, NamedSource, OxcDiagnostic, Severity,
@@ -128,20 +132,28 @@ impl DiagnosticService {
128132
/// Wrap [diagnostics] with the source code and path, converting them into [Error]s.
129133
///
130134
/// [diagnostics]: OxcDiagnostic
131-
pub fn wrap_diagnostics<P: AsRef<Path>>(
135+
pub fn wrap_diagnostics<C: AsRef<Path>, P: AsRef<Path>>(
136+
cwd: C,
132137
path: P,
133138
source_text: &str,
134139
source_start: u32,
135140
diagnostics: Vec<OxcDiagnostic>,
136-
) -> (PathBuf, Vec<Error>) {
137-
let path = path.as_ref();
138-
let path_display = path.to_string_lossy();
139-
// replace windows \ path separator with posix style one
140-
// reflects what eslint is outputting
141-
let path_display = path_display.cow_replace('\\', "/");
141+
) -> Vec<Error> {
142+
// TODO: This causes snapshots to fail when running tests through a JetBrains terminal.
143+
let is_jetbrains =
144+
std::env::var("TERMINAL_EMULATOR").is_ok_and(|x| x.eq("JetBrains-JediTerm"));
145+
146+
let path_ref = path.as_ref();
147+
let path_display = if is_jetbrains { from_file_path(path_ref) } else { None }
148+
.unwrap_or_else(|| {
149+
let relative_path =
150+
path_ref.strip_prefix(cwd).unwrap_or(path_ref).to_string_lossy();
151+
let normalized_path = relative_path.cow_replace('\\', "/");
152+
normalized_path.to_string()
153+
});
142154

143155
let source = Arc::new(NamedSource::new(path_display, source_text.to_owned()));
144-
let diagnostics = diagnostics
156+
diagnostics
145157
.into_iter()
146158
.map(|diagnostic| {
147159
if source_start == 0 {
@@ -166,8 +178,7 @@ impl DiagnosticService {
166178
}
167179
}
168180
})
169-
.collect();
170-
(path.to_path_buf(), diagnostics)
181+
.collect()
171182
}
172183

173184
/// # Panics
@@ -260,3 +271,153 @@ impl DiagnosticService {
260271
}
261272
}
262273
}
274+
275+
// The following from_file_path and strict_canonicalize implementations are from tower-lsp-community/tower-lsp-server
276+
// available under the MIT License or Apache 2.0 License.
277+
//
278+
// Copyright (c) 2023 Eyal Kalderon
279+
// https://github.com/tower-lsp-community/tower-lsp-server/blob/85506ddcbd108c514438e0b62e0eb858c812adcf/src/uri_ext.rs
280+
281+
const ASCII_SET: AsciiSet =
282+
// RFC3986 allows only alphanumeric characters, `-`, `.`, `_`, and `~` in the path.
283+
percent_encoding::NON_ALPHANUMERIC
284+
.remove(b'-')
285+
.remove(b'.')
286+
.remove(b'_')
287+
.remove(b'~')
288+
// we do not want path separators to be percent-encoded
289+
.remove(b'/');
290+
291+
fn from_file_path<A: AsRef<Path>>(path: A) -> Option<String> {
292+
let path = path.as_ref();
293+
294+
let fragment = if path.is_absolute() {
295+
Cow::Borrowed(path)
296+
} else {
297+
match strict_canonicalize(path) {
298+
Ok(path) => Cow::Owned(path),
299+
Err(_) => return None,
300+
}
301+
};
302+
303+
if cfg!(windows) {
304+
// we want to write a triple-slash path for Windows paths
305+
// it's a shorthand for `file://localhost/C:/Windows` with the `localhost` omitted.
306+
let mut components = fragment.components();
307+
let drive = components.next();
308+
309+
Some(format!(
310+
"file:///{:?}:/{}",
311+
drive.unwrap().as_os_str().to_string_lossy(),
312+
percent_encoding::utf8_percent_encode(
313+
// Skip the drive character.
314+
&components.collect::<PathBuf>().to_string_lossy().cow_replace('\\', "/"),
315+
&ASCII_SET
316+
)
317+
))
318+
} else {
319+
Some(format!(
320+
"file://{}",
321+
percent_encoding::utf8_percent_encode(&fragment.to_string_lossy(), &ASCII_SET)
322+
))
323+
}
324+
}
325+
326+
/// On Windows, rewrites the wide path prefix `\\?\C:` to `C:`
327+
/// Source: https://stackoverflow.com/a/70970317
328+
#[inline]
329+
#[cfg(windows)]
330+
fn strict_canonicalize<P: AsRef<Path>>(path: P) -> std::io::Result<PathBuf> {
331+
use std::io;
332+
333+
fn impl_(path: PathBuf) -> std::io::Result<PathBuf> {
334+
let head = path.components().next().ok_or(io::Error::other("empty path"))?;
335+
let disk_;
336+
let head = if let std::path::Component::Prefix(prefix) = head {
337+
if let std::path::Prefix::VerbatimDisk(disk) = prefix.kind() {
338+
disk_ = format!("{}:", disk as char);
339+
Path::new(&disk_)
340+
.components()
341+
.next()
342+
.ok_or(io::Error::other("failed to parse disk component"))?
343+
} else {
344+
head
345+
}
346+
} else {
347+
head
348+
};
349+
Ok(std::iter::once(head).chain(path.components().skip(1)).collect())
350+
}
351+
352+
let canon = std::fs::canonicalize(path)?;
353+
impl_(canon)
354+
}
355+
356+
#[cfg(test)]
357+
mod tests {
358+
use crate::service::from_file_path;
359+
use std::path::PathBuf;
360+
361+
fn with_schema(path: &str) -> String {
362+
const EXPECTED_SCHEMA: &str = if cfg!(windows) { "file:///" } else { "file://" };
363+
format!("{EXPECTED_SCHEMA}{path}")
364+
}
365+
366+
#[test]
367+
#[cfg(windows)]
368+
fn test_idempotent_canonicalization() {
369+
let lhs = strict_canonicalize(Path::new(".")).unwrap();
370+
let rhs = strict_canonicalize(&lhs).unwrap();
371+
assert_eq!(lhs, rhs);
372+
}
373+
374+
#[test]
375+
#[cfg(unix)]
376+
fn test_path_to_uri() {
377+
let paths = [
378+
PathBuf::from("/some/path/to/file.txt"),
379+
PathBuf::from("/some/path/to/file with spaces.txt"),
380+
PathBuf::from("/some/path/[[...rest]]/file.txt"),
381+
PathBuf::from("/some/path/to/файл.txt"),
382+
PathBuf::from("/some/path/to/文件.txt"),
383+
];
384+
385+
let expected = [
386+
with_schema("/some/path/to/file.txt"),
387+
with_schema("/some/path/to/file%20with%20spaces.txt"),
388+
with_schema("/some/path/%5B%5B...rest%5D%5D/file.txt"),
389+
with_schema("/some/path/to/%D1%84%D0%B0%D0%B9%D0%BB.txt"),
390+
with_schema("/some/path/to/%E6%96%87%E4%BB%B6.txt"),
391+
];
392+
393+
for (path, expected) in paths.iter().zip(expected) {
394+
let uri = from_file_path(path).unwrap();
395+
assert_eq!(uri.to_string(), expected);
396+
}
397+
}
398+
399+
#[test]
400+
#[cfg(windows)]
401+
fn test_path_to_uri_windows() {
402+
let paths = [
403+
PathBuf::from("C:\\some\\path\\to\\file.txt"),
404+
PathBuf::from("C:\\some\\path\\to\\file with spaces.txt"),
405+
PathBuf::from("C:\\some\\path\\[[...rest]]\\file.txt"),
406+
PathBuf::from("C:\\some\\path\\to\\файл.txt"),
407+
PathBuf::from("C:\\some\\path\\to\\文件.txt"),
408+
];
409+
410+
let expected = [
411+
with_schema("C:/some/path/to/file.txt"),
412+
with_schema("C:/some/path/to/file%20with%20spaces.txt"),
413+
with_schema("C:/some/path/%5B%5B...rest%5D%5D/file.txt"),
414+
with_schema("C:/some/path/to/%D1%84%D0%B0%D0%B9%D0%BB.txt"),
415+
with_schema("C:/some/path/to/%E6%96%87%E4%BB%B6.txt"),
416+
];
417+
418+
for (path, expected) in paths.iter().zip(expected) {
419+
let uri = Uri::from_file_path(path).unwrap();
420+
assert_eq!(uri.to_string(), expected);
421+
}
422+
}
423+
}

crates/oxc_linter/src/service/runtime.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -550,14 +550,14 @@ impl<'l> Runtime<'l> {
550550

551551
if !messages.is_empty() {
552552
let errors = messages.into_iter().map(Into::into).collect();
553-
let path = path.strip_prefix(&me.cwd).unwrap_or(path);
554553
let diagnostics = DiagnosticService::wrap_diagnostics(
554+
&me.cwd,
555555
path,
556556
dep.source_text,
557557
section.source.start,
558558
errors,
559559
);
560-
tx_error.send(Some(diagnostics)).unwrap();
560+
tx_error.send(Some((path.to_path_buf(), diagnostics))).unwrap();
561561
}
562562
}
563563
// If the new source text is owned, that means it was modified,

0 commit comments

Comments
 (0)