1use std::fmt;
9use std::str::FromStr;
10
11use serde::{Deserialize, Deserializer, Serialize, Serializer};
12use smallvec::{SmallVec, smallvec};
13
14pub fn strip_outer_quotes(s: &str) -> &str {
18 s.strip_prefix('"')
19 .and_then(|s| s.strip_suffix('"'))
20 .unwrap_or(s)
21}
22
23pub(crate) const INVALID_SEGMENT_SENTINEL: &str = "__invalid__";
25
26#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
27pub struct Segment(String);
28
29#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
30pub enum SegmentError {
31 #[error("segment must not be empty")]
32 Empty,
33 #[error("segment must not contain an embedded double quote")]
34 ContainsQuote,
35 #[error("segment must not contain control characters")]
36 ContainsControl,
37}
38
39impl Segment {
40 pub fn from_unquoted(s: impl Into<String>) -> Result<Self, SegmentError> {
46 let s = s.into();
47 if s.is_empty() {
48 return Err(SegmentError::Empty);
49 }
50 if s.contains('"') {
51 return Err(SegmentError::ContainsQuote);
52 }
53 if s.chars().any(|c| c.is_control()) {
54 return Err(SegmentError::ContainsControl);
55 }
56 Ok(Segment(s))
57 }
58
59 pub fn from_source(s: &str) -> Result<Self, SegmentError> {
62 let body = if s.len() >= 2 && s.starts_with('"') && s.ends_with('"') {
63 &s[1..s.len() - 1]
64 } else {
65 s
66 };
67 Segment::from_unquoted(body.to_string())
68 }
69
70 pub fn from_syntax(node: &rnix::SyntaxNode) -> Result<Self, SegmentError> {
72 Segment::from_source(&node.to_string())
73 }
74
75 pub(crate) fn from_syntax_or_sentinel(node: &rnix::SyntaxNode) -> Self {
79 Segment::from_syntax(node).unwrap_or_else(|err| {
80 let raw = node.to_string();
81 tracing::warn!(
82 "follows::path::Segment: invalid attribute segment {raw:?} ({err}); using \
83 sentinel {INVALID_SEGMENT_SENTINEL:?}"
84 );
85 Segment::from_unquoted(INVALID_SEGMENT_SENTINEL)
86 .expect("sentinel segment is non-empty and quote-free")
87 })
88 }
89
90 pub fn as_str(&self) -> &str {
91 &self.0
92 }
93
94 pub fn into_string(self) -> String {
96 self.0
97 }
98
99 pub fn needs_quoting(&self) -> bool {
104 let mut chars = self.0.chars();
105 let Some(first) = chars.next() else {
106 return true;
107 };
108 if !(first.is_ascii_alphabetic() || first == '_') {
109 return true;
110 }
111 for c in chars {
112 if !(c.is_ascii_alphanumeric() || c == '_' || c == '\'' || c == '-') {
113 return true;
114 }
115 }
116 false
117 }
118
119 pub fn render(&self) -> String {
121 if self.needs_quoting() {
122 format!("\"{}\"", self.0)
123 } else {
124 self.0.clone()
125 }
126 }
127}
128
129impl fmt::Display for Segment {
130 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131 f.write_str(&self.render())
132 }
133}
134
135impl FromStr for Segment {
136 type Err = SegmentError;
137
138 fn from_str(s: &str) -> Result<Self, Self::Err> {
139 Segment::from_source(s)
140 }
141}
142
143impl Serialize for Segment {
144 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
145 serializer.serialize_str(&self.0)
146 }
147}
148
149impl<'de> Deserialize<'de> for Segment {
150 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
151 let s = String::deserialize(deserializer)?;
152 Segment::from_unquoted(s).map_err(serde::de::Error::custom)
153 }
154}
155
156#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
157pub struct AttrPath(SmallVec<[Segment; 2]>);
158
159#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
160pub enum AttrPathParseError {
161 #[error("attribute path must not be empty")]
162 Empty,
163 #[error("attribute path has an empty segment")]
164 EmptySegment,
165 #[error("invalid segment: {0}")]
166 SegmentInvalid(#[from] SegmentError),
167}
168
169impl AttrPath {
170 pub fn new(first: Segment) -> Self {
171 AttrPath(smallvec![first])
172 }
173
174 pub fn parse(s: &str) -> Result<Self, AttrPathParseError> {
182 if s.is_empty() {
183 return Err(AttrPathParseError::Empty);
184 }
185 let mut segments: SmallVec<[Segment; 2]> = SmallVec::new();
186 let bytes = s.as_bytes();
187 let mut start = 0;
188 let mut i = 0;
189 while i < bytes.len() {
190 if bytes[i] == b'"' {
191 i += 1;
193 while i < bytes.len() && bytes[i] != b'"' {
194 i += 1;
195 }
196 if i < bytes.len() {
197 i += 1; }
199 } else if bytes[i] == b'.' {
200 let raw = &s[start..i];
201 if raw.is_empty() {
202 return Err(AttrPathParseError::EmptySegment);
203 }
204 segments.push(Segment::from_source(raw)?);
205 i += 1;
206 start = i;
207 } else {
208 i += 1;
209 }
210 }
211 let last = &s[start..];
212 if last.is_empty() {
213 return Err(AttrPathParseError::EmptySegment);
214 }
215 segments.push(Segment::from_source(last)?);
216 Ok(AttrPath(segments))
217 }
218
219 pub fn first(&self) -> &Segment {
220 &self.0[0]
221 }
222
223 pub fn last(&self) -> &Segment {
224 self.0.last().expect("AttrPath is non-empty by invariant")
225 }
226
227 #[expect(clippy::len_without_is_empty)]
228 pub fn len(&self) -> usize {
229 self.0.len()
230 }
231
232 pub fn segments(&self) -> &[Segment] {
233 &self.0
234 }
235
236 pub fn parent(&self) -> Option<AttrPath> {
238 if self.0.len() <= 1 {
239 return None;
240 }
241 let parent_segments: SmallVec<[Segment; 2]> =
242 self.0[..self.0.len() - 1].iter().cloned().collect();
243 Some(AttrPath(parent_segments))
244 }
245
246 pub fn child(&self) -> Option<&Segment> {
248 if self.0.len() >= 2 {
249 self.0.get(1)
250 } else {
251 None
252 }
253 }
254
255 pub fn push(&mut self, seg: Segment) {
256 self.0.push(seg);
257 }
258
259 pub(crate) fn is_prefix_of(&self, other: &AttrPath) -> bool {
262 if self.0.len() > other.0.len() {
263 return false;
264 }
265 self.0.iter().zip(other.0.iter()).all(|(a, b)| a == b)
266 }
267
268 pub(crate) fn parse_follows_target(text: &str, fallback: &Segment) -> Option<AttrPath> {
278 if text.is_empty() {
279 return None;
280 }
281 let body = strip_outer_quotes(text);
282 if body.is_empty() {
283 return None;
284 }
285 let mut segs = body
286 .split('/')
287 .filter(|s| !s.is_empty())
288 .filter_map(|s| Segment::from_unquoted(s.to_string()).ok());
289 let Some(first) = segs.next() else {
290 return Some(AttrPath::new(fallback.clone()));
291 };
292 let mut path = AttrPath::new(first);
293 for seg in segs {
294 path.push(seg);
295 }
296 Some(path)
297 }
298
299 pub fn to_flake_follows_string(&self) -> String {
303 self.0
304 .iter()
305 .map(|s| s.as_str())
306 .collect::<Vec<_>>()
307 .join("/")
308 }
309}
310
311pub(crate) fn join_raw(segments: &[Segment]) -> String {
313 segments
314 .iter()
315 .map(|s| s.as_str())
316 .collect::<Vec<_>>()
317 .join(".")
318}
319
320pub(crate) fn follows_idents_prefixed(segments: &[Segment]) -> Vec<&str> {
322 let mut out: Vec<&str> = Vec::with_capacity(segments.len() * 2 + 1);
323 for seg in segments {
324 out.push("inputs");
325 out.push(seg.as_str());
326 }
327 out.push("follows");
328 out
329}
330
331pub(crate) fn follows_idents_bare(segments: &[Segment]) -> Vec<&str> {
334 let mut out: Vec<&str> = Vec::with_capacity(segments.len() * 2);
335 for (i, seg) in segments.iter().enumerate() {
336 if i > 0 {
337 out.push("inputs");
338 }
339 out.push(seg.as_str());
340 }
341 out.push("follows");
342 out
343}
344
345impl fmt::Display for AttrPath {
346 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
347 let mut first = true;
348 for seg in &self.0 {
349 if !first {
350 f.write_str(".")?;
351 }
352 first = false;
353 f.write_str(&seg.render())?;
354 }
355 Ok(())
356 }
357}
358
359impl FromStr for AttrPath {
360 type Err = AttrPathParseError;
361
362 fn from_str(s: &str) -> Result<Self, Self::Err> {
363 AttrPath::parse(s)
364 }
365}
366
367impl From<Segment> for AttrPath {
368 fn from(value: Segment) -> Self {
369 AttrPath::new(value)
370 }
371}
372
373impl Serialize for AttrPath {
374 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
375 serializer.collect_str(self)
376 }
377}
378
379impl<'de> Deserialize<'de> for AttrPath {
380 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
381 let s = String::deserialize(deserializer)?;
382 AttrPath::parse(&s).map_err(serde::de::Error::custom)
383 }
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389
390 #[test]
391 fn segment_from_unquoted_rejects_empty() {
392 assert_eq!(Segment::from_unquoted(""), Err(SegmentError::Empty));
393 }
394
395 #[test]
396 fn segment_from_unquoted_rejects_embedded_quote() {
397 assert_eq!(
398 Segment::from_unquoted("a\"b"),
399 Err(SegmentError::ContainsQuote)
400 );
401 }
402
403 #[test]
404 fn segment_from_unquoted_rejects_control() {
405 assert_eq!(
406 Segment::from_unquoted("a\nb"),
407 Err(SegmentError::ContainsControl)
408 );
409 }
410
411 #[test]
412 fn segment_from_unquoted_accepts_dotted() {
413 let s = Segment::from_unquoted("hls-1.10").unwrap();
414 assert_eq!(s.as_str(), "hls-1.10");
415 }
416
417 #[test]
418 fn segment_from_source_strips_quotes() {
419 let s = Segment::from_source("\"hls-1.10\"").unwrap();
420 assert_eq!(s.as_str(), "hls-1.10");
421 }
422
423 #[test]
424 fn segment_from_source_unquoted_passthrough() {
425 let s = Segment::from_source("nixpkgs").unwrap();
426 assert_eq!(s.as_str(), "nixpkgs");
427 }
428
429 #[test]
430 fn segment_from_syntax_via_rnix() {
431 let src = r#"{ inputs."hls-1.10".url = "x"; }"#;
434 let parsed = rnix::Root::parse(src);
435 let syntax = parsed.syntax();
436 fn find_string(node: rnix::SyntaxNode) -> Option<rnix::SyntaxNode> {
437 if node.kind() == rnix::SyntaxKind::NODE_STRING {
438 return Some(node);
439 }
440 for c in node.children() {
441 if let Some(s) = find_string(c) {
442 return Some(s);
443 }
444 }
445 None
446 }
447 let string_node = find_string(syntax).expect("has a string node");
448 let seg = Segment::from_syntax(&string_node).unwrap();
449 assert_eq!(seg.as_str(), "hls-1.10");
451 }
452
453 #[test]
454 fn segment_needs_quoting_boundaries() {
455 for bare in ["nixpkgs", "_x", "foo'bar"] {
456 assert!(
457 !Segment::from_unquoted(bare).unwrap().needs_quoting(),
458 "{bare} should be a bare ident",
459 );
460 }
461 for quoted in ["hls-1.10", "24.11", "-x"] {
462 assert!(
463 Segment::from_unquoted(quoted).unwrap().needs_quoting(),
464 "{quoted} should require quoting",
465 );
466 }
467 }
468
469 #[test]
470 fn segment_render_unquoted() {
471 let s = Segment::from_unquoted("nixpkgs").unwrap();
472 assert_eq!(s.render(), "nixpkgs");
473 }
474
475 #[test]
476 fn segment_render_quoted() {
477 let s = Segment::from_unquoted("hls-1.10").unwrap();
478 assert_eq!(s.render(), "\"hls-1.10\"");
479 }
480
481 #[test]
482 fn segment_display_matches_render() {
483 let s = Segment::from_unquoted("hls-1.10").unwrap();
484 assert_eq!(format!("{s}"), s.render());
485 }
486
487 #[test]
488 fn segment_from_str_uses_from_source() {
489 let s: Segment = "\"hls-1.10\"".parse().unwrap();
490 assert_eq!(s.as_str(), "hls-1.10");
491 }
492
493 #[test]
494 fn segment_serde_roundtrip_bare() {
495 let s = Segment::from_unquoted("nixpkgs").unwrap();
496 let j = serde_json::to_string(&s).unwrap();
497 assert_eq!(j, "\"nixpkgs\"");
498 let back: Segment = serde_json::from_str(&j).unwrap();
499 assert_eq!(s, back);
500 }
501
502 #[test]
503 fn segment_serde_roundtrip_dotted() {
504 let s = Segment::from_unquoted("hls-1.10").unwrap();
505 let j = serde_json::to_string(&s).unwrap();
506 assert_eq!(j, "\"hls-1.10\"");
508 let back: Segment = serde_json::from_str(&j).unwrap();
509 assert_eq!(s, back);
510 }
511
512 #[test]
513 fn attr_path_parse_single_segment() {
514 let p = AttrPath::parse("nixpkgs").unwrap();
515 assert_eq!(p.len(), 1);
516 assert_eq!(p.first().as_str(), "nixpkgs");
517 }
518
519 #[test]
520 fn attr_path_parse_two_segments() {
521 let p = AttrPath::parse("crane.nixpkgs").unwrap();
522 assert_eq!(p.len(), 2);
523 assert_eq!(p.first().as_str(), "crane");
524 assert_eq!(p.last().as_str(), "nixpkgs");
525 }
526
527 #[test]
528 fn attr_path_parse_quoted_first() {
529 let p = AttrPath::parse("\"hls-1.10\".nixpkgs").unwrap();
530 assert_eq!(p.len(), 2);
531 assert_eq!(p.first().as_str(), "hls-1.10");
532 assert_eq!(p.last().as_str(), "nixpkgs");
533 }
534
535 #[test]
536 fn attr_path_parse_three_segments_middle_quoted() {
537 let p = AttrPath::parse("a.\"b.c\".d").unwrap();
538 assert_eq!(p.len(), 3);
539 assert_eq!(p.segments()[0].as_str(), "a");
540 assert_eq!(p.segments()[1].as_str(), "b.c");
541 assert_eq!(p.segments()[2].as_str(), "d");
542 }
543
544 #[test]
545 fn attr_path_parse_empty_rejected() {
546 assert_eq!(AttrPath::parse(""), Err(AttrPathParseError::Empty));
547 }
548
549 #[test]
550 fn attr_path_parse_double_dot_rejected() {
551 assert_eq!(
552 AttrPath::parse("a..b"),
553 Err(AttrPathParseError::EmptySegment)
554 );
555 }
556
557 #[test]
558 fn attr_path_display_roundtrip() {
559 for s in ["crane.nixpkgs", "\"hls-1.10\".nixpkgs"] {
560 let p = AttrPath::parse(s).unwrap();
561 assert_eq!(format!("{p}"), s);
562 }
563 }
564
565 #[test]
566 fn attr_path_parent_none_for_single() {
567 let p = AttrPath::parse("nixpkgs").unwrap();
568 assert!(p.parent().is_none());
569 }
570
571 #[test]
572 fn attr_path_parent_some_for_two() {
573 let p = AttrPath::parse("crane.nixpkgs").unwrap();
574 let parent = p.parent().unwrap();
575 assert_eq!(parent.len(), 1);
576 assert_eq!(parent.first().as_str(), "crane");
577 }
578
579 #[test]
580 fn attr_path_child_returns_second_segment() {
581 let p = AttrPath::parse("crane.nixpkgs").unwrap();
582 assert_eq!(p.child().unwrap().as_str(), "nixpkgs");
583 }
584
585 #[test]
586 fn attr_path_child_none_for_single() {
587 let p = AttrPath::parse("crane").unwrap();
588 assert!(p.child().is_none());
589 }
590
591 #[test]
592 fn attr_path_push_extends() {
593 let mut p = AttrPath::parse("a").unwrap();
594 p.push(Segment::from_unquoted("b").unwrap());
595 assert_eq!(format!("{p}"), "a.b");
596 }
597
598 #[test]
599 fn attr_path_is_prefix_self() {
600 let p = AttrPath::parse("a.b").unwrap();
601 assert!(p.is_prefix_of(&p));
602 }
603
604 #[test]
605 fn attr_path_is_prefix_strict() {
606 let a = AttrPath::parse("a").unwrap();
607 let ab = AttrPath::parse("a.b").unwrap();
608 assert!(a.is_prefix_of(&ab));
609 assert!(!ab.is_prefix_of(&a));
610 }
611
612 #[test]
613 fn attr_path_is_prefix_diverging() {
614 let a = AttrPath::parse("a.x").unwrap();
615 let b = AttrPath::parse("a.y").unwrap();
616 assert!(!a.is_prefix_of(&b));
617 }
618
619 #[test]
620 fn attr_path_from_segment() {
621 let s = Segment::from_unquoted("nixpkgs").unwrap();
622 let p: AttrPath = s.clone().into();
623 assert_eq!(p.len(), 1);
624 assert_eq!(p.first(), &s);
625 }
626
627 #[test]
628 fn attr_path_from_str_parses() {
629 let p: AttrPath = "crane.nixpkgs".parse().unwrap();
630 assert_eq!(p.len(), 2);
631 }
632
633 #[test]
634 fn attr_path_serde_roundtrip() {
635 let p = AttrPath::parse("\"hls-1.10\".nixpkgs").unwrap();
636 let j = serde_json::to_string(&p).unwrap();
637 assert_eq!(j, "\"\\\"hls-1.10\\\".nixpkgs\"");
639 let back: AttrPath = serde_json::from_str(&j).unwrap();
640 assert_eq!(p, back);
641 }
642
643 #[test]
644 fn attr_path_to_flake_follows_string_simple() {
645 let p = AttrPath::parse("nixpkgs").unwrap();
646 assert_eq!(p.to_flake_follows_string(), "nixpkgs");
647 }
648
649 #[test]
650 fn attr_path_to_flake_follows_string_two_segments() {
651 let p = AttrPath::parse("crane.nixpkgs").unwrap();
652 assert_eq!(p.to_flake_follows_string(), "crane/nixpkgs");
653 }
654
655 #[test]
656 fn attr_path_to_flake_follows_string_dotted_segment_preserved() {
657 let p = AttrPath::parse("\"hls-1.10\".nixpkgs").unwrap();
658 assert_eq!(p.to_flake_follows_string(), "hls-1.10/nixpkgs");
660 }
661
662 #[test]
663 fn parse_follows_target_accepts_slash_form() {
664 let fallback = Segment::from_unquoted("fallback").unwrap();
665 let parsed = AttrPath::parse_follows_target("hyprland/hyprlang", &fallback)
666 .expect("non-empty input must parse to Some");
667 assert_eq!(parsed.len(), 2);
668 assert_eq!(parsed.first().as_str(), "hyprland");
669 assert_eq!(parsed.last().as_str(), "hyprlang");
670 }
671
672 #[test]
673 fn parse_follows_target_dot_inside_segment_is_not_a_separator() {
674 let fallback = Segment::from_unquoted("fallback").unwrap();
675
676 let single = AttrPath::parse_follows_target("hls-1.10", &fallback).unwrap();
677 assert_eq!(single.len(), 1);
678 assert_eq!(single.first().as_str(), "hls-1.10");
679
680 let two = AttrPath::parse_follows_target("hls-1.10/nixpkgs", &fallback).unwrap();
681 assert_eq!(two.len(), 2);
682 assert_eq!(two.first().as_str(), "hls-1.10");
683 assert_eq!(two.last().as_str(), "nixpkgs");
684 }
685
686 #[test]
687 fn segment_from_syntax_or_sentinel_falls_back_on_empty_string() {
688 use rnix::SyntaxKind;
689
690 let src = r#"{ inputs."" = {}; }"#;
691 let parsed = rnix::Root::parse(src);
692 fn find_first_string(node: rnix::SyntaxNode) -> Option<rnix::SyntaxNode> {
693 if node.kind() == SyntaxKind::NODE_STRING {
694 return Some(node);
695 }
696 for c in node.children() {
697 if let Some(s) = find_first_string(c) {
698 return Some(s);
699 }
700 }
701 None
702 }
703 let empty_string = find_first_string(parsed.syntax()).expect("CST has an empty string");
704 let seg = Segment::from_syntax_or_sentinel(&empty_string);
705 assert_eq!(seg.as_str(), super::INVALID_SEGMENT_SENTINEL);
706 }
707
708 #[test]
709 fn follows_idents_prefixed_interleaves_inputs() {
710 let p = AttrPath::parse("crane.nixpkgs").unwrap();
711 assert_eq!(
712 follows_idents_prefixed(p.segments()),
713 vec!["inputs", "crane", "inputs", "nixpkgs", "follows"],
714 );
715 }
716
717 #[test]
718 fn follows_idents_bare_omits_leading_inputs() {
719 let p = AttrPath::parse("crane.nixpkgs").unwrap();
720 assert_eq!(
721 follows_idents_bare(p.segments()),
722 vec!["crane", "inputs", "nixpkgs", "follows"],
723 );
724 }
725}