1use std::path::{Path, PathBuf};
8
9use schemars::JsonSchema;
10use serde::Deserialize;
11
12use crate::config::{FormatterConfig, LanguageConfig};
13use crate::primitives::grammar::GrammarSpec;
14use crate::types::{LspServerConfig, ProcessLimits};
15
16#[derive(Debug, Clone, Deserialize, JsonSchema)]
24#[schemars(
25 title = "Fresh Package Manifest",
26 description = "Schema for Fresh plugin and theme package.json files"
27)]
28pub struct PackageManifest {
29 #[schemars(regex(pattern = r"^[a-z0-9-]+$"))]
31 pub name: String,
32
33 #[serde(default)]
35 #[schemars(regex(pattern = r"^\d+\.\d+\.\d+"))]
36 pub version: Option<String>,
37
38 #[serde(default)]
40 pub description: Option<String>,
41
42 #[serde(rename = "type", default)]
44 pub package_type: Option<PackageType>,
45
46 #[serde(default)]
48 pub fresh: Option<FreshManifestConfig>,
49
50 #[serde(default)]
52 pub author: Option<String>,
53
54 #[serde(default)]
56 pub license: Option<String>,
57
58 #[serde(default)]
60 pub repository: Option<String>,
61
62 #[serde(default)]
64 pub keywords: Vec<String>,
65
66 #[serde(default)]
68 pub dependencies: std::collections::HashMap<String, String>,
69}
70
71#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq, Eq)]
73#[serde(rename_all = "kebab-case")]
74pub enum PackageType {
75 Plugin,
76 Theme,
77 ThemePack,
78 Language,
79 Bundle,
80}
81
82#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
84pub struct FreshManifestConfig {
85 #[serde(default)]
87 pub min_version: Option<String>,
88
89 #[serde(default)]
91 pub min_api_version: Option<u32>,
92
93 #[serde(default)]
95 pub entry: Option<String>,
96
97 #[serde(default)]
99 pub main: Option<String>,
100
101 #[serde(default)]
103 pub theme: Option<String>,
104
105 #[serde(default)]
107 pub themes: Vec<BundleTheme>,
108
109 #[serde(default)]
111 pub config_schema: Option<serde_json::Value>,
112
113 #[serde(default)]
115 pub grammar: Option<GrammarManifestConfig>,
116
117 #[serde(default)]
119 pub language: Option<LanguageManifestConfig>,
120
121 #[serde(default)]
123 pub lsp: Option<LspManifestConfig>,
124
125 #[serde(default)]
127 pub languages: Vec<BundleLanguage>,
128
129 #[serde(default)]
131 pub plugins: Vec<BundlePlugin>,
132}
133
134#[derive(Debug, Clone, Deserialize, JsonSchema)]
136pub struct GrammarManifestConfig {
137 pub file: String,
139
140 #[serde(default)]
142 pub extensions: Vec<String>,
143
144 #[serde(rename = "firstLine", default)]
146 pub first_line: Option<String>,
147
148 #[serde(rename = "shortName", default)]
151 pub short_name: Option<String>,
152}
153
154#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
156#[serde(rename_all = "camelCase")]
157pub struct LanguageManifestConfig {
158 #[serde(default)]
160 pub comment_prefix: Option<String>,
161
162 #[serde(default)]
164 pub block_comment_start: Option<String>,
165
166 #[serde(default)]
168 pub block_comment_end: Option<String>,
169
170 #[serde(default)]
172 pub tab_size: Option<usize>,
173
174 #[serde(default)]
176 pub use_tabs: Option<bool>,
177
178 #[serde(default)]
180 pub auto_indent: Option<bool>,
181
182 #[serde(default)]
184 pub show_whitespace_tabs: Option<bool>,
185
186 #[serde(default)]
188 pub formatter: Option<FormatterManifestConfig>,
189}
190
191#[derive(Debug, Clone, Deserialize, JsonSchema)]
193pub struct FormatterManifestConfig {
194 pub command: String,
196
197 #[serde(default)]
199 pub args: Vec<String>,
200}
201
202#[derive(Debug, Clone, Deserialize, JsonSchema)]
204#[serde(rename_all = "camelCase")]
205pub struct LspManifestConfig {
206 pub command: String,
208
209 #[serde(default)]
211 pub args: Vec<String>,
212
213 #[serde(default)]
215 pub auto_start: Option<bool>,
216
217 #[serde(default)]
219 pub initialization_options: Option<serde_json::Value>,
220
221 #[serde(default)]
223 pub process_limits: Option<ProcessLimitsManifestConfig>,
224}
225
226#[derive(Debug, Clone, Deserialize, JsonSchema)]
228#[serde(rename_all = "camelCase")]
229pub struct ProcessLimitsManifestConfig {
230 #[serde(default)]
231 pub max_memory_percent: Option<u32>,
232 #[serde(default)]
233 pub max_cpu_percent: Option<u32>,
234 #[serde(default)]
235 pub enabled: Option<bool>,
236}
237
238#[derive(Debug, Clone, Deserialize, JsonSchema)]
240pub struct BundleLanguage {
241 pub id: String,
243
244 #[serde(default)]
246 pub grammar: Option<GrammarManifestConfig>,
247
248 #[serde(default)]
250 pub language: Option<LanguageManifestConfig>,
251
252 #[serde(default)]
254 pub lsp: Option<LspManifestConfig>,
255}
256
257#[derive(Debug, Clone, Deserialize, JsonSchema)]
259pub struct BundlePlugin {
260 pub entry: String,
262}
263
264#[derive(Debug, Clone, Deserialize, JsonSchema)]
266pub struct BundleTheme {
267 pub file: String,
269
270 pub name: String,
272
273 #[serde(default)]
275 pub variant: Option<ThemeVariant>,
276}
277
278#[derive(Debug, Clone, Deserialize, JsonSchema)]
280#[serde(rename_all = "lowercase")]
281pub enum ThemeVariant {
282 Dark,
283 Light,
284}
285
286impl LanguageManifestConfig {
289 pub fn to_language_config(&self) -> LanguageConfig {
291 LanguageConfig {
292 comment_prefix: self.comment_prefix.clone(),
293 auto_indent: self.auto_indent.unwrap_or(true),
294 show_whitespace_tabs: self.show_whitespace_tabs.unwrap_or(true),
295 use_tabs: self.use_tabs,
296 tab_size: self.tab_size,
297 formatter: self.formatter.as_ref().map(|f| FormatterConfig {
298 command: f.command.clone(),
299 args: f.args.clone(),
300 stdin: true,
301 timeout_ms: 10000,
302 }),
303 ..Default::default()
304 }
305 }
306}
307
308impl LspManifestConfig {
309 pub fn to_lsp_config(&self) -> LspServerConfig {
311 let process_limits = self
312 .process_limits
313 .as_ref()
314 .map(|pl| ProcessLimits {
315 max_memory_percent: pl.max_memory_percent,
316 max_cpu_percent: pl.max_cpu_percent,
317 enabled: pl
318 .enabled
319 .unwrap_or(pl.max_memory_percent.is_some() || pl.max_cpu_percent.is_some()),
320 })
321 .unwrap_or_default();
322
323 LspServerConfig {
324 command: self.command.clone(),
325 args: self.args.clone(),
326 enabled: true,
327 auto_start: self.auto_start.unwrap_or(true),
328 initialization_options: self.initialization_options.clone(),
329 process_limits,
330 ..Default::default()
331 }
332 }
333}
334
335#[derive(Debug, Default)]
339pub struct PackageScanResult {
340 pub language_configs: Vec<(String, LanguageConfig)>,
342 pub lsp_configs: Vec<(String, LspServerConfig)>,
344 pub additional_grammars: Vec<GrammarSpec>,
346 pub bundle_plugin_dirs: Vec<PathBuf>,
348 pub bundle_theme_dirs: Vec<PathBuf>,
350}
351
352pub fn scan_installed_packages(config_dir: &Path) -> PackageScanResult {
359 let mut result = PackageScanResult::default();
360
361 let languages_dir = config_dir.join("languages/packages");
363 if languages_dir.is_dir() {
364 scan_language_packs(&languages_dir, &mut result);
365 }
366
367 let bundles_dir = config_dir.join("bundles/packages");
369 if bundles_dir.is_dir() {
370 scan_bundles(&bundles_dir, &mut result);
371 }
372
373 tracing::info!(
374 "[package-scan] Found {} language configs, {} LSP configs, {} grammars, {} bundle plugin dirs, {} bundle theme dirs",
375 result.language_configs.len(),
376 result.lsp_configs.len(),
377 result.additional_grammars.len(),
378 result.bundle_plugin_dirs.len(),
379 result.bundle_theme_dirs.len(),
380 );
381
382 result
383}
384
385fn scan_language_packs(dir: &Path, result: &mut PackageScanResult) {
387 let entries = match std::fs::read_dir(dir) {
388 Ok(e) => e,
389 Err(e) => {
390 tracing::debug!("[package-scan] Failed to read {:?}: {}", dir, e);
391 return;
392 }
393 };
394
395 for entry in entries.flatten() {
396 let pkg_dir = entry.path();
397 if !pkg_dir.is_dir() {
398 continue;
399 }
400 let manifest_path = pkg_dir.join("package.json");
401 if let Some(manifest) = read_manifest(&manifest_path) {
402 process_language_pack(&pkg_dir, &manifest, result);
403 }
404 }
405}
406
407fn process_language_pack(
409 _pkg_dir: &Path,
410 manifest: &PackageManifest,
411 result: &mut PackageScanResult,
412) {
413 let fresh = match &manifest.fresh {
414 Some(f) => f,
415 None => return,
416 };
417
418 let lang_id = manifest.name.clone();
419
420 if let Some(lang_config) = &fresh.language {
425 result
426 .language_configs
427 .push((lang_id.clone(), lang_config.to_language_config()));
428 }
429
430 if let Some(lsp_config) = &fresh.lsp {
432 result
433 .lsp_configs
434 .push((lang_id.clone(), lsp_config.to_lsp_config()));
435 }
436}
437
438fn scan_bundles(dir: &Path, result: &mut PackageScanResult) {
440 let entries = match std::fs::read_dir(dir) {
441 Ok(e) => e,
442 Err(e) => {
443 tracing::debug!("[package-scan] Failed to read {:?}: {}", dir, e);
444 return;
445 }
446 };
447
448 for entry in entries.flatten() {
449 let pkg_dir = entry.path();
450 if !pkg_dir.is_dir() {
451 continue;
452 }
453 let manifest_path = pkg_dir.join("package.json");
454 if let Some(manifest) = read_manifest(&manifest_path) {
455 process_bundle(&pkg_dir, &manifest, result);
456 }
457 }
458}
459
460fn process_bundle(pkg_dir: &Path, manifest: &PackageManifest, result: &mut PackageScanResult) {
462 let fresh = match &manifest.fresh {
463 Some(f) => f,
464 None => return,
465 };
466
467 for lang in &fresh.languages {
469 if let Some(grammar) = &lang.grammar {
471 let grammar_path = pkg_dir.join(&grammar.file);
472 if grammar_path.exists() {
473 result.additional_grammars.push(GrammarSpec {
474 language: lang.id.clone(),
475 path: grammar_path,
476 extensions: grammar.extensions.clone(),
477 });
478 } else {
479 tracing::warn!(
480 "[package-scan] Grammar file not found for '{}' in bundle '{}': {:?}",
481 lang.id,
482 manifest.name,
483 grammar_path
484 );
485 }
486 }
487
488 if let Some(lang_config) = &lang.language {
490 result
491 .language_configs
492 .push((lang.id.clone(), lang_config.to_language_config()));
493 }
494
495 if let Some(lsp_config) = &lang.lsp {
497 result
498 .lsp_configs
499 .push((lang.id.clone(), lsp_config.to_lsp_config()));
500 }
501 }
502
503 for plugin in &fresh.plugins {
505 let entry_path = pkg_dir.join(&plugin.entry);
506 if let Some(plugin_dir) = entry_path.parent() {
508 if plugin_dir.is_dir() {
509 result.bundle_plugin_dirs.push(plugin_dir.to_path_buf());
510 }
511 }
512 }
513
514 if !fresh.themes.is_empty() {
516 result.bundle_theme_dirs.push(pkg_dir.to_path_buf());
517 }
518}
519
520fn read_manifest(path: &Path) -> Option<PackageManifest> {
522 let content = match std::fs::read_to_string(path) {
523 Ok(c) => c,
524 Err(e) => {
525 tracing::debug!("[package-scan] Failed to read {:?}: {}", path, e);
526 return None;
527 }
528 };
529
530 match serde_json::from_str(&content) {
531 Ok(m) => Some(m),
532 Err(e) => {
533 tracing::warn!("[package-scan] Failed to parse {:?}: {}", path, e);
534 None
535 }
536 }
537}
538
539#[cfg(test)]
542mod tests {
543 use super::*;
544
545 #[test]
546 fn test_parse_language_pack_manifest() {
547 let json = r#"{
548 "name": "hare",
549 "version": "1.0.0",
550 "description": "Hare language support",
551 "type": "language",
552 "fresh": {
553 "grammar": {
554 "file": "grammars/Hare.sublime-syntax",
555 "extensions": ["ha"]
556 },
557 "language": {
558 "commentPrefix": "//",
559 "useTabs": true,
560 "tabSize": 8,
561 "showWhitespaceTabs": false,
562 "autoIndent": true
563 },
564 "lsp": {
565 "command": "hare-lsp",
566 "args": ["--stdio"],
567 "autoStart": true
568 }
569 }
570 }"#;
571
572 let manifest: PackageManifest = serde_json::from_str(json).unwrap();
573 assert_eq!(manifest.name, "hare");
574 assert_eq!(manifest.package_type, Some(PackageType::Language));
575
576 let fresh = manifest.fresh.unwrap();
577 let grammar = fresh.grammar.unwrap();
578 assert_eq!(grammar.file, "grammars/Hare.sublime-syntax");
579 assert_eq!(grammar.extensions, vec!["ha"]);
580
581 let lang = fresh.language.unwrap();
582 assert_eq!(lang.comment_prefix, Some("//".to_string()));
583 assert_eq!(lang.use_tabs, Some(true));
584 assert_eq!(lang.tab_size, Some(8));
585 assert_eq!(lang.show_whitespace_tabs, Some(false));
586
587 let lang_config = lang.to_language_config();
589 assert_eq!(lang_config.comment_prefix, Some("//".to_string()));
590 assert_eq!(lang_config.use_tabs, Some(true));
591 assert_eq!(lang_config.tab_size, Some(8));
592 assert!(!lang_config.show_whitespace_tabs);
593
594 let lsp = fresh.lsp.unwrap();
595 assert_eq!(lsp.command, "hare-lsp");
596 assert_eq!(lsp.args, vec!["--stdio"]);
597 assert_eq!(lsp.auto_start, Some(true));
598
599 let lsp_config = lsp.to_lsp_config();
601 assert_eq!(lsp_config.command, "hare-lsp");
602 assert!(lsp_config.auto_start);
603 assert!(lsp_config.enabled);
604 }
605
606 #[test]
607 fn test_parse_bundle_manifest() {
608 let json = r##"{
609 "name": "elixir-bundle",
610 "version": "1.0.0",
611 "description": "Elixir language bundle",
612 "type": "bundle",
613 "fresh": {
614 "languages": [
615 {
616 "id": "elixir",
617 "grammar": {
618 "file": "grammars/Elixir.sublime-syntax",
619 "extensions": ["ex", "exs"]
620 },
621 "language": {
622 "commentPrefix": "#",
623 "tabSize": 2
624 },
625 "lsp": {
626 "command": "elixir-ls",
627 "autoStart": true
628 }
629 },
630 {
631 "id": "heex",
632 "grammar": {
633 "file": "grammars/HEEx.sublime-syntax",
634 "extensions": ["heex"]
635 }
636 }
637 ],
638 "plugins": [
639 { "entry": "plugins/elixir-plugin.ts" }
640 ],
641 "themes": [
642 { "file": "themes/elixir-dark.json", "name": "Elixir Dark", "variant": "dark" }
643 ]
644 }
645 }"##;
646
647 let manifest: PackageManifest = serde_json::from_str(json).unwrap();
648 assert_eq!(manifest.name, "elixir-bundle");
649 assert_eq!(manifest.package_type, Some(PackageType::Bundle));
650
651 let fresh = manifest.fresh.unwrap();
652 assert_eq!(fresh.languages.len(), 2);
653 assert_eq!(fresh.plugins.len(), 1);
654 assert_eq!(fresh.themes.len(), 1);
655
656 let elixir = &fresh.languages[0];
657 assert_eq!(elixir.id, "elixir");
658 assert_eq!(
659 elixir.grammar.as_ref().unwrap().extensions,
660 vec!["ex", "exs"]
661 );
662
663 let heex = &fresh.languages[1];
664 assert_eq!(heex.id, "heex");
665 assert!(heex.language.is_none());
666 assert!(heex.lsp.is_none());
667 }
668
669 #[test]
670 fn test_parse_minimal_manifest() {
671 let json = r#"{ "name": "minimal" }"#;
673 let manifest: PackageManifest = serde_json::from_str(json).unwrap();
674 assert_eq!(manifest.name, "minimal");
675 assert!(manifest.package_type.is_none());
676 assert!(manifest.fresh.is_none());
677 }
678
679 #[test]
680 fn test_parse_manifest_with_unknown_fields() {
681 let json = r#"{
683 "name": "future-pkg",
684 "version": "2.0.0",
685 "description": "From the future",
686 "type": "language",
687 "future_field": true,
688 "fresh": {
689 "grammar": { "file": "grammar.sublime-syntax" },
690 "future_nested": { "key": "value" }
691 }
692 }"#;
693 let manifest: PackageManifest = serde_json::from_str(json).unwrap();
694 assert_eq!(manifest.name, "future-pkg");
695 assert!(manifest.fresh.unwrap().grammar.is_some());
696 }
697
698 #[test]
699 fn test_scan_empty_directories() {
700 let temp_dir = tempfile::tempdir().unwrap();
701 let config_dir = temp_dir.path();
702
703 let result = scan_installed_packages(config_dir);
704 assert!(result.language_configs.is_empty());
705 assert!(result.lsp_configs.is_empty());
706 assert!(result.additional_grammars.is_empty());
707 assert!(result.bundle_plugin_dirs.is_empty());
708 assert!(result.bundle_theme_dirs.is_empty());
709 }
710
711 #[test]
712 fn test_scan_language_pack() {
713 let temp_dir = tempfile::tempdir().unwrap();
714 let config_dir = temp_dir.path();
715
716 let lang_dir = config_dir.join("languages/packages/hare");
718 std::fs::create_dir_all(&lang_dir).unwrap();
719 std::fs::write(
720 lang_dir.join("package.json"),
721 r#"{
722 "name": "hare",
723 "version": "1.0.0",
724 "description": "Hare language",
725 "type": "language",
726 "fresh": {
727 "grammar": {
728 "file": "grammars/Hare.sublime-syntax",
729 "extensions": ["ha"]
730 },
731 "language": {
732 "commentPrefix": "//",
733 "useTabs": true
734 },
735 "lsp": {
736 "command": "hare-lsp",
737 "args": ["--stdio"]
738 }
739 }
740 }"#,
741 )
742 .unwrap();
743
744 let result = scan_installed_packages(config_dir);
745
746 assert_eq!(result.language_configs.len(), 1);
748 assert_eq!(result.language_configs[0].0, "hare");
749 assert_eq!(
750 result.language_configs[0].1.comment_prefix,
751 Some("//".to_string())
752 );
753 assert_eq!(result.language_configs[0].1.use_tabs, Some(true));
754
755 assert_eq!(result.lsp_configs.len(), 1);
757 assert_eq!(result.lsp_configs[0].0, "hare");
758 assert_eq!(result.lsp_configs[0].1.command, "hare-lsp");
759
760 assert!(result.additional_grammars.is_empty());
762 }
763
764 #[test]
765 fn test_scan_bundle() {
766 let temp_dir = tempfile::tempdir().unwrap();
767 let config_dir = temp_dir.path();
768
769 let bundle_dir = config_dir.join("bundles/packages/elixir-bundle");
771 let grammars_dir = bundle_dir.join("grammars");
772 let plugins_dir = bundle_dir.join("plugins");
773 std::fs::create_dir_all(&grammars_dir).unwrap();
774 std::fs::create_dir_all(&plugins_dir).unwrap();
775
776 std::fs::write(
778 grammars_dir.join("Elixir.sublime-syntax"),
779 "# dummy grammar",
780 )
781 .unwrap();
782
783 std::fs::write(plugins_dir.join("elixir-plugin.ts"), "// dummy plugin").unwrap();
785
786 std::fs::write(
787 bundle_dir.join("package.json"),
788 r##"{
789 "name": "elixir-bundle",
790 "version": "1.0.0",
791 "description": "Elixir bundle",
792 "type": "bundle",
793 "fresh": {
794 "languages": [
795 {
796 "id": "elixir",
797 "grammar": {
798 "file": "grammars/Elixir.sublime-syntax",
799 "extensions": ["ex", "exs"]
800 },
801 "language": {
802 "commentPrefix": "#",
803 "tabSize": 2
804 },
805 "lsp": {
806 "command": "elixir-ls",
807 "autoStart": true
808 }
809 }
810 ],
811 "plugins": [
812 { "entry": "plugins/elixir-plugin.ts" }
813 ],
814 "themes": [
815 { "file": "themes/dark.json", "name": "Elixir Dark", "variant": "dark" }
816 ]
817 }
818 }"##,
819 )
820 .unwrap();
821
822 let result = scan_installed_packages(config_dir);
823
824 assert_eq!(result.additional_grammars.len(), 1);
826 assert_eq!(result.additional_grammars[0].language, "elixir");
827 assert_eq!(result.additional_grammars[0].extensions, vec!["ex", "exs"]);
828
829 assert_eq!(result.language_configs.len(), 1);
831 assert_eq!(result.language_configs[0].0, "elixir");
832
833 assert_eq!(result.lsp_configs.len(), 1);
835 assert_eq!(result.lsp_configs[0].1.command, "elixir-ls");
836
837 assert_eq!(result.bundle_plugin_dirs.len(), 1);
839 assert_eq!(result.bundle_plugin_dirs[0], plugins_dir);
840
841 assert_eq!(result.bundle_theme_dirs.len(), 1);
843 assert_eq!(result.bundle_theme_dirs[0], bundle_dir);
844 }
845
846 #[test]
847 fn test_scan_skips_malformed_manifest() {
848 let temp_dir = tempfile::tempdir().unwrap();
849 let config_dir = temp_dir.path();
850
851 let lang_dir = config_dir.join("languages/packages/broken");
853 std::fs::create_dir_all(&lang_dir).unwrap();
854 std::fs::write(lang_dir.join("package.json"), "{ invalid json }").unwrap();
855
856 let result = scan_installed_packages(config_dir);
858 assert!(result.language_configs.is_empty());
859 }
860
861 #[test]
862 fn test_formatter_conversion() {
863 let lang = LanguageManifestConfig {
864 formatter: Some(FormatterManifestConfig {
865 command: "prettier".to_string(),
866 args: vec!["--stdin-filepath".to_string(), "$FILE".to_string()],
867 }),
868 ..Default::default()
869 };
870
871 let config = lang.to_language_config();
872 let fmt = config.formatter.unwrap();
873 assert_eq!(fmt.command, "prettier");
874 assert_eq!(fmt.args, vec!["--stdin-filepath", "$FILE"]);
875 assert!(fmt.stdin);
876 assert_eq!(fmt.timeout_ms, 10000);
877 }
878
879 #[test]
880 fn test_process_limits_conversion() {
881 let lsp = LspManifestConfig {
882 command: "test-lsp".to_string(),
883 args: vec![],
884 auto_start: None,
885 initialization_options: None,
886 process_limits: Some(ProcessLimitsManifestConfig {
887 max_memory_percent: Some(30),
888 max_cpu_percent: Some(50),
889 enabled: Some(true),
890 }),
891 };
892
893 let config = lsp.to_lsp_config();
894 assert_eq!(config.process_limits.max_memory_percent, Some(30));
895 assert_eq!(config.process_limits.max_cpu_percent, Some(50));
896 assert!(config.process_limits.enabled);
897 }
898}