// Copyright 2018-2025 the Deno authors. MIT license. use std::borrow::Cow; use std::collections::BTreeMap; use std::collections::HashSet; use std::collections::VecDeque; use std::path::Path; use std::path::PathBuf; use std::sync::OnceLock; use boxed_error::Boxed; use deno_error::JsError; use deno_maybe_sync::new_rc; use deno_package_json::PackageJson; use deno_package_json::PackageJsonDepValue; use deno_package_json::PackageJsonDepWorkspaceReq; use deno_package_json::PackageJsonLoadError; use deno_package_json::PackageJsonRc; use deno_path_util::url_from_directory_path; use deno_path_util::url_parent; use deno_path_util::url_to_file_path; use deno_semver::RangeSetOrTag; use deno_semver::Version; use deno_semver::VersionReq; use deno_semver::jsr::JsrDepPackageReq; use deno_semver::package::PackageKind; use deno_semver::package::PackageNv; use deno_semver::package::PackageReq; use discovery::ConfigFileDiscovery; use discovery::ConfigFolder; use discovery::DenoOrPkgJson; use discovery::discover_workspace_config_files; use indexmap::IndexMap; use indexmap::IndexSet; use sys_traits::FsMetadata; use sys_traits::FsRead; use sys_traits::FsReadDir; use thiserror::Error; use url::Url; use crate::UrlToFilePathError; use crate::deno_json; use crate::deno_json::BenchConfig; use crate::deno_json::CompileConfig; use crate::deno_json::CompilerOptions; use crate::deno_json::ConfigFile; use crate::deno_json::ConfigFileError; use crate::deno_json::ConfigFileRc; use crate::deno_json::ConfigFileReadError; use crate::deno_json::DeployConfig; use crate::deno_json::FmtConfig; use crate::deno_json::FmtOptionsConfig; use crate::deno_json::LinkConfigParseError; use crate::deno_json::LintRulesConfig; use crate::deno_json::NodeModulesDirMode; use crate::deno_json::NodeModulesDirParseError; use crate::deno_json::PermissionsConfig; use crate::deno_json::PermissionsObjectWithBase; use crate::deno_json::PublishConfig; pub use crate::deno_json::TaskDefinition; use crate::deno_json::TestConfig; use crate::deno_json::ToInvalidConfigError; use crate::deno_json::ToLockConfigError; use crate::deno_json::WorkspaceConfigParseError; use crate::glob::FilePatterns; use crate::glob::PathOrPattern; use crate::glob::PathOrPatternParseError; use crate::glob::PathOrPatternSet; mod discovery; #[allow(clippy::disallowed_types)] type UrlRc = deno_maybe_sync::MaybeArc; #[allow(clippy::disallowed_types)] pub type WorkspaceRc = deno_maybe_sync::MaybeArc; #[allow(clippy::disallowed_types)] pub type WorkspaceDirectoryRc = deno_maybe_sync::MaybeArc; #[derive(Debug, Clone, PartialEq, Eq)] pub struct ResolverWorkspaceJsrPackage { pub base: Url, pub name: String, pub version: Option, pub exports: IndexMap, pub is_link: bool, } impl ResolverWorkspaceJsrPackage { pub fn matches_req(&self, req: &PackageReq) -> bool { self.name == req.name && self .version .as_ref() .map(|v| req.version_req.matches(v)) .unwrap_or(true) } } #[derive(Debug, Clone)] pub struct JsrPackageConfig { /// The package name. pub name: String, pub member_dir: WorkspaceDirectoryRc, pub config_file: ConfigFileRc, pub license: Option, } #[derive(Debug, Clone)] pub struct NpmPackageConfig { pub nv: PackageNv, pub workspace_dir: WorkspaceDirectoryRc, pub pkg_json: PackageJsonRc, } impl NpmPackageConfig { pub fn matches_req(&self, req: &PackageReq) -> bool { self.matches_name_and_version_req(&req.name, &req.version_req) } pub fn matches_name_and_version_req( &self, name: &str, version_req: &VersionReq, ) -> bool { if name != self.nv.name { return false; } match version_req.inner() { RangeSetOrTag::RangeSet(set) => set.satisfies(&self.nv.version), RangeSetOrTag::Tag(tag) => tag == "workspace", } } } #[derive(Clone, Debug, Default, Hash, PartialEq)] pub struct WorkspaceLintConfig { pub report: Option, } #[derive(Debug, Clone, Error, JsError, PartialEq, Eq)] #[class(type)] pub enum WorkspaceDiagnosticKind { #[error( "\"{0}\" field can only be specified in the workspace root deno.json file." )] RootOnlyOption(&'static str), #[error( "\"{0}\" field can only be specified in a workspace member deno.json file and not the workspace root file." )] MemberOnlyOption(&'static str), #[error("\"workspaces\" field was ignored. Use \"workspace\" instead.")] InvalidWorkspacesOption, #[error("\"exports\" field should be specified when specifying a \"name\".")] MissingExports, #[error( "\"importMap\" field is ignored when \"imports\" or \"scopes\" are specified in the config file." )] ImportMapReferencingImportMap, #[error( "\"imports\" and \"scopes\" field is ignored when \"importMap\" is specified in the root config file." )] MemberImportsScopesIgnored, #[error( "`\"nodeModulesDir\": {previous}` is deprecated in Deno 2.0. Use `\"nodeModulesDir\": \"{suggestion}\"` instead." )] DeprecatedNodeModulesDirOption { previous: bool, suggestion: NodeModulesDirMode, }, #[error("\"patch\" property was renamed to \"links\".")] DeprecatedPatch, #[error( "Invalid workspace member name \"{name}\". Ensure the name is in the format '@scope/name'." )] InvalidMemberName { name: String }, } #[derive(Debug, Error, JsError, Clone, PartialEq, Eq)] #[class(inherit)] #[error("{}\n at {}", .kind, .config_url)] pub struct WorkspaceDiagnostic { #[inherit] pub kind: WorkspaceDiagnosticKind, pub config_url: Url, } #[derive(Debug, JsError, Boxed)] pub struct ResolveWorkspaceLinkError(pub Box); #[derive(Debug, Error, JsError)] pub enum ResolveWorkspaceLinkErrorKind { #[class(inherit)] #[error(transparent)] ConfigRead(#[from] ConfigReadError), #[class(type)] #[error("Could not find link member in '{}'.", .dir_url)] NotFound { dir_url: Url }, #[class(type)] #[error("Workspace member cannot be specified as a link.")] WorkspaceMemberNotAllowed, #[class(inherit)] #[error(transparent)] InvalidLink(#[from] url::ParseError), #[class(inherit)] #[error(transparent)] UrlToFilePath(#[from] deno_path_util::UrlToFilePathError), #[class(inherit)] #[error(transparent)] Workspace(Box), } #[derive(Debug, Error, JsError)] pub enum ConfigReadError { #[class(inherit)] #[error(transparent)] DenoJsonRead(#[from] ConfigFileReadError), #[class(inherit)] #[error(transparent)] PackageJsonRead(#[from] PackageJsonLoadError), } #[derive(Debug, JsError, Boxed)] #[class(type)] pub struct ResolveWorkspaceMemberError( pub Box, ); #[derive(Debug, Error, JsError)] #[class(type)] pub enum ResolveWorkspaceMemberErrorKind { #[class(inherit)] #[error(transparent)] ConfigRead(#[from] ConfigReadError), #[error("Could not find config file for workspace member in '{}'.", .dir_url)] NotFound { dir_url: Url }, #[error("Could not find package.json for workspace member in '{}'.", .dir_url)] NotFoundPackageJson { dir_url: Url }, #[error("Could not find config file for workspace member in '{}'. Ensure you specify the directory and not the configuration file in the workspace member.", .dir_url)] NotFoundMaybeSpecifiedFile { dir_url: Url }, #[error( "Workspace member must be nested in a directory under the workspace.\n Member: {member_url}\n Workspace: {workspace_url}" )] NonDescendant { workspace_url: Url, member_url: Url }, #[error("Cannot specify a workspace member twice ('{}').", .member)] Duplicate { member: String }, #[error( "The '{name}' package ('{deno_json_url}') cannot have the same name as the package at '{other_deno_json_url}'." )] DuplicatePackageName { name: String, deno_json_url: Url, other_deno_json_url: Url, }, #[error("Remove the reference to the current config file (\"{}\") in \"workspaces\".", .member)] InvalidSelfReference { member: String }, #[class(inherit)] #[error("Invalid workspace member '{}' for config '{}'.", member, base)] InvalidMember { base: Url, member: String, #[source] #[inherit] source: url::ParseError, }, #[class(inherit)] #[error( "Failed converting {kind} workspace member '{}' to pattern for config '{}'.", member, base )] MemberToPattern { kind: &'static str, base: Url, member: String, // this error has the text that failed #[source] #[inherit] source: PathOrPatternParseError, }, #[error(transparent)] #[class(inherit)] UrlToFilePath(#[from] deno_path_util::UrlToFilePathError), } #[derive(Debug, JsError, Boxed)] #[class(inherit)] pub struct WorkspaceDiscoverError(pub Box); #[derive(Debug, Error, JsError)] #[class(type)] pub enum FailedResolvingStartDirectoryError { #[error("No paths provided.")] NoPathsProvided, #[error("Could not resolve path: '{}'.", .0.display())] CouldNotResolvePath(PathBuf), #[error("Provided config file path ('{}') had no parent directory.", .0.display())] PathHasNoParentDirectory(PathBuf), } #[derive(Debug, Error, JsError)] pub enum WorkspaceDiscoverErrorKind { #[class(inherit)] #[error("Failed resolving start directory.")] FailedResolvingStartDirectory(#[source] FailedResolvingStartDirectoryError), #[class(inherit)] #[error(transparent)] ConfigRead(#[from] ConfigReadError), #[class(inherit)] #[error(transparent)] PackageJsonRead(#[from] PackageJsonLoadError), #[class(inherit)] #[error(transparent)] LinkConfigParse(#[from] LinkConfigParseError), #[class(inherit)] #[error(transparent)] WorkspaceConfigParse(#[from] WorkspaceConfigParseError), #[class(inherit)] #[error(transparent)] ResolveMember(#[from] ResolveWorkspaceMemberError), #[class(inherit)] #[error("Failed loading link '{}' in config '{}'.", link, base)] ResolveLink { link: String, base: Url, #[source] #[inherit] source: ResolveWorkspaceLinkError, }, #[class(type)] #[error( "Command resolved to multiple config files. Ensure all specified paths are within the same workspace.\n First: {base_workspace_url}\n Second: {other_workspace_url}" )] MultipleWorkspaces { base_workspace_url: Url, other_workspace_url: Url, }, #[class(inherit)] #[error(transparent)] UrlToFilePath(#[from] UrlToFilePathError), #[class(inherit)] #[error(transparent)] PathToUrl(#[from] deno_path_util::PathToUrlError), #[class(type)] #[error( "Config file must be a member of the workspace.\n Config: {config_url}\n Workspace: {workspace_url}" )] ConfigNotWorkspaceMember { workspace_url: Url, config_url: Url }, } #[derive(Debug, Clone, Copy)] pub enum WorkspaceDiscoverStart<'a> { Paths(&'a [PathBuf]), ConfigFile(&'a Path), } #[derive(Debug, Clone, Copy)] pub enum VendorEnablement<'a> { Disable, Enable { /// The cwd, which will be used when no configuration file is /// resolved in order to discover the vendor folder. cwd: &'a Path, }, } pub trait WorkspaceCache { fn get(&self, dir_path: &Path) -> Option; fn set(&self, dir_path: PathBuf, workspace: WorkspaceRc); } #[derive(Default, Clone)] pub struct WorkspaceDiscoverOptions<'a> { /// A cache for deno.json files. This is mostly only useful in the LSP where /// workspace discovery may occur multiple times. pub deno_json_cache: Option<&'a dyn crate::deno_json::DenoJsonCache>, pub pkg_json_cache: Option<&'a dyn deno_package_json::PackageJsonCache>, /// A cache for workspaces. This is mostly only useful in the LSP where /// workspace discovery may occur multiple times. pub workspace_cache: Option<&'a dyn WorkspaceCache>, pub additional_config_file_names: &'a [&'a str], pub discover_pkg_json: bool, pub maybe_vendor_override: Option>, } #[derive(Clone)] pub struct WorkspaceDirectoryEmptyOptions<'a> { pub root_dir: UrlRc, pub use_vendor_dir: VendorEnablement<'a>, } /// Configuration files found in a specific folder. #[derive(Debug, Default, Clone)] pub struct FolderConfigs { pub deno_json: Option, pub pkg_json: Option, } impl FolderConfigs { fn from_config_folder(config_folder: ConfigFolder) -> Self { match config_folder { ConfigFolder::Single(deno_or_pkg_json) => match deno_or_pkg_json { DenoOrPkgJson::Deno(deno_json) => FolderConfigs { deno_json: Some(deno_json), pkg_json: None, }, DenoOrPkgJson::PkgJson(pkg_json) => FolderConfigs { deno_json: None, pkg_json: Some(pkg_json), }, }, ConfigFolder::Both { deno_json, pkg_json, } => FolderConfigs { deno_json: Some(deno_json), pkg_json: Some(pkg_json), }, } } } #[derive(Debug, Error, JsError)] #[class(type)] #[error("lint.report must be a string")] pub struct LintConfigError; #[derive(Debug, Default)] struct WorkspaceCachedValues { dirs: deno_maybe_sync::MaybeDashMap, } #[derive(Debug)] pub struct Workspace { root_dir_url: UrlRc, config_folders: IndexMap, links: BTreeMap, pub(crate) vendor_dir: Option, cached: WorkspaceCachedValues, } impl Workspace { pub(crate) fn new( root: ConfigFolder, members: BTreeMap, link: BTreeMap, vendor_dir: Option, ) -> Self { let root_dir_url = new_rc(root.folder_url()); let mut config_folders = IndexMap::with_capacity(members.len() + 1); config_folders.insert( root_dir_url.clone(), FolderConfigs::from_config_folder(root), ); config_folders.extend(members.into_iter().map( |(folder_url, config_folder)| { (folder_url, FolderConfigs::from_config_folder(config_folder)) }, )); Workspace { root_dir_url, config_folders, links: link .into_iter() .map(|(url, folder)| (url, FolderConfigs::from_config_folder(folder))) .collect(), vendor_dir, cached: Default::default(), } } pub fn root_dir_url(&self) -> &UrlRc { &self.root_dir_url } pub fn root_dir(self: &WorkspaceRc) -> WorkspaceDirectoryRc { self.resolve_member_dir(&self.root_dir_url) } pub fn root_dir_path(&self) -> PathBuf { url_to_file_path(&self.root_dir_url).unwrap() } pub fn root_folder_configs(&self) -> &FolderConfigs { self.config_folders.get(&self.root_dir_url).unwrap() } pub fn root_deno_json(&self) -> Option<&ConfigFileRc> { self.root_folder_configs().deno_json.as_ref() } pub fn root_pkg_json(&self) -> Option<&PackageJsonRc> { self.root_folder_configs().pkg_json.as_ref() } pub fn config_folders(&self) -> &IndexMap { &self.config_folders } /// Gets the folders sorted by whether they have a dependency on each other. pub fn config_folders_sorted_by_dependencies( &self, ) -> IndexMap<&UrlRc, &FolderConfigs> { struct PackageNameMaybeVersion<'a> { name: &'a str, version: Option, } enum Dep { Req(JsrDepPackageReq), Path(Url), } impl Dep { pub fn matches_pkg( &self, package_kind: PackageKind, pkg: &PackageNameMaybeVersion, folder_url: &Url, ) -> bool { match self { Dep::Req(req) => { req.kind == package_kind && req.req.name == pkg.name && pkg .version .as_ref() .map(|v| { // just match if it's a tag req.req.version_req.tag().is_some() || req.req.version_req.matches(v) }) .unwrap_or(true) } Dep::Path(url) => { folder_url.as_str().trim_end_matches('/') == url.as_str().trim_end_matches('/') } } } } struct Folder<'a> { index: usize, dir_url: &'a UrlRc, folder: &'a FolderConfigs, npm_nv: Option>, jsr_nv: Option>, deps: Vec, } impl<'a> Folder<'a> { pub fn depends_on(&self, other: &Folder<'a>) -> bool { if let Some(other_nv) = &other.npm_nv && self.has_matching_dep(PackageKind::Npm, other_nv, other.dir_url) { return true; } if let Some(other_nv) = &other.jsr_nv && self.has_matching_dep(PackageKind::Jsr, other_nv, other.dir_url) { return true; } false } fn has_matching_dep( &self, pkg_kind: PackageKind, pkg: &PackageNameMaybeVersion, folder_url: &Url, ) -> bool { self .deps .iter() .any(|dep| dep.matches_pkg(pkg_kind, pkg, folder_url)) } } let mut folders = Vec::with_capacity(self.config_folders.len()); for (index, (dir_url, folder)) in self.config_folders.iter().enumerate() { folders.push(Folder { index, folder, dir_url, jsr_nv: folder.deno_json.as_ref().and_then(|deno_json| { deno_json .json .name .as_ref() .map(|name| PackageNameMaybeVersion { name, version: deno_json .json .version .as_ref() .and_then(|v| Version::parse_standard(v).ok()), }) }), npm_nv: folder.pkg_json.as_ref().and_then(|pkg_json| { pkg_json.name.as_ref().map(|name| PackageNameMaybeVersion { name, version: pkg_json .version .as_ref() .and_then(|v| Version::parse_from_npm(v).ok()), }) }), deps: folder .deno_json .as_ref() .map(|d| d.dependencies().into_iter().map(Dep::Req)) .into_iter() .flatten() .chain( folder .pkg_json .as_ref() .map(|d| { let deps = d.resolve_local_package_json_deps(); deps .dependencies .iter() .chain(deps.dev_dependencies.iter()) .filter_map(|(k, v)| match v.as_ref().ok()? { PackageJsonDepValue::File(path) => { dir_url.join(path).ok().map(Dep::Path) } PackageJsonDepValue::Req(package_req) => { Some(Dep::Req(JsrDepPackageReq { kind: PackageKind::Npm, req: package_req.clone(), })) } PackageJsonDepValue::Workspace(workspace_req) => { Some(Dep::Req(JsrDepPackageReq { kind: PackageKind::Npm, req: PackageReq { name: k.clone(), version_req: match workspace_req { PackageJsonDepWorkspaceReq::VersionReq( version_req, ) => version_req.clone(), PackageJsonDepWorkspaceReq::Tilde | PackageJsonDepWorkspaceReq::Caret => { VersionReq::parse_from_npm("*").unwrap() } }, }, })) } PackageJsonDepValue::JsrReq(req) => { Some(Dep::Req(JsrDepPackageReq { kind: PackageKind::Npm, req: req.clone(), })) } }) }) .into_iter() .flatten(), ) .collect(), }) } // build adjacency + in-degree let n = folders.len(); let mut adj: Vec> = vec![Vec::new(); n]; let mut indeg = vec![0_u32; n]; for i in 0..n { for j in 0..n { if i != j && folders[i].depends_on(&folders[j]) { adj[j].push(i); indeg[i] += 1; } } } // kahn's algorithm let mut queue: VecDeque = indeg .iter() .enumerate() .filter(|&(_, &d)| d == 0) .map(|(i, _)| i) .collect(); // preserve original insertion order for deterministic output queue.make_contiguous().sort_by_key(|&i| folders[i].index); let mut output = Vec::::with_capacity(n); while let Some(i) = queue.pop_front() { output.push(i); for &j in &adj[i] { indeg[j] -= 1; if indeg[j] == 0 { queue.push_back(j); } } } // handle possible cycles if output.len() < n { // collect the still-cyclic nodes let mut cyclic: Vec = (0..n).filter(|&i| indeg[i] > 0).collect(); // stable, deterministic: lowest original index first cyclic.sort_by_key(|&i| folders[i].index); output.extend(cyclic); } output .into_iter() .map(|i| (folders[i].dir_url, folders[i].folder)) .collect() } pub fn deno_jsons(&self) -> impl Iterator { self .config_folders .values() .filter_map(|f| f.deno_json.as_ref()) } pub fn package_jsons(&self) -> impl Iterator { self .config_folders .values() .filter_map(|f| f.pkg_json.as_ref()) } #[allow(clippy::needless_lifetimes)] // clippy issue pub fn jsr_packages<'a>( self: &'a WorkspaceRc, ) -> impl Iterator + 'a { self.deno_jsons().filter_map(|c| { if !c.is_package() { return None; } Some(JsrPackageConfig { member_dir: self.resolve_member_dir(&c.specifier), name: c.json.name.clone()?, config_file: c.clone(), license: c.to_license(), }) }) } pub fn npm_packages(self: &WorkspaceRc) -> Vec { self .package_jsons() .filter_map(|c| self.package_json_to_npm_package_config(c)) .collect() } fn package_json_to_npm_package_config( self: &WorkspaceRc, pkg_json: &PackageJsonRc, ) -> Option { Some(NpmPackageConfig { workspace_dir: self.resolve_member_dir(&pkg_json.specifier()), nv: PackageNv { name: deno_semver::StackString::from(pkg_json.name.as_ref()?.as_str()), version: { let version = pkg_json.version.as_ref()?; deno_semver::Version::parse_from_npm(version).ok()? }, }, pkg_json: pkg_json.clone(), }) } pub fn link_folders(&self) -> &BTreeMap { &self.links } pub fn link_deno_jsons(&self) -> impl Iterator { self.links.values().filter_map(|f| f.deno_json.as_ref()) } pub fn link_pkg_jsons(&self) -> impl Iterator { self.links.values().filter_map(|f| f.pkg_json.as_ref()) } pub fn resolver_deno_jsons(&self) -> impl Iterator { self .deno_jsons() .chain(self.links.values().filter_map(|f| f.deno_json.as_ref())) } pub fn resolver_pkg_jsons( &self, ) -> impl Iterator { self .config_folders .iter() .filter_map(|(k, v)| Some((k, v.pkg_json.as_ref()?))) } pub fn resolver_jsr_pkgs( &self, ) -> impl Iterator + '_ { self .config_folders .iter() .filter_map(|(dir_url, f)| Some((dir_url, f.deno_json.as_ref()?, false))) .chain(self.links.iter().filter_map(|(dir_url, f)| { Some((dir_url, f.deno_json.as_ref()?, true)) })) .filter_map(|(dir_url, config_file, is_link)| { let name = config_file.json.name.as_ref()?; let version = config_file .json .version .as_ref() .and_then(|v| Version::parse_standard(v).ok()); let exports_config = config_file.to_exports_config().ok()?; Some(ResolverWorkspaceJsrPackage { is_link, base: dir_url.as_ref().clone(), name: name.to_string(), version, exports: exports_config.into_map(), }) }) } pub fn resolve_member_dirs( self: &WorkspaceRc, ) -> impl Iterator { self .config_folders() .keys() .map(|url| self.resolve_member_dir(url)) } /// Resolves a workspace directory, which can be used for deriving /// configuration specific to a member. pub fn resolve_member_dir( self: &WorkspaceRc, specifier: &Url, ) -> WorkspaceDirectoryRc { let maybe_folder = self .resolve_folder(specifier) .filter(|(member_url, _)| **member_url != self.root_dir_url); let folder_url = maybe_folder .map(|(folder_url, _)| folder_url.clone()) .unwrap_or_else(|| self.root_dir_url.clone()); if let Some(dir) = self.cached.dirs.get(&folder_url).map(|d| d.clone()) { dir } else { let workspace_dir = match maybe_folder { Some((member_url, folder)) => { let maybe_deno_json = folder .deno_json .as_ref() .map(|c| (member_url, c)) .or_else(|| { let parent = parent_specifier_str(member_url.as_str())?; self.resolve_deno_json_from_str(parent) }) .or_else(|| { let root = self.config_folders.get(&self.root_dir_url).unwrap(); root.deno_json.as_ref().map(|c| (&self.root_dir_url, c)) }); let maybe_pkg_json = folder .pkg_json .as_ref() .map(|pkg_json| (member_url, pkg_json)) .or_else(|| { let parent = parent_specifier_str(member_url.as_str())?; self.resolve_pkg_json_from_str(parent) }) .or_else(|| { let root = self.config_folders.get(&self.root_dir_url).unwrap(); root.pkg_json.as_ref().map(|c| (&self.root_dir_url, c)) }); WorkspaceDirectory { dir_url: member_url.clone(), pkg_json: maybe_pkg_json.map(|(member_url, pkg_json)| { WorkspaceDirConfig { root: if *member_url == self.root_dir_url { None } else { self .config_folders .get(&self.root_dir_url) .unwrap() .pkg_json .clone() }, member: pkg_json.clone(), } }), deno_json: maybe_deno_json.map(|(member_url, config)| { WorkspaceDirConfig { root: if self.root_dir_url == *member_url { None } else { self .config_folders .get(&self.root_dir_url) .unwrap() .deno_json .clone() }, member: config.clone(), } }), workspace: self.clone(), cached: Default::default(), } } None => WorkspaceDirectory::create_from_root_folder(self.clone()), }; let workspace_dir = new_rc(workspace_dir); self.cached.dirs.insert(folder_url, workspace_dir.clone()); workspace_dir } } pub fn resolve_deno_json( &self, specifier: &Url, ) -> Option<(&UrlRc, &ConfigFileRc)> { self.resolve_deno_json_from_str(specifier.as_str()) } fn resolve_deno_json_from_str( &self, specifier: &str, ) -> Option<(&UrlRc, &ConfigFileRc)> { let mut specifier = specifier; if !specifier.ends_with('/') { specifier = parent_specifier_str(specifier)?; } loop { let (folder_url, folder) = self.resolve_folder_str(specifier)?; if let Some(config) = folder.deno_json.as_ref() { return Some((folder_url, config)); } specifier = parent_specifier_str(folder_url.as_str())?; } } fn resolve_pkg_json_from_str( &self, specifier: &str, ) -> Option<(&UrlRc, &PackageJsonRc)> { let mut specifier = specifier; if !specifier.ends_with('/') { specifier = parent_specifier_str(specifier)?; } loop { let (folder_url, folder) = self.resolve_folder_str(specifier)?; if let Some(pkg_json) = folder.pkg_json.as_ref() { return Some((folder_url, pkg_json)); } specifier = parent_specifier_str(folder_url.as_str())?; } } pub fn resolve_folder( &self, specifier: &Url, ) -> Option<(&UrlRc, &FolderConfigs)> { self.resolve_folder_str(specifier.as_str()) } fn resolve_folder_str( &self, specifier: &str, ) -> Option<(&UrlRc, &FolderConfigs)> { let mut best_match: Option<(&UrlRc, &FolderConfigs)> = None; for (dir_url, config) in &self.config_folders { if specifier.starts_with(dir_url.as_str()) && (best_match.is_none() || dir_url.as_str().len() > best_match.unwrap().0.as_str().len()) { best_match = Some((dir_url, config)); } } best_match } pub fn diagnostics(&self) -> Vec { fn check_member_diagnostics( member_config: &ConfigFile, root_config: Option<&ConfigFile>, diagnostics: &mut Vec, ) { if member_config.json.import_map.is_some() { diagnostics.push(WorkspaceDiagnostic { config_url: member_config.specifier.clone(), kind: WorkspaceDiagnosticKind::RootOnlyOption("importMap"), }); } else if member_config.is_an_import_map() && root_config .map(|c| { c.json.import_map.is_some() && c.json.imports.is_none() && c.json.scopes.is_none() }) .unwrap_or(false) { diagnostics.push(WorkspaceDiagnostic { config_url: member_config.specifier.clone(), kind: WorkspaceDiagnosticKind::MemberImportsScopesIgnored, }); } if member_config.json.lock.is_some() { diagnostics.push(WorkspaceDiagnostic { config_url: member_config.specifier.clone(), kind: WorkspaceDiagnosticKind::RootOnlyOption("lock"), }); } if member_config.json.node_modules_dir.is_some() { diagnostics.push(WorkspaceDiagnostic { config_url: member_config.specifier.clone(), kind: WorkspaceDiagnosticKind::RootOnlyOption("nodeModulesDir"), }); } if member_config.json.links.is_some() { diagnostics.push(WorkspaceDiagnostic { config_url: member_config.specifier.clone(), kind: WorkspaceDiagnosticKind::RootOnlyOption("links"), }); } if member_config.json.scopes.is_some() { diagnostics.push(WorkspaceDiagnostic { config_url: member_config.specifier.clone(), kind: WorkspaceDiagnosticKind::RootOnlyOption("scopes"), }); } if !member_config.json.unstable.is_empty() { diagnostics.push(WorkspaceDiagnostic { config_url: member_config.specifier.clone(), kind: WorkspaceDiagnosticKind::RootOnlyOption("unstable"), }); } if member_config.json.vendor.is_some() { diagnostics.push(WorkspaceDiagnostic { config_url: member_config.specifier.clone(), kind: WorkspaceDiagnosticKind::RootOnlyOption("vendor"), }); } if member_config.json.workspace.is_some() { diagnostics.push(WorkspaceDiagnostic { config_url: member_config.specifier.clone(), kind: WorkspaceDiagnosticKind::RootOnlyOption("workspace"), }); } if let Some(value) = &member_config.json.lint && value.get("report").is_some() { diagnostics.push(WorkspaceDiagnostic { config_url: member_config.specifier.clone(), kind: WorkspaceDiagnosticKind::RootOnlyOption("lint.report"), }); } } fn check_all_configs( config: &ConfigFile, diagnostics: &mut Vec, ) { if let Some(name) = &config.json.name && !is_valid_jsr_pkg_name(name) { diagnostics.push(WorkspaceDiagnostic { config_url: config.specifier.clone(), kind: WorkspaceDiagnosticKind::InvalidMemberName { name: name.clone(), }, }); } if config.json.deprecated_workspaces.is_some() { diagnostics.push(WorkspaceDiagnostic { config_url: config.specifier.clone(), kind: WorkspaceDiagnosticKind::InvalidWorkspacesOption, }); } if config.json.deprecated_patch.is_some() { diagnostics.push(WorkspaceDiagnostic { config_url: config.specifier.clone(), kind: WorkspaceDiagnosticKind::DeprecatedPatch, }); } if config.json.name.is_some() && config.json.exports.is_none() { diagnostics.push(WorkspaceDiagnostic { config_url: config.specifier.clone(), kind: WorkspaceDiagnosticKind::MissingExports, }); } if config.is_an_import_map() && config.json.import_map.is_some() { diagnostics.push(WorkspaceDiagnostic { config_url: config.specifier.clone(), kind: WorkspaceDiagnosticKind::ImportMapReferencingImportMap, }); } if let Some(serde_json::Value::Bool(enabled)) = &config.json.node_modules_dir { diagnostics.push(WorkspaceDiagnostic { config_url: config.specifier.clone(), kind: WorkspaceDiagnosticKind::DeprecatedNodeModulesDirOption { previous: *enabled, suggestion: if config.json.unstable.iter().any(|v| v == "byonm") { NodeModulesDirMode::Manual } else if *enabled { NodeModulesDirMode::Auto } else { NodeModulesDirMode::None }, }, }) } } let mut diagnostics = Vec::new(); for (url, folder) in &self.config_folders { if let Some(config) = &folder.deno_json { let is_root = url == &self.root_dir_url; if !is_root { check_member_diagnostics( config, self.root_deno_json().map(|r| r.as_ref()), &mut diagnostics, ); } check_all_configs(config, &mut diagnostics); } } for folder in self.links.values() { if let Some(config) = &folder.deno_json && config.json.links.is_some() { // supporting linking in links is too complicated diagnostics.push(WorkspaceDiagnostic { config_url: config.specifier.clone(), kind: WorkspaceDiagnosticKind::RootOnlyOption("links"), }); } } diagnostics } pub fn vendor_dir_path(&self) -> Option<&PathBuf> { self.vendor_dir.as_ref() } pub fn to_lint_config(&self) -> Result { self .with_root_config_only(|root_config| { Ok(WorkspaceLintConfig { report: match root_config .json .lint .as_ref() .and_then(|l| l.get("report")) { Some(report) => match report { serde_json::Value::String(value) => Some(value.to_string()), serde_json::Value::Null => None, serde_json::Value::Bool(_) | serde_json::Value::Number(_) | serde_json::Value::Array(_) | serde_json::Value::Object(_) => { return Err(LintConfigError); } }, None => None, }, }) }) .unwrap_or(Ok(Default::default())) } pub fn to_import_map_path(&self) -> Result, ConfigFileError> { self .with_root_config_only(|root_config| root_config.to_import_map_path()) .unwrap_or(Ok(None)) } pub fn resolve_lockfile_path( &self, ) -> Result, ToLockConfigError> { if let Some(deno_json) = self.root_deno_json() { Ok(deno_json.resolve_lockfile_path()?) } else if let Some(pkg_json) = self.root_pkg_json() { Ok(pkg_json.path.parent().map(|p| p.join("deno.lock"))) } else { Ok(None) } } pub fn resolve_bench_config_for_members( self: &WorkspaceRc, cli_args: &FilePatterns, ) -> Result, ToInvalidConfigError> { self.resolve_config_for_members(cli_args, |dir, patterns| { dir.to_bench_config(patterns) }) } pub fn resolve_lint_config_for_members( self: &WorkspaceRc, cli_args: &FilePatterns, ) -> Result< Vec<(WorkspaceDirectoryRc, WorkspaceDirLintConfig)>, ToInvalidConfigError, > { self.resolve_config_for_members(cli_args, |dir, patterns| { dir.to_lint_config(patterns) }) } pub fn resolve_fmt_config_for_members( self: &WorkspaceRc, cli_args: &FilePatterns, ) -> Result, ToInvalidConfigError> { self.resolve_config_for_members(cli_args, |dir, patterns| { dir.to_fmt_config(patterns) }) } pub fn resolve_test_config_for_members( self: &WorkspaceRc, cli_args: &FilePatterns, ) -> Result, ToInvalidConfigError> { self.resolve_config_for_members(cli_args, |dir, patterns| { dir.to_test_config(patterns) }) } fn resolve_config_for_members( self: &WorkspaceRc, cli_args: &FilePatterns, resolve_config: impl Fn(&WorkspaceDirectory, FilePatterns) -> Result, ) -> Result, E> { let cli_args_by_folder = self.split_cli_args_by_deno_json_folder(cli_args); let mut result = Vec::with_capacity(cli_args_by_folder.len()); for (folder_url, patterns) in cli_args_by_folder { let dir = self.resolve_member_dir(&folder_url); let config = resolve_config(&dir, patterns)?; result.push((dir, config)); } Ok(result) } fn split_cli_args_by_deno_json_folder( &self, cli_args: &FilePatterns, ) -> IndexMap { fn common_ancestor(a: &Path, b: &Path) -> PathBuf { a.components() .zip(b.components()) .take_while(|(a, b)| a == b) .map(|(a, _)| a) .collect() } let cli_arg_patterns = cli_args.split_by_base(); let deno_json_folders = self .config_folders .iter() .filter(|(_, folder)| folder.deno_json.is_some()) .map(|(url, folder)| { let dir_path = url_to_file_path(url).unwrap(); (dir_path, (url, folder)) }) .collect::>(); let mut results: IndexMap<_, FilePatterns> = IndexMap::with_capacity(deno_json_folders.len() + 1); for pattern in cli_arg_patterns { let mut matches = Vec::with_capacity(deno_json_folders.len()); for (dir_path, v) in deno_json_folders.iter() { if pattern.base.starts_with(dir_path) || dir_path.starts_with(&pattern.base) { matches.push((dir_path, *v)); } } // remove any non-sub/current folders that start with another folder let mut indexes_to_remove = VecDeque::with_capacity(matches.len()); for (i, (m, _)) in matches.iter().enumerate() { if !m.starts_with(&pattern.base) && matches.iter().any(|(sub, _)| { sub.starts_with(m) && sub != m && pattern.base.starts_with(m) }) { indexes_to_remove.push_back(i); } } let mut matched_folder_urls = Vec::with_capacity(std::cmp::max(1, matches.len())); if matches.is_empty() { // This will occur when someone specifies a file that's outside // the workspace directory. In this case, use the root directory's config // so that it's consistent across the workspace. matched_folder_urls.push(&self.root_dir_url); } for (i, (_dir_path, (folder_url, _config))) in matches.iter().enumerate() { if let Some(skip_index) = indexes_to_remove.front() && i == *skip_index { indexes_to_remove.pop_front(); continue; } matched_folder_urls.push(folder_url); } for folder_url in matched_folder_urls { let entry = results.entry((*folder_url).clone()); let folder_path = url_to_file_path(folder_url).unwrap(); match entry { indexmap::map::Entry::Occupied(entry) => { let entry = entry.into_mut(); let common_base = common_ancestor(&pattern.base, &entry.base); if common_base.starts_with(&folder_path) && entry.base.starts_with(&common_base) { entry.base = common_base; } match &mut entry.include { Some(set) => { if let Some(includes) = &pattern.include { for include in includes.inner() { if !set.inner().contains(include) { set.push(include.clone()) } } } } None => { entry.include.clone_from(&pattern.include); } } } indexmap::map::Entry::Vacant(entry) => { entry.insert(FilePatterns { base: if pattern.base.starts_with(&folder_path) { pattern.base.clone() } else { folder_path.clone() }, include: pattern.include.clone(), exclude: pattern.exclude.clone(), }); } } } } results } pub fn resolve_config_excludes( &self, ) -> Result { // have the root excludes at the front because they're lower priority let mut excludes = match &self.root_deno_json() { Some(c) => c.to_exclude_files_config()?.exclude.into_path_or_patterns(), None => Default::default(), }; for (dir_url, folder) in self.config_folders.iter() { let Some(deno_json) = folder.deno_json.as_ref() else { continue; }; if dir_url == &self.root_dir_url { continue; } excludes.extend( deno_json .to_exclude_files_config()? .exclude .into_path_or_patterns(), ); } Ok(PathOrPatternSet::new(excludes)) } pub fn unstable_features(&self) -> &[String] { self .with_root_config_only(|deno_json| { (&deno_json.json.unstable) as &[String] }) .unwrap_or(&[]) } pub fn has_unstable(&self, name: &str) -> bool { self .with_root_config_only(|deno_json| deno_json.has_unstable(name)) .unwrap_or(false) } fn with_root_config_only<'a, R>( &'a self, with_root: impl Fn(&'a ConfigFile) -> R, ) -> Option { self.root_deno_json().map(|c| with_root(c)) } pub fn node_modules_dir( &self, ) -> Result, deno_json::NodeModulesDirParseError> { self .root_deno_json() .and_then(|c| c.json.node_modules_dir.as_ref()) .map(|v| { serde_json::from_value::(v.clone()) .map_err(|err| NodeModulesDirParseError { source: err }) }) .transpose() } } #[derive(Debug, Clone)] struct WorkspaceDirConfig { #[allow(clippy::disallowed_types)] member: deno_maybe_sync::MaybeArc, // will be None when it doesn't exist or the member config // is the root config #[allow(clippy::disallowed_types)] root: Option>, } #[derive(Debug, Error, JsError)] #[class(inherit)] #[error("Failed parsing '{specifier}'.")] pub struct ToTasksConfigError { specifier: Url, #[source] #[inherit] error: ToInvalidConfigError, } #[derive(Clone, Debug, Hash, PartialEq)] pub struct WorkspaceDirLintConfig { pub rules: LintRulesConfig, pub plugins: Vec, pub files: FilePatterns, } /// Represents the "default" type library that should be used when type /// checking the code in the module graph. Note that a user provided config /// of `"lib"` would override this value. #[derive(Debug, Clone, Copy, Eq, Hash, PartialEq)] pub enum TsTypeLib { DenoWindow, DenoWorker, } impl Default for TsTypeLib { fn default() -> Self { Self::DenoWindow } } #[derive(Debug, Clone)] pub struct CompilerOptionsSource { pub specifier: UrlRc, pub compiler_options: Option, } #[derive(Debug, Clone, Default)] struct CachedDirectoryValues { permissions: OnceLock, bench: OnceLock, compile: OnceLock, test: OnceLock, } #[derive(Debug, Clone)] pub struct WorkspaceDirectory { pub workspace: WorkspaceRc, /// The directory that this context is for. This is generally the cwd. dir_url: UrlRc, pkg_json: Option>, deno_json: Option>, cached: CachedDirectoryValues, } impl WorkspaceDirectory { pub fn empty(opts: WorkspaceDirectoryEmptyOptions) -> WorkspaceDirectoryRc { let workspace = new_rc(Workspace { config_folders: IndexMap::from([( opts.root_dir.clone(), FolderConfigs::default(), )]), root_dir_url: opts.root_dir.clone(), links: BTreeMap::new(), vendor_dir: match opts.use_vendor_dir { VendorEnablement::Enable { cwd } => Some(cwd.join("vendor")), VendorEnablement::Disable => None, }, cached: Default::default(), }); workspace.resolve_member_dir(&opts.root_dir) } pub fn discover( sys: &TSys, start: WorkspaceDiscoverStart, opts: &WorkspaceDiscoverOptions, ) -> Result { fn resolve_start_dir( sys: &impl FsMetadata, start: &WorkspaceDiscoverStart, ) -> Result { match start { WorkspaceDiscoverStart::Paths(paths) => { if paths.is_empty() { Err( WorkspaceDiscoverErrorKind::FailedResolvingStartDirectory( FailedResolvingStartDirectoryError::NoPathsProvided, ) .into(), ) } else { // just select the first one... this doesn't matter too much // at the moment because we only use this for lint and fmt, // so this is ok for now let path = &paths[0]; match sys.fs_is_dir(path) { Ok(is_dir) => Ok( url_from_directory_path(if is_dir { path } else { path.parent().unwrap() }) .unwrap(), ), Err(_err) => { // assume the parent is a directory match path.parent() { Some(parent) => Ok(url_from_directory_path(parent).unwrap()), None => Err( WorkspaceDiscoverErrorKind::FailedResolvingStartDirectory( FailedResolvingStartDirectoryError::CouldNotResolvePath( path.clone(), ), ) .into(), ), } } } } } WorkspaceDiscoverStart::ConfigFile(path) => { let parent = path.parent().ok_or_else(|| { WorkspaceDiscoverErrorKind::FailedResolvingStartDirectory( FailedResolvingStartDirectoryError::PathHasNoParentDirectory( path.to_path_buf(), ), ) })?; Ok(url_from_directory_path(parent).unwrap()) } } } let start_dir = resolve_start_dir(sys, &start)?; let config_file_discovery = discover_workspace_config_files(sys, start, opts)?; let context = match config_file_discovery { ConfigFileDiscovery::None { maybe_vendor_dir: vendor_dir, } => { let start_dir = new_rc(start_dir); let workspace = new_rc(Workspace { config_folders: IndexMap::from([( start_dir.clone(), FolderConfigs::default(), )]), root_dir_url: start_dir.clone(), links: BTreeMap::new(), vendor_dir, cached: Default::default(), }); workspace.resolve_member_dir(&start_dir) } ConfigFileDiscovery::Workspace { workspace } => { workspace.resolve_member_dir(&start_dir) } }; debug_assert!( context .workspace .config_folders .contains_key(&context.workspace.root_dir_url), "root should always have a folder" ); Ok(context) } fn create_from_root_folder(workspace: WorkspaceRc) -> Self { let root_folder = workspace .config_folders .get(&workspace.root_dir_url) .unwrap(); let dir_url = workspace.root_dir_url.clone(); WorkspaceDirectory { dir_url, pkg_json: root_folder.pkg_json.as_ref().map(|config| { WorkspaceDirConfig { member: config.clone(), root: None, } }), deno_json: root_folder.deno_json.as_ref().map(|config| { WorkspaceDirConfig { member: config.clone(), root: None, } }), workspace, cached: Default::default(), } } pub fn jsr_packages_for_publish( self: &WorkspaceDirectoryRc, ) -> Vec { // only publish the current folder if it's a package if let Some(package_config) = self.maybe_package_config() { return vec![package_config]; } if let Some(pkg_json) = &self.pkg_json { let dir_path = url_to_file_path(&self.dir_url).unwrap(); // don't publish anything if in a package.json only directory within // a workspace if pkg_json.member.dir_path().starts_with(&dir_path) && dir_path != pkg_json.member.dir_path() { return Vec::new(); } } if self.dir_url == self.workspace.root_dir_url { self.workspace.jsr_packages().collect() } else { // nothing to publish Vec::new() } } pub fn dir_url(&self) -> &UrlRc { &self.dir_url } pub fn dir_path(&self) -> PathBuf { url_to_file_path(&self.dir_url).unwrap() } pub fn has_deno_or_pkg_json(&self) -> bool { self.has_pkg_json() || self.has_deno_json() } pub fn has_deno_json(&self) -> bool { self.deno_json.is_some() } pub fn has_pkg_json(&self) -> bool { self.pkg_json.is_some() } pub fn maybe_deno_json(&self) -> Option<&ConfigFileRc> { self.deno_json.as_ref().map(|c| &c.member) } pub fn maybe_pkg_json(&self) -> Option<&PackageJsonRc> { self.pkg_json.as_ref().map(|c| &c.member) } pub fn maybe_package_config( self: &WorkspaceDirectoryRc, ) -> Option { let deno_json = self.maybe_deno_json()?; let pkg_name = deno_json.json.name.as_ref()?; if !deno_json.is_package() { return None; } Some(JsrPackageConfig { name: pkg_name.clone(), config_file: deno_json.clone(), member_dir: self.clone(), license: deno_json.to_license(), }) } /// Gets a list of raw compiler options that the user provided, in a vec of /// size 0-2 based on `[maybe_root, maybe_member].flatten()`. pub fn to_configured_compiler_options_sources( &self, ) -> Vec { let Some(deno_json) = self.deno_json.as_ref() else { return Vec::new(); }; let root = deno_json.root.as_ref().map(|d| CompilerOptionsSource { specifier: new_rc(d.specifier.clone()), compiler_options: d .json .compiler_options .as_ref() .filter(|v| !v.is_null()) .cloned() .map(CompilerOptions), }); let member = CompilerOptionsSource { specifier: new_rc(deno_json.member.specifier.clone()), compiler_options: deno_json .member .json .compiler_options .as_ref() .filter(|v| !v.is_null()) .cloned() .map(CompilerOptions), }; root.into_iter().chain([member]).collect() } pub fn to_lint_config( &self, cli_args: FilePatterns, ) -> Result { let mut config = self.to_lint_config_inner()?; self.exclude_includes_with_member_for_base_for_root(&mut config.files); combine_files_config_with_cli_args(&mut config.files, cli_args); self.append_workspace_members_to_exclude(&mut config.files); Ok(config) } fn to_lint_config_inner( &self, ) -> Result { let Some(deno_json) = self.deno_json.as_ref() else { return Ok(WorkspaceDirLintConfig { rules: Default::default(), plugins: Default::default(), files: FilePatterns::new_with_base( url_to_file_path(&self.dir_url).unwrap(), ), }); }; let member_config = deno_json.member.to_lint_config()?; let root_config = deno_json .root .as_ref() .map(|root| root.to_lint_config()) .transpose()?; // 1. Merge workspace root + member plugins // 2. Workspace member can filter out plugins by negating // like this: `!my-plugin` // 3. Remove duplicates in case a plugin was defined in both // workspace root and member. let excluded_plugins = member_config .options .plugins .iter() .filter(|plugin| plugin.specifier.starts_with('!')) .map(|plugin| { deno_json .member .specifier .join(&plugin.specifier[1..]) .map_err(|err| ToInvalidConfigError::InvalidConfig { config: "lint", source: err.into(), }) }) .collect::, _>>()?; let plugins = root_config .iter() .flat_map(|root_config| &root_config.options.plugins) .chain(&member_config.options.plugins) .filter(|plugin| !plugin.specifier.starts_with('!')) .map(|plugin| { plugin.base.join(&plugin.specifier).map_err(|err| { ToInvalidConfigError::InvalidConfig { config: "lint", source: err.into(), } }) }) .collect::, _>>()? .into_iter() .filter(|plugin| !excluded_plugins.contains(plugin)) .collect::>(); let (rules, files) = match root_config { Some(root_config) => ( LintRulesConfig { tags: combine_option_vecs( root_config.options.rules.tags, member_config.options.rules.tags, ), include: combine_option_vecs_with_override( CombineOptionVecsWithOverride { root: root_config.options.rules.include, member: member_config .options .rules .include .as_ref() .map(Cow::Borrowed), member_override_root: member_config .options .rules .exclude .as_ref(), }, ), exclude: combine_option_vecs_with_override( CombineOptionVecsWithOverride { root: root_config.options.rules.exclude, member: member_config.options.rules.exclude.map(Cow::Owned), member_override_root: member_config .options .rules .include .as_ref(), }, ), }, combine_patterns(root_config.files, member_config.files), ), None => (member_config.options.rules, member_config.files), }; Ok(WorkspaceDirLintConfig { plugins, rules, files, }) } pub fn to_fmt_config( &self, cli_args: FilePatterns, ) -> Result { let mut config = self.to_fmt_config_inner()?; self.exclude_includes_with_member_for_base_for_root(&mut config.files); combine_files_config_with_cli_args(&mut config.files, cli_args); self.append_workspace_members_to_exclude(&mut config.files); Ok(config) } fn to_fmt_config_inner(&self) -> Result { let Some(deno_json) = self.deno_json.as_ref() else { return Ok(FmtConfig { files: FilePatterns::new_with_base( url_to_file_path(&self.dir_url).unwrap(), ), options: Default::default(), }); }; let member_config = deno_json.member.to_fmt_config()?; let root_config = match &deno_json.root { Some(root) => root.to_fmt_config()?, None => return Ok(member_config), }; Ok(FmtConfig { options: FmtOptionsConfig { use_tabs: member_config .options .use_tabs .or(root_config.options.use_tabs), line_width: member_config .options .line_width .or(root_config.options.line_width), indent_width: member_config .options .indent_width .or(root_config.options.indent_width), single_quote: member_config .options .single_quote .or(root_config.options.single_quote), prose_wrap: member_config .options .prose_wrap .or(root_config.options.prose_wrap), semi_colons: member_config .options .semi_colons .or(root_config.options.semi_colons), quote_props: member_config .options .quote_props .or(root_config.options.quote_props), new_line_kind: member_config .options .new_line_kind .or(root_config.options.new_line_kind), use_braces: member_config .options .use_braces .or(root_config.options.use_braces), brace_position: member_config .options .brace_position .or(root_config.options.brace_position), single_body_position: member_config .options .single_body_position .or(root_config.options.single_body_position), next_control_flow_position: member_config .options .next_control_flow_position .or(root_config.options.next_control_flow_position), trailing_commas: member_config .options .trailing_commas .or(root_config.options.trailing_commas), operator_position: member_config .options .operator_position .or(root_config.options.operator_position), jsx_bracket_position: member_config .options .jsx_bracket_position .or(root_config.options.jsx_bracket_position), jsx_force_new_lines_surrounding_content: member_config .options .jsx_force_new_lines_surrounding_content .or(root_config.options.jsx_force_new_lines_surrounding_content), jsx_multi_line_parens: member_config .options .jsx_multi_line_parens .or(root_config.options.jsx_multi_line_parens), type_literal_separator_kind: member_config .options .type_literal_separator_kind .or(root_config.options.type_literal_separator_kind), space_around: member_config .options .space_around .or(root_config.options.space_around), space_surrounding_properties: member_config .options .space_surrounding_properties .or(root_config.options.space_surrounding_properties), }, files: combine_patterns(root_config.files, member_config.files), }) } pub fn to_bench_config( &self, cli_args: FilePatterns, ) -> Result { let mut config = self.to_bench_config_inner()?.clone(); self.exclude_includes_with_member_for_base_for_root(&mut config.files); combine_files_config_with_cli_args(&mut config.files, cli_args); self.append_workspace_members_to_exclude(&mut config.files); Ok(config) } fn to_bench_config_inner( &self, ) -> Result<&BenchConfig, ToInvalidConfigError> { if let Some(config) = self.cached.bench.get() { Ok(config) } else { let config = self.to_bench_config_inner_no_cache()?; _ = self.cached.bench.set(config); Ok(self.cached.bench.get().unwrap()) } } fn to_bench_config_inner_no_cache( &self, ) -> Result { let Some(deno_json) = self.deno_json.as_ref() else { return Ok(BenchConfig { files: FilePatterns::new_with_base( url_to_file_path(&self.dir_url).unwrap(), ), permissions: None, }); }; let permissions = self.to_permissions_config()?; let member_config = deno_json.member.to_bench_config(permissions)?; let root_config = match &deno_json.root { Some(root) => root.to_bench_config(permissions)?, None => return Ok(member_config), }; Ok(BenchConfig { files: combine_patterns(root_config.files, member_config.files), permissions: match (root_config.permissions, member_config.permissions) { (_, Some(m)) => Some(m), (Some(r), _) => Some(r), (None, None) => None, }, }) } pub fn to_compile_config( &self, ) -> Result<&CompileConfig, ToInvalidConfigError> { if let Some(config) = &self.cached.compile.get() { Ok(config) } else { let config = self.to_compile_config_no_cache()?; _ = self.cached.compile.set(config); Ok(self.cached.compile.get().unwrap()) } } fn to_compile_config_no_cache( &self, ) -> Result { let Some(deno_json) = self.deno_json.as_ref() else { return Ok(CompileConfig { permissions: None }); }; let permissions = self.to_permissions_config()?; let member_config = deno_json.member.to_compile_config(permissions)?; let root_config = match &deno_json.root { Some(root) => root.to_compile_config(permissions)?, None => return Ok(member_config), }; Ok(CompileConfig { permissions: match (root_config.permissions, member_config.permissions) { (_, Some(m)) => Some(m), (Some(r), _) => Some(r), (None, None) => None, }, }) } pub fn to_tasks_config( &self, ) -> Result { fn to_member_tasks_config( maybe_deno_json: Option<&ConfigFileRc>, maybe_pkg_json: Option<&PackageJsonRc>, ) -> Result, ToTasksConfigError> { let config = WorkspaceMemberTasksConfig { deno_json: match maybe_deno_json { Some(deno_json) => deno_json .to_tasks_config() .map(|tasks| { tasks.map(|tasks| WorkspaceMemberTasksConfigFile { folder_url: url_parent(&deno_json.specifier), tasks, package_name: deno_json.json.name.clone(), }) }) .map_err(|error| ToTasksConfigError { specifier: deno_json.specifier.clone(), error, })?, None => None, }, package_json: match maybe_pkg_json { Some(pkg_json) => pkg_json.scripts.clone().map(|scripts| { WorkspaceMemberTasksConfigFile { folder_url: url_parent(&pkg_json.specifier()), tasks: scripts, package_name: pkg_json.name.clone(), } }), None => None, }, }; if config.deno_json.is_none() && config.package_json.is_none() { return Ok(None); } Ok(Some(config)) } Ok(WorkspaceTasksConfig { root: to_member_tasks_config( self.deno_json.as_ref().and_then(|d| d.root.as_ref()), self.pkg_json.as_ref().and_then(|d| d.root.as_ref()), )?, member: to_member_tasks_config( self.deno_json.as_ref().map(|d| &d.member), self.pkg_json.as_ref().map(|d| &d.member), )?, }) } pub fn to_permissions_config( &self, ) -> Result<&PermissionsConfig, ToInvalidConfigError> { if let Some(value) = self.cached.permissions.get() { Ok(value) } else { let base = match self.deno_json.as_ref().and_then(|c| c.root.as_ref()) { Some(value) => value.to_permissions_config()?, None => Default::default(), }; let member = match self.deno_json.as_ref().map(|c| &c.member) { Some(value) => value.to_permissions_config()?, None => Default::default(), }; let value = base.merge(member); _ = self.cached.permissions.set(value); Ok(self.cached.permissions.get().unwrap()) } } pub fn to_bench_permissions_config( &self, ) -> Result, ToInvalidConfigError> { Ok(self.to_bench_config_inner()?.permissions.as_deref()) } pub fn to_compile_permissions_config( &self, ) -> Result, ToInvalidConfigError> { Ok(self.to_compile_config()?.permissions.as_deref()) } pub fn to_test_permissions_config( &self, ) -> Result, ToInvalidConfigError> { Ok(self.to_test_config_inner()?.permissions.as_deref()) } pub fn to_publish_config( &self, ) -> Result { let mut config = self.to_publish_config_inner()?; self.exclude_includes_with_member_for_base_for_root(&mut config.files); self.append_workspace_members_to_exclude(&mut config.files); Ok(config) } fn to_publish_config_inner( &self, ) -> Result { let Some(deno_json) = self.deno_json.as_ref() else { return Ok(PublishConfig { files: FilePatterns::new_with_base( url_to_file_path(&self.dir_url).unwrap(), ), }); }; let member_config = deno_json.member.to_publish_config()?; let root_config = match &deno_json.root { Some(root) => root.to_publish_config()?, None => return Ok(member_config), }; Ok(PublishConfig { files: combine_patterns(root_config.files, member_config.files), }) } pub fn to_test_config( &self, cli_args: FilePatterns, ) -> Result { let mut config = self.to_test_config_inner()?.clone(); self.exclude_includes_with_member_for_base_for_root(&mut config.files); combine_files_config_with_cli_args(&mut config.files, cli_args); self.append_workspace_members_to_exclude(&mut config.files); Ok(config) } fn to_test_config_inner(&self) -> Result<&TestConfig, ToInvalidConfigError> { if let Some(config) = self.cached.test.get() { Ok(config) } else { let value = self.to_test_config_inner_no_cache()?; _ = self.cached.test.set(value); Ok(self.cached.test.get().unwrap()) } } fn to_test_config_inner_no_cache( &self, ) -> Result { let Some(deno_json) = self.deno_json.as_ref() else { return Ok(TestConfig { files: FilePatterns::new_with_base( url_to_file_path(&self.dir_url).unwrap(), ), permissions: None, }); }; let permissions = self.to_permissions_config()?; let member_config = deno_json.member.to_test_config(permissions)?; let root_config = match &deno_json.root { Some(root) => root.to_test_config(permissions)?, None => return Ok(member_config), }; Ok(TestConfig { files: combine_patterns(root_config.files, member_config.files), permissions: match (root_config.permissions, member_config.permissions) { (_, Some(m)) => Some(m), (Some(r), _) => Some(r), (None, None) => None, }, }) } pub fn to_deploy_config( &self, ) -> Result, ToInvalidConfigError> { let config = if let Some(deno_json) = self.deno_json.as_ref() { if let Some(config) = deno_json.member.to_deploy_config()? { Some(config) } else { match &deno_json.root { Some(root) => root.to_deploy_config()?, None => None, } } } else { None }; Ok(config) } /// Removes any "include" patterns from the root files that have /// a base in another workspace member. fn exclude_includes_with_member_for_base_for_root( &self, files: &mut FilePatterns, ) { let Some(include) = &mut files.include else { return; }; let root_url = self.workspace.root_dir_url(); if self.dir_url != *root_url { return; // only do this for the root config } let root_folder_configs = self.workspace.root_folder_configs(); let maybe_root_deno_json = root_folder_configs.deno_json.as_ref(); let non_root_deno_jsons = match maybe_root_deno_json { Some(root_deno_json) => self .workspace .deno_jsons() .filter(|d| d.specifier != root_deno_json.specifier) .collect::>(), None => self.workspace.deno_jsons().collect::>(), }; let include = include.inner_mut(); for i in (0..include.len()).rev() { let Some(path) = include[i].base_path() else { continue; }; for deno_json in non_root_deno_jsons.iter() { if path.starts_with(deno_json.dir_path()) { include.remove(i); break; } } } } fn append_workspace_members_to_exclude(&self, files: &mut FilePatterns) { files.exclude.append( self .workspace .deno_jsons() .filter(|member_deno_json| { let member_dir = member_deno_json.dir_path(); member_dir != files.base && member_dir.starts_with(&files.base) }) .map(|d| PathOrPattern::Path(d.dir_path())), ); } } pub enum TaskOrScript<'a> { /// A task from a deno.json. Task { details: &'a WorkspaceMemberTasksConfigFile, task: &'a TaskDefinition, }, /// A script from a package.json. Script { details: &'a WorkspaceMemberTasksConfigFile, task: &'a str, }, } impl<'a> TaskOrScript<'a> { pub fn package_name(&self) -> Option<&'a str> { match self { TaskOrScript::Task { details, .. } => details.package_name.as_deref(), TaskOrScript::Script { details, .. } => details.package_name.as_deref(), } } pub fn folder_url(&self) -> &'a Url { match self { TaskOrScript::Task { details, .. } => &details.folder_url, TaskOrScript::Script { details, .. } => &details.folder_url, } } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct WorkspaceMemberTasksConfigFile { pub package_name: Option, pub folder_url: Url, pub tasks: IndexMap, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct WorkspaceMemberTasksConfig { pub deno_json: Option>, pub package_json: Option>, } impl WorkspaceMemberTasksConfig { pub fn with_only_pkg_json(self) -> Self { WorkspaceMemberTasksConfig { deno_json: None, package_json: self.package_json, } } pub fn is_empty(&self) -> bool { self .deno_json .as_ref() .map(|d| d.tasks.is_empty()) .unwrap_or(true) && self .package_json .as_ref() .map(|d| d.tasks.is_empty()) .unwrap_or(true) } pub fn task_names(&self) -> impl Iterator { self .deno_json .as_ref() .into_iter() .flat_map(|d| d.tasks.keys()) .chain( self .package_json .as_ref() .into_iter() .flat_map(|d| d.tasks.keys()) .filter(|pkg_json_key| { self .deno_json .as_ref() .map(|d| !d.tasks.contains_key(pkg_json_key.as_str())) .unwrap_or(true) }), ) .map(|s| s.as_str()) } pub fn tasks_count(&self) -> usize { self.deno_json.as_ref().map(|d| d.tasks.len()).unwrap_or(0) + self .package_json .as_ref() .map(|d| d.tasks.len()) .unwrap_or(0) } pub fn task(&self, name: &str) -> Option> { self .deno_json .as_ref() .and_then(|config| { config.tasks.get(name).map(|task| TaskOrScript::Task { details: config, task, }) }) .or_else(|| { self.package_json.as_ref().and_then(|config| { config.tasks.get(name).map(|script| TaskOrScript::Script { details: config, task: script, }) }) }) } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct WorkspaceTasksConfig { pub root: Option, pub member: Option, } impl WorkspaceTasksConfig { pub fn with_only_pkg_json(self) -> Self { WorkspaceTasksConfig { root: self.root.map(|c| c.with_only_pkg_json()), member: self.member.map(|c| c.with_only_pkg_json()), } } pub fn task_names(&self) -> impl Iterator { self .member .as_ref() .into_iter() .flat_map(|r| r.task_names()) .chain( self .root .as_ref() .into_iter() .flat_map(|m| m.task_names()) .filter(|root_key| { self .member .as_ref() .map(|m| m.task(root_key).is_none()) .unwrap_or(true) }), ) } pub fn task(&self, name: &str) -> Option> { self .member .as_ref() .and_then(|m| m.task(name)) .or_else(|| self.root.as_ref().and_then(|r| r.task(name))) } pub fn is_empty(&self) -> bool { self.root.as_ref().map(|r| r.is_empty()).unwrap_or(true) && self.member.as_ref().map(|r| r.is_empty()).unwrap_or(true) } pub fn tasks_count(&self) -> usize { self.root.as_ref().map(|r| r.tasks_count()).unwrap_or(0) + self.member.as_ref().map(|r| r.tasks_count()).unwrap_or(0) } } fn combine_patterns( root_patterns: FilePatterns, member_patterns: FilePatterns, ) -> FilePatterns { FilePatterns { include: { match root_patterns.include { Some(root) => { let filtered_root = root.into_path_or_patterns().into_iter().filter(|p| { match p.base_path() { Some(base) => base.starts_with(&member_patterns.base), None => true, } }); match member_patterns.include { Some(member) => Some( filtered_root .chain(member.into_path_or_patterns()) .collect(), ), None => { let matching_root = filtered_root.collect::>(); if matching_root.is_empty() { // member was None and nothing in the root include list // has a base within this member, so use None to discover // files in here None } else { Some(matching_root) } } } .map(PathOrPatternSet::new) } None => member_patterns.include, } }, exclude: { // have the root excludes at the front because they're lower priority let patterns = root_patterns .exclude .into_path_or_patterns() .into_iter() .filter(|p| match p { PathOrPattern::Path(path) | PathOrPattern::NegatedPath(path) => path.starts_with(&member_patterns.base), PathOrPattern::RemoteUrl(_) | // always include patterns because they may be something like ./**/*.ts in the root PathOrPattern::Pattern(_) => true, }) .chain(member_patterns.exclude.into_path_or_patterns()) .collect::>(); PathOrPatternSet::new(patterns) }, base: member_patterns.base, } } fn combine_files_config_with_cli_args( files_config: &mut FilePatterns, cli_arg_patterns: FilePatterns, ) { if cli_arg_patterns.base.starts_with(&files_config.base) || !files_config.base.starts_with(&cli_arg_patterns.base) { files_config.base = cli_arg_patterns.base; } if let Some(include) = cli_arg_patterns.include && !include.inner().is_empty() { files_config.include = Some(include); } if !cli_arg_patterns.exclude.inner().is_empty() { files_config.exclude = cli_arg_patterns.exclude; } } #[allow(clippy::owned_cow)] struct CombineOptionVecsWithOverride<'a, T: Clone> { root: Option>, member: Option>>, member_override_root: Option<&'a Vec>, } fn combine_option_vecs_with_override( opts: CombineOptionVecsWithOverride, ) -> Option> { let root = opts.root.map(|r| { let member_override_root = opts .member_override_root .map(|p| p.iter().collect::>()) .unwrap_or_default(); r.into_iter() .filter(|p| !member_override_root.contains(p)) .collect::>() }); match (root, opts.member) { (Some(root), Some(member)) => { let capacity = root.len() + member.len(); Some(match member { Cow::Owned(m) => { remove_duplicates_iterator(root.into_iter().chain(m), capacity) } Cow::Borrowed(m) => remove_duplicates_iterator( root.into_iter().chain(m.iter().map(|c| (*c).clone())), capacity, ), }) } (Some(root), None) => Some(root), (None, Some(member)) => Some(match member { Cow::Owned(m) => m, Cow::Borrowed(m) => m.iter().map(|c| (*c).clone()).collect(), }), (None, None) => None, } } fn combine_option_vecs( root_option: Option>, member_option: Option>, ) -> Option> { match (root_option, member_option) { (Some(root), Some(member)) => { if root.is_empty() { return Some(member); } if member.is_empty() { return Some(root); } let capacity = root.len() + member.len(); Some(remove_duplicates_iterator( root.into_iter().chain(member), capacity, )) } (Some(root), None) => Some(root), (None, Some(member)) => Some(member), (None, None) => None, } } fn remove_duplicates_iterator( iterator: impl IntoIterator, capacity: usize, ) -> Vec { let mut seen = HashSet::with_capacity(capacity); let mut result = Vec::with_capacity(capacity); for item in iterator { if seen.insert(item.clone()) { result.push(item); } } result } fn parent_specifier_str(specifier: &str) -> Option<&str> { let specifier = specifier.strip_suffix('/').unwrap_or(specifier); if let Some(index) = specifier.rfind('/') { Some(&specifier[..index + 1]) } else { None } } fn is_valid_jsr_pkg_name(name: &str) -> bool { let jsr = deno_semver::jsr::JsrPackageReqReference::from_str(&format!( "jsr:{}@*", name )); match jsr { Ok(jsr) => jsr.sub_path().is_none(), Err(_) => false, } } #[cfg(test)] pub mod test { use std::cell::RefCell; use std::collections::HashMap; use deno_path_util::normalize_path; use deno_path_util::url_from_directory_path; use deno_path_util::url_from_file_path; use pretty_assertions::assert_eq; use serde_json::json; use sys_traits::impls::InMemorySys; use super::*; use crate::assert_contains; use crate::deno_json::BracePosition; use crate::deno_json::BracketPosition; use crate::deno_json::DenoJsonCache; use crate::deno_json::MultiLineParens; use crate::deno_json::NewLineKind; use crate::deno_json::NextControlFlowPosition; use crate::deno_json::OperatorPosition; use crate::deno_json::ProseWrap; use crate::deno_json::QuoteProps; use crate::deno_json::SeparatorKind; use crate::deno_json::SingleBodyPosition; use crate::deno_json::TrailingCommas; use crate::deno_json::UseBraces; use crate::glob::FileCollector; use crate::glob::GlobPattern; use crate::glob::PathKind; use crate::glob::PathOrPattern; pub struct UnreachableSys; impl sys_traits::BaseFsMetadata for UnreachableSys { type Metadata = sys_traits::impls::RealFsMetadata; #[doc(hidden)] fn base_fs_metadata( &self, _path: &Path, ) -> std::io::Result { unreachable!() } #[doc(hidden)] fn base_fs_symlink_metadata( &self, _path: &Path, ) -> std::io::Result { unreachable!() } } impl sys_traits::BaseFsRead for UnreachableSys { fn base_fs_read( &self, _path: &Path, ) -> std::io::Result> { unreachable!() } } fn root_dir() -> PathBuf { if cfg!(windows) { PathBuf::from("C:\\Users\\user") } else { PathBuf::from("/home/user") } } #[test] fn test_empty_workspaces() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("deno.json"), json!({ "workspace": [] }), ); sys.fs_insert_json( root_dir().join("sub_dir").join("deno.json"), json!({ "workspace": [] }), ); let workspace_dir = WorkspaceDirectory::discover( &sys, WorkspaceDiscoverStart::Paths(&[root_dir().join("sub_dir")]), &WorkspaceDiscoverOptions { ..Default::default() }, ) .unwrap(); assert_eq!( workspace_dir .workspace .deno_jsons() .map(|d| d.specifier.to_file_path().unwrap()) .collect::>(), vec![root_dir().join("sub_dir").join("deno.json")] ); } #[test] fn test_duplicate_members() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("deno.json"), json!({ "workspace": ["./member/a", "./member/../member/a"], }), ); sys.fs_insert_json(root_dir().join("member/a/deno.json"), json!({})); let workspace_config_err = WorkspaceDirectory::discover( &sys, WorkspaceDiscoverStart::Paths(&[root_dir()]), &WorkspaceDiscoverOptions { ..Default::default() }, ) .err() .unwrap(); assert_contains!( workspace_config_err.to_string(), "Cannot specify a workspace member twice ('./member/../member/a')." ); } #[test] fn test_workspace_invalid_self_reference() { for reference in [".", "../sub_dir"] { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("sub_dir").join("deno.json"), json!({ "workspace": [reference], }), ); let workspace_config_err = WorkspaceDirectory::discover( &sys, WorkspaceDiscoverStart::Paths(&[root_dir().join("sub_dir")]), &WorkspaceDiscoverOptions { ..Default::default() }, ) .err() .unwrap(); assert_contains!( workspace_config_err.to_string(), &format!( "Remove the reference to the current config file (\"{reference}\") in \"workspaces\"." ) ); } } #[test] fn test_workspaces_outside_root_config_dir() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("deno.json"), json!({ "workspace": ["../a"] }), ); let workspace_config_err = WorkspaceDirectory::discover( &sys, WorkspaceDiscoverStart::Paths(&[root_dir()]), &WorkspaceDiscoverOptions { ..Default::default() }, ) .err() .unwrap(); assert_contains!( workspace_config_err.to_string(), "Workspace member must be nested in a directory under the workspace." ); } #[test] fn test_workspaces_json_jsonc() { let sys = InMemorySys::default(); let config_text = json!({ "workspace": [ "./a", "./b", ], }); let config_text_a = json!({ "name": "a", "version": "0.1.0" }); let config_text_b = json!({ "name": "b", "version": "0.2.0" }); sys.fs_insert_json(root_dir().join("deno.json"), config_text); sys.fs_insert_json(root_dir().join("a/deno.json"), config_text_a); sys.fs_insert_json(root_dir().join("b/deno.jsonc"), config_text_b); let workspace_dir = WorkspaceDirectory::discover( &sys, WorkspaceDiscoverStart::Paths(&[root_dir()]), &WorkspaceDiscoverOptions { ..Default::default() }, ) .unwrap(); assert_eq!(workspace_dir.workspace.config_folders.len(), 3); } #[test] fn test_tasks() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("deno.json"), json!({ "workspace": ["./member", "./pkg_json"], "tasks": { "hi": "echo hi", "overwrite": "echo overwrite" } }), ); sys.fs_insert_json( root_dir().join("member/deno.json"), json!({ "tasks": { "overwrite": "echo overwritten", "bye": "echo bye" } }), ); sys.fs_insert_json( root_dir().join("pkg_json/package.json"), json!({ "scripts": { "script": "echo 1" } }), ); let workspace_dir = WorkspaceDirectory::discover( &sys, // start at root for this test WorkspaceDiscoverStart::Paths(&[root_dir()]), &WorkspaceDiscoverOptions { discover_pkg_json: true, ..Default::default() }, ) .unwrap(); assert_eq!(workspace_dir.workspace.diagnostics(), vec![]); let root_deno_json = Some(WorkspaceMemberTasksConfigFile { folder_url: url_from_directory_path(&root_dir()).unwrap(), package_name: None, tasks: IndexMap::from([ ("hi".to_string(), "echo hi".into()), ("overwrite".to_string(), "echo overwrite".into()), ]), }); let root = Some(WorkspaceMemberTasksConfig { deno_json: root_deno_json.clone(), package_json: None, }); // root { let tasks_config = workspace_dir.to_tasks_config().unwrap(); assert_eq!( tasks_config, WorkspaceTasksConfig { root: None, // the root context will have the root config as the member config member: root.clone(), } ); assert_eq!( tasks_config.task_names().collect::>(), ["hi", "overwrite"] ); } // member { let member_dir = workspace_dir.workspace.resolve_member_dir( &url_from_directory_path(&root_dir().join("member/deno.json")).unwrap(), ); let tasks_config = member_dir.to_tasks_config().unwrap(); assert_eq!( tasks_config, WorkspaceTasksConfig { root: root.clone(), member: Some(WorkspaceMemberTasksConfig { deno_json: Some(WorkspaceMemberTasksConfigFile { folder_url: url_from_directory_path(&root_dir().join("member")) .unwrap(), package_name: None, tasks: IndexMap::from([ ("overwrite".to_string(), "echo overwritten".into()), ("bye".to_string(), "echo bye".into()), ]), }), package_json: None, }), } ); assert_eq!( tasks_config.task_names().collect::>(), ["overwrite", "bye", "hi"] ); } // pkg json { let member_dir = workspace_dir.workspace.resolve_member_dir( &url_from_directory_path(&root_dir().join("pkg_json/package.json")) .unwrap(), ); let tasks_config = member_dir.to_tasks_config().unwrap(); assert_eq!( tasks_config, WorkspaceTasksConfig { root: None, member: Some(WorkspaceMemberTasksConfig { deno_json: root_deno_json.clone(), package_json: Some(WorkspaceMemberTasksConfigFile { folder_url: url_from_directory_path(&root_dir().join("pkg_json")) .unwrap(), package_name: None, tasks: IndexMap::from([( "script".to_string(), "echo 1".to_string() )]), }), }) } ); assert_eq!( tasks_config.task_names().collect::>(), ["hi", "overwrite", "script"] ); } } #[test] fn test_root_member_import_map() { let workspace_dir = workspace_for_root_and_member_with_fs( json!({ "importMap": "./other.json", }), json!({ "importMap": "./member.json", }), |fs| { fs.fs_insert_json(root_dir().join("other.json"), json!({})); fs.fs_insert_json(root_dir().join("member/member.json"), json!({})); }, ); assert_eq!( workspace_dir .workspace .to_import_map_path() .unwrap() .unwrap(), root_dir().join("other.json"), ); assert_eq!( workspace_dir.workspace.diagnostics(), vec![WorkspaceDiagnostic { kind: WorkspaceDiagnosticKind::RootOnlyOption("importMap"), config_url: Url::from_file_path(root_dir().join("member/deno.json")) .unwrap(), }] ); } #[test] fn test_root_member_link() { let workspace_dir = workspace_for_root_and_member_with_fs( json!({ "links": ["../dir"], }), json!({ "links": [ "../../dir" ], }), |fs| { fs.fs_insert_json(root_dir().join("../dir/deno.json"), json!({})); }, ); assert_eq!( workspace_dir.workspace.diagnostics(), vec![WorkspaceDiagnostic { kind: WorkspaceDiagnosticKind::RootOnlyOption("links"), config_url: Url::from_file_path(root_dir().join("member/deno.json")) .unwrap(), }] ); } #[test] fn test_link_of_link() { let workspace_dir = workspace_for_root_and_member_with_fs( json!({ "links": ["../dir"], }), json!({}), |fs| { fs.fs_insert_json( root_dir().join("../dir/deno.json"), json!({ "links": ["./subdir"] // will be ignored }), ); }, ); assert_eq!( workspace_dir.workspace.diagnostics(), vec![WorkspaceDiagnostic { kind: WorkspaceDiagnosticKind::RootOnlyOption("links"), config_url: url_from_directory_path(&root_dir()) .unwrap() .join("../dir/deno.json") .unwrap(), }] ); } #[test] fn test_link_not_exists() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("deno.json"), json!({ "links": ["./member"] }), ); let err = workspace_at_start_dir_err(&sys, &root_dir()); match err.into_kind() { WorkspaceDiscoverErrorKind::ResolveLink { link, base, source } => { assert_eq!(link, "./member"); assert_eq!(base, url_from_directory_path(&root_dir()).unwrap()); match source.into_kind() { ResolveWorkspaceLinkErrorKind::NotFound { dir_url } => { assert_eq!( dir_url, url_from_directory_path(&root_dir().join("member")).unwrap() ); } _ => unreachable!(), } } _ => unreachable!(), } } #[test] fn test_link_workspace_member() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("deno.json"), json!({ "workspace": ["./member"], "links": ["./member"] }), ); sys.fs_insert_json(root_dir().join("member/deno.json"), json!({})); let err = workspace_at_start_dir_err(&sys, &root_dir()); match err.into_kind() { WorkspaceDiscoverErrorKind::ResolveLink { link, base, source } => { assert_eq!(link, "./member"); assert_eq!(base, url_from_directory_path(&root_dir()).unwrap()); assert!(matches!( source.into_kind(), ResolveWorkspaceLinkErrorKind::WorkspaceMemberNotAllowed )); } _ => unreachable!(), } } #[test] fn test_link_npm_package() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("pkg/deno.json"), json!({ "links": ["../dir"] }), ); sys.fs_insert_json(root_dir().join("dir/package.json"), json!({})); let workspace_dir = workspace_at_start_dir(&sys, &root_dir().join("pkg")); assert_eq!(workspace_dir.workspace.diagnostics(), vec![]); let link_folders = workspace_dir .workspace .link_folders() .values() .collect::>(); assert_eq!(link_folders.len(), 1); assert_eq!( link_folders[0].pkg_json.as_ref().unwrap().specifier(), url_from_file_path(&root_dir().join("dir/package.json")).unwrap() ) } #[test] fn test_link_absolute_path() { let root_path = root_dir().join("../dir"); let workspace_dir = workspace_for_root_and_member_with_fs( json!({ "links": [root_path.to_string_lossy().into_owned()], }), json!({}), |fs| { fs.fs_insert_json(root_dir().join("../dir/deno.json"), json!({})); }, ); assert_eq!(workspace_dir.workspace.diagnostics(), vec![]); let link_folders = workspace_dir .workspace .link_folders() .values() .collect::>(); assert_eq!(link_folders.len(), 1); assert_eq!( link_folders[0].deno_json.as_ref().unwrap().specifier, url_from_file_path(&root_dir().join("../dir/deno.json")).unwrap() ) } #[test] fn test_root_member_imports_and_scopes() { let workspace_dir = workspace_for_root_and_member( json!({ "imports": { "@scope/pkg": "jsr:@scope/pkg@1" }, "scopes": { "https://deno.land/x/": { "@scope/pkg": "jsr:@scope/pkg@2" } } }), json!({ "imports": { "@scope/pkg": "jsr:@scope/pkg@3" }, // will ignore this scopes because it's not in the root "scopes": { "https://deno.land/x/other": { "@scope/pkg": "jsr:@scope/pkg@4" } } }), ); assert_eq!( workspace_dir.workspace.diagnostics(), vec![WorkspaceDiagnostic { kind: WorkspaceDiagnosticKind::RootOnlyOption("scopes"), config_url: Url::from_file_path(root_dir().join("member/deno.json")) .unwrap(), }] ); } #[test] fn test_deprecated_patch() { let workspace_dir = workspace_for_root_and_member_with_fs( json!({ "patch": ["../dir"], }), json!({}), |fs| { fs.fs_insert_json(root_dir().join("../dir/deno.json"), json!({})); }, ); assert_eq!( workspace_dir.workspace.diagnostics(), vec![WorkspaceDiagnostic { kind: WorkspaceDiagnosticKind::DeprecatedPatch, config_url: Url::from_file_path(root_dir().join("deno.json")).unwrap(), }] ); assert_eq!(workspace_dir.workspace.link_folders().len(), 1); // should still work though } #[test] fn test_imports_with_import_map() { let workspace_dir = workspace_for_root_and_member_with_fs( json!({ "imports": {}, "importMap": "./other.json", }), json!({}), |fs| { fs.fs_insert_json(root_dir().join("other.json"), json!({})); }, ); assert_eq!( workspace_dir .workspace .to_import_map_path() .unwrap() .unwrap(), root_dir().join("other.json") ); assert_eq!( workspace_dir.workspace.diagnostics(), vec![WorkspaceDiagnostic { kind: WorkspaceDiagnosticKind::ImportMapReferencingImportMap, config_url: Url::from_file_path(root_dir().join("deno.json")).unwrap(), }] ); } #[test] fn test_root_import_map_with_member_imports_and_scopes() { let workspace_dir = workspace_for_root_and_member( json!({ "importMap": "./other.json" }), json!({ "imports": { "@scope/pkg": "jsr:@scope/pkg@3" } }), ); assert_eq!( workspace_dir.workspace.diagnostics(), vec![WorkspaceDiagnostic { kind: WorkspaceDiagnosticKind::MemberImportsScopesIgnored, config_url: Url::from_file_path(root_dir().join("member/deno.json")) .unwrap(), }] ); } #[test] fn test_root_member_exclude() { let workspace_dir = workspace_for_root_and_member( json!({ "exclude": [ "./root", "./member/vendor", "./**/*.js" ] }), json!({ "exclude": [ "./member_exclude", // unexclude from root "!./vendor" ] }), ); assert_eq!(workspace_dir.workspace.diagnostics(), vec![]); let lint_config = workspace_dir .to_lint_config(FilePatterns::new_with_base(workspace_dir.dir_path())) .unwrap(); assert_eq!( lint_config.files, FilePatterns { base: root_dir().join("member"), include: None, exclude: PathOrPatternSet::new(vec![ PathOrPattern::Path(root_dir().join("member").join("vendor")), PathOrPattern::Pattern( GlobPattern::from_relative(&root_dir(), "./**/*.js").unwrap() ), PathOrPattern::Path(root_dir().join("member").join("member_exclude")), PathOrPattern::NegatedPath(root_dir().join("member").join("vendor")), ]), } ); // will match because it was unexcluded in the member assert!( lint_config .files .matches_path(&root_dir().join("member/vendor"), PathKind::Directory) ) } #[test] fn test_root_member_lint_combinations() { let workspace_dir = workspace_for_root_and_member( json!({ "lint": { "report": "json", "rules": { "tags": ["tag1"], "include": ["rule1"], "exclude": ["rule2"], }, "plugins": ["jsr:@deno/test-plugin1", "jsr:@deno/test-plugin3"] } }), json!({ "lint": { "report": "pretty", "include": ["subdir"], "rules": { "tags": ["tag1"], "include": ["rule2"], }, "plugins": [ "jsr:@deno/test-plugin1", "jsr:@deno/test-plugin2", "!jsr:@deno/test-plugin3" ] } }), ); assert_eq!( workspace_dir.workspace.diagnostics(), vec![WorkspaceDiagnostic { kind: WorkspaceDiagnosticKind::RootOnlyOption("lint.report"), config_url: Url::from_file_path(root_dir().join("member/deno.json")) .unwrap(), }] ); assert_eq!( workspace_dir.workspace.to_lint_config().unwrap(), WorkspaceLintConfig { report: Some("json".to_string()), } ); let lint_config = workspace_dir .to_lint_config(FilePatterns::new_with_base(workspace_dir.dir_path())) .unwrap(); assert_eq!( lint_config, WorkspaceDirLintConfig { rules: LintRulesConfig { tags: Some(vec!["tag1".to_string()]), include: Some(vec!["rule1".to_string(), "rule2".to_string()]), exclude: Some(vec![]), }, plugins: vec![ Url::parse("jsr:@deno/test-plugin1").unwrap(), Url::parse("jsr:@deno/test-plugin2").unwrap(), ], files: FilePatterns { base: root_dir().join("member/"), include: Some(PathOrPatternSet::new(vec![PathOrPattern::Path( root_dir().join("member").join("subdir") )])), exclude: Default::default(), }, }, ); // check the root context let root_ctx = workspace_dir .workspace .resolve_member_dir(&url_from_directory_path(&root_dir()).unwrap()); let root_lint_config = root_ctx .to_lint_config(FilePatterns::new_with_base(root_ctx.dir_path())) .unwrap(); assert_eq!( root_lint_config, WorkspaceDirLintConfig { rules: LintRulesConfig { tags: Some(vec!["tag1".to_string()]), include: Some(vec!["rule1".to_string()]), exclude: Some(vec!["rule2".to_string()]), }, plugins: vec![ Url::parse("jsr:@deno/test-plugin1").unwrap(), Url::parse("jsr:@deno/test-plugin3").unwrap(), ], files: FilePatterns { base: root_dir(), include: None, // the workspace member will be excluded because that needs // to be resolved separately exclude: PathOrPatternSet::new(Vec::from([PathOrPattern::Path( root_dir().join("member") )])), }, }, ); } #[test] fn test_root_member_fmt_combinations() { let workspace_dir = workspace_for_root_and_member( json!({ "fmt": { "useTabs": true, "indentWidth": 4, "lineWidth": 80, "proseWrap": "never", "singleQuote": false, "semiColons": false, "quoteProps": "asNeeded", "newLineKind": "auto", "useBraces": "preferNone", "bracePosition": "maintain", "singleBodyPosition": "sameLine", "nextControlFlowPosition": "nextLine", "trailingCommas": "always", "operatorPosition": "sameLine", "jsx.bracketPosition": "sameLine", "jsx.forceNewLinesSurroundingContent": false, "jsx.multiLineParens": "prefer", "typeLiteral.separatorKind": "comma", "spaceAround": false, "spaceSurroundingProperties": false, } }), json!({ "fmt": { "exclude": ["subdir"], "useTabs": false, "indentWidth": 8, "lineWidth": 120, "proseWrap": "always", "singleQuote": true, "semiColons": true, "quoteProps": "consistent", "newLineKind": "lf", "useBraces": "always", "bracePosition": "nextLine", "singleBodyPosition": "maintain", "nextControlFlowPosition": "maintain", "trailingCommas": "onlyMultiLine", "operatorPosition": "nextLine", "jsx.bracketPosition": "nextLine", "jsx.forceNewLinesSurroundingContent": true, "jsx.multiLineParens": "always", "typeLiteral.separatorKind": "semiColon", "spaceAround": true, "spaceSurroundingProperties": true, } }), ); assert_eq!(workspace_dir.workspace.diagnostics(), vec![]); let fmt_config = workspace_dir .to_fmt_config(FilePatterns::new_with_base(workspace_dir.dir_path())) .unwrap(); assert_eq!( fmt_config, FmtConfig { options: FmtOptionsConfig { use_tabs: Some(false), line_width: Some(120), indent_width: Some(8), prose_wrap: Some(ProseWrap::Always), single_quote: Some(true), semi_colons: Some(true), quote_props: Some(QuoteProps::Consistent), new_line_kind: Some(NewLineKind::LineFeed), use_braces: Some(UseBraces::Always), brace_position: Some(BracePosition::NextLine), single_body_position: Some(SingleBodyPosition::Maintain), next_control_flow_position: Some(NextControlFlowPosition::Maintain), trailing_commas: Some(TrailingCommas::OnlyMultiLine), operator_position: Some(OperatorPosition::NextLine), jsx_bracket_position: Some(BracketPosition::NextLine), jsx_force_new_lines_surrounding_content: Some(true), jsx_multi_line_parens: Some(MultiLineParens::Always), type_literal_separator_kind: Some(SeparatorKind::SemiColon), space_around: Some(true), space_surrounding_properties: Some(true), }, files: FilePatterns { base: root_dir().join("member"), include: None, exclude: PathOrPatternSet::new(vec![PathOrPattern::Path( root_dir().join("member").join("subdir") )]), }, } ); // check the root context let root_ctx = workspace_dir .workspace .resolve_member_dir(&url_from_directory_path(&root_dir()).unwrap()); let root_fmt_config = root_ctx .to_fmt_config(FilePatterns::new_with_base(root_ctx.dir_path())) .unwrap(); assert_eq!( root_fmt_config, FmtConfig { options: FmtOptionsConfig { use_tabs: Some(true), line_width: Some(80), indent_width: Some(4), prose_wrap: Some(ProseWrap::Never), single_quote: Some(false), semi_colons: Some(false), quote_props: Some(QuoteProps::AsNeeded), new_line_kind: Some(NewLineKind::Auto), use_braces: Some(UseBraces::PreferNone), brace_position: Some(BracePosition::Maintain), single_body_position: Some(SingleBodyPosition::SameLine), next_control_flow_position: Some(NextControlFlowPosition::NextLine), trailing_commas: Some(TrailingCommas::Always), operator_position: Some(OperatorPosition::SameLine), jsx_bracket_position: Some(BracketPosition::SameLine), jsx_force_new_lines_surrounding_content: Some(false), jsx_multi_line_parens: Some(MultiLineParens::Prefer), type_literal_separator_kind: Some(SeparatorKind::Comma), space_around: Some(false), space_surrounding_properties: Some(false), }, files: FilePatterns { base: root_dir(), include: None, // the workspace member will be excluded because that needs // to be resolved separately exclude: PathOrPatternSet::new(Vec::from([PathOrPattern::Path( root_dir().join("member") )])), }, } ); } #[test] fn test_root_member_bench_combinations() { let workspace_dir = workspace_for_root_and_member( json!({}), json!({ "bench": { "exclude": ["subdir"], } }), ); assert_eq!(workspace_dir.workspace.diagnostics(), vec![]); let bench_config = workspace_dir .to_bench_config(FilePatterns::new_with_base(workspace_dir.dir_path())) .unwrap(); assert_eq!( bench_config, BenchConfig { files: FilePatterns { base: root_dir().join("member"), include: None, exclude: PathOrPatternSet::new(vec![PathOrPattern::Path( root_dir().join("member").join("subdir") )]), }, permissions: None, } ); // check the root context let root_ctx = workspace_dir .workspace .resolve_member_dir(&url_from_directory_path(&root_dir()).unwrap()); let root_bench_config = root_ctx .to_bench_config(FilePatterns::new_with_base(root_ctx.dir_path())) .unwrap(); assert_eq!( root_bench_config, BenchConfig { files: FilePatterns { base: root_dir(), include: None, // the workspace member will be excluded because that needs // to be resolved separately exclude: PathOrPatternSet::new(Vec::from([PathOrPattern::Path( root_dir().join("member") )])), }, permissions: None, } ); } #[test] fn test_root_member_test_combinations() { let workspace_dir = workspace_for_root_and_member( json!({}), json!({ "test": { "include": ["subdir"], } }), ); assert_eq!(workspace_dir.workspace.diagnostics(), vec![]); let config = workspace_dir .to_test_config(FilePatterns::new_with_base(workspace_dir.dir_path())) .unwrap(); assert_eq!( config, TestConfig { files: FilePatterns { base: root_dir().join("member"), include: Some(PathOrPatternSet::new(vec![PathOrPattern::Path( root_dir().join("member").join("subdir") )])), exclude: Default::default(), }, permissions: None, } ); // check the root context let root_ctx = workspace_dir .workspace .resolve_member_dir(&url_from_directory_path(&root_dir()).unwrap()); let root_test_config = root_ctx .to_test_config(FilePatterns::new_with_base(root_ctx.dir_path())) .unwrap(); assert_eq!( root_test_config, TestConfig { files: FilePatterns { base: root_dir(), include: None, // the workspace member will be excluded because that needs // to be resolved separately exclude: PathOrPatternSet::new(Vec::from([PathOrPattern::Path( root_dir().join("member") )])), }, permissions: None, } ); } #[test] fn test_root_member_publish_combinations() { let workspace_dir = workspace_for_root_and_member( json!({ "publish": { "exclude": ["other"] } }), json!({ "publish": { "include": ["subdir"], }, "exclude": [ "./exclude_dir" ], }), ); assert_eq!(workspace_dir.workspace.diagnostics(), vec![]); let config = workspace_dir.to_publish_config().unwrap(); assert_eq!( config, PublishConfig { files: FilePatterns { base: root_dir().join("member"), include: Some(PathOrPatternSet::new(vec![PathOrPattern::Path( root_dir().join("member").join("subdir") )])), exclude: PathOrPatternSet::new(vec![PathOrPattern::Path( root_dir().join("member").join("exclude_dir") ),]), }, } ); // check the root context let root_publish_config = workspace_dir .workspace .resolve_member_dir(&url_from_directory_path(&root_dir()).unwrap()) .to_publish_config() .unwrap(); assert_eq!( root_publish_config, PublishConfig { files: FilePatterns { base: root_dir(), include: None, exclude: PathOrPatternSet::new(Vec::from([ PathOrPattern::Path(root_dir().join("other")), // the workspace member will be excluded because that needs // to be resolved separately PathOrPattern::Path(root_dir().join("member")), ])), }, } ); } #[test] fn test_root_member_empty_config_resolves_excluded_members() { let workspace_dir = workspace_for_root_and_member(json!({}), json!({})); assert_eq!(workspace_dir.workspace.diagnostics(), vec![]); let expected_root_files = FilePatterns { base: root_dir(), include: None, // the workspace member will be excluded because that needs // to be resolved separately exclude: PathOrPatternSet::new(Vec::from([PathOrPattern::Path( root_dir().join("member"), )])), }; let root_ctx = workspace_dir .workspace .resolve_member_dir(&url_from_directory_path(&root_dir()).unwrap()); let expected_member_files = FilePatterns { base: root_dir().join("member"), include: None, exclude: Default::default(), }; for (expected_files, ctx) in [ (expected_root_files, root_ctx), (expected_member_files, workspace_dir), ] { assert_eq!( ctx .to_bench_config(FilePatterns::new_with_base(ctx.dir_path())) .unwrap(), BenchConfig { files: expected_files.clone(), permissions: None, } ); assert_eq!( ctx .to_fmt_config(FilePatterns::new_with_base(ctx.dir_path())) .unwrap(), FmtConfig { options: Default::default(), files: expected_files.clone(), } ); assert_eq!( ctx .to_lint_config(FilePatterns::new_with_base(ctx.dir_path())) .unwrap(), WorkspaceDirLintConfig { rules: Default::default(), plugins: Default::default(), files: expected_files.clone(), }, ); assert_eq!( ctx .to_test_config(FilePatterns::new_with_base(ctx.dir_path())) .unwrap(), TestConfig { files: expected_files.clone(), permissions: None, } ); assert_eq!( ctx.to_publish_config().unwrap(), PublishConfig { files: expected_files.clone(), } ); } } #[test] fn test_root_member_root_only_in_member() { let workspace_dir = workspace_for_root_and_member( json!({ "unstable": ["byonm"], "lock": false, "nodeModulesDir": false, "vendor": true, }), json!({ "unstable": ["sloppy-imports"], "lock": true, "nodeModulesDir": "auto", "vendor": false, }), ); // ignores member config assert_eq!( workspace_dir.workspace.unstable_features(), &["byonm".to_string()] ); assert!(workspace_dir.workspace.has_unstable("byonm")); assert!(!workspace_dir.workspace.has_unstable("sloppy-imports")); assert_eq!( workspace_dir.workspace.resolve_lockfile_path().unwrap(), None ); assert_eq!( workspace_dir.workspace.node_modules_dir().unwrap(), Some(NodeModulesDirMode::None) ); assert_eq!( workspace_dir.workspace.resolve_lockfile_path().unwrap(), None ); assert_eq!( workspace_dir.workspace.vendor_dir_path().unwrap(), &root_dir().join("vendor") ); assert_eq!( workspace_dir.workspace.diagnostics(), vec![ WorkspaceDiagnostic { kind: WorkspaceDiagnosticKind::DeprecatedNodeModulesDirOption { previous: false, suggestion: NodeModulesDirMode::Manual, }, config_url: Url::from_file_path(root_dir().join("deno.json")) .unwrap(), }, WorkspaceDiagnostic { kind: WorkspaceDiagnosticKind::RootOnlyOption("lock"), config_url: Url::from_file_path(root_dir().join("member/deno.json")) .unwrap(), }, WorkspaceDiagnostic { kind: WorkspaceDiagnosticKind::RootOnlyOption("nodeModulesDir"), config_url: Url::from_file_path(root_dir().join("member/deno.json")) .unwrap(), }, WorkspaceDiagnostic { kind: WorkspaceDiagnosticKind::RootOnlyOption("unstable"), config_url: Url::from_file_path(root_dir().join("member/deno.json")) .unwrap(), }, WorkspaceDiagnostic { kind: WorkspaceDiagnosticKind::RootOnlyOption("vendor"), config_url: Url::from_file_path(root_dir().join("member/deno.json")) .unwrap(), }, ] ); } #[test] fn test_root_member_node_modules_dir_suggestions() { fn suggest( previous: bool, suggestion: NodeModulesDirMode, ) -> WorkspaceDiagnostic { WorkspaceDiagnostic { kind: WorkspaceDiagnosticKind::DeprecatedNodeModulesDirOption { previous, suggestion, }, config_url: Url::from_file_path(root_dir().join("deno.json")).unwrap(), } } let cases = [ ( json!({ "unstable": ["byonm"], "nodeModulesDir": true, }), true, NodeModulesDirMode::Manual, ), ( json!({ "unstable": ["byonm"], "nodeModulesDir": false, }), false, NodeModulesDirMode::Manual, ), ( json!({ "nodeModulesDir": true, }), true, NodeModulesDirMode::Auto, ), ( json!({ "nodeModulesDir": false, }), false, NodeModulesDirMode::None, ), ]; for (config, previous, suggestion) in cases { let workspace_dir = workspace_for_root_and_member(config, json!({})); assert_eq!( workspace_dir.workspace.diagnostics(), vec![suggest(previous, suggestion)] ); } } #[test] fn test_root_member_pkg_only_fields_on_workspace_root() { let workspace_dir = workspace_for_root_and_member( json!({ "name": "@scope/name", "version": "1.0.0", "exports": "./main.ts" }), json!({}), ); // this is fine because we can tell it's a package by it having name and exports fields assert_eq!(workspace_dir.workspace.diagnostics(), vec![]); } #[test] fn test_root_member_workspace_on_member() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("deno.json"), json!({ "workspace": ["./member"] }), ); sys.fs_insert_json( root_dir().join("member/deno.json"), json!({ "workspace": ["./other_dir"] }), ); let workspace_dir = WorkspaceDirectory::discover( &sys, // start at root for this test WorkspaceDiscoverStart::Paths(&[root_dir()]), &WorkspaceDiscoverOptions { ..Default::default() }, ) .unwrap(); assert_eq!( workspace_dir.workspace.diagnostics(), vec![WorkspaceDiagnostic { kind: WorkspaceDiagnosticKind::RootOnlyOption("workspace"), config_url: Url::from_file_path(root_dir().join("member/deno.json")) .unwrap(), }] ); } #[test] fn test_workspaces_property() { run_single_json_diagnostics_test( json!({ "workspaces": ["./member"] }), vec![WorkspaceDiagnosticKind::InvalidWorkspacesOption], ); } #[test] fn test_workspaces_missing_exports() { run_single_json_diagnostics_test( json!({ "name": "@scope/name", }), vec![WorkspaceDiagnosticKind::MissingExports], ); } fn run_single_json_diagnostics_test( json: serde_json::Value, kinds: Vec, ) { let sys = InMemorySys::default(); sys.fs_insert_json(root_dir().join("deno.json"), json); let workspace_dir = WorkspaceDirectory::discover( &sys, WorkspaceDiscoverStart::Paths(&[root_dir()]), &WorkspaceDiscoverOptions { ..Default::default() }, ) .unwrap(); assert_eq!( workspace_dir.workspace.diagnostics(), kinds .into_iter() .map(|kind| { WorkspaceDiagnostic { kind, config_url: Url::from_file_path(root_dir().join("deno.json")) .unwrap(), } }) .collect::>() ); } #[test] fn test_multiple_pkgs_same_name() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("deno.json"), json!({ "workspace": ["./member1", "./member2"] }), ); let pkg = json!({ "name": "@scope/pkg", "version": "1.0.0", "exports": "./main.ts", }); sys.fs_insert_json( root_dir().join("member1").join("deno.json"), pkg.clone(), ); sys.fs_insert_json( root_dir().join("member2").join("deno.json"), pkg.clone(), ); let err = WorkspaceDirectory::discover( &sys, WorkspaceDiscoverStart::Paths(&[root_dir()]), &WorkspaceDiscoverOptions { ..Default::default() }, ) .unwrap_err(); match err.into_kind() { WorkspaceDiscoverErrorKind::ResolveMember(err) => match err.into_kind() { ResolveWorkspaceMemberErrorKind::DuplicatePackageName { name, deno_json_url, other_deno_json_url, } => { assert_eq!(name, "@scope/pkg"); assert_eq!( deno_json_url, Url::from_file_path(root_dir().join("member2").join("deno.json")) .unwrap() ); assert_eq!( other_deno_json_url, Url::from_file_path(root_dir().join("member1").join("deno.json")) .unwrap() ); } _ => unreachable!(), }, _ => unreachable!(), } } #[test] fn test_packages_for_publish_non_workspace() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("deno.json"), json!({ "name": "@scope/pkg", "version": "1.0.0", "exports": "./main.ts", }), ); let workspace_dir = workspace_at_start_dir(&sys, &root_dir().join("member")); assert_eq!(workspace_dir.workspace.diagnostics(), vec![]); let jsr_pkgs = workspace_dir.jsr_packages_for_publish(); let names = jsr_pkgs.iter().map(|p| p.name.as_str()).collect::>(); assert_eq!(names, vec!["@scope/pkg"]); } #[test] fn test_packages_for_publish_workspace() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("deno.json"), json!({ "workspace": ["./a", "./b", "./c", "./d"] }), ); sys.fs_insert_json( root_dir().join("a/deno.json"), json!({ "name": "@scope/a", "version": "1.0.0", "exports": "./main.ts", }), ); sys.fs_insert_json( root_dir().join("b/deno.json"), json!({ "name": "@scope/b", "version": "1.0.0", "exports": "./main.ts", }), ); sys.fs_insert_json( root_dir().join("c/deno.json"), // not a package json!({}), ); sys.fs_insert_json( root_dir().join("d/package.json"), json!({ "name": "pkg", "version": "1.0.0", }), ); // root { let workspace_dir = workspace_at_start_dir(&sys, &root_dir()); assert_eq!(workspace_dir.workspace.diagnostics(), vec![]); let jsr_pkgs = workspace_dir.jsr_packages_for_publish(); let names = jsr_pkgs.iter().map(|p| p.name.as_str()).collect::>(); assert_eq!(names, vec!["@scope/a", "@scope/b"]); } // member { let workspace_dir = workspace_at_start_dir(&sys, &root_dir().join("a")); assert_eq!(workspace_dir.workspace.diagnostics(), vec![]); let jsr_pkgs = workspace_dir.jsr_packages_for_publish(); let names = jsr_pkgs.iter().map(|p| p.name.as_str()).collect::>(); assert_eq!(names, vec!["@scope/a"]); } // member, not a package { let workspace_dir = workspace_at_start_dir(&sys, &root_dir().join("c")); assert_eq!(workspace_dir.workspace.diagnostics(), vec![]); let jsr_pkgs = workspace_dir.jsr_packages_for_publish(); assert!(jsr_pkgs.is_empty()); } // package.json { let workspace_dir = workspace_at_start_dir(&sys, &root_dir().join("d")); assert_eq!(workspace_dir.workspace.diagnostics(), vec![]); let jsr_pkgs = workspace_dir.jsr_packages_for_publish(); assert!(jsr_pkgs.is_empty()); // while we're here, test this assert_eq!( workspace_dir .workspace .package_jsons() .map(|p| p.dir_path().to_path_buf()) .collect::>(), vec![root_dir().join("d")] ); assert_eq!( workspace_dir .workspace .npm_packages() .into_iter() .map(|p| p.pkg_json.dir_path().to_path_buf()) .collect::>(), vec![root_dir().join("d")] ); } } #[test] fn test_packages_for_publish_root_is_package() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("deno.json"), json!({ "name": "@scope/root", "version": "1.0.0", "exports": "./main.ts", "workspace": ["./member"] }), ); sys.fs_insert_json( root_dir().join("member/deno.json"), json!({ "name": "@scope/pkg", "version": "1.0.0", "exports": "./main.ts", }), ); // in a member { let workspace_dir = workspace_at_start_dir(&sys, &root_dir().join("member")); assert_eq!(workspace_dir.workspace.diagnostics(), vec![]); let jsr_pkgs = workspace_dir.jsr_packages_for_publish(); let names = jsr_pkgs.iter().map(|p| p.name.as_str()).collect::>(); assert_eq!(names, vec!["@scope/pkg"]); } // at the root { let workspace_dir = workspace_at_start_dir(&sys, &root_dir()); assert_eq!(workspace_dir.workspace.diagnostics(), vec![]); let jsr_pkgs = workspace_dir.jsr_packages_for_publish(); let names = jsr_pkgs.iter().map(|p| p.name.as_str()).collect::>(); // Only returns the root package because it allows for publishing // this individually. If someone wants the behaviour of publishing // the entire workspace then they should move each package to a descendant // directory. assert_eq!(names, vec!["@scope/root"]); } } #[test] fn test_packages_for_publish_root_not_package() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("deno.json"), json!({ "workspace": ["./member"] }), ); sys.fs_insert_json( root_dir().join("member/deno.json"), json!({ "name": "@scope/pkg", "version": "1.0.0", "exports": "./main.ts", }), ); // the workspace is not a jsr package so publish the members let workspace_dir = workspace_at_start_dir(&sys, &root_dir()); assert_eq!(workspace_dir.workspace.diagnostics(), vec![]); let jsr_pkgs = workspace_dir.jsr_packages_for_publish(); let names = jsr_pkgs.iter().map(|p| p.name.as_str()).collect::>(); assert_eq!(names, vec!["@scope/pkg"]); } #[test] fn test_packages_for_publish_npm_workspace() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("package.json"), json!({ "workspaces": ["./a", "./b", "./c", "./d"] }), ); sys.fs_insert_json(root_dir().join("a/package.json"), json!({})); sys.fs_insert_json( root_dir().join("a/deno.json"), json!({ "name": "@scope/a", "version": "1.0.0", "exports": "./main.ts", }), ); sys.fs_insert_json(root_dir().join("b/package.json"), json!({})); sys.fs_insert_json( root_dir().join("b/deno.json"), json!({ "name": "@scope/b", "version": "1.0.0", "exports": "./main.ts", }), ); sys.fs_insert_json(root_dir().join("c/package.json"), json!({})); sys.fs_insert_json( root_dir().join("c/deno.json"), // not a package json!({}), ); sys.fs_insert_json( root_dir().join("d/package.json"), json!({ "name": "pkg", "version": "1.0.0", }), ); // root { let workspace_dir = workspace_at_start_dir(&sys, &root_dir()); assert_eq!(workspace_dir.workspace.diagnostics(), vec![]); let jsr_pkgs = workspace_dir.jsr_packages_for_publish(); let names = jsr_pkgs.iter().map(|p| p.name.as_str()).collect::>(); assert_eq!(names, vec!["@scope/a", "@scope/b"]); } // member { let workspace_dir = workspace_at_start_dir(&sys, &root_dir().join("a")); assert_eq!(workspace_dir.workspace.diagnostics(), vec![]); let jsr_pkgs = workspace_dir.jsr_packages_for_publish(); let names = jsr_pkgs.iter().map(|p| p.name.as_str()).collect::>(); assert_eq!(names, vec!["@scope/a"]); } // member, not a package { let workspace_dir = workspace_at_start_dir(&sys, &root_dir().join("c")); assert_eq!(workspace_dir.workspace.diagnostics(), vec![]); let jsr_pkgs = workspace_dir.jsr_packages_for_publish(); assert!(jsr_pkgs.is_empty()); } // package.json { let workspace_dir = workspace_at_start_dir(&sys, &root_dir().join("d")); assert_eq!(workspace_dir.workspace.diagnostics(), vec![]); let jsr_pkgs = workspace_dir.jsr_packages_for_publish(); assert!(jsr_pkgs.is_empty()); assert_eq!( workspace_dir .workspace .npm_packages() .into_iter() .map(|p| p.pkg_json.dir_path().to_path_buf()) .collect::>(), vec![root_dir().join("d")] ); } } #[test] fn test_no_auto_discovery_node_modules_dir() { let sys = InMemorySys::default(); sys.fs_insert_json(root_dir().join("deno.json"), json!({})); sys.fs_insert_json( root_dir().join("node_modules/package/package.json"), json!({ "name": "@scope/pkg", "version": "1.0.0" }), ); let workspace_dir = workspace_at_start_dir( &sys, &root_dir().join("node_modules/package/sub_dir"), ); assert_eq!(workspace_dir.workspace.diagnostics(), vec![]); assert_eq!(workspace_dir.workspace.package_jsons().count(), 0); assert_eq!(workspace_dir.workspace.deno_jsons().count(), 1); } #[test] fn test_deno_workspace_globs() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("deno.json"), json!({ "workspace": ["./packages/*"] }), ); sys.fs_insert_json( root_dir().join("packages/package-a/deno.json"), json!({}), ); sys.fs_insert_json( root_dir().join("packages/package-b/deno.json"), json!({}), ); sys.fs_insert_json( root_dir().join("packages/package-c/deno.jsonc"), json!({}), ); let workspace_dir = workspace_at_start_dir(&sys, &root_dir().join("packages")); assert_eq!(workspace_dir.workspace.diagnostics(), Vec::new()); assert_eq!(workspace_dir.workspace.deno_jsons().count(), 4); } #[test] fn test_deno_workspace_globs_with_package_json() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("deno.json"), json!({ "workspace": ["./packages/*", "./examples/*"] }), ); sys.fs_insert_json( root_dir().join("packages/package-a/deno.json"), json!({}), ); sys.fs_insert_json( root_dir().join("packages/package-b/deno.json"), json!({}), ); sys.fs_insert_json( root_dir().join("packages/package-c/deno.jsonc"), json!({}), ); sys.fs_insert_json( root_dir().join("examples/examples1/package.json"), json!({}), ); sys.fs_insert_json( root_dir().join("examples/examples2/package.json"), json!({}), ); let workspace_dir = workspace_at_start_dir(&sys, &root_dir().join("packages")); assert_eq!(workspace_dir.workspace.diagnostics(), Vec::new()); assert_eq!(workspace_dir.workspace.deno_jsons().count(), 4); assert_eq!(workspace_dir.workspace.package_jsons().count(), 2); } #[test] fn test_deno_workspace_negations() { for negation in ["!ignored/package-c", "!ignored/**"] { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("deno.json"), json!({ "workspace": [ "**/*", negation, ] }), ); sys.fs_insert_json( root_dir().join("packages/package-a/deno.json"), json!({}), ); sys.fs_insert_json( root_dir().join("packages/package-b/deno.jsonc"), json!({}), ); sys.fs_insert_json( root_dir().join("ignored/package-c/deno.jsonc"), json!({}), ); let workspace_dir = workspace_at_start_dir(&sys, &root_dir()); assert_eq!(workspace_dir.workspace.diagnostics(), Vec::new()); assert_eq!(workspace_dir.workspace.deno_jsons().count(), 3); } } #[test] fn test_deno_workspace_member_no_config_file_error() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("deno.json"), json!({ "workspace": ["./member"] }), ); // no deno.json in this folder, so should error let err = workspace_at_start_dir_err(&sys, &root_dir().join("package")); assert_eq!( err.to_string(), normalize_err_text( "Could not find config file for workspace member in '[ROOT_DIR_URL]/member/'." ) ); } #[test] fn test_deno_workspace_member_deno_json_member_name() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("deno.json"), json!({ "workspace": ["./member/deno.json"] }), ); // no deno.json in this folder and the name was deno.json so give an error let err = workspace_at_start_dir_err(&sys, &root_dir().join("package")); assert_eq!( err.to_string(), normalize_err_text(concat!( "Could not find config file for workspace member in '[ROOT_DIR_URL]/member/deno.json/'. ", "Ensure you specify the directory and not the configuration file in the workspace member." )) ); } #[test] fn test_deno_member_not_referenced_in_deno_workspace() { fn assert_err(err: &WorkspaceDiscoverError, config_file_path: &Path) { match err.as_kind() { WorkspaceDiscoverErrorKind::ConfigNotWorkspaceMember { workspace_url, config_url, } => { assert_eq!( workspace_url, &url_from_directory_path(&root_dir()).unwrap() ); assert_eq!( config_url, &Url::from_file_path(config_file_path).unwrap() ); } _ => unreachable!(), } } for file_name in ["deno.json", "deno.jsonc"] { let config_file_path = root_dir().join("member-b").join(file_name); let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("deno.json"), json!({ "workspace": ["./member-a"], }), ); sys.fs_insert_json(root_dir().join("member-a/deno.json"), json!({})); sys.fs_insert_json(config_file_path.clone(), json!({})); let err = workspace_at_start_dir_err(&sys, &root_dir().join("member-b")); assert_err(&err, &config_file_path); // try for when the config file is specified as well let err = WorkspaceDirectory::discover( &sys, WorkspaceDiscoverStart::ConfigFile(&config_file_path), &WorkspaceDiscoverOptions { discover_pkg_json: true, ..Default::default() }, ) .unwrap_err(); assert_err(&err, &config_file_path); } } #[test] fn test_config_not_deno_workspace_member_non_natural_config_file_name() { for file_name in ["other-name.json", "deno.jsonc"] { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("deno.json"), json!({ "workspace": ["./member-a", "./member-b"], }), ); sys.fs_insert_json(root_dir().join("member-a/deno.json"), json!({})); // this is the "natural" config file that would be discovered by // workspace discovery and since the file name specified does not // match it, the workspace is not discovered and an error does not // occur sys.fs_insert_json(root_dir().join("member-b/deno.json"), json!({})); let config_file_path = root_dir().join("member-b").join(file_name); sys.fs_insert_json(config_file_path.clone(), json!({})); let workspace_dir = WorkspaceDirectory::discover( &sys, WorkspaceDiscoverStart::ConfigFile(&config_file_path), &WorkspaceDiscoverOptions { discover_pkg_json: true, ..Default::default() }, ) .unwrap(); assert_eq!( workspace_dir .workspace .deno_jsons() .map(|c| c.specifier.to_file_path().unwrap()) .collect::>(), vec![config_file_path] ); } } #[test] fn test_config_workspace_non_natural_config_file_name() { let sys = InMemorySys::default(); let root_config_path = root_dir().join("deno-other.json"); sys.fs_insert_json( root_config_path.clone(), json!({ "workspace": ["./member-a"], }), ); let member_a_config = root_dir().join("member-a/deno.json"); sys.fs_insert_json(member_a_config.clone(), json!({})); let workspace_dir = WorkspaceDirectory::discover( &sys, WorkspaceDiscoverStart::ConfigFile(&root_config_path), &WorkspaceDiscoverOptions { discover_pkg_json: true, ..Default::default() }, ) .unwrap(); assert_eq!( workspace_dir .workspace .deno_jsons() .map(|c| c.specifier.to_file_path().unwrap()) .collect::>(), vec![root_config_path, member_a_config] ); } #[test] fn test_npm_package_not_referenced_in_deno_workspace() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("deno.json"), json!({ "workspace": ["./member"] }), ); sys.fs_insert_json(root_dir().join("member/deno.json"), json!({})); sys.fs_insert_json(root_dir().join("package/package.json"), json!({})); // npm package needs to be a member of the deno workspace let err = workspace_at_start_dir_err(&sys, &root_dir().join("package")); assert_eq!( err.to_string(), normalize_err_text( "Config file must be a member of the workspace. Config: [ROOT_DIR_URL]/package/package.json Workspace: [ROOT_DIR_URL]/" ) ); } #[test] fn test_multiple_workspaces_npm_package_referenced_in_package_json_workspace() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("deno.json"), json!({ "workspace": ["./member"] }), ); sys.fs_insert_json( root_dir().join("package.json"), json!({ "workspaces": ["./package"] }), ); sys.fs_insert_json(root_dir().join("member/deno.json"), json!({})); sys.fs_insert_json(root_dir().join("package/package.json"), json!({})); let workspace_dir = workspace_at_start_dir(&sys, &root_dir().join("package")); assert_eq!(workspace_dir.workspace.diagnostics(), Vec::new()); assert_eq!(workspace_dir.workspace.deno_jsons().count(), 2); assert_eq!(workspace_dir.workspace.package_jsons().count(), 2); } #[test] fn test_npm_workspace_package_json_and_deno_json_ok() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("package.json"), json!({ "workspaces": ["./member"] }), ); sys.fs_insert_json(root_dir().join("member/deno.json"), json!({})); sys.fs_insert_json(root_dir().join("member/package.json"), json!({})); let workspace_dir = workspace_at_start_dir(&sys, &root_dir().join("package")); assert_eq!(workspace_dir.workspace.diagnostics(), Vec::new()); assert_eq!(workspace_dir.workspace.deno_jsons().count(), 1); assert_eq!(workspace_dir.workspace.package_jsons().count(), 2); } #[test] fn test_npm_workspace_member_deno_json_error() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("package.json"), json!({ "workspaces": ["./member"] }), ); // no package.json in this folder, so should error sys.fs_insert_json(root_dir().join("member/deno.json"), json!({})); let err = workspace_at_start_dir_err(&sys, &root_dir().join("package")); assert_eq!( err.to_string(), normalize_err_text( "Could not find package.json for workspace member in '[ROOT_DIR_URL]/member/'." ) ); } #[test] fn test_npm_workspace_member_no_config_file_error() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("package.json"), json!({ "workspaces": ["./member"] }), ); // no package.json in this folder, so should error let err = workspace_at_start_dir_err(&sys, &root_dir().join("package")); assert_eq!( err.to_string(), normalize_err_text( "Could not find package.json for workspace member in '[ROOT_DIR_URL]/member/'." ) ); } #[test] fn test_npm_workspace_globs() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("package.json"), json!({ "workspaces": ["./packages/*"] }), ); sys.fs_insert_json( root_dir().join("packages/package-a/package.json"), json!({}), ); sys.fs_insert_json( root_dir().join("packages/package-b/package.json"), json!({}), ); let workspace_dir = workspace_at_start_dir(&sys, &root_dir().join("packages")); assert_eq!(workspace_dir.workspace.diagnostics(), Vec::new()); assert_eq!(workspace_dir.workspace.package_jsons().count(), 3); } #[test] fn test_npm_workspace_ignores_vendor_folder() { for (is_vendor, expected_count) in [(true, 3), (false, 4)] { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("deno.json"), json!({ "vendor": is_vendor, }), ); sys.fs_insert_json( root_dir().join("package.json"), json!({ "workspaces": ["./**/*"] }), ); sys.fs_insert_json( root_dir().join("packages/package-a/package.json"), json!({}), ); sys.fs_insert_json( root_dir().join("packages/package-b/package.json"), json!({}), ); sys.fs_insert_json( root_dir().join("vendor/package-c/package.json"), json!({}), ); let workspace_dir = workspace_at_start_dir(&sys, &root_dir()); assert_eq!(workspace_dir.workspace.diagnostics(), Vec::new()); assert_eq!( workspace_dir.workspace.package_jsons().count(), expected_count ); } } #[test] fn test_npm_workspace_negations() { for negation in ["!ignored/package-c", "!ignored/**"] { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("package.json"), json!({ "workspaces": [ "**/*", negation, ] }), ); sys.fs_insert_json( root_dir().join("packages/package-a/package.json"), json!({}), ); sys.fs_insert_json( root_dir().join("packages/package-b/package.json"), json!({}), ); sys.fs_insert_json( root_dir().join("ignored/package-c/package.json"), json!({}), ); let workspace_dir = workspace_at_start_dir(&sys, &root_dir()); assert_eq!(workspace_dir.workspace.diagnostics(), Vec::new()); assert_eq!(workspace_dir.workspace.package_jsons().count(), 3); } } #[test] fn test_npm_workspace_self_reference_and_duplicate_references_ok() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("package.json"), json!({ "workspaces": [ ".", "./member", "./member", "**/*" ] }), ); sys.fs_insert_json(root_dir().join("member/package.json"), json!({})); let workspace_dir = workspace_at_start_dir(&sys, &root_dir()); assert_eq!(workspace_dir.workspace.diagnostics(), Vec::new()); assert_eq!(workspace_dir.workspace.package_jsons().count(), 2); } #[test] fn test_npm_workspace_start_deno_json_not_in_workspace() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("package.json"), json!({ "workspaces": ["./package"] }), ); sys.fs_insert_json( root_dir().join("member/deno.json"), json!({ "unstable": ["byonm"], }), ); sys.fs_insert_json(root_dir().join("package/package.json"), json!({})); // only resolves the member because it's not part of the workspace let workspace_dir = workspace_at_start_dir(&sys, &root_dir().join("member")); assert_eq!(workspace_dir.workspace.diagnostics(), Vec::new()); assert_eq!(workspace_dir.workspace.deno_jsons().count(), 1); assert_eq!( workspace_dir .workspace .root_dir_url() .to_file_path() .unwrap(), root_dir().join("member") ); assert_eq!(workspace_dir.workspace.package_jsons().count(), 0); assert!(workspace_dir.workspace.has_unstable("byonm")); assert_eq!( workspace_dir.workspace.resolve_lockfile_path().unwrap(), Some(root_dir().join("member/deno.lock")) ); } #[test] fn test_npm_workspace_start_deno_json_part_of_workspace() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("package.json"), json!({ "workspaces": ["./member"] }), ); sys.fs_insert_json( root_dir().join("member/deno.json"), json!({ "lock": false, "unstable": ["byonm"], }), ); sys.fs_insert_json(root_dir().join("member/package.json"), json!({})); let workspace_dir = workspace_at_start_dir(&sys, &root_dir().join("member")); assert_eq!( workspace_dir .workspace .diagnostics() .into_iter() .map(|d| d.kind) .collect::>(), vec![ WorkspaceDiagnosticKind::RootOnlyOption("lock"), WorkspaceDiagnosticKind::RootOnlyOption("unstable") ] ); assert_eq!(workspace_dir.workspace.deno_jsons().count(), 1); assert_eq!( workspace_dir .workspace .root_dir_url() .to_file_path() .unwrap(), root_dir() ); assert_eq!(workspace_dir.workspace.package_jsons().count(), 2); assert!(!workspace_dir.workspace.has_unstable("byonm")); assert_eq!( workspace_dir.workspace.resolve_lockfile_path().unwrap(), Some(root_dir().join("deno.lock")) ); } #[test] fn test_npm_workspace_start_deno_json_part_of_workspace_sub_folder() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("package.json"), json!({ "workspaces": ["./member"] }), ); sys.fs_insert_json( root_dir().join("member/deno.json"), json!({ "unstable": ["byonm"], }), ); sys.fs_insert_json(root_dir().join("member/package.json"), json!({})); sys.fs_insert("member/sub/sub_folder/sub/file.ts", ""); let workspace_dir = workspace_at_start_dir( &sys, // note how we're starting in a sub folder of the member &root_dir().join("member/sub/sub_folder/sub/"), ); assert_eq!( workspace_dir .workspace .diagnostics() .into_iter() .map(|d| d.kind) .collect::>(), vec![WorkspaceDiagnosticKind::RootOnlyOption("unstable")] ); assert_eq!(workspace_dir.workspace.deno_jsons().count(), 1); assert_eq!( workspace_dir .workspace .root_dir_url() .to_file_path() .unwrap(), root_dir() ); assert_eq!(workspace_dir.workspace.package_jsons().count(), 2); assert!(!workspace_dir.workspace.has_unstable("byonm")); } #[test] fn test_npm_workspace_start_deno_json_part_of_workspace_sub_folder_other_deno_json() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("package.json"), json!({ "workspaces": ["./member", "./member/sub"] }), ); sys.fs_insert_json( root_dir().join("member/deno.json"), json!({ "unstable": ["sloppy-imports"] }), ); sys.fs_insert_json(root_dir().join("member/package.json"), json!({})); sys.fs_insert_json( root_dir().join("member/sub/deno.json"), json!({ "unstable": ["byonm"] }), ); sys.fs_insert_json(root_dir().join("member/sub/package.json"), json!({})); sys.fs_insert("member/sub/sub_folder/sub/file.ts", ""); let workspace_dir = workspace_at_start_dir( &sys, // note how we're starting in a sub folder of the member &root_dir().join("member/sub/sub_folder/sub/"), ); assert_eq!(workspace_dir.workspace.diagnostics().len(), 2); // for each unstable assert_eq!(workspace_dir.workspace.deno_jsons().count(), 2); assert_eq!( workspace_dir.workspace.root_dir_url.to_file_path().unwrap(), root_dir() ); assert_eq!(workspace_dir.workspace.package_jsons().count(), 3); assert!(!workspace_dir.workspace.has_unstable("sloppy-imports")); assert!(!workspace_dir.workspace.has_unstable("byonm")); } #[test] fn test_npm_workspace_start_package_json_not_in_workspace() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("package.json"), json!({ "workspaces": ["./package"] }), ); sys.fs_insert_json(root_dir().join("member/package.json"), json!({})); sys.fs_insert_json(root_dir().join("package/package.json"), json!({})); // only resolves the member because it's not part of the workspace let workspace_dir = workspace_at_start_dir(&sys, &root_dir().join("member")); assert_eq!(workspace_dir.workspace.diagnostics(), Vec::new()); assert_eq!(workspace_dir.workspace.deno_jsons().count(), 0); assert_eq!( workspace_dir .workspace .root_dir_url() .to_file_path() .unwrap(), root_dir().join("member") ); assert_eq!(workspace_dir.workspace.package_jsons().count(), 1); } #[test] fn test_resolve_multiple_dirs() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("workspace").join("deno.json"), json!({ "workspace": ["./member"] }), ); sys.fs_insert_json( root_dir().join("workspace").join("member/deno.json"), json!({ "name": "@scope/pkg", "version": "1.0.0", "exports": "./main.ts", }), ); let workspace_dir = workspace_at_start_dirs( &sys, &[ root_dir().join("workspace/member"), root_dir().join("other_dir"), // will be ignored because it's not in the workspace ], ) .unwrap(); assert_eq!(workspace_dir.workspace.diagnostics(), vec![]); let jsr_pkgs = workspace_dir.jsr_packages_for_publish(); let names = jsr_pkgs.iter().map(|p| p.name.as_str()).collect::>(); assert_eq!(names, vec!["@scope/pkg"]); } #[test] fn test_npm_workspace_ignore_pkg_json_between_member_and_root() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("package.json"), json!({ "workspaces": ["./member/nested"] }), ); // will ignore this one sys.fs_insert_json(root_dir().join("member/package.json"), json!({})); sys .fs_insert_json(root_dir().join("member/nested/package.json"), json!({})); // only resolves the member because it's not part of the workspace let workspace_dir = workspace_at_start_dir(&sys, &root_dir().join("member/nested")); assert_eq!(workspace_dir.workspace.diagnostics(), Vec::new()); assert_eq!(workspace_dir.workspace.deno_jsons().count(), 0); assert_eq!( workspace_dir .workspace .package_jsons() .map(|p| p.path.clone()) .collect::>(), vec![ root_dir().join("package.json"), root_dir().join("member/nested/package.json"), ] ); } #[test] fn test_npm_workspace_ignore_deno_json_between_member_and_root() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("package.json"), json!({ "workspaces": ["./member/nested"] }), ); // will ignore this one sys.fs_insert_json(root_dir().join("member/deno.json"), json!({})); sys .fs_insert_json(root_dir().join("member/nested/package.json"), json!({})); // only resolves the member because it's not part of the workspace let workspace_dir = workspace_at_start_dir(&sys, &root_dir().join("member/nested")); assert_eq!(workspace_dir.workspace.diagnostics(), Vec::new()); assert_eq!(workspace_dir.workspace.deno_jsons().count(), 0); assert_eq!(workspace_dir.workspace.package_jsons().count(), 2); } #[test] fn test_resolve_multiple_dirs_outside_config() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("workspace/deno.json"), json!({ "workspace": { "members": ["./member"] }, }), ); sys .fs_insert_json(root_dir().join("workspace/member/deno.json"), json!({})); // this one will cause issues because it's not in the workspace sys.fs_insert_json(root_dir().join("other_dir/deno.json"), json!({})); let err = workspace_at_start_dirs( &sys, &[ root_dir().join("workspace/member"), root_dir().join("other_dir"), ], ) .unwrap_err(); assert_eq!(err.to_string(), normalize_err_text("Command resolved to multiple config files. Ensure all specified paths are within the same workspace. First: [ROOT_DIR_URL]/workspace/deno.json Second: [ROOT_DIR_URL]/other_dir/deno.json")); } #[test] fn test_resolve_multiple_dirs_outside_workspace() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("workspace/deno.json"), json!({ "workspace": ["./member"] }), ); sys .fs_insert_json(root_dir().join("workspace/member/deno.json"), json!({})); // this one will cause issues because it's not in the workspace sys.fs_insert_json( root_dir().join("other_dir/deno.json"), json!({ "workspace": ["./member"] }), ); sys .fs_insert_json(root_dir().join("other_dir/member/deno.json"), json!({})); let err = workspace_at_start_dirs( &sys, &[ root_dir().join("workspace/member"), root_dir().join("other_dir"), ], ) .unwrap_err(); assert_eq!(err.to_string(), normalize_err_text("Command resolved to multiple config files. Ensure all specified paths are within the same workspace. First: [ROOT_DIR_URL]/workspace/deno.json Second: [ROOT_DIR_URL]/other_dir/deno.json")); } #[test] fn test_specified_config_file_same_dir_discoverable_config_file() { let sys = InMemorySys::default(); // should not start discovering this deno.json because it // should search for a workspace in the parent dir sys.fs_insert_json(root_dir().join("sub_dir/deno.json"), json!({})); let other_deno_json = root_dir().join("sub_dir/deno_other_name.json"); sys.fs_insert_json(&other_deno_json, json!({})); let workspace_dir = WorkspaceDirectory::discover( &sys, WorkspaceDiscoverStart::ConfigFile(&other_deno_json), &WorkspaceDiscoverOptions { discover_pkg_json: true, ..Default::default() }, ) .unwrap(); assert_eq!( workspace_dir .workspace .deno_jsons() .map(|d| d.specifier.clone()) .collect::>(), vec![Url::from_file_path(&other_deno_json).unwrap()] ); } #[test] fn test_config_workspace() { let sys = InMemorySys::default(); let root_config_path = root_dir().join("deno.json"); sys.fs_insert_json( root_config_path.clone(), json!({ "workspace": ["./member-a"], }), ); let member_a_config = root_dir().join("member-a/deno.json"); sys.fs_insert_json(member_a_config.clone(), json!({})); let workspace_dir = WorkspaceDirectory::discover( &sys, WorkspaceDiscoverStart::ConfigFile(&root_config_path), &WorkspaceDiscoverOptions { discover_pkg_json: true, ..Default::default() }, ) .unwrap(); assert_eq!( workspace_dir .workspace .deno_jsons() .map(|c| c.specifier.to_file_path().unwrap()) .collect::>(), vec![root_config_path, member_a_config] ); } #[test] fn test_split_cli_args_by_deno_json_folder() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("deno.json"), json!({ "workspace": ["./member-a", "./member-b"], }), ); sys.fs_insert_json(root_dir().join("member-a/deno.json"), json!({})); sys.fs_insert_json(root_dir().join("member-b/deno.json"), json!({})); let workspace_dir = workspace_at_start_dir(&sys, &root_dir()); // single member { let split = workspace_dir.workspace.split_cli_args_by_deno_json_folder( &FilePatterns { base: root_dir(), include: Some(PathOrPatternSet::new(vec![PathOrPattern::Path( root_dir().join("member-a"), )])), exclude: Default::default(), }, ); assert_eq!( split, IndexMap::from([( new_rc( url_from_directory_path(&root_dir().join("member-a")).unwrap() ), FilePatterns { base: root_dir().join("member-a"), include: Some(PathOrPatternSet::new(vec![PathOrPattern::Path( root_dir().join("member-a") )])), exclude: Default::default(), } )]) ); } // root and in single member { let split = workspace_dir.workspace.split_cli_args_by_deno_json_folder( &FilePatterns { base: root_dir(), include: Some(PathOrPatternSet::new(vec![ PathOrPattern::Path(root_dir().join("member-a").join("sub")), PathOrPattern::Path(root_dir().join("file")), ])), exclude: Default::default(), }, ); assert_eq!( split, IndexMap::from([ ( new_rc( url_from_directory_path(&root_dir().join("member-a")).unwrap() ), FilePatterns { base: root_dir().join("member-a/sub"), include: Some(PathOrPatternSet::new(vec![PathOrPattern::Path( root_dir().join("member-a").join("sub") )])), exclude: Default::default(), } ), ( new_rc(url_from_directory_path(&root_dir()).unwrap()), FilePatterns { base: root_dir().join("file"), include: Some(PathOrPatternSet::new(vec![PathOrPattern::Path( root_dir().join("file") )])), exclude: Default::default(), } ), ]) ); } // multiple members (one with glob) and outside folder { let split = workspace_dir.workspace.split_cli_args_by_deno_json_folder( &FilePatterns { base: root_dir(), include: Some(PathOrPatternSet::new(vec![ PathOrPattern::Path(root_dir().join("member-a")), PathOrPattern::Pattern( GlobPattern::from_relative(&root_dir().join("member-b"), "**/*") .unwrap(), ), PathOrPattern::Path(root_dir().join("other_dir")), ])), exclude: Default::default(), }, ); assert_eq!( split, IndexMap::from([ ( new_rc(url_from_directory_path(&root_dir()).unwrap()), FilePatterns { base: root_dir().join("other_dir"), include: Some(PathOrPatternSet::new(vec![PathOrPattern::Path( root_dir().join("other_dir") )])), exclude: Default::default(), } ), ( new_rc( url_from_directory_path(&root_dir().join("member-a")).unwrap() ), FilePatterns { base: root_dir().join("member-a"), include: Some(PathOrPatternSet::new(vec![PathOrPattern::Path( root_dir().join("member-a") )])), exclude: Default::default(), } ), ( new_rc( url_from_directory_path(&root_dir().join("member-b")).unwrap() ), FilePatterns { base: root_dir().join("member-b"), include: Some(PathOrPatternSet::new(vec![ PathOrPattern::Pattern( GlobPattern::from_relative( &root_dir().join("member-b"), "**/*" ) .unwrap(), ) ])), exclude: Default::default(), } ), ]) ); } // glob at root dir { let root_glob = PathOrPattern::Pattern( GlobPattern::from_relative(&root_dir(), "**/*").unwrap(), ); let split = workspace_dir.workspace.split_cli_args_by_deno_json_folder( &FilePatterns { base: root_dir(), include: Some(PathOrPatternSet::new(vec![root_glob.clone()])), exclude: Default::default(), }, ); assert_eq!( split, IndexMap::from([ ( new_rc(url_from_directory_path(&root_dir()).unwrap()), FilePatterns { base: root_dir(), include: Some(PathOrPatternSet::new(vec![root_glob.clone()])), exclude: Default::default(), } ), ( new_rc( url_from_directory_path(&root_dir().join("member-a")).unwrap() ), FilePatterns { base: root_dir().join("member-a"), include: Some(PathOrPatternSet::new(vec![root_glob.clone()])), exclude: Default::default(), } ), ( new_rc( url_from_directory_path(&root_dir().join("member-b")).unwrap() ), FilePatterns { base: root_dir().join("member-b"), include: Some(PathOrPatternSet::new(vec![root_glob])), exclude: Default::default(), } ), ]) ); } // single path in descendant of member { let split = workspace_dir.workspace.split_cli_args_by_deno_json_folder( &FilePatterns { base: root_dir(), include: Some(PathOrPatternSet::new(vec![PathOrPattern::Path( root_dir().join("member-a/sub-dir/descendant/further"), )])), exclude: Default::default(), }, ); assert_eq!( split, IndexMap::from([( new_rc( url_from_directory_path(&root_dir().join("member-a")).unwrap() ), FilePatterns { base: root_dir().join("member-a/sub-dir/descendant/further"), include: Some(PathOrPatternSet::new(vec![PathOrPattern::Path( root_dir().join("member-a/sub-dir/descendant/further"), )])), exclude: Default::default(), } ),]) ); } // path in descendant of member then second path that goes to a parent folder { let split = workspace_dir.workspace.split_cli_args_by_deno_json_folder( &FilePatterns { base: root_dir(), include: Some(PathOrPatternSet::new(vec![ PathOrPattern::Path( root_dir().join("member-a/sub-dir/descendant/further"), ), PathOrPattern::Path(root_dir().join("member-a/sub-dir/other")), ])), exclude: Default::default(), }, ); assert_eq!( split, IndexMap::from([( new_rc( url_from_directory_path(&root_dir().join("member-a")).unwrap() ), FilePatterns { // should use common base here base: root_dir().join("member-a/sub-dir"), include: Some(PathOrPatternSet::new(vec![ PathOrPattern::Path( root_dir().join("member-a/sub-dir/descendant/further"), ), PathOrPattern::Path(root_dir().join("member-a/sub-dir/other"),) ])), exclude: Default::default(), } )]) ); } // path outside the root directory { let dir_outside = normalize_path(root_dir().join("../dir_outside").into()); let split = workspace_dir.workspace.split_cli_args_by_deno_json_folder( &FilePatterns { base: root_dir(), include: Some(PathOrPatternSet::new(vec![PathOrPattern::Path( dir_outside.to_path_buf(), )])), exclude: Default::default(), }, ); assert_eq!( split, IndexMap::from([( new_rc(url_from_directory_path(&root_dir()).unwrap()), FilePatterns { base: root_dir(), include: Some(PathOrPatternSet::new(vec![PathOrPattern::Path( dir_outside.to_path_buf(), ),])), exclude: Default::default(), } )]) ); } // multiple paths outside the root directory { let dir_outside_1 = normalize_path(root_dir().join("../dir_outside_1").into()); let dir_outside_2 = normalize_path(root_dir().join("../dir_outside_2").into()); let split = workspace_dir.workspace.split_cli_args_by_deno_json_folder( &FilePatterns { base: root_dir(), include: Some(PathOrPatternSet::new(vec![ PathOrPattern::Path(dir_outside_1.to_path_buf()), PathOrPattern::Path(dir_outside_2.to_path_buf()), ])), exclude: Default::default(), }, ); assert_eq!( split, IndexMap::from([( new_rc(url_from_directory_path(&root_dir()).unwrap()), FilePatterns { base: root_dir(), include: Some(PathOrPatternSet::new(vec![ PathOrPattern::Path(dir_outside_1.to_path_buf()), PathOrPattern::Path(dir_outside_2.to_path_buf()), ])), exclude: Default::default(), } )]) ); } } #[test] fn test_split_cli_args_by_deno_json_folder_no_config() { let sys = InMemorySys::default(); sys.fs_insert(root_dir().join("path"), ""); // create the root directory let workspace_dir = workspace_at_start_dir(&sys, &root_dir()); // two paths, looped to ensure that the order is maintained on // the output and not sorted let path1 = normalize_path(root_dir().join("./path-longer").into()); let path2 = normalize_path(root_dir().join("./path").into()); for (path1, path2) in [(&path1, &path2), (&path2, &path1)] { let split = workspace_dir.workspace.split_cli_args_by_deno_json_folder( &FilePatterns { base: root_dir(), include: Some(PathOrPatternSet::new(vec![ PathOrPattern::Path(path1.to_path_buf()), PathOrPattern::Path(path2.to_path_buf()), ])), exclude: Default::default(), }, ); assert_eq!( split, IndexMap::from([( new_rc(url_from_directory_path(&root_dir()).unwrap()), FilePatterns { base: root_dir(), include: Some(PathOrPatternSet::new(vec![ PathOrPattern::Path(path1.to_path_buf()), PathOrPattern::Path(path2.to_path_buf()), ])), exclude: Default::default(), } )]) ); } } #[test] fn test_resolve_config_for_members_include_root_and_sub_member() { fn run_test( config_key: &str, workspace_to_file_patterns: impl Fn(&WorkspaceDirectory) -> Vec, ) { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("deno.json"), json!({ "workspace": ["./member-a", "./member-b", "member-c"], config_key: { "include": ["./file.ts", "./member-c/file.ts"] } }), ); sys.fs_insert_json( root_dir().join("member-a/deno.json"), json!({ config_key: { "include": ["./member-a-file.ts"] } }), ); sys.fs_insert_json(root_dir().join("member-b/deno.json"), json!({})); sys.fs_insert_json(root_dir().join("member-c/deno.json"), json!({})); let workspace = workspace_at_start_dir(&sys, &root_dir()); assert_eq!( workspace_to_file_patterns(&workspace), vec![ FilePatterns { base: root_dir(), include: Some(PathOrPatternSet::new(vec![PathOrPattern::Path( root_dir().join("file.ts") )])), exclude: PathOrPatternSet::new(vec![ PathOrPattern::Path(root_dir().join("member-a")), PathOrPattern::Path(root_dir().join("member-b")), PathOrPattern::Path(root_dir().join("member-c")), ]) }, FilePatterns { base: root_dir().join("member-a"), include: Some(PathOrPatternSet::new(vec![PathOrPattern::Path( root_dir().join("member-a").join("member-a-file.ts") )])), exclude: Default::default(), }, FilePatterns { base: root_dir().join("member-b"), include: None, exclude: Default::default(), }, FilePatterns { base: root_dir().join("member-c"), include: Some(PathOrPatternSet::new(vec![PathOrPattern::Path( root_dir().join("member-c").join("file.ts") )])), exclude: Default::default(), } ] ); } run_test("bench", |workspace_dir| { let config_for_members = workspace_dir .workspace .resolve_bench_config_for_members(&FilePatterns::new_with_base( root_dir(), )) .unwrap(); config_for_members .into_iter() .map(|(_ctx, config)| config.files) .collect::>() }); run_test("fmt", |workspace_dir| { let config_for_members = workspace_dir .workspace .resolve_fmt_config_for_members( &FilePatterns::new_with_base(root_dir()), ) .unwrap(); config_for_members .into_iter() .map(|(_ctx, config)| config.files) .collect::>() }); run_test("lint", |workspace_dir| { let config_for_members = workspace_dir .workspace .resolve_lint_config_for_members(&FilePatterns::new_with_base( root_dir(), )) .unwrap(); config_for_members .into_iter() .map(|(_ctx, config)| config.files) .collect::>() }); run_test("test", |workspace_dir| { let config_for_members = workspace_dir .workspace .resolve_test_config_for_members(&FilePatterns::new_with_base( root_dir(), )) .unwrap(); config_for_members .into_iter() .map(|(_ctx, config)| config.files) .collect::>() }); } #[test] fn test_resolve_config_for_members_excluded_member() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("deno.json"), json!({ "workspace": ["./member-a", "./member-b"], "lint": { "exclude": ["./member-a"] } }), ); sys.fs_insert_json(root_dir().join("member-a/deno.json"), json!({})); sys.fs_insert_json(root_dir().join("member-b/deno.json"), json!({})); let workspace_dir = workspace_at_start_dir(&sys, &root_dir()); let config_for_members = workspace_dir .workspace .resolve_lint_config_for_members(&FilePatterns::new_with_base(root_dir())) .unwrap(); let file_patterns = config_for_members .into_iter() .map(|(_ctx, config)| config.files) .collect::>(); assert_eq!( file_patterns, vec![ FilePatterns { base: root_dir(), include: None, exclude: PathOrPatternSet::new(vec![ PathOrPattern::Path(root_dir().join("member-a")), // It will be in here twice because it's excluded from being // traversed for this set of FilePatterns and also it's excluded // in the "exclude". This is not a big deal because it's an edge // case and the end behaviour is the same. It's probably not worth // the complexity and perf to ensure only unique items are in here PathOrPattern::Path(root_dir().join("member-a")), PathOrPattern::Path(root_dir().join("member-b")), ]) }, // This item is effectively a no-op as it excludes itself. // It would be nice to have this not even included as a member, // but doing that in a maintainable way would require a bit of // refactoring to get resolve_config_for_members to understand // that configs return FilePatterns. FilePatterns { base: root_dir().join("member-a"), include: None, exclude: PathOrPatternSet::new(vec![PathOrPattern::Path( root_dir().join("member-a") ),]), }, FilePatterns { base: root_dir().join("member-b"), include: None, exclude: Default::default(), }, ] ); // ensure the second file patterns is a no-op sys.fs_insert(root_dir().join("member-a/file.ts"), ""); sys.fs_insert(root_dir().join("member-a/sub-dir/file.ts"), ""); let files = FileCollector::new(|_| true) .collect_file_patterns(&sys, &file_patterns[1]); assert!(files.is_empty()); } #[test] fn test_resolve_config_for_members_excluded_member_unexcluded_sub_dir() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("deno.json"), json!({ "workspace": ["./member-a"], "lint": { "exclude": ["./member-a"] } }), ); sys.fs_insert_json( root_dir().join("member-a/deno.json"), json!({ "lint": { // unexclude this sub dir so it's linted "exclude": ["!./sub-dir"] } }), ); let workspace_dir = workspace_at_start_dir(&sys, &root_dir()); let config_for_members = workspace_dir .workspace .resolve_lint_config_for_members(&FilePatterns::new_with_base(root_dir())) .unwrap(); let file_patterns = config_for_members .into_iter() .map(|(_ctx, config)| config.files) .collect::>(); assert_eq!( file_patterns, vec![ FilePatterns { base: root_dir(), include: None, exclude: PathOrPatternSet::new(vec![ PathOrPattern::Path(root_dir().join("member-a")), // see note in previous test about this being here twice PathOrPattern::Path(root_dir().join("member-a")), ]) }, FilePatterns { base: root_dir().join("member-a"), include: None, exclude: PathOrPatternSet::new(vec![ // self will be excluded, but then sub dir will be unexcluded PathOrPattern::Path(root_dir().join("member-a")), PathOrPattern::NegatedPath( root_dir().join("member-a").join("sub-dir") ), ]), }, ] ); sys.fs_insert(root_dir().join("member-a/file.ts"), ""); sys.fs_insert(root_dir().join("member-a/sub-dir/file.ts"), ""); let files = FileCollector::new(|_| true) .collect_file_patterns(&sys, &file_patterns[1]); // should only have member-a/sub-dir/file.ts and not member-a/file.ts assert_eq!(files, vec![root_dir().join("member-a/sub-dir/file.ts")]); } #[test] fn test_lock_path() { let workspace_dir = workspace_for_root_and_member( json!({ "lock": "other.lock", }), json!({}), ); assert_eq!( workspace_dir.workspace.resolve_lockfile_path().unwrap(), Some(root_dir().join("other.lock")) ); } #[derive(Default)] struct DenoJsonMemCache(RefCell>); impl DenoJsonCache for DenoJsonMemCache { fn get(&self, path: &Path) -> Option { self.0.borrow().get(path).cloned() } fn set(&self, path: PathBuf, deno_json: ConfigFileRc) { self.0.borrow_mut().insert(path, deno_json); } } #[derive(Default)] struct PkgJsonMemCache(RefCell>); impl deno_package_json::PackageJsonCache for PkgJsonMemCache { fn get(&self, path: &Path) -> Option { self.0.borrow().get(path).cloned() } fn set(&self, path: PathBuf, value: PackageJsonRc) { self.0.borrow_mut().insert(path, value); } } #[derive(Default)] struct WorkspaceMemCache(RefCell>); impl WorkspaceCache for WorkspaceMemCache { fn get(&self, dir_path: &Path) -> Option { self.0.borrow().get(dir_path).cloned() } fn set(&self, dir_path: PathBuf, workspace: WorkspaceRc) { self.0.borrow_mut().insert(dir_path, workspace); } } #[test] fn workspace_discovery_deno_json_cache() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("deno.json"), json!({ "nodeModulesDir": true }), ); let cache = DenoJsonMemCache::default(); let workspace_dir = WorkspaceDirectory::discover( &sys, WorkspaceDiscoverStart::Paths(&[root_dir()]), &WorkspaceDiscoverOptions { discover_pkg_json: true, deno_json_cache: Some(&cache), ..Default::default() }, ) .unwrap(); assert_eq!(cache.0.borrow().len(), 1); // writes to the cache assert_eq!( workspace_dir.workspace.node_modules_dir().unwrap(), Some(NodeModulesDirMode::Auto) ); let new_config_file = ConfigFile::new( r#"{ "nodeModulesDir": false }"#, Url::from_file_path(root_dir().join("deno.json")).unwrap(), ) .unwrap(); cache .0 .borrow_mut() .insert(root_dir().join("deno.json"), new_rc(new_config_file)); let workspace_dir = WorkspaceDirectory::discover( &sys, WorkspaceDiscoverStart::Paths(&[root_dir()]), &WorkspaceDiscoverOptions { discover_pkg_json: true, deno_json_cache: Some(&cache), ..Default::default() }, ) .unwrap(); assert_eq!( workspace_dir.workspace.node_modules_dir().unwrap(), Some(NodeModulesDirMode::None) // reads from the cache ); } #[test] fn workspace_discovery_pkg_json_cache() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("package.json"), json!({ "name": "member" }), ); let cache = PkgJsonMemCache::default(); let workspace_dir = WorkspaceDirectory::discover( &sys, WorkspaceDiscoverStart::Paths(&[root_dir()]), &WorkspaceDiscoverOptions { discover_pkg_json: true, pkg_json_cache: Some(&cache), ..Default::default() }, ) .unwrap(); assert_eq!(cache.0.borrow().len(), 1); // writes to the cache assert_eq!(workspace_dir.workspace.package_jsons().count(), 1); let new_pkg_json = PackageJson::load_from_string( root_dir().join("package.json"), r#"{ "name": "cached-name" }"#, ) .unwrap(); cache .0 .borrow_mut() .insert(root_dir().join("package.json"), new_rc(new_pkg_json)); let workspace_dir = WorkspaceDirectory::discover( &sys, WorkspaceDiscoverStart::Paths(&[root_dir()]), &WorkspaceDiscoverOptions { discover_pkg_json: true, pkg_json_cache: Some(&cache), ..Default::default() }, ) .unwrap(); // reads from the cache assert_eq!( workspace_dir .workspace .package_jsons() .map(|p| p.name.as_deref().unwrap()) .collect::>(), vec!["cached-name"] ); } #[test] fn workspace_discovery_workspace_cache() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("member/package-a/package.json"), json!({ "name": "member-a" }), ); sys.fs_insert_json( root_dir().join("member/package-b/deno.json"), json!({ "name": "member-b" }), ); sys.fs_insert_json( root_dir().join("deno.json"), json!({ "workspace": ["member/package-a", "member/package-b"] }), ); let deno_json_cache = DenoJsonMemCache::default(); let pkg_json_cache = PkgJsonMemCache::default(); let workspace_cache = WorkspaceMemCache::default(); let workspace_dir = WorkspaceDirectory::discover( &sys, WorkspaceDiscoverStart::Paths(&[root_dir()]), &WorkspaceDiscoverOptions { discover_pkg_json: true, deno_json_cache: Some(&deno_json_cache), pkg_json_cache: Some(&pkg_json_cache), workspace_cache: Some(&workspace_cache), ..Default::default() }, ) .unwrap(); assert_eq!(workspace_dir.workspace.package_jsons().count(), 1); // writes to the caches assert_eq!(pkg_json_cache.0.borrow().len(), 1); assert_eq!(deno_json_cache.0.borrow().len(), 2); assert_eq!(workspace_cache.0.borrow().len(), 1); // now delete from the deno json and pkg json caches deno_json_cache.0.borrow_mut().clear(); pkg_json_cache.0.borrow_mut().clear(); // should load and not write to the caches let workspace_dir = WorkspaceDirectory::discover( &sys, WorkspaceDiscoverStart::Paths(&[root_dir()]), &WorkspaceDiscoverOptions { discover_pkg_json: true, deno_json_cache: Some(&deno_json_cache), pkg_json_cache: Some(&pkg_json_cache), workspace_cache: Some(&workspace_cache), ..Default::default() }, ) .unwrap(); assert_eq!(workspace_dir.workspace.package_jsons().count(), 1); assert_eq!(workspace_dir.workspace.deno_jsons().count(), 2); // it wouldn't have written to these because it just // loads from the workspace cache assert_eq!(pkg_json_cache.0.borrow().len(), 0); assert_eq!(deno_json_cache.0.borrow().len(), 0); } #[test] fn deno_workspace_discovery_workspace_cache() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("member/package-a/deno.json"), json!({ "name": "member-a" }), ); sys.fs_insert_json( root_dir().join("member/package-b/deno.json"), json!({ "name": "member-b" }), ); sys.fs_insert_json( root_dir().join("deno.json"), json!({ "workspace": ["member/package-a", "member/package-b"] }), ); let deno_json_cache = DenoJsonMemCache::default(); let pkg_json_cache = PkgJsonMemCache::default(); let workspace_cache = WorkspaceMemCache::default(); for start_dir in [ root_dir(), root_dir().join("member/package-a"), root_dir().join("member/package-b"), ] { let workspace_dir = WorkspaceDirectory::discover( &sys, WorkspaceDiscoverStart::Paths(&[start_dir]), &WorkspaceDiscoverOptions { discover_pkg_json: true, deno_json_cache: Some(&deno_json_cache), pkg_json_cache: Some(&pkg_json_cache), workspace_cache: Some(&workspace_cache), ..Default::default() }, ) .unwrap(); assert_eq!(workspace_dir.workspace.deno_jsons().count(), 3); } } #[test] fn npm_workspace_discovery_workspace_cache() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("member/package-a/package.json"), json!({ "name": "member-a" }), ); sys.fs_insert_json( root_dir().join("member/package-b/package.json"), json!({ "name": "member-b" }), ); sys.fs_insert_json( root_dir().join("package.json"), json!({ "workspaces": ["member/*"] }), ); let deno_json_cache = DenoJsonMemCache::default(); let pkg_json_cache = PkgJsonMemCache::default(); let workspace_cache = WorkspaceMemCache::default(); for start_dir in [ root_dir(), root_dir().join("member/package-a"), root_dir().join("member/package-b"), ] { let workspace_dir = WorkspaceDirectory::discover( &sys, WorkspaceDiscoverStart::Paths(&[start_dir]), &WorkspaceDiscoverOptions { discover_pkg_json: true, deno_json_cache: Some(&deno_json_cache), pkg_json_cache: Some(&pkg_json_cache), workspace_cache: Some(&workspace_cache), ..Default::default() }, ) .unwrap(); assert_eq!(workspace_dir.workspace.package_jsons().count(), 3); } } #[test] fn test_folder_sorted_dependencies() { #[track_caller] fn assert_order(sys: InMemorySys, expected: Vec) { let workspace_dir = workspace_at_start_dir(&sys, &root_dir()); assert_eq!( workspace_dir .workspace .config_folders_sorted_by_dependencies() .keys() .map(|k| k.to_file_path().unwrap()) .collect::>(), expected, ); } { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("deno.json"), json!({ "workspace": ["./a", "./b", "./c"] }), ); sys.fs_insert_json( root_dir().join("a/package.json"), json!({ "dependencies": { "c": "*" } }), ); sys.fs_insert_json( root_dir().join("b/package.json"), json!({ "name": "b", }), ); sys.fs_insert_json( root_dir().join("c/package.json"), json!({ "name": "c", "dependencies": { "b": "workspace:~" } }), ); assert_order( sys, vec![ root_dir(), root_dir().join("b"), root_dir().join("c"), root_dir().join("a"), ], ); } // circular { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("deno.json"), json!({ "workspace": ["./a", "./b", "./c"] }), ); sys.fs_insert_json( root_dir().join("a/package.json"), json!({ "dependencies": { "b": "*" } }), ); sys.fs_insert_json( root_dir().join("b/package.json"), json!({ "name": "b", "dependencies": { "c": "*" } }), ); sys.fs_insert_json( root_dir().join("c/package.json"), json!({ "name": "c", "dependencies": { "a": "*" } }), ); assert_order( sys, vec![ root_dir(), root_dir().join("c"), root_dir().join("b"), root_dir().join("a"), ], ); } // file specifier { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("deno.json"), json!({ "workspace": ["./a", "./b", "./c"] }), ); sys.fs_insert_json( root_dir().join("a/package.json"), json!({ "dependencies": { "b": "file:../b" } }), ); sys.fs_insert_json( root_dir().join("b/package.json"), json!({ "name": "b", "dependencies": { "c": "file:../c/" } }), ); sys.fs_insert_json( root_dir().join("c/package.json"), json!({ "name": "c" }), ); assert_order( sys, vec![ root_dir(), root_dir().join("c"), root_dir().join("b"), root_dir().join("a"), ], ); } } fn workspace_for_root_and_member( root: serde_json::Value, member: serde_json::Value, ) -> WorkspaceDirectoryRc { workspace_for_root_and_member_with_fs(root, member, |_| {}) } fn workspace_for_root_and_member_with_fs( root: serde_json::Value, member: serde_json::Value, with_sys: impl FnOnce(&InMemorySys), ) -> WorkspaceDirectoryRc { let sys = in_memory_fs_for_root_and_member(root, member); with_sys(&sys); // start in the member workspace_at_start_dir(&sys, &root_dir().join("member")) } fn in_memory_fs_for_root_and_member( mut root: serde_json::Value, member: serde_json::Value, ) -> InMemorySys { root .as_object_mut() .unwrap() .insert("workspace".to_string(), json!(["./member"])); let sys = InMemorySys::default(); sys.fs_insert_json(root_dir().join("deno.json"), root); sys.fs_insert_json(root_dir().join("member/deno.json"), member); sys } fn workspace_at_start_dir( sys: &InMemorySys, start_dir: &Path, ) -> WorkspaceDirectoryRc { workspace_at_start_dir_result(sys, start_dir).unwrap() } fn workspace_at_start_dir_err( sys: &InMemorySys, start_dir: &Path, ) -> WorkspaceDiscoverError { workspace_at_start_dir_result(sys, start_dir).unwrap_err() } fn workspace_at_start_dir_result( sys: &InMemorySys, start_dir: &Path, ) -> Result { workspace_at_start_dirs(sys, &[start_dir.to_path_buf()]) } fn workspace_at_start_dirs( sys: &InMemorySys, start_dirs: &[PathBuf], ) -> Result { WorkspaceDirectory::discover( sys, WorkspaceDiscoverStart::Paths(start_dirs), &WorkspaceDiscoverOptions { discover_pkg_json: true, ..Default::default() }, ) } fn normalize_err_text(text: &str) -> String { text.replace( "[ROOT_DIR_URL]", url_from_directory_path(&root_dir()) .unwrap() .to_string() .trim_end_matches('/'), ) } }