use std::collections::HashMap;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use syntect::parsing::{SyntaxSet, SyntaxSetBuilder};
use super::types::{GrammarRegistry, PackageManifest};
pub trait GrammarLoader: Send + Sync {
fn grammars_dir(&self) -> Option<PathBuf>;
fn languages_packages_dir(&self) -> Option<PathBuf>;
fn read_file(&self, path: &Path) -> io::Result<String>;
fn read_dir(&self, path: &Path) -> io::Result<Vec<PathBuf>>;
fn exists(&self, path: &Path) -> bool;
fn is_dir(&self, path: &Path) -> bool;
}
pub struct LocalGrammarLoader {
config_dir: Option<PathBuf>,
}
impl LocalGrammarLoader {
pub fn new() -> Self {
Self {
config_dir: dirs::config_dir(),
}
}
pub fn with_config_dir(config_dir: Option<PathBuf>) -> Self {
Self { config_dir }
}
}
impl Default for LocalGrammarLoader {
fn default() -> Self {
Self::new()
}
}
impl GrammarLoader for LocalGrammarLoader {
fn grammars_dir(&self) -> Option<PathBuf> {
self.config_dir.as_ref().map(|p| p.join("fresh/grammars"))
}
fn languages_packages_dir(&self) -> Option<PathBuf> {
self.config_dir
.as_ref()
.map(|p| p.join("fresh/languages/packages"))
}
fn read_file(&self, path: &Path) -> io::Result<String> {
std::fs::read_to_string(path)
}
fn read_dir(&self, path: &Path) -> io::Result<Vec<PathBuf>> {
let mut entries = Vec::new();
for entry in std::fs::read_dir(path)? {
entries.push(entry?.path());
}
Ok(entries)
}
fn exists(&self, path: &Path) -> bool {
path.exists()
}
fn is_dir(&self, path: &Path) -> bool {
path.is_dir()
}
}
impl GrammarRegistry {
pub fn load(loader: &dyn GrammarLoader) -> Self {
let mut user_extensions = HashMap::new();
let defaults = SyntaxSet::load_defaults_newlines();
let mut builder = defaults.into_builder();
Self::add_embedded_grammars(&mut builder);
if let Some(grammars_dir) = loader.grammars_dir() {
if loader.exists(&grammars_dir) {
load_user_grammars(loader, &grammars_dir, &mut builder, &mut user_extensions);
}
}
if let Some(packages_dir) = loader.languages_packages_dir() {
if loader.exists(&packages_dir) {
load_language_pack_grammars(
loader,
&packages_dir,
&mut builder,
&mut user_extensions,
);
}
}
let syntax_set = builder.build();
let filename_scopes = Self::build_filename_scopes();
tracing::info!(
"Loaded {} syntaxes, {} user extension mappings, {} filename mappings",
syntax_set.syntaxes().len(),
user_extensions.len(),
filename_scopes.len()
);
Self::new(syntax_set, user_extensions, filename_scopes)
}
pub fn for_editor() -> Arc<Self> {
Arc::new(Self::load(&LocalGrammarLoader::new()))
}
pub fn grammars_directory() -> Option<PathBuf> {
LocalGrammarLoader::default().grammars_dir()
}
}
fn load_user_grammars(
loader: &dyn GrammarLoader,
dir: &Path,
builder: &mut SyntaxSetBuilder,
user_extensions: &mut HashMap<String, String>,
) {
let entries = match loader.read_dir(dir) {
Ok(entries) => entries,
Err(e) => {
tracing::warn!("Failed to read grammars directory {:?}: {}", dir, e);
return;
}
};
for path in entries {
if !loader.is_dir(&path) {
continue;
}
let manifest_path = path.join("package.json");
if loader.exists(&manifest_path) {
if let Ok(manifest) = parse_package_json(loader, &manifest_path) {
process_manifest(loader, &path, manifest, builder, user_extensions);
}
continue;
}
let mut found_any = false;
load_direct_grammar(loader, &path, builder, &mut found_any);
}
}
fn parse_package_json(loader: &dyn GrammarLoader, path: &Path) -> Result<PackageManifest, String> {
let content = loader
.read_file(path)
.map_err(|e| format!("Failed to read file: {}", e))?;
serde_json::from_str(&content).map_err(|e| format!("Failed to parse JSON: {}", e))
}
fn process_manifest(
loader: &dyn GrammarLoader,
package_dir: &Path,
manifest: PackageManifest,
builder: &mut SyntaxSetBuilder,
user_extensions: &mut HashMap<String, String>,
) {
let contributes = match manifest.contributes {
Some(c) => c,
None => return,
};
let mut lang_extensions: HashMap<String, Vec<String>> = HashMap::new();
for lang in &contributes.languages {
lang_extensions.insert(lang.id.clone(), lang.extensions.clone());
}
for grammar in &contributes.grammars {
let grammar_path = package_dir.join(&grammar.path);
if !loader.exists(&grammar_path) {
tracing::warn!("Grammar file not found: {:?}", grammar_path);
continue;
}
let grammar_dir = grammar_path.parent().unwrap_or(package_dir);
if let Err(e) = builder.add_from_folder(grammar_dir, false) {
tracing::warn!("Failed to load grammar {:?}: {}", grammar_path, e);
continue;
}
tracing::info!(
"Loaded grammar {} from {:?}",
grammar.scope_name,
grammar_path
);
if let Some(extensions) = lang_extensions.get(&grammar.language) {
for ext in extensions {
let ext_clean = ext.trim_start_matches('.');
user_extensions.insert(ext_clean.to_string(), grammar.scope_name.clone());
tracing::debug!("Mapped extension .{} to {}", ext_clean, grammar.scope_name);
}
}
}
}
fn load_direct_grammar(
loader: &dyn GrammarLoader,
dir: &Path,
builder: &mut SyntaxSetBuilder,
found_any: &mut bool,
) {
let entries = match loader.read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for path in entries {
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if file_name.ends_with(".tmLanguage") || file_name.ends_with(".sublime-syntax") {
if let Err(e) = builder.add_from_folder(dir, false) {
tracing::warn!("Failed to load grammar from {:?}: {}", dir, e);
} else {
tracing::info!("Loaded grammar from {:?}", dir);
*found_any = true;
}
break;
}
}
}
#[derive(Debug, serde::Deserialize)]
struct FreshPackageManifest {
name: String,
#[serde(default)]
fresh: Option<FreshConfig>,
}
#[derive(Debug, serde::Deserialize)]
struct FreshConfig {
#[serde(default)]
grammar: Option<FreshGrammarConfig>,
}
#[derive(Debug, serde::Deserialize)]
struct FreshGrammarConfig {
file: String,
#[serde(default)]
extensions: Vec<String>,
}
fn load_language_pack_grammars(
loader: &dyn GrammarLoader,
packages_dir: &Path,
builder: &mut SyntaxSetBuilder,
user_extensions: &mut HashMap<String, String>,
) {
let entries = match loader.read_dir(packages_dir) {
Ok(entries) => entries,
Err(e) => {
tracing::debug!(
"Failed to read language packages directory {:?}: {}",
packages_dir,
e
);
return;
}
};
for package_path in entries {
if !loader.is_dir(&package_path) {
continue;
}
let manifest_path = package_path.join("package.json");
if !loader.exists(&manifest_path) {
continue;
}
let content = match loader.read_file(&manifest_path) {
Ok(c) => c,
Err(e) => {
tracing::debug!("Failed to read {:?}: {}", manifest_path, e);
continue;
}
};
let manifest: FreshPackageManifest = match serde_json::from_str(&content) {
Ok(m) => m,
Err(e) => {
tracing::debug!("Failed to parse {:?}: {}", manifest_path, e);
continue;
}
};
let grammar_config = match manifest.fresh.and_then(|f| f.grammar) {
Some(g) => g,
None => continue,
};
let grammar_path = package_path.join(&grammar_config.file);
if !loader.exists(&grammar_path) {
tracing::warn!(
"Grammar file not found for language pack '{}': {:?}",
manifest.name,
grammar_path
);
continue;
}
let content = match loader.read_file(&grammar_path) {
Ok(c) => c,
Err(e) => {
tracing::warn!("Failed to read grammar file {:?}: {}", grammar_path, e);
continue;
}
};
match syntect::parsing::SyntaxDefinition::load_from_str(
&content,
true,
grammar_path.file_stem().and_then(|s| s.to_str()),
) {
Ok(syntax) => {
let scope = syntax.scope.to_string();
tracing::info!(
"Loaded language pack grammar '{}' from {:?} (scope: {}, extensions: {:?})",
manifest.name,
grammar_path,
scope,
grammar_config.extensions
);
builder.add(syntax);
for ext in &grammar_config.extensions {
let ext_clean = ext.trim_start_matches('.');
user_extensions.insert(ext_clean.to_string(), scope.clone());
}
}
Err(e) => {
tracing::warn!(
"Failed to parse grammar for language pack '{}': {}",
manifest.name,
e
);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
struct MockGrammarLoader {
grammars_dir: Option<PathBuf>,
files: HashMap<PathBuf, String>,
dirs: HashMap<PathBuf, Vec<PathBuf>>,
}
impl MockGrammarLoader {
fn new() -> Self {
Self {
grammars_dir: None,
files: HashMap::new(),
dirs: HashMap::new(),
}
}
#[allow(dead_code)]
fn with_grammars_dir(mut self, dir: PathBuf) -> Self {
self.grammars_dir = Some(dir);
self
}
}
impl GrammarLoader for MockGrammarLoader {
fn grammars_dir(&self) -> Option<PathBuf> {
self.grammars_dir.clone()
}
fn languages_packages_dir(&self) -> Option<PathBuf> {
None }
fn read_file(&self, path: &Path) -> io::Result<String> {
self.files
.get(path)
.cloned()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "File not found"))
}
fn read_dir(&self, path: &Path) -> io::Result<Vec<PathBuf>> {
self.dirs
.get(path)
.cloned()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Directory not found"))
}
fn exists(&self, path: &Path) -> bool {
self.files.contains_key(path) || self.dirs.contains_key(path)
}
fn is_dir(&self, path: &Path) -> bool {
self.dirs.contains_key(path)
}
}
#[test]
fn test_mock_loader_no_grammars() {
let loader = MockGrammarLoader::new();
let registry = GrammarRegistry::load(&loader);
assert!(!registry.available_syntaxes().is_empty());
}
#[test]
fn test_local_loader_grammars_dir() {
let loader = LocalGrammarLoader::new();
let grammars_dir = loader.grammars_dir();
if let Some(dir) = grammars_dir {
assert!(dir.to_string_lossy().contains("fresh"));
assert!(dir.to_string_lossy().contains("grammars"));
}
}
#[test]
fn test_for_editor() {
let registry = GrammarRegistry::for_editor();
assert!(!registry.available_syntaxes().is_empty());
}
#[test]
fn test_find_syntax_with_custom_languages_config() {
let registry = GrammarRegistry::for_editor();
let mut languages = std::collections::HashMap::new();
languages.insert(
"bash".to_string(),
crate::config::LanguageConfig {
extensions: vec!["myext".to_string()],
filenames: vec!["CUSTOMBUILD".to_string()],
grammar: "Bourne Again Shell (bash)".to_string(),
comment_prefix: Some("#".to_string()),
auto_indent: true,
highlighter: crate::config::HighlighterPreference::Auto,
textmate_grammar: None,
show_whitespace_tabs: true,
use_tabs: false,
tab_size: None,
formatter: None,
format_on_save: false,
on_save: vec![],
},
);
let path = Path::new("CUSTOMBUILD");
let result = registry.find_syntax_for_file_with_languages(path, &languages);
assert!(
result.is_some(),
"CUSTOMBUILD should be detected via languages config"
);
let syntax = result.unwrap();
assert!(
syntax.name.to_lowercase().contains("bash")
|| syntax.name.to_lowercase().contains("shell"),
"CUSTOMBUILD should be detected as shell/bash, got: {}",
syntax.name
);
let path = Path::new("script.myext");
let result = registry.find_syntax_for_file_with_languages(path, &languages);
assert!(
result.is_some(),
"script.myext should be detected via languages config"
);
let syntax = result.unwrap();
assert!(
syntax.name.to_lowercase().contains("bash")
|| syntax.name.to_lowercase().contains("shell"),
"script.myext should be detected as shell/bash, got: {}",
syntax.name
);
}
#[test]
fn test_list_all_syntaxes() {
let registry = GrammarRegistry::for_editor();
let syntax_set = registry.syntax_set();
let mut syntaxes: Vec<_> = syntax_set
.syntaxes()
.iter()
.map(|s| (s.name.as_str(), s.file_extensions.clone()))
.collect();
syntaxes.sort_by(|a, b| a.0.cmp(b.0));
println!("\n=== Available Syntaxes ({} total) ===", syntaxes.len());
for (name, exts) in &syntaxes {
println!(" {} -> {:?}", name, exts);
}
println!("\n=== TypeScript Check ===");
let ts_syntax = syntax_set.find_syntax_by_extension("ts");
let tsx_syntax = syntax_set.find_syntax_by_extension("tsx");
println!(" .ts -> {:?}", ts_syntax.map(|s| &s.name));
println!(" .tsx -> {:?}", tsx_syntax.map(|s| &s.name));
assert!(!syntaxes.is_empty());
}
}