Skip to main content

nu_lint/
violation.rs

1use std::{borrow::Cow, error::Error, fmt, iter::once, path::Path, string::ToString};
2
3use lsp_types::DiagnosticTag;
4use miette::{Diagnostic, LabeledSpan, Severity};
5use nu_protocol::Span;
6
7use crate::{
8    config::LintLevel,
9    span::{FileSpan, LintSpan},
10};
11
12/// Represents the source file of a lint violation
13#[derive(Debug, Clone, PartialEq, Eq, Hash)]
14pub enum SourceFile {
15    Stdin,
16    File(String),
17}
18
19impl SourceFile {
20    #[must_use]
21    pub const fn as_str(&self) -> &str {
22        match self {
23            Self::Stdin => "<stdin>",
24            Self::File(path) => path.as_str(),
25        }
26    }
27
28    #[must_use]
29    pub fn as_path(&self) -> Option<&Path> {
30        match self {
31            Self::Stdin => None,
32            Self::File(path) => Some(Path::new(path)),
33        }
34    }
35
36    #[must_use]
37    pub const fn is_stdin(&self) -> bool {
38        matches!(self, Self::Stdin)
39    }
40}
41
42impl fmt::Display for SourceFile {
43    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
44        write!(f, "{}", self.as_str())
45    }
46}
47
48impl From<&str> for SourceFile {
49    fn from(s: &str) -> Self {
50        Self::File(s.to_string())
51    }
52}
53
54impl From<String> for SourceFile {
55    fn from(s: String) -> Self {
56        Self::File(s)
57    }
58}
59
60impl From<&Path> for SourceFile {
61    fn from(p: &Path) -> Self {
62        Self::File(p.to_string_lossy().to_string())
63    }
64}
65
66impl From<LintLevel> for Severity {
67    fn from(level: LintLevel) -> Self {
68        match level {
69            LintLevel::Error => Self::Error,
70            LintLevel::Warning => Self::Warning,
71            LintLevel::Hint => Self::Advice,
72        }
73    }
74}
75
76/// A detection in an external file (stdlib, imported module, etc.)
77///
78/// This represents a violation that occurs in a file other than the one being
79/// linted. It carries its own file path, source content, and file-relative span
80/// so it can be rendered with proper context.
81#[derive(Debug, Clone)]
82pub struct ExternalDetection {
83    /// The path to the external file
84    pub file: String,
85    /// The source content of the external file
86    pub source: String,
87    /// File-relative span within the external file
88    pub span: FileSpan,
89    /// The error message for this external location
90    pub message: String,
91    /// Optional label for the span
92    pub label: Option<String>,
93}
94
95impl ExternalDetection {
96    #[must_use]
97    pub fn new(
98        file: impl Into<String>,
99        source: impl Into<String>,
100        span: FileSpan,
101        message: impl Into<String>,
102    ) -> Self {
103        Self {
104            file: file.into(),
105            source: source.into(),
106            span,
107            message: message.into(),
108            label: None,
109        }
110    }
111
112    #[must_use]
113    pub fn with_label(mut self, label: impl Into<String>) -> Self {
114        self.label = Some(label.into());
115        self
116    }
117}
118
119/// A detected violation from a lint rule (before fix is attached).
120///
121/// This type is returned by `LintRule::detect()` and deliberately has no `fix`
122/// field. The engine is responsible for calling `LintRule::fix()` separately
123/// and combining the results into a full `Violation`.
124#[derive(Debug, Clone)]
125pub struct Detection {
126    pub message: Cow<'static, str>,
127    pub span: LintSpan,
128    pub primary_label: Option<Cow<'static, str>>,
129    pub extra_labels: Vec<(LintSpan, Option<String>)>,
130    /// Related detections in external files (stdlib, imported modules, etc.)
131    pub external_detections: Vec<ExternalDetection>,
132}
133
134impl Detection {
135    /// Create a new violation with an AST span (global coordinates)
136    #[must_use]
137    pub fn from_global_span(message: impl Into<Cow<'static, str>>, global_span: Span) -> Self {
138        Self {
139            message: message.into(),
140            span: LintSpan::from(global_span),
141            primary_label: None,
142            extra_labels: Vec::new(),
143            external_detections: Vec::new(),
144        }
145    }
146
147    /// Create a new violation with a file-relative span
148    #[must_use]
149    pub fn from_file_span(message: impl Into<Cow<'static, str>>, span: FileSpan) -> Self {
150        Self {
151            message: message.into(),
152            span: LintSpan::File(span),
153            primary_label: None,
154            extra_labels: Vec::new(),
155            external_detections: Vec::new(),
156        }
157    }
158
159    /// Add an external detection (for errors in stdlib, imported modules, etc.)
160    #[must_use]
161    pub fn with_external_detection(mut self, detection: ExternalDetection) -> Self {
162        self.external_detections.push(detection);
163        self
164    }
165
166    #[must_use]
167    pub fn with_primary_label(mut self, label: impl Into<Cow<'static, str>>) -> Self {
168        self.primary_label = Some(label.into());
169        self
170    }
171
172    #[must_use]
173    pub fn with_extra_label(mut self, label: impl Into<Cow<'static, str>>, span: Span) -> Self {
174        self.extra_labels
175            .push((LintSpan::from(span), Some(label.into().to_string())));
176        self
177    }
178
179    #[must_use]
180    pub fn with_extra_span(mut self, span: Span) -> Self {
181        self.extra_labels.push((LintSpan::from(span), None));
182        self
183    }
184}
185
186/// A lint violation with its full diagnostic information (after fix is
187/// attached).
188///
189/// This is the final form of a violation, constructed by the engine from a
190/// `Detection` plus an optional `Fix`. Rules cannot construct this
191/// type directly - they return `Detection` from `detect()`.
192#[derive(Debug, Clone)]
193pub struct Violation {
194    pub rule_id: Option<Cow<'static, str>>,
195    pub lint_level: LintLevel,
196    pub message: Cow<'static, str>,
197    pub span: LintSpan,
198    pub primary_label: Option<Cow<'static, str>>,
199    pub extra_labels: Vec<(LintSpan, Option<String>)>,
200    pub long_description: Option<String>,
201    pub fix: Option<Fix>,
202    pub(crate) file: Option<SourceFile>,
203    pub(crate) source: Option<Cow<'static, str>>,
204    pub doc_url: Option<&'static str>,
205    /// Short description of the rule (for hover documentation)
206    pub short_description: Option<&'static str>,
207    /// Diagnostic tags for LSP (Unnecessary, Deprecated)
208    pub diagnostic_tags: Vec<DiagnosticTag>,
209    /// Related detections in external files
210    pub external_detections: Vec<ExternalDetection>,
211}
212
213impl Violation {
214    pub(crate) fn from_detected(
215        detected: Detection,
216        fix: Option<Fix>,
217        long_description: impl Into<Option<&'static str>>,
218    ) -> Self {
219        Self {
220            rule_id: None,
221            lint_level: LintLevel::default(),
222            message: detected.message,
223            span: detected.span,
224            primary_label: detected.primary_label,
225            extra_labels: detected.extra_labels,
226            long_description: long_description.into().map(ToString::to_string),
227            fix,
228            file: None,
229            source: None,
230            doc_url: None,
231            short_description: None,
232            diagnostic_tags: Vec::new(),
233            external_detections: detected.external_detections,
234        }
235    }
236
237    /// Set the rule ID for this violation (used by the engine)
238    pub(crate) fn set_rule_id(&mut self, rule_id: &'static str) {
239        self.rule_id = Some(Cow::Borrowed(rule_id));
240    }
241
242    /// Set the lint level for this violation (used by the engine)
243    pub(crate) const fn set_lint_level(&mut self, level: LintLevel) {
244        self.lint_level = level;
245    }
246
247    /// Set the documentation URL for this violation (used by the engine)
248    pub(crate) const fn set_doc_url(&mut self, url: Option<&'static str>) {
249        self.doc_url = url;
250    }
251
252    /// Set the short description for this violation (used by the engine)
253    pub(crate) const fn set_short_description(&mut self, desc: &'static str) {
254        self.short_description = Some(desc);
255    }
256
257    /// Set diagnostic tags for this violation (used by the engine)
258    pub(crate) fn set_diagnostic_tags(&mut self, tags: &[DiagnosticTag]) {
259        self.diagnostic_tags = tags.to_vec();
260    }
261
262    /// Get the span as file-relative. Panics if not normalized.
263    #[must_use]
264    pub fn file_span(&self) -> FileSpan {
265        self.span.file_span()
266    }
267
268    /// Normalize all spans to be file-relative (called by engine before output)
269    pub fn normalize_spans(&mut self, file_offset: usize) {
270        // Convert main span to file-relative
271        let file_span = self.span.to_file_span(file_offset);
272        self.span = LintSpan::File(file_span);
273
274        // Normalize fix replacements
275        if let Some(fix) = &mut self.fix {
276            for replacement in &mut fix.replacements {
277                let file_span = replacement.span.to_file_span(file_offset);
278                replacement.span = LintSpan::File(file_span);
279            }
280        }
281
282        // Normalize extra labels
283        self.extra_labels = self
284            .extra_labels
285            .iter()
286            .map(|(span, label)| {
287                let file_span = span.to_file_span(file_offset);
288                (LintSpan::File(file_span), label.clone())
289            })
290            .collect();
291    }
292}
293
294impl fmt::Display for Violation {
295    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
296        write!(f, "{}", self.message)
297    }
298}
299
300impl Error for Violation {}
301
302impl Diagnostic for Violation {
303    fn code<'a>(&'a self) -> Option<Box<dyn fmt::Display + 'a>> {
304        Some(Box::new(format!(
305            "{:?}({})",
306            self.lint_level,
307            self.rule_id.as_deref().unwrap_or("unknown")
308        )))
309    }
310
311    fn severity(&self) -> Option<Severity> {
312        Some(self.lint_level.into())
313    }
314
315    fn help<'a>(&'a self) -> Option<Box<dyn fmt::Display + 'a>> {
316        self.long_description
317            .as_ref()
318            .map(|h| Box::new(h.clone()) as Box<dyn fmt::Display>)
319    }
320
321    fn url<'a>(&'a self) -> Option<Box<dyn fmt::Display + 'a>> {
322        self.doc_url
323            .map(|url| Box::new(url) as Box<dyn fmt::Display>)
324    }
325
326    fn labels(&self) -> Option<Box<dyn Iterator<Item = LabeledSpan> + '_>> {
327        let file_span = self.file_span();
328        let span_range = file_span.start..file_span.end;
329        let primary = self.primary_label.as_ref().map_or_else(
330            || LabeledSpan::underline(span_range.clone()),
331            |label| LabeledSpan::new_primary_with_span(Some(label.to_string()), span_range.clone()),
332        );
333        let extras = self.extra_labels.iter().map(|(span, label)| {
334            let file_span = span.file_span();
335            LabeledSpan::new_with_span(label.clone(), file_span.start..file_span.end)
336        });
337        Some(Box::new(once(primary).chain(extras)))
338    }
339}
340
341/// An automated fix that can be applied to resolve a violation
342#[derive(Debug, Clone)]
343pub struct Fix {
344    /// User-facing explanation of what this fix does
345    /// Shown in the "ℹ Available fix:" line (can be multi-line)
346    pub explanation: Cow<'static, str>,
347
348    /// The actual code replacements to apply to the file
349    pub replacements: Vec<Replacement>,
350}
351
352/// A single code replacement to apply when fixing a violation
353///
354/// # Important
355///
356/// The `replacement_text` field contains the ACTUAL CODE that will be written
357/// to the file at the specified span. This is not shown directly to the user
358/// (except in the before/after diff), but is what gets applied when the fix
359/// runs.
360#[derive(Debug, Clone)]
361pub struct Replacement {
362    /// Span in source code to replace (tracks global vs file-relative)
363    pub span: LintSpan,
364
365    /// New text to insert at this location
366    pub replacement_text: Cow<'static, str>,
367}
368
369impl Replacement {
370    /// Create a new code replacement with an AST span (global coordinates)
371    #[must_use]
372    pub fn new(span: Span, replacement_text: impl Into<Cow<'static, str>>) -> Self {
373        Self {
374            span: LintSpan::from(span),
375            replacement_text: replacement_text.into(),
376        }
377    }
378
379    /// Create a new code replacement with a file-relative span
380    #[must_use]
381    pub fn with_file_span(span: FileSpan, replacement_text: impl Into<Cow<'static, str>>) -> Self {
382        Self {
383            span: LintSpan::File(span),
384            replacement_text: replacement_text.into(),
385        }
386    }
387
388    /// Get the span as file-relative (for output). Panics if not normalized.
389    #[must_use]
390    pub fn file_span(&self) -> FileSpan {
391        match self.span {
392            LintSpan::File(f) => f,
393            LintSpan::Global(_) => panic!("Span not normalized - call normalize_spans first"),
394        }
395    }
396}