Skip to main content

flake_edit/follows/
path.rs

1//! [`Segment`] and [`AttrPath`]: typed attribute paths.
2//!
3//! [`Segment`] is a single attribute name. [`AttrPath`] is a non-empty
4//! sequence of them. Both store values unquoted. The `"..."` quotes Nix
5//! requires for names containing dots or leading digits live on the rendering
6//! boundary ([`fmt::Display`] / [`Segment::render`]).
7
8use std::fmt;
9use std::str::FromStr;
10
11use serde::{Deserialize, Deserializer, Serialize, Serializer};
12use smallvec::{SmallVec, smallvec};
13
14/// Strip a single outer pair of `"..."` from CST source text.
15///
16/// Returns the input unchanged when it is not bracketed by quotes.
17pub fn strip_outer_quotes(s: &str) -> &str {
18    s.strip_prefix('"')
19        .and_then(|s| s.strip_suffix('"'))
20        .unwrap_or(s)
21}
22
23/// Sentinel segment used when CST text cannot form a valid [`Segment`].
24pub(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    /// Construct from already-unquoted text.
41    ///
42    /// Rejects empty input, embedded `"`, and ASCII control characters.
43    /// Everything else (`.`, `+`, `/`, leading digits, hyphens, single quotes)
44    /// is accepted. [`Self::render`] decides whether to wrap in `"..."`.
45    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    /// Parse source-form text. Strips a single surrounding pair of `"..."`,
60    /// otherwise behaves like [`Self::from_unquoted`].
61    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    /// Build a [`Segment`] from a CST node's source text.
71    pub fn from_syntax(node: &rnix::SyntaxNode) -> Result<Self, SegmentError> {
72        Segment::from_source(&node.to_string())
73    }
74
75    /// Infallible [`Self::from_syntax`]: substitutes [`INVALID_SEGMENT_SENTINEL`]
76    /// when the node text would be rejected by [`Self::from_unquoted`], emitting
77    /// `tracing::warn!` on the fall-through.
78    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    /// Consume the segment, returning the unquoted text.
95    pub fn into_string(self) -> String {
96        self.0
97    }
98
99    /// Whether this segment requires source-level `"..."` quoting.
100    ///
101    /// Bare Nix identifiers match `[a-zA-Z_][a-zA-Z0-9_'-]*`. Anything else
102    /// (leading digit, embedded `.`, leading `-`) needs quoting.
103    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    /// Render to source form, wrapping in `"..."` only when needed.
120    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    /// Parse a dotted path, respecting `"..."` quoting on individual segments.
175    ///
176    /// Examples:
177    /// - `nixpkgs` → 1 segment.
178    /// - `crane.nixpkgs` → 2 segments.
179    /// - `"hls-1.10".nixpkgs` → 2 segments, the first stored unquoted.
180    /// - `a."b.c".d` → 3 segments.
181    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                // Skip until matching closing quote.
192                i += 1;
193                while i < bytes.len() && bytes[i] != b'"' {
194                    i += 1;
195                }
196                if i < bytes.len() {
197                    i += 1; // skip closing quote
198                }
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    /// All segments except the last, or `None` for a length-1 path.
237    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    /// The second segment, or `None` for length-1 paths.
247    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    /// Whether `self` is a structural prefix of `other`. A path is its own
260    /// prefix.
261    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    /// Parse the right-hand side of a `follows = "..."` binding into a typed
269    /// target.
270    ///
271    /// Empty input produces `None`. Non-empty input is split on `/`, the only
272    /// separator Nix recognises in a follows target ,  a `.` inside a segment
273    /// is part of the identifier (`"hls-1.10/nixpkgs"` is two segments, not
274    /// three). Each segment passes through [`Segment::from_unquoted`]; if the
275    /// body is malformed the result falls back to a single-segment path
276    /// built from `fallback` so the caller never loses an entry.
277    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    /// Render for the RHS of `follows = "..."`. `Display` emits the
300    /// LHS attribute-path form and injects per-segment quoting that
301    /// is invalid in this string-value position.
302    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
311/// Raw dot-joined rendering of `segments` (`hls-1.10.nixpkgs`).
312pub(crate) fn join_raw(segments: &[Segment]) -> String {
313    segments
314        .iter()
315        .map(|s| s.as_str())
316        .collect::<Vec<_>>()
317        .join(".")
318}
319
320/// Idents for the `inputs.<S0>.inputs.<S1>...inputs.<SN>.follows` attrpath shape.
321pub(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
331/// Idents for the `<S0>.inputs.<S1>...inputs.<SN>.follows` attrpath shape, with
332/// no leading `inputs.` qualifier.
333pub(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        // Build a tiny CST and route the first NODE_STRING through
432        // `from_syntax` to verify the round-trip.
433        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        // The first NODE_STRING in source order is "hls-1.10".
450        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        // Wire form has no embedded backslash-quote.
507        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        // Wire form is the canonical Display output (quoted as needed).
638        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        // Dot inside a segment must NOT become a slash.
659        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}