diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cb3bc24..b284a5a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Dart/Pub ecosystem support** — New `deps-dart` crate with full pubspec.yaml and pubspec.lock support + - YAML parser with position tracking via yaml-rust2 + - pub.dev API client for package info and search + - pubspec.lock parser for installed version resolution + - Dart version constraint matching (caret, range, any, exact) with correct 0.x semantics + - Hosted, git, path, and SDK dependency sources + ## [0.6.1] - 2026-02-16 ### Added diff --git a/Cargo.lock b/Cargo.lock index 43448ba3..4dc239bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,6 +38,12 @@ version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + [[package]] name = "assert-json-diff" version = "2.0.2" @@ -473,6 +479,27 @@ dependencies = [ "tracing", ] +[[package]] +name = "deps-dart" +version = "0.6.1" +dependencies = [ + "async-trait", + "criterion", + "deps-core", + "insta", + "once_cell", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-test", + "tower-lsp-server", + "tracing", + "urlencoding", + "yaml-rust2", +] + [[package]] name = "deps-go" version = "0.6.1" @@ -503,6 +530,7 @@ dependencies = [ "deps-bundler", "deps-cargo", "deps-core", + "deps-dart", "deps-go", "deps-npm", "deps-pypi", @@ -653,6 +681,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -820,12 +854,30 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "http" version = "1.4.0" @@ -2899,6 +2951,17 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "yaml-rust2" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2462ea039c445496d8793d052e13787f2b90e750b833afee748e601c17621ed9" +dependencies = [ + "arraydeque", + "encoding_rs", + "hashlink", +] + [[package]] name = "yoke" version = "0.8.1" diff --git a/Cargo.toml b/Cargo.toml index bf2674be..95064140 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ deps-npm = { version = "0.6.1", path = "crates/deps-npm" } deps-pypi = { version = "0.6.1", path = "crates/deps-pypi" } deps-go = { version = "0.6.1", path = "crates/deps-go" } deps-bundler = { version = "0.6.1", path = "crates/deps-bundler" } +deps-dart = { version = "0.6.1", path = "crates/deps-dart" } deps-lsp = { version = "0.6.1", path = "crates/deps-lsp" } futures = "0.3" insta = "1" @@ -44,6 +45,7 @@ tower-lsp-server = "0.23" tracing = "0.1" tracing-subscriber = "0.3" urlencoding = "2.1" +yaml-rust2 = "0.10" zed_extension_api = "0.7" [workspace.lints.rust] diff --git a/crates/deps-dart/Cargo.toml b/crates/deps-dart/Cargo.toml new file mode 100644 index 00000000..67469251 --- /dev/null +++ b/crates/deps-dart/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "deps-dart" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +description = "pubspec.yaml support for deps-lsp" +publish = true + +[lints] +workspace = true + +[dependencies] +deps-core = { workspace = true } +async-trait = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["fs"] } +tower-lsp-server = { workspace = true } +tracing = { workspace = true } +urlencoding = { workspace = true } +yaml-rust2 = { workspace = true } + +[dev-dependencies] +criterion = { workspace = true, features = ["html_reports"] } +insta = { workspace = true, features = ["json"] } +once_cell = { workspace = true } +tempfile = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +tokio-test = { workspace = true } diff --git a/crates/deps-dart/src/ecosystem.rs b/crates/deps-dart/src/ecosystem.rs new file mode 100644 index 00000000..b1b5d283 --- /dev/null +++ b/crates/deps-dart/src/ecosystem.rs @@ -0,0 +1,269 @@ +//! Dart ecosystem implementation for deps-lsp. + +use async_trait::async_trait; +use std::any::Any; +use std::collections::HashMap; +use std::sync::Arc; +use tower_lsp_server::ls_types::{ + CodeAction, CompletionItem, Diagnostic, Hover, InlayHint, Position, Uri, +}; + +use deps_core::{ + Ecosystem, EcosystemConfig, ParseResult as ParseResultTrait, Registry, Result, lsp_helpers, +}; + +use crate::formatter::DartFormatter; +use crate::registry::PubDevRegistry; + +pub struct DartEcosystem { + registry: Arc, + formatter: DartFormatter, +} + +impl DartEcosystem { + pub fn new(cache: Arc) -> Self { + Self { + registry: Arc::new(PubDevRegistry::new(cache)), + formatter: DartFormatter, + } + } + + async fn complete_package_names(&self, prefix: &str) -> Vec { + use deps_core::completion::build_package_completion; + + if prefix.len() < 2 || prefix.len() > 100 { + return vec![]; + } + + let results = match self.registry.search(prefix, 20).await { + Ok(r) => r, + Err(e) => { + tracing::warn!("Pub.dev search failed for '{}': {}", prefix, e); + return vec![]; + } + }; + + let insert_range = tower_lsp_server::ls_types::Range::default(); + + results + .into_iter() + .map(|metadata| { + let boxed: Box = Box::new(metadata); + build_package_completion(boxed.as_ref(), insert_range) + }) + .collect() + } + + async fn complete_versions(&self, package_name: &str, prefix: &str) -> Vec { + deps_core::completion::complete_versions_generic( + self.registry.as_ref(), + package_name, + prefix, + &['^', '>', '<', '='], + ) + .await + } +} + +#[async_trait] +impl Ecosystem for DartEcosystem { + fn id(&self) -> &'static str { + "dart" + } + + fn display_name(&self) -> &'static str { + "Dart (Pub)" + } + + fn manifest_filenames(&self) -> &[&'static str] { + &["pubspec.yaml"] + } + + fn lockfile_filenames(&self) -> &[&'static str] { + &["pubspec.lock"] + } + + async fn parse_manifest(&self, content: &str, uri: &Uri) -> Result> { + let result = crate::parser::parse_pubspec_yaml(content, uri)?; + Ok(Box::new(result)) + } + + fn registry(&self) -> Arc { + self.registry.clone() as Arc + } + + fn lockfile_provider(&self) -> Option> { + Some(Arc::new(crate::lockfile::PubspecLockParser)) + } + + async fn generate_inlay_hints( + &self, + parse_result: &dyn ParseResultTrait, + cached_versions: &HashMap, + resolved_versions: &HashMap, + loading_state: deps_core::LoadingState, + config: &EcosystemConfig, + ) -> Vec { + lsp_helpers::generate_inlay_hints( + parse_result, + cached_versions, + resolved_versions, + loading_state, + config, + &self.formatter, + ) + } + + async fn generate_hover( + &self, + parse_result: &dyn ParseResultTrait, + position: Position, + cached_versions: &HashMap, + resolved_versions: &HashMap, + ) -> Option { + lsp_helpers::generate_hover( + parse_result, + position, + cached_versions, + resolved_versions, + self.registry.as_ref(), + &self.formatter, + ) + .await + } + + async fn generate_code_actions( + &self, + parse_result: &dyn ParseResultTrait, + position: Position, + _cached_versions: &HashMap, + uri: &Uri, + ) -> Vec { + lsp_helpers::generate_code_actions( + parse_result, + position, + uri, + self.registry.as_ref(), + &self.formatter, + ) + .await + } + + async fn generate_diagnostics( + &self, + parse_result: &dyn ParseResultTrait, + cached_versions: &HashMap, + resolved_versions: &HashMap, + _uri: &Uri, + ) -> Vec { + lsp_helpers::generate_diagnostics_from_cache( + parse_result, + cached_versions, + resolved_versions, + &self.formatter, + ) + } + + async fn generate_completions( + &self, + parse_result: &dyn ParseResultTrait, + position: Position, + content: &str, + ) -> Vec { + use deps_core::completion::{CompletionContext, detect_completion_context}; + + let context = detect_completion_context(parse_result, position, content); + + match context { + CompletionContext::PackageName { prefix } => self.complete_package_names(&prefix).await, + CompletionContext::Version { + package_name, + prefix, + } => self.complete_versions(&package_name, &prefix).await, + CompletionContext::Feature { .. } | CompletionContext::None => vec![], + } + } + + fn as_any(&self) -> &dyn Any { + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ecosystem_id() { + let cache = Arc::new(deps_core::HttpCache::new()); + let eco = DartEcosystem::new(cache); + assert_eq!(eco.id(), "dart"); + } + + #[test] + fn test_ecosystem_display_name() { + let cache = Arc::new(deps_core::HttpCache::new()); + let eco = DartEcosystem::new(cache); + assert_eq!(eco.display_name(), "Dart (Pub)"); + } + + #[test] + fn test_ecosystem_manifest_filenames() { + let cache = Arc::new(deps_core::HttpCache::new()); + let eco = DartEcosystem::new(cache); + assert_eq!(eco.manifest_filenames(), &["pubspec.yaml"]); + } + + #[test] + fn test_ecosystem_lockfile_filenames() { + let cache = Arc::new(deps_core::HttpCache::new()); + let eco = DartEcosystem::new(cache); + assert_eq!(eco.lockfile_filenames(), &["pubspec.lock"]); + } + + #[test] + fn test_as_any() { + let cache = Arc::new(deps_core::HttpCache::new()); + let eco = DartEcosystem::new(cache); + assert!(eco.as_any().is::()); + } + + #[tokio::test] + async fn test_complete_package_names_min_prefix() { + let cache = Arc::new(deps_core::HttpCache::new()); + let eco = DartEcosystem::new(cache); + assert!(eco.complete_package_names("h").await.is_empty()); + assert!(eco.complete_package_names("").await.is_empty()); + } + + #[tokio::test] + async fn test_complete_package_names_max_length() { + let cache = Arc::new(deps_core::HttpCache::new()); + let eco = DartEcosystem::new(cache); + let long = "a".repeat(101); + assert!(eco.complete_package_names(&long).await.is_empty()); + } + + #[tokio::test] + async fn test_lockfile_provider() { + let cache = Arc::new(deps_core::HttpCache::new()); + let eco = DartEcosystem::new(cache); + assert!(eco.lockfile_provider().is_some()); + } + + #[tokio::test] + async fn test_parse_manifest() { + let cache = Arc::new(deps_core::HttpCache::new()); + let eco = DartEcosystem::new(cache); + + let yaml = "name: app\ndependencies:\n http: ^1.0.0\n"; + #[cfg(windows)] + let path = "C:/test/pubspec.yaml"; + #[cfg(not(windows))] + let path = "/test/pubspec.yaml"; + let uri = Uri::from_file_path(path).unwrap(); + + let result = eco.parse_manifest(yaml, &uri).await.unwrap(); + assert_eq!(result.dependencies().len(), 1); + } +} diff --git a/crates/deps-dart/src/error.rs b/crates/deps-dart/src/error.rs new file mode 100644 index 00000000..91e84f6b --- /dev/null +++ b/crates/deps-dart/src/error.rs @@ -0,0 +1,198 @@ +//! Errors specific to Dart/Pub dependency handling. + +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum DartError { + #[error("Failed to parse pubspec.yaml: {message}")] + ParseError { message: String }, + + #[error("Invalid version constraint '{constraint}': {message}")] + InvalidVersionConstraint { constraint: String, message: String }, + + #[error("Package '{package}' not found on pub.dev")] + PackageNotFound { package: String }, + + #[error("pub.dev request failed for '{package}': {source}")] + RegistryError { + package: String, + #[source] + source: Box, + }, + + #[error("Failed to parse pub.dev API response for '{package}': {source}")] + ApiResponseError { + package: String, + #[source] + source: serde_json::Error, + }, + + #[error("Invalid pubspec.yaml structure: {message}")] + InvalidStructure { message: String }, + + #[error("Invalid file URI: {uri}")] + InvalidUri { uri: String }, + + #[error("Cache error: {0}")] + CacheError(String), + + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + + #[error(transparent)] + Other(#[from] Box), +} + +pub type Result = std::result::Result; + +impl From for DartError { + fn from(err: deps_core::DepsError) -> Self { + match err { + deps_core::DepsError::ParseError { source, .. } => Self::CacheError(source.to_string()), + deps_core::DepsError::CacheError(msg) => Self::CacheError(msg), + deps_core::DepsError::InvalidVersionReq(msg) => Self::InvalidVersionConstraint { + constraint: String::new(), + message: msg, + }, + deps_core::DepsError::Io(e) => Self::Io(e), + deps_core::DepsError::Json(e) => Self::ApiResponseError { + package: String::new(), + source: e, + }, + other => Self::CacheError(other.to_string()), + } + } +} + +impl From for deps_core::DepsError { + fn from(err: DartError) -> Self { + match err { + DartError::ParseError { message } => Self::ParseError { + file_type: "pubspec.yaml".into(), + source: Box::new(std::io::Error::other(message)), + }, + DartError::InvalidVersionConstraint { message, .. } => Self::InvalidVersionReq(message), + DartError::PackageNotFound { package } => { + Self::CacheError(format!("Package '{package}' not found")) + } + DartError::RegistryError { package, source } => Self::ParseError { + file_type: format!("pub.dev for {package}"), + source, + }, + DartError::ApiResponseError { source, .. } => Self::Json(source), + DartError::InvalidStructure { message } => Self::CacheError(message), + DartError::InvalidUri { uri } => Self::CacheError(format!("Invalid URI: {uri}")), + DartError::CacheError(msg) => Self::CacheError(msg), + DartError::Io(e) => Self::Io(e), + DartError::Other(e) => Self::CacheError(e.to_string()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_error_display() { + let err = DartError::PackageNotFound { + package: "nonexistent".into(), + }; + assert_eq!( + err.to_string(), + "Package 'nonexistent' not found on pub.dev" + ); + + let err = DartError::InvalidStructure { + message: "missing name".into(), + }; + assert_eq!( + err.to_string(), + "Invalid pubspec.yaml structure: missing name" + ); + } + + #[test] + fn test_conversion_to_deps_error() { + let err = DartError::PackageNotFound { + package: "test".into(), + }; + let deps_err: deps_core::DepsError = err.into(); + assert!(deps_err.to_string().contains("not found")); + } + + #[test] + fn test_parse_error_to_deps_error() { + let err = DartError::ParseError { + message: "syntax error".into(), + }; + let deps_err: deps_core::DepsError = err.into(); + assert!(matches!(deps_err, deps_core::DepsError::ParseError { .. })); + } + + #[test] + fn test_invalid_constraint_to_deps_error() { + let err = DartError::InvalidVersionConstraint { + constraint: "bad".into(), + message: "invalid".into(), + }; + let deps_err: deps_core::DepsError = err.into(); + assert!(matches!( + deps_err, + deps_core::DepsError::InvalidVersionReq(_) + )); + } + + #[test] + fn test_io_error_conversion() { + let io_err = std::io::Error::from(std::io::ErrorKind::NotFound); + let err: DartError = io_err.into(); + assert!(matches!(err, DartError::Io(_))); + + let deps_err: deps_core::DepsError = err.into(); + assert!(matches!(deps_err, deps_core::DepsError::Io(_))); + } + + #[test] + fn test_deps_error_to_dart_error() { + let deps_err = deps_core::DepsError::CacheError("cache miss".into()); + let dart_err: DartError = deps_err.into(); + assert!(matches!(dart_err, DartError::CacheError(_))); + + let deps_err = deps_core::DepsError::InvalidVersionReq("bad".into()); + let dart_err: DartError = deps_err.into(); + assert!(matches!( + dart_err, + DartError::InvalidVersionConstraint { .. } + )); + } + + #[test] + fn test_api_response_error_to_deps_error() { + let json_err = serde_json::from_str::("{invalid}").unwrap_err(); + let err = DartError::ApiResponseError { + package: "test".into(), + source: json_err, + }; + let deps_err: deps_core::DepsError = err.into(); + assert!(matches!(deps_err, deps_core::DepsError::Json(_))); + } + + #[test] + fn test_invalid_uri_to_deps_error() { + let err = DartError::InvalidUri { + uri: "bad://uri".into(), + }; + let deps_err: deps_core::DepsError = err.into(); + assert!(matches!(deps_err, deps_core::DepsError::CacheError(_))); + } + + #[test] + fn test_other_error_to_deps_error() { + let other: Box = + Box::new(std::io::Error::other("unknown")); + let err = DartError::Other(other); + let deps_err: deps_core::DepsError = err.into(); + assert!(matches!(deps_err, deps_core::DepsError::CacheError(_))); + } +} diff --git a/crates/deps-dart/src/formatter.rs b/crates/deps-dart/src/formatter.rs new file mode 100644 index 00000000..69168d7e --- /dev/null +++ b/crates/deps-dart/src/formatter.rs @@ -0,0 +1,54 @@ +//! Version formatting for Dart ecosystem. + +use crate::version::version_matches_constraint; +use deps_core::lsp_helpers::EcosystemFormatter; + +pub struct DartFormatter; + +impl EcosystemFormatter for DartFormatter { + fn format_version_for_code_action(&self, version: &str) -> String { + format!("^{version}") + } + + fn package_url(&self, name: &str) -> String { + crate::registry::package_url(name) + } + + fn version_satisfies_requirement(&self, version: &str, requirement: &str) -> bool { + version_matches_constraint(version, requirement) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_version() { + let f = DartFormatter; + assert_eq!(f.format_version_for_code_action("1.0.0"), "^1.0.0"); + assert_eq!(f.format_version_for_code_action("6.1.2"), "^6.1.2"); + } + + #[test] + fn test_package_url() { + let f = DartFormatter; + assert_eq!( + f.package_url("provider"), + "https://pub.dev/packages/provider" + ); + } + + #[test] + fn test_version_satisfies() { + let f = DartFormatter; + assert!(f.version_satisfies_requirement("1.5.0", "^1.0.0")); + assert!(!f.version_satisfies_requirement("2.0.0", "^1.0.0")); + } + + #[test] + fn test_normalize_is_identity() { + let f = DartFormatter; + assert_eq!(f.normalize_package_name("flutter_bloc"), "flutter_bloc"); + } +} diff --git a/crates/deps-dart/src/lib.rs b/crates/deps-dart/src/lib.rs new file mode 100644 index 00000000..360c5ef4 --- /dev/null +++ b/crates/deps-dart/src/lib.rs @@ -0,0 +1,22 @@ +//! pubspec.yaml parsing and pub.dev integration. +//! +//! This crate provides Dart/Pub ecosystem support for the deps-lsp server, +//! including pubspec.yaml parsing, dependency extraction, and pub.dev +//! registry integration. + +pub mod ecosystem; +pub mod error; +pub mod formatter; +pub mod lockfile; +pub mod parser; +pub mod registry; +pub mod types; +pub mod version; + +pub use ecosystem::DartEcosystem; +pub use error::{DartError, Result}; +pub use formatter::DartFormatter; +pub use lockfile::PubspecLockParser; +pub use parser::{DartParseResult, parse_pubspec_yaml}; +pub use registry::{PubDevRegistry, package_url}; +pub use types::{DartDependency, DartVersion, DependencySection, DependencySource, PackageInfo}; diff --git a/crates/deps-dart/src/lockfile.rs b/crates/deps-dart/src/lockfile.rs new file mode 100644 index 00000000..67be14a5 --- /dev/null +++ b/crates/deps-dart/src/lockfile.rs @@ -0,0 +1,245 @@ +//! pubspec.lock file parsing. + +use async_trait::async_trait; +use deps_core::error::{DepsError, Result}; +use deps_core::lockfile::{ + LockFileProvider, ResolvedPackage, ResolvedPackages, ResolvedSource, + locate_lockfile_for_manifest, +}; +use std::path::{Path, PathBuf}; +use tower_lsp_server::ls_types::Uri; +use yaml_rust2::{Yaml, YamlLoader}; + +pub struct PubspecLockParser; + +impl PubspecLockParser { + const LOCKFILE_NAMES: &'static [&'static str] = &["pubspec.lock"]; +} + +#[async_trait] +impl LockFileProvider for PubspecLockParser { + fn locate_lockfile(&self, manifest_uri: &Uri) -> Option { + locate_lockfile_for_manifest(manifest_uri, Self::LOCKFILE_NAMES) + } + + async fn parse_lockfile(&self, lockfile_path: &Path) -> Result { + tracing::debug!("Parsing pubspec.lock: {}", lockfile_path.display()); + + let content = tokio::fs::read_to_string(lockfile_path) + .await + .map_err(|e| DepsError::ParseError { + file_type: format!("pubspec.lock at {}", lockfile_path.display()), + source: Box::new(e), + })?; + + parse_pubspec_lock(&content) + } +} + +pub fn parse_pubspec_lock(content: &str) -> Result { + let mut packages = ResolvedPackages::new(); + + let docs = YamlLoader::load_from_str(content).map_err(|e| DepsError::ParseError { + file_type: "pubspec.lock".into(), + source: Box::new(std::io::Error::other(e.to_string())), + })?; + + let doc = match docs.first() { + Some(d) => d, + None => return Ok(packages), + }; + + if let Yaml::Hash(pkgs) = &doc["packages"] { + for (name_yaml, entry) in pkgs { + let Some(name) = name_yaml.as_str() else { + continue; + }; + let Some(version) = entry["version"].as_str() else { + continue; + }; + + let source_type = entry["source"].as_str().unwrap_or("hosted"); + let source = match source_type { + "hosted" => { + let url = entry["description"]["url"] + .as_str() + .unwrap_or("https://pub.dev") + .to_string(); + ResolvedSource::Registry { + url, + checksum: String::new(), + } + } + "git" => { + let url = entry["description"]["url"] + .as_str() + .unwrap_or("") + .to_string(); + let rev = entry["description"]["resolved-ref"] + .as_str() + .unwrap_or("") + .to_string(); + ResolvedSource::Git { url, rev } + } + "path" => { + let path = entry["description"]["path"] + .as_str() + .unwrap_or("") + .to_string(); + ResolvedSource::Path { path } + } + _ => ResolvedSource::Registry { + url: "https://pub.dev".to_string(), + checksum: String::new(), + }, + }; + + // Remove surrounding quotes from version if present + let version = version.trim_matches('"').to_string(); + + packages.insert(ResolvedPackage { + name: name.to_string(), + version, + source, + dependencies: vec![], + }); + } + } + + tracing::info!("Parsed pubspec.lock: {} packages", packages.len()); + + Ok(packages) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_simple_lock() { + let lock = r#" +packages: + http: + dependency: "direct main" + description: + name: http + url: "https://pub.dev" + source: hosted + version: "1.2.0" + provider: + dependency: "direct main" + description: + name: provider + url: "https://pub.dev" + source: hosted + version: "6.1.2" +"#; + let packages = parse_pubspec_lock(lock).unwrap(); + assert_eq!(packages.len(), 2); + assert_eq!(packages.get_version("http"), Some("1.2.0")); + assert_eq!(packages.get_version("provider"), Some("6.1.2")); + } + + #[test] + fn test_parse_git_source() { + let lock = r#" +packages: + my_pkg: + dependency: "direct main" + description: + url: "https://github.com/user/repo.git" + resolved-ref: abc123 + source: git + version: "0.1.0" +"#; + let packages = parse_pubspec_lock(lock).unwrap(); + let pkg = packages.get("my_pkg").unwrap(); + match &pkg.source { + ResolvedSource::Git { url, rev } => { + assert_eq!(url, "https://github.com/user/repo.git"); + assert_eq!(rev, "abc123"); + } + _ => panic!("Expected Git source"), + } + } + + #[test] + fn test_parse_path_source() { + let lock = r#" +packages: + local_pkg: + dependency: "direct main" + description: + path: "../local_pkg" + source: path + version: "0.1.0" +"#; + let packages = parse_pubspec_lock(lock).unwrap(); + let pkg = packages.get("local_pkg").unwrap(); + match &pkg.source { + ResolvedSource::Path { path } => { + assert_eq!(path, "../local_pkg"); + } + _ => panic!("Expected Path source"), + } + } + + #[test] + fn test_parse_empty_lock() { + let lock = ""; + let packages = parse_pubspec_lock(lock).unwrap(); + assert!(packages.is_empty()); + } + + #[test] + fn test_locate_lockfile() { + let temp_dir = tempfile::tempdir().unwrap(); + let manifest_path = temp_dir.path().join("pubspec.yaml"); + let lock_path = temp_dir.path().join("pubspec.lock"); + + std::fs::write(&manifest_path, "name: test").unwrap(); + std::fs::write(&lock_path, "packages:\n").unwrap(); + + let manifest_uri = Uri::from_file_path(&manifest_path).unwrap(); + let parser = PubspecLockParser; + + let located = parser.locate_lockfile(&manifest_uri); + assert!(located.is_some()); + assert_eq!(located.unwrap(), lock_path); + } + + #[test] + fn test_locate_lockfile_not_found() { + let temp_dir = tempfile::tempdir().unwrap(); + let manifest_path = temp_dir.path().join("pubspec.yaml"); + std::fs::write(&manifest_path, "name: test").unwrap(); + + let manifest_uri = Uri::from_file_path(&manifest_path).unwrap(); + let parser = PubspecLockParser; + + assert!(parser.locate_lockfile(&manifest_uri).is_none()); + } + + #[tokio::test] + async fn test_parse_lockfile_from_file() { + let temp_dir = tempfile::tempdir().unwrap(); + let lock_path = temp_dir.path().join("pubspec.lock"); + + let content = r#" +packages: + http: + dependency: "direct main" + description: + name: http + url: "https://pub.dev" + source: hosted + version: "1.2.0" +"#; + std::fs::write(&lock_path, content).unwrap(); + + let parser = PubspecLockParser; + let packages = parser.parse_lockfile(&lock_path).await.unwrap(); + assert_eq!(packages.len(), 1); + assert_eq!(packages.get_version("http"), Some("1.2.0")); + } +} diff --git a/crates/deps-dart/src/parser.rs b/crates/deps-dart/src/parser.rs new file mode 100644 index 00000000..d833e726 --- /dev/null +++ b/crates/deps-dart/src/parser.rs @@ -0,0 +1,434 @@ +//! pubspec.yaml parser with position tracking. + +use crate::error::Result; +use crate::types::{DartDependency, DependencySection, DependencySource}; +use std::any::Any; +use tower_lsp_server::ls_types::{Position, Range, Uri}; +use yaml_rust2::{Yaml, YamlLoader}; + +#[derive(Debug, Clone)] +pub struct DartParseResult { + pub dependencies: Vec, + pub sdk_constraint: Option, + pub uri: Uri, +} + +struct LineOffsetTable { + line_starts: Vec, +} + +impl LineOffsetTable { + fn new(content: &str) -> Self { + let mut line_starts = vec![0]; + for (i, c) in content.char_indices() { + if c == '\n' { + line_starts.push(i + 1); + } + } + Self { line_starts } + } + + fn byte_offset_to_position(&self, content: &str, offset: usize) -> Position { + let line = self + .line_starts + .partition_point(|&start| start <= offset) + .saturating_sub(1); + let line_start = self.line_starts[line]; + + let character = content[line_start..offset] + .chars() + .map(|c| c.len_utf16() as u32) + .sum(); + + Position::new(line as u32, character) + } +} + +pub fn parse_pubspec_yaml(content: &str, doc_uri: &Uri) -> Result { + let line_table = LineOffsetTable::new(content); + let mut dependencies = Vec::new(); + let mut sdk_constraint = None; + + let docs = + YamlLoader::load_from_str(content).map_err(|e| crate::error::DartError::ParseError { + message: e.to_string(), + })?; + + let doc = match docs.first() { + Some(d) => d, + None => { + return Ok(DartParseResult { + dependencies, + sdk_constraint, + uri: doc_uri.clone(), + }); + } + }; + + // Extract SDK constraint + if let Some(env) = doc["environment"]["sdk"].as_str() { + sdk_constraint = Some(env.to_string()); + } + + // Parse each dependency section + let sections = [ + ("dependencies", DependencySection::Dependencies), + ("dev_dependencies", DependencySection::DevDependencies), + ( + "dependency_overrides", + DependencySection::DependencyOverrides, + ), + ]; + + for (key, section) in §ions { + if let Yaml::Hash(map) = &doc[*key] { + for (name_yaml, value) in map { + if let Some(name) = name_yaml.as_str() { + let (name_range, version_req, version_range, source) = + parse_dependency_entry(name, value, content, &line_table); + + dependencies.push(DartDependency { + name: name.to_string(), + name_range, + version_req, + version_range, + section: section.clone(), + source, + }); + } + } + } + } + + Ok(DartParseResult { + dependencies, + sdk_constraint, + uri: doc_uri.clone(), + }) +} + +fn parse_dependency_entry( + name: &str, + value: &Yaml, + content: &str, + line_table: &LineOffsetTable, +) -> (Range, Option, Option, DependencySource) { + let name_range = find_key_range(name, content, line_table); + + match value { + // Simple version string: "package: ^1.0.0" + Yaml::String(ver) => { + let version_range = find_value_range_after_key(name, ver, content, line_table); + ( + name_range, + Some(ver.clone()), + version_range, + DependencySource::Hosted, + ) + } + // Map form + Yaml::Hash(map) => { + let mut version_req = None; + let mut version_range = None; + let mut source = DependencySource::Hosted; + + if let Some(Yaml::String(ver)) = map.get(&Yaml::String("version".into())) { + version_req = Some(ver.clone()); + version_range = find_value_range_after_key("version", ver, content, line_table); + } + + if let Some(git_val) = map.get(&Yaml::String("git".into())) { + source = parse_git_source(git_val); + } else if let Some(Yaml::String(path)) = map.get(&Yaml::String("path".into())) { + source = DependencySource::Path { path: path.clone() }; + } else if let Some(Yaml::String(sdk)) = map.get(&Yaml::String("sdk".into())) { + source = DependencySource::Sdk { sdk: sdk.clone() }; + } + + (name_range, version_req, version_range, source) + } + _ => (name_range, None, None, DependencySource::Hosted), + } +} + +fn parse_git_source(git_val: &Yaml) -> DependencySource { + match git_val { + Yaml::String(url) => DependencySource::Git { + url: url.clone(), + ref_: None, + path: None, + }, + Yaml::Hash(map) => { + let url = map + .get(&Yaml::String("url".into())) + .and_then(Yaml::as_str) + .unwrap_or("") + .to_string(); + let ref_ = map + .get(&Yaml::String("ref".into())) + .and_then(Yaml::as_str) + .map(String::from); + let path = map + .get(&Yaml::String("path".into())) + .and_then(Yaml::as_str) + .map(String::from); + DependencySource::Git { url, ref_, path } + } + _ => DependencySource::Hosted, + } +} + +fn find_key_range(key: &str, content: &str, line_table: &LineOffsetTable) -> Range { + // Search for "key:" pattern in YAML content + for (i, _) in content.match_indices(key) { + let after = i + key.len(); + if after < content.len() { + let next_char = content.as_bytes()[after]; + if next_char == b':' { + // Verify this is at the start of a line (after optional whitespace) + let line_start = content[..i].rfind('\n').map_or(0, |p| p + 1); + let prefix = &content[line_start..i]; + if prefix.chars().all(|c| c == ' ') { + let start = line_table.byte_offset_to_position(content, i); + let end = line_table.byte_offset_to_position(content, after); + return Range::new(start, end); + } + } + } + } + Range::default() +} + +fn find_value_range_after_key( + key: &str, + value: &str, + content: &str, + line_table: &LineOffsetTable, +) -> Option { + // Find "key: value" or "key: 'value'" patterns + let pattern = format!("{key}:"); + for (i, _) in content.match_indices(&pattern) { + let after_colon = i + pattern.len(); + let rest = &content[after_colon..]; + if let Some(val_offset) = rest.find(value) { + let abs_start = after_colon + val_offset; + let abs_end = abs_start + value.len(); + let start = line_table.byte_offset_to_position(content, abs_start); + let end = line_table.byte_offset_to_position(content, abs_end); + return Some(Range::new(start, end)); + } + } + None +} + +impl deps_core::ParseResult for DartParseResult { + fn dependencies(&self) -> Vec<&dyn deps_core::Dependency> { + self.dependencies + .iter() + .map(|d| d as &dyn deps_core::Dependency) + .collect() + } + + fn workspace_root(&self) -> Option<&std::path::Path> { + None + } + + fn uri(&self) -> &Uri { + &self.uri + } + + fn as_any(&self) -> &dyn Any { + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_uri() -> Uri { + #[cfg(windows)] + let path = "C:/test/pubspec.yaml"; + #[cfg(not(windows))] + let path = "/test/pubspec.yaml"; + Uri::from_file_path(path).unwrap() + } + + #[test] + fn test_parse_simple_deps() { + let yaml = r" +name: my_app +dependencies: + provider: ^6.0.0 + http: ^1.0.0 +"; + let result = parse_pubspec_yaml(yaml, &test_uri()).unwrap(); + assert_eq!(result.dependencies.len(), 2); + assert_eq!(result.dependencies[0].name, "provider"); + assert_eq!(result.dependencies[0].version_req, Some("^6.0.0".into())); + assert_eq!(result.dependencies[1].name, "http"); + } + + #[test] + fn test_parse_dev_dependencies() { + let yaml = r" +name: my_app +dev_dependencies: + flutter_test: + sdk: flutter + build_runner: ^2.4.0 +"; + let result = parse_pubspec_yaml(yaml, &test_uri()).unwrap(); + assert_eq!(result.dependencies.len(), 2); + assert!(matches!( + result.dependencies[0].section, + DependencySection::DevDependencies + )); + assert!(matches!( + result.dependencies[0].source, + DependencySource::Sdk { .. } + )); + } + + #[test] + fn test_parse_git_dependency() { + let yaml = r" +name: my_app +dependencies: + my_pkg: + git: + url: https://github.com/user/repo.git + ref: main + path: packages/my_pkg +"; + let result = parse_pubspec_yaml(yaml, &test_uri()).unwrap(); + assert_eq!(result.dependencies.len(), 1); + match &result.dependencies[0].source { + DependencySource::Git { url, ref_, path } => { + assert_eq!(url, "https://github.com/user/repo.git"); + assert_eq!(ref_, &Some("main".into())); + assert_eq!(path, &Some("packages/my_pkg".into())); + } + _ => panic!("Expected Git source"), + } + } + + #[test] + fn test_parse_path_dependency() { + let yaml = r" +name: my_app +dependencies: + local_pkg: + path: ../local_pkg +"; + let result = parse_pubspec_yaml(yaml, &test_uri()).unwrap(); + assert!(matches!( + result.dependencies[0].source, + DependencySource::Path { .. } + )); + } + + #[test] + fn test_parse_sdk_constraint() { + let yaml = r" +name: my_app +environment: + sdk: '>=3.0.0 <4.0.0' +dependencies: + http: ^1.0.0 +"; + let result = parse_pubspec_yaml(yaml, &test_uri()).unwrap(); + assert_eq!(result.sdk_constraint, Some(">=3.0.0 <4.0.0".into())); + } + + #[test] + fn test_parse_empty_pubspec() { + let yaml = "name: empty_app\n"; + let result = parse_pubspec_yaml(yaml, &test_uri()).unwrap(); + assert!(result.dependencies.is_empty()); + assert!(result.sdk_constraint.is_none()); + } + + #[test] + fn test_parse_dependency_overrides() { + let yaml = r" +name: my_app +dependency_overrides: + http: ^2.0.0 +"; + let result = parse_pubspec_yaml(yaml, &test_uri()).unwrap(); + assert_eq!(result.dependencies.len(), 1); + assert!(matches!( + result.dependencies[0].section, + DependencySection::DependencyOverrides + )); + } + + #[test] + fn test_parse_hosted_with_version() { + let yaml = r" +name: my_app +dependencies: + custom_pkg: + hosted: https://custom-registry.example.com + version: ^1.0.0 +"; + let result = parse_pubspec_yaml(yaml, &test_uri()).unwrap(); + assert_eq!(result.dependencies.len(), 1); + assert_eq!(result.dependencies[0].version_req, Some("^1.0.0".into())); + } + + #[test] + fn test_parse_git_shorthand() { + let yaml = r" +name: my_app +dependencies: + my_pkg: + git: https://github.com/user/repo.git +"; + let result = parse_pubspec_yaml(yaml, &test_uri()).unwrap(); + match &result.dependencies[0].source { + DependencySource::Git { url, ref_, path } => { + assert_eq!(url, "https://github.com/user/repo.git"); + assert!(ref_.is_none()); + assert!(path.is_none()); + } + _ => panic!("Expected Git source"), + } + } + + #[test] + fn test_position_tracking() { + let yaml = "name: my_app\ndependencies:\n http: ^1.0.0\n"; + let result = parse_pubspec_yaml(yaml, &test_uri()).unwrap(); + let dep = &result.dependencies[0]; + // Name should be on line 2 (0-indexed) + assert_eq!(dep.name_range.start.line, 2); + } + + #[test] + fn test_parse_result_trait() { + use deps_core::ParseResult; + let yaml = "name: app\ndependencies:\n http: ^1.0.0\n"; + let result = parse_pubspec_yaml(yaml, &test_uri()).unwrap(); + assert_eq!(result.dependencies().len(), 1); + assert!(result.workspace_root().is_none()); + assert!(result.as_any().is::()); + } + + #[test] + fn test_line_offset_table() { + let content = "abc\ndef"; + let table = LineOffsetTable::new(content); + let pos = table.byte_offset_to_position(content, 4); + assert_eq!(pos.line, 1); + assert_eq!(pos.character, 0); + } + + #[test] + fn test_invalid_yaml() { + let yaml = "{{invalid yaml"; + let result = parse_pubspec_yaml(yaml, &test_uri()); + assert!(result.is_err()); + } +} diff --git a/crates/deps-dart/src/registry.rs b/crates/deps-dart/src/registry.rs new file mode 100644 index 00000000..afff5ed2 --- /dev/null +++ b/crates/deps-dart/src/registry.rs @@ -0,0 +1,398 @@ +//! pub.dev registry client. + +use crate::types::{DartVersion, PackageInfo}; +use crate::version::compare_versions; +use deps_core::{HttpCache, Result}; +use serde::Deserialize; +use std::any::Any; +use std::sync::Arc; + +const PUB_DEV_API_BASE: &str = "https://pub.dev/api"; + +pub fn package_url(name: &str) -> String { + format!("https://pub.dev/packages/{name}") +} + +#[derive(Clone)] +pub struct PubDevRegistry { + cache: Arc, +} + +impl PubDevRegistry { + pub const fn new(cache: Arc) -> Self { + Self { cache } + } + + pub async fn get_versions(&self, name: &str) -> Result> { + let url = format!("{PUB_DEV_API_BASE}/packages/{name}"); + let data = self.cache.get_cached(&url).await?; + parse_versions_response(&data) + } + + pub async fn get_latest_matching( + &self, + name: &str, + req_str: &str, + ) -> Result> { + let versions = self.get_versions(name).await?; + Ok(versions.into_iter().find(|v| { + crate::version::version_matches_constraint(&v.version, req_str) && !v.retracted + })) + } + + pub async fn search(&self, query: &str, limit: usize) -> Result> { + let url = format!("{PUB_DEV_API_BASE}/search?q={}", urlencoding::encode(query)); + let data = self.cache.get_cached(&url).await?; + let search_result: SearchResponse = serde_json::from_slice(&data)?; + + let mut results = Vec::new(); + for entry in search_result.packages.into_iter().take(limit) { + // Fetch metadata for each package + let pkg_url = format!("{PUB_DEV_API_BASE}/packages/{}", entry.package); + if let Ok(pkg_data) = self.cache.get_cached(&pkg_url).await + && let Ok(info) = parse_package_info(&pkg_data) + { + results.push(info); + } + } + + Ok(results) + } + + pub async fn get_package_info(&self, name: &str) -> Result { + let url = format!("{PUB_DEV_API_BASE}/packages/{name}"); + let data = self.cache.get_cached(&url).await?; + parse_package_info(&data) + } +} + +#[derive(Deserialize)] +struct PackageResponse { + name: String, + latest: VersionDetail, + versions: Vec, +} + +#[derive(Deserialize)] +struct VersionEntry { + version: String, + #[serde(default)] + retracted: bool, + published: Option, +} + +#[derive(Deserialize)] +struct VersionDetail { + version: String, + pubspec: Option, +} + +#[derive(Deserialize)] +struct PubspecMeta { + name: Option, + description: Option, + homepage: Option, + repository: Option, + documentation: Option, +} + +#[derive(Deserialize)] +struct SearchResponse { + #[serde(default)] + packages: Vec, +} + +#[derive(Deserialize)] +struct SearchEntry { + package: String, +} + +fn parse_versions_response(data: &[u8]) -> Result> { + let response: PackageResponse = serde_json::from_slice(data)?; + + let mut versions: Vec = response + .versions + .into_iter() + .map(|e| DartVersion { + version: e.version, + retracted: e.retracted, + published: e.published, + }) + .collect(); + + versions.sort_by(|a, b| compare_versions(&b.version, &a.version)); + + Ok(versions) +} + +fn parse_package_info(data: &[u8]) -> Result { + let response: PackageResponse = serde_json::from_slice(data)?; + let pubspec = response.latest.pubspec.unwrap_or(PubspecMeta { + name: Some(response.name.clone()), + description: None, + homepage: None, + repository: None, + documentation: None, + }); + + Ok(PackageInfo { + name: pubspec.name.unwrap_or(response.name), + description: pubspec.description, + homepage: pubspec.homepage, + repository: pubspec.repository, + documentation: pubspec.documentation, + version: response.latest.version, + license: None, + }) +} + +// PackageRegistry trait +#[async_trait::async_trait] +impl deps_core::PackageRegistry for PubDevRegistry { + type Version = DartVersion; + type Metadata = PackageInfo; + type VersionReq = String; + + async fn get_versions(&self, name: &str) -> Result> { + self.get_versions(name).await + } + + async fn get_latest_matching( + &self, + name: &str, + req: &Self::VersionReq, + ) -> Result> { + self.get_latest_matching(name, req).await + } + + async fn search(&self, query: &str, limit: usize) -> Result> { + self.search(query, limit).await + } +} + +// VersionInfo trait +impl deps_core::VersionInfo for DartVersion { + fn version_string(&self) -> &str { + &self.version + } + + fn is_yanked(&self) -> bool { + self.retracted + } + + fn features(&self) -> Vec { + vec![] + } +} + +// PackageMetadata trait +impl deps_core::PackageMetadata for PackageInfo { + fn name(&self) -> &str { + &self.name + } + + fn description(&self) -> Option<&str> { + self.description.as_deref() + } + + fn repository(&self) -> Option<&str> { + self.repository.as_deref() + } + + fn documentation(&self) -> Option<&str> { + self.documentation.as_deref() + } + + fn latest_version(&self) -> &str { + &self.version + } +} + +// Registry trait (trait object support) +#[async_trait::async_trait] +impl deps_core::Registry for PubDevRegistry { + async fn get_versions(&self, name: &str) -> Result>> { + let versions = self.get_versions(name).await?; + Ok(versions + .into_iter() + .map(|v| Box::new(v) as Box) + .collect()) + } + + async fn get_latest_matching( + &self, + name: &str, + req: &str, + ) -> Result>> { + let version = self.get_latest_matching(name, req).await?; + Ok(version.map(|v| Box::new(v) as Box)) + } + + async fn search(&self, query: &str, limit: usize) -> Result>> { + let results = self.search(query, limit).await?; + Ok(results + .into_iter() + .map(|m| Box::new(m) as Box) + .collect()) + } + + fn package_url(&self, name: &str) -> String { + package_url(name) + } + + fn as_any(&self) -> &dyn Any { + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_package_url() { + assert_eq!(package_url("provider"), "https://pub.dev/packages/provider"); + assert_eq!(package_url("http"), "https://pub.dev/packages/http"); + } + + #[test] + fn test_parse_versions_response() { + let json = r#"{ + "name": "http", + "latest": {"version": "1.2.0", "pubspec": {"name": "http"}}, + "versions": [ + {"version": "1.0.0", "retracted": false}, + {"version": "1.2.0", "retracted": false}, + {"version": "1.1.0", "retracted": false}, + {"version": "0.9.0", "retracted": true} + ] + }"#; + + let versions = parse_versions_response(json.as_bytes()).unwrap(); + assert_eq!(versions.len(), 4); + assert_eq!(versions[0].version, "1.2.0"); + assert_eq!(versions[1].version, "1.1.0"); + assert_eq!(versions[2].version, "1.0.0"); + assert!(versions[3].retracted); + } + + #[test] + fn test_parse_versions_response_empty() { + let json = r#"{ + "name": "test", + "latest": {"version": "1.0.0"}, + "versions": [] + }"#; + let versions = parse_versions_response(json.as_bytes()).unwrap(); + assert!(versions.is_empty()); + } + + #[test] + fn test_parse_package_info() { + let json = r#"{ + "name": "provider", + "latest": { + "version": "6.1.2", + "pubspec": { + "name": "provider", + "description": "A wrapper around InheritedWidget", + "homepage": "https://pub.dev/packages/provider", + "repository": "https://github.com/rrousselGit/provider", + "documentation": "https://pub.dev/documentation/provider" + } + }, + "versions": [] + }"#; + + let info = parse_package_info(json.as_bytes()).unwrap(); + assert_eq!(info.name, "provider"); + assert_eq!( + info.description, + Some("A wrapper around InheritedWidget".into()) + ); + assert_eq!(info.version, "6.1.2"); + } + + #[test] + fn test_parse_package_info_minimal() { + let json = r#"{ + "name": "minimal", + "latest": {"version": "0.1.0"}, + "versions": [] + }"#; + + let info = parse_package_info(json.as_bytes()).unwrap(); + assert_eq!(info.name, "minimal"); + assert_eq!(info.version, "0.1.0"); + assert!(info.description.is_none()); + } + + #[test] + fn test_parse_search_response() { + let json = r#"{ + "packages": [ + {"package": "provider"}, + {"package": "riverpod"} + ] + }"#; + let response: SearchResponse = serde_json::from_slice(json.as_bytes()).unwrap(); + assert_eq!(response.packages.len(), 2); + assert_eq!(response.packages[0].package, "provider"); + } + + #[test] + fn test_registry_creation() { + let cache = Arc::new(HttpCache::new()); + let _registry = PubDevRegistry::new(cache); + } + + #[test] + fn test_version_info_trait() { + use deps_core::VersionInfo; + let ver = DartVersion { + version: "1.0.0".into(), + retracted: true, + published: None, + }; + assert_eq!(ver.version_string(), "1.0.0"); + assert!(ver.is_yanked()); + assert!(ver.features().is_empty()); + } + + #[test] + fn test_package_metadata_trait() { + use deps_core::PackageMetadata; + let info = PackageInfo { + name: "test".into(), + description: Some("A test package".into()), + homepage: None, + repository: Some("https://github.com/test/test".into()), + documentation: None, + version: "1.0.0".into(), + license: None, + }; + assert_eq!(info.name(), "test"); + assert_eq!(info.description(), Some("A test package")); + assert_eq!(info.repository(), Some("https://github.com/test/test")); + assert!(info.documentation().is_none()); + } + + #[test] + fn test_registry_package_url_trait() { + use deps_core::Registry; + let cache = Arc::new(HttpCache::new()); + let registry = PubDevRegistry::new(cache); + assert_eq!( + registry.package_url("http"), + "https://pub.dev/packages/http" + ); + } + + #[test] + fn test_registry_as_any() { + use deps_core::Registry; + let cache = Arc::new(HttpCache::new()); + let registry = PubDevRegistry::new(cache); + assert!(registry.as_any().is::()); + } +} diff --git a/crates/deps-dart/src/types.rs b/crates/deps-dart/src/types.rs new file mode 100644 index 00000000..da75f1f9 --- /dev/null +++ b/crates/deps-dart/src/types.rs @@ -0,0 +1,361 @@ +//! Domain types for Dart/Pub dependencies. + +use std::any::Any; +use tower_lsp_server::ls_types::Range; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DartDependency { + pub name: String, + pub name_range: Range, + pub version_req: Option, + pub version_range: Option, + pub section: DependencySection, + pub source: DependencySource, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum DependencySection { + #[default] + Dependencies, + DevDependencies, + DependencyOverrides, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum DependencySource { + #[default] + Hosted, + Git { + url: String, + ref_: Option, + path: Option, + }, + Path { + path: String, + }, + Sdk { + sdk: String, + }, +} + +#[derive(Debug, Clone)] +pub struct DartVersion { + pub version: String, + pub retracted: bool, + pub published: Option, +} + +#[derive(Debug, Clone)] +pub struct PackageInfo { + pub name: String, + pub description: Option, + pub homepage: Option, + pub repository: Option, + pub documentation: Option, + pub version: String, + pub license: Option, +} + +// deps-core trait implementations + +impl DartDependency { + fn core_source(&self) -> deps_core::parser::DependencySource { + match &self.source { + DependencySource::Hosted => deps_core::parser::DependencySource::Registry, + DependencySource::Git { url, ref_, .. } => deps_core::parser::DependencySource::Git { + url: url.clone(), + rev: ref_.clone(), + }, + DependencySource::Path { path } => { + deps_core::parser::DependencySource::Path { path: path.clone() } + } + DependencySource::Sdk { .. } => deps_core::parser::DependencySource::Registry, + } + } +} + +impl deps_core::DependencyInfo for DartDependency { + fn name(&self) -> &str { + &self.name + } + + fn name_range(&self) -> Range { + self.name_range + } + + fn version_requirement(&self) -> Option<&str> { + self.version_req.as_deref() + } + + fn version_range(&self) -> Option { + self.version_range + } + + fn source(&self) -> deps_core::parser::DependencySource { + self.core_source() + } + + fn features(&self) -> &[String] { + &[] + } +} + +impl deps_core::Dependency for DartDependency { + fn name(&self) -> &str { + &self.name + } + + fn name_range(&self) -> Range { + self.name_range + } + + fn version_requirement(&self) -> Option<&str> { + self.version_req.as_deref() + } + + fn version_range(&self) -> Option { + self.version_range + } + + fn source(&self) -> deps_core::parser::DependencySource { + self.core_source() + } + + fn features(&self) -> &[String] { + &[] + } + + fn as_any(&self) -> &dyn Any { + self + } +} + +impl deps_core::Version for DartVersion { + fn version_string(&self) -> &str { + &self.version + } + + fn is_yanked(&self) -> bool { + self.retracted + } + + fn features(&self) -> Vec { + vec![] + } + + fn as_any(&self) -> &dyn Any { + self + } +} + +impl deps_core::Metadata for PackageInfo { + fn name(&self) -> &str { + &self.name + } + + fn description(&self) -> Option<&str> { + self.description.as_deref() + } + + fn repository(&self) -> Option<&str> { + self.repository.as_deref() + } + + fn documentation(&self) -> Option<&str> { + self.documentation.as_deref() + } + + fn latest_version(&self) -> &str { + &self.version + } + + fn as_any(&self) -> &dyn Any { + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tower_lsp_server::ls_types::Position; + + fn test_dep(source: DependencySource) -> DartDependency { + DartDependency { + name: "flutter_bloc".into(), + name_range: Range::new(Position::new(5, 2), Position::new(5, 14)), + version_req: Some("^8.1.0".into()), + version_range: Some(Range::new(Position::new(5, 16), Position::new(5, 22))), + section: DependencySection::Dependencies, + source, + } + } + + #[test] + fn test_dependency_source_variants() { + assert!(matches!(DependencySource::Hosted, DependencySource::Hosted)); + assert!(matches!( + DependencySource::Git { + url: "u".into(), + ref_: None, + path: None + }, + DependencySource::Git { .. } + )); + assert!(matches!( + DependencySource::Path { path: "p".into() }, + DependencySource::Path { .. } + )); + assert!(matches!( + DependencySource::Sdk { + sdk: "flutter".into() + }, + DependencySource::Sdk { .. } + )); + } + + #[test] + fn test_dependency_section_default() { + assert!(matches!( + DependencySection::default(), + DependencySection::Dependencies + )); + } + + #[test] + fn test_dependency_trait() { + use deps_core::Dependency; + + let dep = test_dep(DependencySource::Hosted); + assert_eq!(dep.name(), "flutter_bloc"); + assert_eq!(dep.version_requirement(), Some("^8.1.0")); + assert!(dep.as_any().is::()); + } + + #[test] + fn test_dependency_info_source_hosted() { + use deps_core::DependencyInfo; + let dep = test_dep(DependencySource::Hosted); + assert!(matches!( + dep.source(), + deps_core::parser::DependencySource::Registry + )); + } + + #[test] + fn test_dependency_info_source_git() { + use deps_core::DependencyInfo; + let dep = test_dep(DependencySource::Git { + url: "https://github.com/test/repo".into(), + ref_: Some("main".into()), + path: None, + }); + match dep.source() { + deps_core::parser::DependencySource::Git { url, rev } => { + assert_eq!(url, "https://github.com/test/repo"); + assert_eq!(rev, Some("main".to_string())); + } + _ => panic!("Expected Git source"), + } + } + + #[test] + fn test_dependency_info_source_path() { + use deps_core::DependencyInfo; + let dep = test_dep(DependencySource::Path { + path: "../local".into(), + }); + match dep.source() { + deps_core::parser::DependencySource::Path { path } => { + assert_eq!(path, "../local"); + } + _ => panic!("Expected Path source"), + } + } + + #[test] + fn test_dependency_info_source_sdk() { + use deps_core::DependencyInfo; + let dep = test_dep(DependencySource::Sdk { + sdk: "flutter".into(), + }); + assert!(matches!( + dep.source(), + deps_core::parser::DependencySource::Registry + )); + } + + #[test] + fn test_version_trait() { + use deps_core::Version; + let ver = DartVersion { + version: "1.0.0".into(), + retracted: false, + published: Some("2024-01-01".into()), + }; + assert_eq!(ver.version_string(), "1.0.0"); + assert!(!ver.is_yanked()); + assert!(ver.features().is_empty()); + assert!(ver.as_any().is::()); + } + + #[test] + fn test_version_retracted() { + use deps_core::Version; + let ver = DartVersion { + version: "0.9.0".into(), + retracted: true, + published: None, + }; + assert!(ver.is_yanked()); + } + + #[test] + fn test_metadata_trait() { + use deps_core::Metadata; + let info = PackageInfo { + name: "provider".into(), + description: Some("A wrapper around InheritedWidget".into()), + homepage: Some("https://pub.dev/packages/provider".into()), + repository: Some("https://github.com/rrousselGit/provider".into()), + documentation: Some("https://pub.dev/documentation/provider".into()), + version: "6.1.2".into(), + license: Some("MIT".into()), + }; + assert_eq!(info.name(), "provider"); + assert!(info.description().is_some()); + assert_eq!(info.latest_version(), "6.1.2"); + assert!(info.as_any().is::()); + } + + #[test] + fn test_metadata_minimal() { + use deps_core::Metadata; + let info = PackageInfo { + name: "minimal".into(), + description: None, + homepage: None, + repository: None, + documentation: None, + version: "0.1.0".into(), + license: None, + }; + assert!(info.description().is_none()); + assert!(info.repository().is_none()); + assert!(info.documentation().is_none()); + } + + #[test] + fn test_dependency_without_version() { + use deps_core::Dependency; + let dep = DartDependency { + name: "test".into(), + name_range: Range::default(), + version_req: None, + version_range: None, + section: DependencySection::Dependencies, + source: DependencySource::Hosted, + }; + assert!(dep.version_requirement().is_none()); + assert!(dep.version_range().is_none()); + } +} diff --git a/crates/deps-dart/src/version.rs b/crates/deps-dart/src/version.rs new file mode 100644 index 00000000..699a6a7a --- /dev/null +++ b/crates/deps-dart/src/version.rs @@ -0,0 +1,182 @@ +//! Version comparison and constraint matching for Dart packages. + +use std::cmp::Ordering; + +pub fn compare_versions(a: &str, b: &str) -> Ordering { + let a_parts: Vec = a + .split('.') + .filter_map(|p| p.split(|c: char| !c.is_ascii_digit()).next()) + .filter_map(|p| p.parse().ok()) + .collect(); + let b_parts: Vec = b + .split('.') + .filter_map(|p| p.split(|c: char| !c.is_ascii_digit()).next()) + .filter_map(|p| p.parse().ok()) + .collect(); + + let max_len = a_parts.len().max(b_parts.len()); + for i in 0..max_len { + let ap = a_parts.get(i).copied().unwrap_or(0); + let bp = b_parts.get(i).copied().unwrap_or(0); + match ap.cmp(&bp) { + Ordering::Equal => {} + other => return other, + } + } + Ordering::Equal +} + +/// Checks if a version satisfies a Dart version constraint. +/// +/// Supports: ^, >=, >, <=, <, exact, any, and space-separated AND constraints. +pub fn version_matches_constraint(version: &str, constraint: &str) -> bool { + let constraint = constraint.trim(); + + if constraint.is_empty() || constraint == "any" { + return true; + } + + // Space-separated constraints are AND logic + if constraint.contains(' ') && !constraint.starts_with('^') { + return constraint + .split_whitespace() + .all(|c| match_single_constraint(version, c)); + } + + match_single_constraint(version, constraint) +} + +fn match_single_constraint(version: &str, constraint: &str) -> bool { + let constraint = constraint.trim(); + + if constraint.starts_with('^') { + let req_ver = constraint.trim_start_matches('^'); + return matches_caret(version, req_ver); + } + + if constraint.starts_with(">=") { + let req_ver = constraint.trim_start_matches(">=").trim(); + return compare_versions(version, req_ver) != Ordering::Less; + } + + if constraint.starts_with('>') { + let req_ver = constraint.trim_start_matches('>').trim(); + return compare_versions(version, req_ver) == Ordering::Greater; + } + + if constraint.starts_with("<=") { + let req_ver = constraint.trim_start_matches("<=").trim(); + return compare_versions(version, req_ver) != Ordering::Greater; + } + + if constraint.starts_with('<') { + let req_ver = constraint.trim_start_matches('<').trim(); + return compare_versions(version, req_ver) == Ordering::Less; + } + + // Exact match + compare_versions(version, constraint) == Ordering::Equal +} + +fn matches_caret(version: &str, requirement: &str) -> bool { + let req_parts: Vec = requirement + .split('.') + .filter_map(|p| p.parse().ok()) + .collect(); + let ver_parts: Vec = version + .split('.') + .filter_map(|p| p.split(|c: char| !c.is_ascii_digit()).next()) + .filter_map(|p| p.parse().ok()) + .collect(); + + if ver_parts.is_empty() || req_parts.is_empty() { + return false; + } + + if compare_versions(version, requirement) == Ordering::Less { + return false; + } + + let req_major = req_parts.first().copied().unwrap_or(0); + let ver_major = ver_parts.first().copied().unwrap_or(0); + + if req_major == 0 { + // ^0.x.y means >=0.x.y <0.(x+1).0 + let req_minor = req_parts.get(1).copied().unwrap_or(0); + let ver_minor = ver_parts.get(1).copied().unwrap_or(0); + ver_major == 0 && ver_minor == req_minor + } else { + // ^x.y.z means >=x.y.z <(x+1).0.0 + ver_major == req_major + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_compare_versions() { + assert_eq!(compare_versions("1.0.0", "1.0.0"), Ordering::Equal); + assert_eq!(compare_versions("1.0.1", "1.0.0"), Ordering::Greater); + assert_eq!(compare_versions("1.0.0", "1.0.1"), Ordering::Less); + assert_eq!(compare_versions("2.0.0", "1.9.9"), Ordering::Greater); + assert_eq!(compare_versions("1.0.0", "1.0"), Ordering::Equal); + } + + #[test] + fn test_caret_constraint() { + assert!(version_matches_constraint("1.0.0", "^1.0.0")); + assert!(version_matches_constraint("1.5.0", "^1.0.0")); + assert!(version_matches_constraint("1.99.99", "^1.0.0")); + assert!(!version_matches_constraint("2.0.0", "^1.0.0")); + assert!(!version_matches_constraint("0.9.0", "^1.0.0")); + } + + #[test] + fn test_caret_constraint_zero_major() { + // ^0.1.0 means >=0.1.0 <0.2.0 + assert!(version_matches_constraint("0.1.0", "^0.1.0")); + assert!(version_matches_constraint("0.1.5", "^0.1.0")); + assert!(!version_matches_constraint("0.2.0", "^0.1.0")); + assert!(!version_matches_constraint("0.99.0", "^0.1.0")); + assert!(!version_matches_constraint("1.0.0", "^0.1.0")); + } + + #[test] + fn test_range_constraint() { + assert!(version_matches_constraint("1.5.0", ">=1.0.0 <2.0.0")); + assert!(version_matches_constraint("1.0.0", ">=1.0.0 <2.0.0")); + assert!(!version_matches_constraint("2.0.0", ">=1.0.0 <2.0.0")); + assert!(!version_matches_constraint("0.9.0", ">=1.0.0 <2.0.0")); + } + + #[test] + fn test_exact_constraint() { + assert!(version_matches_constraint("1.0.0", "1.0.0")); + assert!(!version_matches_constraint("1.0.1", "1.0.0")); + } + + #[test] + fn test_any_constraint() { + assert!(version_matches_constraint("1.0.0", "any")); + assert!(version_matches_constraint("99.0.0", "any")); + assert!(version_matches_constraint("1.0.0", "")); + } + + #[test] + fn test_comparison_operators() { + assert!(version_matches_constraint("1.5.0", ">=1.0.0")); + assert!(version_matches_constraint("1.0.0", ">=1.0.0")); + assert!(!version_matches_constraint("0.9.0", ">=1.0.0")); + + assert!(version_matches_constraint("2.0.0", ">1.0.0")); + assert!(!version_matches_constraint("1.0.0", ">1.0.0")); + + assert!(version_matches_constraint("1.0.0", "<=1.0.0")); + assert!(!version_matches_constraint("1.1.0", "<=1.0.0")); + + assert!(version_matches_constraint("0.9.0", "<1.0.0")); + assert!(!version_matches_constraint("1.0.0", "<1.0.0")); + } +} diff --git a/crates/deps-lsp/Cargo.toml b/crates/deps-lsp/Cargo.toml index b467e7d9..1c5ac614 100644 --- a/crates/deps-lsp/Cargo.toml +++ b/crates/deps-lsp/Cargo.toml @@ -21,12 +21,13 @@ name = "deps_lsp" path = "src/lib.rs" [features] -default = ["cargo", "npm", "pypi", "go", "bundler"] +default = ["cargo", "npm", "pypi", "go", "bundler", "dart"] cargo = ["dep:deps-cargo"] npm = ["dep:deps-npm"] pypi = ["dep:deps-pypi"] go = ["dep:deps-go"] bundler = ["dep:deps-bundler"] +dart = ["dep:deps-dart"] [dependencies] # Internal crates @@ -36,6 +37,7 @@ deps-npm = { workspace = true, optional = true } deps-pypi = { workspace = true, optional = true } deps-go = { workspace = true, optional = true } deps-bundler = { workspace = true, optional = true } +deps-dart = { workspace = true, optional = true } # External dependencies dashmap = { workspace = true } diff --git a/crates/deps-lsp/src/lib.rs b/crates/deps-lsp/src/lib.rs index cfeb6015..a2c85c76 100644 --- a/crates/deps-lsp/src/lib.rs +++ b/crates/deps-lsp/src/lib.rs @@ -108,6 +108,22 @@ ecosystem!( ] ); +ecosystem!( + "dart", + deps_dart, + DartEcosystem, + [ + DartDependency, + DartParseResult, + DartVersion, + DartFormatter, + PackageInfo, + PubDevRegistry, + PubspecLockParser, + parse_pubspec_yaml, + ] +); + /// Registers all enabled ecosystems. pub fn register_ecosystems(registry: &EcosystemRegistry, cache: Arc) { register!("cargo", CargoEcosystem, registry, &cache); @@ -115,6 +131,7 @@ pub fn register_ecosystems(registry: &EcosystemRegistry, cache: Arc) register!("pypi", PypiEcosystem, registry, &cache); register!("go", GoEcosystem, registry, &cache); register!("bundler", BundlerEcosystem, registry, &cache); + register!("dart", DartEcosystem, registry, &cache); } #[cfg(test)] @@ -137,5 +154,7 @@ mod tests { assert!(registry.get("go").is_some()); #[cfg(feature = "bundler")] assert!(registry.get("bundler").is_some()); + #[cfg(feature = "dart")] + assert!(registry.get("dart").is_some()); } }