Skip to main content

declint_core/
scopes.rs

1//! Scope segmentation: where each scope's regions begin and end.
2//!
3//! This is the "pass 1" of the two-pass shape: segment the file into
4//! regions, then run each scope's rules inside its regions (the
5//! "subpasses").
6
7use crate::config::Scope;
8use crate::linter::Span;
9
10/// Finds the regions of one scope in `source`.
11///
12/// Regions are sequential: the search for the next `start` resumes where
13/// the previous region ended, so a scope's regions never overlap each
14/// other. A region spans from its `start` match to the next `end` match
15/// after that (or end of file when the scope has no `end`). Empty regions
16/// are dropped.
17pub fn segment(source: &str, scope: &Scope) -> Vec<Span> {
18    let mut regions = Vec::new();
19    let mut pos = 0usize;
20    while let Some(start_match) = scope.start_re.find_at(source, pos) {
21        let end = scope
22            .end_re
23            .as_ref()
24            .and_then(|re| re.find_at(source, start_match.end()))
25            .map_or(source.len(), |m| m.start())
26            .max(start_match.end());
27        let span = Span::new(start_match.start(), end);
28        if !span.is_empty() {
29            regions.push(span);
30        }
31        // Always advance, even past a degenerate (empty) start match, so
32        // the loop cannot stall.
33        pos = if start_match.is_empty() {
34            span.end.max(start_match.end() + 1)
35        } else {
36            span.end
37        };
38    }
39    regions
40}
41
42/// Segments `source` for every scope, as `(scope index, region)` pairs
43/// sorted by position (scope order breaks ties). Regions from different
44/// scopes may overlap — each scope's family is independent.
45pub fn segment_all(source: &str, scope_list: &[Scope]) -> Vec<(usize, Span)> {
46    let mut out: Vec<(usize, Span)> = scope_list
47        .iter()
48        .enumerate()
49        .flat_map(|(scope_index, scope)| {
50            segment(source, scope)
51                .into_iter()
52                .map(move |region| (scope_index, region))
53        })
54        .collect();
55    out.sort_by_key(|(scope_index, region)| (region.start, region.end, *scope_index));
56    out
57}
58
59#[cfg(test)]
60mod tests {
61    use super::*;
62    use crate::Config;
63
64    fn segment_with(yaml: &str, text: &str, scope_id: &str) -> Vec<Span> {
65        let config = Config::from_str(yaml).expect("config parses");
66        let scope = config
67            .scopes
68            .iter()
69            .find(|s| s.id == scope_id)
70            .expect("scope exists");
71        segment(text, scope)
72    }
73
74    const SHELL_FENCE: &str = "\
75version: 1
76scopes:
77  - id: sh
78    start: '^```sh$'
79    end: '^```$'
80    rules:
81      - id: r
82        pattern: 'x'
83        message: m
84";
85
86    #[test]
87    fn fenced_regions() {
88        let text = "a\n```sh\nb\n```\nc\n";
89        let regions = segment_with(SHELL_FENCE, text, "sh");
90        assert_eq!(regions.len(), 1);
91        let inner = &text[regions[0].to_range()];
92        assert_eq!(inner, "```sh\nb\n");
93    }
94
95    #[test]
96    fn no_end_means_eof() {
97        let yaml = "\
98version: 1
99scopes:
100  - id: tail
101    start: '^BEGIN$'
102    rules:
103      - id: r
104        pattern: x
105        message: m
106";
107        let text = "x\nBEGIN\nrest of file";
108        let regions = segment_with(yaml, text, "tail");
109        assert_eq!(regions.len(), 1);
110        assert_eq!(regions[0].start, 2);
111        assert_eq!(regions[0].end, text.len());
112    }
113
114    #[test]
115    fn regions_are_sequential() {
116        let text = "```sh\na\n```\nmid\n```sh\nb\n```\n";
117        let regions = segment_with(SHELL_FENCE, text, "sh");
118        let inner: Vec<&str> = regions.iter().map(|r| &text[r.to_range()]).collect();
119        assert_eq!(inner, ["```sh\na\n", "```sh\nb\n"]);
120    }
121
122    #[test]
123    fn no_matches_yields_no_regions() {
124        assert!(segment_with(SHELL_FENCE, "nothing here\n", "sh").is_empty());
125    }
126
127    #[test]
128    fn end_right_after_start_gives_start_only_region() {
129        // `end` on the very next character: the region is just the start
130        // match (non-empty, kept). Genuinely empty regions (empty start
131        // match) are dropped — see the stall test below.
132        let yaml = "\
133version: 1
134scopes:
135  - id: s
136    start: 'X'
137    end: 'X'
138    rules:
139      - id: r
140        pattern: x
141        message: m
142";
143        let regions = segment_with(yaml, "XX\n", "s");
144        let ranges: Vec<_> = regions.iter().map(|r| r.to_range()).collect();
145        // The first X's region ends where the second X begins, so the
146        // second X starts its own region — regions stay disjoint.
147        assert_eq!(ranges, [0..1, 1..3]);
148    }
149
150    #[test]
151    fn empty_start_match_cannot_stall() {
152        let yaml = "\
153version: 1
154scopes:
155  - id: s
156    start: 'x*'
157    end: 'y'
158    rules:
159      - id: r
160        pattern: x
161        message: m
162";
163        // `x*` matches empty at every position; segmentation must still
164        // terminate. "a y b": first region ends at the `y`. The empty
165        // match *at* the `y` yields an immediately-empty region (dropped),
166        // so the tail region starts after it.
167        let regions = segment_with(yaml, "a y b", "s");
168        let ranges: Vec<_> = regions.iter().map(|r| r.to_range()).collect();
169        assert_eq!(ranges, [0..2, 3..5]);
170    }
171
172    #[test]
173    fn different_scopes_may_overlap() {
174        let yaml = "\
175version: 1
176scopes:
177  - id: whole
178    start: '^A$'
179    rules:
180      - id: r1
181        pattern: x
182        message: m
183  - id: line
184    start: 'B'
185    end: '$'
186    rules:
187      - id: r2
188        pattern: x
189        message: m
190";
191        let config = Config::from_str(yaml).unwrap();
192        let text = "A\nB\nC\n";
193        let all = segment_all(text, &config.scopes);
194        assert_eq!(all.len(), 2);
195        // `whole` covers from line A to EOF; `line` covers just the B line.
196        assert_eq!(&text[all[0].1.to_range()], "A\nB\nC\n");
197        assert_eq!(&text[all[1].1.to_range()], "B");
198    }
199}