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