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 follows_idents_prefixed(segments: &[Segment]) -> Vec<&str> {
313 let mut out: Vec<&str> = Vec::with_capacity(segments.len() * 2 + 1);
314 for seg in segments {
315 out.push("inputs");
316 out.push(seg.as_str());
317 }
318 out.push("follows");
319 out
320}
321
322pub(crate) fn follows_idents_bare(segments: &[Segment]) -> Vec<&str> {
325 let mut out: Vec<&str> = Vec::with_capacity(segments.len() * 2);
326 for (i, seg) in segments.iter().enumerate() {
327 if i > 0 {
328 out.push("inputs");
329 }
330 out.push(seg.as_str());
331 }
332 out.push("follows");
333 out
334}
335
336impl fmt::Display for AttrPath {
337 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
338 let mut first = true;
339 for seg in &self.0 {
340 if !first {
341 f.write_str(".")?;
342 }
343 first = false;
344 f.write_str(&seg.render())?;
345 }
346 Ok(())
347 }
348}
349
350impl FromStr for AttrPath {
351 type Err = AttrPathParseError;
352
353 fn from_str(s: &str) -> Result<Self, Self::Err> {
354 AttrPath::parse(s)
355 }
356}
357
358impl From<Segment> for AttrPath {
359 fn from(value: Segment) -> Self {
360 AttrPath::new(value)
361 }
362}
363
364impl Serialize for AttrPath {
365 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
366 serializer.collect_str(self)
367 }
368}
369
370impl<'de> Deserialize<'de> for AttrPath {
371 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
372 let s = String::deserialize(deserializer)?;
373 AttrPath::parse(&s).map_err(serde::de::Error::custom)
374 }
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380
381 #[test]
382 fn segment_from_unquoted_rejects_empty() {
383 assert_eq!(Segment::from_unquoted(""), Err(SegmentError::Empty));
384 }
385
386 #[test]
387 fn segment_from_unquoted_rejects_embedded_quote() {
388 assert_eq!(
389 Segment::from_unquoted("a\"b"),
390 Err(SegmentError::ContainsQuote)
391 );
392 }
393
394 #[test]
395 fn segment_from_unquoted_rejects_control() {
396 assert_eq!(
397 Segment::from_unquoted("a\nb"),
398 Err(SegmentError::ContainsControl)
399 );
400 }
401
402 #[test]
403 fn segment_from_unquoted_accepts_dotted() {
404 let s = Segment::from_unquoted("hls-1.10").unwrap();
405 assert_eq!(s.as_str(), "hls-1.10");
406 }
407
408 #[test]
409 fn segment_from_source_strips_quotes() {
410 let s = Segment::from_source("\"hls-1.10\"").unwrap();
411 assert_eq!(s.as_str(), "hls-1.10");
412 }
413
414 #[test]
415 fn segment_from_source_unquoted_passthrough() {
416 let s = Segment::from_source("nixpkgs").unwrap();
417 assert_eq!(s.as_str(), "nixpkgs");
418 }
419
420 #[test]
421 fn segment_from_syntax_via_rnix() {
422 let src = r#"{ inputs."hls-1.10".url = "x"; }"#;
425 let parsed = rnix::Root::parse(src);
426 let syntax = parsed.syntax();
427 fn find_string(node: rnix::SyntaxNode) -> Option<rnix::SyntaxNode> {
428 if node.kind() == rnix::SyntaxKind::NODE_STRING {
429 return Some(node);
430 }
431 for c in node.children() {
432 if let Some(s) = find_string(c) {
433 return Some(s);
434 }
435 }
436 None
437 }
438 let string_node = find_string(syntax).expect("has a string node");
439 let seg = Segment::from_syntax(&string_node).unwrap();
440 assert_eq!(seg.as_str(), "hls-1.10");
442 }
443
444 #[test]
445 fn segment_needs_quoting_boundaries() {
446 for bare in ["nixpkgs", "_x", "foo'bar"] {
447 assert!(
448 !Segment::from_unquoted(bare).unwrap().needs_quoting(),
449 "{bare} should be a bare ident",
450 );
451 }
452 for quoted in ["hls-1.10", "24.11", "-x"] {
453 assert!(
454 Segment::from_unquoted(quoted).unwrap().needs_quoting(),
455 "{quoted} should require quoting",
456 );
457 }
458 }
459
460 #[test]
461 fn segment_render_unquoted() {
462 let s = Segment::from_unquoted("nixpkgs").unwrap();
463 assert_eq!(s.render(), "nixpkgs");
464 }
465
466 #[test]
467 fn segment_render_quoted() {
468 let s = Segment::from_unquoted("hls-1.10").unwrap();
469 assert_eq!(s.render(), "\"hls-1.10\"");
470 }
471
472 #[test]
473 fn segment_display_matches_render() {
474 let s = Segment::from_unquoted("hls-1.10").unwrap();
475 assert_eq!(format!("{s}"), s.render());
476 }
477
478 #[test]
479 fn segment_from_str_uses_from_source() {
480 let s: Segment = "\"hls-1.10\"".parse().unwrap();
481 assert_eq!(s.as_str(), "hls-1.10");
482 }
483
484 #[test]
485 fn segment_serde_roundtrip_bare() {
486 let s = Segment::from_unquoted("nixpkgs").unwrap();
487 let j = serde_json::to_string(&s).unwrap();
488 assert_eq!(j, "\"nixpkgs\"");
489 let back: Segment = serde_json::from_str(&j).unwrap();
490 assert_eq!(s, back);
491 }
492
493 #[test]
494 fn segment_serde_roundtrip_dotted() {
495 let s = Segment::from_unquoted("hls-1.10").unwrap();
496 let j = serde_json::to_string(&s).unwrap();
497 assert_eq!(j, "\"hls-1.10\"");
499 let back: Segment = serde_json::from_str(&j).unwrap();
500 assert_eq!(s, back);
501 }
502
503 #[test]
504 fn attr_path_parse_single_segment() {
505 let p = AttrPath::parse("nixpkgs").unwrap();
506 assert_eq!(p.len(), 1);
507 assert_eq!(p.first().as_str(), "nixpkgs");
508 }
509
510 #[test]
511 fn attr_path_parse_two_segments() {
512 let p = AttrPath::parse("crane.nixpkgs").unwrap();
513 assert_eq!(p.len(), 2);
514 assert_eq!(p.first().as_str(), "crane");
515 assert_eq!(p.last().as_str(), "nixpkgs");
516 }
517
518 #[test]
519 fn attr_path_parse_quoted_first() {
520 let p = AttrPath::parse("\"hls-1.10\".nixpkgs").unwrap();
521 assert_eq!(p.len(), 2);
522 assert_eq!(p.first().as_str(), "hls-1.10");
523 assert_eq!(p.last().as_str(), "nixpkgs");
524 }
525
526 #[test]
527 fn attr_path_parse_three_segments_middle_quoted() {
528 let p = AttrPath::parse("a.\"b.c\".d").unwrap();
529 assert_eq!(p.len(), 3);
530 assert_eq!(p.segments()[0].as_str(), "a");
531 assert_eq!(p.segments()[1].as_str(), "b.c");
532 assert_eq!(p.segments()[2].as_str(), "d");
533 }
534
535 #[test]
536 fn attr_path_parse_empty_rejected() {
537 assert_eq!(AttrPath::parse(""), Err(AttrPathParseError::Empty));
538 }
539
540 #[test]
541 fn attr_path_parse_double_dot_rejected() {
542 assert_eq!(
543 AttrPath::parse("a..b"),
544 Err(AttrPathParseError::EmptySegment)
545 );
546 }
547
548 #[test]
549 fn attr_path_display_roundtrip() {
550 for s in ["crane.nixpkgs", "\"hls-1.10\".nixpkgs"] {
551 let p = AttrPath::parse(s).unwrap();
552 assert_eq!(format!("{p}"), s);
553 }
554 }
555
556 #[test]
557 fn attr_path_parent_none_for_single() {
558 let p = AttrPath::parse("nixpkgs").unwrap();
559 assert!(p.parent().is_none());
560 }
561
562 #[test]
563 fn attr_path_parent_some_for_two() {
564 let p = AttrPath::parse("crane.nixpkgs").unwrap();
565 let parent = p.parent().unwrap();
566 assert_eq!(parent.len(), 1);
567 assert_eq!(parent.first().as_str(), "crane");
568 }
569
570 #[test]
571 fn attr_path_child_returns_second_segment() {
572 let p = AttrPath::parse("crane.nixpkgs").unwrap();
573 assert_eq!(p.child().unwrap().as_str(), "nixpkgs");
574 }
575
576 #[test]
577 fn attr_path_child_none_for_single() {
578 let p = AttrPath::parse("crane").unwrap();
579 assert!(p.child().is_none());
580 }
581
582 #[test]
583 fn attr_path_push_extends() {
584 let mut p = AttrPath::parse("a").unwrap();
585 p.push(Segment::from_unquoted("b").unwrap());
586 assert_eq!(format!("{p}"), "a.b");
587 }
588
589 #[test]
590 fn attr_path_is_prefix_self() {
591 let p = AttrPath::parse("a.b").unwrap();
592 assert!(p.is_prefix_of(&p));
593 }
594
595 #[test]
596 fn attr_path_is_prefix_strict() {
597 let a = AttrPath::parse("a").unwrap();
598 let ab = AttrPath::parse("a.b").unwrap();
599 assert!(a.is_prefix_of(&ab));
600 assert!(!ab.is_prefix_of(&a));
601 }
602
603 #[test]
604 fn attr_path_is_prefix_diverging() {
605 let a = AttrPath::parse("a.x").unwrap();
606 let b = AttrPath::parse("a.y").unwrap();
607 assert!(!a.is_prefix_of(&b));
608 }
609
610 #[test]
611 fn attr_path_from_segment() {
612 let s = Segment::from_unquoted("nixpkgs").unwrap();
613 let p: AttrPath = s.clone().into();
614 assert_eq!(p.len(), 1);
615 assert_eq!(p.first(), &s);
616 }
617
618 #[test]
619 fn attr_path_from_str_parses() {
620 let p: AttrPath = "crane.nixpkgs".parse().unwrap();
621 assert_eq!(p.len(), 2);
622 }
623
624 #[test]
625 fn attr_path_serde_roundtrip() {
626 let p = AttrPath::parse("\"hls-1.10\".nixpkgs").unwrap();
627 let j = serde_json::to_string(&p).unwrap();
628 assert_eq!(j, "\"\\\"hls-1.10\\\".nixpkgs\"");
630 let back: AttrPath = serde_json::from_str(&j).unwrap();
631 assert_eq!(p, back);
632 }
633
634 #[test]
635 fn attr_path_to_flake_follows_string_simple() {
636 let p = AttrPath::parse("nixpkgs").unwrap();
637 assert_eq!(p.to_flake_follows_string(), "nixpkgs");
638 }
639
640 #[test]
641 fn attr_path_to_flake_follows_string_two_segments() {
642 let p = AttrPath::parse("crane.nixpkgs").unwrap();
643 assert_eq!(p.to_flake_follows_string(), "crane/nixpkgs");
644 }
645
646 #[test]
647 fn attr_path_to_flake_follows_string_dotted_segment_preserved() {
648 let p = AttrPath::parse("\"hls-1.10\".nixpkgs").unwrap();
649 assert_eq!(p.to_flake_follows_string(), "hls-1.10/nixpkgs");
651 }
652
653 #[test]
654 fn parse_follows_target_accepts_slash_form() {
655 let fallback = Segment::from_unquoted("fallback").unwrap();
656 let parsed = AttrPath::parse_follows_target("hyprland/hyprlang", &fallback)
657 .expect("non-empty input must parse to Some");
658 assert_eq!(parsed.len(), 2);
659 assert_eq!(parsed.first().as_str(), "hyprland");
660 assert_eq!(parsed.last().as_str(), "hyprlang");
661 }
662
663 #[test]
664 fn parse_follows_target_dot_inside_segment_is_not_a_separator() {
665 let fallback = Segment::from_unquoted("fallback").unwrap();
666
667 let single = AttrPath::parse_follows_target("hls-1.10", &fallback).unwrap();
668 assert_eq!(single.len(), 1);
669 assert_eq!(single.first().as_str(), "hls-1.10");
670
671 let two = AttrPath::parse_follows_target("hls-1.10/nixpkgs", &fallback).unwrap();
672 assert_eq!(two.len(), 2);
673 assert_eq!(two.first().as_str(), "hls-1.10");
674 assert_eq!(two.last().as_str(), "nixpkgs");
675 }
676
677 #[test]
678 fn segment_from_syntax_or_sentinel_falls_back_on_empty_string() {
679 use rnix::SyntaxKind;
680
681 let src = r#"{ inputs."" = {}; }"#;
682 let parsed = rnix::Root::parse(src);
683 fn find_first_string(node: rnix::SyntaxNode) -> Option<rnix::SyntaxNode> {
684 if node.kind() == SyntaxKind::NODE_STRING {
685 return Some(node);
686 }
687 for c in node.children() {
688 if let Some(s) = find_first_string(c) {
689 return Some(s);
690 }
691 }
692 None
693 }
694 let empty_string = find_first_string(parsed.syntax()).expect("CST has an empty string");
695 let seg = Segment::from_syntax_or_sentinel(&empty_string);
696 assert_eq!(seg.as_str(), super::INVALID_SEGMENT_SENTINEL);
697 }
698
699 #[test]
700 fn follows_idents_prefixed_interleaves_inputs() {
701 let p = AttrPath::parse("crane.nixpkgs").unwrap();
702 assert_eq!(
703 follows_idents_prefixed(p.segments()),
704 vec!["inputs", "crane", "inputs", "nixpkgs", "follows"],
705 );
706 }
707
708 #[test]
709 fn follows_idents_bare_omits_leading_inputs() {
710 let p = AttrPath::parse("crane.nixpkgs").unwrap();
711 assert_eq!(
712 follows_idents_bare(p.segments()),
713 vec!["crane", "inputs", "nixpkgs", "follows"],
714 );
715 }
716}