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    #[must_use]
223    pub fn len(&self) -> usize {
224        self.user_words.len() + self.bundled_words.len()
225    }
226
227    /// How many inflected forms were generated from those words.
228    #[must_use]
229    pub fn derived_len(&self) -> usize {
230        self.derived_words.len()
231    }
232
233    /// Whether the dictionary is empty.
234    #[must_use]
235    pub fn is_empty(&self) -> bool {
236        self.user_words.is_empty() && self.bundled_words.is_empty()
237    }
238
239    /// Persist only user-added words to the workspace dictionary file.
240    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
257/// Parse a wordlist string (one word per line) into a set.
258fn 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
267/// Bundled dictionary data embedded at compile time.
268/// See `dictionaries/THIRD_PARTY_NOTICES.md` for attribution and licensing.
269pub mod bundled {
270    /// Software development terms, tools, acronyms, and compound words.
271    /// Sources: cspell-dicts (software-terms, cpp). License: MIT.
272    pub const SOFTWARE_TERMS: &str = include_str!("../dictionaries/bundled/software-terms.txt");
273
274    /// TypeScript and JavaScript keywords, builtins, and API terms.
275    /// Source: cspell-dicts (typescript). License: MIT.
276    pub const TYPESCRIPT: &str = include_str!("../dictionaries/bundled/typescript.txt");
277
278    /// Well-known company and brand names.
279    /// Source: cspell-dicts (companies). License: MIT.
280    pub const COMPANIES: &str = include_str!("../dictionaries/bundled/companies.txt");
281
282    /// Computing jargon, hardware terms, and domain-specific vocabulary.
283    /// Sources: hunspell-jargon (MIT), `SpellCheckDic` (MIT),
284    ///          autoware-spell-check-dict (Apache-2.0).
285    pub const JARGON: &str = include_str!("../dictionaries/bundled/jargon.txt");
286
287    /// Mathematics, category theory, type theory, and mathematical physics terms.
288    ///
289    /// Source: nLab page titles, harvested by `scripts/build-nlab-dictionary.py`.
290    /// License: none formally stated; free use granted in exchange for attribution.
291    pub const MATHEMATICS: &str = include_str!("../dictionaries/bundled/mathematics.txt");
292
293    /// All bundled wordlists, keyed by the name users write in
294    /// `dictionaries.disabled` to turn one off.
295    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    /// Just the names from [`ALL`], for diagnostics.
304    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")); // case-insensitive
329        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        // Write
339        {
340            let mut dict = Dictionary::load(&dir).unwrap();
341            dict.add_word("kubernetes").unwrap();
342            dict.add_word("terraform").unwrap();
343        }
344
345        // Read back
346        {
347            let dict = Dictionary::load(&dir).unwrap();
348            assert!(dict.contains("kubernetes"));
349            assert!(dict.contains("Kubernetes")); // case-insensitive
350            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        // Should have thousands of words from bundled sources
400        assert!(
401            dict.len() > 5000,
402            "Expected > 5000 bundled words, got {}",
403            dict.len()
404        );
405
406        // Spot-check some well-known terms from each category
407        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        // Harvested with diacritics intact, and matched case-insensitively.
437        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        // A typo'd entry must not take the other dictionaries down with it.
462        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        // `mathematics.txt` carries `subalgebras` but not `subalgebra`; the reverse gap
516        // is what generation closes.
517        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        // Unicode hyphen (U+2010) and non-breaking hyphen (U+2011) too.
530        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        // A trailing, leading, or doubled hyphen leaves an empty part, which must
549        // not collapse into "every part is known".
550        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        // The compound rule widens what the word lists cover; it never invents
558        // vocabulary. Both halves must be present independently, so an ordinary
559        // hyphenated typo stays flagged.
560        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        // These appear in nLab `[[!redirects]]` aliases, which exist precisely so
574        // that misspelled links keep resolving. Harvesting them would suppress
575        // real typos, so the build script reads page titles only.
576        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        // Bundled words still present
600        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        // Add a user word alongside bundled words
629        {
630            let mut dict = Dictionary::load(&dir).unwrap();
631            dict.load_bundled();
632            dict.add_word("myuserword").unwrap();
633        }
634
635        // Read the persisted file directly — should only contain user words
636        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        // Reload and verify everything still works
648        {
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}