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#[derive(Debug, Deserialize, Clone)]
16pub struct LanguageSchema {
17 pub name: String,
19 #[serde(default)]
21 pub extensions: Vec<String>,
22 #[serde(default)]
24 pub prose_patterns: Vec<PatternRule>,
25 #[serde(default)]
27 pub skip_patterns: Vec<PatternRule>,
28 #[serde(default)]
30 pub skip_blocks: Vec<BlockRule>,
31}
32
33#[derive(Debug, Deserialize, Clone)]
35pub struct PatternRule {
36 pub pattern: String,
38}
39
40#[derive(Debug, Deserialize, Clone)]
42pub struct BlockRule {
43 pub start: String,
45 pub end: String,
47}
48
49#[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 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 #[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; if skip_regions
110 .iter()
111 .any(|(s, e)| line_start >= *s && line_start < *e)
112 {
113 continue;
114 }
115
116 if self.skip_patterns.iter().any(|re| re.is_match(line)) {
118 continue;
119 }
120
121 if line.trim().is_empty() {
123 continue;
124 }
125
126 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_ranges(prose_lines)
138 }
139
140 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 let mut block_end = text.len();
160 for &(_, inner_line) in &lines[i + 1..] {
161 if end_re.is_match(inner_line) {
162 let inner_end = inner_line.as_ptr() as usize - text.as_ptr() as usize
164 + inner_line.len();
165 block_end = inner_end;
166 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
185fn 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 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#[derive(Debug, Default)]
224pub struct SchemaRegistry {
225 schemas: Vec<CompiledSchema>,
226 source_hashes: Vec<u64>,
233}
234
235impl SchemaRegistry {
236 #[must_use]
237 pub fn new() -> Self {
238 Self::default()
239 }
240
241 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 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 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 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 #[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 #[must_use]
282 pub const fn len(&self) -> usize {
283 self.schemas.len()
284 }
285
286 #[must_use]
288 pub const fn is_empty(&self) -> bool {
289 self.schemas.is_empty()
290 }
291
292 #[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 #[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 #[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 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 let text = "# Comment\n[section]\nkey = \"value\"";
410 let ranges = compiled.extract(text);
411 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 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}