forge_lint/
inline_config.rs

1use solar::{
2    ast::{Item, SourceUnit, visit::Visit as VisitAst},
3    interface::SourceMap,
4    parse::ast::Span,
5    sema::hir::{self, Visit as VisitHir},
6};
7use std::{collections::HashMap, fmt, marker::PhantomData, ops::ControlFlow};
8
9/// An inline config item
10#[derive(Clone, Debug)]
11pub enum InlineConfigItem {
12    /// Disables the next code (AST) item regardless of newlines
13    DisableNextItem(Vec<String>),
14    /// Disables formatting on the current line
15    DisableLine(Vec<String>),
16    /// Disables formatting between the next newline and the newline after
17    DisableNextLine(Vec<String>),
18    /// Disables formatting for any code that follows this and before the next "disable-end"
19    DisableStart(Vec<String>),
20    /// Disables formatting for any code that precedes this and after the previous "disable-start"
21    DisableEnd(Vec<String>),
22}
23
24impl InlineConfigItem {
25    /// Parse an inline config item from a string. Validates lint IDs against available lints.
26    pub fn parse(s: &str, lint_ids: &[&str]) -> Result<Self, InvalidInlineConfigItem> {
27        let (disable, relevant) = s.split_once('(').unwrap_or((s, ""));
28        let lints = if relevant.is_empty() || relevant == "all)" {
29            vec!["all".to_string()]
30        } else {
31            match relevant.split_once(')') {
32                Some((lint, _)) => lint.split(",").map(|s| s.trim().to_string()).collect(),
33                None => return Err(InvalidInlineConfigItem::Syntax(s.into())),
34            }
35        };
36
37        // Validate lint IDs
38        let mut invalid_ids = Vec::new();
39        'ids: for id in &lints {
40            if id == "all" {
41                continue;
42            }
43            for lint in lint_ids {
44                if *lint == id {
45                    continue 'ids;
46                }
47            }
48            invalid_ids.push(id.to_owned());
49        }
50
51        if !invalid_ids.is_empty() {
52            return Err(InvalidInlineConfigItem::LintIds(invalid_ids));
53        }
54
55        let res = match disable {
56            "disable-next-item" => Self::DisableNextItem(lints),
57            "disable-line" => Self::DisableLine(lints),
58            "disable-next-line" => Self::DisableNextLine(lints),
59            "disable-start" => Self::DisableStart(lints),
60            "disable-end" => Self::DisableEnd(lints),
61            s => return Err(InvalidInlineConfigItem::Syntax(s.into())),
62        };
63
64        Ok(res)
65    }
66}
67
68#[derive(Debug)]
69pub enum InvalidInlineConfigItem {
70    Syntax(String),
71    LintIds(Vec<String>),
72}
73
74impl fmt::Display for InvalidInlineConfigItem {
75    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76        match self {
77            Self::Syntax(s) => write!(f, "invalid inline config item: {s}"),
78            Self::LintIds(ids) => {
79                write!(f, "unknown lint id: '{}'", ids.join("', '"))
80            }
81        }
82    }
83}
84
85/// A disabled formatting range. `loose` designates that the range includes any loc which
86/// may start in between start and end, whereas the strict version requires that
87/// `range.start >= loc.start <=> loc.end <= range.end`
88#[derive(Debug, Clone, Copy)]
89struct DisabledRange {
90    start: usize,
91    end: usize,
92    loose: bool,
93}
94
95impl DisabledRange {
96    fn includes(&self, range: std::ops::Range<usize>) -> bool {
97        range.start >= self.start && (if self.loose { range.start } else { range.end } <= self.end)
98    }
99}
100
101/// An inline config. Keeps track of ranges which should not be formatted.
102#[derive(Debug, Default)]
103pub struct InlineConfig {
104    disabled_ranges: HashMap<String, Vec<DisabledRange>>,
105}
106
107impl InlineConfig {
108    /// Build a new inline config with an iterator of inline config items and their locations in a
109    /// source file.
110    ///
111    /// # Panics
112    ///
113    /// Panics if `items` is not sorted in ascending order of [`Span`]s.
114    pub fn from_ast<'ast>(
115        items: impl IntoIterator<Item = (Span, InlineConfigItem)>,
116        ast: &'ast SourceUnit<'ast>,
117        source_map: &SourceMap,
118    ) -> Self {
119        Self::build(items, source_map, |offset| NextItemFinderAst::new(offset).find(ast))
120    }
121
122    /// Build a new inline config with an iterator of inline config items and their locations in a
123    /// source file.
124    ///
125    /// # Panics
126    ///
127    /// Panics if `items` is not sorted in ascending order of [`Span`]s.
128    pub fn from_hir<'hir>(
129        items: impl IntoIterator<Item = (Span, InlineConfigItem)>,
130        hir: &'hir hir::Hir<'hir>,
131        source_id: hir::SourceId,
132        source_map: &SourceMap,
133    ) -> Self {
134        Self::build(items, source_map, |offset| NextItemFinderHir::new(offset, hir).find(source_id))
135    }
136
137    fn build(
138        items: impl IntoIterator<Item = (Span, InlineConfigItem)>,
139        source_map: &SourceMap,
140        mut find_next_item: impl FnMut(usize) -> Option<Span>,
141    ) -> Self {
142        let mut disabled_ranges: HashMap<String, Vec<DisabledRange>> = HashMap::new();
143        let mut disabled_blocks: HashMap<String, (usize, usize, usize)> = HashMap::new();
144
145        let mut prev_sp = Span::DUMMY;
146        for (sp, item) in items {
147            if cfg!(debug_assertions) {
148                assert!(sp >= prev_sp, "InlineConfig::new: unsorted items: {sp:?} < {prev_sp:?}");
149                prev_sp = sp;
150            }
151
152            let Ok((file, comment_range)) = source_map.span_to_source(sp) else { continue };
153            let src = file.src.as_str();
154            match item {
155                InlineConfigItem::DisableNextItem(lints) => {
156                    if let Some(next_item) = find_next_item(sp.hi().to_usize()) {
157                        for lint in lints {
158                            disabled_ranges.entry(lint).or_default().push(DisabledRange {
159                                start: next_item.lo().to_usize(),
160                                end: next_item.hi().to_usize(),
161                                loose: false,
162                            });
163                        }
164                    }
165                }
166                InlineConfigItem::DisableLine(lints) => {
167                    let start = src[..comment_range.start].rfind('\n').map_or(0, |i| i);
168                    let end = src[comment_range.end..]
169                        .find('\n')
170                        .map_or(src.len(), |i| comment_range.end + i);
171
172                    for lint in lints {
173                        disabled_ranges.entry(lint).or_default().push(DisabledRange {
174                            start: start + file.start_pos.to_usize(),
175                            end: end + file.start_pos.to_usize(),
176                            loose: false,
177                        })
178                    }
179                }
180                InlineConfigItem::DisableNextLine(lints) => {
181                    if let Some(offset) = src[comment_range.end..].find('\n') {
182                        let start = comment_range.end + offset + 1;
183                        if start < src.len() {
184                            let end = src[start..].find('\n').map_or(src.len(), |i| start + i);
185                            for lint in lints {
186                                disabled_ranges.entry(lint).or_default().push(DisabledRange {
187                                    start: start + file.start_pos.to_usize(),
188                                    end: end + file.start_pos.to_usize(),
189                                    loose: false,
190                                })
191                            }
192                        }
193                    }
194                }
195                InlineConfigItem::DisableStart(lints) => {
196                    for lint in lints {
197                        disabled_blocks
198                            .entry(lint)
199                            .and_modify(|(_, depth, _)| *depth += 1)
200                            .or_insert((
201                                sp.hi().to_usize(),
202                                1,
203                                // Use file end as fallback for unclosed blocks
204                                file.start_pos.to_usize() + src.len(),
205                            ));
206                    }
207                }
208                InlineConfigItem::DisableEnd(lints) => {
209                    for lint in lints {
210                        if let Some((start, depth, _)) = disabled_blocks.get_mut(&lint) {
211                            *depth = depth.saturating_sub(1);
212
213                            if *depth == 0 {
214                                let start = *start;
215                                _ = disabled_blocks.remove(&lint);
216
217                                disabled_ranges.entry(lint).or_default().push(DisabledRange {
218                                    start,
219                                    end: sp.lo().to_usize(),
220                                    loose: false,
221                                })
222                            }
223                        }
224                    }
225                }
226            }
227        }
228
229        for (lint, (start, _, file_end)) in disabled_blocks {
230            disabled_ranges.entry(lint).or_default().push(DisabledRange {
231                start,
232                end: file_end,
233                loose: false,
234            });
235        }
236
237        Self { disabled_ranges }
238    }
239
240    /// Check if the lint location is in a disabled range.
241    pub fn is_disabled(&self, span: Span, lint: &str) -> bool {
242        if let Some(ranges) = self.disabled_ranges.get(lint) {
243            return ranges.iter().any(|range| range.includes(span.to_range()));
244        }
245
246        if let Some(ranges) = self.disabled_ranges.get("all") {
247            return ranges.iter().any(|range| range.includes(span.to_range()));
248        }
249
250        false
251    }
252}
253
254/// An AST visitor that finds the first `Item` that starts after a given offset.
255#[derive(Debug, Default)]
256struct NextItemFinderAst<'ast> {
257    /// The offset to search after.
258    offset: usize,
259    _pd: PhantomData<&'ast ()>,
260}
261
262impl<'ast> NextItemFinderAst<'ast> {
263    fn new(offset: usize) -> Self {
264        Self { offset, _pd: PhantomData }
265    }
266
267    /// Finds the next AST item which a span that begins after the `offset`.
268    fn find(&mut self, ast: &'ast SourceUnit<'ast>) -> Option<Span> {
269        match self.visit_source_unit(ast) {
270            ControlFlow::Break(span) => Some(span),
271            ControlFlow::Continue(()) => None,
272        }
273    }
274}
275
276impl<'ast> VisitAst<'ast> for NextItemFinderAst<'ast> {
277    type BreakValue = Span;
278
279    fn visit_item(&mut self, item: &'ast Item<'ast>) -> ControlFlow<Self::BreakValue> {
280        // Check if this item starts after the offset.
281        if item.span.lo().to_usize() > self.offset {
282            return ControlFlow::Break(item.span);
283        }
284
285        // Otherwise, continue traversing inside this item.
286        self.walk_item(item)
287    }
288}
289
290/// A HIR visitor that finds the first `Item` that starts after a given offset.
291#[derive(Debug)]
292struct NextItemFinderHir<'hir> {
293    hir: &'hir hir::Hir<'hir>,
294    /// The offset to search after.
295    offset: usize,
296}
297
298impl<'hir> NextItemFinderHir<'hir> {
299    fn new(offset: usize, hir: &'hir hir::Hir<'hir>) -> Self {
300        Self { offset, hir }
301    }
302
303    /// Finds the next HIR item which a span that begins after the `offset`.
304    fn find(&mut self, id: hir::SourceId) -> Option<Span> {
305        match self.visit_nested_source(id) {
306            ControlFlow::Break(span) => Some(span),
307            ControlFlow::Continue(()) => None,
308        }
309    }
310}
311
312impl<'hir> VisitHir<'hir> for NextItemFinderHir<'hir> {
313    type BreakValue = Span;
314
315    fn hir(&self) -> &'hir hir::Hir<'hir> {
316        self.hir
317    }
318
319    fn visit_item(&mut self, item: hir::Item<'hir, 'hir>) -> ControlFlow<Self::BreakValue> {
320        // Check if this item starts after the offset.
321        if item.span().lo().to_usize() > self.offset {
322            return ControlFlow::Break(item.span());
323        }
324
325        // If the item is before the offset, skip traverse.
326        if item.span().hi().to_usize() < self.offset {
327            return ControlFlow::Continue(());
328        }
329
330        // Otherwise, continue traversing inside this item.
331        self.walk_item(item)
332    }
333}