diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e497c6f..598d675d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,13 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Gradle ecosystem support** — New `deps-gradle` crate with support for three manifest formats - - Version Catalog parser (`gradle/libs.versions.toml`) via toml_edit with position tracking + - Version Catalog parser (`gradle/libs.versions.toml`) via toml-span with reliable span tracking - Kotlin DSL parser (`build.gradle.kts`) via regex - Groovy DSL parser (`build.gradle`) via regex - Reuses `MavenCentralRegistry` from deps-maven (no registry duplication) - Parses `[versions]`, `[libraries]` sections with `version.ref` resolution - Recognizes all Gradle configurations: implementation, api, compileOnly, runtimeOnly, testImplementation, etc. - Feature-gated registration in deps-lsp (`gradle`) +- **Google Maven repository support** — Android packages (`androidx.*`, `com.google.firebase.*`, `com.google.android.*`, `com.android.*`) now resolve from Google Maven instead of Maven Central ### Changed - Extract `LineOffsetTable` and `position_in_range` to deps-core for reuse across ecosystems diff --git a/Cargo.toml b/Cargo.toml index 8766facb..ed640e5f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,7 @@ thiserror = "2" tokio = "1.49" tokio-test = "0.4" toml_edit = "0.25" +toml-span = "0.7" tower-lsp-server = "0.23" tracing = "0.1" tracing-subscriber = "0.3" diff --git a/crates/deps-core/src/lsp_helpers.rs b/crates/deps-core/src/lsp_helpers.rs index bf9b8648..8f66aba3 100644 --- a/crates/deps-core/src/lsp_helpers.rs +++ b/crates/deps-core/src/lsp_helpers.rs @@ -44,21 +44,6 @@ impl LineOffsetTable { Self { line_starts } } - /// Returns the byte offset of the start of the given 0-based line. - pub fn line_start_offset(&self, line: u32) -> usize { - self.line_starts.get(line as usize).copied().unwrap_or(0) - } - - /// Returns the byte offset of the end of the given 0-based line (before newline). - pub fn line_end_offset(&self, content: &str, line: u32) -> usize { - let next_line = line as usize + 1; - if next_line < self.line_starts.len() { - self.line_starts[next_line].saturating_sub(1) - } else { - content.len() - } - } - /// Converts a byte offset into an LSP `Position`. pub fn byte_offset_to_position(&self, content: &str, offset: usize) -> Position { let offset = offset.min(content.len()); diff --git a/crates/deps-gradle/Cargo.toml b/crates/deps-gradle/Cargo.toml index 45c3b6cf..f7d5983d 100644 --- a/crates/deps-gradle/Cargo.toml +++ b/crates/deps-gradle/Cargo.toml @@ -19,7 +19,7 @@ async-trait = { workspace = true } regex = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } -toml_edit = { workspace = true } +toml-span = { workspace = true } tower-lsp-server = { workspace = true } tracing = { workspace = true } diff --git a/crates/deps-gradle/src/parser/catalog.rs b/crates/deps-gradle/src/parser/catalog.rs index 64fd1c61..5a2245ca 100644 --- a/crates/deps-gradle/src/parser/catalog.rs +++ b/crates/deps-gradle/src/parser/catalog.rs @@ -1,53 +1,47 @@ //! Parser for Gradle Version Catalog (gradle/libs.versions.toml). //! -//! Handles \[versions\], \[libraries\] sections with position tracking via toml_edit spans. +//! Handles \[versions\], \[libraries\] sections with position tracking via toml-span. use crate::error::{GradleError, Result}; use crate::parser::{GradleParseResult, LineOffsetTable}; use crate::types::GradleDependency; use std::collections::HashMap; -use toml_edit::DocumentMut; +use toml_span::value::{Table, Value}; use tower_lsp_server::ls_types::{Range, Uri}; pub fn parse_version_catalog(content: &str, uri: &Uri) -> Result { - let doc: DocumentMut = - content - .parse() - .map_err(|e: toml_edit::TomlError| GradleError::ParseError { - message: e.to_string(), - })?; + let doc = toml_span::parse(content).map_err(|e| GradleError::ParseError { + message: e.to_string(), + })?; let line_table = LineOffsetTable::new(content); let mut version_refs: HashMap = HashMap::new(); // Collect [versions] section: key -> version string - if let Some(versions_item) = doc.get("versions") - && let Some(versions_table) = versions_item.as_table() + if let Some(versions_table) = doc.as_table().and_then(|t| get_table_val(t, "versions")) + && let Some(t) = versions_table.as_table() { - for (key, item) in versions_table { + for (key, item) in t { if let Some(ver_str) = item.as_str() { - version_refs.insert(key.to_string(), ver_str.to_string()); + version_refs.insert(key.name.to_string(), ver_str.to_string()); } } } let mut dependencies = Vec::new(); - // Parse [libraries] section - let Some(libs_item) = doc.get("libraries") else { - return Ok(GradleParseResult { - dependencies, - uri: uri.clone(), - }); - }; - let Some(libs_table) = libs_item.as_table() else { + let Some(libs_table) = doc + .as_table() + .and_then(|t| get_table_val(t, "libraries")) + .and_then(|v| v.as_table()) + else { return Ok(GradleParseResult { dependencies, uri: uri.clone(), }); }; - for (_alias, item) in libs_table { + for item in libs_table.values() { let Some(dep) = parse_library_entry(item, content, &line_table, &version_refs) else { continue; }; @@ -60,55 +54,20 @@ pub fn parse_version_catalog(content: &str, uri: &Uri) -> Result, -) -> Option { - if let Some(inline) = item.as_inline_table() { - return parse_from_inline(inline, content, line_table, version_refs); - } - if let Some(table) = item.as_table() { - return parse_from_table(table, content, line_table, version_refs); - } - None -} - -fn parse_from_inline( - table: &toml_edit::InlineTable, - content: &str, - line_table: &LineOffsetTable, - version_refs: &HashMap, -) -> Option { - let (group_id, artifact_id, name, name_range) = - extract_coordinates_inline(table, content, line_table)?; - let hint_line = name_range.start.line; - let (version_req, version_range) = - extract_version_inline(table, content, line_table, version_refs, hint_line); - - Some(GradleDependency { - group_id, - artifact_id, - name, - name_range, - version_req, - version_range, - configuration: String::new(), - }) +fn get_table_val<'a>(table: &'a Table<'a>, key: &str) -> Option<&'a Value<'a>> { + table.get(key) } -fn parse_from_table( - table: &toml_edit::Table, +fn parse_library_entry( + item: &Value<'_>, content: &str, line_table: &LineOffsetTable, version_refs: &HashMap, ) -> Option { + let table = item.as_table()?; let (group_id, artifact_id, name, name_range) = - extract_coordinates_table(table, content, line_table)?; - let hint_line = name_range.start.line; - let (version_req, version_range) = - extract_version_table(table, content, line_table, version_refs, hint_line); + extract_coordinates(table, content, line_table)?; + let (version_req, version_range) = extract_version(table, content, line_table, version_refs); Some(GradleDependency { group_id, @@ -121,22 +80,14 @@ fn parse_from_table( }) } -fn extract_coordinates_inline( - table: &toml_edit::InlineTable, +fn extract_coordinates<'a>( + table: &'a Table<'a>, content: &str, line_table: &LineOffsetTable, ) -> Option<(String, String, String, Range)> { - let parent_span = table.span(); - if let Some(module_val) = table.get("module") { + if let Some(module_val) = get_table_val(table, "module") { let module_str = module_val.as_str()?; - let name_range = span_to_range_or_fallback( - content, - line_table, - module_val.span(), - parent_span, - "module", - module_str, - ); + let name_range = span_to_range(content, line_table, module_val.span); let (g, a) = module_str.split_once(':')?; return Some(( g.to_string(), @@ -145,270 +96,50 @@ fn extract_coordinates_inline( name_range, )); } - let group_val = table.get("group")?; - let name_val = table.get("name")?; + let group_val = get_table_val(table, "group")?; + let name_val = get_table_val(table, "name")?; let g = group_val.as_str()?.to_string(); let a = name_val.as_str()?.to_string(); let name_str = format!("{g}:{a}"); - let name_range = span_to_range_or_fallback( - content, - line_table, - name_val.span(), - parent_span, - "name", - &a, - ); - Some((g, a, name_str, name_range)) -} - -fn extract_coordinates_table( - table: &toml_edit::Table, - content: &str, - line_table: &LineOffsetTable, -) -> Option<(String, String, String, Range)> { - let parent_span = table.span(); - if let Some(module_item) = table.get("module") { - let module_str = module_item.as_str()?; - let name_range = span_to_range_or_fallback( - content, - line_table, - module_item.span(), - parent_span, - "module", - module_str, - ); - let (g, a) = module_str.split_once(':')?; - return Some(( - g.to_string(), - a.to_string(), - module_str.to_string(), - name_range, - )); - } - let group_item = table.get("group")?; - let name_item = table.get("name")?; - let g = group_item.as_str()?.to_string(); - let a = name_item.as_str()?.to_string(); - let name_str = format!("{g}:{a}"); - let name_range = span_to_range_or_fallback( - content, - line_table, - name_item.span(), - parent_span, - "name", - &a, - ); + let name_range = span_to_range(content, line_table, name_val.span); Some((g, a, name_str, name_range)) } -fn extract_version_inline( - table: &toml_edit::InlineTable, +fn extract_version( + table: &Table<'_>, content: &str, line_table: &LineOffsetTable, version_refs: &HashMap, - hint_line: u32, ) -> (Option, Option) { - let Some(version_val) = table.get("version") else { + let Some(version_val) = get_table_val(table, "version") else { return (None, None); }; if let Some(ver_str) = version_val.as_str() { - let range = span_to_range_with_hint( - content, - line_table, - version_val.span(), - "version", - ver_str, - hint_line, - ); + let range = span_to_range(content, line_table, version_val.span); return (Some(ver_str.to_string()), Some(range)); } - if let Some(version_table) = version_val.as_inline_table() - && let Some(ref_val) = version_table.get("ref") + // version.ref = "alias" — toml-span represents dotted keys as nested tables + if let Some(version_table) = version_val.as_table() + && let Some(ref_val) = get_table_val(version_table, "ref") && let Some(ref_key) = ref_val.as_str() { let resolved = version_refs.get(ref_key).cloned(); - let range = span_to_range_with_hint( - content, - line_table, - ref_val.span(), - "ref", - ref_key, - hint_line, - ); + let range = span_to_range(content, line_table, ref_val.span); return (resolved, Some(range)); } (None, None) } -fn extract_version_table( - table: &toml_edit::Table, - content: &str, - line_table: &LineOffsetTable, - version_refs: &HashMap, - hint_line: u32, -) -> (Option, Option) { - let Some(version_item) = table.get("version") else { - return (None, None); - }; - - if let Some(ver_str) = version_item.as_str() { - let range = span_to_range_with_hint( - content, - line_table, - version_item.span(), - "version", - ver_str, - hint_line, - ); - return (Some(ver_str.to_string()), Some(range)); - } - - if let Some(version_table) = version_item.as_table() - && let Some(ref_item) = version_table.get("ref") - && let Some(ref_key) = ref_item.as_str() - { - let resolved = version_refs.get(ref_key).cloned(); - let range = span_to_range_with_hint( - content, - line_table, - ref_item.span(), - "ref", - ref_key, - hint_line, - ); - return (resolved, Some(range)); - } - - if let Some(version_table) = version_item.as_inline_table() - && let Some(ref_val) = version_table.get("ref") - && let Some(ref_key) = ref_val.as_str() - { - let resolved = version_refs.get(ref_key).cloned(); - let range = span_to_range_with_hint( - content, - line_table, - ref_val.span(), - "ref", - ref_key, - hint_line, - ); - return (resolved, Some(range)); - } - - (None, None) -} - -fn span_to_range_or_fallback( - content: &str, - line_table: &LineOffsetTable, - span: Option>, - _parent_span: Option>, - key: &str, - value: &str, -) -> Range { - if span.is_some() { - return span_to_range(content, line_table, span); - } - find_value_in_content(content, line_table, key, value) -} - -fn span_to_range_with_hint( - content: &str, - line_table: &LineOffsetTable, - span: Option>, - key: &str, - value: &str, - hint_line: u32, -) -> Range { - if span.is_some() { - return span_to_range(content, line_table, span); - } - find_value_on_line(content, line_table, key, value, hint_line) -} - -fn span_to_range( - content: &str, - line_table: &LineOffsetTable, - span: Option>, -) -> Range { - let Some(span) = span else { - return Range::default(); - }; - let (start_off, end_off) = strip_quotes(content, span.start, span.end); - let start = line_table.byte_offset_to_position(content, start_off); - let end = line_table.byte_offset_to_position(content, end_off); +fn span_to_range(content: &str, line_table: &LineOffsetTable, span: toml_span::Span) -> Range { + // toml-span string spans already exclude surrounding quotes + let start = line_table.byte_offset_to_position(content, span.start); + let end = line_table.byte_offset_to_position(content, span.end); Range::new(start, end) } -/// Find the first occurrence of a quoted value by key in the entire content. -/// Used for unique values like module/name coordinates. -fn find_value_in_content( - content: &str, - line_table: &LineOffsetTable, - key: &str, - value: &str, -) -> Range { - let needle = format!("\"{value}\""); - let mut search_from = 0; - while let Some(key_pos) = content[search_from..].find(key) { - let abs_key = search_from + key_pos; - let after_key = &content[abs_key..]; - let line_end = after_key.find('\n').unwrap_or(after_key.len()); - if let Some(val_offset) = after_key[..line_end].find(&needle) { - let abs_start = abs_key + val_offset + 1; - 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 Range::new(start, end); - } - search_from = abs_key + key.len(); - } - Range::default() -} - -/// Find the range of a quoted value on a specific line by text search. -/// `hint_line` constrains the search to a specific 0-based line number. -fn find_value_on_line( - content: &str, - line_table: &LineOffsetTable, - key: &str, - value: &str, - hint_line: u32, -) -> Range { - let line_start = line_table.line_start_offset(hint_line); - let line_end = line_table.line_end_offset(content, hint_line); - let line_slice = &content[line_start..line_end]; - let needle = format!("\"{value}\""); - if let Some(key_pos) = line_slice.find(key) - && let Some(val_offset) = line_slice[key_pos..].find(&needle) - { - let abs_start = line_start + key_pos + val_offset + 1; - 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 Range::new(start, end); - } - Range::default() -} - -/// If the byte range in `content` is a quoted string, returns the inner range (excluding quotes). -fn strip_quotes(content: &str, start: usize, end: usize) -> (usize, usize) { - if start >= content.len() || end > content.len() || start >= end { - return (start, end); - } - let slice = &content[start..end]; - if (slice.starts_with('"') && slice.ends_with('"')) - || (slice.starts_with('\'') && slice.ends_with('\'')) - { - (start + 1, end - 1) - } else { - (start, end) - } -} - #[cfg(test)] mod tests { use super::*; @@ -491,13 +222,15 @@ guava = { module = "com.google.guava:guava", version.ref = "guava" } #[test] fn test_strip_quotes() { - let content = "\"hello\""; - let (s, e) = strip_quotes(content, 0, content.len()); - assert_eq!(&content[s..e], "hello"); - - let content = "plain"; - let (s, e) = strip_quotes(content, 0, content.len()); - assert_eq!(&content[s..e], "plain"); + // toml-span handles quote stripping internally; this test verifies that + // string values returned via as_str() don't include surrounding quotes. + let content = "[libraries]\njunit = { module = \"junit:junit\", version = \"4.13.2\" }\n"; + let result = parse_version_catalog(content, &make_uri()).unwrap(); + assert_eq!(result.dependencies[0].name, "junit:junit"); + assert_eq!( + result.dependencies[0].version_req, + Some("4.13.2".to_string()) + ); } #[test] diff --git a/crates/deps-maven/src/registry.rs b/crates/deps-maven/src/registry.rs index dcf9bb84..1e86f7d3 100644 --- a/crates/deps-maven/src/registry.rs +++ b/crates/deps-maven/src/registry.rs @@ -13,15 +13,45 @@ use std::any::Any; use std::sync::Arc; const MAVEN_REPO_BASE: &str = "https://repo1.maven.org/maven2"; +const GOOGLE_MAVEN_BASE: &str = "https://dl.google.com/dl/android/maven2"; const MAVEN_SEARCH_BASE: &str = "https://search.maven.org/solrsearch/select"; +const GOOGLE_PREFIXES: &[&str] = &[ + "androidx.", + "com.google.firebase.", + "com.google.android.", + "com.google.gms.", + "com.android.", +]; + +fn is_google_group(group_id: &str) -> bool { + GOOGLE_PREFIXES.iter().any(|p| group_id.starts_with(p)) +} + +fn repo_base_for_group(group_id: &str) -> &'static str { + if is_google_group(group_id) { + GOOGLE_MAVEN_BASE + } else { + MAVEN_REPO_BASE + } +} + pub fn package_url(name: &str) -> String { let parts: Vec<&str> = name.splitn(2, ':').collect(); if parts.len() == 2 { - format!( - "https://central.sonatype.com/artifact/{}/{}", - parts[0], parts[1] - ) + let group_id = parts[0]; + let artifact_id = parts[1]; + if is_google_group(group_id) { + format!( + "https://maven.google.com/web/index.html#{}:{}", + group_id, artifact_id + ) + } else { + format!( + "https://central.sonatype.com/artifact/{}/{}", + group_id, artifact_id + ) + } } else { format!( "https://central.sonatype.com/search?q={}", @@ -86,12 +116,13 @@ impl MavenCentralRegistry { } } -/// Converts `groupId:artifactId` to maven-metadata.xml URL. +/// Converts `groupId:artifactId` to maven-metadata.xml URL, routing to the correct repository. fn metadata_url(name: &str) -> Option { let (group_id, artifact_id) = name.split_once(':')?; + let base = repo_base_for_group(group_id); let group_path = group_id.replace('.', "/"); Some(format!( - "{MAVEN_REPO_BASE}/{group_path}/{artifact_id}/maven-metadata.xml" + "{base}/{group_path}/{artifact_id}/maven-metadata.xml" )) } @@ -224,13 +255,51 @@ mod tests { use super::*; #[test] - fn test_package_url() { + fn test_repo_base_for_group_central() { + assert_eq!(repo_base_for_group("org.apache.commons"), MAVEN_REPO_BASE); + assert_eq!(repo_base_for_group("com.example"), MAVEN_REPO_BASE); + // com.google.protobuf is on Maven Central, not Google Maven + assert_eq!(repo_base_for_group("com.google.protobuf"), MAVEN_REPO_BASE); + } + + #[test] + fn test_repo_base_for_group_google() { + assert_eq!(repo_base_for_group("androidx.core"), GOOGLE_MAVEN_BASE); + assert_eq!( + repo_base_for_group("com.google.firebase.crashlytics"), + GOOGLE_MAVEN_BASE + ); + assert_eq!( + repo_base_for_group("com.google.android.gms"), + GOOGLE_MAVEN_BASE + ); + assert_eq!( + repo_base_for_group("com.google.gms.google-services"), + GOOGLE_MAVEN_BASE + ); + assert_eq!(repo_base_for_group("com.android.tools"), GOOGLE_MAVEN_BASE); + } + + #[test] + fn test_package_url_central() { assert_eq!( package_url("org.apache.commons:commons-lang3"), "https://central.sonatype.com/artifact/org.apache.commons/commons-lang3" ); } + #[test] + fn test_package_url_google() { + assert_eq!( + package_url("androidx.core:core-ktx"), + "https://maven.google.com/web/index.html#androidx.core:core-ktx" + ); + assert_eq!( + package_url("com.google.firebase.crashlytics:firebase-crashlytics"), + "https://maven.google.com/web/index.html#com.google.firebase.crashlytics:firebase-crashlytics" + ); + } + #[test] fn test_package_url_no_colon() { let url = package_url("bad"); @@ -238,13 +307,28 @@ mod tests { } #[test] - fn test_metadata_url() { + fn test_metadata_url_central() { assert_eq!( metadata_url("org.apache.commons:commons-lang3"), Some("https://repo1.maven.org/maven2/org/apache/commons/commons-lang3/maven-metadata.xml".into()) ); } + #[test] + fn test_metadata_url_google() { + assert_eq!( + metadata_url("androidx.core:core-ktx"), + Some( + "https://dl.google.com/dl/android/maven2/androidx/core/core-ktx/maven-metadata.xml" + .into() + ) + ); + assert_eq!( + metadata_url("com.google.firebase.crashlytics:firebase-crashlytics"), + Some("https://dl.google.com/dl/android/maven2/com/google/firebase/crashlytics/firebase-crashlytics/maven-metadata.xml".into()) + ); + } + #[test] fn test_metadata_url_no_colon() { assert_eq!(metadata_url("bad"), None);