/
githubmirror
/
deno
Обзор
Документация
Войти
/
githubmirror
/
deno
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
cli/util/pnpm_workspace.rs
517 строк
17 KB
Bartek Iwańczuk
feat(cli): auto-migrate pnpm-workspace.yaml on resolution failure (#34993)
23 июн 2026, 15:17
Не верифицирован
23 июн 2026, 15:17
dbcd1a9
Код
Авторство
О чём код?
// Copyright 2018-2026 the Deno authors. MIT license. //! Automatic migration of a `pnpm-workspace.yaml` into the equivalent //! `deno.json` fields. //! //! Deno deliberately does not read `pnpm-workspace.yaml` during module //! resolution, so a project whose workspace members (or catalog versions) live //! in that file fails to resolve. Rather than adding a dedicated subcommand for //! a single package manager, we detect this situation on the resolution-failure //! path: when an error looks like a workspace/npm resolution failure and a //! `pnpm-workspace.yaml` is found nearby, we convert its `packages`, `catalog`, //! and `catalogs` into `deno.json` and ask the user to run the command again. //! The conversion preserves existing comments and formatting via the jsonc CST //! and never overwrites fields the user already set. use std::path::Path; use std::path::PathBuf; use deno_core::anyhow::Context; use deno_core::error::AnyError; use deno_terminal::colors; use indexmap::IndexMap; use jsonc_parser::cst::CstInputValue; use jsonc_parser::cst::CstRootNode; use yaml_parser::SyntaxKind; use yaml_parser::SyntaxNode; use yaml_parser::ast::AstNode; use yaml_parser::ast::BlockMap; use yaml_parser::ast::Document; use yaml_parser::ast::Root; /// The subset of `pnpm-workspace.yaml` that maps cleanly onto `deno.json`. #[derive(Default)] struct PnpmWorkspace { /// `packages:` glob list -> `deno.json` `workspace`. packages: Vec<String>, /// `catalog:` map -> `deno.json` `catalog`. catalog: IndexMap<String, String>, /// `catalogs:` map of maps -> `deno.json` `catalogs`. catalogs: IndexMap<String, IndexMap<String, String>>, /// Top-level keys that have no `deno.json` equivalent (e.g. `overrides`, /// `patchedDependencies`, `registries`). We do not translate these, but we /// surface them so the user is not misled into thinking the migration was /// lossless. unsupported_keys: Vec<String>, } /// Error message fragments produced when a workspace/npm package fails to /// resolve. When one of these is shown and a `pnpm-workspace.yaml` is nearby, /// the user is almost certainly in a pnpm workspace that Deno can't read. const RESOLUTION_FAILURE_MARKERS: &[&str] = &[ "Deno expects the node_modules/ directory to be up to date", "Could not find package.json with name", "Could not find a matching package for", ]; /// If `error_text` looks like a workspace/npm resolution failure and a /// `pnpm-workspace.yaml` exists in `cwd` or an ancestor, converts it into the /// equivalent `deno.json` fields (without overwriting anything the user already /// set) and returns a note telling the user to run the command again. /// /// Returns `None` when this does not apply: the error is unrelated, no /// `pnpm-workspace.yaml` is found, or `deno.json` already has the fields the /// migration would add (so re-running would not change anything and there is /// nothing to migrate). /// /// This only ever runs on an error path, so the filesystem work never affects /// successful resolution. pub fn maybe_auto_migrate_pnpm_workspace( error_text: &str, cwd: Option<&Path>, ) -> Option<String> { if !RESOLUTION_FAILURE_MARKERS .iter() .any(|m| error_text.contains(m)) { return None; } let cwd = crate::util::env::resolve_cwd_or_fallback(cwd); let yaml_path = find_pnpm_workspace(&cwd)?; let dir = yaml_path.parent().unwrap_or(&cwd).to_path_buf(); match auto_migrate(&yaml_path, &dir) { Ok(Some(message)) => Some(message), // Nothing to migrate (e.g. `deno.json` already has these fields): stay // silent and let the original error speak for itself. Ok(None) => None, // We found a `pnpm-workspace.yaml` but couldn't convert it. Point the user // at it rather than failing silently; the migration is best-effort. Err(err) => Some(format!( "\n\n{} Found {} nearby, which Deno does not read directly, but it could not be converted automatically: {}", colors::yellow("warning:"), colors::cyan("pnpm-workspace.yaml"), err, )), } } fn auto_migrate( yaml_path: &Path, dir: &Path, ) -> Result<Option<String>, AnyError> { let yaml_text = std::fs::read_to_string(yaml_path) .with_context(|| format!("Reading '{}'", yaml_path.display()))?; let parsed = parse_pnpm_workspace(&yaml_text) .with_context(|| format!("Parsing '{}'", yaml_path.display()))?; let (config_path, config_kind) = pick_target_config(dir); let Some(new_contents) = apply_to_config(&config_path, config_kind, &parsed)? else { return Ok(None); }; std::fs::write(&config_path, new_contents) .with_context(|| format!("Writing '{}'", config_path.display()))?; let mut message = format!( "\n\n{} Found {} nearby, which Deno does not read directly.\n{} Migrated its workspace configuration into {}. Run the command again.", colors::yellow("warning:"), colors::cyan("pnpm-workspace.yaml"), colors::intense_blue("hint:"), colors::cyan(config_path.display().to_string()), ); if !parsed.unsupported_keys.is_empty() { message.push_str(&format!( "\n{} these pnpm-workspace.yaml keys have no equivalent in Deno and were not migrated: {}", colors::yellow("note:"), parsed.unsupported_keys.join(", "), )); } Ok(Some(message)) } /// Walks up from `start` looking for a `pnpm-workspace.yaml`. fn find_pnpm_workspace(start: &Path) -> Option<PathBuf> { let mut dir = Some(start); while let Some(current) = dir { let candidate = current.join("pnpm-workspace.yaml"); if candidate.is_file() { return Some(candidate); } dir = current.parent(); } None } /// Which config file the migration writes into. A pnpm project already has a /// `package.json`, and Deno reads `workspaces`/`catalog`/`catalogs` from it /// natively, so we prefer keeping the project's config there rather than /// introducing a `deno.json`. We only fall back to `deno.json`/`deno.jsonc` when /// there is no `package.json` next to the `pnpm-workspace.yaml`. #[derive(Clone, Copy)] enum ConfigKind { PackageJson, DenoConfig, } impl ConfigKind { /// The key that holds the workspace member globs in this config file /// (`workspaces` in `package.json`, `workspace` in `deno.json`). fn workspace_key(self) -> &'static str { match self { ConfigKind::PackageJson => "workspaces", ConfigKind::DenoConfig => "workspace", } } } fn pick_target_config(dir: &Path) -> (PathBuf, ConfigKind) { let package_json = dir.join("package.json"); if package_json.is_file() { return (package_json, ConfigKind::PackageJson); } let jsonc = dir.join("deno.jsonc"); if jsonc.is_file() { return (jsonc, ConfigKind::DenoConfig); } (dir.join("deno.json"), ConfigKind::DenoConfig) } /// Merges the parsed pnpm workspace into an existing config file (preserving /// comments and formatting via the jsonc CST) or creates a new one. Fields the /// user has already set are left untouched. Returns the new file contents, or /// `None` when there is nothing to add (so the caller can avoid a no-op write). fn apply_to_config( path: &Path, kind: ConfigKind, parsed: &PnpmWorkspace, ) -> Result<Option<String>, AnyError> { let existing = std::fs::read_to_string(path).unwrap_or_else(|_| "{}\n".to_string()); let cst = CstRootNode::parse(&existing, &Default::default()) .with_context(|| format!("Parsing existing '{}'", path.display()))?; let root = cst.object_value_or_set(); let mut changed = false; let workspace_key = kind.workspace_key(); if !parsed.packages.is_empty() && root.get(workspace_key).is_none() { let array = CstInputValue::Array( parsed .packages .iter() .map(|p| CstInputValue::String(p.clone())) .collect(), ); root.append(workspace_key, array); changed = true; } if !parsed.catalog.is_empty() && root.get("catalog").is_none() { root.append("catalog", string_map_to_value(&parsed.catalog)); changed = true; } if !parsed.catalogs.is_empty() && root.get("catalogs").is_none() { let obj = CstInputValue::Object( parsed .catalogs .iter() .map(|(name, entries)| (name.clone(), string_map_to_value(entries))) .collect(), ); root.append("catalogs", obj); changed = true; } if !changed { return Ok(None); } root.ensure_multiline(); Ok(Some(cst.to_string())) } fn string_map_to_value(map: &IndexMap<String, String>) -> CstInputValue { CstInputValue::Object( map .iter() .map(|(k, v)| (k.clone(), CstInputValue::String(v.clone()))) .collect(), ) } // =========================================================================== // pnpm-workspace.yaml parsing (via the yaml_parser CST already used by // `deno fmt`, so no new YAML dependency is introduced). // =========================================================================== fn parse_pnpm_workspace(text: &str) -> Result<PnpmWorkspace, AnyError> { let tree = yaml_parser::parse(text) .map_err(|e| deno_core::anyhow::anyhow!("Invalid YAML: {e}"))?; let Some(root) = Root::cast(tree) else { return Ok(PnpmWorkspace::default()); }; let Some(block_map) = root .documents() .next() .and_then(|doc: Document| doc.block()) .and_then(|block| block.block_map()) else { return Ok(PnpmWorkspace::default()); }; let mut result = PnpmWorkspace::default(); for entry in block_map.entries() { let Some(key) = entry.key().map(|k| k.syntax().clone()) else { continue; }; let Some(key_name) = scalar_text(&key) else { continue; }; let value = entry.value().map(|v| v.syntax().clone()); match key_name.as_str() { "packages" => { if let Some(value) = &value { result.packages = collect_string_seq(value); } } "catalog" => { if let Some(value) = &value { result.catalog = collect_string_map(value); } } "catalogs" => { if let Some(value) = &value { result.catalogs = collect_catalogs(value); } } other => result.unsupported_keys.push(other.to_string()), } } Ok(result) } /// Collects a YAML block/flow sequence of scalars into a `Vec<String>`. fn collect_string_seq(value: &SyntaxNode) -> Vec<String> { let mut out = Vec::new(); // Block sequence: each `BLOCK_SEQ_ENTRY` holds one scalar. for node in value.descendants() { match node.kind() { SyntaxKind::BLOCK_SEQ_ENTRY | SyntaxKind::FLOW_SEQ_ENTRY => { if let Some(s) = scalar_text(&node) { out.push(s); } } _ => {} } } out } /// Collects a YAML block/flow mapping of scalar -> scalar into an ordered map. fn collect_string_map(value: &SyntaxNode) -> IndexMap<String, String> { let mut out = IndexMap::new(); for node in value.descendants() { if matches!( node.kind(), SyntaxKind::BLOCK_MAP_ENTRY | SyntaxKind::FLOW_MAP_ENTRY ) { let mut children = node.children(); let key = children.next().and_then(|n| scalar_text(&n)); let val = children.next().and_then(|n| scalar_text(&n)); if let (Some(key), Some(val)) = (key, val) { out.insert(key, val); } } } out } /// Collects `catalogs:` (a mapping of catalog-name -> mapping of dep -> req). fn collect_catalogs( value: &SyntaxNode, ) -> IndexMap<String, IndexMap<String, String>> { let mut out = IndexMap::new(); let Some(map) = value .descendants() .find(|n| n.kind() == SyntaxKind::BLOCK_MAP) .and_then(BlockMap::cast) else { return out; }; for entry in map.entries() { let Some(name) = entry.key().and_then(|k| scalar_text(k.syntax())) else { continue; }; if let Some(value) = entry.value() { out.insert(name, collect_string_map(value.syntax())); } } out } /// Returns the first scalar token's decoded text within `node`, or its direct /// scalar if `node` is itself a scalar holder. fn scalar_text(node: &SyntaxNode) -> Option<String> { node .descendants_with_tokens() .filter_map(|el| el.into_token()) .find_map(|tok| match tok.kind() { SyntaxKind::PLAIN_SCALAR => Some(tok.text().trim().to_string()), SyntaxKind::SINGLE_QUOTED_SCALAR => Some(unquote(tok.text(), '\'')), SyntaxKind::DOUBLE_QUOTED_SCALAR => Some(unquote(tok.text(), '"')), _ => None, }) } fn unquote(text: &str, quote: char) -> String { text .strip_prefix(quote) .and_then(|t| t.strip_suffix(quote)) .unwrap_or(text) .to_string() } #[cfg(test)] mod tests { use super::*; #[test] fn parses_packages() { let yaml = "packages:\n - \"packages/*\"\n - 'apps/*'\n - tools/cli\n"; let parsed = parse_pnpm_workspace(yaml).unwrap(); assert_eq!(parsed.packages, vec!["packages/*", "apps/*", "tools/cli"]); } #[test] fn parses_catalog_and_catalogs() { let yaml = "packages:\n - \"a\"\ncatalog:\n react: ^18.0.0\n zod: ^3.0.0\ncatalogs:\n react17:\n react: ^17.0.0\n"; let parsed = parse_pnpm_workspace(yaml).unwrap(); assert_eq!( parsed.catalog.get("react").map(|s| s.as_str()), Some("^18.0.0") ); assert_eq!( parsed.catalog.get("zod").map(|s| s.as_str()), Some("^3.0.0") ); assert_eq!( parsed .catalogs .get("react17") .and_then(|m| m.get("react")) .map(|s| s.as_str()), Some("^17.0.0") ); } #[test] fn flags_unsupported_keys() { let yaml = "packages:\n - \"a\"\noverrides:\n foo: 1.0.0\nshamefully-hoist: true\n"; let parsed = parse_pnpm_workspace(yaml).unwrap(); assert!(parsed.unsupported_keys.contains(&"overrides".to_string())); assert!( parsed .unsupported_keys .contains(&"shamefully-hoist".to_string()) ); } #[test] fn writes_workspace_into_empty_deno_json() { let parsed = PnpmWorkspace { packages: vec!["packages/*".to_string()], ..Default::default() }; let cst = CstRootNode::parse("{}\n", &Default::default()).unwrap(); let root = cst.object_value_or_set(); let array = CstInputValue::Array( parsed .packages .iter() .map(|p| CstInputValue::String(p.clone())) .collect(), ); root.append("workspace", array); root.ensure_multiline(); let out = cst.to_string(); assert!(out.contains("\"workspace\"")); assert!(out.contains("\"packages/*\"")); } #[test] fn apply_skips_existing_fields() { // `deno.json` already declares `workspace`; nothing to add -> no-op. let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("deno.json"); std::fs::write(&path, "{ \"workspace\": [\"a\"] }\n").unwrap(); let parsed = PnpmWorkspace { packages: vec!["packages/*".to_string()], ..Default::default() }; assert!( apply_to_config(&path, ConfigKind::DenoConfig, &parsed) .unwrap() .is_none() ); } #[test] fn apply_adds_only_missing_fields() { // `workspace` is present but `catalog` is not: only `catalog` is added. let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("deno.json"); std::fs::write(&path, "{ \"workspace\": [\"a\"] }\n").unwrap(); let mut catalog = IndexMap::new(); catalog.insert("react".to_string(), "^18.0.0".to_string()); let parsed = PnpmWorkspace { packages: vec!["packages/*".to_string()], catalog, ..Default::default() }; let out = apply_to_config(&path, ConfigKind::DenoConfig, &parsed) .unwrap() .unwrap(); assert!(out.contains("\"catalog\"")); assert!(out.contains("\"react\"")); // The existing workspace was left as-is (not duplicated). assert_eq!(out.matches("\"workspace\"").count(), 1); } #[test] fn apply_to_package_json_uses_workspaces_key() { // A pnpm project keeps its config in package.json, so the migration writes // `workspaces` (the package.json key) rather than `workspace`. let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("package.json"); std::fs::write(&path, "{ \"name\": \"root\" }\n").unwrap(); let mut catalog = IndexMap::new(); catalog.insert("react".to_string(), "^18.0.0".to_string()); let parsed = PnpmWorkspace { packages: vec!["packages/*".to_string()], catalog, ..Default::default() }; let out = apply_to_config(&path, ConfigKind::PackageJson, &parsed) .unwrap() .unwrap(); assert!(out.contains("\"workspaces\"")); assert!(!out.contains("\"workspace\":")); assert!(out.contains("\"packages/*\"")); assert!(out.contains("\"catalog\"")); // The existing `name` field was preserved. assert!(out.contains("\"name\"")); } #[test] fn pick_target_prefers_package_json() { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("package.json"), "{}\n").unwrap(); std::fs::write(dir.path().join("deno.json"), "{}\n").unwrap(); let (path, kind) = pick_target_config(dir.path()); assert!(matches!(kind, ConfigKind::PackageJson)); assert_eq!(path, dir.path().join("package.json")); } }