oxc_diagnostics/
service.rs

1use std::{
2    borrow::Cow,
3    io::{ErrorKind, Write},
4    path::{Path, PathBuf},
5    sync::{Arc, mpsc},
6};
7
8use cow_utils::CowUtils;
9use percent_encoding::AsciiSet;
10#[cfg(not(windows))]
11use std::fs::canonicalize as strict_canonicalize;
12
13use crate::{
14    Error, NamedSource, OxcDiagnostic, Severity,
15    reporter::{DiagnosticReporter, DiagnosticResult},
16};
17
18pub type DiagnosticTuple = (PathBuf, Vec<Error>);
19pub type DiagnosticSender = mpsc::Sender<DiagnosticTuple>;
20pub type DiagnosticReceiver = mpsc::Receiver<DiagnosticTuple>;
21
22/// Listens for diagnostics sent over a [channel](DiagnosticSender) by some job, and
23/// formats/reports them to the user.
24///
25/// [`DiagnosticService`] is designed to support multi-threaded jobs that may produce
26/// reports. These jobs can send [messages](DiagnosticTuple) to the service over its
27/// multi-producer, single-consumer channel.
28///
29/// # Example
30/// ```rust
31/// use std::{path::PathBuf, thread};
32/// use oxc_diagnostics::{Error, OxcDiagnostic, DiagnosticService, GraphicalReportHandler};
33///
34/// // Create a service with a graphical reporter
35/// let (mut service, sender) = DiagnosticService::new(Box::new(GraphicalReportHandler::new()));
36///
37/// // Spawn a thread that does work and reports diagnostics
38/// thread::spawn(move || {
39///     sender.send((
40///         PathBuf::from("file.txt"),
41///         vec![Error::new(OxcDiagnostic::error("Something went wrong"))],
42///     ));
43///
44///     // The service will stop listening when all senders are dropped.
45///     // No explicit termination signal is needed.
46/// });
47///
48/// // Listen for and process messages
49/// service.run()
50/// ```
51pub struct DiagnosticService {
52    reporter: Box<dyn DiagnosticReporter>,
53
54    /// Disable reporting on warnings, only errors are reported
55    quiet: bool,
56
57    /// Do not display any diagnostics
58    silent: bool,
59
60    /// Specify a warning threshold,
61    /// which can be used to force exit with an error status if there are too many warning-level rule violations in your project
62    max_warnings: Option<usize>,
63
64    receiver: DiagnosticReceiver,
65}
66
67impl DiagnosticService {
68    /// Create a new [`DiagnosticService`] that will render and report diagnostics using the
69    /// provided [`DiagnosticReporter`].
70    pub fn new(reporter: Box<dyn DiagnosticReporter>) -> (Self, DiagnosticSender) {
71        let (sender, receiver) = mpsc::channel();
72        (Self { reporter, quiet: false, silent: false, max_warnings: None, receiver }, sender)
73    }
74
75    /// Set to `true` to only report errors and ignore warnings.
76    ///
77    /// Use [`with_silent`](DiagnosticService::with_silent) to disable reporting entirely.
78    ///
79    /// Default: `false`
80    #[must_use]
81    pub fn with_quiet(mut self, yes: bool) -> Self {
82        self.quiet = yes;
83        self
84    }
85
86    /// Set to `true` to disable reporting entirely.
87    ///
88    /// Use [`with_quiet`](DiagnosticService::with_quiet) to only disable reporting on warnings.
89    ///
90    /// Default is `false`.
91    #[must_use]
92    pub fn with_silent(mut self, yes: bool) -> Self {
93        self.silent = yes;
94        self
95    }
96
97    /// Specify a warning threshold, which can be used to force exit with an error status if there
98    /// are too many warning-level rule violations in your project. Errors do not count towards the
99    /// warning limit.
100    ///
101    /// Use [`DiagnosticResult`](DiagnosticResult::max_warnings_exceeded) to check if too
102    /// many warnings have been received.
103    ///
104    /// Default: [`None`]
105    #[must_use]
106    pub fn with_max_warnings(mut self, max_warnings: Option<usize>) -> Self {
107        self.max_warnings = max_warnings;
108        self
109    }
110
111    /// Check if the max warning threshold, as set by
112    /// [`with_max_warnings`](DiagnosticService::with_max_warnings), has been exceeded.
113    fn max_warnings_exceeded(&self, warnings_count: usize) -> bool {
114        self.max_warnings.is_some_and(|max_warnings| warnings_count > max_warnings)
115    }
116
117    /// Wrap [diagnostics] with the source code and path, converting them into [Error]s.
118    ///
119    /// [diagnostics]: OxcDiagnostic
120    pub fn wrap_diagnostics<C: AsRef<Path>, P: AsRef<Path>>(
121        cwd: C,
122        path: P,
123        source_text: &str,
124        diagnostics: Vec<OxcDiagnostic>,
125    ) -> Vec<Error> {
126        // TODO: This causes snapshots to fail when running tests through a JetBrains terminal.
127        let is_jetbrains =
128            std::env::var("TERMINAL_EMULATOR").is_ok_and(|x| x.eq("JetBrains-JediTerm"));
129
130        let path_ref = path.as_ref();
131        let path_display = if is_jetbrains { from_file_path(path_ref) } else { None }
132            .unwrap_or_else(|| {
133                let relative_path =
134                    path_ref.strip_prefix(cwd).unwrap_or(path_ref).to_string_lossy();
135                let normalized_path = relative_path.cow_replace('\\', "/");
136                normalized_path.to_string()
137            });
138
139        let source = Arc::new(NamedSource::new(path_display, source_text.to_owned()));
140        diagnostics
141            .into_iter()
142            .map(|diagnostic| diagnostic.with_source_code(Arc::clone(&source)))
143            .collect()
144    }
145
146    /// # Panics
147    ///
148    /// * When the writer fails to write
149    ///
150    /// ToDo:
151    /// We are passing [`DiagnosticResult`] to the [`DiagnosticReporter`] already
152    /// currently for the GraphicalReporter there is another extra output,
153    /// which does some more things. This is the reason why we are returning it.
154    /// Let's check at first it we can easily change for the default output before removing this return.
155    pub fn run(&mut self, writer: &mut dyn Write) -> DiagnosticResult {
156        let mut warnings_count: usize = 0;
157        let mut errors_count: usize = 0;
158
159        while let Ok((path, diagnostics)) = self.receiver.recv() {
160            let mut is_minified = false;
161            for diagnostic in diagnostics {
162                let severity = diagnostic.severity();
163                let is_warning = severity == Some(Severity::Warning);
164                let is_error = severity == Some(Severity::Error) || severity.is_none();
165                if is_warning || is_error {
166                    if is_warning {
167                        warnings_count += 1;
168                    }
169                    if is_error {
170                        errors_count += 1;
171                    }
172                    // The --quiet flag follows ESLint's --quiet behavior as documented here: https://eslint.org/docs/latest/use/command-line-interface#--quiet
173                    // Note that it does not disable ALL diagnostics, only Warning diagnostics
174                    else if self.quiet {
175                        continue;
176                    }
177                }
178
179                if self.silent || is_minified {
180                    continue;
181                }
182
183                if let Some(err_str) = self.reporter.render_error(diagnostic) {
184                    // Skip large output and print only once.
185                    // Setting to 1200 because graphical output may contain ansi escape codes and other decorations.
186                    if err_str.lines().any(|line| line.len() >= 1200) {
187                        let minified_diagnostic = Error::new(
188                            OxcDiagnostic::warn("File is too long to fit on the screen").with_help(
189                                format!("{} seems like a minified file", path.display()),
190                            ),
191                        );
192
193                        if let Some(err_str) = self.reporter.render_error(minified_diagnostic) {
194                            writer
195                                .write_all(err_str.as_bytes())
196                                .or_else(Self::check_for_writer_error)
197                                .unwrap();
198                        }
199                        is_minified = true;
200                        continue;
201                    }
202
203                    writer
204                        .write_all(err_str.as_bytes())
205                        .or_else(Self::check_for_writer_error)
206                        .unwrap();
207                }
208            }
209        }
210
211        let result = DiagnosticResult::new(
212            warnings_count,
213            errors_count,
214            self.max_warnings_exceeded(warnings_count),
215        );
216
217        if let Some(finish_output) = self.reporter.finish(&result) {
218            writer
219                .write_all(finish_output.as_bytes())
220                .or_else(Self::check_for_writer_error)
221                .unwrap();
222        }
223
224        writer.flush().or_else(Self::check_for_writer_error).unwrap();
225
226        result
227    }
228
229    fn check_for_writer_error(error: std::io::Error) -> Result<(), std::io::Error> {
230        // Do not panic when the process is killed (e.g. piping into `less`).
231        if matches!(error.kind(), ErrorKind::Interrupted | ErrorKind::BrokenPipe) {
232            Ok(())
233        } else {
234            Err(error)
235        }
236    }
237}
238
239// The following from_file_path and strict_canonicalize implementations are from tower-lsp-community/tower-lsp-server
240// available under the MIT License or Apache 2.0 License.
241//
242// Copyright (c) 2023 Eyal Kalderon
243// https://github.com/tower-lsp-community/tower-lsp-server/blob/85506ddcbd108c514438e0b62e0eb858c812adcf/src/uri_ext.rs
244
245const ASCII_SET: AsciiSet =
246    // RFC3986 allows only alphanumeric characters, `-`, `.`, `_`, and `~` in the path.
247    percent_encoding::NON_ALPHANUMERIC
248        .remove(b'-')
249        .remove(b'.')
250        .remove(b'_')
251        .remove(b'~')
252        // we do not want path separators to be percent-encoded
253        .remove(b'/');
254
255fn from_file_path<A: AsRef<Path>>(path: A) -> Option<String> {
256    let path = path.as_ref();
257
258    let fragment = if path.is_absolute() {
259        Cow::Borrowed(path)
260    } else {
261        match strict_canonicalize(path) {
262            Ok(path) => Cow::Owned(path),
263            Err(_) => return None,
264        }
265    };
266
267    if cfg!(windows) {
268        // we want to write a triple-slash path for Windows paths
269        // it's a shorthand for `file://localhost/C:/Windows` with the `localhost` omitted.
270        let mut components = fragment.components();
271        let drive = components.next();
272
273        if let Some(drive) = drive {
274            Some(format!(
275                "file:///{}{}",
276                drive.as_os_str().to_string_lossy().cow_replace('\\', "/"),
277                // Skip encoding ":" in the drive "C:/".
278                percent_encoding::utf8_percent_encode(
279                    &components.collect::<PathBuf>().to_string_lossy().cow_replace('\\', "/"),
280                    &ASCII_SET
281                )
282            ))
283        } else {
284            Some(format!(
285                "file:///{}",
286                percent_encoding::utf8_percent_encode(
287                    &components.collect::<PathBuf>().to_string_lossy().cow_replace('\\', "/"),
288                    &ASCII_SET
289                )
290            ))
291        }
292    } else {
293        Some(format!(
294            "file://{}",
295            percent_encoding::utf8_percent_encode(&fragment.to_string_lossy(), &ASCII_SET)
296        ))
297    }
298}
299
300/// On Windows, rewrites the wide path prefix `\\?\C:` to `C:`
301/// Source: <https://stackoverflow.com/a/70970317>
302#[inline]
303#[cfg(windows)]
304fn strict_canonicalize<P: AsRef<Path>>(path: P) -> std::io::Result<PathBuf> {
305    use std::io;
306
307    fn impl_(path: &Path) -> std::io::Result<PathBuf> {
308        let head = path.components().next().ok_or(io::Error::other("empty path"))?;
309        let disk_;
310        let head = if let std::path::Component::Prefix(prefix) = head {
311            if let std::path::Prefix::VerbatimDisk(disk) = prefix.kind() {
312                disk_ = format!("{}:", disk as char);
313                Path::new(&disk_)
314                    .components()
315                    .next()
316                    .ok_or(io::Error::other("failed to parse disk component"))?
317            } else {
318                head
319            }
320        } else {
321            head
322        };
323        Ok(std::iter::once(head).chain(path.components().skip(1)).collect())
324    }
325
326    let canon = std::fs::canonicalize(path)?;
327    impl_(&canon)
328}
329
330#[cfg(test)]
331mod tests {
332    use crate::service::from_file_path;
333    use std::path::PathBuf;
334
335    fn with_schema(path: &str) -> String {
336        const EXPECTED_SCHEMA: &str = if cfg!(windows) { "file:///" } else { "file://" };
337        format!("{EXPECTED_SCHEMA}{path}")
338    }
339
340    #[test]
341    #[cfg(windows)]
342    fn test_idempotent_canonicalization() {
343        use crate::service::strict_canonicalize;
344        use std::path::Path;
345
346        let lhs = strict_canonicalize(Path::new(".")).unwrap();
347        let rhs = strict_canonicalize(&lhs).unwrap();
348        assert_eq!(lhs, rhs);
349    }
350
351    #[test]
352    #[cfg(unix)]
353    fn test_path_to_uri() {
354        let paths = [
355            PathBuf::from("/some/path/to/file.txt"),
356            PathBuf::from("/some/path/to/file with spaces.txt"),
357            PathBuf::from("/some/path/[[...rest]]/file.txt"),
358            PathBuf::from("/some/path/to/файл.txt"),
359            PathBuf::from("/some/path/to/文件.txt"),
360        ];
361
362        let expected = [
363            with_schema("/some/path/to/file.txt"),
364            with_schema("/some/path/to/file%20with%20spaces.txt"),
365            with_schema("/some/path/%5B%5B...rest%5D%5D/file.txt"),
366            with_schema("/some/path/to/%D1%84%D0%B0%D0%B9%D0%BB.txt"),
367            with_schema("/some/path/to/%E6%96%87%E4%BB%B6.txt"),
368        ];
369
370        for (path, expected) in paths.iter().zip(expected) {
371            let uri = from_file_path(path).unwrap();
372            assert_eq!(uri.to_string(), expected);
373        }
374    }
375
376    #[test]
377    #[cfg(windows)]
378    fn test_path_to_uri_windows() {
379        let paths = [
380            PathBuf::from("C:\\some\\path\\to\\file.txt"),
381            PathBuf::from("C:\\some\\path\\to\\file with spaces.txt"),
382            PathBuf::from("C:\\some\\path\\[[...rest]]\\file.txt"),
383            PathBuf::from("C:\\some\\path\\to\\файл.txt"),
384            PathBuf::from("C:\\some\\path\\to\\文件.txt"),
385        ];
386
387        let expected = [
388            with_schema("C:/some/path/to/file.txt"),
389            with_schema("C:/some/path/to/file%20with%20spaces.txt"),
390            with_schema("C:/some/path/%5B%5B...rest%5D%5D/file.txt"),
391            with_schema("C:/some/path/to/%D1%84%D0%B0%D0%B9%D0%BB.txt"),
392            with_schema("C:/some/path/to/%E6%96%87%E4%BB%B6.txt"),
393        ];
394
395        for (path, expected) in paths.iter().zip(expected) {
396            let uri = from_file_path(path).unwrap();
397            assert_eq!(uri, expected);
398        }
399    }
400}