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/// Idents for the `inputs.<S0>.inputs.<S1>...inputs.<SN>.follows` attrpath shape.
312pub(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
322/// Idents for the `<S0>.inputs.<S1>...inputs.<SN>.follows` attrpath shape, with
323/// no leading `inputs.` qualifier.
324pub(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        // Build a tiny CST and route the first NODE_STRING through
423        // `from_syntax` to verify the round-trip.
424        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        // The first NODE_STRING in source order is "hls-1.10".
441        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        // Wire form has no embedded backslash-quote.
498        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        // Wire form is the canonical Display output (quoted as needed).
629        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        // Dot inside a segment must NOT become a slash.
650        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}