1use anyhow::Result;
2use std::collections::HashSet;
3use std::path::{Path, PathBuf};
4use tracing::{debug, warn};
5
6use crate::morphology::inflection;
7
8pub struct Dictionary {
14 user_words: HashSet<String>,
15 bundled_words: HashSet<String>,
16 derived_words: HashSet<String>,
23 workspace_path: Option<PathBuf>,
24}
25
26impl Default for Dictionary {
27 fn default() -> Self {
28 Self::new()
29 }
30}
31
32impl Dictionary {
33 #[must_use]
34 pub fn new() -> Self {
35 Self {
36 user_words: HashSet::new(),
37 bundled_words: HashSet::new(),
38 derived_words: HashSet::new(),
39 workspace_path: None,
40 }
41 }
42
43 pub fn load(workspace_root: &Path) -> Result<Self> {
46 let mut dict = Self::new();
47 let dict_path = workspace_root.join(".languagecheck").join("dictionary.txt");
48 dict.workspace_path = Some(dict_path.clone());
49
50 if dict_path.exists() {
51 let content = std::fs::read_to_string(&dict_path)?;
52 for line in content.lines() {
53 let word = line.trim();
54 if !word.is_empty() && !word.starts_with('#') {
55 dict.user_words.insert(word.to_lowercase());
56 }
57 }
58 }
59
60 Ok(dict)
61 }
62
63 pub fn load_bundled(&mut self) {
67 self.load_bundled_except(&[]);
68 }
69
70 pub fn load_bundled_except(&mut self, disabled: &[String]) {
77 for name in disabled {
78 if !bundled::ALL
79 .iter()
80 .any(|(known, _)| known.eq_ignore_ascii_case(name))
81 {
82 warn!(
83 name,
84 known = ?bundled::NAMES,
85 "Unknown bundled dictionary in dictionaries.disabled; ignoring"
86 );
87 }
88 }
89
90 for (name, words_str) in bundled::ALL {
91 if disabled.iter().any(|d| d.eq_ignore_ascii_case(name)) {
92 debug!(name, "Skipping bundled dictionary");
93 continue;
94 }
95 parse_wordlist_into(words_str, &mut self.bundled_words);
96 }
97 }
98
99 pub fn load_wordlist_file(&mut self, path: &Path, base: &Path) -> Result<()> {
104 let resolved = if path.is_absolute() {
105 path.to_path_buf()
106 } else {
107 base.join(path)
108 };
109
110 let resolved = resolved.canonicalize().map_err(|e| {
111 anyhow::anyhow!("Cannot resolve wordlist path {}: {e}", resolved.display())
112 })?;
113
114 let canonical_base = base.canonicalize().unwrap_or_else(|_| base.to_path_buf());
117 if !resolved.starts_with(&canonical_base)
118 && !resolved.starts_with(dirs::config_dir().unwrap_or_default())
119 && !resolved.starts_with(dirs::home_dir().unwrap_or_default().join(".config"))
120 {
121 anyhow::bail!(
122 "Wordlist path {} is outside the workspace and known config directories",
123 resolved.display()
124 );
125 }
126
127 let content = std::fs::read_to_string(&resolved)
128 .map_err(|e| anyhow::anyhow!("Cannot read wordlist {}: {e}", resolved.display()))?;
129 parse_wordlist_into(&content, &mut self.bundled_words);
130 Ok(())
131 }
132
133 pub fn add_word(&mut self, word: &str) -> Result<()> {
139 let lower = word.to_lowercase();
140 if self.user_words.insert(lower.clone()) {
141 match inflection::expand([lower.as_str()]) {
144 Ok(forms) => self.derived_words.extend(forms),
145 Err(error) => {
146 warn!(word = %lower, %error, "Could not inflect added word; the exact form is still accepted");
147 }
148 }
149 self.persist()?;
150 }
151 Ok(())
152 }
153
154 pub fn derive_inflections(&mut self) {
159 let lemmas: Vec<&str> = self
160 .user_words
161 .iter()
162 .chain(self.bundled_words.iter())
163 .map(String::as_str)
164 .collect();
165 match inflection::expand(lemmas) {
166 Ok(forms) => {
167 debug!(
168 lemmas = self.user_words.len() + self.bundled_words.len(),
169 derived = forms.len(),
170 "Generated dictionary inflections"
171 );
172 self.derived_words = forms;
173 }
174 Err(error) => warn!(%error, "Could not inflect the dictionary; exact matching only"),
175 }
176 }
177
178 #[must_use]
187 pub fn contains(&self, word: &str) -> bool {
188 let lower = word.to_lowercase();
189 if self.contains_exact(&lower) {
190 return true;
191 }
192 self.is_known_compound(&lower)
193 }
194
195 fn contains_exact(&self, lower: &str) -> bool {
197 self.user_words.contains(lower)
198 || self.bundled_words.contains(lower)
199 || self.derived_words.contains(lower)
200 }
201
202 fn is_known_compound(&self, lower: &str) -> bool {
209 const HYPHENS: [char; 3] = ['-', '\u{2010}', '\u{2011}'];
210 lower.contains(HYPHENS)
211 && lower
212 .split(HYPHENS)
213 .all(|part| !part.is_empty() && self.contains_exact(part))
214 }
215
216 pub fn words(&self) -> impl Iterator<Item = &String> {
218 self.user_words.iter().chain(self.bundled_words.iter())
219 }
220
221 #[must_use]
228 pub fn fingerprint(&self) -> u64 {
229 let mut sorted: Vec<&str> = self.words().map(String::as_str).collect();
230 sorted.sort_unstable();
231 crate::hashing::stable_hash(&sorted.join("\u{1f}"))
232 }
233
234 #[must_use]
235 pub fn len(&self) -> usize {
236 self.user_words.len() + self.bundled_words.len()
237 }
238
239 #[must_use]
241 pub fn derived_len(&self) -> usize {
242 self.derived_words.len()
243 }
244
245 #[must_use]
247 pub fn is_empty(&self) -> bool {
248 self.user_words.is_empty() && self.bundled_words.is_empty()
249 }
250
251 fn persist(&self) -> Result<()> {
253 let Some(path) = &self.workspace_path else {
254 return Ok(());
255 };
256
257 if let Some(parent) = path.parent() {
258 std::fs::create_dir_all(parent)?;
259 }
260
261 let mut words: Vec<&str> = self.user_words.iter().map(String::as_str).collect();
262 words.sort_unstable();
263 let content = words.join("\n");
264 std::fs::write(path, content + "\n")?;
265 Ok(())
266 }
267}
268
269fn parse_wordlist_into(content: &str, set: &mut HashSet<String>) {
271 for line in content.lines() {
272 let word = line.trim();
273 if !word.is_empty() && !word.starts_with('#') {
274 set.insert(word.to_lowercase());
275 }
276 }
277}
278
279pub mod bundled {
282 pub const SOFTWARE_TERMS: &str = include_str!("../dictionaries/bundled/software-terms.txt");
285
286 pub const TYPESCRIPT: &str = include_str!("../dictionaries/bundled/typescript.txt");
289
290 pub const COMPANIES: &str = include_str!("../dictionaries/bundled/companies.txt");
293
294 pub const JARGON: &str = include_str!("../dictionaries/bundled/jargon.txt");
298
299 pub const MATHEMATICS: &str = include_str!("../dictionaries/bundled/mathematics.txt");
304
305 pub const ALL: &[(&str, &str)] = &[
308 ("software-terms", SOFTWARE_TERMS),
309 ("typescript", TYPESCRIPT),
310 ("companies", COMPANIES),
311 ("jargon", JARGON),
312 ("mathematics", MATHEMATICS),
313 ];
314
315 pub const NAMES: &[&str] = &[
317 "software-terms",
318 "typescript",
319 "companies",
320 "jargon",
321 "mathematics",
322 ];
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328
329 #[test]
330 fn new_dictionary_is_empty() {
331 let dict = Dictionary::new();
332 assert!(!dict.contains("anything"));
333 }
334
335 #[test]
336 fn add_and_contains() {
337 let mut dict = Dictionary::new();
338 dict.user_words.insert("hello".to_string());
339 assert!(dict.contains("hello"));
340 assert!(dict.contains("Hello")); assert!(dict.contains("HELLO"));
342 }
343
344 #[test]
345 fn persistence_roundtrip() {
346 let dir = std::env::temp_dir().join("lang_check_test_dict");
347 let _ = std::fs::remove_dir_all(&dir);
348 std::fs::create_dir_all(&dir).unwrap();
349
350 {
352 let mut dict = Dictionary::load(&dir).unwrap();
353 dict.add_word("kubernetes").unwrap();
354 dict.add_word("terraform").unwrap();
355 }
356
357 {
359 let dict = Dictionary::load(&dir).unwrap();
360 assert!(dict.contains("kubernetes"));
361 assert!(dict.contains("Kubernetes")); assert!(dict.contains("terraform"));
363 assert!(!dict.contains("nonexistent"));
364 }
365
366 let _ = std::fs::remove_dir_all(&dir);
367 }
368
369 #[test]
370 fn skips_comments_and_blank_lines() {
371 let dir = std::env::temp_dir().join("lang_check_test_dict_comments");
372 let _ = std::fs::remove_dir_all(&dir);
373 let dict_dir = dir.join(".languagecheck");
374 std::fs::create_dir_all(&dict_dir).unwrap();
375 std::fs::write(
376 dict_dir.join("dictionary.txt"),
377 "# This is a comment\n\nkubernetes\n \n# Another comment\nterraform\n",
378 )
379 .unwrap();
380
381 let dict = Dictionary::load(&dir).unwrap();
382 assert!(dict.contains("kubernetes"));
383 assert!(dict.contains("terraform"));
384 assert_eq!(dict.words().count(), 2);
385
386 let _ = std::fs::remove_dir_all(&dir);
387 }
388
389 #[test]
390 fn add_duplicate_word_is_idempotent() {
391 let mut dict = Dictionary::new();
392 dict.user_words.insert("test".to_string());
393 let initial_count = dict.words().count();
394 dict.user_words.insert("test".to_string());
395 assert_eq!(dict.words().count(), initial_count);
396 }
397
398 #[test]
399 fn words_iterator() {
400 let mut dict = Dictionary::new();
401 dict.user_words.insert("alpha".to_string());
402 dict.user_words.insert("beta".to_string());
403 assert_eq!(dict.words().count(), 2);
404 }
405
406 #[test]
407 fn bundled_dictionaries_load() {
408 let mut dict = Dictionary::new();
409 dict.load_bundled();
410
411 assert!(
413 dict.len() > 5000,
414 "Expected > 5000 bundled words, got {}",
415 dict.len()
416 );
417
418 assert!(
420 dict.contains("kubernetes"),
421 "software-terms should include kubernetes"
422 );
423 assert!(
424 dict.contains("webpack"),
425 "software-terms should include webpack"
426 );
427 assert!(
428 dict.contains("instanceof"),
429 "typescript should include instanceof"
430 );
431 assert!(dict.contains("stdout"), "jargon should include stdout");
432 }
433
434 #[test]
435 fn mathematics_dictionary_loads() {
436 let mut dict = Dictionary::new();
437 dict.load_bundled();
438
439 for term in [
440 "monoidal",
441 "presheaf",
442 "colimit",
443 "endofunctor",
444 "cobordism",
445 ] {
446 assert!(dict.contains(term), "mathematics should include {term}");
447 }
448 assert!(dict.contains("étale"), "mathematics should include étale");
450 assert!(dict.contains("Grothendieck"), "lookup is case-insensitive");
451 }
452
453 #[test]
454 fn disabling_a_bundled_set_drops_only_that_set() {
455 let mut dict = Dictionary::new();
456 dict.load_bundled_except(&["mathematics".to_string()]);
457
458 assert!(!dict.contains("presheaf"), "mathematics should be skipped");
459 assert!(dict.contains("kubernetes"), "software-terms should remain");
460 assert!(dict.contains("instanceof"), "typescript should remain");
461 }
462
463 #[test]
464 fn disabled_set_names_are_case_insensitive() {
465 let mut dict = Dictionary::new();
466 dict.load_bundled_except(&["Mathematics".to_string()]);
467
468 assert!(!dict.contains("presheaf"));
469 }
470
471 #[test]
472 fn unknown_disabled_set_name_is_tolerated() {
473 let mut dict = Dictionary::new();
475 dict.load_bundled_except(&["mathmatics".to_string()]);
476
477 assert!(
478 dict.contains("presheaf"),
479 "nothing should have been skipped"
480 );
481 assert!(dict.contains("kubernetes"));
482 }
483
484 #[test]
485 fn every_bundled_set_has_a_name() {
486 assert_eq!(bundled::ALL.len(), bundled::NAMES.len());
487 for ((name, _), listed) in bundled::ALL.iter().zip(bundled::NAMES) {
488 assert_eq!(name, listed);
489 }
490 }
491
492 #[test]
493 fn derived_inflections_are_accepted() {
494 let mut dict = Dictionary::new();
495 dict.user_words.insert("functor".to_string());
496 assert!(!dict.contains("functors"));
497 dict.derive_inflections();
498 assert!(dict.contains("functors"));
499 assert!(dict.contains("Functors"), "and case-insensitively");
500 }
501
502 #[test]
503 fn derived_inflections_are_never_persisted() {
504 let dir = std::env::temp_dir().join("lang_check_test_derived_persist");
505 let _ = std::fs::remove_dir_all(&dir);
506 std::fs::create_dir_all(&dir).unwrap();
507
508 let mut dict = Dictionary::load(&dir).unwrap();
509 dict.add_word("functor").unwrap();
510 assert!(dict.contains("functors"), "the plural is accepted");
511
512 let written = std::fs::read_to_string(dir.join(".languagecheck/dictionary.txt")).unwrap();
513 assert_eq!(
514 written.trim(),
515 "functor",
516 "but only the typed word is recorded"
517 );
518
519 let _ = std::fs::remove_dir_all(&dir);
520 }
521
522 #[test]
523 fn a_bundled_word_inflects_too() {
524 let mut dict = Dictionary::new();
525 dict.load_bundled();
526 dict.derive_inflections();
527 assert!(dict.contains("preorders"));
530 assert!(dict.derived_len() > 1000);
531 }
532
533 #[test]
534 fn hyphenated_compound_matches_when_all_parts_known() {
535 let mut dict = Dictionary::new();
536 dict.load_bundled();
537
538 assert!(dict.contains("Chern-Simons"));
539 assert!(dict.contains("Yang-Mills"));
540 assert!(dict.contains("Seiberg-Witten"));
541 assert!(dict.contains("Chern\u{2010}Simons"));
543 assert!(dict.contains("Chern\u{2011}Simons"));
544 }
545
546 #[test]
547 fn hyphenated_compound_rejected_when_a_part_is_unknown() {
548 let mut dict = Dictionary::new();
549 dict.user_words.insert("chern".to_string());
550
551 assert!(!dict.contains("chern-simmmons"));
552 assert!(!dict.contains("cherm-chern"));
553 }
554
555 #[test]
556 fn hyphen_split_rejects_empty_parts() {
557 let mut dict = Dictionary::new();
558 dict.user_words.insert("chern".to_string());
559
560 for input in ["chern-", "-chern", "chern--chern", "-", "--"] {
563 assert!(!dict.contains(input), "{input} must not match");
564 }
565 }
566
567 #[test]
568 fn hyphen_split_only_accepts_words_the_lists_already_carry() {
569 let mut dict = Dictionary::new();
573 dict.user_words.insert("chern".to_string());
574 dict.user_words.insert("simons".to_string());
575
576 assert!(dict.contains("chern-simons"));
577 assert!(!dict.contains("well-known"));
578 }
579
580 #[test]
581 fn mathematics_dictionary_excludes_nlab_misspellings() {
582 let mut dict = Dictionary::new();
583 dict.load_bundled();
584
585 for typo in [
589 "alebraic",
590 "cohomlogy",
591 "basises",
592 "automorpism",
593 "geoemtric",
594 ] {
595 assert!(
596 !dict.contains(typo),
597 "{typo} must not be an accepted spelling"
598 );
599 }
600 }
601
602 #[test]
603 fn bundled_plus_user_words() {
604 let mut dict = Dictionary::new();
605 dict.load_bundled();
606 let bundled_count = dict.len();
607
608 dict.user_words.insert("myprojectword".to_string());
609 assert_eq!(dict.len(), bundled_count + 1);
610 assert!(dict.contains("myprojectword"));
611 assert!(dict.contains("kubernetes"));
613 }
614
615 #[test]
616 fn load_wordlist_file_works() {
617 let dir = std::env::temp_dir().join("lang_check_test_wordlist");
618 let _ = std::fs::remove_dir_all(&dir);
619 std::fs::create_dir_all(&dir).unwrap();
620
621 let wordlist = dir.join("custom.txt");
622 std::fs::write(&wordlist, "# My custom words\nfoobar\nbazqux\n").unwrap();
623
624 let mut dict = Dictionary::new();
625 dict.load_wordlist_file(&wordlist, &dir).unwrap();
626
627 assert!(dict.contains("foobar"));
628 assert!(dict.contains("bazqux"));
629 assert_eq!(dict.len(), 2);
630
631 let _ = std::fs::remove_dir_all(&dir);
632 }
633
634 #[test]
635 fn persistence_excludes_bundled_words() {
636 let dir = std::env::temp_dir().join("lang_check_test_dict_bundled_persist");
637 let _ = std::fs::remove_dir_all(&dir);
638 std::fs::create_dir_all(&dir).unwrap();
639
640 {
642 let mut dict = Dictionary::load(&dir).unwrap();
643 dict.load_bundled();
644 dict.add_word("myuserword").unwrap();
645 }
646
647 let dict_path = dir.join(".languagecheck").join("dictionary.txt");
649 let content = std::fs::read_to_string(&dict_path).unwrap();
650 assert!(
651 content.contains("myuserword"),
652 "User word should be persisted"
653 );
654 assert!(
655 !content.contains("kubernetes"),
656 "Bundled words should NOT be persisted"
657 );
658
659 {
661 let mut dict = Dictionary::load(&dir).unwrap();
662 dict.load_bundled();
663 assert!(dict.contains("myuserword"));
664 assert!(dict.contains("kubernetes"));
665 }
666
667 let _ = std::fs::remove_dir_all(&dir);
668 }
669
670 #[test]
671 fn load_wordlist_file_relative_path() {
672 let dir = std::env::temp_dir().join("lang_check_test_wordlist_rel");
673 let _ = std::fs::remove_dir_all(&dir);
674 std::fs::create_dir_all(&dir).unwrap();
675
676 std::fs::write(dir.join("terms.txt"), "myterm\n").unwrap();
677
678 let mut dict = Dictionary::new();
679 dict.load_wordlist_file(Path::new("terms.txt"), &dir)
680 .unwrap();
681
682 assert!(dict.contains("myterm"));
683
684 let _ = std::fs::remove_dir_all(&dir);
685 }
686}