1use std::path::Path;
39
40use super::pnpm_catalog::{parse_key, strip_inline_comment};
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
44#[serde(rename_all = "snake_case")]
45pub enum OverrideSource {
46 PnpmWorkspaceYaml,
48 PnpmPackageJson,
50}
51
52#[derive(Debug, Clone, Default)]
54pub struct PnpmOverrideData {
55 pub entries: Vec<PnpmOverrideEntry>,
57}
58
59#[derive(Debug, Clone)]
61pub struct PnpmOverrideEntry {
62 pub raw_key: String,
66 pub parsed_key: Option<ParsedOverrideKey>,
70 pub raw_value: Option<String>,
73 pub line: u32,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct ParsedOverrideKey {
80 pub parent_package: Option<String>,
82 pub parent_version_selector: Option<String>,
85 pub target_package: String,
87 pub target_version_selector: Option<String>,
90}
91
92#[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#[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#[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 let (parent_part, target_part) = if let Some(idx) = trimmed.rfind('>') {
185 (Some(trimmed[..idx].trim()), trimmed[idx + 1..].trim())
186 } else {
187 (None, trimmed)
188 };
189
190 let (target_package, target_version_selector) = split_pkg_and_selector(target_part)?;
191
192 let (parent_package, parent_version_selector) = match parent_part {
193 Some(parent) if !parent.is_empty() => {
194 let (pkg, selector) = split_pkg_and_selector(parent)?;
195 (Some(pkg), selector)
196 }
197 Some(_) => return None,
198 None => (None, None),
199 };
200
201 Some(ParsedOverrideKey {
202 parent_package,
203 parent_version_selector,
204 target_package,
205 target_version_selector,
206 })
207}
208
209fn split_pkg_and_selector(segment: &str) -> Option<(String, Option<String>)> {
213 let trimmed = segment.trim();
214 if trimmed.is_empty() {
215 return None;
216 }
217
218 let bytes = trimmed.as_bytes();
219 let scoped = bytes.first().copied() == Some(b'@');
220 let start = usize::from(scoped);
221 let at_pos = trimmed[start..].find('@').map(|i| i + start);
222
223 let (pkg, selector) = match at_pos {
224 Some(pos) => (
225 trimmed[..pos].to_string(),
226 Some(trimmed[pos + 1..].to_string()),
227 ),
228 None => (trimmed.to_string(), None),
229 };
230
231 if pkg.is_empty() {
232 return None;
233 }
234 Some((pkg, selector))
235}
236
237#[must_use]
241pub fn is_valid_override_value(value: &str) -> bool {
242 let trimmed = value.trim();
243 if trimmed.is_empty() {
244 return false;
245 }
246 if trimmed.contains('\n') {
247 return false;
248 }
249 true
250}
251
252#[must_use]
255pub fn override_misconfig_reason(entry: &PnpmOverrideEntry) -> Option<MisconfigReason> {
256 if entry.parsed_key.is_none() {
257 return Some(MisconfigReason::UnparsableKey);
258 }
259 match &entry.raw_value {
260 None => Some(MisconfigReason::EmptyValue),
261 Some(v) if !is_valid_override_value(v) => Some(MisconfigReason::EmptyValue),
262 _ => None,
263 }
264}
265
266#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
268#[serde(rename_all = "kebab-case")]
269pub enum MisconfigReason {
270 UnparsableKey,
272 EmptyValue,
274}
275
276impl MisconfigReason {
277 #[must_use]
279 pub const fn describe(self) -> &'static str {
280 match self {
281 Self::UnparsableKey => "override key cannot be parsed",
282 Self::EmptyValue => "override value is missing or empty",
283 }
284 }
285}
286
287struct YamlLineIndex {
288 entries: Vec<(String, u32)>,
289}
290
291impl YamlLineIndex {
292 fn line_for(&self, key: &str) -> Option<u32> {
293 self.entries
294 .iter()
295 .find(|(k, _)| k == key)
296 .map(|(_, line)| *line)
297 }
298}
299
300fn build_yaml_line_index(source: &str) -> YamlLineIndex {
303 let mut entries = Vec::new();
304 let mut in_overrides = false;
305
306 for (idx, raw_line) in source.lines().enumerate() {
307 let line_no = u32::try_from(idx).unwrap_or(u32::MAX).saturating_add(1);
308 let trimmed = strip_inline_comment(raw_line);
309 let trimmed_left = trimmed.trim_start();
310 let indent = trimmed.len() - trimmed_left.len();
311
312 if trimmed_left.is_empty() {
313 continue;
314 }
315
316 if indent == 0 {
317 in_overrides = trimmed_left.starts_with("overrides:");
318 continue;
319 }
320
321 if in_overrides && let Some(key) = parse_key(trimmed_left) {
322 entries.push((key, line_no));
323 }
324 }
325
326 YamlLineIndex { entries }
327}
328
329#[derive(Default)]
335struct OverridesJsonScan {
336 entries: Vec<(String, u32)>,
337 depth: i32,
338 pnpm_depth: Option<i32>,
339 in_overrides_depth: Option<i32>,
340 in_string: bool,
341 escape: bool,
342 last_key: Option<String>,
343 key_buf: String,
344 collecting_key: bool,
345}
346
347impl OverridesJsonScan {
348 fn consume_in_string_char(&mut self, ch: char) {
351 if self.escape {
352 if self.collecting_key {
353 self.key_buf.push(ch);
354 }
355 self.escape = false;
356 return;
357 }
358 if ch == '\\' {
359 self.escape = true;
360 if self.collecting_key {
361 self.key_buf.push(ch);
362 }
363 return;
364 }
365 if ch == '"' {
366 self.in_string = false;
367 if self.collecting_key {
368 self.last_key = Some(std::mem::take(&mut self.key_buf));
369 self.collecting_key = false;
370 }
371 return;
372 }
373 if self.collecting_key {
374 self.key_buf.push(ch);
375 }
376 }
377
378 fn consume_structural_char(&mut self, ch: char, current_line: u32) {
381 match ch {
382 '"' => {
383 self.in_string = true;
384 self.collecting_key = true;
385 self.key_buf.clear();
386 }
387 '{' => self.depth += 1,
388 '}' => {
389 if Some(self.depth) == self.in_overrides_depth {
390 self.in_overrides_depth = None;
391 }
392 if Some(self.depth) == self.pnpm_depth {
393 self.pnpm_depth = None;
394 }
395 self.depth -= 1;
396 }
397 ':' => self.record_key_after_colon(current_line),
398 ',' => {
399 self.last_key = None;
400 }
401 _ => {}
402 }
403 }
404
405 fn record_key_after_colon(&mut self, current_line: u32) {
408 let Some(key) = self.last_key.take() else {
409 return;
410 };
411 if self.pnpm_depth.is_none() && self.depth == 1 && key == "pnpm" {
412 self.pnpm_depth = Some(self.depth);
413 } else if self.in_overrides_depth.is_none()
414 && self.pnpm_depth.is_some()
415 && self.depth == self.pnpm_depth.unwrap_or(0) + 1
416 && key == "overrides"
417 {
418 self.in_overrides_depth = Some(self.depth);
419 } else if let Some(d) = self.in_overrides_depth
420 && self.depth == d + 1
421 {
422 self.entries.push((key, current_line));
423 }
424 }
425}
426
427fn build_package_json_line_index(source: &str) -> YamlLineIndex {
428 let mut scan = OverridesJsonScan::default();
429 let mut current_line = 1u32;
430
431 for ch in source.chars() {
432 if ch == '\n' {
433 current_line += 1;
434 }
435
436 if scan.in_string {
437 scan.consume_in_string_char(ch);
438 } else {
439 scan.consume_structural_char(ch, current_line);
440 }
441 }
442
443 YamlLineIndex {
444 entries: scan.entries,
445 }
446}
447
448fn yaml_value_to_string(value: &serde_yaml_ng::Value) -> String {
449 match value {
450 serde_yaml_ng::Value::String(s) => s.clone(),
451 serde_yaml_ng::Value::Number(n) => n.to_string(),
452 serde_yaml_ng::Value::Bool(b) => b.to_string(),
453 serde_yaml_ng::Value::Null => String::new(),
454 _ => serde_yaml_ng::to_string(value).unwrap_or_default(),
455 }
456}
457
458#[must_use]
460pub fn override_source_label(source: OverrideSource, path: &Path) -> String {
461 match source {
462 OverrideSource::PnpmWorkspaceYaml => "pnpm-workspace.yaml".to_string(),
463 OverrideSource::PnpmPackageJson => path.display().to_string(),
464 }
465}
466
467#[cfg(test)]
468mod tests {
469 use super::*;
470
471 #[test]
472 fn parse_bare_target() {
473 let parsed = parse_override_key("axios").unwrap();
474 assert_eq!(parsed.target_package, "axios");
475 assert!(parsed.parent_package.is_none());
476 assert!(parsed.target_version_selector.is_none());
477 }
478
479 #[test]
480 fn parse_scoped_target() {
481 let parsed = parse_override_key("@types/react").unwrap();
482 assert_eq!(parsed.target_package, "@types/react");
483 assert!(parsed.target_version_selector.is_none());
484 }
485
486 #[test]
487 fn parse_target_with_version_selector() {
488 let parsed = parse_override_key("@types/react@<18").unwrap();
489 assert_eq!(parsed.target_package, "@types/react");
490 assert_eq!(parsed.target_version_selector.as_deref(), Some("<18"));
491 }
492
493 #[test]
494 fn parse_parent_chain() {
495 let parsed = parse_override_key("react>react-dom").unwrap();
496 assert_eq!(parsed.parent_package.as_deref(), Some("react"));
497 assert_eq!(parsed.target_package, "react-dom");
498 }
499
500 #[test]
501 fn parse_parent_chain_with_selectors() {
502 let parsed = parse_override_key("react@1>zoo").unwrap();
503 assert_eq!(parsed.parent_package.as_deref(), Some("react"));
504 assert_eq!(parsed.parent_version_selector.as_deref(), Some("1"));
505 assert_eq!(parsed.target_package, "zoo");
506 }
507
508 #[test]
509 fn parse_scoped_parent_and_target() {
510 let parsed = parse_override_key("@react-spring/web>@react-spring/core").unwrap();
511 assert_eq!(parsed.parent_package.as_deref(), Some("@react-spring/web"));
512 assert_eq!(parsed.target_package, "@react-spring/core");
513 }
514
515 #[test]
516 fn parse_empty_returns_none() {
517 assert!(parse_override_key("").is_none());
518 assert!(parse_override_key(" ").is_none());
519 }
520
521 #[test]
522 fn parse_dangling_separator_returns_none() {
523 assert!(parse_override_key("react>").is_none());
524 assert!(parse_override_key(">react-dom").is_none());
525 }
526
527 #[test]
528 fn is_valid_override_value_accepts_pnpm_idioms() {
529 assert!(is_valid_override_value("^1.6.0"));
530 assert!(is_valid_override_value("-"));
531 assert!(is_valid_override_value("$foo"));
532 assert!(is_valid_override_value("npm:@scope/alias@^1.0.0"));
533 assert!(is_valid_override_value("workspace:*"));
534 }
535
536 #[test]
537 fn is_valid_override_value_rejects_empty_and_newline() {
538 assert!(!is_valid_override_value(""));
539 assert!(!is_valid_override_value(" "));
540 assert!(!is_valid_override_value("^1\n^2"));
541 }
542
543 #[test]
544 fn parses_workspace_yaml_overrides() {
545 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";
546 let data = parse_pnpm_workspace_overrides(yaml);
547 assert_eq!(data.entries.len(), 3);
548 assert_eq!(data.entries[0].raw_key, "axios");
549 assert_eq!(data.entries[0].line, 5);
550 assert_eq!(data.entries[0].raw_value.as_deref(), Some("^1.6.0"));
551
552 assert_eq!(data.entries[1].raw_key, "@types/react@<18");
553 assert_eq!(data.entries[1].line, 6);
554 assert_eq!(data.entries[1].raw_value.as_deref(), Some("18.0.0"));
555 assert_eq!(
556 data.entries[1]
557 .parsed_key
558 .as_ref()
559 .and_then(|p| p.target_version_selector.as_deref()),
560 Some("<18")
561 );
562
563 assert_eq!(data.entries[2].raw_key, "react>react-dom");
564 assert_eq!(data.entries[2].line, 7);
565 assert_eq!(
566 data.entries[2]
567 .parsed_key
568 .as_ref()
569 .map(|p| p.target_package.as_str()),
570 Some("react-dom")
571 );
572 }
573
574 #[test]
575 fn parses_package_json_overrides() {
576 let json = r#"{
577 "name": "root",
578 "pnpm": {
579 "overrides": {
580 "axios": "^1.6.0",
581 "react>react-dom": "^17"
582 }
583 },
584 "dependenciesMeta": {
585 "shouldNotMatch": { "injected": true }
586 }
587}"#;
588 let data = parse_pnpm_package_json_overrides(json);
589 assert_eq!(data.entries.len(), 2);
590 assert_eq!(data.entries[0].raw_key, "axios");
591 assert_eq!(data.entries[0].raw_value.as_deref(), Some("^1.6.0"));
592 assert_eq!(data.entries[0].line, 5);
593 assert_eq!(data.entries[1].raw_key, "react>react-dom");
594 assert_eq!(data.entries[1].line, 6);
595 }
596
597 #[test]
598 fn empty_workspace_overrides_returns_no_entries() {
599 let data = parse_pnpm_workspace_overrides("overrides: {}\n");
600 assert!(data.entries.is_empty());
601 }
602
603 #[test]
604 fn malformed_yaml_returns_no_entries() {
605 let data = parse_pnpm_workspace_overrides("{this is\nnot: valid: yaml");
606 assert!(data.entries.is_empty());
607 }
608
609 #[test]
610 fn package_json_without_pnpm_overrides_returns_no_entries() {
611 let data = parse_pnpm_package_json_overrides(r#"{"dependencies": {"axios": "^1"}}"#);
612 assert!(data.entries.is_empty());
613 }
614
615 #[test]
616 fn malformed_json_returns_no_entries() {
617 let data = parse_pnpm_package_json_overrides("{not valid json");
618 assert!(data.entries.is_empty());
619 }
620
621 #[test]
622 fn unparsable_key_carries_misconfig_signal() {
623 let yaml = "overrides:\n \">@bad-key>\": ^1.0.0\n";
624 let data = parse_pnpm_workspace_overrides(yaml);
625 assert_eq!(data.entries.len(), 1);
626 assert!(data.entries[0].parsed_key.is_none());
627 assert_eq!(
628 override_misconfig_reason(&data.entries[0]),
629 Some(MisconfigReason::UnparsableKey)
630 );
631 }
632}