Skip to main content

fresh/primitives/grammar/
loader.rs

1//! Grammar loading with I/O abstraction.
2//!
3//! This module provides the `GrammarLoader` trait for loading grammars from various sources,
4//! and `LocalGrammarLoader` as the default filesystem-based implementation.
5
6use std::collections::HashMap;
7use std::io;
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10
11use syntect::parsing::{SyntaxSet, SyntaxSetBuilder};
12
13use super::types::{GrammarInfo, GrammarRegistry, GrammarSource, GrammarSpec, PackageManifest};
14
15/// Trait for loading grammar files from various sources.
16///
17/// This abstraction allows:
18/// - Testing with mock implementations
19/// - WASM builds with fetch-based loaders
20/// - Custom grammar sources (network, embedded, etc.)
21pub trait GrammarLoader: Send + Sync {
22    /// Get the user grammars directory path.
23    fn grammars_dir(&self) -> Option<PathBuf>;
24
25    /// Get the language packages directory path (installed via pkg manager).
26    fn languages_packages_dir(&self) -> Option<PathBuf>;
27
28    /// Get the bundles packages directory path (installed bundles with grammars).
29    fn bundles_packages_dir(&self) -> Option<PathBuf>;
30
31    /// Read file contents as string.
32    fn read_file(&self, path: &Path) -> io::Result<String>;
33
34    /// List entries in a directory.
35    fn read_dir(&self, path: &Path) -> io::Result<Vec<PathBuf>>;
36
37    /// Check if path exists.
38    fn exists(&self, path: &Path) -> bool;
39
40    /// Check if path is a directory.
41    fn is_dir(&self, path: &Path) -> bool;
42}
43
44/// Default implementation using local filesystem.
45pub struct LocalGrammarLoader {
46    config_dir: Option<PathBuf>,
47}
48
49impl LocalGrammarLoader {
50    /// Create a LocalGrammarLoader with the given config directory.
51    pub fn new(config_dir: PathBuf) -> Self {
52        Self {
53            config_dir: Some(config_dir),
54        }
55    }
56
57    /// Create a LocalGrammarLoader with no config directory (embedded grammars only).
58    pub fn embedded_only() -> Self {
59        Self { config_dir: None }
60    }
61}
62
63impl GrammarLoader for LocalGrammarLoader {
64    fn grammars_dir(&self) -> Option<PathBuf> {
65        self.config_dir.as_ref().map(|p| p.join("grammars"))
66    }
67
68    fn languages_packages_dir(&self) -> Option<PathBuf> {
69        self.config_dir
70            .as_ref()
71            .map(|p| p.join("languages/packages"))
72    }
73
74    fn bundles_packages_dir(&self) -> Option<PathBuf> {
75        self.config_dir.as_ref().map(|p| p.join("bundles/packages"))
76    }
77
78    fn read_file(&self, path: &Path) -> io::Result<String> {
79        std::fs::read_to_string(path)
80    }
81
82    fn read_dir(&self, path: &Path) -> io::Result<Vec<PathBuf>> {
83        let mut entries = Vec::new();
84        for entry in std::fs::read_dir(path)? {
85            entries.push(entry?.path());
86        }
87        Ok(entries)
88    }
89
90    fn exists(&self, path: &Path) -> bool {
91        path.exists()
92    }
93
94    fn is_dir(&self, path: &Path) -> bool {
95        path.is_dir()
96    }
97}
98
99// Builder/factory methods that use GrammarLoader
100impl GrammarRegistry {
101    /// Load grammar registry using a GrammarLoader.
102    ///
103    /// This loads:
104    /// 1. Built-in syntect grammars
105    /// 2. Embedded grammars (TOML, Odin, etc.)
106    /// 3. User-installed grammars from ~/.config/fresh/grammars/
107    /// 4. Language pack grammars from ~/.config/fresh/languages/packages/
108    pub fn load(loader: &dyn GrammarLoader) -> Self {
109        Self::load_with_additional(loader, &[])
110    }
111
112    /// Create a fully-loaded grammar registry for the editor.
113    /// Uses LocalGrammarLoader to load grammars from the filesystem.
114    pub fn for_editor(config_dir: std::path::PathBuf) -> Arc<Self> {
115        Arc::new(Self::load(&LocalGrammarLoader::new(config_dir)))
116    }
117
118    /// Create a fully-loaded grammar registry for the editor, also including
119    /// additional grammars registered by plugins.
120    ///
121    /// This performs a single build that combines filesystem grammars (user grammars,
122    /// language packs) with plugin-registered grammars, avoiding redundant rebuilds.
123    pub fn for_editor_with_additional(
124        config_dir: std::path::PathBuf,
125        additional: &[GrammarSpec],
126    ) -> Arc<Self> {
127        Arc::new(Self::load_with_additional(
128            &LocalGrammarLoader::new(config_dir),
129            additional,
130        ))
131    }
132
133    /// Load grammar registry using a GrammarLoader, including additional grammars.
134    ///
135    /// Same as `load()` but includes extra grammars (from plugins) in the same
136    /// builder pass, so only one `builder.build()` call is needed.
137    pub fn load_with_additional(loader: &dyn GrammarLoader, additional: &[GrammarSpec]) -> Self {
138        // Start with built-in extra extension mappings, user grammars override these
139        let mut user_extensions = Self::build_extra_extensions();
140
141        // Check if there are any user grammars or language packs to add
142        let has_user_grammars = loader.grammars_dir().is_some_and(|dir| loader.exists(&dir));
143        let has_language_packs = loader
144            .languages_packages_dir()
145            .is_some_and(|dir| loader.exists(&dir));
146        let has_bundle_packs = loader
147            .bundles_packages_dir()
148            .is_some_and(|dir| loader.exists(&dir));
149
150        let needs_builder =
151            has_user_grammars || has_language_packs || has_bundle_packs || !additional.is_empty();
152        let mut loaded_grammar_paths = Vec::new();
153        let mut grammar_sources: HashMap<String, GrammarInfo>;
154
155        let syntax_set = if !needs_builder {
156            // Fast path: no user additions or plugin grammars, use packdump directly
157            tracing::info!(
158                "[grammar-build] No user grammars, language packs, or plugin grammars — using pre-compiled packdump"
159            );
160            let ss: SyntaxSet = syntect::dumps::from_uncompressed_data(include_bytes!(concat!(
161                env!("OUT_DIR"),
162                "/default_syntaxes.packdump"
163            )))
164            .expect("Failed to load pre-compiled syntax packdump");
165            tracing::info!(
166                "[grammar-build] Loaded {} syntaxes from packdump",
167                ss.syntaxes().len()
168            );
169            // All packdump syntaxes are built-in
170            grammar_sources = Self::build_grammar_sources_from_syntax_set(&ss);
171            ss
172        } else {
173            // Slow path: need to add grammars, must go through builder
174            tracing::info!("[grammar-build] Loading pre-compiled packdump as builder base...");
175            let base: SyntaxSet = syntect::dumps::from_uncompressed_data(include_bytes!(concat!(
176                env!("OUT_DIR"),
177                "/default_syntaxes.packdump"
178            )))
179            .expect("Failed to load pre-compiled syntax packdump");
180            // Tag all base syntaxes as built-in before converting to builder
181            grammar_sources = Self::build_grammar_sources_from_syntax_set(&base);
182            tracing::info!("[grammar-build] Converting to builder...");
183            let mut builder = base.into_builder();
184
185            if has_user_grammars {
186                let grammars_dir = loader.grammars_dir().unwrap();
187                tracing::info!(
188                    "[grammar-build] Loading user grammars from {:?}...",
189                    grammars_dir
190                );
191                load_user_grammars(
192                    loader,
193                    &grammars_dir,
194                    &mut builder,
195                    &mut user_extensions,
196                    &mut grammar_sources,
197                );
198            }
199
200            if has_language_packs {
201                let packages_dir = loader.languages_packages_dir().unwrap();
202                tracing::info!(
203                    "[grammar-build] Loading language pack grammars from {:?}...",
204                    packages_dir
205                );
206                load_language_pack_grammars(
207                    loader,
208                    &packages_dir,
209                    &mut builder,
210                    &mut user_extensions,
211                    &mut grammar_sources,
212                );
213            }
214
215            if has_bundle_packs {
216                let bundles_dir = loader.bundles_packages_dir().unwrap();
217                tracing::info!(
218                    "[grammar-build] Loading bundle grammars from {:?}...",
219                    bundles_dir
220                );
221                load_bundle_grammars(
222                    loader,
223                    &bundles_dir,
224                    &mut builder,
225                    &mut user_extensions,
226                    &mut loaded_grammar_paths,
227                    &mut grammar_sources,
228                );
229            }
230
231            // Add plugin-registered grammars in the same builder pass
232            if !additional.is_empty() {
233                tracing::info!(
234                    "[grammar-build] Adding {} plugin-registered grammars...",
235                    additional.len()
236                );
237                for spec in additional {
238                    match Self::load_grammar_file(&spec.path) {
239                        Ok(syntax) => {
240                            let scope = syntax.scope.to_string();
241                            let syntax_name = syntax.name.clone();
242                            tracing::info!(
243                                "[grammar-build] Loaded plugin grammar '{}' from {:?}",
244                                spec.language,
245                                spec.path
246                            );
247                            builder.add(syntax);
248                            for ext in &spec.extensions {
249                                user_extensions.insert(ext.clone(), scope.clone());
250                            }
251                            grammar_sources.insert(
252                                syntax_name.clone(),
253                                GrammarInfo {
254                                    name: syntax_name,
255                                    source: GrammarSource::Plugin {
256                                        plugin: spec.language.clone(),
257                                        path: spec.path.clone(),
258                                    },
259                                    file_extensions: spec.extensions.clone(),
260                                    short_name: None,
261                                },
262                            );
263                            loaded_grammar_paths.push(spec.clone());
264                        }
265                        Err(e) => {
266                            tracing::warn!(
267                                "[grammar-build] Failed to load plugin grammar '{}' from {:?}: {}",
268                                spec.language,
269                                spec.path,
270                                e
271                            );
272                        }
273                    }
274                }
275            }
276
277            tracing::info!(
278                "[grammar-build] Building syntax set ({} syntaxes)...",
279                builder.syntaxes().len()
280            );
281            let ss = builder.build();
282            tracing::info!("[grammar-build] Syntax set built");
283            ss
284        };
285        let filename_scopes = Self::build_filename_scopes();
286
287        tracing::info!(
288            "Loaded {} syntaxes, {} user extension mappings, {} filename mappings",
289            syntax_set.syntaxes().len(),
290            user_extensions.len(),
291            filename_scopes.len()
292        );
293
294        let mut registry = Self::new_with_loaded_paths(
295            syntax_set,
296            user_extensions,
297            filename_scopes,
298            loaded_grammar_paths,
299            grammar_sources,
300        );
301
302        // Register short-name aliases: built-in first, then manifest-declared
303        registry.populate_built_in_aliases();
304        let manifest_aliases: Vec<(String, String)> = registry
305            .grammar_sources()
306            .values()
307            .filter_map(|info| {
308                info.short_name
309                    .as_ref()
310                    .map(|short| (short.clone(), info.name.clone()))
311            })
312            .collect();
313        for (short, full) in &manifest_aliases {
314            registry.register_alias(short, full);
315        }
316
317        registry
318    }
319
320    /// Get the grammars directory path for the given config directory.
321    pub fn grammars_directory(config_dir: &std::path::Path) -> PathBuf {
322        config_dir.join("grammars")
323    }
324}
325
326/// Load user grammars from a directory using the provided loader.
327fn load_user_grammars(
328    loader: &dyn GrammarLoader,
329    dir: &Path,
330    builder: &mut SyntaxSetBuilder,
331    user_extensions: &mut HashMap<String, String>,
332    grammar_sources: &mut HashMap<String, GrammarInfo>,
333) {
334    // Iterate through subdirectories looking for package.json or direct grammar files
335    let entries = match loader.read_dir(dir) {
336        Ok(entries) => entries,
337        Err(e) => {
338            tracing::warn!("Failed to read grammars directory {:?}: {}", dir, e);
339            return;
340        }
341    };
342
343    for path in entries {
344        if !loader.is_dir(&path) {
345            continue;
346        }
347
348        // Check for package.json (VSCode extension format)
349        let manifest_path = path.join("package.json");
350        if loader.exists(&manifest_path) {
351            if let Ok(manifest) = parse_package_json(loader, &manifest_path) {
352                process_manifest(
353                    loader,
354                    &path,
355                    manifest,
356                    builder,
357                    user_extensions,
358                    grammar_sources,
359                );
360            }
361            continue;
362        }
363
364        // Check for direct grammar files
365        let mut found_any = false;
366        load_direct_grammar(loader, &path, builder, &mut found_any, grammar_sources);
367    }
368}
369
370/// Parse a VSCode package.json manifest using the loader.
371fn parse_package_json(loader: &dyn GrammarLoader, path: &Path) -> Result<PackageManifest, String> {
372    let content = loader
373        .read_file(path)
374        .map_err(|e| format!("Failed to read file: {}", e))?;
375
376    serde_json::from_str(&content).map_err(|e| format!("Failed to parse JSON: {}", e))
377}
378
379/// Process a package manifest and load its grammars.
380fn process_manifest(
381    loader: &dyn GrammarLoader,
382    package_dir: &Path,
383    manifest: PackageManifest,
384    builder: &mut SyntaxSetBuilder,
385    user_extensions: &mut HashMap<String, String>,
386    grammar_sources: &mut HashMap<String, GrammarInfo>,
387) {
388    let contributes = match manifest.contributes {
389        Some(c) => c,
390        None => return,
391    };
392
393    // Build language ID -> extensions mapping
394    let mut lang_extensions: HashMap<String, Vec<String>> = HashMap::new();
395    for lang in &contributes.languages {
396        lang_extensions.insert(lang.id.clone(), lang.extensions.clone());
397    }
398
399    // Process each grammar
400    for grammar in &contributes.grammars {
401        let grammar_path = package_dir.join(&grammar.path);
402
403        if !loader.exists(&grammar_path) {
404            tracing::warn!("Grammar file not found: {:?}", grammar_path);
405            continue;
406        }
407
408        // Try to load the grammar
409        let grammar_dir = grammar_path.parent().unwrap_or(package_dir);
410        if let Err(e) = builder.add_from_folder(grammar_dir, false) {
411            tracing::warn!("Failed to load grammar {:?}: {}", grammar_path, e);
412            continue;
413        }
414
415        tracing::info!(
416            "Loaded grammar {} from {:?}",
417            grammar.scope_name,
418            grammar_path
419        );
420
421        // Map extensions to scope name and track provenance
422        let extensions: Vec<String> = lang_extensions
423            .get(&grammar.language)
424            .map(|exts| {
425                exts.iter()
426                    .map(|ext| {
427                        let ext_clean = ext.trim_start_matches('.').to_string();
428                        user_extensions.insert(ext_clean.clone(), grammar.scope_name.clone());
429                        tracing::debug!(
430                            "Mapped extension .{} to {}",
431                            ext_clean,
432                            grammar.scope_name
433                        );
434                        ext_clean
435                    })
436                    .collect()
437            })
438            .unwrap_or_default();
439
440        grammar_sources.insert(
441            grammar.language.clone(),
442            GrammarInfo {
443                name: grammar.language.clone(),
444                source: GrammarSource::User {
445                    path: grammar_path.clone(),
446                },
447                file_extensions: extensions,
448                short_name: None,
449            },
450        );
451    }
452}
453
454/// Load a grammar directly from a .sublime-syntax or .tmLanguage file.
455fn load_direct_grammar(
456    loader: &dyn GrammarLoader,
457    dir: &Path,
458    builder: &mut SyntaxSetBuilder,
459    found_any: &mut bool,
460    grammar_sources: &mut HashMap<String, GrammarInfo>,
461) {
462    // Look for .sublime-syntax or .tmLanguage files
463    let entries = match loader.read_dir(dir) {
464        Ok(e) => e,
465        Err(_) => return,
466    };
467
468    for path in entries {
469        let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
470
471        if file_name.ends_with(".tmLanguage") || file_name.ends_with(".sublime-syntax") {
472            let count_before = builder.syntaxes().len();
473            if let Err(e) = builder.add_from_folder(dir, false) {
474                tracing::warn!("Failed to load grammar from {:?}: {}", dir, e);
475            } else {
476                tracing::info!("Loaded grammar from {:?}", dir);
477                *found_any = true;
478                // Track any new syntaxes that were added
479                for syntax in builder.syntaxes()[count_before..].iter() {
480                    grammar_sources.insert(
481                        syntax.name.clone(),
482                        GrammarInfo {
483                            name: syntax.name.clone(),
484                            source: GrammarSource::User {
485                                path: dir.to_path_buf(),
486                            },
487                            file_extensions: syntax.file_extensions.clone(),
488                            short_name: None,
489                        },
490                    );
491                }
492            }
493            break;
494        }
495    }
496}
497
498/// Fresh-specific language pack manifest format
499#[derive(Debug, serde::Deserialize)]
500struct FreshPackageManifest {
501    name: String,
502    #[serde(default)]
503    fresh: Option<FreshConfig>,
504}
505
506#[derive(Debug, serde::Deserialize)]
507struct FreshConfig {
508    #[serde(default)]
509    grammar: Option<FreshGrammarConfig>,
510}
511
512#[derive(Debug, serde::Deserialize)]
513struct FreshGrammarConfig {
514    file: String,
515    #[serde(default)]
516    extensions: Vec<String>,
517    /// Optional short name alias for this grammar (e.g., "hare")
518    #[serde(default)]
519    short_name: Option<String>,
520}
521
522/// Load grammars from Fresh language packages (installed via pkg manager).
523///
524/// These packages use a Fresh-specific package.json format with:
525/// ```json
526/// {
527///   "name": "hare",
528///   "fresh": {
529///     "grammar": {
530///       "file": "grammars/Hare.sublime-syntax",
531///       "extensions": ["ha"]
532///     }
533///   }
534/// }
535/// ```
536fn load_language_pack_grammars(
537    loader: &dyn GrammarLoader,
538    packages_dir: &Path,
539    builder: &mut SyntaxSetBuilder,
540    user_extensions: &mut HashMap<String, String>,
541    grammar_sources: &mut HashMap<String, GrammarInfo>,
542) {
543    let entries = match loader.read_dir(packages_dir) {
544        Ok(entries) => entries,
545        Err(e) => {
546            tracing::debug!(
547                "Failed to read language packages directory {:?}: {}",
548                packages_dir,
549                e
550            );
551            return;
552        }
553    };
554
555    for package_path in entries {
556        if !loader.is_dir(&package_path) {
557            continue;
558        }
559
560        let manifest_path = package_path.join("package.json");
561        if !loader.exists(&manifest_path) {
562            continue;
563        }
564
565        // Try to parse as Fresh language pack format
566        let content = match loader.read_file(&manifest_path) {
567            Ok(c) => c,
568            Err(e) => {
569                tracing::debug!("Failed to read {:?}: {}", manifest_path, e);
570                continue;
571            }
572        };
573
574        let manifest: FreshPackageManifest = match serde_json::from_str(&content) {
575            Ok(m) => m,
576            Err(e) => {
577                tracing::debug!("Failed to parse {:?}: {}", manifest_path, e);
578                continue;
579            }
580        };
581
582        // Check for Fresh grammar config
583        let grammar_config = match manifest.fresh.and_then(|f| f.grammar) {
584            Some(g) => g,
585            None => continue,
586        };
587
588        let grammar_path = package_path.join(&grammar_config.file);
589        if !loader.exists(&grammar_path) {
590            tracing::warn!(
591                "Grammar file not found for language pack '{}': {:?}",
592                manifest.name,
593                grammar_path
594            );
595            continue;
596        }
597
598        // Load the grammar file
599        let content = match loader.read_file(&grammar_path) {
600            Ok(c) => c,
601            Err(e) => {
602                tracing::warn!("Failed to read grammar file {:?}: {}", grammar_path, e);
603                continue;
604            }
605        };
606
607        // Parse and add the syntax
608        match syntect::parsing::SyntaxDefinition::load_from_str(
609            &content,
610            true,
611            grammar_path.file_stem().and_then(|s| s.to_str()),
612        ) {
613            Ok(syntax) => {
614                let scope = syntax.scope.to_string();
615                let syntax_name = syntax.name.clone();
616                tracing::info!(
617                    "Loaded language pack grammar '{}' from {:?} (scope: {}, extensions: {:?})",
618                    manifest.name,
619                    grammar_path,
620                    scope,
621                    grammar_config.extensions
622                );
623                builder.add(syntax);
624
625                // Map extensions to scope
626                let mut clean_extensions = Vec::new();
627                for ext in &grammar_config.extensions {
628                    let ext_clean = ext.trim_start_matches('.');
629                    user_extensions.insert(ext_clean.to_string(), scope.clone());
630                    clean_extensions.push(ext_clean.to_string());
631                }
632
633                grammar_sources.insert(
634                    syntax_name.clone(),
635                    GrammarInfo {
636                        name: syntax_name,
637                        source: GrammarSource::LanguagePack {
638                            name: manifest.name.clone(),
639                            path: grammar_path.clone(),
640                        },
641                        file_extensions: clean_extensions,
642                        short_name: grammar_config.short_name.clone(),
643                    },
644                );
645            }
646            Err(e) => {
647                tracing::warn!(
648                    "Failed to parse grammar for language pack '{}': {}",
649                    manifest.name,
650                    e
651                );
652            }
653        }
654    }
655}
656
657/// Load grammars from bundle packages (installed via pkg manager).
658///
659/// Bundles use a `fresh.languages` array in their `package.json`, where each
660/// language entry may have a `grammar` with a `file` path. This loads all
661/// grammar files found in bundle manifests.
662fn load_bundle_grammars(
663    loader: &dyn GrammarLoader,
664    bundles_dir: &Path,
665    builder: &mut SyntaxSetBuilder,
666    user_extensions: &mut HashMap<String, String>,
667    loaded_grammar_paths: &mut Vec<GrammarSpec>,
668    grammar_sources: &mut HashMap<String, GrammarInfo>,
669) {
670    let entries = match loader.read_dir(bundles_dir) {
671        Ok(entries) => entries,
672        Err(e) => {
673            tracing::debug!(
674                "Failed to read bundle packages directory {:?}: {}",
675                bundles_dir,
676                e
677            );
678            return;
679        }
680    };
681
682    for package_path in entries {
683        if !loader.is_dir(&package_path) {
684            continue;
685        }
686
687        let manifest_path = package_path.join("package.json");
688        if !loader.exists(&manifest_path) {
689            continue;
690        }
691
692        let content = match loader.read_file(&manifest_path) {
693            Ok(c) => c,
694            Err(e) => {
695                tracing::debug!("Failed to read {:?}: {}", manifest_path, e);
696                continue;
697            }
698        };
699
700        // Parse the manifest to find bundle language grammars
701        let manifest: crate::services::packages::PackageManifest =
702            match serde_json::from_str(&content) {
703                Ok(m) => m,
704                Err(e) => {
705                    tracing::debug!("Failed to parse {:?}: {}", manifest_path, e);
706                    continue;
707                }
708            };
709
710        let fresh = match &manifest.fresh {
711            Some(f) => f,
712            None => continue,
713        };
714
715        for lang in &fresh.languages {
716            let grammar_config = match &lang.grammar {
717                Some(g) => g,
718                None => continue,
719            };
720
721            let grammar_path = package_path.join(&grammar_config.file);
722            if !loader.exists(&grammar_path) {
723                tracing::warn!(
724                    "Bundle grammar file not found for '{}' in '{}': {:?}",
725                    lang.id,
726                    manifest.name,
727                    grammar_path
728                );
729                continue;
730            }
731
732            let content = match loader.read_file(&grammar_path) {
733                Ok(c) => c,
734                Err(e) => {
735                    tracing::warn!("Failed to read bundle grammar {:?}: {}", grammar_path, e);
736                    continue;
737                }
738            };
739
740            match syntect::parsing::SyntaxDefinition::load_from_str(
741                &content,
742                true,
743                grammar_path.file_stem().and_then(|s| s.to_str()),
744            ) {
745                Ok(syntax) => {
746                    let scope = syntax.scope.to_string();
747                    let syntax_name = syntax.name.clone();
748                    tracing::info!(
749                        "Loaded bundle grammar '{}' from {:?} (scope: {}, extensions: {:?})",
750                        lang.id,
751                        grammar_path,
752                        scope,
753                        grammar_config.extensions
754                    );
755                    builder.add(syntax);
756
757                    for ext in &grammar_config.extensions {
758                        let ext_clean = ext.trim_start_matches('.');
759                        user_extensions.insert(ext_clean.to_string(), scope.clone());
760                    }
761
762                    grammar_sources.insert(
763                        syntax_name.clone(),
764                        GrammarInfo {
765                            name: syntax_name,
766                            source: GrammarSource::Bundle {
767                                name: manifest.name.clone(),
768                                path: grammar_path.clone(),
769                            },
770                            file_extensions: grammar_config.extensions.clone(),
771                            short_name: None,
772                        },
773                    );
774
775                    loaded_grammar_paths.push(GrammarSpec {
776                        language: lang.id.clone(),
777                        path: grammar_path,
778                        extensions: grammar_config.extensions.clone(),
779                    });
780                }
781                Err(e) => {
782                    tracing::warn!("Failed to parse bundle grammar for '{}': {}", lang.id, e);
783                }
784            }
785        }
786    }
787}
788
789#[cfg(test)]
790mod tests {
791    use super::*;
792
793    /// Mock grammar loader for testing
794    struct MockGrammarLoader {
795        grammars_dir: Option<PathBuf>,
796        files: HashMap<PathBuf, String>,
797        dirs: HashMap<PathBuf, Vec<PathBuf>>,
798    }
799
800    impl MockGrammarLoader {
801        fn new() -> Self {
802            Self {
803                grammars_dir: None,
804                files: HashMap::new(),
805                dirs: HashMap::new(),
806            }
807        }
808
809        #[allow(dead_code)]
810        fn with_grammars_dir(mut self, dir: PathBuf) -> Self {
811            self.grammars_dir = Some(dir);
812            self
813        }
814    }
815
816    impl GrammarLoader for MockGrammarLoader {
817        fn grammars_dir(&self) -> Option<PathBuf> {
818            self.grammars_dir.clone()
819        }
820
821        fn languages_packages_dir(&self) -> Option<PathBuf> {
822            None // Not used in current tests
823        }
824
825        fn bundles_packages_dir(&self) -> Option<PathBuf> {
826            None // Not used in current tests
827        }
828
829        fn read_file(&self, path: &Path) -> io::Result<String> {
830            self.files
831                .get(path)
832                .cloned()
833                .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "File not found"))
834        }
835
836        fn read_dir(&self, path: &Path) -> io::Result<Vec<PathBuf>> {
837            self.dirs
838                .get(path)
839                .cloned()
840                .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Directory not found"))
841        }
842
843        fn exists(&self, path: &Path) -> bool {
844            self.files.contains_key(path) || self.dirs.contains_key(path)
845        }
846
847        fn is_dir(&self, path: &Path) -> bool {
848            self.dirs.contains_key(path)
849        }
850    }
851
852    #[test]
853    fn test_mock_loader_no_grammars() {
854        let loader = MockGrammarLoader::new();
855        let registry = GrammarRegistry::load(&loader);
856
857        // Should still have built-in syntaxes
858        assert!(!registry.available_syntaxes().is_empty());
859    }
860
861    #[test]
862    fn test_local_loader_grammars_dir() {
863        let temp_dir = tempfile::tempdir().unwrap();
864        let config_dir = temp_dir.path().to_path_buf();
865        let loader = LocalGrammarLoader::new(config_dir.clone());
866        let grammars_dir = loader.grammars_dir();
867
868        // Should return the grammars subdirectory
869        assert!(grammars_dir.is_some());
870        let dir = grammars_dir.unwrap();
871        assert_eq!(dir, config_dir.join("grammars"));
872    }
873
874    #[test]
875    fn test_for_editor() {
876        let temp_dir = tempfile::tempdir().unwrap();
877        let config_dir = temp_dir.path().to_path_buf();
878        let registry = GrammarRegistry::for_editor(config_dir);
879        // Should have built-in syntaxes
880        assert!(!registry.available_syntaxes().is_empty());
881    }
882
883    #[test]
884    fn test_find_syntax_with_custom_languages_config() {
885        let temp_dir = tempfile::tempdir().unwrap();
886        let mut registry =
887            Arc::try_unwrap(GrammarRegistry::for_editor(temp_dir.path().to_path_buf()))
888                .ok()
889                .expect("registry should have refcount 1");
890
891        // Create a custom languages config that maps "custom.myext" files to bash
892        let mut languages = std::collections::HashMap::new();
893        languages.insert(
894            "bash".to_string(),
895            crate::config::LanguageConfig {
896                extensions: vec!["myext".to_string()],
897                filenames: vec!["CUSTOMBUILD".to_string()],
898                grammar: "Bourne Again Shell (bash)".to_string(),
899                comment_prefix: Some("#".to_string()),
900                auto_indent: true,
901                auto_close: None,
902                auto_surround: None,
903                textmate_grammar: None,
904                show_whitespace_tabs: true,
905                line_wrap: None,
906                wrap_column: None,
907                page_view: None,
908                page_width: None,
909                use_tabs: None,
910                tab_size: None,
911                formatter: None,
912                format_on_save: false,
913                on_save: vec![],
914                word_characters: None,
915                indent: None,
916            },
917        );
918        registry.apply_language_config(&languages);
919
920        // Custom filename resolves to bash via the catalog.
921        let entry = registry
922            .find_by_path(Path::new("CUSTOMBUILD"), None)
923            .unwrap();
924        assert!(
925            entry.display_name.to_lowercase().contains("bash")
926                || entry.display_name.to_lowercase().contains("shell"),
927            "CUSTOMBUILD should resolve to shell/bash, got: {}",
928            entry.display_name
929        );
930
931        // Custom extension resolves to bash via the catalog.
932        let entry = registry
933            .find_by_path(Path::new("script.myext"), None)
934            .unwrap();
935        assert!(
936            entry.display_name.to_lowercase().contains("bash")
937                || entry.display_name.to_lowercase().contains("shell"),
938            "script.myext should resolve to shell/bash, got: {}",
939            entry.display_name
940        );
941    }
942
943    #[test]
944    fn test_load_delegates_to_load_with_additional() {
945        // load() should produce the same result as load_with_additional(loader, &[])
946        let loader = MockGrammarLoader::new();
947        let registry_via_load = GrammarRegistry::load(&loader);
948        let registry_via_additional = GrammarRegistry::load_with_additional(&loader, &[]);
949
950        assert_eq!(
951            registry_via_load.available_syntaxes().len(),
952            registry_via_additional.available_syntaxes().len()
953        );
954        assert_eq!(
955            registry_via_load.user_extensions().len(),
956            registry_via_additional.user_extensions().len()
957        );
958        // No additional grammars loaded, so loaded_grammar_paths should be empty
959        assert!(registry_via_additional.loaded_grammar_paths().is_empty());
960    }
961
962    #[test]
963    fn test_load_with_additional_empty_is_same_as_load() {
964        // for_editor_with_additional with empty slice should behave like for_editor
965        let temp_dir = tempfile::tempdir().unwrap();
966        let config_dir = temp_dir.path().to_path_buf();
967        let registry = GrammarRegistry::for_editor_with_additional(config_dir, &[]);
968        assert!(!registry.available_syntaxes().is_empty());
969        assert!(registry.loaded_grammar_paths().is_empty());
970    }
971
972    #[test]
973    fn test_load_with_additional_bad_path_is_skipped() {
974        let loader = MockGrammarLoader::new();
975        let specs = vec![GrammarSpec {
976            language: "nonexistent".to_string(),
977            path: PathBuf::from("/nonexistent/grammar.sublime-syntax"),
978            extensions: vec!["nope".to_string()],
979        }];
980        let registry = GrammarRegistry::load_with_additional(&loader, &specs);
981        // Should still have built-in syntaxes
982        assert!(!registry.available_syntaxes().is_empty());
983        // The bad grammar should not be in loaded_grammar_paths
984        assert!(registry.loaded_grammar_paths().is_empty());
985        // The extension should NOT be mapped (grammar failed to load)
986        assert!(!registry.user_extensions().contains_key("nope"));
987    }
988
989    #[test]
990    fn test_list_all_syntaxes() {
991        let temp_dir = tempfile::tempdir().unwrap();
992        let registry = GrammarRegistry::for_editor(temp_dir.path().to_path_buf());
993        let syntax_set = registry.syntax_set();
994
995        let mut syntaxes: Vec<_> = syntax_set
996            .syntaxes()
997            .iter()
998            .map(|s| (s.name.as_str(), s.file_extensions.clone()))
999            .collect();
1000        syntaxes.sort_by(|a, b| a.0.cmp(b.0));
1001
1002        println!("\n=== Available Syntaxes ({} total) ===", syntaxes.len());
1003        for (name, exts) in &syntaxes {
1004            println!("  {} -> {:?}", name, exts);
1005        }
1006
1007        // Check TypeScript specifically
1008        println!("\n=== TypeScript Check ===");
1009        let ts_syntax = syntax_set.find_syntax_by_extension("ts");
1010        let tsx_syntax = syntax_set.find_syntax_by_extension("tsx");
1011        println!("  .ts  -> {:?}", ts_syntax.map(|s| &s.name));
1012        println!("  .tsx -> {:?}", tsx_syntax.map(|s| &s.name));
1013
1014        // This test always passes - it's for dumping info
1015        assert!(!syntaxes.is_empty());
1016    }
1017}