use std::any::Any;
use std::pin::Pin;
use std::sync::Arc;
use tower_lsp_server::ls_types::{
CodeAction, CodeLens, Diagnostic, DocumentLink, Hover, InlayHint, Position, TextEdit, Uri,
};
use crate::{
Registry,
completion::Completions,
lsp_helpers::{EcosystemFormatter, VersionData},
registry::Metadata,
};
#[doc(hidden)]
pub mod private {
pub trait Sealed {}
}
pub type BoxFuture<'a, T> = Pin<Box<dyn std::future::Future<Output = T> + Send + 'a>>;
pub async fn parse_manifest_blocking(
ecosystem: &Arc<dyn Ecosystem>,
content: &str,
uri: &Uri,
) -> crate::error::Result<Box<dyn ParseResult>> {
let ecosystem = Arc::clone(ecosystem);
let owned_content = content.to_owned();
let owned_uri = uri.clone();
let handle = tokio::runtime::Handle::current();
let parsed = tokio::task::spawn_blocking(move || {
handle.block_on(ecosystem.parse_manifest(&owned_content, &owned_uri))
})
.await
.map_err(|e| crate::error::DepsError::ParseError {
file_type: format!("manifest at {uri:?}"),
source: Box::new(std::io::Error::other(e)),
})??;
Ok(crate::dependency_cap::cap_dependencies(
parsed,
crate::dependency_cap::MAX_DEPENDENCIES_PER_DOCUMENT,
))
}
macro_rules! ecosystem_ids {
( $( $(#[$vmeta:meta])* $variant:ident => $id:literal ),+ $(,)? ) => {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum EcosystemId {
$( $(#[$vmeta])* $variant, )+
}
impl EcosystemId {
pub const ALL: &'static [Self] = &[ $( Self::$variant ),+ ];
#[must_use]
pub const fn id(self) -> &'static str {
match self {
$( Self::$variant => $id, )+
}
}
}
impl std::str::FromStr for EcosystemId {
type Err = crate::error::DepsError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
$( $id => Ok(Self::$variant), )+
_ => Err(crate::error::DepsError::UnsupportedEcosystem(s.to_string())),
}
}
}
};
}
ecosystem_ids! {
Cargo => "cargo",
Npm => "npm",
Pypi => "pypi",
Go => "go",
Bundler => "bundler",
Dart => "dart",
Maven => "maven",
Composer => "composer",
Gradle => "gradle",
Swift => "swift",
NuGet => "nuget",
Deno => "deno",
GithubActions => "github-actions",
GitlabCi => "gitlab-ci",
}
impl EcosystemId {
#[must_use]
pub const fn osv_ecosystem(self) -> Option<&'static str> {
match self {
Self::Cargo => Some("crates.io"),
Self::Npm | Self::Deno => Some("npm"),
Self::Pypi => Some("PyPI"),
Self::Go => Some("Go"),
Self::Bundler => Some("RubyGems"),
Self::Dart => Some("Pub"),
Self::Maven | Self::Gradle => Some("Maven"),
Self::Composer => Some("Packagist"),
Self::Swift => Some("SwiftURL"),
Self::NuGet => Some("NuGet"),
Self::GithubActions => Some("GitHub Actions"),
Self::GitlabCi => None,
}
}
}
impl std::fmt::Display for EcosystemId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.id())
}
}
#[derive(Debug, Clone)]
pub struct BlockedRegistryOccurrence {
pub range: tower_lsp_server::ls_types::Range,
pub class: crate::net_policy::HostClass,
pub raw_value: String,
pub declaration_key: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BlockedSourceClass {
pub class: crate::net_policy::HostClass,
pub raw_value: String,
pub declaration_key: String,
}
impl BlockedSourceClass {
#[must_use]
pub fn into_occurrence(
self,
range: tower_lsp_server::ls_types::Range,
) -> BlockedRegistryOccurrence {
BlockedRegistryOccurrence {
range,
class: self.class,
raw_value: self.raw_value,
declaration_key: self.declaration_key,
}
}
}
pub trait ParseResult: Send + Sync {
fn dependencies(&self) -> Vec<&dyn Dependency>;
fn workspace_root(&self) -> Option<&std::path::Path>;
fn uri(&self) -> &Uri;
fn blocked_registries(&self) -> Vec<BlockedRegistryOccurrence> {
Vec::new()
}
fn as_any(&self) -> &dyn Any;
fn dependency_truncation(&self) -> Option<(usize, usize)> {
None
}
}
pub trait Dependency: Send + Sync {
fn name(&self) -> &crate::PackageName;
fn name_range(&self) -> tower_lsp_server::ls_types::Range;
fn version_requirement(&self) -> Option<&crate::VersionReq>;
fn version_range(&self) -> Option<tower_lsp_server::ls_types::Range>;
fn source(&self) -> crate::parser::DependencySource;
fn features(&self) -> &[String] {
&[]
}
fn features_range(&self) -> Option<tower_lsp_server::ls_types::Range> {
None
}
fn markers(&self) -> Option<&str> {
None
}
fn markers_range(&self) -> Option<tower_lsp_server::ls_types::Range> {
None
}
fn version_literal(&self) -> Option<&str> {
None
}
fn name_range_is_synthetic(&self) -> bool {
false
}
fn as_any(&self) -> &dyn Any;
}
#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct EcosystemConfig {
pub show_up_to_date_hints: bool,
pub up_to_date_text: String,
pub needs_update_text: String,
pub loading_text: String,
pub show_loading_hints: bool,
pub offline: bool,
}
impl Default for EcosystemConfig {
fn default() -> Self {
Self::new()
}
}
impl EcosystemConfig {
#[must_use]
pub fn new() -> Self {
Self {
show_up_to_date_hints: true,
up_to_date_text: "✅".to_string(),
needs_update_text: "❌ {}".to_string(),
loading_text: "⏳".to_string(),
show_loading_hints: true,
offline: false,
}
}
#[must_use]
pub const fn with_show_up_to_date_hints(mut self, show_up_to_date_hints: bool) -> Self {
self.show_up_to_date_hints = show_up_to_date_hints;
self
}
#[must_use]
pub fn with_up_to_date_text(mut self, up_to_date_text: impl Into<String>) -> Self {
self.up_to_date_text = up_to_date_text.into();
self
}
#[must_use]
pub fn with_needs_update_text(mut self, needs_update_text: impl Into<String>) -> Self {
self.needs_update_text = needs_update_text.into();
self
}
#[must_use]
pub fn with_loading_text(mut self, loading_text: impl Into<String>) -> Self {
self.loading_text = loading_text.into();
self
}
#[must_use]
pub const fn with_show_loading_hints(mut self, show_loading_hints: bool) -> Self {
self.show_loading_hints = show_loading_hints;
self
}
#[must_use]
pub const fn with_offline(mut self, offline: bool) -> Self {
self.offline = offline;
self
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LicenseSource {
#[default]
RegistryDeclaredSpdx,
FetchedDeclaredSpdx,
DetectedSpdx,
PomFreeText,
}
impl LicenseSource {
#[must_use]
pub const fn requires_dedicated_fetch(self) -> bool {
match self {
Self::RegistryDeclaredSpdx => false,
Self::FetchedDeclaredSpdx | Self::DetectedSpdx | Self::PomFreeText => true,
}
}
}
pub trait Ecosystem: Send + Sync + private::Sealed {
fn ecosystem_id(&self) -> EcosystemId;
fn id(&self) -> &'static str {
self.ecosystem_id().id()
}
fn display_name(&self) -> &'static str;
fn manifest_filenames(&self) -> &[&'static str];
fn manifest_extensions(&self) -> &[&'static str] {
&[]
}
fn manifest_patterns(&self) -> &[&'static str] {
&[]
}
fn manifest_directory_patterns(&self) -> &[(&'static str, &'static str)] {
&[]
}
fn lockfile_filenames(&self) -> &[&'static str] {
&[]
}
fn watched_config_filenames(&self) -> &[&'static str] {
&[]
}
fn parse_manifest<'a>(
&'a self,
content: &'a str,
uri: &'a Uri,
) -> BoxFuture<'a, crate::error::Result<Box<dyn ParseResult>>>;
fn registry(&self) -> Arc<dyn Registry>;
fn lockfile_provider(&self) -> Option<Arc<dyn crate::lockfile::LockFileProvider>> {
None
}
fn formatter(&self) -> &dyn EcosystemFormatter;
fn generate_inlay_hints<'a>(
&'a self,
parse_result: &'a dyn ParseResult,
versions: VersionData<'a>,
loading_state: crate::LoadingState,
config: &'a EcosystemConfig,
) -> BoxFuture<'a, Vec<InlayHint>> {
Box::pin(async move {
crate::lsp_helpers::generate_inlay_hints(
parse_result,
versions,
loading_state,
config,
self.formatter(),
)
})
}
fn generate_hover<'a>(
&'a self,
parse_result: &'a dyn ParseResult,
position: Position,
versions: VersionData<'a>,
freshness: crate::freshness::FreshnessSettings,
) -> BoxFuture<'a, Option<Hover>> {
Box::pin(async move {
let registry = self.registry();
crate::lsp_helpers::generate_hover(
parse_result,
position,
versions,
registry.as_ref(),
self.formatter(),
freshness,
crate::freshness::PublishTime::now(),
)
.await
})
}
fn generate_code_actions<'a>(
&'a self,
parse_result: &'a dyn ParseResult,
position: Position,
uri: &'a Uri,
versions: VersionData<'a>,
content: &'a str,
) -> BoxFuture<'a, Vec<CodeAction>> {
Box::pin(async move {
let registry = self.registry();
crate::lsp_helpers::generate_code_actions(
parse_result,
position,
uri,
versions,
content,
registry.as_ref(),
self.formatter(),
)
.await
})
}
fn generate_diagnostics<'a>(
&'a self,
parse_result: &'a dyn ParseResult,
versions: VersionData<'a>,
uri: &'a Uri,
freshness: crate::freshness::FreshnessSettings,
severities: crate::lsp_helpers::DiagnosticSeverities,
) -> BoxFuture<'a, Vec<Diagnostic>> {
Box::pin(async move {
crate::lsp_helpers::generate_diagnostics_from_cache(
parse_result,
versions,
self.formatter(),
uri,
freshness,
severities,
crate::freshness::PublishTime::now(),
)
})
}
fn generate_document_links(
&self,
_parse_result: &dyn ParseResult,
_uri: &Uri,
) -> Vec<DocumentLink> {
Vec::new()
}
fn generate_code_lenses<'a>(
&'a self,
parse_result: &'a dyn ParseResult,
content: &'a str,
versions: VersionData<'a>,
uri: &'a Uri,
command_id: &'a str,
) -> BoxFuture<'a, Vec<CodeLens>> {
Box::pin(async move {
crate::lsp_helpers::generate_code_lenses(
parse_result,
content,
versions,
self.formatter(),
uri,
command_id,
)
})
}
fn prepare_completions(&self) {}
fn generate_completions<'a>(
&'a self,
parse_result: &'a dyn ParseResult,
position: Position,
content: &'a str,
freshness: crate::FreshnessSettings,
) -> BoxFuture<'a, Completions> {
Box::pin(async move {
self.prepare_completions();
let request =
crate::completion::CompletionRequest::new(parse_result, position, freshness);
match crate::completion::detect_completion_context(parse_result, position, content) {
crate::completion::CompletionContext::PackageName { prefix, range } => {
self.complete_package_name(request, prefix, range).await
}
crate::completion::CompletionContext::Version {
package_name,
prefix,
} => self.complete_version(request, package_name, prefix).await,
crate::completion::CompletionContext::Feature {
package_name,
prefix,
} => self.complete_feature(request, package_name, prefix).await,
crate::completion::CompletionContext::None => Completions::default(),
}
})
}
fn complete_package_name<'a>(
&'a self,
_request: crate::completion::CompletionRequest<'a>,
_prefix: String,
_range: tower_lsp_server::ls_types::Range,
) -> BoxFuture<'a, Completions> {
Box::pin(std::future::ready(Completions::default()))
}
fn complete_version<'a>(
&'a self,
request: crate::completion::CompletionRequest<'a>,
package_name: crate::PackageName,
prefix: String,
) -> BoxFuture<'a, Completions>;
fn complete_feature<'a>(
&'a self,
_request: crate::completion::CompletionRequest<'a>,
_package_name: crate::PackageName,
_prefix: String,
) -> BoxFuture<'a, Completions> {
Box::pin(std::future::ready(Completions::default()))
}
fn fallback_completion_prefix<'a>(
&self,
_content: &'a str,
_position: Position,
) -> Option<&'a str> {
None
}
fn completion_insert_text(&self, metadata: &dyn Metadata) -> Option<String>;
fn fallback_completion_is_bare(&self, _content: &str, _position: Position) -> bool {
false
}
fn fallback_bare_insert_text(&self, metadata: &dyn Metadata) -> Option<String> {
Some(metadata.name().to_string())
}
fn package_search_is_incomplete(&self) -> bool {
false
}
fn fetch_license<'a>(
&'a self,
_name: &'a str,
_version: &'a str,
) -> BoxFuture<'a, Vec<String>> {
Box::pin(std::future::ready(Vec::new()))
}
fn license_source(&self) -> LicenseSource {
LicenseSource::RegistryDeclaredSpdx
}
fn as_any(&self) -> &dyn Any;
fn collect_pin_all_to_sha_edits(
&self,
_parse_result: &dyn ParseResult,
_versions: VersionData<'_>,
) -> Vec<TextEdit> {
Vec::new()
}
fn pin_all_to_sha_noun(&self) -> crate::lsp_helpers::PinNoun {
crate::lsp_helpers::PinNoun {
singular: "ref",
plural: "refs",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::assert_matches;
#[test]
fn test_ecosystem_id_roundtrip() {
const ALL: &[EcosystemId] = &[
EcosystemId::Cargo,
EcosystemId::Npm,
EcosystemId::Pypi,
EcosystemId::Go,
EcosystemId::Bundler,
EcosystemId::Dart,
EcosystemId::Maven,
EcosystemId::Composer,
EcosystemId::Gradle,
EcosystemId::Swift,
EcosystemId::NuGet,
EcosystemId::Deno,
EcosystemId::GithubActions,
EcosystemId::GitlabCi,
];
for id in ALL {
let parsed: EcosystemId = id.id().parse().unwrap();
assert_eq!(parsed, *id);
assert_eq!(id.to_string(), id.id());
}
}
#[test]
fn test_osv_ecosystem_mapping_pinned() {
let expected: &[(EcosystemId, &str)] = &[
(EcosystemId::Cargo, "crates.io"),
(EcosystemId::Npm, "npm"),
(EcosystemId::Pypi, "PyPI"),
(EcosystemId::Go, "Go"),
(EcosystemId::Bundler, "RubyGems"),
(EcosystemId::Dart, "Pub"),
(EcosystemId::Maven, "Maven"),
(EcosystemId::Composer, "Packagist"),
(EcosystemId::Gradle, "Maven"),
(EcosystemId::Swift, "SwiftURL"),
(EcosystemId::NuGet, "NuGet"),
(EcosystemId::Deno, "npm"),
(EcosystemId::GithubActions, "GitHub Actions"),
];
for (id, expected_str) in expected {
assert_eq!(
id.osv_ecosystem(),
Some(*expected_str),
"unexpected OSV ecosystem string for {id:?}"
);
}
assert_eq!(EcosystemId::GitlabCi.osv_ecosystem(), None);
}
#[test]
fn test_ecosystem_id_from_str_unknown() {
let err = "unknown".parse::<EcosystemId>().unwrap_err();
assert_matches!(err, crate::error::DepsError::UnsupportedEcosystem(s) if s == "unknown");
}
#[test]
fn test_ecosystem_config_default() {
let config = EcosystemConfig::default();
assert!(config.show_up_to_date_hints);
assert_eq!(config.up_to_date_text, "✅");
assert_eq!(config.needs_update_text, "❌ {}");
}
#[test]
fn test_ecosystem_config_custom() {
let config = EcosystemConfig {
show_up_to_date_hints: false,
up_to_date_text: "OK".to_string(),
needs_update_text: "Update to {}".to_string(),
loading_text: "Loading...".to_string(),
show_loading_hints: false,
offline: false,
};
assert!(!config.show_up_to_date_hints);
assert_eq!(config.up_to_date_text, "OK");
assert_eq!(config.needs_update_text, "Update to {}");
}
#[test]
fn test_ecosystem_config_clone() {
let config1 = EcosystemConfig::default();
let config2 = config1.clone();
assert_eq!(config1.up_to_date_text, config2.up_to_date_text);
assert_eq!(config1.show_up_to_date_hints, config2.show_up_to_date_hints);
assert_eq!(config1.needs_update_text, config2.needs_update_text);
}
#[test]
fn test_dependency_default_features() {
struct MockDep;
impl Dependency for MockDep {
fn name(&self) -> &crate::PackageName {
static NAME: std::sync::LazyLock<crate::PackageName> =
std::sync::LazyLock::new(|| crate::PackageName::new("test"));
&NAME
}
fn name_range(&self) -> tower_lsp_server::ls_types::Range {
tower_lsp_server::ls_types::Range::default()
}
fn version_requirement(&self) -> Option<&crate::VersionReq> {
None
}
fn version_range(&self) -> Option<tower_lsp_server::ls_types::Range> {
None
}
fn source(&self) -> crate::parser::DependencySource {
crate::parser::DependencySource::Registry
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
let dep = MockDep;
assert_eq!(dep.features(), &[] as &[String]);
}
struct StubDep;
impl Dependency for StubDep {
fn name(&self) -> &crate::PackageName {
static NAME: std::sync::LazyLock<crate::PackageName> =
std::sync::LazyLock::new(|| crate::PackageName::new("stub-dep"));
&NAME
}
fn name_range(&self) -> tower_lsp_server::ls_types::Range {
tower_lsp_server::ls_types::Range::default()
}
fn version_requirement(&self) -> Option<&crate::VersionReq> {
None
}
fn version_range(&self) -> Option<tower_lsp_server::ls_types::Range> {
None
}
fn source(&self) -> crate::parser::DependencySource {
crate::parser::DependencySource::Registry
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
struct StubParseResult {
uri: Uri,
dep_count: usize,
}
impl ParseResult for StubParseResult {
fn dependencies(&self) -> Vec<&dyn Dependency> {
static STUB_DEP: StubDep = StubDep;
vec![&STUB_DEP as &dyn Dependency; self.dep_count]
}
fn workspace_root(&self) -> Option<&std::path::Path> {
None
}
fn uri(&self) -> &Uri {
&self.uri
}
fn as_any(&self) -> &dyn Any {
self
}
}
struct StubEcosystem {
calling_thread: std::thread::ThreadId,
should_panic: bool,
dep_count: usize,
}
impl private::Sealed for StubEcosystem {}
impl Ecosystem for StubEcosystem {
fn id(&self) -> &'static str {
"stub"
}
fn ecosystem_id(&self) -> EcosystemId {
EcosystemId::Cargo
}
fn display_name(&self) -> &'static str {
"Stub"
}
fn manifest_filenames(&self) -> &[&'static str] {
&[]
}
fn parse_manifest<'a>(
&'a self,
_content: &'a str,
uri: &'a Uri,
) -> BoxFuture<'a, crate::error::Result<Box<dyn ParseResult>>> {
Box::pin(async move {
assert!(!self.should_panic, "boom");
assert_ne!(
std::thread::current().id(),
self.calling_thread,
"parse must run on the blocking pool, not the calling thread"
);
Ok(Box::new(StubParseResult {
uri: uri.clone(),
dep_count: self.dep_count,
}) as Box<dyn ParseResult>)
})
}
fn registry(&self) -> Arc<dyn crate::Registry> {
unimplemented!()
}
fn formatter(&self) -> &dyn crate::lsp_helpers::EcosystemFormatter {
unimplemented!()
}
fn generate_completions<'a>(
&'a self,
_parse_result: &'a dyn ParseResult,
_position: Position,
_content: &'a str,
_freshness: crate::FreshnessSettings,
) -> BoxFuture<'a, crate::completion::Completions> {
unimplemented!()
}
fn complete_version<'a>(
&'a self,
_request: crate::completion::CompletionRequest<'a>,
_package_name: crate::PackageName,
_prefix: String,
) -> BoxFuture<'a, crate::completion::Completions> {
unimplemented!()
}
fn completion_insert_text(&self, _metadata: &dyn crate::Metadata) -> Option<String> {
unimplemented!()
}
fn as_any(&self) -> &dyn Any {
self
}
}
#[tokio::test]
async fn test_parse_manifest_blocking_runs_parse_off_calling_thread() {
let ecosystem: Arc<dyn Ecosystem> = Arc::new(StubEcosystem {
calling_thread: std::thread::current().id(),
should_panic: false,
dep_count: 0,
});
let uri = crate::test_util::test_uri("/test/manifest.toml");
let parsed = parse_manifest_blocking(&ecosystem, "content", &uri)
.await
.unwrap();
assert_eq!(parsed.uri(), &uri);
}
#[tokio::test]
async fn test_parse_manifest_blocking_caps_dependencies_over_the_ceiling() {
let ecosystem: Arc<dyn Ecosystem> = Arc::new(StubEcosystem {
calling_thread: std::thread::current().id(),
should_panic: false,
dep_count: crate::dependency_cap::MAX_DEPENDENCIES_PER_DOCUMENT + 1,
});
let uri = crate::test_util::test_uri("/test/manifest.toml");
let parsed = parse_manifest_blocking(&ecosystem, "content", &uri)
.await
.unwrap();
assert_eq!(
parsed.dependencies().len(),
crate::dependency_cap::MAX_DEPENDENCIES_PER_DOCUMENT
);
assert_eq!(
parsed.dependency_truncation(),
Some((
crate::dependency_cap::MAX_DEPENDENCIES_PER_DOCUMENT,
crate::dependency_cap::MAX_DEPENDENCIES_PER_DOCUMENT + 1
))
);
}
#[tokio::test]
async fn test_parse_manifest_blocking_panic_becomes_parse_error() {
let ecosystem: Arc<dyn Ecosystem> = Arc::new(StubEcosystem {
calling_thread: std::thread::current().id(),
should_panic: true,
dep_count: 0,
});
let uri = crate::test_util::test_uri("/test/manifest.toml");
let Err(err) = parse_manifest_blocking(&ecosystem, "content", &uri).await else {
panic!("expected parse_manifest_blocking to return an error");
};
match err {
crate::error::DepsError::ParseError { file_type, source } => {
assert!(file_type.contains("manifest at"));
assert!(
source.to_string().contains("boom"),
"panic message should be preserved in the error source, got: {source}"
);
}
other => panic!("Expected ParseError, got: {other:?}"),
}
}
}