nu_lint/
violation.rs

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