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 });
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#[derive(Debug, Default)]
222pub struct SchemaRegistry {
223 schemas: Vec<CompiledSchema>,
224 source_hashes: Vec<u64>,
231}
232
233impl SchemaRegistry {
234 #[must_use]
235 pub fn new() -> Self {
236 Self::default()
237 }
238
239 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 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 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 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 #[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 #[must_use]
280 pub const fn len(&self) -> usize {
281 self.schemas.len()
282 }
283
284 #[must_use]
286 pub const fn is_empty(&self) -> bool {
287 self.schemas.is_empty()
288 }
289
290 #[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 #[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 #[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 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 let text = "# Comment\n[section]\nkey = \"value\"";
408 let ranges = compiled.extract(text);
409 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 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}