diff --git a/Cargo.lock b/Cargo.lock index f105e6a7..ff4ce610 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -173,6 +173,9 @@ name = "camino" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1de8bc0aa9e9385ceb3bf0c152e3a9b9544f6c4a912c8ae504e80c1f0368603" +dependencies = [ + "serde_core", +] [[package]] name = "cfg-if" @@ -436,6 +439,7 @@ name = "djls-conf" version = "0.0.0" dependencies = [ "anyhow", + "camino", "config", "directories", "serde", @@ -450,6 +454,7 @@ version = "0.0.0" dependencies = [ "djls-project", "djls-semantic", + "djls-source", "djls-templates", "djls-workspace", "salsa", @@ -461,6 +466,8 @@ name = "djls-project" version = "0.0.0" dependencies = [ "anyhow", + "camino", + "djls-source", "djls-workspace", "rustc-hash", "salsa", @@ -474,7 +481,9 @@ dependencies = [ name = "djls-semantic" version = "0.0.0" dependencies = [ + "camino", "djls-conf", + "djls-source", "djls-templates", "djls-workspace", "rustc-hash", @@ -495,6 +504,7 @@ dependencies = [ "djls-ide", "djls-project", "djls-semantic", + "djls-source", "djls-templates", "djls-workspace", "percent-encoding", @@ -510,12 +520,23 @@ dependencies = [ "url", ] +[[package]] +name = "djls-source" +version = "0.0.0" +dependencies = [ + "camino", + "salsa", + "serde", +] + [[package]] name = "djls-templates" version = "0.0.0" dependencies = [ "anyhow", + "camino", "djls-conf", + "djls-source", "djls-workspace", "insta", "salsa", @@ -532,6 +553,7 @@ dependencies = [ "anyhow", "camino", "dashmap", + "djls-source", "notify", "percent-encoding", "rustc-hash", diff --git a/Cargo.toml b/Cargo.toml index dbae3208..4464d65a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ djls-ide = { path = "crates/djls-ide" } djls-project = { path = "crates/djls-project" } djls-semantic = { path = "crates/djls-semantic" } djls-server = { path = "crates/djls-server" } +djls-source = { path = "crates/djls-source" } djls-templates = { path = "crates/djls-templates" } djls-workspace = { path = "crates/djls-workspace" } diff --git a/crates/djls-conf/Cargo.toml b/crates/djls-conf/Cargo.toml index 464357af..67758aa9 100644 --- a/crates/djls-conf/Cargo.toml +++ b/crates/djls-conf/Cargo.toml @@ -5,6 +5,7 @@ edition = "2021" [dependencies] anyhow = { workspace = true } +camino = { workspace = true } config = { workspace = true } directories = { workspace = true } serde = { workspace = true } diff --git a/crates/djls-conf/src/lib.rs b/crates/djls-conf/src/lib.rs index 5375fee6..091c7946 100644 --- a/crates/djls-conf/src/lib.rs +++ b/crates/djls-conf/src/lib.rs @@ -3,6 +3,7 @@ pub mod tagspecs; use std::fs; use std::path::Path; +use camino::Utf8Path; use config::Config; use config::ConfigError as ExternalConfigError; use config::File; @@ -40,7 +41,7 @@ pub struct Settings { } impl Settings { - pub fn new(project_root: &Path) -> Result { + pub fn new(project_root: &Utf8Path) -> Result { let user_config_file = ProjectDirs::from("com.github", "joshuadavidthomas", "djls") .map(|proj_dirs| proj_dirs.config_dir().join("djls.toml")); @@ -48,7 +49,7 @@ impl Settings { } fn load_from_paths( - project_root: &Path, + project_root: &Utf8Path, user_config_path: Option<&Path>, ) -> Result { let mut builder = Config::builder(); @@ -74,13 +75,13 @@ impl Settings { } builder = builder.add_source( - File::from(project_root.join(".djls.toml")) + File::from(project_root.join(".djls.toml").as_std_path()) .format(FileFormat::Toml) .required(false), ); builder = builder.add_source( - File::from(project_root.join("djls.toml")) + File::from(project_root.join("djls.toml").as_std_path()) .format(FileFormat::Toml) .required(false), ); @@ -120,7 +121,7 @@ mod tests { #[test] fn test_load_no_files() { let dir = tempdir().unwrap(); - let settings = Settings::new(dir.path()).unwrap(); + let settings = Settings::new(Utf8Path::from_path(dir.path()).unwrap()).unwrap(); // Add assertions for future default fields here assert_eq!( settings, @@ -140,7 +141,7 @@ mod tests { fn test_load_djls_toml_only() { let dir = tempdir().unwrap(); fs::write(dir.path().join("djls.toml"), "debug = true").unwrap(); - let settings = Settings::new(dir.path()).unwrap(); + let settings = Settings::new(Utf8Path::from_path(dir.path()).unwrap()).unwrap(); assert_eq!( settings, Settings { @@ -154,7 +155,7 @@ mod tests { fn test_load_venv_path_config() { let dir = tempdir().unwrap(); fs::write(dir.path().join("djls.toml"), "venv_path = '/path/to/venv'").unwrap(); - let settings = Settings::new(dir.path()).unwrap(); + let settings = Settings::new(Utf8Path::from_path(dir.path()).unwrap()).unwrap(); assert_eq!( settings, Settings { @@ -168,7 +169,7 @@ mod tests { fn test_load_dot_djls_toml_only() { let dir = tempdir().unwrap(); fs::write(dir.path().join(".djls.toml"), "debug = true").unwrap(); - let settings = Settings::new(dir.path()).unwrap(); + let settings = Settings::new(Utf8Path::from_path(dir.path()).unwrap()).unwrap(); assert_eq!( settings, Settings { @@ -184,7 +185,7 @@ mod tests { // Write the setting under [tool.djls] let content = "[tool.djls]\ndebug = true\n"; fs::write(dir.path().join("pyproject.toml"), content).unwrap(); - let settings = Settings::new(dir.path()).unwrap(); + let settings = Settings::new(Utf8Path::from_path(dir.path()).unwrap()).unwrap(); assert_eq!( settings, Settings { @@ -203,7 +204,7 @@ mod tests { let dir = tempdir().unwrap(); fs::write(dir.path().join(".djls.toml"), "debug = false").unwrap(); fs::write(dir.path().join("djls.toml"), "debug = true").unwrap(); - let settings = Settings::new(dir.path()).unwrap(); + let settings = Settings::new(Utf8Path::from_path(dir.path()).unwrap()).unwrap(); // djls.toml wins assert_eq!( settings, @@ -220,7 +221,7 @@ mod tests { let pyproject_content = "[tool.djls]\ndebug = false\n"; fs::write(dir.path().join("pyproject.toml"), pyproject_content).unwrap(); fs::write(dir.path().join(".djls.toml"), "debug = true").unwrap(); - let settings = Settings::new(dir.path()).unwrap(); + let settings = Settings::new(Utf8Path::from_path(dir.path()).unwrap()).unwrap(); // .djls.toml wins assert_eq!( settings, @@ -238,7 +239,7 @@ mod tests { fs::write(dir.path().join("pyproject.toml"), pyproject_content).unwrap(); fs::write(dir.path().join(".djls.toml"), "debug = false").unwrap(); fs::write(dir.path().join("djls.toml"), "debug = true").unwrap(); - let settings = Settings::new(dir.path()).unwrap(); + let settings = Settings::new(Utf8Path::from_path(dir.path()).unwrap()).unwrap(); // djls.toml wins assert_eq!( settings, @@ -258,8 +259,11 @@ mod tests { let pyproject_content = "[tool.djls]\ndebug = false\n"; // Project: false fs::write(project_dir.path().join("pyproject.toml"), pyproject_content).unwrap(); - let settings = - Settings::load_from_paths(project_dir.path(), Some(&user_conf_path)).unwrap(); + let settings = Settings::load_from_paths( + Utf8Path::from_path(project_dir.path()).unwrap(), + Some(&user_conf_path), + ) + .unwrap(); // pyproject.toml overrides user assert_eq!( settings, @@ -278,8 +282,11 @@ mod tests { fs::write(&user_conf_path, "debug = true").unwrap(); // User: true fs::write(project_dir.path().join("djls.toml"), "debug = false").unwrap(); // Project: false - let settings = - Settings::load_from_paths(project_dir.path(), Some(&user_conf_path)).unwrap(); + let settings = Settings::load_from_paths( + Utf8Path::from_path(project_dir.path()).unwrap(), + Some(&user_conf_path), + ) + .unwrap(); // djls.toml overrides user assert_eq!( settings, @@ -301,8 +308,11 @@ mod tests { let user_conf_path = user_dir.path().join("config.toml"); fs::write(&user_conf_path, "debug = true").unwrap(); - let settings = - Settings::load_from_paths(project_dir.path(), Some(&user_conf_path)).unwrap(); + let settings = Settings::load_from_paths( + Utf8Path::from_path(project_dir.path()).unwrap(), + Some(&user_conf_path), + ) + .unwrap(); assert_eq!( settings, Settings { @@ -321,8 +331,11 @@ mod tests { fs::write(project_dir.path().join("pyproject.toml"), pyproject_content).unwrap(); // Should load project settings fine, ignoring non-existent user config - let settings = - Settings::load_from_paths(project_dir.path(), Some(&user_conf_path)).unwrap(); + let settings = Settings::load_from_paths( + Utf8Path::from_path(project_dir.path()).unwrap(), + Some(&user_conf_path), + ) + .unwrap(); assert_eq!( settings, Settings { @@ -339,7 +352,9 @@ mod tests { fs::write(project_dir.path().join("djls.toml"), "debug = true").unwrap(); // Call helper with None for user path - let settings = Settings::load_from_paths(project_dir.path(), None).unwrap(); + let settings = + Settings::load_from_paths(Utf8Path::from_path(project_dir.path()).unwrap(), None) + .unwrap(); assert_eq!( settings, Settings { @@ -358,7 +373,7 @@ mod tests { let dir = tempdir().unwrap(); fs::write(dir.path().join("djls.toml"), "debug = not_a_boolean").unwrap(); // Need to call Settings::new here as load_from_paths doesn't involve ProjectDirs - let result = Settings::new(dir.path()); + let result = Settings::new(Utf8Path::from_path(dir.path()).unwrap()); assert!(result.is_err()); assert!(matches!(result.unwrap_err(), ConfigError::Config(_))); } @@ -390,7 +405,7 @@ args = [ ] "#; fs::write(dir.path().join("djls.toml"), content).unwrap(); - let settings = Settings::new(dir.path()).unwrap(); + let settings = Settings::new(Utf8Path::from_path(dir.path()).unwrap()).unwrap(); assert_eq!(settings.tagspecs().len(), 2); @@ -423,7 +438,7 @@ args = [ ] "#; fs::write(dir.path().join("pyproject.toml"), content).unwrap(); - let settings = Settings::new(dir.path()).unwrap(); + let settings = Settings::new(Utf8Path::from_path(dir.path()).unwrap()).unwrap(); assert_eq!(settings.tagspecs().len(), 1); let cache = &settings.tagspecs()[0]; @@ -446,7 +461,7 @@ args = [ ] "#; fs::write(dir.path().join("djls.toml"), content).unwrap(); - let settings = Settings::new(dir.path()).unwrap(); + let settings = Settings::new(Utf8Path::from_path(dir.path()).unwrap()).unwrap(); let test = &settings.tagspecs()[0]; assert_eq!(test.args.len(), 3); @@ -485,7 +500,7 @@ args = [ ] "#; fs::write(dir.path().join("djls.toml"), content).unwrap(); - let settings = Settings::new(dir.path()).unwrap(); + let settings = Settings::new(Utf8Path::from_path(dir.path()).unwrap()).unwrap(); let if_tag = &settings.tagspecs()[0]; assert_eq!(if_tag.name, "if"); @@ -508,7 +523,7 @@ args = [ ] "#; fs::write(dir.path().join("djls.toml"), content).unwrap(); - let settings = Settings::new(dir.path()).unwrap(); + let settings = Settings::new(Utf8Path::from_path(dir.path()).unwrap()).unwrap(); let block_tag = &settings.tagspecs()[0]; assert_eq!(block_tag.name, "block"); @@ -532,7 +547,7 @@ module = "myapp.tags" args = [] "#; fs::write(dir.path().join("djls.toml"), content).unwrap(); - let settings = Settings::new(dir.path()).unwrap(); + let settings = Settings::new(Utf8Path::from_path(dir.path()).unwrap()).unwrap(); assert!(settings.debug()); assert_eq!(settings.venv_path(), Some("/path/to/venv")); @@ -557,7 +572,7 @@ args = [ ] "#; fs::write(dir.path().join("djls.toml"), content).unwrap(); - let settings = Settings::new(dir.path()).unwrap(); + let settings = Settings::new(Utf8Path::from_path(dir.path()).unwrap()).unwrap(); let test = &settings.tagspecs()[0]; assert_eq!(test.args.len(), 6); diff --git a/crates/djls-ide/Cargo.toml b/crates/djls-ide/Cargo.toml index 9ad485c3..b233537b 100644 --- a/crates/djls-ide/Cargo.toml +++ b/crates/djls-ide/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" [dependencies] djls-project = { workspace = true } djls-semantic = { workspace = true } +djls-source = { workspace = true } djls-templates = { workspace = true } djls-workspace = { workspace = true } diff --git a/crates/djls-ide/src/completions.rs b/crates/djls-ide/src/completions.rs index 32e5989a..05d4484b 100644 --- a/crates/djls-ide/src/completions.rs +++ b/crates/djls-ide/src/completions.rs @@ -6,7 +6,7 @@ use djls_project::TemplateTags; use djls_semantic::TagArg; use djls_semantic::TagSpecs; -use djls_workspace::FileKind; +use djls_source::FileKind; use djls_workspace::PositionEncoding; use djls_workspace::TextDocument; use tower_lsp_server::lsp_types; diff --git a/crates/djls-ide/src/diagnostics.rs b/crates/djls-ide/src/diagnostics.rs index d8c3d51b..a47c2ddb 100644 --- a/crates/djls-ide/src/diagnostics.rs +++ b/crates/djls-ide/src/diagnostics.rs @@ -1,9 +1,9 @@ use djls_semantic::ValidationError; +use djls_source::File; +use djls_source::Span; use djls_templates::LineOffsets; -use djls_templates::Span; use djls_templates::TemplateError; use djls_templates::TemplateErrorAccumulator; -use djls_workspace::SourceFile; use tower_lsp_server::lsp_types; trait DiagnosticError: std::fmt::Display { @@ -126,7 +126,7 @@ fn error_to_diagnostic( #[must_use] pub fn collect_diagnostics( db: &dyn djls_semantic::Db, - file: SourceFile, + file: File, nodelist: Option>, ) -> Vec { let mut diagnostics = Vec::new(); diff --git a/crates/djls-project/Cargo.toml b/crates/djls-project/Cargo.toml index b9a38fd6..a7eeb83a 100644 --- a/crates/djls-project/Cargo.toml +++ b/crates/djls-project/Cargo.toml @@ -7,6 +7,7 @@ edition = "2021" djls-workspace = { workspace = true } anyhow = { workspace = true } +camino = { workspace = true, features = ["serde1"] } rustc-hash = { workspace = true } salsa = { workspace = true } serde = { workspace = true } @@ -18,6 +19,7 @@ which = { workspace = true} which = { workspace = true } [dev-dependencies] +djls-source = { workspace = true } [lints] workspace = true diff --git a/crates/djls-project/src/db.rs b/crates/djls-project/src/db.rs index c1bce1f2..64eb3f84 100644 --- a/crates/djls-project/src/db.rs +++ b/crates/djls-project/src/db.rs @@ -10,17 +10,16 @@ //! - Tracked functions compute derived values (Python env, Django config) //! - Database trait provides stable configuration (metadata, template tags) -use std::path::Path; use std::sync::Arc; -use djls_workspace::Db as WorkspaceDb; +use camino::Utf8Path; use crate::inspector::pool::InspectorPool; use crate::project::Project; /// Project-specific database trait extending the workspace database #[salsa::db] -pub trait Db: WorkspaceDb { +pub trait Db: salsa::Database { /// Get the current project (if set) fn project(&self) -> Option; @@ -28,7 +27,7 @@ pub trait Db: WorkspaceDb { fn inspector_pool(&self) -> Arc; /// Get the project root path if a project is set - fn project_path(&self) -> Option<&Path> { + fn project_path(&self) -> Option<&Utf8Path> { self.project().map(|p| p.root(self).as_path()) } } diff --git a/crates/djls-project/src/inspector/ipc.rs b/crates/djls-project/src/inspector/ipc.rs index 820b852f..6093605f 100644 --- a/crates/djls-project/src/inspector/ipc.rs +++ b/crates/djls-project/src/inspector/ipc.rs @@ -1,13 +1,13 @@ use std::io::BufRead; use std::io::BufReader; use std::io::Write; -use std::path::Path; use std::process::Child; use std::process::Command; use std::process::Stdio; use anyhow::Context; use anyhow::Result; +use camino::Utf8Path; use serde_json; use super::zipapp::InspectorFile; @@ -23,7 +23,7 @@ pub struct InspectorProcess { } impl InspectorProcess { - pub fn new(python_env: &PythonEnvironment, project_path: &Path) -> Result { + pub fn new(python_env: &PythonEnvironment, project_path: &Utf8Path) -> Result { let zipapp_file = InspectorFile::create()?; let mut cmd = Command::new(&python_env.python_path); @@ -34,7 +34,7 @@ impl InspectorProcess { .current_dir(project_path); if let Ok(pythonpath) = std::env::var("PYTHONPATH") { - let mut paths = vec![project_path.to_string_lossy().to_string()]; + let mut paths = vec![project_path.to_string()]; paths.push(pythonpath); cmd.env("PYTHONPATH", paths.join(":")); } else { diff --git a/crates/djls-project/src/inspector/pool.rs b/crates/djls-project/src/inspector/pool.rs index b5a153d2..5c797ae9 100644 --- a/crates/djls-project/src/inspector/pool.rs +++ b/crates/djls-project/src/inspector/pool.rs @@ -1,10 +1,11 @@ -use std::path::Path; use std::sync::Arc; use std::sync::Mutex; use std::time::Duration; use std::time::Instant; use anyhow::Result; +use camino::Utf8Path; +use camino::Utf8PathBuf; use super::ipc::InspectorProcess; use super::DjlsRequest; @@ -36,7 +37,7 @@ struct InspectorProcessHandle { process: InspectorProcess, last_used: Instant, python_env: PythonEnvironment, - project_path: std::path::PathBuf, + project_path: Utf8PathBuf, } impl InspectorPool { @@ -63,7 +64,7 @@ impl InspectorPool { pub fn query( &self, python_env: &PythonEnvironment, - project_path: &Path, + project_path: &Utf8Path, request: &DjlsRequest, ) -> Result { let mut inner = self.inner.lock().expect("Inspector pool mutex poisoned"); diff --git a/crates/djls-project/src/inspector/queries.rs b/crates/djls-project/src/inspector/queries.rs index 5da21594..e74b385c 100644 --- a/crates/djls-project/src/inspector/queries.rs +++ b/crates/djls-project/src/inspector/queries.rs @@ -1,5 +1,4 @@ -use std::path::PathBuf; - +use camino::Utf8PathBuf; use serde::Deserialize; use serde::Serialize; @@ -15,11 +14,11 @@ pub enum Query { #[derive(Serialize, Deserialize)] #[allow(clippy::struct_field_names)] pub struct PythonEnvironmentQueryData { - pub sys_base_prefix: PathBuf, - pub sys_executable: PathBuf, - pub sys_path: Vec, + pub sys_base_prefix: Utf8PathBuf, + pub sys_executable: Utf8PathBuf, + pub sys_path: Vec, pub sys_platform: String, - pub sys_prefix: PathBuf, + pub sys_prefix: Utf8PathBuf, pub sys_version_info: (u32, u32, u32, VersionReleaseLevel, u32), } diff --git a/crates/djls-project/src/inspector/zipapp.rs b/crates/djls-project/src/inspector/zipapp.rs index 239571a9..4b99b705 100644 --- a/crates/djls-project/src/inspector/zipapp.rs +++ b/crates/djls-project/src/inspector/zipapp.rs @@ -1,8 +1,8 @@ use std::io::Write; -use std::path::Path; use anyhow::Context; use anyhow::Result; +use camino::Utf8Path; use tempfile::NamedTempFile; const INSPECTOR_PYZ: &[u8] = include_bytes!(concat!( @@ -30,7 +30,7 @@ impl InspectorFile { Ok(Self(zipapp_file)) } - pub fn path(&self) -> &Path { - self.0.path() + pub fn path(&self) -> &Utf8Path { + Utf8Path::from_path(self.0.path()).expect("Temp file path should always be valid UTF-8") } } diff --git a/crates/djls-project/src/project.rs b/crates/djls-project/src/project.rs index 735b7945..58e8a5ba 100644 --- a/crates/djls-project/src/project.rs +++ b/crates/djls-project/src/project.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use camino::Utf8PathBuf; use crate::db::Db as ProjectDb; use crate::django_available; @@ -17,7 +17,7 @@ use crate::python::Interpreter; pub struct Project { /// The project root path #[returns(ref)] - pub root: PathBuf, + pub root: Utf8PathBuf, /// Interpreter specification for Python environment discovery pub interpreter: Interpreter, /// Optional Django settings module override from configuration diff --git a/crates/djls-project/src/python.rs b/crates/djls-project/src/python.rs index 9de88494..e9c1f6b3 100644 --- a/crates/djls-project/src/python.rs +++ b/crates/djls-project/src/python.rs @@ -1,8 +1,9 @@ use std::fmt; -use std::path::Path; -use std::path::PathBuf; use std::sync::Arc; +use camino::Utf8Path; +use camino::Utf8PathBuf; + use crate::db::Db as ProjectDb; use crate::system; use crate::Project; @@ -26,10 +27,10 @@ pub enum Interpreter { /// This tracked function determines the interpreter path based on the project's /// interpreter specification. #[salsa::tracked] -pub fn resolve_interpreter(db: &dyn ProjectDb, project: Project) -> Option { +pub fn resolve_interpreter(db: &dyn ProjectDb, project: Project) -> Option { match &project.interpreter(db) { Interpreter::InterpreterPath(path) => { - let path_buf = PathBuf::from(path.as_str()); + let path_buf = Utf8PathBuf::from(path.as_str()); if path_buf.exists() { Some(path_buf) } else { @@ -39,9 +40,11 @@ pub fn resolve_interpreter(db: &dyn ProjectDb, project: Project) -> Option { // Derive interpreter path from venv #[cfg(unix)] - let interpreter_path = PathBuf::from(venv_path.as_str()).join("bin").join("python"); + let interpreter_path = Utf8PathBuf::from(venv_path.as_str()) + .join("bin") + .join("python"); #[cfg(windows)] - let interpreter_path = PathBuf::from(venv_path.as_str()) + let interpreter_path = Utf8PathBuf::from(venv_path.as_str()) .join("Scripts") .join("python.exe"); @@ -75,16 +78,16 @@ pub fn resolve_interpreter(db: &dyn ProjectDb, project: Project) -> Option, - pub sys_prefix: PathBuf, + pub python_path: Utf8PathBuf, + pub sys_path: Vec, + pub sys_prefix: Utf8PathBuf, } impl PythonEnvironment { #[must_use] - pub fn new(project_path: &Path, venv_path: Option<&str>) -> Option { + pub fn new(project_path: &Utf8Path, venv_path: Option<&str>) -> Option { if let Some(path) = venv_path { - let prefix = PathBuf::from(path); + let prefix = Utf8PathBuf::from(path); if let Some(env) = Self::from_venv_prefix(&prefix) { return Some(env); } @@ -92,7 +95,7 @@ impl PythonEnvironment { } if let Ok(virtual_env) = system::env_var("VIRTUAL_ENV") { - let prefix = PathBuf::from(virtual_env); + let prefix = Utf8PathBuf::from(virtual_env); if let Some(env) = Self::from_venv_prefix(&prefix) { return Some(env); } @@ -110,7 +113,7 @@ impl PythonEnvironment { Self::from_system_python() } - fn from_venv_prefix(prefix: &Path) -> Option { + fn from_venv_prefix(prefix: &Utf8Path) -> Option { #[cfg(unix)] let python_path = prefix.join("bin").join("python"); #[cfg(windows)] @@ -165,7 +168,7 @@ impl PythonEnvironment { } #[cfg(unix)] - fn find_site_packages(prefix: &Path) -> Option { + fn find_site_packages(prefix: &Utf8Path) -> Option { let lib_dir = prefix.join("lib"); if !lib_dir.is_dir() { return None; @@ -177,22 +180,26 @@ impl PythonEnvironment { e.file_type().is_ok_and(|ft| ft.is_dir()) && e.file_name().to_string_lossy().starts_with("python") }) - .map(|e| e.path().join("site-packages")) + .and_then(|e| { + Utf8PathBuf::from_path_buf(e.path()) + .ok() + .map(|p| p.join("site-packages")) + }) } #[cfg(windows)] - fn find_site_packages(prefix: &Path) -> Option { + fn find_site_packages(prefix: &Utf8Path) -> Option { Some(prefix.join("Lib").join("site-packages")) } } impl fmt::Display for PythonEnvironment { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - writeln!(f, "Python path: {}", self.python_path.display())?; - writeln!(f, "Sys prefix: {}", self.sys_prefix.display())?; + writeln!(f, "Python path: {}", self.python_path)?; + writeln!(f, "Sys prefix: {}", self.sys_prefix)?; writeln!(f, "Sys paths:")?; for path in &self.sys_path { - writeln!(f, " {}", path.display())?; + writeln!(f, " {path}")?; } Ok(()) } @@ -218,7 +225,7 @@ pub fn python_environment(db: &dyn ProjectDb, project: Project) -> Option Some(path.as_str()), Interpreter::Auto => { @@ -240,7 +247,7 @@ mod tests { use super::*; - fn create_mock_venv(dir: &Path, version: Option<&str>) -> PathBuf { + fn create_mock_venv(dir: &Utf8Path, version: Option<&str>) -> Utf8PathBuf { let prefix = dir.to_path_buf(); #[cfg(unix)] @@ -280,11 +287,13 @@ mod tests { fn test_explicit_venv_path_found() { let project_dir = tempdir().unwrap(); let venv_dir = tempdir().unwrap(); - let venv_prefix = create_mock_venv(venv_dir.path(), None); + let venv_prefix = create_mock_venv(Utf8Path::from_path(venv_dir.path()).unwrap(), None); - let env = - PythonEnvironment::new(project_dir.path(), Some(venv_prefix.to_str().unwrap())) - .expect("Should find environment with explicit path"); + let env = PythonEnvironment::new( + Utf8Path::from_path(project_dir.path()).unwrap(), + Some(venv_prefix.as_ref()), + ) + .expect("Should find environment with explicit path"); assert_eq!(env.sys_prefix, venv_prefix); @@ -309,17 +318,24 @@ mod tests { #[test] fn test_explicit_venv_path_invalid_falls_through_to_project_venv() { let project_dir = tempdir().unwrap(); - let project_venv_prefix = create_mock_venv(&project_dir.path().join(".venv"), None); + let project_venv_prefix = create_mock_venv( + Utf8Path::from_path(&project_dir.path().join(".venv")).unwrap(), + None, + ); let _guard = MockGuard; // Ensure VIRTUAL_ENV is not set (returns VarError::NotPresent) sys_mock::remove_env_var("VIRTUAL_ENV"); // Provide an invalid explicit path - let invalid_path = project_dir.path().join("non_existent_venv"); - let env = - PythonEnvironment::new(project_dir.path(), Some(invalid_path.to_str().unwrap())) - .expect("Should fall through to project .venv"); + let invalid_path = + Utf8PathBuf::from_path_buf(project_dir.path().join("non_existent_venv")) + .expect("Invalid UTF-8 path"); + let env = PythonEnvironment::new( + Utf8Path::from_path(project_dir.path()).unwrap(), + Some(invalid_path.as_ref()), + ) + .expect("Should fall through to project .venv"); // Should have found the one in the project dir assert_eq!(env.sys_prefix, project_venv_prefix); @@ -329,14 +345,15 @@ mod tests { fn test_virtual_env_variable_found() { let project_dir = tempdir().unwrap(); let venv_dir = tempdir().unwrap(); - let venv_prefix = create_mock_venv(venv_dir.path(), None); + let venv_prefix = create_mock_venv(Utf8Path::from_path(venv_dir.path()).unwrap(), None); let _guard = MockGuard; // Mock VIRTUAL_ENV to point to the mock venv - sys_mock::set_env_var("VIRTUAL_ENV", venv_prefix.to_str().unwrap().to_string()); + sys_mock::set_env_var("VIRTUAL_ENV", venv_prefix.to_string()); - let env = PythonEnvironment::new(project_dir.path(), None) - .expect("Should find environment via VIRTUAL_ENV"); + let env = + PythonEnvironment::new(Utf8Path::from_path(project_dir.path()).unwrap(), None) + .expect("Should find environment via VIRTUAL_ENV"); assert_eq!(env.sys_prefix, venv_prefix); @@ -350,18 +367,22 @@ mod tests { fn test_explicit_path_overrides_virtual_env() { let project_dir = tempdir().unwrap(); let venv1_dir = tempdir().unwrap(); - let venv1_prefix = create_mock_venv(venv1_dir.path(), None); // Mocked by VIRTUAL_ENV + let venv1_prefix = + create_mock_venv(Utf8Path::from_path(venv1_dir.path()).unwrap(), None); // Mocked by VIRTUAL_ENV let venv2_dir = tempdir().unwrap(); - let venv2_prefix = create_mock_venv(venv2_dir.path(), None); // Provided explicitly + let venv2_prefix = + create_mock_venv(Utf8Path::from_path(venv2_dir.path()).unwrap(), None); // Provided explicitly let _guard = MockGuard; // Mock VIRTUAL_ENV to point to venv1 - sys_mock::set_env_var("VIRTUAL_ENV", venv1_prefix.to_str().unwrap().to_string()); + sys_mock::set_env_var("VIRTUAL_ENV", venv1_prefix.to_string()); // Call with explicit path to venv2 - let env = - PythonEnvironment::new(project_dir.path(), Some(venv2_prefix.to_str().unwrap())) - .expect("Should find environment via explicit path"); + let env = PythonEnvironment::new( + Utf8Path::from_path(project_dir.path()).unwrap(), + Some(venv2_prefix.as_ref()), + ) + .expect("Should find environment via explicit path"); // Explicit path (venv2) should take precedence assert_eq!( @@ -373,13 +394,15 @@ mod tests { #[test] fn test_project_venv_found() { let project_dir = tempdir().unwrap(); - let venv_prefix = create_mock_venv(&project_dir.path().join(".venv"), None); + let project_utf8 = Utf8Path::from_path(project_dir.path()).unwrap(); + let venv_path = project_dir.path().join(".venv"); + let venv_prefix = create_mock_venv(Utf8Path::from_path(&venv_path).unwrap(), None); let _guard = MockGuard; // Ensure VIRTUAL_ENV is not set sys_mock::remove_env_var("VIRTUAL_ENV"); - let env = PythonEnvironment::new(project_dir.path(), None) + let env = PythonEnvironment::new(project_utf8, None) .expect("Should find environment in project .venv"); assert_eq!(env.sys_prefix, venv_prefix); @@ -388,15 +411,21 @@ mod tests { #[test] fn test_project_venv_priority() { let project_dir = tempdir().unwrap(); - let dot_venv_prefix = create_mock_venv(&project_dir.path().join(".venv"), None); - let _venv_prefix = create_mock_venv(&project_dir.path().join("venv"), None); + let project_utf8 = Utf8Path::from_path(project_dir.path()).unwrap(); + let dot_venv_prefix = create_mock_venv( + Utf8Path::from_path(&project_dir.path().join(".venv")).unwrap(), + None, + ); + let _venv_prefix = create_mock_venv( + Utf8Path::from_path(&project_dir.path().join("venv")).unwrap(), + None, + ); let _guard = MockGuard; // Ensure VIRTUAL_ENV is not set sys_mock::remove_env_var("VIRTUAL_ENV"); - let env = - PythonEnvironment::new(project_dir.path(), None).expect("Should find environment"); + let env = PythonEnvironment::new(project_utf8, None).expect("Should find environment"); // Should find .venv because it's checked first in the loop assert_eq!(env.sys_prefix, dot_venv_prefix); @@ -405,25 +434,26 @@ mod tests { #[test] fn test_system_python_fallback() { let project_dir = tempdir().unwrap(); + let project_utf8 = Utf8Path::from_path(project_dir.path()).unwrap(); let _guard = MockGuard; // Ensure VIRTUAL_ENV is not set sys_mock::remove_env_var("VIRTUAL_ENV"); let mock_sys_python_dir = tempdir().unwrap(); - let mock_sys_python_prefix = mock_sys_python_dir.path(); + let mock_sys_python_prefix = Utf8Path::from_path(mock_sys_python_dir.path()).unwrap(); #[cfg(unix)] let (bin_subdir, python_exe, site_packages_rel_path) = ( "bin", "python", - Path::new("lib").join("python3.9").join("site-packages"), + Utf8PathBuf::from("lib/python3.9/site-packages"), ); #[cfg(windows)] let (bin_subdir, python_exe, site_packages_rel_path) = ( "Scripts", "python.exe", - Path::new("Lib").join("site-packages"), + Utf8PathBuf::from("Lib/site-packages"), ); let bin_dir = mock_sys_python_prefix.join(bin_subdir); @@ -443,7 +473,7 @@ mod tests { sys_mock::set_exec_path("python", python_path.clone()); - let system_env = PythonEnvironment::new(project_dir.path(), None); + let system_env = PythonEnvironment::new(project_utf8, None); // Assert it found the mock system python via the mocked finder assert!( @@ -476,6 +506,7 @@ mod tests { #[test] fn test_no_python_found() { let project_dir = tempdir().unwrap(); + let project_utf8 = Utf8Path::from_path(project_dir.path()).unwrap(); let _guard = MockGuard; // Setup guard to clear mocks @@ -485,7 +516,7 @@ mod tests { // Ensure find_executable returns an error sys_mock::set_exec_error("python", WhichError::CannotFindBinaryPath); - let env = PythonEnvironment::new(project_dir.path(), None); + let env = PythonEnvironment::new(project_utf8, None); assert!( env.is_none(), @@ -497,7 +528,7 @@ mod tests { #[cfg(unix)] fn test_unix_site_packages_discovery() { let venv_dir = tempdir().unwrap(); - let prefix = venv_dir.path(); + let prefix = Utf8Path::from_path(venv_dir.path()).unwrap(); let bin_dir = prefix.join("bin"); fs::create_dir_all(&bin_dir).unwrap(); fs::write(bin_dir.join("python"), "").unwrap(); @@ -524,7 +555,7 @@ mod tests { #[cfg(windows)] fn test_windows_site_packages_discovery() { let venv_dir = tempdir().unwrap(); - let prefix = venv_dir.path(); + let prefix = Utf8Path::from_path(venv_dir.path()).unwrap(); let bin_dir = prefix.join("Scripts"); fs::create_dir_all(&bin_dir).unwrap(); fs::write(bin_dir.join("python.exe"), "").unwrap(); @@ -545,14 +576,15 @@ mod tests { #[test] fn test_from_venv_prefix_returns_none_if_dir_missing() { let dir = tempdir().unwrap(); - let result = PythonEnvironment::from_venv_prefix(dir.path()); + let result = + PythonEnvironment::from_venv_prefix(Utf8Path::from_path(dir.path()).unwrap()); assert!(result.is_none()); } #[test] fn test_from_venv_prefix_returns_none_if_binary_missing() { let dir = tempdir().unwrap(); - let prefix = dir.path(); + let prefix = Utf8Path::from_path(dir.path()).unwrap(); fs::create_dir_all(prefix).unwrap(); #[cfg(unix)] @@ -580,13 +612,13 @@ mod tests { #[derive(Clone)] struct TestDatabase { storage: salsa::Storage, - project_root: PathBuf, + project_root: Utf8PathBuf, project: Arc>>, fs: Arc, } impl TestDatabase { - fn new(project_root: PathBuf) -> Self { + fn new(project_root: Utf8PathBuf) -> Self { Self { storage: salsa::Storage::new(None), project_root, @@ -603,15 +635,18 @@ mod tests { #[salsa::db] impl salsa::Database for TestDatabase {} + #[salsa::db] + impl djls_source::Db for TestDatabase { + fn read_file_source(&self, path: &Utf8Path) -> std::io::Result { + self.fs.read_to_string(path) + } + } + #[salsa::db] impl djls_workspace::Db for TestDatabase { fn fs(&self) -> Arc { self.fs.clone() } - - fn read_file_content(&self, path: &std::path::Path) -> std::io::Result { - self.fs.read_to_string(path) - } } #[salsa::db] @@ -645,16 +680,20 @@ mod tests { let venv_dir = tempdir().unwrap(); // Create a mock venv - let venv_prefix = create_mock_venv(venv_dir.path(), None); + let venv_prefix = create_mock_venv(Utf8Path::from_path(venv_dir.path()).unwrap(), None); // Create a TestDatabase with the project root - let db = TestDatabase::new(project_dir.path().to_path_buf()); + let db = TestDatabase::new( + Utf8PathBuf::from_path_buf(project_dir.path().to_path_buf()) + .expect("Invalid UTF-8 path"), + ); // Create and configure the project with the venv path let project = Project::new( &db, - project_dir.path().to_path_buf(), - Interpreter::VenvPath(venv_prefix.to_string_lossy().to_string()), + Utf8PathBuf::from_path_buf(project_dir.path().to_path_buf()) + .expect("Invalid UTF-8 path"), + Interpreter::VenvPath(venv_prefix.to_string()), None, ); db.set_project(project); @@ -686,10 +725,16 @@ mod tests { let project_dir = tempdir().unwrap(); // Create a .venv in the project directory - let venv_prefix = create_mock_venv(&project_dir.path().join(".venv"), None); + let venv_prefix = create_mock_venv( + Utf8Path::from_path(&project_dir.path().join(".venv")).unwrap(), + None, + ); // Create a TestDatabase with the project root - let db = TestDatabase::new(project_dir.path().to_path_buf()); + let db = TestDatabase::new( + Utf8PathBuf::from_path_buf(project_dir.path().to_path_buf()) + .expect("Invalid UTF-8 path"), + ); // Mock to ensure VIRTUAL_ENV is not set let _guard = system::mock::MockGuard; diff --git a/crates/djls-project/src/system.rs b/crates/djls-project/src/system.rs index 6aaa9bc2..651ffb8c 100644 --- a/crates/djls-project/src/system.rs +++ b/crates/djls-project/src/system.rs @@ -1,12 +1,14 @@ use std::env::VarError; -use std::path::PathBuf; +use camino::Utf8PathBuf; use which::Error as WhichError; -pub fn find_executable(name: &str) -> Result { +pub fn find_executable(name: &str) -> Result { #[cfg(not(test))] { - which::which(name) + which::which(name).and_then(|path| { + Utf8PathBuf::from_path_buf(path).map_err(|_| WhichError::CannotFindBinaryPath) + }) } #[cfg(test)] { @@ -35,11 +37,11 @@ pub mod mock { use super::*; thread_local! { - static MOCK_EXEC_RESULTS: RefCell>> = RefCell::new(FxHashMap::default()); + static MOCK_EXEC_RESULTS: RefCell>> = RefCell::new(FxHashMap::default()); static MOCK_ENV_RESULTS: RefCell>> = RefCell::new(FxHashMap::default()); } - pub(super) fn find_executable_mocked(name: &str) -> Result { + pub(super) fn find_executable_mocked(name: &str) -> Result { MOCK_EXEC_RESULTS.with(|mocks| { mocks .borrow() @@ -68,7 +70,7 @@ pub mod mock { } } - pub fn set_exec_path(name: &str, path: PathBuf) { + pub fn set_exec_path(name: &str, path: Utf8PathBuf) { MOCK_EXEC_RESULTS.with(|mocks| { mocks.borrow_mut().insert(name.to_string(), Ok(path)); }); @@ -99,9 +101,6 @@ pub mod mock { #[cfg(test)] mod tests { use std::env::VarError; - use std::path::PathBuf; - - use which::Error as WhichError; use super::mock::MockGuard; use super::mock::{ @@ -112,7 +111,7 @@ mod tests { #[test] fn test_exec_mock_path_retrieval() { let _guard = MockGuard; - let expected_path = PathBuf::from("/mock/path/to/python"); + let expected_path = Utf8PathBuf::from("/mock/path/to/python"); sys_mock::set_exec_path("python", expected_path.clone()); let result = find_executable("python"); assert_eq!(result.unwrap(), expected_path); @@ -160,7 +159,7 @@ mod tests { #[test] fn test_mock_guard_clears_all_mocks() { - let expected_exec_path = PathBuf::from("/tmp/myprog"); + let expected_exec_path = Utf8PathBuf::from("/tmp/myprog"); let expected_env_val = "test_value".to_string(); { @@ -192,7 +191,7 @@ mod tests { assert!(matches!(result_myvar, Err(VarError::NotPresent))); // Set mocks specific to this test - let expected_path_node = PathBuf::from("/usr/bin/node"); + let expected_path_node = Utf8PathBuf::from("/usr/bin/node"); sys_mock::set_exec_path("node", expected_path_node.clone()); sys_mock::set_env_var("NODE_ENV", "production".to_string()); diff --git a/crates/djls-semantic/Cargo.toml b/crates/djls-semantic/Cargo.toml index 533ca475..65ee2f7a 100644 --- a/crates/djls-semantic/Cargo.toml +++ b/crates/djls-semantic/Cargo.toml @@ -5,9 +5,11 @@ edition = "2021" [dependencies] djls-conf = { workspace = true } +djls-source = { workspace = true } djls-templates = { workspace = true } djls-workspace = { workspace = true } +camino = { workspace = true } rustc-hash = { workspace = true } salsa = { workspace = true } serde = { workspace = true } diff --git a/crates/djls-semantic/src/db.rs b/crates/djls-semantic/src/db.rs index 3c8005ed..956db0b3 100644 --- a/crates/djls-semantic/src/db.rs +++ b/crates/djls-semantic/src/db.rs @@ -1,13 +1,12 @@ use std::sync::Arc; use djls_templates::Db as TemplateDb; -use djls_workspace::Db as WorkspaceDb; use crate::errors::ValidationError; use crate::templatetags::TagSpecs; #[salsa::db] -pub trait Db: TemplateDb + WorkspaceDb { +pub trait Db: TemplateDb { /// Get the Django tag specifications for semantic analysis fn tag_specs(&self) -> Arc; } diff --git a/crates/djls-semantic/src/errors.rs b/crates/djls-semantic/src/errors.rs index 670a031a..b2e5a08d 100644 --- a/crates/djls-semantic/src/errors.rs +++ b/crates/djls-semantic/src/errors.rs @@ -1,4 +1,4 @@ -use djls_templates::Span; +use djls_source::Span; use serde::Serialize; use thiserror::Error; diff --git a/crates/djls-semantic/src/templatetags/specs.rs b/crates/djls-semantic/src/templatetags/specs.rs index 57c3e2c8..798119fc 100644 --- a/crates/djls-semantic/src/templatetags/specs.rs +++ b/crates/djls-semantic/src/templatetags/specs.rs @@ -370,6 +370,10 @@ impl From for IntermediateTag { #[cfg(test)] mod tests { + use std::fs; + + use camino::Utf8Path; + use super::*; // Helper function to create a small test TagSpecs @@ -785,11 +789,9 @@ mod tests { #[test] fn test_conversion_from_settings() { - use std::fs; - // Test case 1: Empty settings gives built-in specs let dir = tempfile::TempDir::new().unwrap(); - let settings = djls_conf::Settings::new(dir.path()).unwrap(); + let settings = djls_conf::Settings::new(Utf8Path::from_path(dir.path()).unwrap()).unwrap(); let specs = TagSpecs::from(&settings); // Should have built-in specs @@ -817,7 +819,7 @@ end_tag = { name = "endif", optional = true } "#; fs::write(dir.path().join("djls.toml"), config_content).unwrap(); - let settings = djls_conf::Settings::new(dir.path()).unwrap(); + let settings = djls_conf::Settings::new(Utf8Path::from_path(dir.path()).unwrap()).unwrap(); let specs = TagSpecs::from(&settings); // Should have built-in specs diff --git a/crates/djls-semantic/src/validation.rs b/crates/djls-semantic/src/validation.rs index 06853042..b8acaa53 100644 --- a/crates/djls-semantic/src/validation.rs +++ b/crates/djls-semantic/src/validation.rs @@ -17,8 +17,8 @@ //! The `TagValidator` follows the same pattern as the Parser and Lexer, //! maintaining minimal state and walking through the node list to accumulate errors. +use djls_source::Span; use djls_templates::nodelist::Node; -use djls_templates::nodelist::Span; use djls_templates::nodelist::TagBit; use djls_templates::nodelist::TagName; use djls_templates::NodeList; diff --git a/crates/djls-server/Cargo.toml b/crates/djls-server/Cargo.toml index 1d43110a..0bb3605d 100644 --- a/crates/djls-server/Cargo.toml +++ b/crates/djls-server/Cargo.toml @@ -8,6 +8,7 @@ djls-conf = { workspace = true } djls-ide = { workspace = true } djls-project = { workspace = true } djls-semantic = { workspace = true } +djls-source = { workspace = true } djls-templates = { workspace = true } djls-workspace = { workspace = true } diff --git a/crates/djls-server/src/db.rs b/crates/djls-server/src/db.rs index f1faae20..0210f80b 100644 --- a/crates/djls-server/src/db.rs +++ b/crates/djls-server/src/db.rs @@ -4,11 +4,11 @@ //! the database traits from workspace, template, and project crates. This follows //! Ruff's architecture pattern where the concrete database lives at the top level. -use std::path::Path; -use std::path::PathBuf; use std::sync::Arc; use std::sync::Mutex; +use camino::Utf8Path; +use camino::Utf8PathBuf; use dashmap::DashMap; use djls_project::Db as ProjectDb; use djls_project::InspectorPool; @@ -16,10 +16,10 @@ use djls_project::Interpreter; use djls_project::Project; use djls_semantic::Db as SemanticDb; use djls_semantic::TagSpecs; +use djls_source::Db as SourceDb; +use djls_source::File; use djls_templates::db::Db as TemplateDb; use djls_workspace::db::Db as WorkspaceDb; -use djls_workspace::db::SourceFile; -use djls_workspace::FileKind; use djls_workspace::FileSystem; use salsa::Setter; @@ -36,7 +36,7 @@ pub struct DjangoDatabase { fs: Arc, /// Maps paths to [`SourceFile`] entities for O(1) lookup. - files: Arc>, + files: Arc>, /// The single project for this database instance project: Arc>>, @@ -87,7 +87,7 @@ impl DjangoDatabase { /// # Panics /// /// Panics if the project mutex is poisoned. - pub fn set_project(&self, root: &Path) { + pub fn set_project(&self, root: &Utf8Path) { let interpreter = Interpreter::Auto; let django_settings = std::env::var("DJANGO_SETTINGS_MODULE").ok(); @@ -96,7 +96,7 @@ impl DjangoDatabase { *self.project.lock().unwrap() = Some(project); } /// Create a new [`DjangoDatabase`] with the given file system and file map. - pub fn new(file_system: Arc, files: Arc>) -> Self { + pub fn new(file_system: Arc, files: Arc>) -> Self { Self { fs: file_system, files, @@ -111,7 +111,7 @@ impl DjangoDatabase { /// Get an existing [`SourceFile`] for the given path without creating it. /// /// Returns `Some(SourceFile)` if the file is already tracked, `None` otherwise. - pub fn get_file(&self, path: &Path) -> Option { + pub fn get_file(&self, path: &Utf8Path) -> Option { self.files.get(path).map(|file_ref| *file_ref) } @@ -119,21 +119,19 @@ impl DjangoDatabase { /// /// Files are created with an initial revision of 0 and tracked in the database's /// `DashMap`. The `Arc` ensures cheap cloning while maintaining thread safety. - pub fn get_or_create_file(&mut self, path: &PathBuf) -> SourceFile { + pub fn get_or_create_file(&mut self, path: &Utf8PathBuf) -> File { if let Some(file_ref) = self.files.get(path) { return *file_ref; } - // File doesn't exist, so we need to create it - let kind = FileKind::from_path(path); - let file = SourceFile::new(self, kind, Arc::from(path.to_string_lossy().as_ref()), 0); + let file = File::new(self, path.clone(), 0); self.files.insert(path.clone(), file); file } /// Check if a file is being tracked without creating it. - pub fn has_file(&self, path: &Path) -> bool { + pub fn has_file(&self, path: &Utf8Path) -> bool { self.files.contains_key(path) } @@ -141,9 +139,9 @@ impl DjangoDatabase { /// /// Updates the file's revision number to signal that cached query results /// depending on this file should be invalidated. - pub fn touch_file(&mut self, path: &Path) { + pub fn touch_file(&mut self, path: &Utf8Path) { let Some(file_ref) = self.files.get(path) else { - tracing::debug!("File {} not tracked, skipping touch", path.display()); + tracing::debug!("File {} not tracked, skipping touch", path); return; }; let file = *file_ref; @@ -153,27 +151,25 @@ impl DjangoDatabase { let new_rev = current_rev + 1; file.set_revision(self).to(new_rev); - tracing::debug!( - "Touched {}: revision {} -> {}", - path.display(), - current_rev, - new_rev - ); + tracing::debug!("Touched {}: revision {} -> {}", path, current_rev, new_rev); } } #[salsa::db] impl salsa::Database for DjangoDatabase {} +#[salsa::db] +impl SourceDb for DjangoDatabase { + fn read_file_source(&self, path: &Utf8Path) -> std::io::Result { + self.fs.read_to_string(path) + } +} + #[salsa::db] impl WorkspaceDb for DjangoDatabase { fn fs(&self) -> Arc { self.fs.clone() } - - fn read_file_content(&self, path: &Path) -> std::io::Result { - self.fs.read_to_string(path) - } } #[salsa::db] @@ -185,7 +181,10 @@ impl SemanticDb for DjangoDatabase { let project_root = if let Some(project) = self.project() { project.root(self).clone() } else { - std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")) + std::env::current_dir() + .ok() + .and_then(|p| Utf8PathBuf::from_path_buf(p).ok()) + .unwrap_or_else(|| Utf8PathBuf::from(".")) }; let tag_specs = if let Ok(settings) = djls_conf::Settings::new(&project_root) { diff --git a/crates/djls-server/src/server.rs b/crates/djls-server/src/server.rs index 4f00252c..640133e8 100644 --- a/crates/djls-server/src/server.rs +++ b/crates/djls-server/src/server.rs @@ -3,8 +3,8 @@ use std::sync::Arc; use djls_project::Db as ProjectDb; use djls_semantic::Db as SemanticDb; +use djls_source::FileKind; use djls_workspace::paths; -use djls_workspace::FileKind; use tokio::sync::Mutex; use tower_lsp_server::jsonrpc::Result as LspResult; use tower_lsp_server::lsp_types; @@ -185,16 +185,13 @@ impl LanguageServer for DjangoLanguageServer { .map(|p| p.root(session_lock.database()).clone()); if let Some(path) = project_path { - tracing::info!( - "Task: Starting initialization for project at: {}", - path.display() - ); + tracing::info!("Task: Starting initialization for project at: {}", path); if let Some(project) = session_lock.project() { project.initialize(session_lock.database()); } - tracing::info!("Task: Successfully initialized project: {}", path.display()); + tracing::info!("Task: Successfully initialized project: {}", path); } else { tracing::info!("Task: No project configured, skipping initialization."); } @@ -393,7 +390,7 @@ impl LanguageServer for DjangoLanguageServer { }; // Only provide diagnostics for template files - let file_kind = FileKind::from_path(std::path::Path::new(url.path())); + let file_kind = FileKind::from_path(url.path().into()); if file_kind != FileKind::Template { return Ok(lsp_types::DocumentDiagnosticReportResult::Report( lsp_types::DocumentDiagnosticReport::Full( @@ -412,7 +409,7 @@ impl LanguageServer for DjangoLanguageServer { let diagnostics: Vec = self .with_session(|session| { session.with_db(|db| { - let Some(file) = db.get_file(std::path::Path::new(url.path())) else { + let Some(file) = db.get_file(url.path().into()) else { return vec![]; }; @@ -442,7 +439,7 @@ impl LanguageServer for DjangoLanguageServer { if let Some(project) = session.project() { let project_root = project.root(session.database()); - match djls_conf::Settings::new(project_root.as_path()) { + match djls_conf::Settings::new(project_root) { Ok(new_settings) => { session.set_settings(new_settings); } diff --git a/crates/djls-server/src/session.rs b/crates/djls-server/src/session.rs index 5302208e..45836206 100644 --- a/crates/djls-server/src/session.rs +++ b/crates/djls-server/src/session.rs @@ -3,16 +3,16 @@ //! This module implements the LSP session abstraction that manages project-specific //! state and the Salsa database for incremental computation. -use std::path::PathBuf; use std::sync::Arc; +use camino::Utf8PathBuf; use dashmap::DashMap; use djls_conf::Settings; use djls_project::Db as ProjectDb; use djls_project::Interpreter; -use djls_workspace::db::SourceFile; +use djls_source::File; +use djls_source::FileKind; use djls_workspace::paths; -use djls_workspace::FileKind; use djls_workspace::PositionEncoding; use djls_workspace::TextDocument; use djls_workspace::Workspace; @@ -62,7 +62,9 @@ impl Session { .and_then(|folder| paths::lsp_uri_to_path(&folder.uri)) .or_else(|| { // Fall back to current directory - std::env::current_dir().ok() + std::env::current_dir() + .ok() + .and_then(|p| Utf8PathBuf::from_path_buf(p).ok()) }); let settings = if let Some(path) = &project_path { @@ -249,7 +251,7 @@ impl Session { } /// Get or create a file in the database. - pub fn get_or_create_file(&mut self, path: &PathBuf) -> SourceFile { + pub fn get_or_create_file(&mut self, path: &Utf8PathBuf) -> File { self.db.get_or_create_file(path) } @@ -287,18 +289,17 @@ impl Default for Session { #[cfg(test)] mod tests { - use djls_workspace::db::source_text; use djls_workspace::LanguageId; use super::*; // Helper function to create a test file path and URL that works on all platforms - fn test_file_url(filename: &str) -> (PathBuf, Url) { + fn test_file_url(filename: &str) -> (Utf8PathBuf, Url) { // Use an absolute path that's valid on the platform #[cfg(windows)] - let path = PathBuf::from(format!("C:\\temp\\{filename}")); + let path = Utf8PathBuf::from(format!("C:\\temp\\{filename}")); #[cfg(not(windows))] - let path = PathBuf::from(format!("/tmp/{filename}")); + let path = Utf8PathBuf::from(format!("/tmp/{filename}")); let url = Url::from_file_path(&path).expect("Failed to create file URL"); (path, url) @@ -309,11 +310,11 @@ mod tests { let mut session = Session::default(); // Can create files in the database - let path = PathBuf::from("/test.py"); + let path = Utf8PathBuf::from("/test.py"); let file = session.get_or_create_file(&path); // Can read file content through database - let content = session.with_db(|db| source_text(db, file).to_string()); + let content = session.with_db(|db| file.source(db).to_string()); assert_eq!(content, ""); // Non-existent file returns empty } @@ -331,7 +332,7 @@ mod tests { // Should be queryable through database let file = session.get_or_create_file(&path); - let content = session.with_db(|db| source_text(db, file).to_string()); + let content = session.with_db(|db| file.source(db).to_string()); assert_eq!(content, "print('hello')"); // Close document @@ -363,7 +364,7 @@ mod tests { // Database should also see updated content let file = session.get_or_create_file(&path); - let content = session.with_db(|db| source_text(db, file).to_string()); + let content = session.with_db(|db| file.source(db).to_string()); assert_eq!(content, "updated"); } } diff --git a/crates/djls-source/Cargo.toml b/crates/djls-source/Cargo.toml new file mode 100644 index 00000000..b694d749 --- /dev/null +++ b/crates/djls-source/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "djls-source" +version = "0.0.0" +edition = "2021" + +[dependencies] +camino = { workspace = true } +salsa = { workspace = true } +serde = { workspace = true } + +[dev-dependencies] + +[lints] +workspace = true diff --git a/crates/djls-source/src/db.rs b/crates/djls-source/src/db.rs new file mode 100644 index 00000000..66923e17 --- /dev/null +++ b/crates/djls-source/src/db.rs @@ -0,0 +1,6 @@ +use camino::Utf8Path; + +#[salsa::db] +pub trait Db: salsa::Database { + fn read_file_source(&self, path: &Utf8Path) -> std::io::Result; +} diff --git a/crates/djls-source/src/file.rs b/crates/djls-source/src/file.rs new file mode 100644 index 00000000..0dca4f6e --- /dev/null +++ b/crates/djls-source/src/file.rs @@ -0,0 +1,128 @@ +use std::ops::Deref; +use std::sync::Arc; + +use camino::Utf8Path; +use camino::Utf8PathBuf; + +use crate::db::Db; + +#[salsa::input] +pub struct File { + #[returns(ref)] + pub path: Utf8PathBuf, + /// The revision number for invalidation tracking + pub revision: u64, +} + +#[salsa::tracked] +impl File { + #[salsa::tracked] + pub fn source(self, db: &dyn Db) -> SourceText { + let _ = self.revision(db); + let path = self.path(db); + let source = db.read_file_source(path).unwrap_or_default(); + SourceText::new(path, source) + } + + #[salsa::tracked(returns(ref))] + pub fn line_index(self, db: &dyn Db) -> LineIndex { + let text = self.source(db); + let mut starts = Vec::with_capacity(256); + starts.push(0); + for (i, b) in text.0.source.bytes().enumerate() { + if b == b'\n' { + starts.push(u32::try_from(i).unwrap_or_default() + 1); + } + } + LineIndex(starts) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SourceText(Arc); + +impl SourceText { + #[must_use] + pub fn new(path: &Utf8Path, source: String) -> Self { + let encoding = if source.is_ascii() { + FileEncoding::Ascii + } else { + FileEncoding::Utf8 + }; + let kind = FileKind::from_path(path); + Self(Arc::new(SourceTextInner { + encoding, + kind, + source, + })) + } + + #[must_use] + pub fn kind(&self) -> &FileKind { + &self.0.kind + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.0.source + } +} + +impl Default for SourceText { + fn default() -> Self { + Self(Arc::new(SourceTextInner { + encoding: FileEncoding::Ascii, + kind: FileKind::Other, + source: String::new(), + })) + } +} + +impl AsRef for SourceText { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl Deref for SourceText { + type Target = str; + + fn deref(&self) -> &str { + self.as_str() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct SourceTextInner { + encoding: FileEncoding, + kind: FileKind, + source: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum FileEncoding { + Ascii, + Utf8, +} + +#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)] +pub enum FileKind { + Other, + Python, + Template, +} + +impl FileKind { + /// Determine [`FileKind`] from a file path extension. + #[must_use] + pub fn from_path(path: &Utf8Path) -> Self { + match path.extension() { + Some("py") => FileKind::Python, + Some("html" | "htm") => FileKind::Template, + _ => FileKind::Other, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LineIndex(Vec); diff --git a/crates/djls-source/src/lib.rs b/crates/djls-source/src/lib.rs new file mode 100644 index 00000000..ed035339 --- /dev/null +++ b/crates/djls-source/src/lib.rs @@ -0,0 +1,8 @@ +mod db; +mod file; +mod span; + +pub use db::Db; +pub use file::File; +pub use file::FileKind; +pub use span::Span; diff --git a/crates/djls-source/src/span.rs b/crates/djls-source/src/span.rs new file mode 100644 index 00000000..e91fbfd7 --- /dev/null +++ b/crates/djls-source/src/span.rs @@ -0,0 +1,14 @@ +use serde::Serialize; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] +pub struct Span { + pub start: u32, + pub length: u32, +} + +impl Span { + #[must_use] + pub fn new(start: u32, length: u32) -> Self { + Self { start, length } + } +} diff --git a/crates/djls-templates/Cargo.toml b/crates/djls-templates/Cargo.toml index b9bd93bc..e3b867a2 100644 --- a/crates/djls-templates/Cargo.toml +++ b/crates/djls-templates/Cargo.toml @@ -5,6 +5,7 @@ edition = "2021" [dependencies] djls-conf = { workspace = true } +djls-source = { workspace = true } djls-workspace = { workspace = true } anyhow = { workspace = true } @@ -14,6 +15,7 @@ thiserror = { workspace = true } toml = { workspace = true } [dev-dependencies] +camino = { workspace = true } insta = { workspace = true } tempfile = { workspace = true } diff --git a/crates/djls-templates/src/db.rs b/crates/djls-templates/src/db.rs index bd1aadd7..9ac3ebd7 100644 --- a/crates/djls-templates/src/db.rs +++ b/crates/djls-templates/src/db.rs @@ -42,7 +42,7 @@ //! } //! ``` -use djls_workspace::Db as WorkspaceDb; +use djls_source::Db as SourceDb; use crate::error::TemplateError; @@ -52,4 +52,4 @@ pub struct TemplateErrorAccumulator(pub TemplateError); /// Template-specific database trait extending the workspace database #[salsa::db] -pub trait Db: WorkspaceDb {} +pub trait Db: SourceDb {} diff --git a/crates/djls-templates/src/lexer.rs b/crates/djls-templates/src/lexer.rs index 98ce7cc7..fadb4d0e 100644 --- a/crates/djls-templates/src/lexer.rs +++ b/crates/djls-templates/src/lexer.rs @@ -205,6 +205,8 @@ impl<'db> Lexer<'db> { #[cfg(test)] mod tests { + use camino::Utf8Path; + use super::*; use crate::tokens::TokenSnapshotVec; @@ -226,16 +228,8 @@ mod tests { impl salsa::Database for TestDatabase {} #[salsa::db] - impl djls_workspace::Db for TestDatabase { - fn fs(&self) -> std::sync::Arc { - use djls_workspace::InMemoryFileSystem; - static FS: std::sync::OnceLock> = - std::sync::OnceLock::new(); - FS.get_or_init(|| std::sync::Arc::new(InMemoryFileSystem::default())) - .clone() - } - - fn read_file_content(&self, path: &std::path::Path) -> Result { + impl djls_source::Db for TestDatabase { + fn read_file_source(&self, path: &Utf8Path) -> Result { std::fs::read_to_string(path) } } diff --git a/crates/djls-templates/src/lib.rs b/crates/djls-templates/src/lib.rs index e5275035..e8683d0a 100644 --- a/crates/djls-templates/src/lib.rs +++ b/crates/djls-templates/src/lib.rs @@ -54,13 +54,12 @@ mod tokens; pub use db::Db; pub use db::TemplateErrorAccumulator; -use djls_workspace::db::SourceFile; -use djls_workspace::FileKind; +use djls_source::File; +use djls_source::FileKind; pub use error::TemplateError; pub use lexer::Lexer; pub use nodelist::LineOffsets; pub use nodelist::NodeList; -pub use nodelist::Span; pub use parser::ParseError; pub use parser::Parser; use salsa::Accumulator; @@ -68,14 +67,12 @@ use tokens::TokenStream; /// Lex a template file into tokens. #[salsa::tracked] -fn lex_template(db: &dyn Db, file: SourceFile) -> TokenStream<'_> { - if file.kind(db) != FileKind::Template { +fn lex_template(db: &dyn Db, file: File) -> TokenStream<'_> { + let source = file.source(db); + if *source.kind() != FileKind::Template { return TokenStream::new(db, vec![], LineOffsets::default()); } - - let text_arc = djls_workspace::db::source_text(db, file); - let text = text_arc.as_ref(); - + let text = source.as_ref(); let (tokens, line_offsets) = Lexer::new(db, text).tokenize(); TokenStream::new(db, tokens, line_offsets) } @@ -88,8 +85,9 @@ fn lex_template(db: &dyn Db, file: SourceFile) -> TokenStream<'_> { /// parse_template::accumulated::(db, file); /// ``` #[salsa::tracked] -pub fn parse_template(db: &dyn Db, file: SourceFile) -> Option> { - if file.kind(db) != FileKind::Template { +pub fn parse_template(db: &dyn Db, file: File) -> Option> { + let source = file.source(db); + if *source.kind() != FileKind::Template { return None; } diff --git a/crates/djls-templates/src/nodelist.rs b/crates/djls-templates/src/nodelist.rs index 5f89be00..c9d3934b 100644 --- a/crates/djls-templates/src/nodelist.rs +++ b/crates/djls-templates/src/nodelist.rs @@ -1,7 +1,7 @@ +use djls_source::Span; use serde::Serialize; use crate::db::Db as TemplateDb; -use crate::tokens::Token; #[salsa::tracked(debug)] pub struct NodeList<'db> { @@ -143,26 +143,6 @@ pub struct FilterName<'db> { pub text: String, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] -pub struct Span { - pub start: u32, - pub length: u32, -} - -impl Span { - #[must_use] - pub fn new(start: u32, length: u32) -> Self { - Self { start, length } - } - - #[must_use] - pub fn from_token(token: &Token<'_>, db: &dyn TemplateDb) -> Self { - let start = token.offset().unwrap_or(0); - let length = token.length(db); - Span::new(start, length) - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/djls-templates/src/parser.rs b/crates/djls-templates/src/parser.rs index 504b891d..95b04003 100644 --- a/crates/djls-templates/src/parser.rs +++ b/crates/djls-templates/src/parser.rs @@ -1,3 +1,4 @@ +use djls_source::Span; use salsa::Accumulator; use serde::Serialize; use thiserror::Error; @@ -9,10 +10,10 @@ use crate::nodelist::FilterName; use crate::nodelist::LineOffsets; use crate::nodelist::Node; use crate::nodelist::NodeList; -use crate::nodelist::Span; use crate::nodelist::TagBit; use crate::nodelist::TagName; use crate::nodelist::VariableName; +use crate::tokens::span_from_token; use crate::tokens::Token; use crate::tokens::TokenStream; @@ -76,7 +77,7 @@ impl<'db> Parser<'db> { Ok(Node::Comment { content: token.content(self.db), - span: Span::from_token(token, self.db), + span: span_from_token(token, self.db), }) } @@ -118,7 +119,7 @@ impl<'db> Parser<'db> { let name = TagName::new(self.db, name_str.to_string()); let bits = parts.map(|s| TagBit::new(self.db, s.to_string())).collect(); - let span = Span::from_token(token, self.db); + let span = span_from_token(token, self.db); Ok(Node::Tag { name, bits, span }) } @@ -147,7 +148,7 @@ impl<'db> Parser<'db> { FilterName::new(self.db, trimmed.to_string()) }) .collect(); - let span = Span::from_token(token, self.db); + let span = span_from_token(token, self.db); Ok(Node::Variable { var, filters, span }) } @@ -300,6 +301,7 @@ impl ParseError { #[cfg(test)] mod tests { + use camino::Utf8Path; use serde::Serialize; use super::*; @@ -324,16 +326,8 @@ mod tests { impl salsa::Database for TestDatabase {} #[salsa::db] - impl djls_workspace::Db for TestDatabase { - fn fs(&self) -> std::sync::Arc { - use djls_workspace::InMemoryFileSystem; - static FS: std::sync::OnceLock> = - std::sync::OnceLock::new(); - FS.get_or_init(|| std::sync::Arc::new(InMemoryFileSystem::default())) - .clone() - } - - fn read_file_content(&self, path: &std::path::Path) -> Result { + impl djls_source::Db for TestDatabase { + fn read_file_source(&self, path: &Utf8Path) -> Result { std::fs::read_to_string(path) } } diff --git a/crates/djls-templates/src/tokens.rs b/crates/djls-templates/src/tokens.rs index f2d3f276..ab57ffc7 100644 --- a/crates/djls-templates/src/tokens.rs +++ b/crates/djls-templates/src/tokens.rs @@ -1,3 +1,5 @@ +use djls_source::Span; + use crate::db::Db as TemplateDb; use crate::nodelist::LineOffsets; @@ -176,3 +178,9 @@ impl<'db> TokenStream<'db> { self.stream(db).len() } } + +pub fn span_from_token(token: &Token<'_>, db: &dyn TemplateDb) -> Span { + let start = token.offset().unwrap_or(0); + let length = token.length(db); + Span::new(start, length) +} diff --git a/crates/djls-workspace/Cargo.toml b/crates/djls-workspace/Cargo.toml index c37acaad..12cecb1f 100644 --- a/crates/djls-workspace/Cargo.toml +++ b/crates/djls-workspace/Cargo.toml @@ -4,6 +4,8 @@ version = "0.0.0" edition = "2021" [dependencies] +djls-source = { workspace = true} + anyhow = { workspace = true } camino = { workspace = true } dashmap = { workspace = true } diff --git a/crates/djls-workspace/src/db.rs b/crates/djls-workspace/src/db.rs index bbbcc2be..2e28c6da 100644 --- a/crates/djls-workspace/src/db.rs +++ b/crates/djls-workspace/src/db.rs @@ -20,10 +20,8 @@ //! let _ = file.revision(db); // Creates the dependency chain! //! ``` -use std::path::Path; use std::sync::Arc; -use crate::FileKind; use crate::FileSystem; /// Base database trait that provides file system access for Salsa queries @@ -31,56 +29,4 @@ use crate::FileSystem; pub trait Db: salsa::Database { /// Get the file system for reading files. fn fs(&self) -> Arc; - - /// Read file content through the file system. - /// - /// Checks buffers first via [`WorkspaceFileSystem`](crate::fs::WorkspaceFileSystem), - /// then falls back to disk. - fn read_file_content(&self, path: &Path) -> std::io::Result; -} - -/// Represents a single file without storing its content. -/// -/// [`SourceFile`] is a Salsa input entity that tracks a file's path, revision, and -/// classification for analysis routing. Following Ruff's pattern, content is NOT -/// stored here but read on-demand through the `source_text` tracked function. -#[salsa::input] -pub struct SourceFile { - /// The file's classification for analysis routing - pub kind: FileKind, - // TODO: Change from Arc to PathBuf for consistency with Project.root - /// The file path - #[returns(ref)] - pub path: Arc, - /// The revision number for invalidation tracking - pub revision: u64, -} - -/// Read file content, creating a Salsa dependency on the file's revision. -#[salsa::tracked] -pub fn source_text(db: &dyn Db, file: SourceFile) -> Arc { - // This line creates the Salsa dependency on revision! Without this call, - // revision changes won't trigger invalidation - let _ = file.revision(db); - - let path = Path::new(file.path(db).as_ref()); - match db.read_file_content(path) { - Ok(content) => Arc::from(content), - Err(_) => { - Arc::from("") // Return empty string for missing files - } - } -} - -/// Represents a file path for Salsa tracking. -/// -/// [`FilePath`] is a Salsa input entity that tracks a file path for use in -/// path-based queries. This allows Salsa to properly track dependencies -/// on files identified by path rather than by SourceFile input. -#[salsa::input] -pub struct FilePath { - // TODO: Change from Arc to PathBuf for consistency with Project.root - /// The file path as a string - #[returns(ref)] - pub path: Arc, } diff --git a/crates/djls-workspace/src/fs.rs b/crates/djls-workspace/src/fs.rs index 486cb002..c1bb958e 100644 --- a/crates/djls-workspace/src/fs.rs +++ b/crates/djls-workspace/src/fs.rs @@ -4,22 +4,22 @@ //! This allows the LSP to work with both real files and in-memory overlays. use std::io; -use std::path::Path; -use std::path::PathBuf; use std::sync::Arc; +use camino::Utf8Path; +use camino::Utf8PathBuf; use rustc_hash::FxHashMap; use crate::buffers::Buffers; use crate::paths; pub trait FileSystem: Send + Sync { - fn read_to_string(&self, path: &Path) -> io::Result; - fn exists(&self, path: &Path) -> bool; + fn read_to_string(&self, path: &Utf8Path) -> io::Result; + fn exists(&self, path: &Utf8Path) -> bool; } pub struct InMemoryFileSystem { - files: FxHashMap, + files: FxHashMap, } impl InMemoryFileSystem { @@ -30,7 +30,7 @@ impl InMemoryFileSystem { } } - pub fn add_file(&mut self, path: PathBuf, content: String) { + pub fn add_file(&mut self, path: Utf8PathBuf, content: String) { self.files.insert(path, content); } } @@ -42,14 +42,14 @@ impl Default for InMemoryFileSystem { } impl FileSystem for InMemoryFileSystem { - fn read_to_string(&self, path: &Path) -> io::Result { + fn read_to_string(&self, path: &Utf8Path) -> io::Result { self.files .get(path) .cloned() .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "File not found")) } - fn exists(&self, path: &Path) -> bool { + fn exists(&self, path: &Utf8Path) -> bool { self.files.contains_key(path) } } @@ -58,11 +58,11 @@ impl FileSystem for InMemoryFileSystem { pub struct OsFileSystem; impl FileSystem for OsFileSystem { - fn read_to_string(&self, path: &Path) -> io::Result { + fn read_to_string(&self, path: &Utf8Path) -> io::Result { std::fs::read_to_string(path) } - fn exists(&self, path: &Path) -> bool { + fn exists(&self, path: &Utf8Path) -> bool { path.exists() } } @@ -101,7 +101,7 @@ impl WorkspaceFileSystem { } impl FileSystem for WorkspaceFileSystem { - fn read_to_string(&self, path: &Path) -> io::Result { + fn read_to_string(&self, path: &Utf8Path) -> io::Result { if let Some(url) = paths::path_to_url(path) { if let Some(document) = self.buffers.get(&url) { return Ok(document.content().to_string()); @@ -110,7 +110,7 @@ impl FileSystem for WorkspaceFileSystem { self.disk.read_to_string(path) } - fn exists(&self, path: &Path) -> bool { + fn exists(&self, path: &Utf8Path) -> bool { paths::path_to_url(path).is_some_and(|url| self.buffers.contains(&url)) || self.disk.exists(path) } @@ -129,7 +129,7 @@ mod tests { fs.add_file("/test.py".into(), "file content".to_string()); assert_eq!( - fs.read_to_string(Path::new("/test.py")).unwrap(), + fs.read_to_string(Utf8Path::new("/test.py")).unwrap(), "file content" ); } @@ -138,7 +138,7 @@ mod tests { fn test_read_nonexistent_file() { let fs = InMemoryFileSystem::new(); - let result = fs.read_to_string(Path::new("/missing.py")); + let result = fs.read_to_string(Utf8Path::new("/missing.py")); assert!(result.is_err()); assert_eq!(result.unwrap_err().kind(), io::ErrorKind::NotFound); } @@ -148,14 +148,14 @@ mod tests { let mut fs = InMemoryFileSystem::new(); fs.add_file("/exists.py".into(), "content".to_string()); - assert!(fs.exists(Path::new("/exists.py"))); + assert!(fs.exists(Utf8Path::new("/exists.py"))); } #[test] fn test_exists_returns_false_for_nonexistent() { let fs = InMemoryFileSystem::new(); - assert!(!fs.exists(Path::new("/missing.py"))); + assert!(!fs.exists(Utf8Path::new("/missing.py"))); } } @@ -168,11 +168,11 @@ mod tests { use crate::language::LanguageId; // Helper to create platform-appropriate test paths - fn test_file_path(name: &str) -> PathBuf { + fn test_file_path(name: &str) -> Utf8PathBuf { #[cfg(windows)] - return PathBuf::from(format!("C:\\temp\\{name}")); + return Utf8PathBuf::from(format!("C:\\temp\\{name}")); #[cfg(not(windows))] - return PathBuf::from(format!("/tmp/{name}")); + return Utf8PathBuf::from(format!("/tmp/{name}")); } #[test] diff --git a/crates/djls-workspace/src/language.rs b/crates/djls-workspace/src/language.rs index ea9b3834..b6b7f5a7 100644 --- a/crates/djls-workspace/src/language.rs +++ b/crates/djls-workspace/src/language.rs @@ -3,7 +3,7 @@ //! Maps LSP language identifiers to internal [`FileKind`] for analyzer routing. //! Language IDs come from the LSP client and determine how files are processed. -use crate::FileKind; +use djls_source::FileKind; /// Language identifier as reported by the LSP client. /// diff --git a/crates/djls-workspace/src/lib.rs b/crates/djls-workspace/src/lib.rs index 08a24244..b6ffa52f 100644 --- a/crates/djls-workspace/src/lib.rs +++ b/crates/djls-workspace/src/lib.rs @@ -21,11 +21,8 @@ mod language; pub mod paths; mod workspace; -use std::path::Path; - pub use buffers::Buffers; pub use db::Db; -pub use db::SourceFile; pub use document::TextDocument; pub use encoding::PositionEncoding; pub use fs::FileSystem; @@ -34,28 +31,3 @@ pub use fs::OsFileSystem; pub use fs::WorkspaceFileSystem; pub use language::LanguageId; pub use workspace::Workspace; - -/// File classification for routing to analyzers. -/// -/// [`FileKind`] determines how a file should be processed by downstream analyzers. -#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)] -pub enum FileKind { - /// Python source file - Python, - /// Django template file - Template, - /// Other file type - Other, -} - -impl FileKind { - /// Determine [`FileKind`] from a file path extension. - #[must_use] - pub fn from_path(path: &Path) -> Self { - match path.extension().and_then(|s| s.to_str()) { - Some("py") => FileKind::Python, - Some("html" | "htm") => FileKind::Template, - _ => FileKind::Other, - } - } -} diff --git a/crates/djls-workspace/src/paths.rs b/crates/djls-workspace/src/paths.rs index de57e89c..abde7ea5 100644 --- a/crates/djls-workspace/src/paths.rs +++ b/crates/djls-workspace/src/paths.rs @@ -3,18 +3,18 @@ //! This module provides consistent conversion between file paths and URLs, //! handling platform-specific differences and encoding issues. -use std::path::Path; -use std::path::PathBuf; use std::str::FromStr; +use camino::Utf8Path; +use camino::Utf8PathBuf; use tower_lsp_server::lsp_types; use url::Url; -/// Convert a `file://` URL to a [`PathBuf`]. +/// Convert a `file://` URL to a [`Utf8PathBuf`]. /// /// Handles percent-encoding and platform-specific path formats (e.g., Windows drives). #[must_use] -pub fn url_to_path(url: &Url) -> Option { +pub fn url_to_path(url: &Url) -> Option { // Only handle file:// URLs if url.scheme() != "file" { return None; @@ -43,7 +43,7 @@ pub fn url_to_path(url: &Url) -> Option { } }; - Some(PathBuf::from(&*path)) + Some(Utf8PathBuf::from(&*path)) } /// Context for LSP operations, used for error reporting @@ -95,11 +95,11 @@ pub fn parse_lsp_uri(lsp_uri: &lsp_types::Uri, context: LspContext) -> Option Option { +pub fn lsp_uri_to_path(lsp_uri: &lsp_types::Uri) -> Option { let url = Url::parse(lsp_uri.as_str()).ok()?; url_to_path(&url) } @@ -118,7 +118,7 @@ pub fn url_to_lsp_uri(url: &Url) -> Option { /// the path to exist on the filesystem, making it suitable for overlay /// files and other virtual content. #[must_use] -pub fn path_to_url(path: &Path) -> Option { +pub fn path_to_url(path: &Utf8Path) -> Option { // For absolute paths, convert directly if path.is_absolute() { return Url::from_file_path(path).ok(); @@ -147,12 +147,18 @@ mod tests { #[cfg(not(windows))] { let url = Url::parse("file:///home/user/test.py").unwrap(); - assert_eq!(url_to_path(&url), Some(PathBuf::from("/home/user/test.py"))); + assert_eq!( + url_to_path(&url), + Some(Utf8PathBuf::from("/home/user/test.py")) + ); } #[cfg(windows)] { let url = Url::parse("file:///C:/Users/test.py").unwrap(); - assert_eq!(url_to_path(&url), Some(PathBuf::from("C:/Users/test.py"))); + assert_eq!( + url_to_path(&url), + Some(Utf8PathBuf::from("C:/Users/test.py")) + ); } } @@ -169,7 +175,7 @@ mod tests { let url = Url::parse("file:///home/user/test%20file.py").unwrap(); assert_eq!( url_to_path(&url), - Some(PathBuf::from("/home/user/test file.py")) + Some(Utf8PathBuf::from("/home/user/test file.py")) ); } #[cfg(windows)] @@ -177,7 +183,7 @@ mod tests { let url = Url::parse("file:///C:/Users/test%20file.py").unwrap(); assert_eq!( url_to_path(&url), - Some(PathBuf::from("C:/Users/test file.py")) + Some(Utf8PathBuf::from("C:/Users/test file.py")) ); } } @@ -186,7 +192,10 @@ mod tests { #[cfg(windows)] fn test_url_to_path_windows_drive() { let url = Url::parse("file:///C:/Users/test.py").unwrap(); - assert_eq!(url_to_path(&url), Some(PathBuf::from("C:/Users/test.py"))); + assert_eq!( + url_to_path(&url), + Some(Utf8PathBuf::from("C:/Users/test.py")) + ); } #[test] @@ -205,7 +214,7 @@ mod tests { let uri = lsp_types::Uri::from_str("file:///home/user/test.py").unwrap(); assert_eq!( lsp_uri_to_path(&uri), - Some(PathBuf::from("/home/user/test.py")) + Some(Utf8PathBuf::from("/home/user/test.py")) ); } #[cfg(windows)] @@ -213,7 +222,7 @@ mod tests { let uri = lsp_types::Uri::from_str("file:///C:/Users/test.py").unwrap(); assert_eq!( lsp_uri_to_path(&uri), - Some(PathBuf::from("C:/Users/test.py")) + Some(Utf8PathBuf::from("C:/Users/test.py")) ); } } @@ -233,7 +242,7 @@ mod tests { // path_to_url tests #[test] fn test_path_to_url_absolute() { - let path = Path::new("/home/user/test.py"); + let path = Utf8Path::new("/home/user/test.py"); let url = path_to_url(path); assert!(url.is_some()); assert_eq!(url.clone().unwrap().scheme(), "file"); @@ -242,7 +251,7 @@ mod tests { #[test] fn test_path_to_url_relative() { - let path = Path::new("test.py"); + let path = Utf8Path::new("test.py"); let url = path_to_url(path); assert!(url.is_some()); assert_eq!(url.clone().unwrap().scheme(), "file"); @@ -252,7 +261,7 @@ mod tests { #[test] fn test_path_to_url_nonexistent_absolute() { - let path = Path::new("/definitely/does/not/exist/test.py"); + let path = Utf8Path::new("/definitely/does/not/exist/test.py"); let url = path_to_url(path); assert!(url.is_some()); assert_eq!(url.unwrap().scheme(), "file"); diff --git a/crates/djls-workspace/src/workspace.rs b/crates/djls-workspace/src/workspace.rs index 8f143d33..11deb6f5 100644 --- a/crates/djls-workspace/src/workspace.rs +++ b/crates/djls-workspace/src/workspace.rs @@ -118,6 +118,7 @@ impl Default for Workspace { #[cfg(test)] mod tests { + use camino::Utf8Path; use tempfile::tempdir; use super::*; @@ -191,7 +192,10 @@ mod tests { workspace.open_document(&url, document); // File system should return buffer content, not disk content - let content = workspace.file_system().read_to_string(&file_path).unwrap(); + let content = workspace + .file_system() + .read_to_string(Utf8Path::from_path(&file_path).unwrap()) + .unwrap(); assert_eq!(content, "buffer content"); } }