// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. use std::collections::HashSet; use std::sync::Arc; use deno_core::error::AnyError; use deno_semver::package::PackageNv; use deno_semver::package::PackageReq; use deno_semver::VersionReq; use deno_terminal::colors; use crate::args::CacheSetting; use crate::args::CliOptions; use crate::args::Flags; use crate::args::OutdatedFlags; use crate::factory::CliFactory; use crate::file_fetcher::FileFetcher; use crate::jsr::JsrFetchResolver; use crate::npm::NpmFetchResolver; use crate::tools::registry::pm::deps::DepKind; use super::deps::Dep; use super::deps::DepManager; use super::deps::DepManagerArgs; use super::deps::PackageLatestVersion; #[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] struct OutdatedPackage { kind: DepKind, latest: String, semver_compatible: String, current: String, name: String, } #[allow(clippy::print_stdout)] fn print_outdated_table(packages: &[OutdatedPackage]) { const HEADINGS: &[&str] = &["Package", "Current", "Update", "Latest"]; let mut longest_package = 0; let mut longest_current = 0; let mut longest_update = 0; let mut longest_latest = 0; for package in packages { let name_len = package.kind.scheme().len() + 1 + package.name.len(); longest_package = longest_package.max(name_len); longest_current = longest_current.max(package.current.len()); longest_update = longest_update.max(package.semver_compatible.len()); longest_latest = longest_latest.max(package.latest.len()); } let package_column_width = longest_package.max(HEADINGS[0].len()) + 2; let current_column_width = longest_current.max(HEADINGS[1].len()) + 2; let update_column_width = longest_update.max(HEADINGS[2].len()) + 2; let latest_column_width = longest_latest.max(HEADINGS[3].len()) + 2; let package_fill = "─".repeat(package_column_width); let current_fill = "─".repeat(current_column_width); let update_fill = "─".repeat(update_column_width); let latest_fill = "─".repeat(latest_column_width); println!("┌{package_fill}┬{current_fill}┬{update_fill}┬{latest_fill}┐"); println!( "│ {}{} │ {}{} │ {}{} │ {}{} │", colors::intense_blue(HEADINGS[0]), " ".repeat(package_column_width - 2 - HEADINGS[0].len()), colors::intense_blue(HEADINGS[1]), " ".repeat(current_column_width - 2 - HEADINGS[1].len()), colors::intense_blue(HEADINGS[2]), " ".repeat(update_column_width - 2 - HEADINGS[2].len()), colors::intense_blue(HEADINGS[3]), " ".repeat(latest_column_width - 2 - HEADINGS[3].len()) ); for package in packages { println!("├{package_fill}┼{current_fill}┼{update_fill}┼{latest_fill}┤",); print!( "│ {:Run deno outdated --update{} to update to the latest {} versions,\nor deno outdated --help for more information.", cmd, txt, ) ); } fn print_outdated( deps: &mut DepManager, compatible: bool, ) -> Result<(), AnyError> { let mut outdated = Vec::new(); let mut seen = std::collections::BTreeSet::new(); for (dep_id, resolved, latest_versions) in deps.deps_with_resolved_latest_versions() { let dep = deps.get_dep(dep_id); let Some(resolved) = resolved else { continue }; let latest = { let preferred = if compatible { &latest_versions.semver_compatible } else { &latest_versions.latest }; if let Some(v) = preferred { v } else { continue; } }; if latest > &resolved && seen.insert((dep.kind, dep.req.name.clone(), resolved.version.clone())) { outdated.push(OutdatedPackage { kind: dep.kind, name: dep.req.name.clone(), current: resolved.version.to_string(), latest: latest_versions .latest .map(|l| l.version.to_string()) .unwrap_or_default(), semver_compatible: latest_versions .semver_compatible .map(|l| l.version.to_string()) .unwrap_or_default(), }) } } if !outdated.is_empty() { outdated.sort(); print_outdated_table(&outdated); print_suggestion(compatible); } Ok(()) } pub async fn outdated( flags: Arc, update_flags: OutdatedFlags, ) -> Result<(), AnyError> { let factory = CliFactory::from_flags(flags.clone()); let cli_options = factory.cli_options()?; let workspace = cli_options.workspace(); let http_client = factory.http_client_provider(); let deps_http_cache = factory.global_http_cache()?; let mut file_fetcher = FileFetcher::new( deps_http_cache.clone(), CacheSetting::RespectHeaders, true, http_client.clone(), Default::default(), None, ); file_fetcher.set_download_log_level(log::Level::Trace); let file_fetcher = Arc::new(file_fetcher); let npm_fetch_resolver = Arc::new(NpmFetchResolver::new( file_fetcher.clone(), cli_options.npmrc().clone(), )); let jsr_fetch_resolver = Arc::new(JsrFetchResolver::new(file_fetcher.clone())); let args = dep_manager_args( &factory, cli_options, npm_fetch_resolver.clone(), jsr_fetch_resolver.clone(), ) .await?; let filter_set = filter::FilterSet::from_filter_strings( update_flags.filters.iter().map(|s| s.as_str()), )?; let filter_fn = |alias: Option<&str>, req: &PackageReq, _: DepKind| { if filter_set.is_empty() { return true; } let name = alias.unwrap_or(&req.name); filter_set.matches(name) }; let mut deps = if update_flags.recursive { super::deps::DepManager::from_workspace(workspace, filter_fn, args)? } else { super::deps::DepManager::from_workspace_dir( &cli_options.start_dir, filter_fn, args, )? }; deps.resolve_versions().await?; match update_flags.kind { crate::args::OutdatedKind::Update { latest } => { update(deps, latest, &filter_set, flags).await?; } crate::args::OutdatedKind::PrintOutdated { compatible } => { print_outdated(&mut deps, compatible)?; } } Ok(()) } fn choose_new_version_req( dep: &Dep, resolved: Option<&PackageNv>, latest_versions: &PackageLatestVersion, update_to_latest: bool, filter_set: &filter::FilterSet, ) -> Option { let explicit_version_req = filter_set .matching_filter(dep.alias.as_deref().unwrap_or(&dep.req.name)) .version_spec() .cloned(); if let Some(version_req) = explicit_version_req { if let Some(resolved) = resolved { // todo(nathanwhit): handle tag if version_req.tag().is_none() && version_req.matches(&resolved.version) { return None; } } Some(version_req) } else { let preferred = if update_to_latest { latest_versions.latest.as_ref()? } else { latest_versions.semver_compatible.as_ref()? }; if preferred.version <= resolved?.version { return None; } Some( VersionReq::parse_from_specifier( format!("^{}", preferred.version).as_str(), ) .unwrap(), ) } } async fn update( mut deps: DepManager, update_to_latest: bool, filter_set: &filter::FilterSet, flags: Arc, ) -> Result<(), AnyError> { let mut updated = Vec::new(); for (dep_id, resolved, latest_versions) in deps .deps_with_resolved_latest_versions() .into_iter() .collect::>() { let dep = deps.get_dep(dep_id); let new_version_req = choose_new_version_req( dep, resolved.as_ref(), &latest_versions, update_to_latest, filter_set, ); let Some(new_version_req) = new_version_req else { continue; }; updated.push(( dep_id, format!("{}:{}", dep.kind.scheme(), dep.req.name), deps.resolved_version(dep.id).cloned(), new_version_req.clone(), )); deps.update_dep(dep_id, new_version_req); } deps.commit_changes()?; if !updated.is_empty() { let factory = super::npm_install_after_modification( flags.clone(), Some(deps.jsr_fetch_resolver.clone()), ) .await?; let mut updated_to_versions = HashSet::new(); let cli_options = factory.cli_options()?; let args = dep_manager_args( &factory, cli_options, deps.npm_fetch_resolver.clone(), deps.jsr_fetch_resolver.clone(), ) .await?; let mut deps = deps.reloaded_after_modification(args); deps.resolve_current_versions().await?; for (dep_id, package_name, maybe_current_version, new_version_req) in updated { if let Some(nv) = deps.resolved_version(dep_id) { updated_to_versions.insert(( package_name, maybe_current_version, nv.version.clone(), )); } else { log::warn!( "Failed to resolve version for new version requirement: {} -> {}", package_name, new_version_req ); } } log::info!( "Updated {} dependenc{}:", updated_to_versions.len(), if updated_to_versions.len() == 1 { "y" } else { "ies" } ); let mut updated_to_versions = updated_to_versions.into_iter().collect::>(); updated_to_versions.sort_by(|(k, _, _), (k2, _, _)| k.cmp(k2)); let max_name = updated_to_versions .iter() .map(|(name, _, _)| name.len()) .max() .unwrap_or(0); let max_old = updated_to_versions .iter() .map(|(_, maybe_current, _)| { maybe_current .as_ref() .map(|v| v.version.to_string().len()) .unwrap_or(0) }) .max() .unwrap_or(0); let max_new = updated_to_versions .iter() .map(|(_, _, new_version)| new_version.to_string().len()) .max() .unwrap_or(0); for (package_name, maybe_current_version, new_version) in updated_to_versions { let current_version = if let Some(current_version) = maybe_current_version { current_version.version.to_string() } else { "".to_string() }; log::info!( " - {}{} {}{} -> {}{}", format!( "{}{}", colors::gray(package_name[0..4].to_string()), package_name[4..].to_string() ), " ".repeat(max_name - package_name.len()), " ".repeat(max_old - current_version.len()), colors::gray(¤t_version), " ".repeat(max_new - new_version.to_string().len()), colors::green(&new_version), ); } } else { log::info!( "All {}dependencies are up to date.", if filter_set.is_empty() { "" } else { "matching " } ); } Ok(()) } async fn dep_manager_args( factory: &CliFactory, cli_options: &CliOptions, npm_fetch_resolver: Arc, jsr_fetch_resolver: Arc, ) -> Result { Ok(DepManagerArgs { module_load_preparer: factory.module_load_preparer().await?.clone(), jsr_fetch_resolver, npm_fetch_resolver, npm_resolver: factory.npm_resolver().await?.clone(), permissions_container: factory.root_permissions_container()?.clone(), main_module_graph_container: factory .main_module_graph_container() .await? .clone(), lockfile: cli_options.maybe_lockfile().cloned(), }) } mod filter { use deno_core::anyhow::anyhow; use deno_core::anyhow::Context; use deno_core::error::AnyError; use deno_semver::VersionReq; enum FilterKind { Exclude, Include, } pub struct Filter { kind: FilterKind, regex: regex::Regex, version_spec: Option, } fn pattern_to_regex(pattern: &str) -> Result { let escaped = regex::escape(pattern); let unescaped_star = escaped.replace(r"\*", ".*"); Ok(regex::Regex::new(&format!("^{}$", unescaped_star))?) } impl Filter { pub fn version_spec(&self) -> Option<&VersionReq> { self.version_spec.as_ref() } pub fn from_str(input: &str) -> Result { let (kind, first_idx) = if input.starts_with('!') { (FilterKind::Exclude, 1) } else { (FilterKind::Include, 0) }; let s = &input[first_idx..]; let (pattern, version_spec) = if let Some(scope_name) = s.strip_prefix('@') { if let Some(idx) = scope_name.find('@') { let (pattern, version_spec) = s.split_at(idx + 1); ( pattern, Some( VersionReq::parse_from_specifier( version_spec.trim_start_matches('@'), ) .with_context(|| format!("Invalid filter \"{input}\""))?, ), ) } else { (s, None) } } else { let mut parts = s.split('@'); let Some(pattern) = parts.next() else { return Err(anyhow!("Invalid filter \"{input}\"")); }; ( pattern, parts .next() .map(VersionReq::parse_from_specifier) .transpose() .with_context(|| format!("Invalid filter \"{input}\""))?, ) }; Ok(Filter { kind, regex: pattern_to_regex(pattern) .with_context(|| format!("Invalid filter \"{input}\""))?, version_spec, }) } pub fn matches(&self, name: &str) -> bool { self.regex.is_match(name) } } pub struct FilterSet { filters: Vec, has_exclude: bool, has_include: bool, } impl FilterSet { pub fn from_filter_strings<'a>( filter_strings: impl IntoIterator, ) -> Result { let filters = filter_strings .into_iter() .map(Filter::from_str) .collect::, _>>()?; let has_exclude = filters .iter() .any(|f| matches!(f.kind, FilterKind::Exclude)); let has_include = filters .iter() .any(|f| matches!(f.kind, FilterKind::Include)); Ok(FilterSet { filters, has_exclude, has_include, }) } pub fn is_empty(&self) -> bool { self.filters.is_empty() } pub fn matches(&self, name: &str) -> bool { self.matching_filter(name).is_included() } pub fn matching_filter(&self, name: &str) -> MatchResult<'_> { if self.filters.is_empty() { return MatchResult::Included; } let mut matched = None; for filter in &self.filters { match filter.kind { FilterKind::Include => { if matched.is_none() && filter.matches(name) { matched = Some(filter); } } FilterKind::Exclude => { if filter.matches(name) { return MatchResult::Excluded; } } } } if let Some(filter) = matched { MatchResult::Matches(filter) } else if self.has_exclude && !self.has_include { MatchResult::Included } else { MatchResult::Excluded } } } pub enum MatchResult<'a> { Matches(&'a Filter), Included, Excluded, } impl MatchResult<'_> { pub fn version_spec(&self) -> Option<&VersionReq> { match self { MatchResult::Matches(filter) => filter.version_spec(), _ => None, } } pub fn is_included(&self) -> bool { matches!(self, MatchResult::Included | MatchResult::Matches(_)) } } #[cfg(test)] mod test { fn matches_filters<'a, 'b>( filters: impl IntoIterator, name: &str, ) -> bool { let filters = super::FilterSet::from_filter_strings(filters).unwrap(); filters.matches(name) } fn version_spec(s: &str) -> deno_semver::VersionReq { deno_semver::VersionReq::parse_from_specifier(s).unwrap() } #[test] fn basic_glob() { assert!(matches_filters(["foo*"], "foo")); assert!(matches_filters(["foo*"], "foobar")); assert!(!matches_filters(["foo*"], "barfoo")); assert!(matches_filters(["*foo"], "foo")); assert!(matches_filters(["*foo"], "barfoo")); assert!(!matches_filters(["*foo"], "foobar")); assert!(matches_filters(["@scope/foo*"], "@scope/foobar")); } #[test] fn basic_glob_with_version() { assert!(matches_filters(["foo*@1"], "foo",)); assert!(matches_filters(["foo*@1"], "foobar",)); assert!(matches_filters(["foo*@1"], "foo-bar",)); assert!(!matches_filters(["foo*@1"], "barfoo",)); assert!(matches_filters(["@scope/*@1"], "@scope/foo")); } #[test] fn glob_exclude() { assert!(!matches_filters(["!foo*"], "foo")); assert!(!matches_filters(["!foo*"], "foobar")); assert!(matches_filters(["!foo*"], "barfoo")); assert!(!matches_filters(["!*foo"], "foo")); assert!(!matches_filters(["!*foo"], "barfoo")); assert!(matches_filters(["!*foo"], "foobar")); assert!(!matches_filters(["!@scope/foo*"], "@scope/foobar")); } #[test] fn multiple_globs() { assert!(matches_filters(["foo*", "bar*"], "foo")); assert!(matches_filters(["foo*", "bar*"], "bar")); assert!(!matches_filters(["foo*", "bar*"], "baz")); assert!(matches_filters(["foo*", "!bar*"], "foo")); assert!(!matches_filters(["foo*", "!bar*"], "bar")); assert!(matches_filters(["foo*", "!bar*"], "foobar")); assert!(!matches_filters(["foo*", "!*bar"], "foobar")); assert!(!matches_filters(["foo*", "!*bar"], "baz")); let filters = super::FilterSet::from_filter_strings(["foo*@1", "bar*@2"]).unwrap(); assert_eq!( filters.matching_filter("foo").version_spec().cloned(), Some(version_spec("1")) ); assert_eq!( filters.matching_filter("bar").version_spec().cloned(), Some(version_spec("2")) ); } } }