1use crate::config::RyoConfig;
24use ryo_source::pure::PureFile;
25use ryo_symbol::{
26 write_with_parents, CargoMetadataProvider, WorkspaceFilePath, WorkspaceMetadataProvider,
27 WorkspacePathResolver,
28};
29use std::collections::HashMap;
30use std::path::{Path, PathBuf};
31
32#[derive(Debug, thiserror::Error)]
34pub enum ProjectError {
35 #[error("IO error: {0}")]
36 Io(#[from] std::io::Error),
37
38 #[error("Parse error in {path}: {message}")]
39 Parse { path: PathBuf, message: String },
40
41 #[error("File not found: {0}")]
42 FileNotFound(PathBuf),
43
44 #[error("Cargo metadata error: {0}")]
45 Metadata(#[from] ryo_symbol::MetadataError),
46
47 #[error("Config error: {0}")]
48 Config(#[from] crate::config::ConfigError),
49
50 #[error("Source generation failed: {0}")]
51 SourceGeneration(#[from] ryo_source::pure::ToSynError),
52}
53
54pub struct Project {
67 config_root: PathBuf,
69
70 metadata: CargoMetadataProvider,
72
73 config: RyoConfig,
75
76 files: HashMap<PathBuf, PureFile>,
78}
79
80impl Project {
81 pub fn load(path: impl AsRef<Path>) -> Result<Self, ProjectError> {
94 let config_root = path.as_ref().canonicalize()?;
95
96 let config = RyoConfig::load_or_default(&config_root);
98
99 let manifest_path = Self::resolve_manifest_path(&config_root, &config);
101
102 let metadata = CargoMetadataProvider::from_manifest(&manifest_path)?;
104
105 let mut files = HashMap::new();
107 let workspace_root = metadata.workspace_root();
108 for member in metadata.members() {
109 Self::load_from_entry_points(workspace_root, member, &mut files)?;
110 }
111
112 Ok(Self {
113 config_root,
114 metadata,
115 config,
116 files,
117 })
118 }
119
120 fn resolve_manifest_path(config_root: &Path, config: &RyoConfig) -> PathBuf {
122 if let Some(ref manifest) = config.project.manifest_path {
129 return config_root.join(manifest);
130 }
131
132 if let Some(ref ws_root) = config.project.workspace_root {
133 return config_root.join(ws_root).join("Cargo.toml");
134 }
135
136 let default_manifest = config_root.join("Cargo.toml");
138 if default_manifest.exists() {
139 return default_manifest;
140 }
141
142 config_root
144 .ancestors()
145 .skip(1) .find(|p| p.join("Cargo.toml").exists())
147 .map(|p| p.join("Cargo.toml"))
148 .unwrap_or(default_manifest)
149 }
150
151 pub fn config_root(&self) -> &Path {
153 &self.config_root
154 }
155
156 pub fn workspace_root(&self) -> &Path {
158 self.metadata.workspace_root()
159 }
160
161 pub fn root(&self) -> &Path {
163 self.workspace_root()
164 }
165
166 pub fn metadata(&self) -> &CargoMetadataProvider {
168 &self.metadata
169 }
170
171 pub fn config(&self) -> &RyoConfig {
173 &self.config
174 }
175
176 pub fn path_resolver(&self) -> WorkspacePathResolver {
181 WorkspacePathResolver::with_type(
182 self.workspace_root().to_path_buf(),
183 self.metadata.workspace_type(),
184 )
185 }
186
187 pub fn file_paths(&self) -> impl Iterator<Item = &PathBuf> {
189 self.files.keys()
190 }
191
192 pub fn files(&self) -> &HashMap<PathBuf, PureFile> {
194 &self.files
195 }
196
197 pub fn files_mut(&mut self) -> &mut HashMap<PathBuf, PureFile> {
199 &mut self.files
200 }
201
202 pub fn file_count(&self) -> usize {
204 self.files.len()
205 }
206
207 pub fn resolve_path(&self, path: &Path) -> Option<PathBuf> {
210 if self.files.contains_key(path) {
212 return Some(path.to_path_buf());
213 }
214
215 if path.is_relative() {
217 let absolute = self.root().join(path);
218 if self.files.contains_key(&absolute) {
219 return Some(absolute);
220 }
221 }
222
223 if path.is_absolute() {
225 if let Ok(canonical) = path.canonicalize() {
226 if self.files.contains_key(&canonical) {
227 return Some(canonical);
228 }
229 }
230 if let Ok(relative) = path.strip_prefix(self.root()) {
232 let relative_buf = relative.to_path_buf();
233 if self.files.contains_key(&relative_buf) {
234 return Some(relative_buf);
235 }
236 }
237 }
238
239 None
240 }
241
242 pub fn get_file(&self, path: &Path) -> Option<&PureFile> {
244 self.resolve_path(path)
245 .and_then(|resolved| self.files.get(&resolved))
246 }
247
248 pub fn get_file_mut(&mut self, path: &Path) -> Option<&mut PureFile> {
250 if let Some(resolved) = self.resolve_path(path) {
251 self.files.get_mut(&resolved)
252 } else {
253 None
254 }
255 }
256
257 pub fn insert_file(&mut self, path: PathBuf, file: PureFile) {
259 self.files.insert(path, file);
260 }
261
262 pub fn get_file_with_path(&self, path: &Path) -> Option<(PathBuf, &PureFile)> {
264 self.resolve_path(path)
265 .and_then(|resolved| self.files.get(&resolved).map(|f| (resolved, f)))
266 }
267
268 pub fn get_file_mut_with_path(&mut self, path: &Path) -> Option<(PathBuf, &mut PureFile)> {
270 if let Some(resolved) = self.resolve_path(path) {
271 self.files.get_mut(&resolved).map(|f| (resolved, f))
272 } else {
273 None
274 }
275 }
276
277 pub fn contains_file(&self, path: &Path) -> bool {
279 self.resolve_path(path).is_some()
280 }
281
282 pub fn get_source(&self, path: &Path) -> Result<Option<String>, ProjectError> {
284 Ok(self.get_file(path).map(|f| f.to_source()).transpose()?)
285 }
286
287 pub fn write_to_disk(&self, paths: &[PathBuf]) -> Result<usize, ProjectError> {
291 let mut written = 0;
292
293 for path in paths {
294 if let Some(file) = self.files.get(path) {
295 let source = file.to_source()?;
296 write_with_parents(path, &source)?;
297 written += 1;
298 }
299 }
300
301 Ok(written)
302 }
303
304 pub fn write_all_to_disk(&self) -> Result<usize, ProjectError> {
306 let paths: Vec<_> = self.files.keys().cloned().collect();
307 self.write_to_disk(&paths)
308 }
309
310 pub fn load_files(root: impl AsRef<Path>) -> Result<HashMap<PathBuf, PureFile>, ProjectError> {
326 let root = root.as_ref().canonicalize()?;
327 let mut files = HashMap::new();
328
329 Self::load_dir(&root, &root, &mut files)?;
330
331 Ok(files)
332 }
333
334 pub fn write_from_context(
345 &self,
346 ctx: &ryo_analysis::AnalysisContext,
347 files: &[WorkspaceFilePath],
348 ) -> Result<usize, ProjectError> {
349 let mut written = 0;
350
351 for file_path in files {
352 if let Some(file) = ctx.file(file_path) {
353 let source = file.to_source()?;
354 file_path.write(&source)?;
356 written += 1;
357 }
358 }
359
360 Ok(written)
361 }
362
363 pub fn sync_from_context(
367 &mut self,
368 ctx: &ryo_analysis::AnalysisContext,
369 files: &[WorkspaceFilePath],
370 ) {
371 for file_path in files {
372 if let Some(file) = ctx.file(file_path) {
373 let absolute_path = file_path.to_absolute();
374 self.files.insert(absolute_path, (*file).clone());
375 }
376 }
377 }
378
379 fn load_from_entry_points(
385 workspace_root: &Path,
386 crate_info: &ryo_symbol::CrateInfo,
387 files: &mut HashMap<PathBuf, PureFile>,
388 ) -> Result<(), ProjectError> {
389 use ryo_symbol::TargetKind;
390
391 for target in &crate_info.entry_points {
392 if !matches!(target.kind, TargetKind::Lib | TargetKind::Bin) {
394 continue;
395 }
396
397 let entry_path = workspace_root.join(target.src_path.as_str());
399 if entry_path.exists() {
400 Self::load_module_tree(&entry_path, files)?;
401 }
402 }
403
404 Ok(())
405 }
406
407 fn load_module_tree(
409 file_path: &Path,
410 files: &mut HashMap<PathBuf, PureFile>,
411 ) -> Result<(), ProjectError> {
412 if files.contains_key(file_path) {
414 return Ok(());
415 }
416
417 let canonical_path = file_path
419 .canonicalize()
420 .unwrap_or_else(|_| file_path.to_path_buf());
421
422 if files.contains_key(&canonical_path) {
423 return Ok(());
424 }
425
426 let pure_file = match Self::load_file(file_path) {
428 Ok(f) => f,
429 Err(e) => {
430 tracing::warn!("Failed to load {}: {}", file_path.display(), e);
431 return Ok(());
432 }
433 };
434
435 let mod_names: Vec<String> = pure_file
437 .items
438 .iter()
439 .filter_map(|item| {
440 if let ryo_source::pure::PureItem::Mod(m) = item {
441 if m.items.is_empty() {
443 return Some(m.name.clone());
444 }
445 }
446 None
447 })
448 .collect();
449
450 files.insert(canonical_path.clone(), pure_file);
452
453 let parent_dir = canonical_path
457 .parent()
458 .ok_or_else(|| ProjectError::FileNotFound(file_path.to_path_buf()))?;
459
460 let child_search_dir = if let Some(file_stem) = canonical_path.file_stem() {
462 let file_name = canonical_path.file_name().and_then(|n| n.to_str());
463 if file_name != Some("mod.rs")
464 && file_name != Some("lib.rs")
465 && file_name != Some("main.rs")
466 {
467 parent_dir.join(file_stem)
469 } else {
470 parent_dir.to_path_buf()
472 }
473 } else {
474 parent_dir.to_path_buf()
475 };
476
477 for mod_name in mod_names {
479 if let Some(child_path) = Self::resolve_mod_path(&child_search_dir, &mod_name) {
480 Self::load_module_tree(&child_path, files)?;
481 }
482 }
483
484 Ok(())
485 }
486
487 fn resolve_mod_path(parent_dir: &Path, mod_name: &str) -> Option<PathBuf> {
493 let modern_path = parent_dir.join(format!("{}.rs", mod_name));
495 if modern_path.exists() {
496 return Some(modern_path);
497 }
498
499 let classic_path = parent_dir.join(mod_name).join("mod.rs");
501 if classic_path.exists() {
502 return Some(classic_path);
503 }
504
505 tracing::debug!(
507 "Module '{}' not found in {} (tried {} and {})",
508 mod_name,
509 parent_dir.display(),
510 modern_path.display(),
511 classic_path.display()
512 );
513 None
514 }
515
516 #[allow(dead_code)]
517 fn load_dir(
518 _root: &Path,
519 dir: &Path,
520 files: &mut HashMap<PathBuf, PureFile>,
521 ) -> Result<(), ProjectError> {
522 if !dir.is_dir() {
523 return Ok(());
524 }
525
526 let dir_name = dir.file_name().and_then(|n| n.to_str()).unwrap_or("");
527 if matches!(
528 dir_name,
529 "target" | "node_modules" | ".git" | "dist" | "build"
530 ) {
531 return Ok(());
532 }
533
534 for entry in std::fs::read_dir(dir)? {
535 let entry = entry?;
536 let path = entry.path();
537
538 if path.is_dir() {
539 Self::load_dir(_root, &path, files)?;
540 } else if path.extension().map(|e| e == "rs").unwrap_or(false) {
541 match Self::load_file(&path) {
542 Ok(pure) => {
543 files.insert(path, pure);
544 }
545 Err(e) => {
546 tracing::warn!("Failed to parse {}: {}", path.display(), e);
547 }
548 }
549 }
550 }
551
552 Ok(())
553 }
554
555 fn load_file(path: &Path) -> Result<PureFile, ProjectError> {
556 let content = std::fs::read_to_string(path)?;
557 PureFile::from_source(&content).map_err(|e| ProjectError::Parse {
558 path: path.to_path_buf(),
559 message: e.to_string(),
560 })
561 }
562}
563
564#[cfg(test)]
565mod tests {
566 use super::*;
567 use std::fs;
568 use tempfile::tempdir;
569
570 fn create_test_project() -> tempfile::TempDir {
571 let dir = tempdir().unwrap();
572 let src = dir.path().join("src");
573 fs::create_dir(&src).unwrap();
574
575 fs::write(
577 dir.path().join("Cargo.toml"),
578 r#"[package]
579name = "test-project"
580version = "0.1.0"
581edition = "2021"
582"#,
583 )
584 .unwrap();
585
586 fs::write(
587 src.join("lib.rs"),
588 r#"
589pub fn hello() -> &'static str {
590 "Hello, World!"
591}
592"#,
593 )
594 .unwrap();
595
596 fs::write(
597 src.join("main.rs"),
598 r#"
599fn main() {
600 println!("{}", hello());
601}
602"#,
603 )
604 .unwrap();
605
606 dir
607 }
608
609 #[test]
610 fn test_load_project() {
611 let dir = create_test_project();
612 let project = Project::load(dir.path()).unwrap();
613
614 assert!(
616 project.file_count() >= 1,
617 "Expected at least 1 file, got {}",
618 project.file_count()
619 );
620 assert!(project.workspace_root().exists());
621
622 let lib_exists = project.file_paths().any(|p| p.ends_with("lib.rs"));
624 assert!(lib_exists, "lib.rs not found in project");
625 }
626
627 #[test]
628 fn test_get_file() {
629 let dir = create_test_project();
630 let project = Project::load(dir.path()).unwrap();
631
632 let lib_path = dir.path().canonicalize().unwrap().join("src/lib.rs");
634 assert!(project.get_file(&lib_path).is_some());
635 }
636
637 #[test]
638 fn test_resolve_relative_path() {
639 let dir = create_test_project();
640 let project = Project::load(dir.path()).unwrap();
641
642 let relative = PathBuf::from("src/lib.rs");
644 let resolved = project.resolve_path(&relative);
645 assert!(resolved.is_some(), "Failed to resolve src/lib.rs");
646
647 if let Some(resolved_path) = resolved {
649 assert!(
650 project.files().contains_key(&resolved_path),
651 "Resolved path not in files: {:?}",
652 resolved_path
653 );
654 }
655 }
656
657 #[test]
658 fn test_metadata_provider() {
659 let dir = create_test_project();
660 let project = Project::load(dir.path()).unwrap();
661
662 let metadata = project.metadata();
664 assert_eq!(metadata.workspace_root(), project.workspace_root());
665
666 let crates = metadata.all_crates();
668 assert!(!crates.is_empty());
669 }
670
671 #[test]
672 fn test_path_resolver() {
673 let dir = create_test_project();
674 let project = Project::load(dir.path()).unwrap();
675
676 let resolver = project.path_resolver();
678 let absolute_path = dir.path().canonicalize().unwrap().join("src/lib.rs");
680 let result = resolver.resolve(&absolute_path);
681 assert!(result.is_ok());
682 }
683
684 #[test]
685 fn test_load_files_static() {
686 let dir = create_test_project();
688 let files = Project::load_files(dir.path()).unwrap();
689
690 assert_eq!(files.len(), 2);
691 assert!(files
692 .values()
693 .any(|f| f.to_source().unwrap().contains("hello")));
694 }
695
696 }