use std::path::{Path, PathBuf};
use schemars::JsonSchema;
use serde::Deserialize;
use crate::config::{FormatterConfig, LanguageConfig};
use crate::primitives::grammar::GrammarSpec;
use crate::types::{LspServerConfig, ProcessLimits};
#[derive(Debug, Clone, Deserialize, JsonSchema)]
#[schemars(
title = "Fresh Package Manifest",
description = "Schema for Fresh plugin and theme package.json files"
)]
pub struct PackageManifest {
#[schemars(regex(pattern = r"^[a-z0-9-]+$"))]
pub name: String,
#[serde(default)]
#[schemars(regex(pattern = r"^\d+\.\d+\.\d+"))]
pub version: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(rename = "type", default)]
pub package_type: Option<PackageType>,
#[serde(default)]
pub fresh: Option<FreshManifestConfig>,
#[serde(default)]
pub author: Option<String>,
#[serde(default)]
pub license: Option<String>,
#[serde(default)]
pub repository: Option<String>,
#[serde(default)]
pub keywords: Vec<String>,
#[serde(default)]
pub dependencies: std::collections::HashMap<String, String>,
}
#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum PackageType {
Plugin,
Theme,
ThemePack,
Language,
Bundle,
}
#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
pub struct FreshManifestConfig {
#[serde(default)]
pub min_version: Option<String>,
#[serde(default)]
pub min_api_version: Option<u32>,
#[serde(default)]
pub entry: Option<String>,
#[serde(default)]
pub main: Option<String>,
#[serde(default)]
pub theme: Option<String>,
#[serde(default)]
pub themes: Vec<BundleTheme>,
#[serde(default)]
pub config_schema: Option<serde_json::Value>,
#[serde(default)]
pub grammar: Option<GrammarManifestConfig>,
#[serde(default)]
pub language: Option<LanguageManifestConfig>,
#[serde(default)]
pub lsp: Option<LspManifestConfig>,
#[serde(default)]
pub languages: Vec<BundleLanguage>,
#[serde(default)]
pub plugins: Vec<BundlePlugin>,
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct GrammarManifestConfig {
pub file: String,
#[serde(default)]
pub extensions: Vec<String>,
#[serde(rename = "firstLine", default)]
pub first_line: Option<String>,
#[serde(rename = "shortName", default)]
pub short_name: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct LanguageManifestConfig {
#[serde(default)]
pub comment_prefix: Option<String>,
#[serde(default)]
pub block_comment_start: Option<String>,
#[serde(default)]
pub block_comment_end: Option<String>,
#[serde(default)]
pub tab_size: Option<usize>,
#[serde(default)]
pub use_tabs: Option<bool>,
#[serde(default)]
pub auto_indent: Option<bool>,
#[serde(default)]
pub show_whitespace_tabs: Option<bool>,
#[serde(default)]
pub formatter: Option<FormatterManifestConfig>,
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct FormatterManifestConfig {
pub command: String,
#[serde(default)]
pub args: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct LspManifestConfig {
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub auto_start: Option<bool>,
#[serde(default)]
pub initialization_options: Option<serde_json::Value>,
#[serde(default)]
pub process_limits: Option<ProcessLimitsManifestConfig>,
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct ProcessLimitsManifestConfig {
#[serde(default)]
pub max_memory_percent: Option<u32>,
#[serde(default)]
pub max_cpu_percent: Option<u32>,
#[serde(default)]
pub enabled: Option<bool>,
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct BundleLanguage {
pub id: String,
#[serde(default)]
pub grammar: Option<GrammarManifestConfig>,
#[serde(default)]
pub language: Option<LanguageManifestConfig>,
#[serde(default)]
pub lsp: Option<LspManifestConfig>,
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct BundlePlugin {
pub entry: String,
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct BundleTheme {
pub file: String,
pub name: String,
#[serde(default)]
pub variant: Option<ThemeVariant>,
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum ThemeVariant {
Dark,
Light,
}
impl LanguageManifestConfig {
pub fn to_language_config(&self) -> LanguageConfig {
LanguageConfig {
comment_prefix: self.comment_prefix.clone(),
auto_indent: self.auto_indent.unwrap_or(true),
show_whitespace_tabs: self.show_whitespace_tabs.unwrap_or(true),
use_tabs: self.use_tabs,
tab_size: self.tab_size,
formatter: self.formatter.as_ref().map(|f| FormatterConfig {
command: f.command.clone(),
args: f.args.clone(),
stdin: true,
timeout_ms: 10000,
}),
..Default::default()
}
}
}
impl LspManifestConfig {
pub fn to_lsp_config(&self) -> LspServerConfig {
let process_limits = self
.process_limits
.as_ref()
.map(|pl| ProcessLimits {
max_memory_percent: pl.max_memory_percent,
max_cpu_percent: pl.max_cpu_percent,
enabled: pl
.enabled
.unwrap_or(pl.max_memory_percent.is_some() || pl.max_cpu_percent.is_some()),
})
.unwrap_or_default();
LspServerConfig {
command: self.command.clone(),
args: self.args.clone(),
enabled: true,
auto_start: self.auto_start.unwrap_or(true),
initialization_options: self.initialization_options.clone(),
process_limits,
..Default::default()
}
}
}
#[derive(Debug, Default)]
pub struct PackageScanResult {
pub language_configs: Vec<(String, LanguageConfig)>,
pub lsp_configs: Vec<(String, LspServerConfig)>,
pub additional_grammars: Vec<GrammarSpec>,
pub bundle_plugin_dirs: Vec<PathBuf>,
pub bundle_theme_dirs: Vec<PathBuf>,
}
pub fn scan_installed_packages(config_dir: &Path) -> PackageScanResult {
let mut result = PackageScanResult::default();
let languages_dir = config_dir.join("languages/packages");
if languages_dir.is_dir() {
scan_language_packs(&languages_dir, &mut result);
}
let bundles_dir = config_dir.join("bundles/packages");
if bundles_dir.is_dir() {
scan_bundles(&bundles_dir, &mut result);
}
tracing::info!(
"[package-scan] Found {} language configs, {} LSP configs, {} grammars, {} bundle plugin dirs, {} bundle theme dirs",
result.language_configs.len(),
result.lsp_configs.len(),
result.additional_grammars.len(),
result.bundle_plugin_dirs.len(),
result.bundle_theme_dirs.len(),
);
result
}
fn scan_language_packs(dir: &Path, result: &mut PackageScanResult) {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(e) => {
tracing::debug!("[package-scan] Failed to read {:?}: {}", dir, e);
return;
}
};
for entry in entries.flatten() {
let pkg_dir = entry.path();
if !pkg_dir.is_dir() {
continue;
}
let manifest_path = pkg_dir.join("package.json");
if let Some(manifest) = read_manifest(&manifest_path) {
process_language_pack(&pkg_dir, &manifest, result);
}
}
}
fn process_language_pack(
_pkg_dir: &Path,
manifest: &PackageManifest,
result: &mut PackageScanResult,
) {
let fresh = match &manifest.fresh {
Some(f) => f,
None => return,
};
let lang_id = manifest.name.clone();
if let Some(lang_config) = &fresh.language {
result
.language_configs
.push((lang_id.clone(), lang_config.to_language_config()));
}
if let Some(lsp_config) = &fresh.lsp {
result
.lsp_configs
.push((lang_id.clone(), lsp_config.to_lsp_config()));
}
}
fn scan_bundles(dir: &Path, result: &mut PackageScanResult) {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(e) => {
tracing::debug!("[package-scan] Failed to read {:?}: {}", dir, e);
return;
}
};
for entry in entries.flatten() {
let pkg_dir = entry.path();
if !pkg_dir.is_dir() {
continue;
}
let manifest_path = pkg_dir.join("package.json");
if let Some(manifest) = read_manifest(&manifest_path) {
process_bundle(&pkg_dir, &manifest, result);
}
}
}
fn process_bundle(pkg_dir: &Path, manifest: &PackageManifest, result: &mut PackageScanResult) {
let fresh = match &manifest.fresh {
Some(f) => f,
None => return,
};
for lang in &fresh.languages {
if let Some(grammar) = &lang.grammar {
let grammar_path = pkg_dir.join(&grammar.file);
if grammar_path.exists() {
result.additional_grammars.push(GrammarSpec {
language: lang.id.clone(),
path: grammar_path,
extensions: grammar.extensions.clone(),
});
} else {
tracing::warn!(
"[package-scan] Grammar file not found for '{}' in bundle '{}': {:?}",
lang.id,
manifest.name,
grammar_path
);
}
}
if let Some(lang_config) = &lang.language {
result
.language_configs
.push((lang.id.clone(), lang_config.to_language_config()));
}
if let Some(lsp_config) = &lang.lsp {
result
.lsp_configs
.push((lang.id.clone(), lsp_config.to_lsp_config()));
}
}
for plugin in &fresh.plugins {
let entry_path = pkg_dir.join(&plugin.entry);
if let Some(plugin_dir) = entry_path.parent() {
if plugin_dir.is_dir() {
result.bundle_plugin_dirs.push(plugin_dir.to_path_buf());
}
}
}
if !fresh.themes.is_empty() {
result.bundle_theme_dirs.push(pkg_dir.to_path_buf());
}
}
fn read_manifest(path: &Path) -> Option<PackageManifest> {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) => {
tracing::debug!("[package-scan] Failed to read {:?}: {}", path, e);
return None;
}
};
match serde_json::from_str(&content) {
Ok(m) => Some(m),
Err(e) => {
tracing::warn!("[package-scan] Failed to parse {:?}: {}", path, e);
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_language_pack_manifest() {
let json = r#"{
"name": "hare",
"version": "1.0.0",
"description": "Hare language support",
"type": "language",
"fresh": {
"grammar": {
"file": "grammars/Hare.sublime-syntax",
"extensions": ["ha"]
},
"language": {
"commentPrefix": "//",
"useTabs": true,
"tabSize": 8,
"showWhitespaceTabs": false,
"autoIndent": true
},
"lsp": {
"command": "hare-lsp",
"args": ["--stdio"],
"autoStart": true
}
}
}"#;
let manifest: PackageManifest = serde_json::from_str(json).unwrap();
assert_eq!(manifest.name, "hare");
assert_eq!(manifest.package_type, Some(PackageType::Language));
let fresh = manifest.fresh.unwrap();
let grammar = fresh.grammar.unwrap();
assert_eq!(grammar.file, "grammars/Hare.sublime-syntax");
assert_eq!(grammar.extensions, vec!["ha"]);
let lang = fresh.language.unwrap();
assert_eq!(lang.comment_prefix, Some("//".to_string()));
assert_eq!(lang.use_tabs, Some(true));
assert_eq!(lang.tab_size, Some(8));
assert_eq!(lang.show_whitespace_tabs, Some(false));
let lang_config = lang.to_language_config();
assert_eq!(lang_config.comment_prefix, Some("//".to_string()));
assert_eq!(lang_config.use_tabs, Some(true));
assert_eq!(lang_config.tab_size, Some(8));
assert!(!lang_config.show_whitespace_tabs);
let lsp = fresh.lsp.unwrap();
assert_eq!(lsp.command, "hare-lsp");
assert_eq!(lsp.args, vec!["--stdio"]);
assert_eq!(lsp.auto_start, Some(true));
let lsp_config = lsp.to_lsp_config();
assert_eq!(lsp_config.command, "hare-lsp");
assert!(lsp_config.auto_start);
assert!(lsp_config.enabled);
}
#[test]
fn test_parse_bundle_manifest() {
let json = r##"{
"name": "elixir-bundle",
"version": "1.0.0",
"description": "Elixir language bundle",
"type": "bundle",
"fresh": {
"languages": [
{
"id": "elixir",
"grammar": {
"file": "grammars/Elixir.sublime-syntax",
"extensions": ["ex", "exs"]
},
"language": {
"commentPrefix": "#",
"tabSize": 2
},
"lsp": {
"command": "elixir-ls",
"autoStart": true
}
},
{
"id": "heex",
"grammar": {
"file": "grammars/HEEx.sublime-syntax",
"extensions": ["heex"]
}
}
],
"plugins": [
{ "entry": "plugins/elixir-plugin.ts" }
],
"themes": [
{ "file": "themes/elixir-dark.json", "name": "Elixir Dark", "variant": "dark" }
]
}
}"##;
let manifest: PackageManifest = serde_json::from_str(json).unwrap();
assert_eq!(manifest.name, "elixir-bundle");
assert_eq!(manifest.package_type, Some(PackageType::Bundle));
let fresh = manifest.fresh.unwrap();
assert_eq!(fresh.languages.len(), 2);
assert_eq!(fresh.plugins.len(), 1);
assert_eq!(fresh.themes.len(), 1);
let elixir = &fresh.languages[0];
assert_eq!(elixir.id, "elixir");
assert_eq!(
elixir.grammar.as_ref().unwrap().extensions,
vec!["ex", "exs"]
);
let heex = &fresh.languages[1];
assert_eq!(heex.id, "heex");
assert!(heex.language.is_none());
assert!(heex.lsp.is_none());
}
#[test]
fn test_parse_minimal_manifest() {
let json = r#"{ "name": "minimal" }"#;
let manifest: PackageManifest = serde_json::from_str(json).unwrap();
assert_eq!(manifest.name, "minimal");
assert!(manifest.package_type.is_none());
assert!(manifest.fresh.is_none());
}
#[test]
fn test_parse_manifest_with_unknown_fields() {
let json = r#"{
"name": "future-pkg",
"version": "2.0.0",
"description": "From the future",
"type": "language",
"future_field": true,
"fresh": {
"grammar": { "file": "grammar.sublime-syntax" },
"future_nested": { "key": "value" }
}
}"#;
let manifest: PackageManifest = serde_json::from_str(json).unwrap();
assert_eq!(manifest.name, "future-pkg");
assert!(manifest.fresh.unwrap().grammar.is_some());
}
#[test]
fn test_scan_empty_directories() {
let temp_dir = tempfile::tempdir().unwrap();
let config_dir = temp_dir.path();
let result = scan_installed_packages(config_dir);
assert!(result.language_configs.is_empty());
assert!(result.lsp_configs.is_empty());
assert!(result.additional_grammars.is_empty());
assert!(result.bundle_plugin_dirs.is_empty());
assert!(result.bundle_theme_dirs.is_empty());
}
#[test]
fn test_scan_language_pack() {
let temp_dir = tempfile::tempdir().unwrap();
let config_dir = temp_dir.path();
let lang_dir = config_dir.join("languages/packages/hare");
std::fs::create_dir_all(&lang_dir).unwrap();
std::fs::write(
lang_dir.join("package.json"),
r#"{
"name": "hare",
"version": "1.0.0",
"description": "Hare language",
"type": "language",
"fresh": {
"grammar": {
"file": "grammars/Hare.sublime-syntax",
"extensions": ["ha"]
},
"language": {
"commentPrefix": "//",
"useTabs": true
},
"lsp": {
"command": "hare-lsp",
"args": ["--stdio"]
}
}
}"#,
)
.unwrap();
let result = scan_installed_packages(config_dir);
assert_eq!(result.language_configs.len(), 1);
assert_eq!(result.language_configs[0].0, "hare");
assert_eq!(
result.language_configs[0].1.comment_prefix,
Some("//".to_string())
);
assert_eq!(result.language_configs[0].1.use_tabs, Some(true));
assert_eq!(result.lsp_configs.len(), 1);
assert_eq!(result.lsp_configs[0].0, "hare");
assert_eq!(result.lsp_configs[0].1.command, "hare-lsp");
assert!(result.additional_grammars.is_empty());
}
#[test]
fn test_scan_bundle() {
let temp_dir = tempfile::tempdir().unwrap();
let config_dir = temp_dir.path();
let bundle_dir = config_dir.join("bundles/packages/elixir-bundle");
let grammars_dir = bundle_dir.join("grammars");
let plugins_dir = bundle_dir.join("plugins");
std::fs::create_dir_all(&grammars_dir).unwrap();
std::fs::create_dir_all(&plugins_dir).unwrap();
std::fs::write(
grammars_dir.join("Elixir.sublime-syntax"),
"# dummy grammar",
)
.unwrap();
std::fs::write(plugins_dir.join("elixir-plugin.ts"), "// dummy plugin").unwrap();
std::fs::write(
bundle_dir.join("package.json"),
r##"{
"name": "elixir-bundle",
"version": "1.0.0",
"description": "Elixir bundle",
"type": "bundle",
"fresh": {
"languages": [
{
"id": "elixir",
"grammar": {
"file": "grammars/Elixir.sublime-syntax",
"extensions": ["ex", "exs"]
},
"language": {
"commentPrefix": "#",
"tabSize": 2
},
"lsp": {
"command": "elixir-ls",
"autoStart": true
}
}
],
"plugins": [
{ "entry": "plugins/elixir-plugin.ts" }
],
"themes": [
{ "file": "themes/dark.json", "name": "Elixir Dark", "variant": "dark" }
]
}
}"##,
)
.unwrap();
let result = scan_installed_packages(config_dir);
assert_eq!(result.additional_grammars.len(), 1);
assert_eq!(result.additional_grammars[0].language, "elixir");
assert_eq!(result.additional_grammars[0].extensions, vec!["ex", "exs"]);
assert_eq!(result.language_configs.len(), 1);
assert_eq!(result.language_configs[0].0, "elixir");
assert_eq!(result.lsp_configs.len(), 1);
assert_eq!(result.lsp_configs[0].1.command, "elixir-ls");
assert_eq!(result.bundle_plugin_dirs.len(), 1);
assert_eq!(result.bundle_plugin_dirs[0], plugins_dir);
assert_eq!(result.bundle_theme_dirs.len(), 1);
assert_eq!(result.bundle_theme_dirs[0], bundle_dir);
}
#[test]
fn test_scan_skips_malformed_manifest() {
let temp_dir = tempfile::tempdir().unwrap();
let config_dir = temp_dir.path();
let lang_dir = config_dir.join("languages/packages/broken");
std::fs::create_dir_all(&lang_dir).unwrap();
std::fs::write(lang_dir.join("package.json"), "{ invalid json }").unwrap();
let result = scan_installed_packages(config_dir);
assert!(result.language_configs.is_empty());
}
#[test]
fn test_formatter_conversion() {
let lang = LanguageManifestConfig {
formatter: Some(FormatterManifestConfig {
command: "prettier".to_string(),
args: vec!["--stdin-filepath".to_string(), "$FILE".to_string()],
}),
..Default::default()
};
let config = lang.to_language_config();
let fmt = config.formatter.unwrap();
assert_eq!(fmt.command, "prettier");
assert_eq!(fmt.args, vec!["--stdin-filepath", "$FILE"]);
assert!(fmt.stdin);
assert_eq!(fmt.timeout_ms, 10000);
}
#[test]
fn test_process_limits_conversion() {
let lsp = LspManifestConfig {
command: "test-lsp".to_string(),
args: vec![],
auto_start: None,
initialization_options: None,
process_limits: Some(ProcessLimitsManifestConfig {
max_memory_percent: Some(30),
max_cpu_percent: Some(50),
enabled: Some(true),
}),
};
let config = lsp.to_lsp_config();
assert_eq!(config.process_limits.max_memory_percent, Some(30));
assert_eq!(config.process_limits.max_cpu_percent, Some(50));
assert!(config.process_limits.enabled);
}
}