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