Skip to main content

lang_check/
sls.rs

1use anyhow::Result;
2use regex::Regex;
3use serde::Deserialize;
4use std::collections::BTreeSet;
5use std::path::Path;
6
7use crate::prose::ProseRange;
8
9pub const DEFAULT_SCHEMA_DIR: &str = ".langcheck/schemas";
10
11/// A Simplified Language Schema definition, loaded from YAML.
12///
13/// Defines how to extract prose regions from a file format using regex patterns,
14/// for languages that don't have tree-sitter grammars (e.g. RST, `AsciiDoc`, TOML).
15#[derive(Debug, Deserialize, Clone)]
16pub struct LanguageSchema {
17    /// Schema name (e.g. "restructuredtext").
18    pub name: String,
19    /// File extensions this schema handles (e.g. [`rst`, `rest`]).
20    #[serde(default)]
21    pub extensions: Vec<String>,
22    /// Patterns that match lines containing prose text.
23    #[serde(default)]
24    pub prose_patterns: Vec<PatternRule>,
25    /// Patterns that match lines to skip (comments, directives, code, etc.).
26    #[serde(default)]
27    pub skip_patterns: Vec<PatternRule>,
28    /// Block delimiters for multi-line regions to skip entirely.
29    #[serde(default)]
30    pub skip_blocks: Vec<BlockRule>,
31}
32
33/// A single-line regex pattern rule.
34#[derive(Debug, Deserialize, Clone)]
35pub struct PatternRule {
36    /// The regex pattern to match against each line.
37    pub pattern: String,
38}
39
40/// A block delimiter pair for regions to skip.
41#[derive(Debug, Deserialize, Clone)]
42pub struct BlockRule {
43    /// Regex matching the start of the block.
44    pub start: String,
45    /// Regex matching the end of the block.
46    pub end: String,
47}
48
49/// Compiled version of a `LanguageSchema`, ready for fast matching.
50#[derive(Debug)]
51pub struct CompiledSchema {
52    pub name: String,
53    pub extensions: Vec<String>,
54    prose_patterns: Vec<Regex>,
55    skip_patterns: Vec<Regex>,
56    skip_blocks: Vec<(Regex, Regex)>,
57}
58
59impl CompiledSchema {
60    /// Compile a schema from its YAML definition.
61    pub fn compile(schema: &LanguageSchema) -> Result<Self> {
62        let prose_patterns: Result<Vec<_>> = schema
63            .prose_patterns
64            .iter()
65            .map(|p| Regex::new(&p.pattern).map_err(Into::into))
66            .collect();
67
68        let skip_patterns: Result<Vec<_>> = schema
69            .skip_patterns
70            .iter()
71            .map(|p| Regex::new(&p.pattern).map_err(Into::into))
72            .collect();
73
74        let skip_blocks: Result<Vec<_>> = schema
75            .skip_blocks
76            .iter()
77            .map(|b| Ok((Regex::new(&b.start)?, Regex::new(&b.end)?)))
78            .collect();
79
80        Ok(Self {
81            name: schema.name.clone(),
82            extensions: schema.extensions.clone(),
83            prose_patterns: prose_patterns?,
84            skip_patterns: skip_patterns?,
85            skip_blocks: skip_blocks?,
86        })
87    }
88
89    /// Extract prose ranges from the given text.
90    ///
91    /// Strategy:
92    /// 1. First, identify skip-block regions and mark them as excluded.
93    /// 2. For each line, check if it matches a skip pattern (excluded).
94    /// 3. For remaining lines, check if they match a prose pattern (included).
95    /// 4. If no prose patterns are defined, all non-skipped lines are prose.
96    /// 5. Merge adjacent prose ranges.
97    #[must_use]
98    pub fn extract(&self, text: &str) -> Vec<ProseRange> {
99        let skip_regions = self.find_skip_blocks(text);
100        let mut prose_lines: Vec<(usize, usize)> = Vec::new();
101
102        let mut offset = 0;
103        for line in text.split('\n') {
104            let line_start = offset;
105            let line_end = offset + line.len();
106            offset = line_end + 1; // +1 for newline
107
108            // Skip if inside a skip block
109            if skip_regions
110                .iter()
111                .any(|(s, e)| line_start >= *s && line_start < *e)
112            {
113                continue;
114            }
115
116            // Skip if matches a skip pattern
117            if self.skip_patterns.iter().any(|re| re.is_match(line)) {
118                continue;
119            }
120
121            // Skip empty lines
122            if line.trim().is_empty() {
123                continue;
124            }
125
126            // If prose patterns are defined, line must match at least one
127            if !self.prose_patterns.is_empty()
128                && !self.prose_patterns.iter().any(|re| re.is_match(line))
129            {
130                continue;
131            }
132
133            prose_lines.push((line_start, line_end));
134        }
135
136        // Merge adjacent/contiguous ranges
137        merge_ranges(prose_lines)
138    }
139
140    /// Find byte ranges of skip blocks in the text.
141    fn find_skip_blocks(&self, text: &str) -> Vec<(usize, usize)> {
142        let mut regions = Vec::new();
143
144        for (start_re, end_re) in &self.skip_blocks {
145            let lines: Vec<(usize, &str)> = text
146                .split('\n')
147                .scan(0usize, |offset, line| {
148                    let start = *offset;
149                    *offset += line.len() + 1;
150                    Some((start, line))
151                })
152                .collect();
153
154            let mut i = 0;
155            while i < lines.len() {
156                let (line_start, line) = lines[i];
157                if start_re.is_match(line) {
158                    // Find the matching end, starting from the NEXT line
159                    let mut block_end = text.len();
160                    for &(_, inner_line) in &lines[i + 1..] {
161                        if end_re.is_match(inner_line) {
162                            // End includes the closing delimiter line
163                            let inner_end = inner_line.as_ptr() as usize - text.as_ptr() as usize
164                                + inner_line.len();
165                            block_end = inner_end;
166                            // Skip past the end delimiter
167                            i = lines
168                                .iter()
169                                .position(|&(s, _)| s >= block_end)
170                                .unwrap_or(lines.len());
171                            break;
172                        }
173                    }
174                    regions.push((line_start, block_end));
175                    continue;
176                }
177                i += 1;
178            }
179        }
180
181        regions
182    }
183}
184
185/// Merge contiguous or overlapping byte ranges into larger ones.
186fn merge_ranges(mut ranges: Vec<(usize, usize)>) -> Vec<ProseRange> {
187    if ranges.is_empty() {
188        return Vec::new();
189    }
190
191    ranges.sort_by_key(|(s, _)| *s);
192    let mut merged = Vec::new();
193    let (mut cur_start, mut cur_end) = ranges[0];
194
195    for &(start, end) in &ranges[1..] {
196        // If this range is adjacent (within 1 byte for newline) or overlapping, extend
197        if start <= cur_end + 2 {
198            cur_end = cur_end.max(end);
199        } else {
200            merged.push(ProseRange {
201                start_byte: cur_start,
202                end_byte: cur_end,
203                exclusions: vec![],
204                language: None,
205                language_span: None,
206            });
207            cur_start = start;
208            cur_end = end;
209        }
210    }
211    merged.push(ProseRange {
212        start_byte: cur_start,
213        end_byte: cur_end,
214        exclusions: vec![],
215        language: None,
216        language_span: None,
217    });
218
219    merged
220}
221
222/// Registry of compiled schemas for looking up by file extension.
223#[derive(Debug, Default)]
224pub struct SchemaRegistry {
225    schemas: Vec<CompiledSchema>,
226    /// Hashes of the sources loaded, in load order.
227    ///
228    /// Kept because a schema decides which lines of a document are prose, so
229    /// editing one changes the answer to a check -- and the stored result of
230    /// that check has to stop applying when it does. Compiled schemas hold
231    /// regexes, which cannot be hashed, so the source is hashed as it arrives.
232    source_hashes: Vec<u64>,
233}
234
235impl SchemaRegistry {
236    #[must_use]
237    pub fn new() -> Self {
238        Self::default()
239    }
240
241    /// Load and compile a schema from a YAML string.
242    pub fn load_yaml(&mut self, yaml: &str) -> Result<()> {
243        let schema: LanguageSchema = serde_yaml::from_str(yaml)?;
244        let compiled = CompiledSchema::compile(&schema)?;
245        self.schemas.push(compiled);
246        self.source_hashes.push(crate::hashing::stable_hash(yaml));
247        Ok(())
248    }
249
250    /// Load and compile a schema from a YAML file.
251    pub fn load_file(&mut self, path: &std::path::Path) -> Result<()> {
252        let content = std::fs::read_to_string(path)?;
253        self.load_yaml(&content)
254    }
255
256    /// Load all `.yaml`/`.yml` schemas from a directory, returning how many
257    /// files were loaded.
258    pub fn load_dir(&mut self, dir: &std::path::Path) -> Result<usize> {
259        crate::fs_util::load_yaml_dir(dir, |path| {
260            self.load_file(path)?;
261            Ok(1)
262        })
263    }
264
265    /// Load all workspace schemas from the default config directory.
266    pub fn from_workspace(workspace_root: &Path) -> Result<Self> {
267        let mut registry = Self::new();
268        registry.load_dir(&workspace_root.join(DEFAULT_SCHEMA_DIR))?;
269        Ok(registry)
270    }
271
272    /// Find a compiled schema by file extension.
273    #[must_use]
274    pub fn find_by_extension(&self, ext: &str) -> Option<&CompiledSchema> {
275        self.schemas
276            .iter()
277            .find(|s| s.extensions.iter().any(|e| e == ext))
278    }
279
280    /// Number of loaded schemas.
281    #[must_use]
282    pub const fn len(&self) -> usize {
283        self.schemas.len()
284    }
285
286    /// Whether the registry is empty.
287    #[must_use]
288    pub const fn is_empty(&self) -> bool {
289        self.schemas.is_empty()
290    }
291
292    /// A value that changes whenever the loaded schemas do.
293    ///
294    /// Read by the check cache. A schema says which lines of a document are
295    /// prose, so editing one changes what a check reports -- and without this
296    /// the stored result from before the edit still applied, which made an
297    /// edited schema look like a schema that was never read.
298    #[must_use]
299    pub fn fingerprint(&self) -> u64 {
300        let joined = self
301            .source_hashes
302            .iter()
303            .map(u64::to_string)
304            .collect::<Vec<_>>()
305            .join(",");
306        crate::hashing::stable_hash(&joined)
307    }
308
309    /// Extensions handled only by a schema, without the dot.
310    ///
311    /// The editor needs these to know which documents to send at all: its own
312    /// list is of language ids it has grammars for, and a schema language has
313    /// no entry there. Built-in extensions are left out because a built-in
314    /// grammar takes precedence over a schema anyway.
315    #[must_use]
316    pub fn fallback_extensions(&self) -> Vec<String> {
317        let mut extensions = BTreeSet::new();
318        for schema in &self.schemas {
319            for ext in &schema.extensions {
320                if crate::languages::builtin_language_for_extension(ext).is_none() {
321                    extensions.insert(ext.clone());
322                }
323            }
324        }
325        extensions.into_iter().collect()
326    }
327
328    /// Glob patterns for extensions handled only by SLS, preserving built-in precedence.
329    #[must_use]
330    pub fn fallback_file_patterns(&self) -> Vec<(String, String)> {
331        let mut patterns = BTreeSet::new();
332
333        for schema in &self.schemas {
334            for ext in &schema.extensions {
335                if crate::languages::builtin_language_for_extension(ext).is_none() {
336                    patterns.insert((format!("**/*.{ext}"), schema.name.clone()));
337                }
338            }
339        }
340
341        patterns.into_iter().collect()
342    }
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    const RST_SCHEMA: &str = r#"
350name: restructuredtext
351extensions:
352  - rst
353  - rest
354prose_patterns:
355  - pattern: "^[^\\s\\.\\:].*\\S"
356skip_patterns:
357  - pattern: "^\\.\\."
358  - pattern: "^\\s*$"
359  - pattern: "^[=\\-~`:'\"^_*+#]{3,}$"
360skip_blocks:
361  - start: "^::\\s*$"
362    end: "^\\S"
363"#;
364
365    const TOML_SCHEMA: &str = r#"
366name: toml
367extensions:
368  - toml
369prose_patterns: []
370skip_patterns:
371  - pattern: "^\\s*#"
372  - pattern: "^\\s*\\["
373  - pattern: "^\\s*\\w+\\s*="
374skip_blocks: []
375"#;
376
377    #[test]
378    fn compile_rst_schema() {
379        let schema: LanguageSchema = serde_yaml::from_str(RST_SCHEMA).unwrap();
380        let compiled = CompiledSchema::compile(&schema).unwrap();
381        assert_eq!(compiled.name, "restructuredtext");
382        assert_eq!(compiled.extensions, vec!["rst", "rest"]);
383    }
384
385    #[test]
386    fn rst_extract_prose() {
387        let schema: LanguageSchema = serde_yaml::from_str(RST_SCHEMA).unwrap();
388        let compiled = CompiledSchema::compile(&schema).unwrap();
389
390        let text = "Title\n=====\n\nThis is a paragraph.\n\n.. note::\n\n   This is a directive.\n\nAnother paragraph here.";
391        let ranges = compiled.extract(text);
392
393        let extracted: Vec<&str> = ranges
394            .iter()
395            .map(|r| &text[r.start_byte..r.end_byte])
396            .collect();
397        assert!(extracted.iter().any(|t| t.contains("This is a paragraph")));
398        assert!(extracted.iter().any(|t| t.contains("Another paragraph")));
399        // Directive content should be excluded via skip pattern
400        assert!(!extracted.iter().any(|t| t.contains(".. note")));
401    }
402
403    #[test]
404    fn toml_no_prose_patterns_means_all_non_skipped() {
405        let schema: LanguageSchema = serde_yaml::from_str(TOML_SCHEMA).unwrap();
406        let compiled = CompiledSchema::compile(&schema).unwrap();
407
408        // TOML with no prose_patterns and all lines matching skip patterns
409        let text = "# Comment\n[section]\nkey = \"value\"";
410        let ranges = compiled.extract(text);
411        // All lines match skip patterns, so no prose
412        assert!(ranges.is_empty());
413    }
414
415    #[test]
416    fn skip_blocks() {
417        let yaml = r#"
418name: test
419extensions: [test]
420prose_patterns: []
421skip_patterns: []
422skip_blocks:
423  - start: "^```"
424    end: "^```"
425"#;
426        let schema: LanguageSchema = serde_yaml::from_str(yaml).unwrap();
427        let compiled = CompiledSchema::compile(&schema).unwrap();
428
429        let text = "Prose line one\n```\ncode here\nmore code\n```\nProse line two";
430        let ranges = compiled.extract(text);
431
432        let extracted: Vec<&str> = ranges
433            .iter()
434            .map(|r| &text[r.start_byte..r.end_byte])
435            .collect();
436        assert!(extracted.iter().any(|t| t.contains("Prose line one")));
437        assert!(extracted.iter().any(|t| t.contains("Prose line two")));
438        assert!(!extracted.iter().any(|t| t.contains("code here")));
439    }
440
441    #[test]
442    fn schema_registry_lookup() {
443        let mut registry = SchemaRegistry::new();
444        registry.load_yaml(RST_SCHEMA).unwrap();
445        registry.load_yaml(TOML_SCHEMA).unwrap();
446        assert_eq!(registry.len(), 2);
447
448        let rst = registry.find_by_extension("rst");
449        assert!(rst.is_some());
450        assert_eq!(rst.unwrap().name, "restructuredtext");
451
452        let toml = registry.find_by_extension("toml");
453        assert!(toml.is_some());
454        assert_eq!(toml.unwrap().name, "toml");
455
456        assert!(registry.find_by_extension("py").is_none());
457    }
458
459    #[test]
460    fn merge_adjacent_ranges() {
461        let ranges = vec![(0, 5), (6, 10), (11, 15)];
462        let merged = merge_ranges(ranges);
463        // All within 2 bytes of each other, should merge to one
464        assert_eq!(merged.len(), 1);
465        assert_eq!(merged[0].start_byte, 0);
466        assert_eq!(merged[0].end_byte, 15);
467    }
468
469    #[test]
470    fn no_merge_for_distant_ranges() {
471        let ranges = vec![(0, 5), (20, 25)];
472        let merged = merge_ranges(ranges);
473        assert_eq!(merged.len(), 2);
474    }
475
476    #[test]
477    fn empty_text() {
478        let schema: LanguageSchema = serde_yaml::from_str(RST_SCHEMA).unwrap();
479        let compiled = CompiledSchema::compile(&schema).unwrap();
480        let ranges = compiled.extract("");
481        assert!(ranges.is_empty());
482    }
483
484    #[test]
485    fn invalid_regex_returns_error() {
486        let yaml = r#"
487name: bad
488extensions: [bad]
489prose_patterns:
490  - pattern: "[invalid"
491"#;
492        let schema: LanguageSchema = serde_yaml::from_str(yaml).unwrap();
493        assert!(CompiledSchema::compile(&schema).is_err());
494    }
495
496    #[test]
497    fn fallback_file_patterns_skip_builtins() {
498        let mut registry = SchemaRegistry::new();
499        registry.load_yaml(RST_SCHEMA).unwrap();
500        registry
501            .load_yaml(
502                r#"
503name: asciidoc
504extensions: [adoc, asciidoc]
505prose_patterns: []
506skip_patterns: []
507skip_blocks: []
508"#,
509            )
510            .unwrap();
511
512        let patterns = registry.fallback_file_patterns();
513
514        assert!(!patterns.iter().any(|(pattern, _)| pattern == "**/*.rst"));
515        assert!(
516            patterns
517                .iter()
518                .any(|(pattern, lang)| pattern == "**/*.adoc" && lang == "asciidoc")
519        );
520        assert!(
521            patterns
522                .iter()
523                .any(|(pattern, lang)| pattern == "**/*.asciidoc" && lang == "asciidoc")
524        );
525    }
526}