nu_lint/
violation.rs

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