1use std::path::Path;
34
35use thiserror::Error;
36use tree_sitter::{Parser, Query, QueryCursor, StreamingIterator};
37
38use crate::embedded_queries::get_manifest_query;
39use crate::parser::ManifestLanguage;
40
41#[derive(Debug, Error)]
47pub enum ManifestError {
48 #[error("Unrecognized manifest file: {0}")]
50 UnrecognizedManifest(String),
51
52 #[error("Failed to parse manifest: {0}")]
54 ParseFailed(String),
55
56 #[error("Failed to compile manifest query: {0}")]
58 QueryCompileFailed(String),
59
60 #[error("Failed to set parser language: {0}")]
62 LanguageSetFailed(String),
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
74pub enum DependencyType {
75 Path,
77 Workspace,
79 ProjectReference,
81 Replace,
83 Subdirectory,
85}
86
87impl DependencyType {
88 pub fn as_str(&self) -> &'static str {
90 match self {
91 DependencyType::Path => "path",
92 DependencyType::Workspace => "workspace",
93 DependencyType::ProjectReference => "project_reference",
94 DependencyType::Replace => "replace",
95 DependencyType::Subdirectory => "subdirectory",
96 }
97 }
98}
99
100impl std::fmt::Display for DependencyType {
101 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102 write!(f, "{}", self.as_str())
103 }
104}
105
106#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct LocalDependency {
116 pub name: String,
118 pub path: Option<String>,
120 pub dep_type: DependencyType,
122 pub is_dev: bool,
124 pub is_build: bool,
126 pub version_spec: Option<String>,
128}
129
130impl LocalDependency {
131 pub fn new(name: String, dep_type: DependencyType) -> Self {
133 Self {
134 name,
135 path: None,
136 dep_type,
137 is_dev: false,
138 is_build: false,
139 version_spec: None,
140 }
141 }
142
143 pub fn with_path(name: String, path: String, dep_type: DependencyType) -> Self {
145 Self {
146 name,
147 path: Some(path),
148 dep_type,
149 is_dev: false,
150 is_build: false,
151 version_spec: None,
152 }
153 }
154
155 pub fn as_dev(mut self) -> Self {
157 self.is_dev = true;
158 self
159 }
160
161 pub fn as_build(mut self) -> Self {
163 self.is_build = true;
164 self
165 }
166
167 pub fn with_version(mut self, version: String) -> Self {
169 self.version_spec = Some(version);
170 self
171 }
172}
173
174#[derive(Debug, Clone, Default)]
182pub struct ManifestInfo {
183 pub component_name: Option<String>,
185 pub version: Option<String>,
187 pub is_workspace_root: bool,
189 pub workspace_members: Vec<String>,
191 pub local_dependencies: Vec<LocalDependency>,
193 pub ecosystem: Option<String>,
195}
196
197impl ManifestInfo {
198 pub fn new() -> Self {
200 Self::default()
201 }
202
203 pub fn is_empty(&self) -> bool {
205 self.component_name.is_none()
206 && self.version.is_none()
207 && !self.is_workspace_root
208 && self.workspace_members.is_empty()
209 && self.local_dependencies.is_empty()
210 }
211
212 pub fn is_publishable(&self) -> bool {
214 self.component_name.is_some()
215 }
216}
217
218pub struct ManifestParser {
227 parser: Parser,
229}
230
231impl ManifestParser {
232 pub fn new() -> Result<Self, ManifestError> {
234 Ok(Self {
235 parser: Parser::new(),
236 })
237 }
238
239 pub fn parse(&mut self, path: &Path, content: &str) -> Result<ManifestInfo, ManifestError> {
250 let language = ManifestLanguage::from_path(path)
252 .ok_or_else(|| ManifestError::UnrecognizedManifest(path.display().to_string()))?;
253
254 self.parse_with_language(content, language)
255 }
256
257 pub fn parse_with_language(
268 &mut self,
269 content: &str,
270 language: ManifestLanguage,
271 ) -> Result<ManifestInfo, ManifestError> {
272 self.parser
274 .set_language(&language.tree_sitter_language())
275 .map_err(|e| ManifestError::LanguageSetFailed(e.to_string()))?;
276
277 let tree = self
279 .parser
280 .parse(content, None)
281 .ok_or_else(|| ManifestError::ParseFailed("tree-sitter parse returned None".into()))?;
282
283 let query_source = get_manifest_query(language);
285 let query = Query::new(&language.tree_sitter_language(), query_source)
286 .map_err(|e| ManifestError::QueryCompileFailed(format!("{:?}", e)))?;
287
288 let mut cursor = QueryCursor::new();
290 let source_bytes = content.as_bytes();
291 let capture_names = query.capture_names();
292
293 let mut info = ManifestInfo::new();
294 info.ecosystem = Some(language.as_str().to_string());
295
296 let mut pending_path_deps: std::collections::HashMap<String, PendingPathDep> =
298 std::collections::HashMap::new();
299
300 let mut matches = cursor.matches(&query, tree.root_node(), source_bytes);
301 while let Some(match_) = matches.next() {
302 for capture in match_.captures {
303 let capture_name = &capture_names[capture.index as usize];
304 let text = capture
305 .node
306 .utf8_text(source_bytes)
307 .unwrap_or("")
308 .trim_matches('"')
309 .to_string();
310
311 self.process_capture(capture_name, &text, &mut info, &mut pending_path_deps);
312 }
313 }
314
315 for (_, pending) in pending_path_deps {
317 if let (Some(name), Some(path)) = (pending.name, pending.path) {
318 let mut dep = LocalDependency::with_path(name, path, DependencyType::Path);
319 dep.is_dev = pending.is_dev;
320 dep.is_build = pending.is_build;
321 info.local_dependencies.push(dep);
322 }
323 }
324
325 Ok(info)
326 }
327
328 fn process_capture(
330 &self,
331 capture_name: &str,
332 text: &str,
333 info: &mut ManifestInfo,
334 pending_path_deps: &mut std::collections::HashMap<String, PendingPathDep>,
335 ) {
336 if capture_name.starts_with('_') {
338 return;
339 }
340
341 let parts: Vec<&str> = capture_name.split('.').collect();
344 if parts.is_empty() || parts[0] != "manifest" {
345 return;
346 }
347
348 match parts.get(1).copied() {
350 Some("component") => self.process_component_capture(&parts[2..], text, info),
351 Some("workspace") => self.process_workspace_capture(&parts[2..], text, info),
352 Some("dependency") => {
353 self.process_dependency_capture(&parts[2..], text, info, pending_path_deps)
354 }
355 _ => {}
356 }
357 }
358
359 fn process_component_capture(&self, parts: &[&str], text: &str, info: &mut ManifestInfo) {
361 match parts.first().copied() {
362 Some("name") => {
363 if info.component_name.is_none() {
365 info.component_name = Some(text.to_string());
366 }
367 }
368 Some("version") => {
369 if info.version.is_none() {
371 info.version = Some(text.to_string());
372 }
373 }
374 Some("namespace") => {
375 if info.component_name.is_none() {
377 info.component_name = Some(text.to_string());
378 }
379 }
380 Some("packageversion") => {
381 if info.version.is_none() {
383 info.version = Some(text.to_string());
384 }
385 }
386 _ => {}
387 }
388 }
389
390 fn process_workspace_capture(&self, parts: &[&str], text: &str, info: &mut ManifestInfo) {
392 match parts.first().copied() {
393 Some("root") => {
394 info.is_workspace_root = true;
396 }
397 Some("member") => {
398 info.workspace_members.push(text.to_string());
400 }
401 _ => {}
402 }
403 }
404
405 fn process_dependency_capture(
407 &self,
408 parts: &[&str],
409 text: &str,
410 info: &mut ManifestInfo,
411 pending_path_deps: &mut std::collections::HashMap<String, PendingPathDep>,
412 ) {
413 if parts.is_empty() {
414 return;
415 }
416
417 let ecosystem = parts[0];
419 let remaining = &parts[1..];
420
421 match ecosystem {
422 "cargo" => self.process_cargo_dependency(remaining, text, info, pending_path_deps),
423 "gomod" => self.process_gomod_dependency(remaining, text, info),
424 "poetry" => self.process_poetry_dependency(remaining, text, info, pending_path_deps),
425 "local" => self.process_local_dependency(remaining, text, info, pending_path_deps),
426 "projectref" => self.process_projectref_dependency(remaining, text, info),
427 "cmake" => self.process_cmake_dependency(remaining, text, info),
428 _ => {}
429 }
430 }
431
432 fn process_cargo_dependency(
434 &self,
435 parts: &[&str],
436 text: &str,
437 info: &mut ManifestInfo,
438 pending_path_deps: &mut std::collections::HashMap<String, PendingPathDep>,
439 ) {
440 match parts {
447 ["path", "name"] => {
448 let key = format!("cargo:path:{}", text);
449 pending_path_deps.entry(key).or_default().name = Some(text.to_string());
450 }
451 ["path", "value"] => {
452 let name = infer_name_from_path(text);
456 info.local_dependencies.push(LocalDependency::with_path(
457 name,
458 text.to_string(),
459 DependencyType::Path,
460 ));
461 }
462 ["path", "dev", "name"] => {
463 let key = format!("cargo:path:dev:{}", text);
464 let entry = pending_path_deps.entry(key).or_default();
465 entry.name = Some(text.to_string());
466 entry.is_dev = true;
467 }
468 ["path", "dev", "value"] => {
469 let name = infer_name_from_path(text);
470 info.local_dependencies.push(
471 LocalDependency::with_path(name, text.to_string(), DependencyType::Path)
472 .as_dev(),
473 );
474 }
475 _ => {} }
477 }
478
479 fn process_gomod_dependency(&self, parts: &[&str], text: &str, info: &mut ManifestInfo) {
481 match parts {
487 ["replace", "local"] | ["replace", "multi", "local"] => {
488 let name = infer_name_from_path(text);
490 info.local_dependencies.push(LocalDependency::with_path(
491 name,
492 text.to_string(),
493 DependencyType::Replace,
494 ));
495 }
496 _ => {} }
498 }
499
500 fn process_poetry_dependency(
502 &self,
503 parts: &[&str],
504 text: &str,
505 info: &mut ManifestInfo,
506 pending_path_deps: &mut std::collections::HashMap<String, PendingPathDep>,
507 ) {
508 match parts {
509 ["path", "name"] => {
510 let key = format!("poetry:path:{}", text);
511 pending_path_deps.entry(key).or_default().name = Some(text.to_string());
512 }
513 ["path", "value"] => {
514 let name = infer_name_from_path(text);
515 info.local_dependencies.push(LocalDependency::with_path(
516 name,
517 text.to_string(),
518 DependencyType::Path,
519 ));
520 }
521 _ => {}
522 }
523 }
524
525 fn process_local_dependency(
527 &self,
528 parts: &[&str],
529 text: &str,
530 info: &mut ManifestInfo,
531 pending_path_deps: &mut std::collections::HashMap<String, PendingPathDep>,
532 ) {
533 match parts {
534 ["name"] => {
535 let key = "npm:local:pending".to_string();
537 pending_path_deps.entry(key).or_default().name = Some(text.to_string());
538 }
539 ["version"] => {
540 let pending_name = pending_path_deps
542 .remove("npm:local:pending")
543 .and_then(|p| p.name);
544
545 if text.starts_with("workspace:") {
547 let name = pending_name.unwrap_or_else(|| {
549 text.trim_start_matches("workspace:").to_string()
551 });
552 info.local_dependencies
553 .push(LocalDependency::new(name, DependencyType::Workspace));
554 } else if text.starts_with("file:") || text.starts_with("link:") {
555 let path = text.trim_start_matches("file:").trim_start_matches("link:");
556 let name = pending_name.unwrap_or_else(|| infer_name_from_path(path));
557 info.local_dependencies.push(LocalDependency::with_path(
558 name,
559 path.to_string(),
560 DependencyType::Path,
561 ));
562 }
563 }
564 _ => {}
565 }
566 }
567
568 fn process_projectref_dependency(&self, parts: &[&str], text: &str, info: &mut ManifestInfo) {
570 if let ["dotnet"] = parts {
571 let name = infer_name_from_csproj_path(text);
574 info.local_dependencies.push(LocalDependency::with_path(
575 name,
576 text.to_string(),
577 DependencyType::ProjectReference,
578 ));
579 }
580 }
581
582 fn process_cmake_dependency(&self, parts: &[&str], text: &str, info: &mut ManifestInfo) {
584 if let ["subdirectory"] = parts {
585 let name = infer_name_from_path(text);
587 info.local_dependencies.push(LocalDependency::with_path(
588 name,
589 text.to_string(),
590 DependencyType::Subdirectory,
591 ));
592 }
593 }
594}
595
596#[derive(Debug, Default)]
602struct PendingPathDep {
603 name: Option<String>,
604 path: Option<String>,
605 is_dev: bool,
606 is_build: bool,
607}
608
609fn infer_name_from_path(path: &str) -> String {
617 let path = path.replace('\\', "/");
619
620 path.rsplit('/')
622 .find(|s| !s.is_empty())
623 .unwrap_or(&path)
624 .to_string()
625}
626
627fn infer_name_from_csproj_path(path: &str) -> String {
631 let path = path.replace('\\', "/");
632 let filename = path.rsplit('/').find(|s| !s.is_empty()).unwrap_or(&path);
633
634 filename
636 .strip_suffix(".csproj")
637 .or_else(|| filename.strip_suffix(".vbproj"))
638 .or_else(|| filename.strip_suffix(".fsproj"))
639 .unwrap_or(filename)
640 .to_string()
641}
642
643#[cfg(test)]
648mod tests {
649 use super::*;
650
651 #[test]
656 fn test_parse_package_json_simple() {
657 let content = r#"{"name": "my-package", "version": "1.0.0"}"#;
658 let path = Path::new("package.json");
659
660 let mut parser = ManifestParser::new().unwrap();
661 let info = parser.parse(path, content).unwrap();
662
663 assert_eq!(info.component_name, Some("my-package".to_string()));
664 assert_eq!(info.version, Some("1.0.0".to_string()));
665 assert!(!info.is_workspace_root);
666 }
667
668 #[test]
669 fn test_parse_package_json_with_workspaces() {
670 let content = r#"{
671 "name": "monorepo",
672 "version": "1.0.0",
673 "workspaces": ["packages/*", "apps/*"]
674 }"#;
675 let path = Path::new("package.json");
676
677 let mut parser = ManifestParser::new().unwrap();
678 let info = parser.parse(path, content).unwrap();
679
680 assert_eq!(info.component_name, Some("monorepo".to_string()));
681 assert!(info.workspace_members.contains(&"packages/*".to_string()));
682 assert!(info.workspace_members.contains(&"apps/*".to_string()));
683 }
684
685 #[test]
686 fn test_parse_cargo_toml_simple() {
687 let content = r#"
688[package]
689name = "my-crate"
690version = "0.1.0"
691"#;
692 let path = Path::new("Cargo.toml");
693
694 let mut parser = ManifestParser::new().unwrap();
695 let info = parser.parse(path, content).unwrap();
696
697 assert_eq!(info.component_name, Some("my-crate".to_string()));
698 assert_eq!(info.version, Some("0.1.0".to_string()));
699 }
700
701 #[test]
702 fn test_parse_cargo_toml_workspace() {
703 let content = r#"
704[workspace]
705members = ["crates/*", "examples/*"]
706"#;
707 let path = Path::new("Cargo.toml");
708
709 let mut parser = ManifestParser::new().unwrap();
710 let info = parser.parse(path, content).unwrap();
711
712 assert!(info.is_workspace_root);
713 assert!(info.workspace_members.contains(&"crates/*".to_string()));
714 assert!(info.workspace_members.contains(&"examples/*".to_string()));
715 }
716
717 #[test]
718 fn test_parse_go_mod() {
719 let content = r#"module github.com/myorg/myproject
720
721go 1.21
722"#;
723 let path = Path::new("go.mod");
724
725 let mut parser = ManifestParser::new().unwrap();
726 let info = parser.parse(path, content).unwrap();
727
728 assert_eq!(
729 info.component_name,
730 Some("github.com/myorg/myproject".to_string())
731 );
732 }
733
734 #[test]
735 fn test_parse_go_mod_with_replace() {
736 let content = r#"module github.com/myorg/myproject
737
738go 1.21
739
740replace github.com/myorg/shared => ../shared
741"#;
742 let path = Path::new("go.mod");
743
744 let mut parser = ManifestParser::new().unwrap();
745 let info = parser.parse(path, content).unwrap();
746
747 assert_eq!(info.local_dependencies.len(), 1);
748 let dep = &info.local_dependencies[0];
749 assert_eq!(dep.path, Some("../shared".to_string()));
750 assert_eq!(dep.dep_type, DependencyType::Replace);
751 }
752
753 #[test]
754 fn test_parse_csproj() {
755 let content = r#"<Project Sdk="Microsoft.NET.Sdk">
756 <PropertyGroup>
757 <AssemblyName>MyProject</AssemblyName>
758 <Version>1.0.0</Version>
759 </PropertyGroup>
760 <ItemGroup>
761 <ProjectReference Include="..\Shared\Shared.csproj" />
762 </ItemGroup>
763</Project>"#;
764 let path = Path::new("MyProject.csproj");
765
766 let mut parser = ManifestParser::new().unwrap();
767 let info = parser.parse(path, content).unwrap();
768
769 assert_eq!(info.component_name, Some("MyProject".to_string()));
770 assert_eq!(info.version, Some("1.0.0".to_string()));
771 assert_eq!(info.local_dependencies.len(), 1);
772
773 let dep = &info.local_dependencies[0];
774 assert_eq!(dep.name, "Shared");
775 assert_eq!(dep.dep_type, DependencyType::ProjectReference);
776 }
777
778 #[test]
779 fn test_parse_cmake() {
780 let content = r#"cmake_minimum_required(VERSION 3.20)
781project(my-project VERSION 1.0.0)
782add_subdirectory(../shared shared_build)
783"#;
784 let path = Path::new("CMakeLists.txt");
785
786 let mut parser = ManifestParser::new().unwrap();
787 let info = parser.parse(path, content).unwrap();
788
789 assert_eq!(info.component_name, Some("my-project".to_string()));
790 assert!(!info.local_dependencies.is_empty());
793
794 let dep = info
796 .local_dependencies
797 .iter()
798 .find(|d| d.path.as_deref() == Some("../shared"))
799 .expect("Should find ../shared dependency");
800 assert_eq!(dep.dep_type, DependencyType::Subdirectory);
801 }
802
803 #[test]
804 fn test_parse_pyproject_toml() {
805 let content = r#"
806[project]
807name = "my-package"
808version = "1.0.0"
809"#;
810 let path = Path::new("pyproject.toml");
811
812 let mut parser = ManifestParser::new().unwrap();
813 let info = parser.parse(path, content).unwrap();
814
815 assert_eq!(info.component_name, Some("my-package".to_string()));
816 assert_eq!(info.version, Some("1.0.0".to_string()));
817 }
818
819 #[test]
824 fn test_infer_name_from_path() {
825 assert_eq!(infer_name_from_path("../shared"), "shared");
826 assert_eq!(infer_name_from_path("packages/core"), "core");
827 assert_eq!(infer_name_from_path("..\\shared"), "shared");
828 assert_eq!(infer_name_from_path("./libs/utils"), "utils");
829 assert_eq!(infer_name_from_path("sibling"), "sibling");
830 }
831
832 #[test]
833 fn test_infer_name_from_csproj_path() {
834 assert_eq!(
835 infer_name_from_csproj_path("..\\Shared\\Shared.csproj"),
836 "Shared"
837 );
838 assert_eq!(infer_name_from_csproj_path("../Core/Core.vbproj"), "Core");
839 assert_eq!(infer_name_from_csproj_path("Utils.fsproj"), "Utils");
840 }
841
842 #[test]
847 fn test_dependency_type_as_str() {
848 assert_eq!(DependencyType::Path.as_str(), "path");
849 assert_eq!(DependencyType::Workspace.as_str(), "workspace");
850 assert_eq!(
851 DependencyType::ProjectReference.as_str(),
852 "project_reference"
853 );
854 assert_eq!(DependencyType::Replace.as_str(), "replace");
855 assert_eq!(DependencyType::Subdirectory.as_str(), "subdirectory");
856 }
857
858 #[test]
859 fn test_dependency_type_display() {
860 assert_eq!(format!("{}", DependencyType::Path), "path");
861 assert_eq!(format!("{}", DependencyType::Workspace), "workspace");
862 }
863
864 #[test]
869 fn test_local_dependency_new() {
870 let dep = LocalDependency::new("my-dep".to_string(), DependencyType::Path);
871 assert_eq!(dep.name, "my-dep");
872 assert_eq!(dep.dep_type, DependencyType::Path);
873 assert!(!dep.is_dev);
874 assert!(!dep.is_build);
875 assert!(dep.path.is_none());
876 }
877
878 #[test]
879 fn test_local_dependency_with_path() {
880 let dep = LocalDependency::with_path(
881 "my-dep".to_string(),
882 "../sibling".to_string(),
883 DependencyType::Path,
884 );
885 assert_eq!(dep.path, Some("../sibling".to_string()));
886 }
887
888 #[test]
889 fn test_local_dependency_as_dev() {
890 let dep = LocalDependency::new("my-dep".to_string(), DependencyType::Path).as_dev();
891 assert!(dep.is_dev);
892 assert!(!dep.is_build);
893 }
894
895 #[test]
896 fn test_local_dependency_as_build() {
897 let dep = LocalDependency::new("my-dep".to_string(), DependencyType::Path).as_build();
898 assert!(!dep.is_dev);
899 assert!(dep.is_build);
900 }
901
902 #[test]
907 fn test_manifest_info_is_empty() {
908 let info = ManifestInfo::new();
909 assert!(info.is_empty());
910
911 let mut info_with_name = ManifestInfo::new();
912 info_with_name.component_name = Some("test".to_string());
913 assert!(!info_with_name.is_empty());
914 }
915
916 #[test]
917 fn test_manifest_info_is_publishable() {
918 let mut info = ManifestInfo::new();
919 assert!(!info.is_publishable());
920
921 info.component_name = Some("my-package".to_string());
922 assert!(info.is_publishable());
923 }
924
925 #[test]
930 fn test_unrecognized_manifest() {
931 let mut parser = ManifestParser::new().unwrap();
932 let result = parser.parse(Path::new("README.md"), "# Hello");
933 assert!(matches!(
934 result,
935 Err(ManifestError::UnrecognizedManifest(_))
936 ));
937 }
938}