Skip to main content

fallow_config/workspace/
pnpm_overrides.rs

1//! Parser for the `overrides:` section of `pnpm-workspace.yaml` and the
2//! `pnpm.overrides` section of a root `package.json`.
3//!
4//! pnpm supports forcing transitive dependency versions through two equivalent
5//! locations:
6//!
7//! ```yaml
8//! # pnpm-workspace.yaml (pnpm 9+, canonical)
9//! overrides:
10//!   axios: ^1.6.0
11//!   "@types/react@<18": "18.0.0"
12//!   "react>react-dom": ^17
13//! ```
14//!
15//! ```json
16//! // package.json (legacy form, still supported)
17//! { "pnpm": { "overrides": { "axios": "^1.6.0" } } }
18//! ```
19//!
20//! For the unused-dependency-override and misconfigured-dependency-override
21//! detectors we need both the structured map of entries and the 1-based line
22//! number of each entry in the source so findings can point users to the exact
23//! line. `serde_yaml_ng` and `serde_json` give us the structural parse; a second
24//! targeted scan over the raw source recovers the line numbers.
25//!
26//! The detector treats the following key shapes as valid pnpm syntax:
27//! - `axios` (bare package)
28//! - `@scope/pkg` (scoped package)
29//! - `axios@>=1.0.0` (version selector on the overridden package)
30//! - `react>react-dom` (parent matcher; override `react-dom` only inside `react`'s subtree)
31//! - `react@1>zoo` (parent matcher with version selector on the parent)
32//! - `@scope/parent>@scope/child` (scoped packages on both sides)
33//!
34//! Special values that are valid pnpm syntax and must NOT be flagged as
35//! misconfigured: `-` (removal), `$ref` (self-reference to a workspace dep),
36//! `npm:alias@^1` (npm-protocol alias).
37
38use std::path::Path;
39
40use super::pnpm_catalog::{parse_key, strip_inline_comment};
41
42/// Where an override entry was declared.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
44#[serde(rename_all = "snake_case")]
45pub enum OverrideSource {
46    /// Top-level `overrides:` in `pnpm-workspace.yaml`.
47    PnpmWorkspaceYaml,
48    /// `pnpm.overrides` in a root `package.json`.
49    PnpmPackageJson,
50}
51
52/// Structured override data extracted from one source.
53#[derive(Debug, Clone, Default)]
54pub struct PnpmOverrideData {
55    /// Entries declared in source order.
56    pub entries: Vec<PnpmOverrideEntry>,
57}
58
59/// A single override entry.
60#[derive(Debug, Clone)]
61pub struct PnpmOverrideEntry {
62    /// The full original key as written in the source (e.g.
63    /// `"react>react-dom"`, `"@types/react@<18"`). Preserved for round-trip
64    /// reporting so agents see the unmodified spelling.
65    pub raw_key: String,
66    /// Parsed structure of the key. `None` when the key cannot be parsed into
67    /// a pnpm-recognised shape; in that case the entry is reported as
68    /// misconfigured rather than checked for usage.
69    pub parsed_key: Option<ParsedOverrideKey>,
70    /// The right-hand side of the entry (the version pnpm should force).
71    /// `None` when the value is missing or unparsable.
72    pub raw_value: Option<String>,
73    /// 1-based line number of the entry within the source file.
74    pub line: u32,
75}
76
77/// Parsed structure of an override key.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct ParsedOverrideKey {
80    /// Optional parent package (left side of `>`). `None` for bare-target keys.
81    pub parent_package: Option<String>,
82    /// Optional version selector on the parent (e.g. `react@1>zoo` has
83    /// `parent_version_selector = Some("1")`).
84    pub parent_version_selector: Option<String>,
85    /// The target package name (the entry pnpm rewrites).
86    pub target_package: String,
87    /// Optional version selector on the target (e.g. `@types/react@<18` has
88    /// `target_version_selector = Some("<18")`).
89    pub target_version_selector: Option<String>,
90}
91
92/// Parse the `overrides:` section of `pnpm-workspace.yaml`. Returns an empty
93/// `PnpmOverrideData` when the file has no overrides, when the YAML is
94/// malformed, or when the section is present but empty.
95#[must_use]
96pub fn parse_pnpm_workspace_overrides(source: &str) -> PnpmOverrideData {
97    let value: serde_yaml_ng::Value = match serde_yaml_ng::from_str(source) {
98        Ok(v) => v,
99        Err(_) => return PnpmOverrideData::default(),
100    };
101    let Some(mapping) = value.as_mapping() else {
102        return PnpmOverrideData::default();
103    };
104    let Some(overrides_value) = mapping.get("overrides") else {
105        return PnpmOverrideData::default();
106    };
107    let Some(overrides_map) = overrides_value.as_mapping() else {
108        return PnpmOverrideData::default();
109    };
110
111    let line_index = build_yaml_line_index(source);
112    let entries = overrides_map
113        .iter()
114        .filter_map(|(k, v)| {
115            let raw_key = k.as_str()?.to_string();
116            let raw_value = match v {
117                serde_yaml_ng::Value::String(s) => Some(s.clone()),
118                serde_yaml_ng::Value::Null => None,
119                other => Some(yaml_value_to_string(other)),
120            };
121            let line = line_index.line_for(&raw_key)?;
122            let parsed_key = parse_override_key(&raw_key);
123            Some(PnpmOverrideEntry {
124                raw_key,
125                parsed_key,
126                raw_value,
127                line,
128            })
129        })
130        .collect();
131
132    PnpmOverrideData { entries }
133}
134
135/// Parse the `pnpm.overrides` section of a root `package.json`. Returns an
136/// empty `PnpmOverrideData` when the file has no overrides, when the JSON is
137/// malformed, or when the section is present but empty.
138#[must_use]
139pub fn parse_pnpm_package_json_overrides(source: &str) -> PnpmOverrideData {
140    let value: serde_json::Value = match serde_json::from_str(source) {
141        Ok(v) => v,
142        Err(_) => return PnpmOverrideData::default(),
143    };
144    let Some(overrides) = value.get("pnpm").and_then(|p| p.get("overrides")) else {
145        return PnpmOverrideData::default();
146    };
147    let Some(overrides_obj) = overrides.as_object() else {
148        return PnpmOverrideData::default();
149    };
150
151    let line_index = build_package_json_line_index(source);
152    let entries = overrides_obj
153        .iter()
154        .filter_map(|(raw_key, v)| {
155            let raw_value = match v {
156                serde_json::Value::String(s) => Some(s.clone()),
157                serde_json::Value::Null => None,
158                other => Some(other.to_string()),
159            };
160            let line = line_index.line_for(raw_key)?;
161            let parsed_key = parse_override_key(raw_key);
162            Some(PnpmOverrideEntry {
163                raw_key: raw_key.clone(),
164                parsed_key,
165                raw_value,
166                line,
167            })
168        })
169        .collect();
170
171    PnpmOverrideData { entries }
172}
173
174/// Parse an override key into `parent`, `target`, and optional version
175/// selectors. Returns `None` when the key cannot be split into a recognised
176/// shape (empty key, parent or target missing).
177#[must_use]
178pub fn parse_override_key(key: &str) -> Option<ParsedOverrideKey> {
179    let trimmed = key.trim();
180    if trimmed.is_empty() {
181        return None;
182    }
183
184    // Split on the last `>`. pnpm parses single-depth parent matchers; multi-hop
185    // `a>b>c` is not officially documented but the resolver treats the rightmost
186    // segment as the target and everything left as the parent chain. We split
187    // on the LAST `>` so the parent side keeps any earlier `>` for future
188    // multi-hop support.
189    let (parent_part, target_part) = if let Some(idx) = trimmed.rfind('>') {
190        (Some(trimmed[..idx].trim()), trimmed[idx + 1..].trim())
191    } else {
192        (None, trimmed)
193    };
194
195    let (target_package, target_version_selector) = split_pkg_and_selector(target_part)?;
196
197    let (parent_package, parent_version_selector) = match parent_part {
198        Some(parent) if !parent.is_empty() => {
199            let (pkg, selector) = split_pkg_and_selector(parent)?;
200            (Some(pkg), selector)
201        }
202        // `>target` (leading separator with empty parent) is malformed: the
203        // user clearly intended a parent chain but left the parent slot blank.
204        Some(_) => return None,
205        None => (None, None),
206    };
207
208    Some(ParsedOverrideKey {
209        parent_package,
210        parent_version_selector,
211        target_package,
212        target_version_selector,
213    })
214}
215
216/// Split a `pkg@selector` segment into `(package_name, Option<selector>)`.
217/// Handles scoped packages (`@scope/name@<2`) by skipping the leading `@`.
218/// Returns `None` when the package name is empty.
219fn split_pkg_and_selector(segment: &str) -> Option<(String, Option<String>)> {
220    let trimmed = segment.trim();
221    if trimmed.is_empty() {
222        return None;
223    }
224
225    let bytes = trimmed.as_bytes();
226    let scoped = bytes.first().copied() == Some(b'@');
227    let start = usize::from(scoped);
228    let at_pos = trimmed[start..].find('@').map(|i| i + start);
229
230    let (pkg, selector) = match at_pos {
231        Some(pos) => (
232            trimmed[..pos].to_string(),
233            Some(trimmed[pos + 1..].to_string()),
234        ),
235        None => (trimmed.to_string(), None),
236    };
237
238    if pkg.is_empty() {
239        return None;
240    }
241    Some((pkg, selector))
242}
243
244/// Check whether `value` is a valid pnpm override right-hand side, even if it
245/// is not a semver range. Returns `false` when the value is empty, contains a
246/// raw newline, or is otherwise garbage.
247#[must_use]
248pub fn is_valid_override_value(value: &str) -> bool {
249    let trimmed = value.trim();
250    if trimmed.is_empty() {
251        return false;
252    }
253    if trimmed.contains('\n') {
254        return false;
255    }
256    // pnpm accepts: semver ranges, `-` (removal), `$ref` (self-ref),
257    // `npm:alias@^1` (alias), `workspace:*`. We do not validate semver ranges
258    // here, only screen for obviously broken inputs.
259    true
260}
261
262/// Convenience: is this entry effectively a misconfiguration the user should
263/// see as an error?
264#[must_use]
265pub fn override_misconfig_reason(entry: &PnpmOverrideEntry) -> Option<MisconfigReason> {
266    if entry.parsed_key.is_none() {
267        return Some(MisconfigReason::UnparsableKey);
268    }
269    match &entry.raw_value {
270        None => Some(MisconfigReason::EmptyValue),
271        Some(v) if !is_valid_override_value(v) => Some(MisconfigReason::EmptyValue),
272        _ => None,
273    }
274}
275
276/// Why an override entry is misconfigured.
277#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
278#[serde(rename_all = "kebab-case")]
279pub enum MisconfigReason {
280    /// The override key cannot be parsed into a recognised pnpm shape.
281    UnparsableKey,
282    /// The override value is missing or empty.
283    EmptyValue,
284}
285
286impl MisconfigReason {
287    /// Human-readable description.
288    #[must_use]
289    pub const fn describe(self) -> &'static str {
290        match self {
291            Self::UnparsableKey => "override key cannot be parsed",
292            Self::EmptyValue => "override value is missing or empty",
293        }
294    }
295}
296
297struct YamlLineIndex {
298    entries: Vec<(String, u32)>,
299}
300
301impl YamlLineIndex {
302    fn line_for(&self, key: &str) -> Option<u32> {
303        self.entries
304            .iter()
305            .find(|(k, _)| k == key)
306            .map(|(_, line)| *line)
307    }
308}
309
310/// Walk the raw YAML source to map each `overrides:` entry key to its 1-based
311/// line number. Mirrors the catalog parser's section-aware scanner.
312fn build_yaml_line_index(source: &str) -> YamlLineIndex {
313    let mut entries = Vec::new();
314    let mut in_overrides = false;
315
316    for (idx, raw_line) in source.lines().enumerate() {
317        let line_no = u32::try_from(idx).unwrap_or(u32::MAX).saturating_add(1);
318        let trimmed = strip_inline_comment(raw_line);
319        let trimmed_left = trimmed.trim_start();
320        let indent = trimmed.len() - trimmed_left.len();
321
322        if trimmed_left.is_empty() {
323            continue;
324        }
325
326        if indent == 0 {
327            in_overrides = trimmed_left.starts_with("overrides:");
328            continue;
329        }
330
331        if in_overrides && let Some(key) = parse_key(trimmed_left) {
332            entries.push((key, line_no));
333        }
334    }
335
336    YamlLineIndex { entries }
337}
338
339/// Walk a raw `package.json` source string to map each `pnpm.overrides` entry
340/// key to its 1-based line number. The scan tracks brace depth so nested
341/// objects under unrelated keys (e.g., `dependenciesMeta`) cannot be misread
342/// as override entries.
343fn build_package_json_line_index(source: &str) -> YamlLineIndex {
344    let mut entries = Vec::new();
345    let mut depth: i32 = 0;
346    let mut pnpm_depth: Option<i32> = None;
347    let mut in_overrides_depth: Option<i32> = None;
348    let mut in_string = false;
349    let mut escape = false;
350    let mut current_line = 1u32;
351    let mut last_key: Option<String> = None;
352    let mut key_buf = String::new();
353    let mut collecting_key = false;
354
355    for ch in source.chars() {
356        if ch == '\n' {
357            current_line += 1;
358        }
359
360        if in_string {
361            if escape {
362                if collecting_key {
363                    key_buf.push(ch);
364                }
365                escape = false;
366                continue;
367            }
368            if ch == '\\' {
369                escape = true;
370                if collecting_key {
371                    key_buf.push(ch);
372                }
373                continue;
374            }
375            if ch == '"' {
376                in_string = false;
377                if collecting_key {
378                    last_key = Some(std::mem::take(&mut key_buf));
379                    collecting_key = false;
380                }
381                continue;
382            }
383            if collecting_key {
384                key_buf.push(ch);
385            }
386            continue;
387        }
388
389        match ch {
390            '"' => {
391                in_string = true;
392                // Start collecting a new key candidate. We commit it only when
393                // followed by `:` at the appropriate depth.
394                collecting_key = true;
395                key_buf.clear();
396            }
397            '{' => depth += 1,
398            '}' => {
399                if Some(depth) == in_overrides_depth {
400                    in_overrides_depth = None;
401                }
402                if Some(depth) == pnpm_depth {
403                    pnpm_depth = None;
404                }
405                depth -= 1;
406            }
407            ':' => {
408                if let Some(key) = last_key.take() {
409                    // Promote into a section if the key opens an object
410                    // immediately. We track section transitions by matching
411                    // the key name + current depth.
412                    if pnpm_depth.is_none() && depth == 1 && key == "pnpm" {
413                        pnpm_depth = Some(depth);
414                    } else if in_overrides_depth.is_none()
415                        && pnpm_depth.is_some()
416                        && depth == pnpm_depth.unwrap_or(0) + 1
417                        && key == "overrides"
418                    {
419                        in_overrides_depth = Some(depth);
420                    } else if let Some(d) = in_overrides_depth
421                        && depth == d + 1
422                    {
423                        // This is an override entry key at the right depth.
424                        entries.push((key, current_line));
425                    }
426                }
427            }
428            ',' => {
429                last_key = None;
430            }
431            _ => {}
432        }
433    }
434
435    YamlLineIndex { entries }
436}
437
438fn yaml_value_to_string(value: &serde_yaml_ng::Value) -> String {
439    match value {
440        serde_yaml_ng::Value::String(s) => s.clone(),
441        serde_yaml_ng::Value::Number(n) => n.to_string(),
442        serde_yaml_ng::Value::Bool(b) => b.to_string(),
443        serde_yaml_ng::Value::Null => String::new(),
444        _ => serde_yaml_ng::to_string(value).unwrap_or_default(),
445    }
446}
447
448/// Source-name string for diagnostics.
449#[must_use]
450pub fn override_source_label(source: OverrideSource, path: &Path) -> String {
451    match source {
452        OverrideSource::PnpmWorkspaceYaml => "pnpm-workspace.yaml".to_string(),
453        OverrideSource::PnpmPackageJson => path.display().to_string(),
454    }
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460
461    #[test]
462    fn parse_bare_target() {
463        let parsed = parse_override_key("axios").unwrap();
464        assert_eq!(parsed.target_package, "axios");
465        assert!(parsed.parent_package.is_none());
466        assert!(parsed.target_version_selector.is_none());
467    }
468
469    #[test]
470    fn parse_scoped_target() {
471        let parsed = parse_override_key("@types/react").unwrap();
472        assert_eq!(parsed.target_package, "@types/react");
473        assert!(parsed.target_version_selector.is_none());
474    }
475
476    #[test]
477    fn parse_target_with_version_selector() {
478        let parsed = parse_override_key("@types/react@<18").unwrap();
479        assert_eq!(parsed.target_package, "@types/react");
480        assert_eq!(parsed.target_version_selector.as_deref(), Some("<18"));
481    }
482
483    #[test]
484    fn parse_parent_chain() {
485        let parsed = parse_override_key("react>react-dom").unwrap();
486        assert_eq!(parsed.parent_package.as_deref(), Some("react"));
487        assert_eq!(parsed.target_package, "react-dom");
488    }
489
490    #[test]
491    fn parse_parent_chain_with_selectors() {
492        let parsed = parse_override_key("react@1>zoo").unwrap();
493        assert_eq!(parsed.parent_package.as_deref(), Some("react"));
494        assert_eq!(parsed.parent_version_selector.as_deref(), Some("1"));
495        assert_eq!(parsed.target_package, "zoo");
496    }
497
498    #[test]
499    fn parse_scoped_parent_and_target() {
500        let parsed = parse_override_key("@react-spring/web>@react-spring/core").unwrap();
501        assert_eq!(parsed.parent_package.as_deref(), Some("@react-spring/web"));
502        assert_eq!(parsed.target_package, "@react-spring/core");
503    }
504
505    #[test]
506    fn parse_empty_returns_none() {
507        assert!(parse_override_key("").is_none());
508        assert!(parse_override_key("   ").is_none());
509    }
510
511    #[test]
512    fn parse_dangling_separator_returns_none() {
513        assert!(parse_override_key("react>").is_none());
514        assert!(parse_override_key(">react-dom").is_none());
515    }
516
517    #[test]
518    fn is_valid_override_value_accepts_pnpm_idioms() {
519        assert!(is_valid_override_value("^1.6.0"));
520        assert!(is_valid_override_value("-"));
521        assert!(is_valid_override_value("$foo"));
522        assert!(is_valid_override_value("npm:@scope/alias@^1.0.0"));
523        assert!(is_valid_override_value("workspace:*"));
524    }
525
526    #[test]
527    fn is_valid_override_value_rejects_empty_and_newline() {
528        assert!(!is_valid_override_value(""));
529        assert!(!is_valid_override_value("   "));
530        assert!(!is_valid_override_value("^1\n^2"));
531    }
532
533    #[test]
534    fn parses_workspace_yaml_overrides() {
535        let yaml = "packages:\n  - 'packages/*'\n\noverrides:\n  axios: ^1.6.0\n  \"@types/react@<18\": '18.0.0'\n  \"react>react-dom\": ^17\n";
536        let data = parse_pnpm_workspace_overrides(yaml);
537        assert_eq!(data.entries.len(), 3);
538        assert_eq!(data.entries[0].raw_key, "axios");
539        assert_eq!(data.entries[0].line, 5);
540        assert_eq!(data.entries[0].raw_value.as_deref(), Some("^1.6.0"));
541
542        assert_eq!(data.entries[1].raw_key, "@types/react@<18");
543        assert_eq!(data.entries[1].line, 6);
544        assert_eq!(data.entries[1].raw_value.as_deref(), Some("18.0.0"));
545        assert_eq!(
546            data.entries[1]
547                .parsed_key
548                .as_ref()
549                .and_then(|p| p.target_version_selector.as_deref()),
550            Some("<18")
551        );
552
553        assert_eq!(data.entries[2].raw_key, "react>react-dom");
554        assert_eq!(data.entries[2].line, 7);
555        assert_eq!(
556            data.entries[2]
557                .parsed_key
558                .as_ref()
559                .map(|p| p.target_package.as_str()),
560            Some("react-dom")
561        );
562    }
563
564    #[test]
565    fn parses_package_json_overrides() {
566        let json = r#"{
567  "name": "root",
568  "pnpm": {
569    "overrides": {
570      "axios": "^1.6.0",
571      "react>react-dom": "^17"
572    }
573  },
574  "dependenciesMeta": {
575    "shouldNotMatch": { "injected": true }
576  }
577}"#;
578        let data = parse_pnpm_package_json_overrides(json);
579        assert_eq!(data.entries.len(), 2);
580        assert_eq!(data.entries[0].raw_key, "axios");
581        assert_eq!(data.entries[0].raw_value.as_deref(), Some("^1.6.0"));
582        assert_eq!(data.entries[0].line, 5);
583        assert_eq!(data.entries[1].raw_key, "react>react-dom");
584        assert_eq!(data.entries[1].line, 6);
585    }
586
587    #[test]
588    fn empty_workspace_overrides_returns_no_entries() {
589        let data = parse_pnpm_workspace_overrides("overrides: {}\n");
590        assert!(data.entries.is_empty());
591    }
592
593    #[test]
594    fn malformed_yaml_returns_no_entries() {
595        let data = parse_pnpm_workspace_overrides("{this is\nnot: valid: yaml");
596        assert!(data.entries.is_empty());
597    }
598
599    #[test]
600    fn package_json_without_pnpm_overrides_returns_no_entries() {
601        let data = parse_pnpm_package_json_overrides(r#"{"dependencies": {"axios": "^1"}}"#);
602        assert!(data.entries.is_empty());
603    }
604
605    #[test]
606    fn malformed_json_returns_no_entries() {
607        let data = parse_pnpm_package_json_overrides("{not valid json");
608        assert!(data.entries.is_empty());
609    }
610
611    #[test]
612    fn unparsable_key_carries_misconfig_signal() {
613        let yaml = "overrides:\n  \">@bad-key>\": ^1.0.0\n";
614        let data = parse_pnpm_workspace_overrides(yaml);
615        assert_eq!(data.entries.len(), 1);
616        assert!(data.entries[0].parsed_key.is_none());
617        assert_eq!(
618            override_misconfig_reason(&data.entries[0]),
619            Some(MisconfigReason::UnparsableKey)
620        );
621    }
622}