use crate::config::RyoConfig;
use ryo_source::pure::PureFile;
use ryo_symbol::{
write_with_parents, CargoMetadataProvider, WorkspaceFilePath, WorkspaceMetadataProvider,
WorkspacePathResolver,
};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug, thiserror::Error)]
pub enum ProjectError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Parse error in {path}: {message}")]
Parse { path: PathBuf, message: String },
#[error("File not found: {0}")]
FileNotFound(PathBuf),
#[error("Cargo metadata error: {0}")]
Metadata(#[from] ryo_symbol::MetadataError),
#[error("Config error: {0}")]
Config(#[from] crate::config::ConfigError),
#[error("Source generation failed: {0}")]
SourceGeneration(#[from] ryo_source::pure::ToSynError),
}
pub struct Project {
config_root: PathBuf,
metadata: CargoMetadataProvider,
config: RyoConfig,
files: HashMap<PathBuf, PureFile>,
}
impl Project {
pub fn load(path: impl AsRef<Path>) -> Result<Self, ProjectError> {
let config_root = path.as_ref().canonicalize()?;
let config = RyoConfig::load_or_default(&config_root);
let manifest_path = Self::resolve_manifest_path(&config_root, &config);
let metadata = CargoMetadataProvider::from_manifest(&manifest_path)?;
let mut files = HashMap::new();
let workspace_root = metadata.workspace_root();
for member in metadata.members() {
Self::load_from_entry_points(workspace_root, member, &mut files)?;
}
Ok(Self {
config_root,
metadata,
config,
files,
})
}
fn resolve_manifest_path(config_root: &Path, config: &RyoConfig) -> PathBuf {
if let Some(ref manifest) = config.project.manifest_path {
return config_root.join(manifest);
}
if let Some(ref ws_root) = config.project.workspace_root {
return config_root.join(ws_root).join("Cargo.toml");
}
let default_manifest = config_root.join("Cargo.toml");
if default_manifest.exists() {
return default_manifest;
}
config_root
.ancestors()
.skip(1) .find(|p| p.join("Cargo.toml").exists())
.map(|p| p.join("Cargo.toml"))
.unwrap_or(default_manifest)
}
pub fn config_root(&self) -> &Path {
&self.config_root
}
pub fn workspace_root(&self) -> &Path {
self.metadata.workspace_root()
}
pub fn root(&self) -> &Path {
self.workspace_root()
}
pub fn metadata(&self) -> &CargoMetadataProvider {
&self.metadata
}
pub fn config(&self) -> &RyoConfig {
&self.config
}
pub fn path_resolver(&self) -> WorkspacePathResolver {
WorkspacePathResolver::with_type(
self.workspace_root().to_path_buf(),
self.metadata.workspace_type(),
)
}
pub fn file_paths(&self) -> impl Iterator<Item = &PathBuf> {
self.files.keys()
}
pub fn files(&self) -> &HashMap<PathBuf, PureFile> {
&self.files
}
pub fn files_mut(&mut self) -> &mut HashMap<PathBuf, PureFile> {
&mut self.files
}
pub fn file_count(&self) -> usize {
self.files.len()
}
pub fn resolve_path(&self, path: &Path) -> Option<PathBuf> {
if self.files.contains_key(path) {
return Some(path.to_path_buf());
}
if path.is_relative() {
let absolute = self.root().join(path);
if self.files.contains_key(&absolute) {
return Some(absolute);
}
}
if path.is_absolute() {
if let Ok(canonical) = path.canonicalize() {
if self.files.contains_key(&canonical) {
return Some(canonical);
}
}
if let Ok(relative) = path.strip_prefix(self.root()) {
let relative_buf = relative.to_path_buf();
if self.files.contains_key(&relative_buf) {
return Some(relative_buf);
}
}
}
None
}
pub fn get_file(&self, path: &Path) -> Option<&PureFile> {
self.resolve_path(path)
.and_then(|resolved| self.files.get(&resolved))
}
pub fn get_file_mut(&mut self, path: &Path) -> Option<&mut PureFile> {
if let Some(resolved) = self.resolve_path(path) {
self.files.get_mut(&resolved)
} else {
None
}
}
pub fn insert_file(&mut self, path: PathBuf, file: PureFile) {
self.files.insert(path, file);
}
pub fn get_file_with_path(&self, path: &Path) -> Option<(PathBuf, &PureFile)> {
self.resolve_path(path)
.and_then(|resolved| self.files.get(&resolved).map(|f| (resolved, f)))
}
pub fn get_file_mut_with_path(&mut self, path: &Path) -> Option<(PathBuf, &mut PureFile)> {
if let Some(resolved) = self.resolve_path(path) {
self.files.get_mut(&resolved).map(|f| (resolved, f))
} else {
None
}
}
pub fn contains_file(&self, path: &Path) -> bool {
self.resolve_path(path).is_some()
}
pub fn get_source(&self, path: &Path) -> Result<Option<String>, ProjectError> {
Ok(self.get_file(path).map(|f| f.to_source()).transpose()?)
}
pub fn write_to_disk(&self, paths: &[PathBuf]) -> Result<usize, ProjectError> {
let mut written = 0;
for path in paths {
if let Some(file) = self.files.get(path) {
let source = file.to_source()?;
write_with_parents(path, &source)?;
written += 1;
}
}
Ok(written)
}
pub fn write_all_to_disk(&self) -> Result<usize, ProjectError> {
let paths: Vec<_> = self.files.keys().cloned().collect();
self.write_to_disk(&paths)
}
pub fn load_files(root: impl AsRef<Path>) -> Result<HashMap<PathBuf, PureFile>, ProjectError> {
let root = root.as_ref().canonicalize()?;
let mut files = HashMap::new();
Self::load_dir(&root, &root, &mut files)?;
Ok(files)
}
pub fn write_from_context(
&self,
ctx: &ryo_analysis::AnalysisContext,
files: &[WorkspaceFilePath],
) -> Result<usize, ProjectError> {
let mut written = 0;
for file_path in files {
if let Some(file) = ctx.file(file_path) {
let source = file.to_source()?;
file_path.write(&source)?;
written += 1;
}
}
Ok(written)
}
pub fn sync_from_context(
&mut self,
ctx: &ryo_analysis::AnalysisContext,
files: &[WorkspaceFilePath],
) {
for file_path in files {
if let Some(file) = ctx.file(file_path) {
let absolute_path = file_path.to_absolute();
self.files.insert(absolute_path, (*file).clone());
}
}
}
fn load_from_entry_points(
workspace_root: &Path,
crate_info: &ryo_symbol::CrateInfo,
files: &mut HashMap<PathBuf, PureFile>,
) -> Result<(), ProjectError> {
use ryo_symbol::TargetKind;
for target in &crate_info.entry_points {
if !matches!(target.kind, TargetKind::Lib | TargetKind::Bin) {
continue;
}
let entry_path = workspace_root.join(target.src_path.as_str());
if entry_path.exists() {
Self::load_module_tree(&entry_path, files)?;
}
}
Ok(())
}
fn load_module_tree(
file_path: &Path,
files: &mut HashMap<PathBuf, PureFile>,
) -> Result<(), ProjectError> {
if files.contains_key(file_path) {
return Ok(());
}
let canonical_path = file_path
.canonicalize()
.unwrap_or_else(|_| file_path.to_path_buf());
if files.contains_key(&canonical_path) {
return Ok(());
}
let pure_file = match Self::load_file(file_path) {
Ok(f) => f,
Err(e) => {
tracing::warn!("Failed to load {}: {}", file_path.display(), e);
return Ok(());
}
};
let mod_names: Vec<String> = pure_file
.items
.iter()
.filter_map(|item| {
if let ryo_source::pure::PureItem::Mod(m) = item {
if m.items.is_empty() {
return Some(m.name.clone());
}
}
None
})
.collect();
files.insert(canonical_path.clone(), pure_file);
let parent_dir = canonical_path
.parent()
.ok_or_else(|| ProjectError::FileNotFound(file_path.to_path_buf()))?;
let child_search_dir = if let Some(file_stem) = canonical_path.file_stem() {
let file_name = canonical_path.file_name().and_then(|n| n.to_str());
if file_name != Some("mod.rs")
&& file_name != Some("lib.rs")
&& file_name != Some("main.rs")
{
parent_dir.join(file_stem)
} else {
parent_dir.to_path_buf()
}
} else {
parent_dir.to_path_buf()
};
for mod_name in mod_names {
if let Some(child_path) = Self::resolve_mod_path(&child_search_dir, &mod_name) {
Self::load_module_tree(&child_path, files)?;
}
}
Ok(())
}
fn resolve_mod_path(parent_dir: &Path, mod_name: &str) -> Option<PathBuf> {
let modern_path = parent_dir.join(format!("{}.rs", mod_name));
if modern_path.exists() {
return Some(modern_path);
}
let classic_path = parent_dir.join(mod_name).join("mod.rs");
if classic_path.exists() {
return Some(classic_path);
}
tracing::debug!(
"Module '{}' not found in {} (tried {} and {})",
mod_name,
parent_dir.display(),
modern_path.display(),
classic_path.display()
);
None
}
#[allow(dead_code)]
fn load_dir(
_root: &Path,
dir: &Path,
files: &mut HashMap<PathBuf, PureFile>,
) -> Result<(), ProjectError> {
if !dir.is_dir() {
return Ok(());
}
let dir_name = dir.file_name().and_then(|n| n.to_str()).unwrap_or("");
if matches!(
dir_name,
"target" | "node_modules" | ".git" | "dist" | "build"
) {
return Ok(());
}
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
Self::load_dir(_root, &path, files)?;
} else if path.extension().map(|e| e == "rs").unwrap_or(false) {
match Self::load_file(&path) {
Ok(pure) => {
files.insert(path, pure);
}
Err(e) => {
tracing::warn!("Failed to parse {}: {}", path.display(), e);
}
}
}
}
Ok(())
}
fn load_file(path: &Path) -> Result<PureFile, ProjectError> {
let content = std::fs::read_to_string(path)?;
PureFile::from_source(&content).map_err(|e| ProjectError::Parse {
path: path.to_path_buf(),
message: e.to_string(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
fn create_test_project() -> tempfile::TempDir {
let dir = tempdir().unwrap();
let src = dir.path().join("src");
fs::create_dir(&src).unwrap();
fs::write(
dir.path().join("Cargo.toml"),
r#"[package]
name = "test-project"
version = "0.1.0"
edition = "2021"
"#,
)
.unwrap();
fs::write(
src.join("lib.rs"),
r#"
pub fn hello() -> &'static str {
"Hello, World!"
}
"#,
)
.unwrap();
fs::write(
src.join("main.rs"),
r#"
fn main() {
println!("{}", hello());
}
"#,
)
.unwrap();
dir
}
#[test]
fn test_load_project() {
let dir = create_test_project();
let project = Project::load(dir.path()).unwrap();
assert!(
project.file_count() >= 1,
"Expected at least 1 file, got {}",
project.file_count()
);
assert!(project.workspace_root().exists());
let lib_exists = project.file_paths().any(|p| p.ends_with("lib.rs"));
assert!(lib_exists, "lib.rs not found in project");
}
#[test]
fn test_get_file() {
let dir = create_test_project();
let project = Project::load(dir.path()).unwrap();
let lib_path = dir.path().canonicalize().unwrap().join("src/lib.rs");
assert!(project.get_file(&lib_path).is_some());
}
#[test]
fn test_resolve_relative_path() {
let dir = create_test_project();
let project = Project::load(dir.path()).unwrap();
let relative = PathBuf::from("src/lib.rs");
let resolved = project.resolve_path(&relative);
assert!(resolved.is_some(), "Failed to resolve src/lib.rs");
if let Some(resolved_path) = resolved {
assert!(
project.files().contains_key(&resolved_path),
"Resolved path not in files: {:?}",
resolved_path
);
}
}
#[test]
fn test_metadata_provider() {
let dir = create_test_project();
let project = Project::load(dir.path()).unwrap();
let metadata = project.metadata();
assert_eq!(metadata.workspace_root(), project.workspace_root());
let crates = metadata.all_crates();
assert!(!crates.is_empty());
}
#[test]
fn test_path_resolver() {
let dir = create_test_project();
let project = Project::load(dir.path()).unwrap();
let resolver = project.path_resolver();
let absolute_path = dir.path().canonicalize().unwrap().join("src/lib.rs");
let result = resolver.resolve(&absolute_path);
assert!(result.is_ok());
}
#[test]
fn test_load_files_static() {
let dir = create_test_project();
let files = Project::load_files(dir.path()).unwrap();
assert_eq!(files.len(), 2);
assert!(files
.values()
.any(|f| f.to_source().unwrap().contains("hello")));
}
}