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