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    collections::{HashMap, hash_map::Entry},
7    hash::Hash,
8    ops::ControlFlow,
9};
10
11/// A disabled formatting range.
12#[derive(Debug, Clone, Copy)]
13struct DisabledRange<T = BytePos> {
14    /// Start position, inclusive.
15    lo: T,
16    /// End position, inclusive.
17    hi: T,
18    /// Whether the range stems from a `disable-start`/`disable-end` block.
19    block: bool,
20}
21
22impl DisabledRange<BytePos> {
23    fn includes(&self, span: Span) -> bool {
24        span.lo() >= self.lo && span.hi() <= self.hi
25    }
26}
27
28/// An inline config item
29#[derive(Clone, Debug)]
30pub enum InlineConfigItem<I> {
31    /// Disables the next code (AST) item regardless of newlines
32    DisableNextItem(I),
33    /// Disables formatting on the current line
34    DisableLine(I),
35    /// Disables formatting between the next newline and the newline after
36    DisableNextLine(I),
37    /// Disables formatting for any code that follows this and before the next "disable-end"
38    DisableStart(I),
39    /// Disables formatting for any code that precedes this and after the previous "disable-start"
40    DisableEnd(I),
41}
42
43impl InlineConfigItem<Vec<String>> {
44    /// Parse an inline config item from a string. Validates IDs against available IDs.
45    pub fn parse(s: &str, available_ids: &[&str]) -> Result<Self, InvalidInlineConfigItem> {
46        let (disable, relevant) = s.split_once('(').unwrap_or((s, ""));
47        let ids = if relevant.is_empty() || relevant == "all)" {
48            vec!["all".to_string()]
49        } else {
50            match relevant.split_once(')') {
51                Some((id_str, _)) => id_str.split(',').map(|s| s.trim().to_string()).collect(),
52                None => return Err(InvalidInlineConfigItem::Syntax(s.into())),
53            }
54        };
55
56        // Validate IDs
57        let mut invalid_ids = Vec::new();
58        'ids: for id in &ids {
59            if id == "all" {
60                continue;
61            }
62            for available_id in available_ids {
63                if *available_id == id {
64                    continue 'ids;
65                }
66            }
67            invalid_ids.push(id.to_owned());
68        }
69
70        if !invalid_ids.is_empty() {
71            return Err(InvalidInlineConfigItem::Ids(invalid_ids));
72        }
73
74        let res = match disable {
75            "disable-next-item" => Self::DisableNextItem(ids),
76            "disable-line" => Self::DisableLine(ids),
77            "disable-next-line" => Self::DisableNextLine(ids),
78            "disable-start" => Self::DisableStart(ids),
79            "disable-end" => Self::DisableEnd(ids),
80            s => return Err(InvalidInlineConfigItem::Syntax(s.into())),
81        };
82
83        Ok(res)
84    }
85}
86
87impl std::str::FromStr for InlineConfigItem<()> {
88    type Err = InvalidInlineConfigItem;
89    fn from_str(s: &str) -> Result<Self, Self::Err> {
90        Ok(match s {
91            "disable-next-item" => Self::DisableNextItem(()),
92            "disable-line" => Self::DisableLine(()),
93            "disable-next-line" => Self::DisableNextLine(()),
94            "disable-start" => Self::DisableStart(()),
95            "disable-end" => Self::DisableEnd(()),
96            s => return Err(InvalidInlineConfigItem::Syntax(s.into())),
97        })
98    }
99}
100
101#[derive(Debug)]
102pub enum InvalidInlineConfigItem {
103    Syntax(String),
104    Ids(Vec<String>),
105}
106
107impl std::fmt::Display for InvalidInlineConfigItem {
108    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109        match self {
110            Self::Syntax(s) => write!(f, "invalid inline config item: {s}"),
111            Self::Ids(ids) => {
112                write!(f, "unknown id: '{}'", ids.join("', '"))
113            }
114        }
115    }
116}
117
118/// A trait for `InlineConfigItem` types that can be iterated over to produce keys for storage.
119pub trait ItemIdIterator {
120    type Item: Eq + Hash + Clone;
121    fn into_iter(self) -> impl IntoIterator<Item = Self::Item>;
122}
123
124impl ItemIdIterator for () {
125    type Item = ();
126    fn into_iter(self) -> impl IntoIterator<Item = Self::Item> {
127        std::iter::once(())
128    }
129}
130
131impl ItemIdIterator for Vec<String> {
132    type Item = String;
133    fn into_iter(self) -> impl IntoIterator<Item = Self::Item> {
134        self
135    }
136}
137
138#[derive(Debug, Default)]
139pub struct InlineConfig<I: ItemIdIterator> {
140    disabled_ranges: HashMap<I::Item, Vec<DisabledRange>>,
141}
142
143impl<I: ItemIdIterator> InlineConfig<I> {
144    /// Build a new inline config with an iterator of inline config items and their locations in a
145    /// source file.
146    ///
147    /// # Panics
148    ///
149    /// Panics if `items` is not sorted in ascending order of [`Span`]s.
150    pub fn from_ast<'ast>(
151        items: impl IntoIterator<Item = (Span, InlineConfigItem<I>)>,
152        ast: &'ast ast::SourceUnit<'ast>,
153        source_map: &SourceMap,
154    ) -> Self {
155        Self::build(items, source_map, |offset| NextItemFinder::new(offset).find(ast))
156    }
157
158    fn build(
159        items: impl IntoIterator<Item = (Span, InlineConfigItem<I>)>,
160        source_map: &SourceMap,
161        mut find_next_item: impl FnMut(BytePos) -> Option<Span>,
162    ) -> Self {
163        let mut cfg = Self::new();
164        let mut disabled_blocks = HashMap::new();
165
166        let mut prev_sp = Span::DUMMY;
167        for (sp, item) in items {
168            if cfg!(debug_assertions) {
169                assert!(sp >= prev_sp, "InlineConfig::new: unsorted items: {sp:?} < {prev_sp:?}");
170                prev_sp = sp;
171            }
172
173            cfg.disable_item(sp, item, source_map, &mut disabled_blocks, &mut find_next_item);
174        }
175
176        for (id, (_, lo, hi)) in disabled_blocks {
177            cfg.disable(id, DisabledRange { lo, hi, block: true });
178        }
179
180        cfg
181    }
182
183    fn new() -> Self {
184        Self { disabled_ranges: HashMap::new() }
185    }
186
187    fn disable_many(&mut self, ids: I, range: DisabledRange) {
188        for id in ids.into_iter() {
189            self.disable(id, range);
190        }
191    }
192
193    fn disable(&mut self, id: I::Item, range: DisabledRange) {
194        self.disabled_ranges.entry(id).or_default().push(range);
195    }
196
197    fn disable_item(
198        &mut self,
199        span: Span,
200        item: InlineConfigItem<I>,
201        source_map: &SourceMap,
202        disabled_blocks: &mut HashMap<I::Item, (usize, BytePos, BytePos)>,
203        find_next_item: &mut dyn FnMut(BytePos) -> Option<Span>,
204    ) {
205        let result = source_map.span_to_source(span).unwrap();
206        let file = result.file;
207        let comment_range = result.data;
208        let src = file.src.as_str();
209
210        #[allow(clippy::collapsible_match)]
211        match item {
212            InlineConfigItem::DisableNextItem(ids) => {
213                if let Some(next_item) = find_next_item(span.hi()) {
214                    self.disable_many(
215                        ids,
216                        DisabledRange { lo: next_item.lo(), hi: next_item.hi(), block: false },
217                    );
218                }
219            }
220            InlineConfigItem::DisableLine(ids) => {
221                let start = src[..comment_range.start].rfind('\n').unwrap_or(0);
222                let end = src[comment_range.end..]
223                    .find('\n')
224                    .map_or(src.len(), |i| comment_range.end + i);
225                self.disable_many(
226                    ids,
227                    DisabledRange {
228                        lo: file.absolute_position(RelativeBytePos::from_usize(start)),
229                        hi: file.absolute_position(RelativeBytePos::from_usize(end)),
230                        block: false,
231                    },
232                );
233            }
234            InlineConfigItem::DisableNextLine(ids) => {
235                if let Some(offset) = src[comment_range.end..].find('\n') {
236                    let next_line = comment_range.end + offset + 1;
237                    if next_line < src.len() {
238                        let end = src[next_line..].find('\n').map_or(src.len(), |i| next_line + i);
239                        self.disable_many(
240                            ids,
241                            DisabledRange {
242                                lo: file.absolute_position(RelativeBytePos::from_usize(
243                                    comment_range.start,
244                                )),
245                                hi: file.absolute_position(RelativeBytePos::from_usize(end)),
246                                block: false,
247                            },
248                        );
249                    }
250                }
251            }
252
253            InlineConfigItem::DisableStart(ids) => {
254                for id in ids.into_iter() {
255                    disabled_blocks.entry(id).and_modify(|(depth, _, _)| *depth += 1).or_insert((
256                        1,
257                        span.lo(),
258                        // Use file end as fallback for unclosed blocks
259                        file.absolute_position(RelativeBytePos::from_usize(src.len())),
260                    ));
261                }
262            }
263            InlineConfigItem::DisableEnd(ids) => {
264                for id in ids.into_iter() {
265                    if let Entry::Occupied(mut entry) = disabled_blocks.entry(id) {
266                        let (depth, lo, _) = entry.get_mut();
267                        *depth = depth.saturating_sub(1);
268
269                        if *depth == 0 {
270                            let lo = *lo;
271                            let (id, _) = entry.remove_entry();
272
273                            self.disable(id, DisabledRange { lo, hi: span.hi(), block: true });
274                        }
275                    }
276                }
277            }
278        }
279    }
280}
281
282impl InlineConfig<()> {
283    /// Checks if a span is disabled (only applicable when inline config doesn't require an id).
284    pub fn is_disabled(&self, span: Span) -> bool {
285        if let Some(ranges) = self.disabled_ranges.get(&()) {
286            return ranges.iter().any(|range| range.includes(span));
287        }
288        false
289    }
290
291    /// Checks if a span is disabled by a `disable-start`/`disable-end` block, as opposed to a
292    /// line-based directive such as `disable-line`.
293    pub fn is_disabled_block(&self, span: Span) -> bool {
294        if let Some(ranges) = self.disabled_ranges.get(&()) {
295            return ranges.iter().any(|range| range.block && range.includes(span));
296        }
297        false
298    }
299}
300
301impl<I: ItemIdIterator> InlineConfig<I>
302where
303    I::Item: std::borrow::Borrow<str>,
304{
305    /// Checks if a span is disabled for a specific id. Also checks against "all", which disables
306    /// all rules.
307    pub fn is_id_disabled(&self, span: Span, id: &str) -> bool {
308        self.is_id_disabled_inner(span, id)
309            || (id != "all" && self.is_id_disabled_inner(span, "all"))
310    }
311
312    fn is_id_disabled_inner(&self, span: Span, id: &str) -> bool {
313        if let Some(ranges) = self.disabled_ranges.get(id)
314            && ranges.iter().any(|range| range.includes(span))
315        {
316            return true;
317        }
318
319        false
320    }
321}
322
323macro_rules! find_next_item {
324    ($self:expr, $x:expr, $span:expr, $walk:ident) => {{
325        let span = $span;
326        // If the item is *entirely* before the offset, skip traversing it.
327        if span.hi() < $self.offset {
328            return ControlFlow::Continue(());
329        }
330        // Check if this item starts after the offset.
331        if span.lo() > $self.offset {
332            return ControlFlow::Break(span);
333        }
334        // Otherwise, continue traversing inside this item.
335        $self.$walk($x)
336    }};
337}
338
339/// An AST visitor that finds the first `Item` that starts after a given offset.
340#[derive(Debug)]
341struct NextItemFinder {
342    /// The offset to search after.
343    offset: BytePos,
344}
345
346impl NextItemFinder {
347    const fn new(offset: BytePos) -> Self {
348        Self { offset }
349    }
350
351    /// Finds the next AST item or statement which a span that begins after the `offset`.
352    fn find<'ast>(&mut self, ast: &'ast ast::SourceUnit<'ast>) -> Option<Span> {
353        match self.visit_source_unit(ast) {
354            ControlFlow::Break(span) => Some(span),
355            ControlFlow::Continue(()) => None,
356        }
357    }
358}
359
360impl<'ast> ast::Visit<'ast> for NextItemFinder {
361    type BreakValue = Span;
362
363    fn visit_item(&mut self, item: &'ast ast::Item<'ast>) -> ControlFlow<Self::BreakValue> {
364        find_next_item!(self, item, item.span, walk_item)
365    }
366
367    fn visit_stmt(&mut self, stmt: &'ast ast::Stmt<'ast>) -> ControlFlow<Self::BreakValue> {
368        find_next_item!(self, stmt, stmt.span, walk_stmt)
369    }
370
371    fn visit_yul_stmt(
372        &mut self,
373        stmt: &'ast ast::yul::Stmt<'ast>,
374    ) -> ControlFlow<Self::BreakValue> {
375        find_next_item!(self, stmt, stmt.span, walk_yul_stmt)
376    }
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382
383    impl DisabledRange<usize> {
384        fn to_byte_pos(self) -> DisabledRange<BytePos> {
385            DisabledRange::<BytePos> {
386                lo: BytePos::from_usize(self.lo),
387                hi: BytePos::from_usize(self.hi),
388                block: self.block,
389            }
390        }
391
392        fn includes(&self, range: std::ops::Range<usize>) -> bool {
393            self.to_byte_pos().includes(Span::new(
394                BytePos::from_usize(range.start),
395                BytePos::from_usize(range.end),
396            ))
397        }
398    }
399
400    #[test]
401    fn test_disabled_range_includes() {
402        let strict = DisabledRange { lo: 10, hi: 20, block: false };
403        assert!(strict.includes(10..20));
404        assert!(strict.includes(12..18));
405        assert!(!strict.includes(5..15)); // Partial overlap fails
406    }
407
408    #[test]
409    fn test_inline_config_item_from_str() {
410        assert!(matches!(
411            "disable-next-item".parse::<InlineConfigItem<()>>().unwrap(),
412            InlineConfigItem::DisableNextItem(())
413        ));
414        assert!(matches!(
415            "disable-line".parse::<InlineConfigItem<()>>().unwrap(),
416            InlineConfigItem::DisableLine(())
417        ));
418        assert!(matches!(
419            "disable-start".parse::<InlineConfigItem<()>>().unwrap(),
420            InlineConfigItem::DisableStart(())
421        ));
422        assert!(matches!(
423            "disable-end".parse::<InlineConfigItem<()>>().unwrap(),
424            InlineConfigItem::DisableEnd(())
425        ));
426        assert!("invalid".parse::<InlineConfigItem<()>>().is_err());
427    }
428
429    #[test]
430    fn test_inline_config_item_parse_with_lints() {
431        let lint_ids = vec!["lint1", "lint2"];
432
433        // No lints = "all"
434        match InlineConfigItem::parse("disable-line", &lint_ids).unwrap() {
435            InlineConfigItem::DisableLine(lints) => assert_eq!(lints, vec!["all"]),
436            _ => panic!("Wrong type"),
437        }
438
439        // Valid single lint
440        match InlineConfigItem::parse("disable-start(lint1)", &lint_ids).unwrap() {
441            InlineConfigItem::DisableStart(lints) => assert_eq!(lints, vec!["lint1"]),
442            _ => panic!("Wrong type"),
443        }
444
445        // Multiple lints with spaces
446        match InlineConfigItem::parse("disable-end(lint1, lint2)", &lint_ids).unwrap() {
447            InlineConfigItem::DisableEnd(lints) => assert_eq!(lints, vec!["lint1", "lint2"]),
448            _ => panic!("Wrong type"),
449        }
450
451        // Invalid lint ID
452        assert!(matches!(
453            InlineConfigItem::parse("disable-line(unknown)", &lint_ids),
454            Err(InvalidInlineConfigItem::Ids(_))
455        ));
456
457        // Malformed syntax
458        assert!(matches!(
459            InlineConfigItem::parse("disable-line(lint1", &lint_ids),
460            Err(InvalidInlineConfigItem::Syntax(_))
461        ));
462    }
463}