1use crate::error::{RazError, RazResult};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::fs;
7use std::path::{Path, PathBuf};
8
9#[derive(Debug, Clone)]
11pub struct ProjectContext {
12 pub workspace_root: PathBuf,
14
15 pub current_file: Option<FileContext>,
17
18 pub cursor_position: Option<Position>,
20
21 pub project_type: ProjectType,
23
24 pub dependencies: Vec<Dependency>,
26
27 pub workspace_members: Vec<WorkspaceMember>,
29
30 pub build_targets: Vec<BuildTarget>,
32
33 pub active_features: Vec<String>,
35
36 pub env_vars: HashMap<String, String>,
38}
39
40#[derive(Debug, Clone)]
42pub struct FileContext {
43 pub path: PathBuf,
45
46 pub language: Language,
48
49 pub symbols: Vec<Symbol>,
51
52 pub imports: Vec<Import>,
54
55 pub cursor_symbol: Option<Symbol>,
57
58 pub module_path: Option<String>,
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
64pub struct Position {
65 pub line: u32,
66 pub column: u32,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
71pub struct Range {
72 pub start: Position,
73 pub end: Position,
74}
75
76impl Range {
77 pub fn contains(&self, position: Position) -> bool {
78 position >= self.start && position <= self.end
79 }
80
81 pub fn contains_position(&self, position: Position) -> bool {
82 self.contains(position)
83 }
84}
85
86impl PartialOrd for Position {
87 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
88 Some(self.cmp(other))
89 }
90}
91
92impl Ord for Position {
93 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
94 self.line
95 .cmp(&other.line)
96 .then(self.column.cmp(&other.column))
97 }
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102pub enum ProjectType {
103 Binary,
104 Library,
105 Workspace,
106 Leptos,
107 Dioxus,
108 Axum,
109 Bevy,
110 Tauri,
111 Yew,
112 Mixed(Vec<ProjectType>),
113}
114
115#[derive(Debug, Clone, PartialEq, Eq)]
117pub enum Language {
118 Rust,
119 Toml,
120 Json,
121 Yaml,
122 Markdown,
123 Unknown,
124}
125
126impl Language {
127 pub fn from_path(path: &Path) -> Self {
128 match path.extension().and_then(|s| s.to_str()) {
129 Some("rs") => Language::Rust,
130 Some("toml") => Language::Toml,
131 Some("json") => Language::Json,
132 Some("yaml" | "yml") => Language::Yaml,
133 Some("md") => Language::Markdown,
134 _ => Language::Unknown,
135 }
136 }
137}
138
139#[derive(Debug, Clone, PartialEq, Eq)]
141pub struct Symbol {
142 pub name: String,
143 pub kind: SymbolKind,
144 pub range: Range,
145 pub modifiers: Vec<String>,
146 pub children: Vec<Symbol>,
147 pub metadata: HashMap<String, String>,
148}
149
150#[derive(Debug, Clone, PartialEq, Eq)]
152pub enum SymbolKind {
153 Function,
154 Struct,
155 Enum,
156 Trait,
157 Module,
158 Test,
159 Impl,
160 Constant,
161 Static,
162 TypeAlias,
163 Macro,
164 Variable,
165}
166
167#[derive(Debug, Clone, PartialEq, Eq)]
169pub enum Visibility {
170 Public,
171 Private,
172 Crate,
173 Super,
174}
175
176#[derive(Debug, Clone, PartialEq, Eq)]
178pub struct Import {
179 pub path: String,
180 pub alias: Option<String>,
181 pub items: Vec<String>,
182}
183
184#[derive(Debug, Clone, PartialEq, Eq)]
186pub struct Dependency {
187 pub name: String,
188 pub version: String,
189 pub features: Vec<String>,
190 pub optional: bool,
191 pub dev_dependency: bool,
192}
193
194#[derive(Debug, Clone, PartialEq, Eq)]
196pub struct WorkspaceMember {
197 pub name: String,
198 pub path: PathBuf,
199 pub package_type: ProjectType,
200}
201
202#[derive(Debug, Clone, PartialEq, Eq)]
204pub struct BuildTarget {
205 pub name: String,
206 pub target_type: TargetType,
207 pub path: PathBuf,
208}
209
210#[derive(Debug, Clone, PartialEq, Eq)]
212pub enum TargetType {
213 Binary,
214 Library,
215 Example,
216 Test,
217 Bench,
218}
219
220#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
222pub struct TestContext {
223 pub package_name: Option<String>,
225 pub target_type: TestTargetType,
227 pub module_path: Vec<String>,
229 pub test_name: Option<String>,
231 pub features: Vec<String>,
233 pub env_vars: Vec<(String, String)>,
235 pub working_dir: Option<PathBuf>,
237}
238
239#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
241pub enum TestTargetType {
242 Lib,
244 Bin(String),
246 Test(String),
248 Bench(String),
250 Example(String),
252}
253
254impl TestContext {
255 pub fn new() -> Self {
257 Self {
258 package_name: None,
259 target_type: TestTargetType::Lib,
260 module_path: Vec::new(),
261 test_name: None,
262 features: Vec::new(),
263 env_vars: Vec::new(),
264 working_dir: None,
265 }
266 }
267
268 pub fn module_path_string(&self) -> Option<String> {
270 if self.module_path.is_empty() {
271 None
272 } else {
273 Some(self.module_path.join("::"))
274 }
275 }
276
277 pub fn full_test_path(&self) -> Option<String> {
279 match (self.module_path_string(), &self.test_name) {
280 (Some(module), Some(test)) => Some(format!("{module}::{test}")),
281 (None, Some(test)) => Some(test.clone()),
282 (Some(module), None) => Some(module),
283 (None, None) => None,
284 }
285 }
286
287 pub fn is_precise(&self) -> bool {
289 self.test_name.is_some() || !self.module_path.is_empty()
290 }
291}
292
293impl Default for TestContext {
294 fn default() -> Self {
295 Self::new()
296 }
297}
298
299pub struct ProjectAnalyzer {
301 workspace_detector: WorkspaceDetector,
302 dependency_analyzer: DependencyAnalyzer,
303 target_detector: TargetDetector,
304 framework_detector: FrameworkDetector,
305 file_analyzer: FileAnalyzer,
306}
307
308impl ProjectAnalyzer {
309 pub fn new() -> Self {
310 Self {
311 workspace_detector: WorkspaceDetector::new(),
312 dependency_analyzer: DependencyAnalyzer::new(),
313 target_detector: TargetDetector::new(),
314 framework_detector: FrameworkDetector::new(),
315 file_analyzer: FileAnalyzer::new(),
316 }
317 }
318}
319
320impl Default for ProjectAnalyzer {
321 fn default() -> Self {
322 Self::new()
323 }
324}
325
326impl ProjectAnalyzer {
327 pub async fn analyze_project(&self, root: &Path) -> RazResult<ProjectContext> {
329 let cargo_toml = self.find_cargo_toml(root)?;
331
332 let mut workspace_info = self.workspace_detector.detect(&cargo_toml).await?;
334
335 let mut all_dependencies = self.dependency_analyzer.analyze(&cargo_toml).await?;
337
338 if workspace_info.is_workspace {
340 for member in &mut workspace_info.members {
341 let member_cargo_toml = root.join(&member.path).join("Cargo.toml");
342 if member_cargo_toml.exists() {
343 if let Ok(package_name) =
345 self.extract_package_name_from_toml(&member_cargo_toml)
346 {
347 member.name = package_name;
348 }
349
350 let member_deps = self.dependency_analyzer.analyze(&member_cargo_toml).await?;
351
352 let member_root = root.join(&member.path);
354 member.package_type = self
355 .framework_detector
356 .detect(&member_deps, &[], &member_root)
357 .await?;
358
359 for dep in member_deps {
361 if !all_dependencies.iter().any(|d| d.name == dep.name) {
362 all_dependencies.push(dep);
363 }
364 }
365 }
366 }
367 } else {
368 if !workspace_info.members.is_empty() {
370 if let Ok(package_name) = self.extract_package_name_from_toml(&cargo_toml) {
371 workspace_info.members[0].name = package_name;
372 }
373 }
374 }
375
376 let targets = self.target_detector.detect(root).await?;
378
379 let project_type = self
381 .framework_detector
382 .detect(&all_dependencies, &targets, root)
383 .await?;
384
385 Ok(ProjectContext {
386 workspace_root: root.to_path_buf(),
387 current_file: None,
388 cursor_position: None,
389 project_type,
390 dependencies: all_dependencies,
391 workspace_members: workspace_info.members,
392 build_targets: targets,
393 active_features: Vec::new(),
394 env_vars: HashMap::new(),
395 })
396 }
397
398 pub async fn analyze_file(
400 &self,
401 context: &mut ProjectContext,
402 file_path: &Path,
403 cursor: Option<Position>,
404 ) -> RazResult<()> {
405 let file_context = self.file_analyzer.analyze_file(file_path, cursor).await?;
406 context.current_file = Some(file_context);
407 context.cursor_position = cursor;
408 Ok(())
409 }
410
411 pub async fn resolve_test_context(
413 &self,
414 context: &ProjectContext,
415 ) -> RazResult<Option<TestContext>> {
416 let Some(ref file_context) = context.current_file else {
417 return Ok(None);
418 };
419
420 if file_context.language != Language::Rust {
422 return Ok(None);
423 }
424
425 let mut test_context = TestContext::new();
426
427 test_context.package_name = self.resolve_package_name(context, &file_context.path)?;
429
430 test_context.target_type = self.resolve_target_type(context, &file_context.path)?;
432
433 test_context.module_path = self.resolve_module_path(&file_context.path)?;
435
436 if let Some(ref cursor_symbol) = file_context.cursor_symbol {
438 if cursor_symbol.kind == SymbolKind::Test
439 || (cursor_symbol.kind == SymbolKind::Function
440 && cursor_symbol.name.starts_with("test_"))
441 {
442 test_context.test_name = Some(cursor_symbol.name.clone());
443 }
444 }
445
446 test_context.features = self.resolve_test_features(context, &file_context.path)?;
448 test_context.env_vars = self.resolve_test_env_vars(context)?;
449
450 test_context.working_dir = Some(context.workspace_root.clone());
452
453 if test_context.is_precise() || test_context.package_name.is_some() {
455 Ok(Some(test_context))
456 } else {
457 Ok(None)
458 }
459 }
460
461 fn resolve_package_name(
463 &self,
464 context: &ProjectContext,
465 file_path: &Path,
466 ) -> RazResult<Option<String>> {
467 if context.workspace_members.len() == 1 {
469 return Ok(Some(context.workspace_members[0].name.clone()));
470 }
471
472 for member in &context.workspace_members {
474 let member_path = if member.path.is_absolute() {
475 member.path.clone()
476 } else {
477 context.workspace_root.join(&member.path)
478 };
479
480 if file_path.starts_with(&member_path) {
481 return Ok(Some(member.name.clone()));
482 }
483 }
484
485 let mut current_dir = file_path.parent();
487 while let Some(dir) = current_dir {
488 let cargo_toml = dir.join("Cargo.toml");
489 if cargo_toml.exists() {
490 if let Ok(name) = self.extract_package_name_from_toml(&cargo_toml) {
491 return Ok(Some(name));
492 }
493 }
494 current_dir = dir.parent();
495 }
496
497 Ok(None)
498 }
499
500 fn extract_package_name_from_toml(&self, cargo_toml: &Path) -> RazResult<String> {
502 let content = fs::read_to_string(cargo_toml)?;
503 let parsed: toml::Value = toml::from_str(&content)
504 .map_err(|e| RazError::parse(format!("Invalid Cargo.toml: {e}")))?;
505
506 parsed
507 .get("package")
508 .and_then(|p| p.get("name"))
509 .and_then(|n| n.as_str())
510 .map(|s| s.to_string())
511 .ok_or_else(|| RazError::parse("No package name found in Cargo.toml".to_string()))
512 }
513
514 fn resolve_target_type(
516 &self,
517 context: &ProjectContext,
518 file_path: &Path,
519 ) -> RazResult<TestTargetType> {
520 let file_str = file_path.to_string_lossy();
521
522 if file_str.contains("/tests/") {
524 if let Some(test_name) = file_path.file_stem().and_then(|s| s.to_str()) {
525 return Ok(TestTargetType::Test(test_name.to_string()));
526 }
527 }
528
529 if file_str.contains("/examples/") {
531 if let Some(example_name) = file_path.file_stem().and_then(|s| s.to_str()) {
532 return Ok(TestTargetType::Example(example_name.to_string()));
533 }
534 }
535
536 if file_str.contains("/benches/") {
538 if let Some(bench_name) = file_path.file_stem().and_then(|s| s.to_str()) {
539 return Ok(TestTargetType::Bench(bench_name.to_string()));
540 }
541 }
542
543 if file_str.contains("/src/lib.rs") || file_str.contains("/src/mod.rs") {
545 return Ok(TestTargetType::Lib);
546 }
547
548 if file_str.contains("/src/main.rs") {
550 return Ok(TestTargetType::Bin("main".to_string()));
552 }
553
554 if file_str.contains("/src/bin/") {
555 if let Some(bin_name) = file_path.file_stem().and_then(|s| s.to_str()) {
556 return Ok(TestTargetType::Bin(bin_name.to_string()));
557 }
558 }
559
560 for target in &context.build_targets {
562 if file_path.starts_with(target.path.parent().unwrap_or(&context.workspace_root)) {
563 return match target.target_type {
564 TargetType::Binary => Ok(TestTargetType::Bin(target.name.clone())),
565 TargetType::Library => Ok(TestTargetType::Lib),
566 TargetType::Test => Ok(TestTargetType::Test(target.name.clone())),
567 TargetType::Bench => Ok(TestTargetType::Bench(target.name.clone())),
568 TargetType::Example => Ok(TestTargetType::Example(target.name.clone())),
569 };
570 }
571 }
572
573 Ok(TestTargetType::Lib)
575 }
576
577 fn resolve_module_path(&self, file_path: &Path) -> RazResult<Vec<String>> {
579 if let Ok(module_path) = self.resolve_module_path_treesitter(file_path) {
581 if !module_path.is_empty() {
582 return Ok(module_path);
583 }
584 }
585
586 if let Ok(module_path) = self.resolve_module_path_rust_analyzer(file_path) {
588 if !module_path.is_empty() {
589 return Ok(module_path);
590 }
591 }
592
593 if let Some(module_str) = FileAnalyzer::extract_module_path(file_path) {
595 Ok(module_str
596 .split("::")
597 .filter(|s| !s.is_empty())
598 .map(|s| s.to_string())
599 .collect())
600 } else {
601 Ok(Vec::new())
602 }
603 }
604
605 fn resolve_module_path_treesitter(&self, file_path: &Path) -> RazResult<Vec<String>> {
607 if self.file_analyzer.rust_analyzer.is_none() {
609 return Ok(Vec::new());
610 }
611
612 let content = fs::read_to_string(file_path)?;
613 let mut rust_analyzer = crate::ast::RustAnalyzer::new()?;
614 let tree = rust_analyzer.parse(&content)?;
615
616 let mut module_path = Vec::new();
618 if let Some(base_path) = FileAnalyzer::extract_module_path(file_path) {
619 module_path = base_path
620 .split("::")
621 .filter(|s| !s.is_empty())
622 .map(|s| s.to_string())
623 .collect();
624 }
625
626 let symbols = rust_analyzer.extract_symbols(&tree, &content)?;
628
629 let test_modules: Vec<_> = symbols
631 .iter()
632 .filter(|s| {
633 s.kind == SymbolKind::Module && (s.name == "tests" || s.name.contains("test"))
634 })
635 .collect();
636
637 if !test_modules.is_empty() {
639 if let Some(test_mod) = test_modules.first() {
642 module_path.push(test_mod.name.clone());
643 }
644 }
645
646 module_path = self.enhance_module_path_with_patterns(module_path, file_path, &content)?;
648
649 Ok(module_path)
650 }
651
652 fn enhance_module_path_with_patterns(
654 &self,
655 mut module_path: Vec<String>,
656 file_path: &Path,
657 content: &str,
658 ) -> RazResult<Vec<String>> {
659 let file_str = file_path.to_string_lossy();
660
661 if (content.contains("#[cfg(test)]") || content.contains("mod tests"))
663 && !module_path.iter().any(|m| m == "tests")
664 {
665 module_path.push("tests".to_string());
666 }
667
668 if file_str.contains("/tests/") {
670 if let Some(test_name) = file_path.file_stem().and_then(|s| s.to_str()) {
673 module_path = vec![test_name.to_string()];
674 }
675 }
676
677 let common_modules = [
679 "middleware",
680 "handlers",
681 "controllers",
682 "services",
683 "utils",
684 "helpers",
685 ];
686 for common in &common_modules {
687 if file_str.contains(&format!("/{common}/"))
688 && !module_path.contains(&common.to_string())
689 {
690 if let Some(pos) = module_path.iter().position(|m| m == "tests") {
692 module_path.insert(pos, common.to_string());
693 } else {
694 module_path.insert(
695 module_path.len().saturating_sub(1).max(0),
696 common.to_string(),
697 );
698 }
699 }
700 }
701
702 Ok(module_path)
703 }
704
705 fn resolve_module_path_rust_analyzer(&self, _file_path: &Path) -> RazResult<Vec<String>> {
707 Ok(Vec::new())
716 }
717
718 fn resolve_test_features(
720 &self,
721 context: &ProjectContext,
722 _file_path: &Path,
723 ) -> RazResult<Vec<String>> {
724 Ok(context.active_features.clone())
727 }
728
729 fn resolve_test_env_vars(&self, context: &ProjectContext) -> RazResult<Vec<(String, String)>> {
731 Ok(context
732 .env_vars
733 .iter()
734 .map(|(k, v)| (k.clone(), v.clone()))
735 .collect())
736 }
737
738 fn find_cargo_toml(&self, root: &Path) -> RazResult<PathBuf> {
739 let cargo_toml = root.join("Cargo.toml");
740 if cargo_toml.exists() {
741 Ok(cargo_toml)
742 } else {
743 Err(RazError::invalid_workspace(root))
744 }
745 }
746}
747
748pub struct WorkspaceDetector;
750
751impl WorkspaceDetector {
752 pub fn new() -> Self {
753 Self
754 }
755}
756
757impl Default for WorkspaceDetector {
758 fn default() -> Self {
759 Self::new()
760 }
761}
762
763impl WorkspaceDetector {
764 pub async fn detect(&self, cargo_toml: &Path) -> RazResult<WorkspaceInfo> {
765 let content = fs::read_to_string(cargo_toml)?;
766 let parsed: toml::Value = toml::from_str(&content)
767 .map_err(|e| RazError::parse(format!("Invalid Cargo.toml: {e}")))?;
768
769 if let Some(workspace) = parsed.get("workspace") {
770 let members = self.extract_workspace_members(workspace)?;
771 Ok(WorkspaceInfo {
772 is_workspace: true,
773 members,
774 })
775 } else {
776 let package_name = parsed
778 .get("package")
779 .and_then(|p| p.get("name"))
780 .and_then(|n| n.as_str())
781 .unwrap_or("unknown")
782 .to_string();
783
784 Ok(WorkspaceInfo {
785 is_workspace: false,
786 members: vec![WorkspaceMember {
787 name: package_name,
788 path: cargo_toml.parent().unwrap().to_path_buf(),
789 package_type: ProjectType::Binary, }],
791 })
792 }
793 }
794
795 fn extract_workspace_members(
796 &self,
797 workspace: &toml::Value,
798 ) -> RazResult<Vec<WorkspaceMember>> {
799 let members = workspace
800 .get("members")
801 .and_then(|m| m.as_array())
802 .ok_or_else(|| RazError::parse("Workspace missing members"))?;
803
804 let mut result = Vec::new();
805
806 for member in members {
807 if let Some(path_str) = member.as_str() {
808 result.push(WorkspaceMember {
809 name: path_str.to_string(), path: PathBuf::from(path_str),
811 package_type: ProjectType::Binary, });
813 }
814 }
815
816 Ok(result)
817 }
818}
819
820#[derive(Debug)]
821pub struct WorkspaceInfo {
822 pub is_workspace: bool,
823 pub members: Vec<WorkspaceMember>,
824}
825
826pub struct DependencyAnalyzer;
828
829impl DependencyAnalyzer {
830 pub fn new() -> Self {
831 Self
832 }
833}
834
835impl Default for DependencyAnalyzer {
836 fn default() -> Self {
837 Self::new()
838 }
839}
840
841impl DependencyAnalyzer {
842 pub async fn analyze(&self, cargo_toml: &Path) -> RazResult<Vec<Dependency>> {
843 let content = fs::read_to_string(cargo_toml)?;
844 let parsed: toml::Value = toml::from_str(&content)
845 .map_err(|e| RazError::parse(format!("Invalid Cargo.toml: {e}")))?;
846
847 let mut dependencies = Vec::new();
848
849 if let Some(deps) = parsed.get("dependencies").and_then(|d| d.as_table()) {
851 dependencies.extend(self.parse_dependencies(deps, false)?);
852 }
853
854 if let Some(dev_deps) = parsed.get("dev-dependencies").and_then(|d| d.as_table()) {
856 dependencies.extend(self.parse_dependencies(dev_deps, true)?);
857 }
858
859 Ok(dependencies)
860 }
861
862 fn parse_dependencies(
863 &self,
864 deps: &toml::value::Table,
865 is_dev: bool,
866 ) -> RazResult<Vec<Dependency>> {
867 let mut result = Vec::new();
868
869 for (name, value) in deps {
870 let dependency = match value {
871 toml::Value::String(version) => Dependency {
872 name: name.clone(),
873 version: version.clone(),
874 features: Vec::new(),
875 optional: false,
876 dev_dependency: is_dev,
877 },
878 toml::Value::Table(table) => {
879 let version = table
880 .get("version")
881 .and_then(|v| v.as_str())
882 .unwrap_or("*")
883 .to_string();
884
885 let features = table
886 .get("features")
887 .and_then(|f| f.as_array())
888 .map(|arr| {
889 arr.iter()
890 .filter_map(|v| v.as_str())
891 .map(|s| s.to_string())
892 .collect()
893 })
894 .unwrap_or_default();
895
896 let optional = table
897 .get("optional")
898 .and_then(|o| o.as_bool())
899 .unwrap_or(false);
900
901 Dependency {
902 name: name.clone(),
903 version,
904 features,
905 optional,
906 dev_dependency: is_dev,
907 }
908 }
909 _ => continue,
910 };
911
912 result.push(dependency);
913 }
914
915 Ok(result)
916 }
917}
918
919pub struct TargetDetector;
921
922impl TargetDetector {
923 pub fn new() -> Self {
924 Self
925 }
926}
927
928impl Default for TargetDetector {
929 fn default() -> Self {
930 Self::new()
931 }
932}
933
934impl TargetDetector {
935 pub async fn detect(&self, root: &Path) -> RazResult<Vec<BuildTarget>> {
936 let mut targets = Vec::new();
937
938 let main_rs = root.join("src/main.rs");
940 if main_rs.exists() {
941 targets.push(BuildTarget {
942 name: "main".to_string(),
943 target_type: TargetType::Binary,
944 path: main_rs,
945 });
946 }
947
948 let lib_rs = root.join("src/lib.rs");
950 if lib_rs.exists() {
951 targets.push(BuildTarget {
952 name: "lib".to_string(),
953 target_type: TargetType::Library,
954 path: lib_rs,
955 });
956 }
957
958 let examples_dir = root.join("examples");
960 if examples_dir.exists() {
961 for entry in fs::read_dir(&examples_dir)? {
962 let entry = entry?;
963 if let Some(name) = entry.file_name().to_str() {
964 if name.ends_with(".rs") {
965 let name = name.strip_suffix(".rs").unwrap();
966 targets.push(BuildTarget {
967 name: name.to_string(),
968 target_type: TargetType::Example,
969 path: entry.path(),
970 });
971 }
972 }
973 }
974 }
975
976 let tests_dir = root.join("tests");
978 if tests_dir.exists() {
979 for entry in fs::read_dir(&tests_dir)? {
980 let entry = entry?;
981 if let Some(name) = entry.file_name().to_str() {
982 if name.ends_with(".rs") {
983 let name = name.strip_suffix(".rs").unwrap();
984 targets.push(BuildTarget {
985 name: name.to_string(),
986 target_type: TargetType::Test,
987 path: entry.path(),
988 });
989 }
990 }
991 }
992 }
993
994 Ok(targets)
995 }
996}
997
998pub struct FrameworkDetector {
1000 rules: Vec<DetectionRule>,
1001}
1002
1003impl FrameworkDetector {
1004 pub fn new() -> Self {
1005 Self {
1006 rules: Self::default_rules(),
1007 }
1008 }
1009}
1010
1011impl Default for FrameworkDetector {
1012 fn default() -> Self {
1013 Self::new()
1014 }
1015}
1016
1017impl FrameworkDetector {
1018 pub async fn detect(
1019 &self,
1020 dependencies: &[Dependency],
1021 _targets: &[BuildTarget],
1022 workspace_root: &Path,
1023 ) -> RazResult<ProjectType> {
1024 let mut detected_frameworks = Vec::new();
1025
1026 for rule in &self.rules {
1027 if rule.matches(dependencies, workspace_root) {
1028 if !detected_frameworks.contains(&rule.project_type) {
1030 detected_frameworks.push(rule.project_type.clone());
1031 }
1032 }
1033 }
1034
1035 match detected_frameworks.len() {
1036 0 => Ok(ProjectType::Binary), 1 => Ok(detected_frameworks.into_iter().next().unwrap()),
1038 _ => {
1039 detected_frameworks.sort_by_key(|framework| match framework {
1042 ProjectType::Tauri => 0, ProjectType::Leptos => 1, ProjectType::Dioxus => 2, ProjectType::Yew => 3, ProjectType::Bevy => 4, ProjectType::Axum => 5, _ => 6, });
1050 Ok(ProjectType::Mixed(detected_frameworks))
1051 }
1052 }
1053 }
1054
1055 fn default_rules() -> Vec<DetectionRule> {
1056 vec![
1057 DetectionRule {
1059 framework: "tauri".to_string(),
1060 project_type: ProjectType::Tauri,
1061 conditions: vec![
1062 DetectionCondition::HasFilePattern("src-tauri/Cargo.toml".to_string()),
1063 DetectionCondition::HasFilePattern("src-tauri/tauri.conf.json".to_string()),
1064 ],
1065 },
1066 DetectionRule {
1068 framework: "tauri".to_string(),
1069 project_type: ProjectType::Tauri,
1070 conditions: vec![DetectionCondition::HasDependency("tauri".to_string())],
1071 },
1072 DetectionRule {
1074 framework: "leptos".to_string(),
1075 project_type: ProjectType::Leptos,
1076 conditions: vec![
1077 DetectionCondition::HasAnyDependency(vec![
1078 "leptos".to_string(),
1079 "leptos_axum".to_string(),
1080 "leptos_actix".to_string(),
1081 "leptos_router".to_string(),
1082 "leptos_reactive".to_string(),
1083 ]),
1084 DetectionCondition::ConfigFileContains(
1085 PathBuf::from("Cargo.toml"),
1086 "[package.metadata.leptos]".to_string(),
1087 ),
1088 ],
1089 },
1090 DetectionRule {
1092 framework: "leptos".to_string(),
1093 project_type: ProjectType::Leptos,
1094 conditions: vec![DetectionCondition::HasAnyDependency(vec![
1095 "leptos".to_string(),
1096 "leptos_axum".to_string(),
1097 "leptos_actix".to_string(),
1098 "leptos_router".to_string(),
1099 "leptos_reactive".to_string(),
1100 ])],
1101 },
1102 DetectionRule {
1104 framework: "dioxus".to_string(),
1105 project_type: ProjectType::Dioxus,
1106 conditions: vec![
1107 DetectionCondition::HasDependency("dioxus".to_string()),
1108 DetectionCondition::HasFile(PathBuf::from("Dioxus.toml")),
1109 ],
1110 },
1111 DetectionRule {
1113 framework: "dioxus".to_string(),
1114 project_type: ProjectType::Dioxus,
1115 conditions: vec![DetectionCondition::HasAnyDependency(vec![
1116 "dioxus".to_string(),
1117 "dioxus-web".to_string(),
1118 "dioxus-desktop".to_string(),
1119 "dioxus-mobile".to_string(),
1120 ])],
1121 },
1122 DetectionRule {
1124 framework: "bevy".to_string(),
1125 project_type: ProjectType::Bevy,
1126 conditions: vec![DetectionCondition::HasDependency("bevy".to_string())],
1127 },
1128 DetectionRule {
1130 framework: "axum".to_string(),
1131 project_type: ProjectType::Axum,
1132 conditions: vec![DetectionCondition::HasDependency("axum".to_string())],
1133 },
1134 DetectionRule {
1136 framework: "yew".to_string(),
1137 project_type: ProjectType::Yew,
1138 conditions: vec![
1139 DetectionCondition::HasDependency("yew".to_string()),
1140 DetectionCondition::HasFile(PathBuf::from("Trunk.toml")),
1141 ],
1142 },
1143 DetectionRule {
1145 framework: "yew".to_string(),
1146 project_type: ProjectType::Yew,
1147 conditions: vec![DetectionCondition::HasAnyDependency(vec![
1148 "yew".to_string(),
1149 "yew-router".to_string(),
1150 "yew-hooks".to_string(),
1151 ])],
1152 },
1153 ]
1154 }
1155}
1156
1157#[derive(Debug)]
1158pub struct DetectionRule {
1159 pub framework: String,
1160 pub project_type: ProjectType,
1161 pub conditions: Vec<DetectionCondition>,
1162}
1163
1164impl DetectionRule {
1165 pub fn matches(&self, dependencies: &[Dependency], workspace_root: &Path) -> bool {
1166 self.conditions
1167 .iter()
1168 .all(|condition| condition.is_met(dependencies, workspace_root))
1169 }
1170}
1171
1172#[derive(Debug)]
1173pub enum DetectionCondition {
1174 HasDependency(String),
1175 HasAnyDependency(Vec<String>),
1176 HasFile(PathBuf),
1177 HasFilePattern(String),
1178 ConfigFileContains(PathBuf, String),
1179}
1180
1181impl DetectionCondition {
1182 pub fn is_met(&self, dependencies: &[Dependency], workspace_root: &Path) -> bool {
1183 match self {
1184 DetectionCondition::HasDependency(name) => {
1185 dependencies.iter().any(|dep| dep.name == *name)
1186 }
1187 DetectionCondition::HasAnyDependency(names) => {
1188 dependencies.iter().any(|dep| names.contains(&dep.name))
1189 }
1190 DetectionCondition::HasFile(path) => path.exists(),
1191 DetectionCondition::HasFilePattern(pattern) => {
1192 Self::check_file_pattern(workspace_root, pattern)
1193 }
1194 DetectionCondition::ConfigFileContains(path, content) => {
1195 Self::check_file_content(workspace_root, path, content)
1196 }
1197 }
1198 }
1199
1200 fn check_file_pattern(workspace_root: &Path, pattern: &str) -> bool {
1202 let full_pattern = workspace_root.join(pattern);
1204 let pattern_str = full_pattern.to_string_lossy();
1205
1206 match glob::glob(&pattern_str) {
1208 Ok(paths) => {
1209 paths.filter_map(Result::ok).next().is_some()
1211 }
1212 Err(_) => false,
1213 }
1214 }
1215
1216 fn check_file_content(workspace_root: &Path, file_path: &Path, content: &str) -> bool {
1218 let full_path = if file_path.is_absolute() {
1219 file_path.to_path_buf()
1220 } else {
1221 workspace_root.join(file_path)
1222 };
1223
1224 match std::fs::read_to_string(&full_path) {
1225 Ok(file_content) => file_content.contains(content),
1226 Err(_) => false,
1227 }
1228 }
1229}
1230
1231pub struct FileAnalyzer {
1233 rust_analyzer: Option<crate::ast::RustAnalyzer>,
1234}
1235
1236impl Default for FileAnalyzer {
1237 fn default() -> Self {
1238 Self::new()
1239 }
1240}
1241
1242impl FileAnalyzer {
1243 pub fn new() -> Self {
1244 let rust_analyzer = crate::ast::RustAnalyzer::new().ok();
1245 Self { rust_analyzer }
1246 }
1247
1248 pub async fn analyze_file(
1249 &self,
1250 path: &Path,
1251 cursor: Option<Position>,
1252 ) -> RazResult<FileContext> {
1253 let language = Language::from_path(path);
1254
1255 if language == Language::Rust && self.rust_analyzer.is_some() {
1256 self.analyze_rust_file(path, cursor).await
1257 } else {
1258 Ok(FileContext {
1260 path: path.to_path_buf(),
1261 language,
1262 symbols: Vec::new(),
1263 imports: Vec::new(),
1264 cursor_symbol: None,
1265 module_path: Self::extract_module_path(path),
1266 })
1267 }
1268 }
1269
1270 async fn analyze_rust_file(
1271 &self,
1272 path: &Path,
1273 cursor: Option<Position>,
1274 ) -> RazResult<FileContext> {
1275 if let Some(ref _analyzer) = self.rust_analyzer {
1276 let content = fs::read_to_string(path)?;
1277 let mut rust_analyzer = crate::ast::RustAnalyzer::new()?;
1278 let tree = rust_analyzer.parse(&content)?;
1279
1280 let symbols = rust_analyzer.extract_symbols(&tree, &content)?;
1282
1283 let cursor_symbol = if let Some(pos) = cursor {
1285 rust_analyzer.symbol_at_position(&tree, &content, pos)?
1286 } else {
1287 None
1288 };
1289
1290 let imports = self.extract_imports(&content);
1292
1293 Ok(FileContext {
1294 path: path.to_path_buf(),
1295 language: Language::Rust,
1296 symbols,
1297 imports,
1298 cursor_symbol,
1299 module_path: Self::extract_module_path(path),
1300 })
1301 } else {
1302 Err(RazError::analysis(
1303 "Rust analyzer not available".to_string(),
1304 ))
1305 }
1306 }
1307
1308 fn extract_imports(&self, content: &str) -> Vec<Import> {
1309 let mut imports = Vec::new();
1310
1311 for line in content.lines() {
1312 let trimmed = line.trim();
1313 if trimmed.starts_with("use ") {
1314 if let Some(import) = self.parse_use_statement(trimmed) {
1315 imports.push(import);
1316 }
1317 }
1318 }
1319
1320 imports
1321 }
1322
1323 fn parse_use_statement(&self, use_line: &str) -> Option<Import> {
1324 let use_part = use_line.strip_prefix("use ")?.strip_suffix(";")?;
1325
1326 if use_part.ends_with("::*") {
1328 let path = use_part.strip_suffix("::*")?.to_string();
1329 return Some(Import {
1330 path,
1331 alias: None,
1332 items: vec!["*".to_string()],
1333 });
1334 }
1335
1336 if let Some(brace_start) = use_part.find("::{") {
1339 if let Some(brace_end) = use_part.rfind('}') {
1340 let path = use_part[..brace_start].trim().to_string();
1341 let items_str = &use_part[brace_start + 3..brace_end];
1342 let items = self.parse_import_items(items_str);
1343
1344 return Some(Import {
1345 path,
1346 alias: None,
1347 items,
1348 });
1349 }
1350 }
1351
1352 if let Some(as_pos) = use_part.find(" as ") {
1354 let path = use_part[..as_pos].trim();
1355 let alias = use_part[as_pos + 4..].trim();
1356 return Some(Import {
1357 path: path.to_string(),
1358 alias: Some(alias.to_string()),
1359 items: vec![],
1360 });
1361 }
1362
1363 Some(Import {
1365 path: use_part.to_string(),
1366 alias: None,
1367 items: vec![],
1368 })
1369 }
1370
1371 fn parse_import_items(&self, items_str: &str) -> Vec<String> {
1373 let mut items = Vec::new();
1374 let mut current_item = String::new();
1375 let mut brace_depth = 0;
1376
1377 for ch in items_str.chars() {
1378 match ch {
1379 '{' => {
1380 brace_depth += 1;
1381 current_item.push(ch);
1382 }
1383 '}' => {
1384 brace_depth -= 1;
1385 current_item.push(ch);
1386 }
1387 ',' if brace_depth == 0 => {
1388 let item = current_item.trim();
1389 if !item.is_empty() {
1390 items.push(item.to_string());
1391 }
1392 current_item.clear();
1393 }
1394 _ => current_item.push(ch),
1395 }
1396 }
1397
1398 let item = current_item.trim();
1400 if !item.is_empty() {
1401 items.push(item.to_string());
1402 }
1403
1404 items
1405 }
1406
1407 pub fn extract_module_path(path: &Path) -> Option<String> {
1408 if let Some(src_index) = path.to_str()?.find("src/") {
1411 let relative_path = &path.to_str()?[src_index + 4..];
1412 let without_extension = relative_path.strip_suffix(".rs")?;
1413 let module_path = without_extension.replace('/', "::").replace("main", "");
1414 if module_path.is_empty() {
1415 None
1416 } else {
1417 Some(module_path)
1418 }
1419 } else {
1420 None
1421 }
1422 }
1423}
1424
1425#[cfg(test)]
1426mod tests {
1427 use super::*;
1428 use tempfile::TempDir;
1429
1430 #[tokio::test]
1431 async fn test_project_analysis() {
1432 let temp_dir = TempDir::new().unwrap();
1433 let cargo_toml = temp_dir.path().join("Cargo.toml");
1434 fs::write(
1435 &cargo_toml,
1436 r#"
1437 [package]
1438 name = "test-project"
1439 version = "0.1.0"
1440 edition = "2021"
1441
1442 [dependencies]
1443 leptos = "0.5"
1444 "#,
1445 )
1446 .unwrap();
1447
1448 let analyzer = ProjectAnalyzer::new();
1449 let context = analyzer.analyze_project(temp_dir.path()).await.unwrap();
1450
1451 assert_eq!(context.workspace_root, temp_dir.path());
1452 assert_eq!(context.project_type, ProjectType::Leptos);
1453 assert!(context.dependencies.iter().any(|d| d.name == "leptos"));
1454 }
1455
1456 #[test]
1457 fn test_language_detection() {
1458 assert_eq!(Language::from_path(Path::new("main.rs")), Language::Rust);
1459 assert_eq!(Language::from_path(Path::new("Cargo.toml")), Language::Toml);
1460 assert_eq!(
1461 Language::from_path(Path::new("package.json")),
1462 Language::Json
1463 );
1464 }
1465
1466 #[test]
1467 fn test_position_ordering() {
1468 let pos1 = Position { line: 1, column: 5 };
1469 let pos2 = Position { line: 2, column: 3 };
1470 let pos3 = Position {
1471 line: 1,
1472 column: 10,
1473 };
1474
1475 assert!(pos1 < pos2);
1476 assert!(pos1 < pos3);
1477 assert!(pos3 < pos2);
1478 }
1479
1480 #[test]
1481 fn test_range_contains() {
1482 let range = Range {
1483 start: Position { line: 1, column: 0 },
1484 end: Position {
1485 line: 3,
1486 column: 10,
1487 },
1488 };
1489
1490 assert!(range.contains(Position { line: 2, column: 5 }));
1491 assert!(range.contains(Position { line: 1, column: 0 }));
1492 assert!(range.contains(Position {
1493 line: 3,
1494 column: 10
1495 }));
1496 assert!(!range.contains(Position { line: 0, column: 5 }));
1497 assert!(!range.contains(Position { line: 4, column: 5 }));
1498 }
1499
1500 #[test]
1501 fn test_file_pattern_detection() {
1502 let temp_dir = TempDir::new().unwrap();
1503 let temp_path = temp_dir.path();
1504
1505 std::fs::create_dir_all(temp_path.join("src-tauri")).unwrap();
1507 std::fs::write(temp_path.join("src-tauri/Cargo.toml"), "").unwrap();
1508 std::fs::write(temp_path.join("src-tauri/tauri.conf.json"), "{}").unwrap();
1509 std::fs::write(temp_path.join("Dioxus.toml"), "[application]").unwrap();
1510
1511 assert!(DetectionCondition::check_file_pattern(
1513 temp_path,
1514 "src-tauri/Cargo.toml"
1515 ));
1516 assert!(DetectionCondition::check_file_pattern(
1517 temp_path,
1518 "src-tauri/*.json"
1519 ));
1520 assert!(DetectionCondition::check_file_pattern(
1521 temp_path,
1522 "Dioxus.toml"
1523 ));
1524 assert!(!DetectionCondition::check_file_pattern(
1525 temp_path,
1526 "nonexistent.toml"
1527 ));
1528 assert!(!DetectionCondition::check_file_pattern(
1529 temp_path,
1530 "src-nonexistent/*.toml"
1531 ));
1532 }
1533
1534 #[test]
1535 fn test_file_content_detection() {
1536 let temp_dir = TempDir::new().unwrap();
1537 let temp_path = temp_dir.path();
1538
1539 let cargo_toml_content = r#"
1541[package]
1542name = "test"
1543
1544[package.metadata.leptos]
1545output-name = "my-app"
1546 "#;
1547 std::fs::write(temp_path.join("Cargo.toml"), cargo_toml_content).unwrap();
1548
1549 assert!(DetectionCondition::check_file_content(
1551 temp_path,
1552 &PathBuf::from("Cargo.toml"),
1553 "[package.metadata.leptos]"
1554 ));
1555 assert!(DetectionCondition::check_file_content(
1556 temp_path,
1557 &PathBuf::from("Cargo.toml"),
1558 "output-name"
1559 ));
1560 assert!(!DetectionCondition::check_file_content(
1561 temp_path,
1562 &PathBuf::from("Cargo.toml"),
1563 "nonexistent-content"
1564 ));
1565 assert!(!DetectionCondition::check_file_content(
1566 temp_path,
1567 &PathBuf::from("nonexistent.toml"),
1568 "any-content"
1569 ));
1570 }
1571
1572 #[tokio::test]
1573 async fn test_multi_framework_detection() {
1574 let temp_dir = TempDir::new().unwrap();
1575 let temp_path = temp_dir.path();
1576
1577 std::fs::create_dir_all(temp_path.join("src-tauri")).unwrap();
1579 std::fs::write(temp_path.join("src-tauri/Cargo.toml"), "").unwrap();
1580 std::fs::write(temp_path.join("src-tauri/tauri.conf.json"), "{}").unwrap();
1581
1582 let cargo_toml_content = r#"
1583[package]
1584name = "mixed-app"
1585
1586[package.metadata.leptos]
1587output-name = "my-app"
1588
1589[dependencies]
1590leptos = "0.6"
1591tauri = "1.0"
1592 "#;
1593 std::fs::write(temp_path.join("Cargo.toml"), cargo_toml_content).unwrap();
1594
1595 let detector = FrameworkDetector::new();
1597 let deps = vec![
1598 Dependency {
1599 name: "leptos".to_string(),
1600 version: "0.6".to_string(),
1601 features: vec![],
1602 optional: false,
1603 dev_dependency: false,
1604 },
1605 Dependency {
1606 name: "tauri".to_string(),
1607 version: "1.0".to_string(),
1608 features: vec![],
1609 optional: false,
1610 dev_dependency: false,
1611 },
1612 ];
1613
1614 let result = detector.detect(&deps, &[], temp_path).await.unwrap();
1615
1616 match result {
1617 ProjectType::Mixed(frameworks) => {
1618 assert!(frameworks.contains(&ProjectType::Tauri));
1619 assert!(frameworks.contains(&ProjectType::Leptos));
1620 assert_eq!(frameworks[0], ProjectType::Tauri);
1622 }
1623 _ => panic!("Expected Mixed project type, got {result:?}"),
1624 }
1625 }
1626
1627 #[test]
1628 fn test_complex_use_statement_parsing() {
1629 let analyzer = FileAnalyzer::new();
1630
1631 let import = analyzer.parse_use_statement("use std::*;").unwrap();
1633 assert_eq!(import.path, "std");
1634 assert_eq!(import.items, vec!["*"]);
1635
1636 let import = analyzer
1638 .parse_use_statement("use std::collections::HashMap as Map;")
1639 .unwrap();
1640 assert_eq!(import.path, "std::collections::HashMap");
1641 assert_eq!(import.alias, Some("Map".to_string()));
1642
1643 let import = analyzer
1645 .parse_use_statement("use std::{fs, io, collections::HashMap};")
1646 .unwrap();
1647 assert_eq!(import.path, "std");
1648 assert_eq!(import.items, vec!["fs", "io", "collections::HashMap"]);
1649
1650 let import = analyzer
1652 .parse_use_statement("use crate::module::{Item1, Item2 as Alias, Item3};")
1653 .unwrap();
1654 assert_eq!(import.path, "crate::module");
1655 assert_eq!(import.items, vec!["Item1", "Item2 as Alias", "Item3"]);
1656
1657 let import = analyzer
1659 .parse_use_statement("use std::{fs, io::{self, Read, Write}};")
1660 .unwrap();
1661 assert_eq!(import.path, "std");
1662 assert_eq!(import.items, vec!["fs", "io::{self, Read, Write}"]);
1663
1664 let import = analyzer
1666 .parse_use_statement("use std::collections::HashMap;")
1667 .unwrap();
1668 assert_eq!(import.path, "std::collections::HashMap");
1669 assert!(import.items.is_empty());
1670 assert!(import.alias.is_none());
1671 }
1672}