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            });
206            cur_start = start;
207            cur_end = end;
208        }
209    }
210    merged.push(ProseRange {
211        start_byte: cur_start,
212        end_byte: cur_end,
213        exclusions: vec![],
214        language: None,
215    });
216
217    merged
218}
219
220/// Registry of compiled schemas for looking up by file extension.
221#[derive(Debug, Default)]
222pub struct SchemaRegistry {
223    schemas: Vec<CompiledSchema>,
224    /// Hashes of the sources loaded, in load order.
225    ///
226    /// Kept because a schema decides which lines of a document are prose, so
227    /// editing one changes the answer to a check -- and the stored result of
228    /// that check has to stop applying when it does. Compiled schemas hold
229    /// regexes, which cannot be hashed, so the source is hashed as it arrives.
230    source_hashes: Vec<u64>,
231}
232
233impl SchemaRegistry {
234    #[must_use]
235    pub fn new() -> Self {
236        Self::default()
237    }
238
239    /// Load and compile a schema from a YAML string.
240    pub fn load_yaml(&mut self, yaml: &str) -> Result<()> {
241        let schema: LanguageSchema = serde_yaml::from_str(yaml)?;
242        let compiled = CompiledSchema::compile(&schema)?;
243        self.schemas.push(compiled);
244        self.source_hashes.push(crate::hashing::stable_hash(yaml));
245        Ok(())
246    }
247
248    /// Load and compile a schema from a YAML file.
249    pub fn load_file(&mut self, path: &std::path::Path) -> Result<()> {
250        let content = std::fs::read_to_string(path)?;
251        self.load_yaml(&content)
252    }
253
254    /// Load all `.yaml`/`.yml` schemas from a directory, returning how many
255    /// files were loaded.
256    pub fn load_dir(&mut self, dir: &std::path::Path) -> Result<usize> {
257        crate::fs_util::load_yaml_dir(dir, |path| {
258            self.load_file(path)?;
259            Ok(1)
260        })
261    }
262
263    /// Load all workspace schemas from the default config directory.
264    pub fn from_workspace(workspace_root: &Path) -> Result<Self> {
265        let mut registry = Self::new();
266        registry.load_dir(&workspace_root.join(DEFAULT_SCHEMA_DIR))?;
267        Ok(registry)
268    }
269
270    /// Find a compiled schema by file extension.
271    #[must_use]
272    pub fn find_by_extension(&self, ext: &str) -> Option<&CompiledSchema> {
273        self.schemas
274            .iter()
275            .find(|s| s.extensions.iter().any(|e| e == ext))
276    }
277
278    /// Number of loaded schemas.
279    #[must_use]
280    pub const fn len(&self) -> usize {
281        self.schemas.len()
282    }
283
284    /// Whether the registry is empty.
285    #[must_use]
286    pub const fn is_empty(&self) -> bool {
287        self.schemas.is_empty()
288    }
289
290    /// A value that changes whenever the loaded schemas do.
291    ///
292    /// Read by the check cache. A schema says which lines of a document are
293    /// prose, so editing one changes what a check reports -- and without this
294    /// the stored result from before the edit still applied, which made an
295    /// edited schema look like a schema that was never read.
296    #[must_use]
297    pub fn fingerprint(&self) -> u64 {
298        let joined = self
299            .source_hashes
300            .iter()
301            .map(u64::to_string)
302            .collect::<Vec<_>>()
303            .join(",");
304        crate::hashing::stable_hash(&joined)
305    }
306
307    /// Extensions handled only by a schema, without the dot.
308    ///
309    /// The editor needs these to know which documents to send at all: its own
310    /// list is of language ids it has grammars for, and a schema language has
311    /// no entry there. Built-in extensions are left out because a built-in
312    /// grammar takes precedence over a schema anyway.
313    #[must_use]
314    pub fn fallback_extensions(&self) -> Vec<String> {
315        let mut extensions = BTreeSet::new();
316        for schema in &self.schemas {
317            for ext in &schema.extensions {
318                if crate::languages::builtin_language_for_extension(ext).is_none() {
319                    extensions.insert(ext.clone());
320                }
321            }
322        }
323        extensions.into_iter().collect()
324    }
325
326    /// Glob patterns for extensions handled only by SLS, preserving built-in precedence.
327    #[must_use]
328    pub fn fallback_file_patterns(&self) -> Vec<(String, String)> {
329        let mut patterns = BTreeSet::new();
330
331        for schema in &self.schemas {
332            for ext in &schema.extensions {
333                if crate::languages::builtin_language_for_extension(ext).is_none() {
334                    patterns.insert((format!("**/*.{ext}"), schema.name.clone()));
335                }
336            }
337        }
338
339        patterns.into_iter().collect()
340    }
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    const RST_SCHEMA: &str = r#"
348name: restructuredtext
349extensions:
350  - rst
351  - rest
352prose_patterns:
353  - pattern: "^[^\\s\\.\\:].*\\S"
354skip_patterns:
355  - pattern: "^\\.\\."
356  - pattern: "^\\s*$"
357  - pattern: "^[=\\-~`:'\"^_*+#]{3,}$"
358skip_blocks:
359  - start: "^::\\s*$"
360    end: "^\\S"
361"#;
362
363    const TOML_SCHEMA: &str = r#"
364name: toml
365extensions:
366  - toml
367prose_patterns: []
368skip_patterns:
369  - pattern: "^\\s*#"
370  - pattern: "^\\s*\\["
371  - pattern: "^\\s*\\w+\\s*="
372skip_blocks: []
373"#;
374
375    #[test]
376    fn compile_rst_schema() {
377        let schema: LanguageSchema = serde_yaml::from_str(RST_SCHEMA).unwrap();
378        let compiled = CompiledSchema::compile(&schema).unwrap();
379        assert_eq!(compiled.name, "restructuredtext");
380        assert_eq!(compiled.extensions, vec!["rst", "rest"]);
381    }
382
383    #[test]
384    fn rst_extract_prose() {
385        let schema: LanguageSchema = serde_yaml::from_str(RST_SCHEMA).unwrap();
386        let compiled = CompiledSchema::compile(&schema).unwrap();
387
388        let text = "Title\n=====\n\nThis is a paragraph.\n\n.. note::\n\n   This is a directive.\n\nAnother paragraph here.";
389        let ranges = compiled.extract(text);
390
391        let extracted: Vec<&str> = ranges
392            .iter()
393            .map(|r| &text[r.start_byte..r.end_byte])
394            .collect();
395        assert!(extracted.iter().any(|t| t.contains("This is a paragraph")));
396        assert!(extracted.iter().any(|t| t.contains("Another paragraph")));
397        // Directive content should be excluded via skip pattern
398        assert!(!extracted.iter().any(|t| t.contains(".. note")));
399    }
400
401    #[test]
402    fn toml_no_prose_patterns_means_all_non_skipped() {
403        let schema: LanguageSchema = serde_yaml::from_str(TOML_SCHEMA).unwrap();
404        let compiled = CompiledSchema::compile(&schema).unwrap();
405
406        // TOML with no prose_patterns and all lines matching skip patterns
407        let text = "# Comment\n[section]\nkey = \"value\"";
408        let ranges = compiled.extract(text);
409        // All lines match skip patterns, so no prose
410        assert!(ranges.is_empty());
411    }
412
413    #[test]
414    fn skip_blocks() {
415        let yaml = r#"
416name: test
417extensions: [test]
418prose_patterns: []
419skip_patterns: []
420skip_blocks:
421  - start: "^```"
422    end: "^```"
423"#;
424        let schema: LanguageSchema = serde_yaml::from_str(yaml).unwrap();
425        let compiled = CompiledSchema::compile(&schema).unwrap();
426
427        let text = "Prose line one\n```\ncode here\nmore code\n```\nProse line two";
428        let ranges = compiled.extract(text);
429
430        let extracted: Vec<&str> = ranges
431            .iter()
432            .map(|r| &text[r.start_byte..r.end_byte])
433            .collect();
434        assert!(extracted.iter().any(|t| t.contains("Prose line one")));
435        assert!(extracted.iter().any(|t| t.contains("Prose line two")));
436        assert!(!extracted.iter().any(|t| t.contains("code here")));
437    }
438
439    #[test]
440    fn schema_registry_lookup() {
441        let mut registry = SchemaRegistry::new();
442        registry.load_yaml(RST_SCHEMA).unwrap();
443        registry.load_yaml(TOML_SCHEMA).unwrap();
444        assert_eq!(registry.len(), 2);
445
446        let rst = registry.find_by_extension("rst");
447        assert!(rst.is_some());
448        assert_eq!(rst.unwrap().name, "restructuredtext");
449
450        let toml = registry.find_by_extension("toml");
451        assert!(toml.is_some());
452        assert_eq!(toml.unwrap().name, "toml");
453
454        assert!(registry.find_by_extension("py").is_none());
455    }
456
457    #[test]
458    fn merge_adjacent_ranges() {
459        let ranges = vec![(0, 5), (6, 10), (11, 15)];
460        let merged = merge_ranges(ranges);
461        // All within 2 bytes of each other, should merge to one
462        assert_eq!(merged.len(), 1);
463        assert_eq!(merged[0].start_byte, 0);
464        assert_eq!(merged[0].end_byte, 15);
465    }
466
467    #[test]
468    fn no_merge_for_distant_ranges() {
469        let ranges = vec![(0, 5), (20, 25)];
470        let merged = merge_ranges(ranges);
471        assert_eq!(merged.len(), 2);
472    }
473
474    #[test]
475    fn empty_text() {
476        let schema: LanguageSchema = serde_yaml::from_str(RST_SCHEMA).unwrap();
477        let compiled = CompiledSchema::compile(&schema).unwrap();
478        let ranges = compiled.extract("");
479        assert!(ranges.is_empty());
480    }
481
482    #[test]
483    fn invalid_regex_returns_error() {
484        let yaml = r#"
485name: bad
486extensions: [bad]
487prose_patterns:
488  - pattern: "[invalid"
489"#;
490        let schema: LanguageSchema = serde_yaml::from_str(yaml).unwrap();
491        assert!(CompiledSchema::compile(&schema).is_err());
492    }
493
494    #[test]
495    fn fallback_file_patterns_skip_builtins() {
496        let mut registry = SchemaRegistry::new();
497        registry.load_yaml(RST_SCHEMA).unwrap();
498        registry
499            .load_yaml(
500                r#"
501name: asciidoc
502extensions: [adoc, asciidoc]
503prose_patterns: []
504skip_patterns: []
505skip_blocks: []
506"#,
507            )
508            .unwrap();
509
510        let patterns = registry.fallback_file_patterns();
511
512        assert!(!patterns.iter().any(|(pattern, _)| pattern == "**/*.rst"));
513        assert!(
514            patterns
515                .iter()
516                .any(|(pattern, lang)| pattern == "**/*.adoc" && lang == "asciidoc")
517        );
518        assert!(
519            patterns
520                .iter()
521                .any(|(pattern, lang)| pattern == "**/*.asciidoc" && lang == "asciidoc")
522        );
523    }
524}