diff --git a/cli/lsp/config.rs b/cli/lsp/config.rs index b2e2120ac86bcf..f925821a3cdba9 100644 --- a/cli/lsp/config.rs +++ b/cli/lsp/config.rs @@ -59,6 +59,7 @@ use indexmap::IndexSet; use lsp_types::ClientCapabilities; use lsp_types::Uri; use node_resolver::NodeResolverOptions; +use sys_traits::SystemTimeNow; use tower_lsp::lsp_types as lsp; use super::logging::lsp_log; @@ -1775,6 +1776,35 @@ impl ConfigData { self.member_dir.member_or_root_pkg_json() } + /// Workspace `minimumDependencyAge`, with the same `.npmrc` and default + /// fallbacks the CLI uses when the config does not set an age. + pub fn minimum_dependency_age_config( + &self, + ) -> deno_config::deno_json::MinimumDependencyAgeConfig { + let parsed = match self + .member_dir + .workspace + .minimum_dependency_age(&CliSys::default()) + { + Ok(config) => config, + Err(err) => { + lsp_warn!( + "Failed to parse \"minimumDependencyAge\" for \"{}\": {err}", + self.scope + ); + Default::default() + } + }; + deno_resolver::factory::resolve_minimum_dependency_age_config( + parsed, + self + .npmrc + .as_ref() + .and_then(|npmrc| npmrc.min_release_age_days), + chrono::DateTime::::from(CliSys::default().sys_time_now()), + ) + } + pub fn scope_contains_specifier(&self, specifier: &ModuleSpecifier) -> bool { specifier.as_str().starts_with(self.scope.as_str()) || self diff --git a/cli/lsp/jsr.rs b/cli/lsp/jsr.rs index 36a1f076a0971f..cfae4a62b7b32c 100644 --- a/cli/lsp/jsr.rs +++ b/cli/lsp/jsr.rs @@ -21,6 +21,7 @@ use deno_semver::jsr::JsrPackageReqReference; use deno_semver::package::PackageNv; use deno_semver::package::PackageReq; use serde::Deserialize; +use sys_traits::SystemTimeNow; use super::config::ConfigData; use super::search::PackageSearchApi; @@ -48,6 +49,8 @@ pub struct JsrCacheResolver { info_by_name: DashMap>>, workspace_packages_by_name: HashMap, cache: Arc, + newest_dependency_date_options: + deno_graph::packages::NewestDependencyDateOptions, } impl JsrCacheResolver { @@ -56,6 +59,22 @@ impl JsrCacheResolver { config_data: Option<&ConfigData>, workspace_resolver: &WorkspaceResolver, ) -> Self { + let newest_dependency_date_options = match config_data { + Some(config_data) => { + deno_resolver::factory::jsr_newest_dependency_date_options( + &config_data.minimum_dependency_age_config(), + ) + } + None => deno_resolver::factory::jsr_newest_dependency_date_options( + &deno_resolver::factory::resolve_minimum_dependency_age_config( + Default::default(), + None, + chrono::DateTime::::from( + CliSys::default().sys_time_now(), + ), + ), + ), + }; let nv_by_req = DashMap::new(); let info_by_nv = DashMap::new(); let info_by_name = DashMap::new(); @@ -127,6 +146,7 @@ impl JsrCacheResolver { info_by_name, workspace_packages_by_name, cache: cache.clone(), + newest_dependency_date_options, } } @@ -147,6 +167,8 @@ impl JsrCacheResolver { &req.version_req, &package_info, ); + let newest_dependency_date = + self.newest_dependency_date_options.get_for_package(name); let version = versions .into_iter() .rev() @@ -160,6 +182,12 @@ impl JsrCacheResolver { { return false; } + if let Some(cutoff) = newest_dependency_date + && let Some(version_info) = package_info.versions.get(*v) + && !version_info.matches_newest_dependency_date(cutoff) + { + return false; + } let nv = PackageNv { name: name.clone(), version: (*v).clone(), @@ -372,12 +400,14 @@ pub struct CliJsrSearchApi { } impl CliJsrSearchApi { - pub fn new(file_fetcher: Arc) -> Self { + pub fn new( + file_fetcher: Arc, + newest_dependency_date_options: deno_graph::packages::NewestDependencyDateOptions, + ) -> Self { let resolver = JsrFetchResolver::new( file_fetcher.clone(), Arc::new(JsrVersionResolver { - // not currently supported in the lsp - newest_dependency_date_options: Default::default(), + newest_dependency_date_options, }), ); Self { @@ -428,7 +458,18 @@ impl PackageSearchApi for CliJsrSearchApi { .package_info(name) .await .ok_or_else(|| anyhow!("JSR package info not found: {}", name))?; - let mut versions = info.versions.keys().cloned().collect::>(); + let package_name = deno_semver::package::PackageName::from(name); + let version_resolver = self + .resolver + .version_resolver_for_package(&package_name, &info); + let mut versions = info + .versions + .iter() + .filter(|(_, version_info)| { + version_resolver.matches_newest_dependency_date(version_info) + }) + .map(|(version, _)| version.clone()) + .collect::>(); versions.sort(); versions.reverse(); let versions = Arc::new(versions); diff --git a/cli/lsp/language_server.rs b/cli/lsp/language_server.rs index 2b76df103e21b4..7ff4d2fb9a1036 100644 --- a/cli/lsp/language_server.rs +++ b/cli/lsp/language_server.rs @@ -46,6 +46,7 @@ use indexmap::IndexSet; use log::error; use serde::Deserialize; use serde_json::from_value; +use sys_traits::SystemTimeNow; use tokio::sync::OnceCell; use tokio::sync::mpsc::UnboundedReceiver; use tokio::sync::mpsc::UnboundedSender; @@ -622,6 +623,47 @@ impl LanguageServer { } } +/// `minimumDependencyAge` for import completions. The search APIs are +/// process-global, so this follows the root workspace folder (then any other +/// loaded scope). Resolution itself is per-scope. +fn minimum_dependency_age_config( + config: &Config, +) -> deno_config::deno_json::MinimumDependencyAgeConfig { + let data = config + .root_url() + .and_then(|root| config.tree.data_for_specifier(root)) + .or_else(|| config.tree.data_by_scope().values().next()); + match data { + Some(data) => data.minimum_dependency_age_config(), + None => deno_resolver::factory::resolve_minimum_dependency_age_config( + Default::default(), + None, + chrono::DateTime::::from(CliSys::default().sys_time_now()), + ), + } +} + +fn package_search_apis( + file_fetcher: Arc, + config: &deno_config::deno_json::MinimumDependencyAgeConfig, +) -> (CliJsrSearchApi, CliNpmSearchApi) { + let jsr_search_api = CliJsrSearchApi::new( + file_fetcher.clone(), + deno_resolver::factory::jsr_newest_dependency_date_options(config), + ); + let npm_search_api = CliNpmSearchApi::new( + file_fetcher, + Arc::new(NpmVersionResolver { + link_packages: Default::default(), + newest_dependency_date_options: + deno_resolver::factory::npm_newest_dependency_date_options(config), + overrides: Default::default(), + trust_policy: Default::default(), + }), + ); + (jsr_search_api, npm_search_api) +} + impl Inner { fn new(client: Client, performance: Arc) -> Self { let cache = LspCache::default(); @@ -630,18 +672,11 @@ impl Inner { cache.deno_dir().registries_folder_path(), http_client_provider.clone(), ); - let jsr_search_api = - CliJsrSearchApi::new(module_registry.file_fetcher.clone()); - let npm_search_api = CliNpmSearchApi::new( + let config = Config::default(); + let (jsr_search_api, npm_search_api) = package_search_apis( module_registry.file_fetcher.clone(), - Arc::new(NpmVersionResolver { - link_packages: Default::default(), - newest_dependency_date_options: Default::default(), - overrides: Default::default(), - trust_policy: Default::default(), - }), + &minimum_dependency_age_config(&config), ); - let config = Config::default(); let ts_server = Arc::new(TsServer::new(performance.clone())); let initial_cwd = resolve_cwd(None).unwrap().into_owned(); @@ -855,21 +890,20 @@ impl Inner { self.module_registry.disable(registry); } } - self.jsr_search_api = - CliJsrSearchApi::new(self.module_registry.file_fetcher.clone()); - self.npm_search_api = CliNpmSearchApi::new( + // todo(dsherret): the npm_search_api should probably be specific + // to each workspace so that the link packages can be properly + // hooked up. minimumDependencyAge follows the root workspace. + self.rebuild_package_search_apis(); + self.performance.measure(mark); + } + + fn rebuild_package_search_apis(&mut self) { + let (jsr_search_api, npm_search_api) = package_search_apis( self.module_registry.file_fetcher.clone(), - Arc::new(NpmVersionResolver { - // todo(dsherret): the npm_search_api should probably be specific - // to each workspace so that the link packages can be properly - // hooked up - link_packages: Default::default(), - newest_dependency_date_options: Default::default(), - overrides: Default::default(), - trust_policy: Default::default(), - }), + &minimum_dependency_age_config(&self.config), ); - self.performance.measure(mark); + self.jsr_search_api = jsr_search_api; + self.npm_search_api = npm_search_api; } #[cfg_attr(feature = "lsp-tracing", tracing::instrument(skip_all))] @@ -1249,6 +1283,10 @@ impl Inner { reason, ) .await; + // Import-specifier completions read versions through the search APIs, + // which are process-global. Rebuild them so a deno.json change (including + // `minimumDependencyAge` and its `exclude` list) takes effect. + self.rebuild_package_search_apis(); self .client .send_did_refresh_deno_configuration_tree_notification( diff --git a/cli/lsp/resolver.rs b/cli/lsp/resolver.rs index 2780b1b36fd0a5..f5ebdbdd3b8064 100644 --- a/cli/lsp/resolver.rs +++ b/cli/lsp/resolver.rs @@ -63,6 +63,7 @@ use node_resolver::ResolutionMode; use node_resolver::cache::NodeResolutionSys; use node_resolver::cache::NodeResolutionThreadLocalCache; use once_cell::sync::Lazy; +use sys_traits::SystemTimeNow; use super::cache::LspCache; use super::documents::DocumentModule; @@ -1068,11 +1069,24 @@ impl<'a> ResolverFactory<'a> { http_client_provider.clone(), pb.clone(), )); + let minimum_dependency_age = match self.config_data { + Some(data) => data.minimum_dependency_age_config(), + None => deno_resolver::factory::resolve_minimum_dependency_age_config( + Default::default(), + npmrc.min_release_age_days, + chrono::DateTime::::from(sys.sys_time_now()), + ), + }; let registry_info_provider = Arc::new(CliNpmRegistryInfoProvider::new( npm_cache.clone(), npm_client.clone(), npmrc.clone(), - NpmPackumentFormat::Abbreviated, + NpmPackumentFormat::new( + &npmrc, + minimum_dependency_age + .age + .and_then(|date| date.into_option()), + ), )); let link_packages: WorkspaceNpmLinkPackagesRc = self .config_data @@ -1115,7 +1129,10 @@ impl<'a> ResolverFactory<'a> { .unwrap_or_default(); let npm_version_resolver = Arc::new(NpmVersionResolver { link_packages: link_packages.0.clone(), - newest_dependency_date_options: Default::default(), + newest_dependency_date_options: + deno_resolver::factory::npm_newest_dependency_date_options( + &minimum_dependency_age, + ), overrides: Arc::new(overrides), trust_policy: Default::default(), }); diff --git a/libs/resolver/factory.rs b/libs/resolver/factory.rs index 56e91b0a101514..f39dfba44fb7fe 100644 --- a/libs/resolver/factory.rs +++ b/libs/resolver/factory.rs @@ -985,23 +985,10 @@ impl ResolverFactory { let minimum_dependency_age_config = self.minimum_dependency_age_config()?; - let (exclude_jsr_pkgs, exclude_jsr_pkg_prefixes) = - split_minimum_dependency_age_excludes( - &minimum_dependency_age_config.exclude, - "jsr:", - ); - Ok(new_rc(deno_graph::packages::JsrVersionResolver { - newest_dependency_date_options: - deno_graph::packages::NewestDependencyDateOptions { - date: minimum_dependency_age_config - .age - .as_ref() - .and_then(|d| d.into_option()) - .map(deno_graph::packages::NewestDependencyDate), - exclude_jsr_pkgs, - exclude_jsr_pkg_prefixes, - }, + newest_dependency_date_options: jsr_newest_dependency_date_options( + minimum_dependency_age_config, + ), })) }) } @@ -1012,7 +999,7 @@ impl ResolverFactory { ) -> Result<&MinimumDependencyAgeConfig, anyhow::Error> { self.minimum_dependency_age.get_or_try_init(|| { let workspace_factory = self.workspace_factory(); - let mut config = if let Some(date) = self.options.newest_dependency_date { + let config = if let Some(date) = self.options.newest_dependency_date { MinimumDependencyAgeConfig { age: Some(date), exclude: Vec::new(), @@ -1021,15 +1008,20 @@ impl ResolverFactory { let workspace = &workspace_factory.workspace_directory()?.workspace; workspace.minimum_dependency_age(workspace_factory.sys())? }; - if config.age.is_none() { - apply_minimum_dependency_age_fallbacks( - &mut config, - workspace_factory.npmrc()?.min_release_age_days, - chrono::DateTime::::from( - workspace_factory.sys().sys_time_now(), - ), - ); - } + // Only read `.npmrc` when the age is unset. A configured age does not + // depend on it, and a missing npmrc must not fail that path. + let npmrc_min_release_age_days = if config.age.is_none() { + workspace_factory.npmrc()?.min_release_age_days + } else { + None + }; + let config = resolve_minimum_dependency_age_config( + config, + npmrc_min_release_age_days, + chrono::DateTime::::from( + workspace_factory.sys().sys_time_now(), + ), + ); if let Some(newest_dependency_date) = config.age.and_then(|d| d.into_option()) { @@ -1195,22 +1187,10 @@ impl ResolverFactory { ), }; - let (exclude, exclude_prefixes) = split_minimum_dependency_age_excludes( - &minimum_dependency_age_config.exclude, - "npm:", - ); - Ok(new_rc(NpmVersionResolver { - newest_dependency_date_options: - deno_npm::resolution::NewestDependencyDateOptions { - date: minimum_dependency_age_config - .age - .as_ref() - .and_then(|d| d.into_option()) - .map(deno_npm::resolution::NewestDependencyDate), - exclude, - exclude_prefixes, - }, + newest_dependency_date_options: npm_newest_dependency_date_options( + minimum_dependency_age_config, + ), link_packages: self .workspace_factory .workspace_npm_link_packages()? @@ -1345,6 +1325,58 @@ impl ResolverFactory { } } +/// Resolves `minimumDependencyAge`, applying the `.npmrc` `min-release-age` +/// and built-in default when the config does not set an age. +pub fn resolve_minimum_dependency_age_config( + mut config: MinimumDependencyAgeConfig, + npmrc_min_release_age_days: Option, + now: chrono::DateTime, +) -> MinimumDependencyAgeConfig { + if config.age.is_none() { + apply_minimum_dependency_age_fallbacks( + &mut config, + npmrc_min_release_age_days, + now, + ); + } + config +} + +/// npm `NewestDependencyDateOptions` for a resolved minimum dependency age, +/// including `exclude` entries (`npm:` exact names and `npm:` wildcard prefixes). +pub fn npm_newest_dependency_date_options( + config: &MinimumDependencyAgeConfig, +) -> deno_npm::resolution::NewestDependencyDateOptions { + let (exclude, exclude_prefixes) = + split_minimum_dependency_age_excludes(&config.exclude, "npm:"); + deno_npm::resolution::NewestDependencyDateOptions { + date: config + .age + .and_then(|date| date.into_option()) + .map(deno_npm::resolution::NewestDependencyDate), + exclude, + exclude_prefixes, + } +} + +/// JSR `NewestDependencyDateOptions` for a resolved minimum dependency age, +/// including `exclude` entries (`jsr:` exact names and `jsr:` wildcard prefixes). +#[cfg(feature = "graph")] +pub fn jsr_newest_dependency_date_options( + config: &MinimumDependencyAgeConfig, +) -> deno_graph::packages::NewestDependencyDateOptions { + let (exclude_jsr_pkgs, exclude_jsr_pkg_prefixes) = + split_minimum_dependency_age_excludes(&config.exclude, "jsr:"); + deno_graph::packages::NewestDependencyDateOptions { + date: config + .age + .and_then(|date| date.into_option()) + .map(deno_graph::packages::NewestDependencyDate), + exclude_jsr_pkgs, + exclude_jsr_pkg_prefixes, + } +} + /// Splits `minimumDependencyAge.exclude` entries with the given scheme /// (`npm:` or `jsr:`) into exact package names and package name prefixes /// (from trailing `*` wildcard entries like `npm:@scope/*`). diff --git a/tests/integration/lsp_tests.rs b/tests/integration/lsp_tests.rs index 3f91ecbbfafc16..fdaf9289c1db62 100644 --- a/tests/integration/lsp_tests.rs +++ b/tests/integration/lsp_tests.rs @@ -11641,6 +11641,141 @@ fn lsp_completions_npm() { client.shutdown(); } +/// Versions newer than `minimumDependencyAge` are omitted from jsr/npm import +/// completions, `exclude` opts a package out, and editing deno.json refreshes +/// the list. https://github.com/denoland/deno/issues/31009 +#[test(timeout = 300)] +fn lsp_completions_minimum_dependency_age() { + let context = TestContextBuilder::new() + .use_http_server() + .use_temp_cwd() + .build(); + let temp_dir = context.temp_dir(); + // @denotest/add: 0.2.0 is 2025-01-20, 0.2.1 is 2025-05-20, 1.0.0 is 2025-06-01. + // @denotest/min-release-age-latest: 1.0.0 is year 2000, 2.0.0 is year 9999. + temp_dir.write( + "deno.json", + r#"{ "minimumDependencyAge": "2025-03-01T00:00:00.000Z" }"#, + ); + let deno_json_uri = + url_to_uri(&temp_dir.url().join("deno.json").unwrap()).unwrap(); + let mut client = context.new_lsp_command().build(); + client.initialize_default(); + let uri = "file:///a/file.ts"; + client.did_open(json!({ + "textDocument": { + "uri": uri, + "languageId": "typescript", + "version": 1, + "text": "import \"jsr:@denotest/add@\";\nimport \"npm:@denotest/min-release-age-latest@\";\n", + } + })); + + let jsr_labels = + version_completion_labels(&mut client, uri, (0, 26), "jsr:@denotest/add@"); + assert_eq!( + jsr_labels, + vec!["jsr:@denotest/add@0.2.0".to_string()], + "jsr completions should only include versions older than the cutoff", + ); + let npm_labels = version_completion_labels( + &mut client, + uri, + (1, 45), + "npm:@denotest/min-release-age-latest@", + ); + assert_eq!( + npm_labels, + vec!["npm:@denotest/min-release-age-latest@1.0.0".to_string()], + "npm completions should only include versions older than the cutoff", + ); + + temp_dir.write( + "deno.json", + r#"{ + "minimumDependencyAge": { + "age": "2025-03-01T00:00:00.000Z", + "exclude": ["jsr:@denotest/add", "npm:@denotest/min-release-age-latest"] + } + }"#, + ); + reload_deno_json(&mut client, &deno_json_uri); + let jsr_labels = + version_completion_labels(&mut client, uri, (0, 26), "jsr:@denotest/add@"); + assert!( + jsr_labels + .iter() + .any(|label| label == "jsr:@denotest/add@1.0.0"), + "excluded jsr package should still offer versions newer than the cutoff, got {jsr_labels:?}" + ); + let npm_labels = version_completion_labels( + &mut client, + uri, + (1, 45), + "npm:@denotest/min-release-age-latest@", + ); + assert!( + npm_labels + .iter() + .any(|label| label == "npm:@denotest/min-release-age-latest@2.0.0"), + "excluded npm package should still offer versions newer than the cutoff, got {npm_labels:?}" + ); + + temp_dir.write("deno.json", r#"{ "minimumDependencyAge": false }"#); + reload_deno_json(&mut client, &deno_json_uri); + let jsr_labels = + version_completion_labels(&mut client, uri, (0, 26), "jsr:@denotest/add@"); + assert!( + jsr_labels + .iter() + .any(|label| label == "jsr:@denotest/add@1.0.0"), + "disabling minimumDependencyAge should offer the newer jsr version, got {jsr_labels:?}" + ); + let npm_labels = version_completion_labels( + &mut client, + uri, + (1, 45), + "npm:@denotest/min-release-age-latest@", + ); + assert!( + npm_labels + .iter() + .any(|label| label == "npm:@denotest/min-release-age-latest@2.0.0"), + "disabling minimumDependencyAge should offer the newer npm version, got {npm_labels:?}" + ); + + client.shutdown(); +} + +fn version_completion_labels( + client: &mut LspClient, + uri: &str, + position: (usize, usize), + prefix: &str, +) -> Vec { + let list = client.get_completion_list( + uri, + position, + json!({ "triggerKind": 2, "triggerCharacter": "@" }), + ); + list + .items + .into_iter() + .map(|item| item.label) + .filter(|label| label.starts_with(prefix)) + .collect() +} + +fn reload_deno_json(client: &mut LspClient, deno_json_uri: &impl Serialize) { + client.did_change_watched_files(json!({ + "changes": [{ + "uri": deno_json_uri, + "type": 2, + }], + })); + client.handle_refresh_diagnostics_request(); +} + // Regression test for https://github.com/denoland/deno/issues/24927. // Auto-completion for the configuration file `imports` field should suggest // jsr:/npm:/node: specifiers just like in script imports do.