forge_lint/
inline_config.rs

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