nu_lint/
violation.rs

1use std::{borrow::Cow, error::Error, fmt, iter::once, path::Path};
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    pub help: Option<Cow<'static, str>>,
130    pub notes: Vec<Cow<'static, str>>,
131    /// Related detections in external files (stdlib, imported modules, etc.)
132    pub external_detections: Vec<ExternalDetection>,
133}
134
135impl Detection {
136    /// Create a new violation with an AST span (global coordinates)
137    #[must_use]
138    pub fn from_global_span(message: impl Into<Cow<'static, str>>, global_span: Span) -> Self {
139        Self {
140            message: message.into(),
141            span: LintSpan::from(global_span),
142            primary_label: None,
143            extra_labels: Vec::new(),
144            help: None,
145            notes: Vec::new(),
146            external_detections: Vec::new(),
147        }
148    }
149
150    /// Create a new violation with a file-relative span
151    #[must_use]
152    pub fn from_file_span(message: impl Into<Cow<'static, str>>, span: FileSpan) -> Self {
153        Self {
154            message: message.into(),
155            span: LintSpan::File(span),
156            primary_label: None,
157            extra_labels: Vec::new(),
158            help: None,
159            notes: Vec::new(),
160            external_detections: Vec::new(),
161        }
162    }
163
164    /// Add an external detection (for errors in stdlib, imported modules, etc.)
165    #[must_use]
166    pub fn with_external_detection(mut self, detection: ExternalDetection) -> Self {
167        self.external_detections.push(detection);
168        self
169    }
170
171    #[must_use]
172    pub fn with_help(mut self, help: impl Into<Cow<'static, str>>) -> Self {
173        self.help = Some(help.into());
174        self
175    }
176
177    #[must_use]
178    pub fn with_primary_label(mut self, label: impl Into<Cow<'static, str>>) -> Self {
179        self.primary_label = Some(label.into());
180        self
181    }
182
183    #[must_use]
184    pub fn with_extra_label(mut self, label: impl Into<Cow<'static, str>>, span: Span) -> Self {
185        self.extra_labels
186            .push((LintSpan::from(span), Some(label.into().to_string())));
187        self
188    }
189
190    #[must_use]
191    pub fn with_extra_span(mut self, span: Span) -> Self {
192        self.extra_labels.push((LintSpan::from(span), None));
193        self
194    }
195}
196
197/// A lint violation with its full diagnostic information (after fix is
198/// attached).
199///
200/// This is the final form of a violation, constructed by the engine from a
201/// `Detection` plus an optional `Fix`. Rules cannot construct this
202/// type directly - they return `Detection` from `detect()`.
203#[derive(Debug, Clone)]
204pub struct Violation {
205    pub rule_id: Option<Cow<'static, str>>,
206    pub lint_level: LintLevel,
207    pub message: Cow<'static, str>,
208    pub span: LintSpan,
209    pub primary_label: Option<Cow<'static, str>>,
210    pub extra_labels: Vec<(LintSpan, Option<String>)>,
211    pub help: Option<Cow<'static, str>>,
212    pub fix: Option<Fix>,
213    pub(crate) file: Option<SourceFile>,
214    pub(crate) source: Option<Cow<'static, str>>,
215    pub doc_url: Option<&'static str>,
216    /// Related detections in external files
217    pub external_detections: Vec<ExternalDetection>,
218}
219
220impl Violation {
221    pub(crate) fn from_detected(detected: Detection, fix: Option<Fix>) -> Self {
222        Self {
223            rule_id: None,
224            lint_level: LintLevel::default(),
225            message: detected.message,
226            span: detected.span,
227            primary_label: detected.primary_label,
228            extra_labels: detected.extra_labels,
229            help: detected.help,
230            fix,
231            file: None,
232            source: None,
233            doc_url: None,
234            external_detections: detected.external_detections,
235        }
236    }
237
238    /// Set the rule ID for this violation (used by the engine)
239    pub(crate) fn set_rule_id(&mut self, rule_id: &'static str) {
240        self.rule_id = Some(Cow::Borrowed(rule_id));
241    }
242
243    /// Set the lint level for this violation (used by the engine)
244    pub(crate) const fn set_lint_level(&mut self, level: LintLevel) {
245        self.lint_level = level;
246    }
247
248    /// Set the documentation URL for this violation (used by the engine)
249    pub(crate) const fn set_doc_url(&mut self, url: Option<&'static str>) {
250        self.doc_url = url;
251    }
252
253    /// Get the span as file-relative. Panics if not normalized.
254    #[must_use]
255    pub fn file_span(&self) -> FileSpan {
256        self.span.file_span()
257    }
258
259    /// Normalize all spans to be file-relative (called by engine before output)
260    pub fn normalize_spans(&mut self, file_offset: usize) {
261        // Convert main span to file-relative
262        let file_span = self.span.to_file_span(file_offset);
263        self.span = LintSpan::File(file_span);
264
265        // Normalize fix replacements
266        if let Some(fix) = &mut self.fix {
267            for replacement in &mut fix.replacements {
268                let file_span = replacement.span.to_file_span(file_offset);
269                replacement.span = LintSpan::File(file_span);
270            }
271        }
272
273        // Normalize extra labels
274        self.extra_labels = self
275            .extra_labels
276            .iter()
277            .map(|(span, label)| {
278                let file_span = span.to_file_span(file_offset);
279                (LintSpan::File(file_span), label.clone())
280            })
281            .collect();
282    }
283}
284
285impl fmt::Display for Violation {
286    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
287        write!(f, "{}", self.message)
288    }
289}
290
291impl Error for Violation {}
292
293impl Diagnostic for Violation {
294    fn code<'a>(&'a self) -> Option<Box<dyn fmt::Display + 'a>> {
295        Some(Box::new(format!(
296            "{:?}({})",
297            self.lint_level,
298            self.rule_id.as_deref().unwrap_or("unknown")
299        )))
300    }
301
302    fn severity(&self) -> Option<Severity> {
303        Some(self.lint_level.into())
304    }
305
306    fn help<'a>(&'a self) -> Option<Box<dyn fmt::Display + 'a>> {
307        self.help
308            .as_ref()
309            .map(|h| Box::new(h.clone()) as Box<dyn fmt::Display>)
310    }
311
312    fn url<'a>(&'a self) -> Option<Box<dyn fmt::Display + 'a>> {
313        self.doc_url
314            .map(|url| Box::new(url) as Box<dyn fmt::Display>)
315    }
316
317    fn labels(&self) -> Option<Box<dyn Iterator<Item = LabeledSpan> + '_>> {
318        let file_span = self.file_span();
319        let span_range = file_span.start..file_span.end;
320        let primary = self.primary_label.as_ref().map_or_else(
321            || LabeledSpan::underline(span_range.clone()),
322            |label| LabeledSpan::new_primary_with_span(Some(label.to_string()), span_range.clone()),
323        );
324        let extras = self.extra_labels.iter().map(|(span, label)| {
325            let file_span = span.file_span();
326            LabeledSpan::new_with_span(label.clone(), file_span.start..file_span.end)
327        });
328        Some(Box::new(once(primary).chain(extras)))
329    }
330}
331
332/// An automated fix that can be applied to resolve a violation
333#[derive(Debug, Clone)]
334pub struct Fix {
335    /// User-facing explanation of what this fix does
336    /// Shown in the "ℹ Available fix:" line (can be multi-line)
337    pub explanation: Cow<'static, str>,
338
339    /// The actual code replacements to apply to the file
340    pub replacements: Vec<Replacement>,
341}
342
343impl Fix {
344    /// Create a fix with an explanation and code replacements
345    #[must_use]
346    pub fn with_explanation(
347        explanation: impl Into<Cow<'static, str>>,
348        replacements: Vec<Replacement>,
349    ) -> Self {
350        Self {
351            explanation: explanation.into(),
352            replacements,
353        }
354    }
355}
356
357/// A single code replacement to apply when fixing a violation
358///
359/// # Important
360///
361/// The `replacement_text` field contains the ACTUAL CODE that will be written
362/// to the file at the specified span. This is not shown directly to the user
363/// (except in the before/after diff), but is what gets applied when the fix
364/// runs.
365#[derive(Debug, Clone)]
366pub struct Replacement {
367    /// Span in source code to replace (tracks global vs file-relative)
368    pub span: LintSpan,
369
370    /// New text to insert at this location
371    pub replacement_text: Cow<'static, str>,
372}
373
374impl Replacement {
375    /// Create a new code replacement with an AST span (global coordinates)
376    #[must_use]
377    pub fn new(span: Span, replacement_text: impl Into<Cow<'static, str>>) -> Self {
378        Self {
379            span: LintSpan::from(span),
380            replacement_text: replacement_text.into(),
381        }
382    }
383
384    /// Create a new code replacement with a file-relative span
385    #[must_use]
386    pub fn with_file_span(span: FileSpan, replacement_text: impl Into<Cow<'static, str>>) -> Self {
387        Self {
388            span: LintSpan::File(span),
389            replacement_text: replacement_text.into(),
390        }
391    }
392
393    /// Get the span as file-relative (for output). Panics if not normalized.
394    #[must_use]
395    pub fn file_span(&self) -> FileSpan {
396        match self.span {
397            LintSpan::File(f) => f,
398            LintSpan::Global(_) => panic!("Span not normalized - call normalize_spans first"),
399        }
400    }
401}