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]
223 pub fn len(&self) -> usize {
224 self.user_words.len() + self.bundled_words.len()
225 }
226
227 #[must_use]
229 pub fn derived_len(&self) -> usize {
230 self.derived_words.len()
231 }
232
233 #[must_use]
235 pub fn is_empty(&self) -> bool {
236 self.user_words.is_empty() && self.bundled_words.is_empty()
237 }
238
239 fn persist(&self) -> Result<()> {
241 let Some(path) = &self.workspace_path else {
242 return Ok(());
243 };
244
245 if let Some(parent) = path.parent() {
246 std::fs::create_dir_all(parent)?;
247 }
248
249 let mut words: Vec<&str> = self.user_words.iter().map(String::as_str).collect();
250 words.sort_unstable();
251 let content = words.join("\n");
252 std::fs::write(path, content + "\n")?;
253 Ok(())
254 }
255}
256
257fn parse_wordlist_into(content: &str, set: &mut HashSet<String>) {
259 for line in content.lines() {
260 let word = line.trim();
261 if !word.is_empty() && !word.starts_with('#') {
262 set.insert(word.to_lowercase());
263 }
264 }
265}
266
267pub mod bundled {
270 pub const SOFTWARE_TERMS: &str = include_str!("../dictionaries/bundled/software-terms.txt");
273
274 pub const TYPESCRIPT: &str = include_str!("../dictionaries/bundled/typescript.txt");
277
278 pub const COMPANIES: &str = include_str!("../dictionaries/bundled/companies.txt");
281
282 pub const JARGON: &str = include_str!("../dictionaries/bundled/jargon.txt");
286
287 pub const MATHEMATICS: &str = include_str!("../dictionaries/bundled/mathematics.txt");
292
293 pub const ALL: &[(&str, &str)] = &[
296 ("software-terms", SOFTWARE_TERMS),
297 ("typescript", TYPESCRIPT),
298 ("companies", COMPANIES),
299 ("jargon", JARGON),
300 ("mathematics", MATHEMATICS),
301 ];
302
303 pub const NAMES: &[&str] = &[
305 "software-terms",
306 "typescript",
307 "companies",
308 "jargon",
309 "mathematics",
310 ];
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316
317 #[test]
318 fn new_dictionary_is_empty() {
319 let dict = Dictionary::new();
320 assert!(!dict.contains("anything"));
321 }
322
323 #[test]
324 fn add_and_contains() {
325 let mut dict = Dictionary::new();
326 dict.user_words.insert("hello".to_string());
327 assert!(dict.contains("hello"));
328 assert!(dict.contains("Hello")); assert!(dict.contains("HELLO"));
330 }
331
332 #[test]
333 fn persistence_roundtrip() {
334 let dir = std::env::temp_dir().join("lang_check_test_dict");
335 let _ = std::fs::remove_dir_all(&dir);
336 std::fs::create_dir_all(&dir).unwrap();
337
338 {
340 let mut dict = Dictionary::load(&dir).unwrap();
341 dict.add_word("kubernetes").unwrap();
342 dict.add_word("terraform").unwrap();
343 }
344
345 {
347 let dict = Dictionary::load(&dir).unwrap();
348 assert!(dict.contains("kubernetes"));
349 assert!(dict.contains("Kubernetes")); assert!(dict.contains("terraform"));
351 assert!(!dict.contains("nonexistent"));
352 }
353
354 let _ = std::fs::remove_dir_all(&dir);
355 }
356
357 #[test]
358 fn skips_comments_and_blank_lines() {
359 let dir = std::env::temp_dir().join("lang_check_test_dict_comments");
360 let _ = std::fs::remove_dir_all(&dir);
361 let dict_dir = dir.join(".languagecheck");
362 std::fs::create_dir_all(&dict_dir).unwrap();
363 std::fs::write(
364 dict_dir.join("dictionary.txt"),
365 "# This is a comment\n\nkubernetes\n \n# Another comment\nterraform\n",
366 )
367 .unwrap();
368
369 let dict = Dictionary::load(&dir).unwrap();
370 assert!(dict.contains("kubernetes"));
371 assert!(dict.contains("terraform"));
372 assert_eq!(dict.words().count(), 2);
373
374 let _ = std::fs::remove_dir_all(&dir);
375 }
376
377 #[test]
378 fn add_duplicate_word_is_idempotent() {
379 let mut dict = Dictionary::new();
380 dict.user_words.insert("test".to_string());
381 let initial_count = dict.words().count();
382 dict.user_words.insert("test".to_string());
383 assert_eq!(dict.words().count(), initial_count);
384 }
385
386 #[test]
387 fn words_iterator() {
388 let mut dict = Dictionary::new();
389 dict.user_words.insert("alpha".to_string());
390 dict.user_words.insert("beta".to_string());
391 assert_eq!(dict.words().count(), 2);
392 }
393
394 #[test]
395 fn bundled_dictionaries_load() {
396 let mut dict = Dictionary::new();
397 dict.load_bundled();
398
399 assert!(
401 dict.len() > 5000,
402 "Expected > 5000 bundled words, got {}",
403 dict.len()
404 );
405
406 assert!(
408 dict.contains("kubernetes"),
409 "software-terms should include kubernetes"
410 );
411 assert!(
412 dict.contains("webpack"),
413 "software-terms should include webpack"
414 );
415 assert!(
416 dict.contains("instanceof"),
417 "typescript should include instanceof"
418 );
419 assert!(dict.contains("stdout"), "jargon should include stdout");
420 }
421
422 #[test]
423 fn mathematics_dictionary_loads() {
424 let mut dict = Dictionary::new();
425 dict.load_bundled();
426
427 for term in [
428 "monoidal",
429 "presheaf",
430 "colimit",
431 "endofunctor",
432 "cobordism",
433 ] {
434 assert!(dict.contains(term), "mathematics should include {term}");
435 }
436 assert!(dict.contains("étale"), "mathematics should include étale");
438 assert!(dict.contains("Grothendieck"), "lookup is case-insensitive");
439 }
440
441 #[test]
442 fn disabling_a_bundled_set_drops_only_that_set() {
443 let mut dict = Dictionary::new();
444 dict.load_bundled_except(&["mathematics".to_string()]);
445
446 assert!(!dict.contains("presheaf"), "mathematics should be skipped");
447 assert!(dict.contains("kubernetes"), "software-terms should remain");
448 assert!(dict.contains("instanceof"), "typescript should remain");
449 }
450
451 #[test]
452 fn disabled_set_names_are_case_insensitive() {
453 let mut dict = Dictionary::new();
454 dict.load_bundled_except(&["Mathematics".to_string()]);
455
456 assert!(!dict.contains("presheaf"));
457 }
458
459 #[test]
460 fn unknown_disabled_set_name_is_tolerated() {
461 let mut dict = Dictionary::new();
463 dict.load_bundled_except(&["mathmatics".to_string()]);
464
465 assert!(
466 dict.contains("presheaf"),
467 "nothing should have been skipped"
468 );
469 assert!(dict.contains("kubernetes"));
470 }
471
472 #[test]
473 fn every_bundled_set_has_a_name() {
474 assert_eq!(bundled::ALL.len(), bundled::NAMES.len());
475 for ((name, _), listed) in bundled::ALL.iter().zip(bundled::NAMES) {
476 assert_eq!(name, listed);
477 }
478 }
479
480 #[test]
481 fn derived_inflections_are_accepted() {
482 let mut dict = Dictionary::new();
483 dict.user_words.insert("functor".to_string());
484 assert!(!dict.contains("functors"));
485 dict.derive_inflections();
486 assert!(dict.contains("functors"));
487 assert!(dict.contains("Functors"), "and case-insensitively");
488 }
489
490 #[test]
491 fn derived_inflections_are_never_persisted() {
492 let dir = std::env::temp_dir().join("lang_check_test_derived_persist");
493 let _ = std::fs::remove_dir_all(&dir);
494 std::fs::create_dir_all(&dir).unwrap();
495
496 let mut dict = Dictionary::load(&dir).unwrap();
497 dict.add_word("functor").unwrap();
498 assert!(dict.contains("functors"), "the plural is accepted");
499
500 let written = std::fs::read_to_string(dir.join(".languagecheck/dictionary.txt")).unwrap();
501 assert_eq!(
502 written.trim(),
503 "functor",
504 "but only the typed word is recorded"
505 );
506
507 let _ = std::fs::remove_dir_all(&dir);
508 }
509
510 #[test]
511 fn a_bundled_word_inflects_too() {
512 let mut dict = Dictionary::new();
513 dict.load_bundled();
514 dict.derive_inflections();
515 assert!(dict.contains("preorders"));
518 assert!(dict.derived_len() > 1000);
519 }
520
521 #[test]
522 fn hyphenated_compound_matches_when_all_parts_known() {
523 let mut dict = Dictionary::new();
524 dict.load_bundled();
525
526 assert!(dict.contains("Chern-Simons"));
527 assert!(dict.contains("Yang-Mills"));
528 assert!(dict.contains("Seiberg-Witten"));
529 assert!(dict.contains("Chern\u{2010}Simons"));
531 assert!(dict.contains("Chern\u{2011}Simons"));
532 }
533
534 #[test]
535 fn hyphenated_compound_rejected_when_a_part_is_unknown() {
536 let mut dict = Dictionary::new();
537 dict.user_words.insert("chern".to_string());
538
539 assert!(!dict.contains("chern-simmmons"));
540 assert!(!dict.contains("cherm-chern"));
541 }
542
543 #[test]
544 fn hyphen_split_rejects_empty_parts() {
545 let mut dict = Dictionary::new();
546 dict.user_words.insert("chern".to_string());
547
548 for input in ["chern-", "-chern", "chern--chern", "-", "--"] {
551 assert!(!dict.contains(input), "{input} must not match");
552 }
553 }
554
555 #[test]
556 fn hyphen_split_only_accepts_words_the_lists_already_carry() {
557 let mut dict = Dictionary::new();
561 dict.user_words.insert("chern".to_string());
562 dict.user_words.insert("simons".to_string());
563
564 assert!(dict.contains("chern-simons"));
565 assert!(!dict.contains("well-known"));
566 }
567
568 #[test]
569 fn mathematics_dictionary_excludes_nlab_misspellings() {
570 let mut dict = Dictionary::new();
571 dict.load_bundled();
572
573 for typo in [
577 "alebraic",
578 "cohomlogy",
579 "basises",
580 "automorpism",
581 "geoemtric",
582 ] {
583 assert!(
584 !dict.contains(typo),
585 "{typo} must not be an accepted spelling"
586 );
587 }
588 }
589
590 #[test]
591 fn bundled_plus_user_words() {
592 let mut dict = Dictionary::new();
593 dict.load_bundled();
594 let bundled_count = dict.len();
595
596 dict.user_words.insert("myprojectword".to_string());
597 assert_eq!(dict.len(), bundled_count + 1);
598 assert!(dict.contains("myprojectword"));
599 assert!(dict.contains("kubernetes"));
601 }
602
603 #[test]
604 fn load_wordlist_file_works() {
605 let dir = std::env::temp_dir().join("lang_check_test_wordlist");
606 let _ = std::fs::remove_dir_all(&dir);
607 std::fs::create_dir_all(&dir).unwrap();
608
609 let wordlist = dir.join("custom.txt");
610 std::fs::write(&wordlist, "# My custom words\nfoobar\nbazqux\n").unwrap();
611
612 let mut dict = Dictionary::new();
613 dict.load_wordlist_file(&wordlist, &dir).unwrap();
614
615 assert!(dict.contains("foobar"));
616 assert!(dict.contains("bazqux"));
617 assert_eq!(dict.len(), 2);
618
619 let _ = std::fs::remove_dir_all(&dir);
620 }
621
622 #[test]
623 fn persistence_excludes_bundled_words() {
624 let dir = std::env::temp_dir().join("lang_check_test_dict_bundled_persist");
625 let _ = std::fs::remove_dir_all(&dir);
626 std::fs::create_dir_all(&dir).unwrap();
627
628 {
630 let mut dict = Dictionary::load(&dir).unwrap();
631 dict.load_bundled();
632 dict.add_word("myuserword").unwrap();
633 }
634
635 let dict_path = dir.join(".languagecheck").join("dictionary.txt");
637 let content = std::fs::read_to_string(&dict_path).unwrap();
638 assert!(
639 content.contains("myuserword"),
640 "User word should be persisted"
641 );
642 assert!(
643 !content.contains("kubernetes"),
644 "Bundled words should NOT be persisted"
645 );
646
647 {
649 let mut dict = Dictionary::load(&dir).unwrap();
650 dict.load_bundled();
651 assert!(dict.contains("myuserword"));
652 assert!(dict.contains("kubernetes"));
653 }
654
655 let _ = std::fs::remove_dir_all(&dir);
656 }
657
658 #[test]
659 fn load_wordlist_file_relative_path() {
660 let dir = std::env::temp_dir().join("lang_check_test_wordlist_rel");
661 let _ = std::fs::remove_dir_all(&dir);
662 std::fs::create_dir_all(&dir).unwrap();
663
664 std::fs::write(dir.join("terms.txt"), "myterm\n").unwrap();
665
666 let mut dict = Dictionary::new();
667 dict.load_wordlist_file(Path::new("terms.txt"), &dir)
668 .unwrap();
669
670 assert!(dict.contains("myterm"));
671
672 let _ = std::fs::remove_dir_all(&dir);
673 }
674}