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