Skip to main content

lang_check/
dictionary.rs

1use anyhow::Result;
2use std::collections::HashSet;
3use std::path::{Path, PathBuf};
4use tracing::{debug, warn};
5
6use crate::morphology::inflection;
7
8/// Manages custom dictionaries for the language checker.
9/// Words in the dictionary are excluded from spelling diagnostics.
10///
11/// Internally, user-added words and bundled words are kept in separate sets.
12/// Only user words are persisted to `dictionary.txt`.
13pub struct Dictionary {
14    user_words: HashSet<String>,
15    bundled_words: HashSet<String>,
16    /// Regular inflections generated from the other two sets by
17    /// [`crate::morphology::inflection`].
18    ///
19    /// Third set rather than folded into `bundled_words` so that [`Self::persist`] is
20    /// correct by construction: it reads `user_words` alone, and no derived form can
21    /// ever reach the file that records what the user actually typed.
22    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    /// Load dictionaries from a workspace root.
44    /// Reads from .languagecheck/dictionary.txt (one word per line).
45    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    /// Load the bundled dictionaries that ship with the extension.
64    /// These contain domain-specific technical terms from open-source wordlists.
65    /// Bundled words are kept separate and never persisted to the user's dictionary file.
66    pub fn load_bundled(&mut self) {
67        self.load_bundled_except(&[]);
68    }
69
70    /// Load every bundled dictionary whose name is not listed in `disabled`.
71    ///
72    /// Names match those in `bundled::ALL` and the section headings of
73    /// `dictionaries/THIRD_PARTY_NOTICES.md`, case-insensitively. An unrecognised
74    /// name is warned about rather than rejected: a config that silently does
75    /// nothing is harder to diagnose than one that says so.
76    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    /// Load additional words from a file path. The file is expected to contain
100    /// one word per line; lines starting with `#` and blank lines are skipped.
101    ///
102    /// Paths are resolved relative to `base` if they are not absolute.
103    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        // Security: refuse to read files outside the workspace or common config dirs.
115        // Canonicalize base too — on macOS /var → /private/var, on Windows UNC prefixes differ.
116        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    /// Add a word to the user dictionary and persist to disk.
134    ///
135    /// The word's regular inflections are derived at the same time, so adding `functor`
136    /// also accepts `functors` — that is the whole point of the feature, and a user who
137    /// has to add both has not been helped. Only the word itself is written to disk.
138    pub fn add_word(&mut self, word: &str) -> Result<()> {
139        let lower = word.to_lowercase();
140        if self.user_words.insert(lower.clone()) {
141            // A failed expansion must not lose the word the user just added, so this is
142            // reported and stepped over rather than propagated.
143            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    /// Generate the regular inflections of every word loaded so far.
155    ///
156    /// Call once, after every wordlist is in place: the expansion is a snapshot, and
157    /// words added later are inflected by [`Self::add_word`] instead.
158    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    /// Check if a word is in the dictionary (case-insensitive).
179    /// Checks both user words and bundled/external wordlists.
180    ///
181    /// A hyphenated compound also matches when every one of its parts is known
182    /// on its own, so `Chern-Simons` resolves from `chern` and `simons`. The
183    /// word handed to us is whatever span the engine reported, and the engines
184    /// disagree about whether a compound is one token or two — matching both
185    /// shapes here is the only place that can be correct for either.
186    #[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    /// Look a single already-lowercased token up in all three sets.
196    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    /// Whether `lower` is a hyphenated compound whose every part is known.
203    ///
204    /// A present hyphen always yields at least two parts, so the only shapes to
205    /// reject are the ones with an empty part — a bare `-`, a trailing `chern-`,
206    /// or a doubled `--` — which would otherwise decide "every part is known"
207    /// over a single token, or over nothing at all.
208    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    /// Return all words the user configured (user + bundled), excluding derived forms.
217    pub fn words(&self) -> impl Iterator<Item = &String> {
218        self.user_words.iter().chain(self.bundled_words.iter())
219    }
220
221    /// Return the number of words loaded (user + bundled), excluding derived forms.
222    /// A value that changes whenever the accepted words do.
223    ///
224    /// Read by the check cache. Adding a word to the dictionary must make the
225    /// misspelling it covers disappear, which cannot happen if a stored result
226    /// from before the addition is still served.
227    #[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    /// How many inflected forms were generated from those words.
240    #[must_use]
241    pub fn derived_len(&self) -> usize {
242        self.derived_words.len()
243    }
244
245    /// Whether the dictionary is empty.
246    #[must_use]
247    pub fn is_empty(&self) -> bool {
248        self.user_words.is_empty() && self.bundled_words.is_empty()
249    }
250
251    /// Persist only user-added words to the workspace dictionary file.
252    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
269/// Parse a wordlist string (one word per line) into a set.
270fn 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
279/// Bundled dictionary data embedded at compile time.
280/// See `dictionaries/THIRD_PARTY_NOTICES.md` for attribution and licensing.
281pub mod bundled {
282    /// Software development terms, tools, acronyms, and compound words.
283    /// Sources: cspell-dicts (software-terms, cpp). License: MIT.
284    pub const SOFTWARE_TERMS: &str = include_str!("../dictionaries/bundled/software-terms.txt");
285
286    /// TypeScript and JavaScript keywords, builtins, and API terms.
287    /// Source: cspell-dicts (typescript). License: MIT.
288    pub const TYPESCRIPT: &str = include_str!("../dictionaries/bundled/typescript.txt");
289
290    /// Well-known company and brand names.
291    /// Source: cspell-dicts (companies). License: MIT.
292    pub const COMPANIES: &str = include_str!("../dictionaries/bundled/companies.txt");
293
294    /// Computing jargon, hardware terms, and domain-specific vocabulary.
295    /// Sources: hunspell-jargon (MIT), `SpellCheckDic` (MIT),
296    ///          autoware-spell-check-dict (Apache-2.0).
297    pub const JARGON: &str = include_str!("../dictionaries/bundled/jargon.txt");
298
299    /// Mathematics, category theory, type theory, and mathematical physics terms.
300    ///
301    /// Source: nLab page titles, harvested by `scripts/build-nlab-dictionary.py`.
302    /// License: none formally stated; free use granted in exchange for attribution.
303    pub const MATHEMATICS: &str = include_str!("../dictionaries/bundled/mathematics.txt");
304
305    /// All bundled wordlists, keyed by the name users write in
306    /// `dictionaries.disabled` to turn one off.
307    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    /// Just the names from [`ALL`], for diagnostics.
316    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")); // case-insensitive
341        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        // Write
351        {
352            let mut dict = Dictionary::load(&dir).unwrap();
353            dict.add_word("kubernetes").unwrap();
354            dict.add_word("terraform").unwrap();
355        }
356
357        // Read back
358        {
359            let dict = Dictionary::load(&dir).unwrap();
360            assert!(dict.contains("kubernetes"));
361            assert!(dict.contains("Kubernetes")); // case-insensitive
362            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        // Should have thousands of words from bundled sources
412        assert!(
413            dict.len() > 5000,
414            "Expected > 5000 bundled words, got {}",
415            dict.len()
416        );
417
418        // Spot-check some well-known terms from each category
419        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        // Harvested with diacritics intact, and matched case-insensitively.
449        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        // A typo'd entry must not take the other dictionaries down with it.
474        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        // `mathematics.txt` carries `subalgebras` but not `subalgebra`; the reverse gap
528        // is what generation closes.
529        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        // Unicode hyphen (U+2010) and non-breaking hyphen (U+2011) too.
542        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        // A trailing, leading, or doubled hyphen leaves an empty part, which must
561        // not collapse into "every part is known".
562        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        // The compound rule widens what the word lists cover; it never invents
570        // vocabulary. Both halves must be present independently, so an ordinary
571        // hyphenated typo stays flagged.
572        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        // These appear in nLab `[[!redirects]]` aliases, which exist precisely so
586        // that misspelled links keep resolving. Harvesting them would suppress
587        // real typos, so the build script reads page titles only.
588        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        // Bundled words still present
612        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        // Add a user word alongside bundled words
641        {
642            let mut dict = Dictionary::load(&dir).unwrap();
643            dict.load_bundled();
644            dict.add_word("myuserword").unwrap();
645        }
646
647        // Read the persisted file directly — should only contain user words
648        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        // Reload and verify everything still works
660        {
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}