Skip to main content

foundry_common/comments/
inline_config.rs

1use solar::{
2    interface::{BytePos, RelativeBytePos, SourceMap, Span},
3    parse::ast::{self, Visit},
4};
5use std::{
6    borrow::Borrow,
7    collections::{HashMap, HashSet},
8    hash::Hash,
9    ops::ControlFlow,
10    sync::atomic::{AtomicBool, Ordering},
11};
12
13/// An inline suppression and its optional target range.
14#[derive(Debug)]
15struct DisabledRange<T = BytePos> {
16    /// Disabled range, or `None` if the directive has no target.
17    range: Option<(T, T)>,
18    /// Whether the range stems from a `disable-start`/`disable-end` block.
19    block: bool,
20    /// Span of the directive comment that created this range, used to report unused suppressions.
21    directive: Span,
22    /// Whether this range suppressed at least one diagnostic during the run.
23    used: AtomicBool,
24}
25
26impl DisabledRange<BytePos> {
27    fn includes(&self, span: Span) -> bool {
28        self.range.is_some_and(|(lo, hi)| span.lo() >= lo && span.hi() <= hi)
29    }
30
31    /// Marks the range as having suppressed a diagnostic and reports whether it includes `span`.
32    fn mark_if_includes(&self, span: Span) -> bool {
33        if self.includes(span) {
34            self.used.store(true, Ordering::Relaxed);
35            return true;
36        }
37        false
38    }
39}
40
41/// An inline config item
42#[derive(Clone, Debug)]
43pub enum InlineConfigItem<I> {
44    /// Disables the next code (AST) item regardless of newlines
45    DisableNextItem(I),
46    /// Disables formatting on the current line
47    DisableLine(I),
48    /// Disables formatting between the next newline and the newline after
49    DisableNextLine(I),
50    /// Disables formatting for any code that follows this and before the next "disable-end"
51    DisableStart(I),
52    /// Disables formatting for any code that precedes this and after the previous "disable-start"
53    DisableEnd(I),
54}
55
56impl InlineConfigItem<Vec<String>> {
57    /// Parse an inline config item from a string. Validates IDs against available IDs.
58    pub fn parse(s: &str, available_ids: &[&str]) -> Result<Self, InvalidInlineConfigItem> {
59        let (disable, relevant) = s.split_once('(').unwrap_or((s, ""));
60        let mut ids = if relevant.is_empty() || relevant == "all)" {
61            vec!["all".to_string()]
62        } else {
63            match relevant.split_once(')') {
64                Some((id_str, _)) => id_str.split(',').map(|s| s.trim().to_string()).collect(),
65                None => return Err(InvalidInlineConfigItem::Syntax(s.into())),
66            }
67        };
68        let mut seen = HashSet::new();
69        ids.retain(|id| seen.insert(id.clone()));
70
71        // Validate IDs
72        let mut invalid_ids = Vec::new();
73        'ids: for id in &ids {
74            if id == "all" {
75                continue;
76            }
77            for available_id in available_ids {
78                if *available_id == id {
79                    continue 'ids;
80                }
81            }
82            invalid_ids.push(id.to_owned());
83        }
84
85        if !invalid_ids.is_empty() {
86            return Err(InvalidInlineConfigItem::Ids(invalid_ids));
87        }
88
89        let res = match disable {
90            "disable-next-item" => Self::DisableNextItem(ids),
91            "disable-line" => Self::DisableLine(ids),
92            "disable-next-line" => Self::DisableNextLine(ids),
93            "disable-start" => Self::DisableStart(ids),
94            "disable-end" => Self::DisableEnd(ids),
95            s => return Err(InvalidInlineConfigItem::Syntax(s.into())),
96        };
97
98        Ok(res)
99    }
100}
101
102impl std::str::FromStr for InlineConfigItem<()> {
103    type Err = InvalidInlineConfigItem;
104    fn from_str(s: &str) -> Result<Self, Self::Err> {
105        Ok(match s {
106            "disable-next-item" => Self::DisableNextItem(()),
107            "disable-line" => Self::DisableLine(()),
108            "disable-next-line" => Self::DisableNextLine(()),
109            "disable-start" => Self::DisableStart(()),
110            "disable-end" => Self::DisableEnd(()),
111            s => return Err(InvalidInlineConfigItem::Syntax(s.into())),
112        })
113    }
114}
115
116#[derive(Debug)]
117pub enum InvalidInlineConfigItem {
118    Syntax(String),
119    Ids(Vec<String>),
120}
121
122impl std::fmt::Display for InvalidInlineConfigItem {
123    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124        match self {
125            Self::Syntax(s) => write!(f, "invalid inline config item: {s}"),
126            Self::Ids(ids) => {
127                write!(f, "unknown id: '{}'", ids.join("', '"))
128            }
129        }
130    }
131}
132
133/// A trait for `InlineConfigItem` types that can be iterated over to produce keys for storage.
134pub trait ItemIdIterator {
135    type Item: Eq + Hash + Clone;
136    fn into_iter(self) -> impl IntoIterator<Item = Self::Item>;
137}
138
139impl ItemIdIterator for () {
140    type Item = ();
141    fn into_iter(self) -> impl IntoIterator<Item = Self::Item> {
142        std::iter::once(())
143    }
144}
145
146impl ItemIdIterator for Vec<String> {
147    type Item = String;
148    fn into_iter(self) -> impl IntoIterator<Item = Self::Item> {
149        self
150    }
151}
152
153#[derive(Debug, Default)]
154pub struct InlineConfig<I: ItemIdIterator> {
155    disabled_ranges: HashMap<I::Item, Vec<DisabledRange>>,
156}
157
158impl<I: ItemIdIterator> InlineConfig<I> {
159    /// Build a new inline config with an iterator of inline config items and their locations in a
160    /// source file.
161    ///
162    /// # Panics
163    ///
164    /// Panics if `items` is not sorted in ascending order of [`Span`]s.
165    pub fn from_ast<'ast>(
166        items: impl IntoIterator<Item = (Span, InlineConfigItem<I>)>,
167        ast: &'ast ast::SourceUnit<'ast>,
168        source_map: &SourceMap,
169    ) -> Self {
170        Self::build(items, source_map, |offset| NextItemFinder::new(offset).find(ast))
171    }
172
173    fn build(
174        items: impl IntoIterator<Item = (Span, InlineConfigItem<I>)>,
175        source_map: &SourceMap,
176        mut find_next_item: impl FnMut(BytePos) -> Option<Span>,
177    ) -> Self {
178        let mut cfg = Self::new();
179        let mut disabled_blocks = HashMap::<I::Item, Vec<(BytePos, BytePos, Span)>>::new();
180
181        let mut prev_sp = Span::DUMMY;
182        for (sp, item) in items {
183            if cfg!(debug_assertions) {
184                assert!(sp >= prev_sp, "InlineConfig::new: unsorted items: {sp:?} < {prev_sp:?}");
185                prev_sp = sp;
186            }
187
188            cfg.disable_item(sp, item, source_map, &mut disabled_blocks, &mut find_next_item);
189        }
190
191        for (id, blocks) in disabled_blocks {
192            for (lo, hi, directive) in blocks {
193                cfg.disable(id.clone(), Some((lo, hi)), true, directive);
194            }
195        }
196
197        cfg
198    }
199
200    fn new() -> Self {
201        Self { disabled_ranges: HashMap::new() }
202    }
203
204    fn disable_many(
205        &mut self,
206        ids: I,
207        range: Option<(BytePos, BytePos)>,
208        block: bool,
209        directive: Span,
210    ) {
211        for id in ids.into_iter() {
212            self.disable(id, range, block, directive);
213        }
214    }
215
216    fn disable(
217        &mut self,
218        id: I::Item,
219        range: Option<(BytePos, BytePos)>,
220        block: bool,
221        directive: Span,
222    ) {
223        self.disabled_ranges.entry(id).or_default().push(DisabledRange {
224            range,
225            block,
226            directive,
227            used: AtomicBool::new(false),
228        });
229    }
230
231    fn disable_item(
232        &mut self,
233        span: Span,
234        item: InlineConfigItem<I>,
235        source_map: &SourceMap,
236        disabled_blocks: &mut HashMap<I::Item, Vec<(BytePos, BytePos, Span)>>,
237        find_next_item: &mut dyn FnMut(BytePos) -> Option<Span>,
238    ) {
239        let result = source_map.span_to_source(span).unwrap();
240        let file = result.file;
241        let comment_range = result.data;
242        let src = file.src.as_str();
243
244        #[allow(clippy::collapsible_match)]
245        match item {
246            InlineConfigItem::DisableNextItem(ids) => {
247                let range = find_next_item(span.hi()).map(|item| (item.lo(), item.hi()));
248                self.disable_many(ids, range, false, span);
249            }
250            InlineConfigItem::DisableLine(ids) => {
251                let start = src[..comment_range.start].rfind('\n').unwrap_or(0);
252                let end = src[comment_range.end..]
253                    .find('\n')
254                    .map_or(src.len(), |i| comment_range.end + i);
255                self.disable_many(
256                    ids,
257                    Some((
258                        file.absolute_position(RelativeBytePos::from_usize(start)),
259                        file.absolute_position(RelativeBytePos::from_usize(end)),
260                    )),
261                    false,
262                    span,
263                );
264            }
265            InlineConfigItem::DisableNextLine(ids) => {
266                let range = src[comment_range.end..].find('\n').and_then(|offset| {
267                    let next_line = comment_range.end + offset + 1;
268                    (next_line < src.len()).then(|| {
269                        let end = src[next_line..].find('\n').map_or(src.len(), |i| next_line + i);
270                        (
271                            file.absolute_position(RelativeBytePos::from_usize(
272                                comment_range.start,
273                            )),
274                            file.absolute_position(RelativeBytePos::from_usize(end)),
275                        )
276                    })
277                });
278                self.disable_many(ids, range, false, span);
279            }
280
281            InlineConfigItem::DisableStart(ids) => {
282                for id in ids.into_iter() {
283                    disabled_blocks.entry(id).or_default().push((
284                        span.lo(),
285                        // Use file end as fallback for unclosed blocks
286                        file.absolute_position(RelativeBytePos::from_usize(src.len())),
287                        span,
288                    ));
289                }
290            }
291            InlineConfigItem::DisableEnd(ids) => {
292                for id in ids.into_iter() {
293                    // An unmatched end closes no suppression and is ignored.
294                    if let Some(blocks) = disabled_blocks.get_mut(&id)
295                        && let Some((lo, _, directive)) = blocks.pop()
296                    {
297                        self.disable(id, Some((lo, span.hi())), true, directive);
298                    }
299                }
300            }
301        }
302    }
303}
304
305impl InlineConfig<()> {
306    /// Checks if a span is disabled (only applicable when inline config doesn't require an id).
307    pub fn is_disabled(&self, span: Span) -> bool {
308        if let Some(ranges) = self.disabled_ranges.get(&()) {
309            return ranges.iter().any(|range| range.includes(span));
310        }
311        false
312    }
313
314    /// Checks if a span is disabled by a `disable-start`/`disable-end` block, as opposed to a
315    /// line-based directive such as `disable-line`.
316    pub fn is_disabled_block(&self, span: Span) -> bool {
317        if let Some(ranges) = self.disabled_ranges.get(&()) {
318            return ranges.iter().any(|range| range.block && range.includes(span));
319        }
320        false
321    }
322}
323
324impl<I: ItemIdIterator> InlineConfig<I>
325where
326    I::Item: Borrow<str>,
327{
328    /// Checks if a span is disabled for a specific id. Also checks against "all", which disables
329    /// all rules.
330    pub fn is_id_disabled(&self, span: Span, id: &str) -> bool {
331        let id_disabled = self.is_id_disabled_inner(span, id);
332        let all_disabled = id != "all" && self.is_id_disabled_inner(span, "all");
333        id_disabled || all_disabled
334    }
335
336    fn is_id_disabled_inner(&self, span: Span, id: &str) -> bool {
337        let Some(ranges) = self.disabled_ranges.get(id) else { return false };
338        // Mark every matching range as used, not just the first, so overlapping suppressions are
339        // all credited when reporting unused ones.
340        let mut disabled = false;
341        for range in ranges {
342            disabled |= range.mark_if_includes(span);
343        }
344        disabled
345    }
346
347    /// Returns the directive span and lint id of each suppression that never suppressed a
348    /// diagnostic during the run.
349    ///
350    /// Only suppressions for ids in `active` (or the catch-all `"all"`) are reported, so severity
351    /// filters and excluded lints do not produce false positives. Results are sorted by directive
352    /// location for stable output.
353    pub fn unused_suppressions(&self, active: &[&str]) -> Vec<(Span, String)> {
354        if active.is_empty() {
355            return Vec::new();
356        }
357        let mut unused = Vec::new();
358        for (id, ranges) in &self.disabled_ranges {
359            let id = id.borrow();
360            if id != "all" && !active.contains(&id) {
361                continue;
362            }
363            for range in ranges {
364                if !range.used.load(Ordering::Relaxed) {
365                    unused.push((range.directive, id.to_string()));
366                }
367            }
368        }
369        unused.sort_by(|(a, ida), (b, idb)| a.lo().cmp(&b.lo()).then_with(|| ida.cmp(idb)));
370        unused
371    }
372}
373
374macro_rules! find_next_item {
375    ($self:expr, $x:expr, $span:expr, $walk:ident) => {{
376        let span = $span;
377        // If the item is *entirely* before the offset, skip traversing it.
378        if span.hi() < $self.offset {
379            return ControlFlow::Continue(());
380        }
381        // Check if this item starts after the offset.
382        if span.lo() > $self.offset {
383            return ControlFlow::Break(span);
384        }
385        // Otherwise, continue traversing inside this item.
386        $self.$walk($x)
387    }};
388}
389
390/// An AST visitor that finds the first `Item` that starts after a given offset.
391#[derive(Debug)]
392struct NextItemFinder {
393    /// The offset to search after.
394    offset: BytePos,
395}
396
397impl NextItemFinder {
398    const fn new(offset: BytePos) -> Self {
399        Self { offset }
400    }
401
402    /// Finds the next AST item or statement which a span that begins after the `offset`.
403    fn find<'ast>(&mut self, ast: &'ast ast::SourceUnit<'ast>) -> Option<Span> {
404        match self.visit_source_unit(ast) {
405            ControlFlow::Break(span) => Some(span),
406            ControlFlow::Continue(()) => None,
407        }
408    }
409}
410
411impl<'ast> ast::Visit<'ast> for NextItemFinder {
412    type BreakValue = Span;
413
414    fn visit_item(&mut self, item: &'ast ast::Item<'ast>) -> ControlFlow<Self::BreakValue> {
415        find_next_item!(self, item, item.span, walk_item)
416    }
417
418    fn visit_stmt(&mut self, stmt: &'ast ast::Stmt<'ast>) -> ControlFlow<Self::BreakValue> {
419        find_next_item!(self, stmt, stmt.span, walk_stmt)
420    }
421
422    fn visit_yul_stmt(
423        &mut self,
424        stmt: &'ast ast::yul::Stmt<'ast>,
425    ) -> ControlFlow<Self::BreakValue> {
426        find_next_item!(self, stmt, stmt.span, walk_yul_stmt)
427    }
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433
434    impl DisabledRange<usize> {
435        fn to_byte_pos(&self) -> DisabledRange<BytePos> {
436            DisabledRange::<BytePos> {
437                range: self
438                    .range
439                    .map(|(lo, hi)| (BytePos::from_usize(lo), BytePos::from_usize(hi))),
440                block: self.block,
441                directive: Span::DUMMY,
442                used: AtomicBool::new(false),
443            }
444        }
445
446        fn includes(&self, range: std::ops::Range<usize>) -> bool {
447            self.to_byte_pos().includes(Span::new(
448                BytePos::from_usize(range.start),
449                BytePos::from_usize(range.end),
450            ))
451        }
452    }
453
454    #[test]
455    fn test_disabled_range_includes() {
456        let strict = DisabledRange {
457            range: Some((10, 20)),
458            block: false,
459            directive: Span::DUMMY,
460            used: AtomicBool::new(false),
461        };
462        assert!(strict.includes(10..20));
463        assert!(strict.includes(12..18));
464        assert!(!strict.includes(5..15)); // Partial overlap fails
465    }
466
467    #[test]
468    fn test_unused_suppressions_credits_all_overlapping_ranges() {
469        let mut config = InlineConfig::<Vec<String>>::new();
470        config.disable(
471            "lint1".to_string(),
472            Some((BytePos::from_usize(10), BytePos::from_usize(20))),
473            false,
474            Span::new(BytePos::from_usize(1), BytePos::from_usize(2)),
475        );
476        config.disable(
477            "all".to_string(),
478            Some((BytePos::from_usize(5), BytePos::from_usize(25))),
479            true,
480            Span::new(BytePos::from_usize(3), BytePos::from_usize(4)),
481        );
482        config.disable(
483            "lint1".to_string(),
484            Some((BytePos::from_usize(5), BytePos::from_usize(25))),
485            true,
486            Span::new(BytePos::from_usize(5), BytePos::from_usize(6)),
487        );
488
489        assert!(
490            config.is_id_disabled(
491                Span::new(BytePos::from_usize(12), BytePos::from_usize(18)),
492                "lint1"
493            )
494        );
495        assert!(config.unused_suppressions(&["lint1"]).is_empty());
496    }
497
498    #[test]
499    fn test_unused_suppressions_tracks_missing_targets_and_active_lints() {
500        let mut config = InlineConfig::<Vec<String>>::new();
501        let directive = Span::new(BytePos::from_usize(1), BytePos::from_usize(2));
502        config.disable_many(vec!["lint1".to_string(), "all".to_string()], None, false, directive);
503
504        assert!(config.unused_suppressions(&[]).is_empty());
505        assert_eq!(
506            config.unused_suppressions(&["lint1"]),
507            vec![(directive, "all".to_string()), (directive, "lint1".to_string())]
508        );
509    }
510
511    #[test]
512    fn test_inline_config_item_from_str() {
513        assert!(matches!(
514            "disable-next-item".parse::<InlineConfigItem<()>>().unwrap(),
515            InlineConfigItem::DisableNextItem(())
516        ));
517        assert!(matches!(
518            "disable-line".parse::<InlineConfigItem<()>>().unwrap(),
519            InlineConfigItem::DisableLine(())
520        ));
521        assert!(matches!(
522            "disable-start".parse::<InlineConfigItem<()>>().unwrap(),
523            InlineConfigItem::DisableStart(())
524        ));
525        assert!(matches!(
526            "disable-end".parse::<InlineConfigItem<()>>().unwrap(),
527            InlineConfigItem::DisableEnd(())
528        ));
529        assert!("invalid".parse::<InlineConfigItem<()>>().is_err());
530    }
531
532    #[test]
533    fn test_inline_config_item_parse_with_lints() {
534        let lint_ids = vec!["lint1", "lint2"];
535
536        // No lints = "all"
537        match InlineConfigItem::parse("disable-line", &lint_ids).unwrap() {
538            InlineConfigItem::DisableLine(lints) => assert_eq!(lints, vec!["all"]),
539            _ => panic!("Wrong type"),
540        }
541
542        // Valid single lint
543        match InlineConfigItem::parse("disable-start(lint1)", &lint_ids).unwrap() {
544            InlineConfigItem::DisableStart(lints) => assert_eq!(lints, vec!["lint1"]),
545            _ => panic!("Wrong type"),
546        }
547
548        // Multiple lints with spaces
549        match InlineConfigItem::parse("disable-end(lint1, lint2)", &lint_ids).unwrap() {
550            InlineConfigItem::DisableEnd(lints) => assert_eq!(lints, vec!["lint1", "lint2"]),
551            _ => panic!("Wrong type"),
552        }
553
554        // Duplicate lint IDs are normalized within a directive.
555        match InlineConfigItem::parse("disable-line(lint1, lint1)", &lint_ids).unwrap() {
556            InlineConfigItem::DisableLine(lints) => assert_eq!(lints, vec!["lint1"]),
557            _ => panic!("Wrong type"),
558        }
559
560        // Invalid lint ID
561        assert!(matches!(
562            InlineConfigItem::parse("disable-line(unknown)", &lint_ids),
563            Err(InvalidInlineConfigItem::Ids(_))
564        ));
565
566        // Malformed syntax
567        assert!(matches!(
568            InlineConfigItem::parse("disable-line(lint1", &lint_ids),
569            Err(InvalidInlineConfigItem::Syntax(_))
570        ));
571    }
572}