1use std::path::Path;
39
40use super::pnpm_catalog::{parse_key, strip_inline_comment};
41use crate::yaml::YamlNode;
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
45#[serde(rename_all = "snake_case")]
46pub enum OverrideSource {
47 PnpmWorkspaceYaml,
49 PnpmPackageJson,
51}
52
53#[derive(Debug, Clone, Default)]
55pub struct PnpmOverrideData {
56 pub entries: Vec<PnpmOverrideEntry>,
58}
59
60#[derive(Debug, Clone)]
62pub struct PnpmOverrideEntry {
63 pub raw_key: String,
67 pub parsed_key: Option<ParsedOverrideKey>,
71 pub raw_value: Option<String>,
74 pub line: u32,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct ParsedOverrideKey {
81 pub parent_package: Option<String>,
83 pub parent_version_selector: Option<String>,
86 pub target_package: String,
88 pub target_version_selector: Option<String>,
91}
92
93pub 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#[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#[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
217pub 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#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
276#[serde(rename_all = "kebab-case")]
277pub enum MisconfigReason {
278 UnparsableKey,
280 EmptyValue,
282}
283
284impl MisconfigReason {
285 #[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
308fn 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#[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 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 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 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#[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}