Skip to main content

devscout_rs/semantic/
layer.rs

1// The resolve-time consumption path's own artifact reader and compatibility
2// join. Reads the ADMITTED artifact's raw bytes directly (`bytes` off
3// `graph::compiler_facts::read_compiler_facts`, never re-parsing oracle
4// output and never a second admission path) into the occurrence-site facts
5// the resolver needs, keeping every field this crate has no other use for
6// (candidates, diagnostics, symbols) opaque and unread.
7//
8// Two passes, deliberately kept apart: `parse_raw` (no `DefIndex`, pure byte
9// parsing, callable before the resolver builds one) and `SemanticLayer::build`
10// (translates each occurrence's own Roslyn-encoded identity into a
11// `resolve::DefIndex` def id, using the index the resolver already built for
12// this same run). The admitted artifact's `symbols[]`/`occurrences[].target`
13// identity encoding is Roslyn's dotted display format with an inline
14// generic-argument suffix at whichever segment declares it (`Ns.Outer.Nested<T>`),
15// not devscout's own `+`-nested, arity-free def-id scheme
16// (`Ns.Outer+Nested`); `translate_type_id` performs that translation, the
17// same shortest-head-first walk `resolve::ladder`'s own nested-type step
18// already performs for extractor-emitted references, reimplemented narrowly
19// here rather than crossing a `pub(crate)` seam into a sibling module for a
20// handful of lines.
21
22use std::collections::HashMap;
23use std::path::Path;
24
25use serde_json::Value;
26
27use crate::graph::read_compiler_facts;
28use crate::resolve::DefIndex;
29
30use super::freshness::{self, Freshness};
31use super::uncertainty::{self, Uncertainty};
32
33/// One occurrence site, exactly as the admitted artifact wrote it -- every
34/// field this module reads, and no others. `target_type`/`target_member` are
35/// `None` for a site with no confirmed or candidate symbol at all
36/// (`resolution == "unresolved"`).
37///
38/// `(file, name_line, target_member)` is the compatibility join key against
39/// today's graph (`SemanticLayer::by_site`) -- it is never the fact's own
40/// identity. The fields below it are that exact identity, carried alongside
41/// the key rather than folded into it: `target_assembly`/
42/// `target_generic_arity`/`target_overload_signature` are the target's
43/// complete identity in the producer's own encoding (`occurrences.
44/// identityEncoding`); `span_*` is the occurrence's own span under
45/// `occurrences.spanEncoding`'s stated convention; `document_content_identity`/
46/// `target_document_content_identities` are the caller's and target's own
47/// document content identities. `target_overload_signature` doubles as
48/// `lookup`'s own overload disambiguator (`overload_arg_count`): two
49/// same-line occurrences of one member with different signatures are kept
50/// as distinct facts instead of collapsing into one ambiguous outcome the
51/// way the bare `(file, line, member)` key alone would.
52#[derive(Debug, Clone)]
53struct RawOccurrence {
54    file: String,
55    name_line: usize,
56    resolution: String,
57    target_type: Option<String>,
58    target_member: Option<String>,
59    compilation_identity: Value,
60    #[allow(
61        dead_code,
62        reason = "the exact identity this consumer's own model carries alongside the compatibility key, parsed for a future consumer (an exact-identity export, a disagreement diagnostic naming the exact overload) -- target_overload_signature is the one field lookup reads today, for its own arity narrowing and target identity"
63    )]
64    target_assembly: Option<String>,
65    #[allow(dead_code, reason = "see target_assembly's own allow above")]
66    target_generic_arity: Option<u64>,
67    target_overload_signature: Option<String>,
68    #[allow(dead_code, reason = "see target_assembly's own allow above")]
69    span_start_line: Option<u64>,
70    #[allow(dead_code, reason = "see target_assembly's own allow above")]
71    span_start_char: Option<u64>,
72    #[allow(dead_code, reason = "see target_assembly's own allow above")]
73    span_end_line: Option<u64>,
74    #[allow(dead_code, reason = "see target_assembly's own allow above")]
75    span_end_char: Option<u64>,
76    #[allow(dead_code, reason = "see target_assembly's own allow above")]
77    document_content_identity: Option<String>,
78    #[allow(dead_code, reason = "see target_assembly's own allow above")]
79    target_document_content_identities: Vec<String>,
80}
81
82/// One embedded envelope compilation's own state, keyed by its `identity`
83/// for a same-value match against an occurrence's own `compilation.identity`
84/// -- the same match the admission path's own consistency check performs.
85/// `imports` is this compilation's own external-file list (`freshness::
86/// ImportRef`), flattened across every compilation by `parse_raw` into the
87/// whole-artifact list `SemanticLayer::load` passes to `freshness::evaluate`.
88#[derive(Debug, Clone)]
89struct RawCompilation {
90    identity: Value,
91    state: String,
92    reason: String,
93    imports: Vec<freshness::ImportRef>,
94}
95
96fn parse_imports(entry: &serde_json::Map<String, Value>) -> Vec<freshness::ImportRef> {
97    let Some(raw) = entry.get("imports").and_then(Value::as_array) else {
98        return Vec::new();
99    };
100    raw.iter()
101        .filter_map(|item| {
102            let obj = as_obj(item)?;
103            Some(freshness::ImportRef {
104                identity: obj.get("identity").and_then(Value::as_str)?.to_string(),
105                hash: obj.get("hash").and_then(Value::as_str)?.to_string(),
106            })
107        })
108        .collect()
109}
110
111/// The admitted artifact's bytes, parsed into exactly what this consumer
112/// needs and nothing else. Building one performs no file I/O of its own --
113/// see `load`, the one place this module reads a file.
114struct RawFacts {
115    source_head_sha: Option<String>,
116    source_dirty: bool,
117    compilations: Vec<RawCompilation>,
118    occurrences: Vec<RawOccurrence>,
119}
120
121fn as_obj(v: &Value) -> Option<&serde_json::Map<String, Value>> {
122    v.as_object()
123}
124
125fn parse_compilations(root: &Value) -> Vec<RawCompilation> {
126    let Some(entries) = root
127        .get("context")
128        .and_then(as_obj)
129        .and_then(|c| c.get("envelope"))
130        .and_then(as_obj)
131        .and_then(|e| e.get("compilations"))
132        .and_then(Value::as_array)
133    else {
134        return Vec::new();
135    };
136    entries
137        .iter()
138        .filter_map(|entry| {
139            let obj = as_obj(entry)?;
140            Some(RawCompilation {
141                identity: obj.get("identity")?.clone(),
142                state: obj.get("state").and_then(Value::as_str)?.to_string(),
143                reason: obj
144                    .get("reason")
145                    .and_then(Value::as_str)
146                    .unwrap_or("")
147                    .to_string(),
148                imports: parse_imports(obj),
149            })
150        })
151        .collect()
152}
153
154fn parse_occurrences(root: &Value) -> Vec<RawOccurrence> {
155    let Some(sites) = root
156        .get("occurrences")
157        .and_then(as_obj)
158        .and_then(|o| o.get("sites"))
159        .and_then(Value::as_array)
160    else {
161        return Vec::new();
162    };
163    sites
164        .iter()
165        .filter_map(|site| {
166            let obj = as_obj(site)?;
167            let file = obj.get("file").and_then(Value::as_str)?.to_string();
168            let name_line = obj
169                .get("name")
170                .and_then(as_obj)
171                .and_then(|n| n.get("line"))
172                .and_then(Value::as_u64)? as usize;
173            let resolution = obj.get("resolution").and_then(Value::as_str)?.to_string();
174            let target = obj.get("target").and_then(as_obj);
175            let target_type = target
176                .and_then(|t| t.get("type"))
177                .and_then(Value::as_str)
178                .map(str::to_string);
179            let target_member = target
180                .and_then(|t| t.get("member"))
181                .and_then(Value::as_str)
182                .map(str::to_string);
183            let compilation_identity = obj
184                .get("compilation")
185                .and_then(as_obj)
186                .and_then(|c| c.get("identity"))
187                .cloned()
188                .unwrap_or(Value::Null);
189            let target_assembly = target
190                .and_then(|t| t.get("assembly"))
191                .and_then(Value::as_str)
192                .map(str::to_string);
193            let target_generic_arity = target
194                .and_then(|t| t.get("genericArity"))
195                .and_then(Value::as_u64);
196            let target_overload_signature = target
197                .and_then(|t| t.get("overloadSignature"))
198                .and_then(Value::as_str)
199                .map(str::to_string);
200            let span = obj.get("span").and_then(as_obj);
201            let span_start_line = span
202                .and_then(|s| s.get("startLine"))
203                .and_then(Value::as_u64);
204            let span_start_char = span
205                .and_then(|s| s.get("startChar"))
206                .and_then(Value::as_u64);
207            let span_end_line = span.and_then(|s| s.get("endLine")).and_then(Value::as_u64);
208            let span_end_char = span.and_then(|s| s.get("endChar")).and_then(Value::as_u64);
209            let document_content_identity = obj
210                .get("documentContentIdentity")
211                .and_then(Value::as_str)
212                .map(str::to_string);
213            let target_document_content_identities = obj
214                .get("targetDocumentContentIdentities")
215                .and_then(Value::as_array)
216                .map(|a| {
217                    a.iter()
218                        .filter_map(|v| v.as_str().map(str::to_string))
219                        .collect()
220                })
221                .unwrap_or_default();
222            Some(RawOccurrence {
223                file,
224                name_line,
225                resolution,
226                target_type,
227                target_member,
228                compilation_identity,
229                target_assembly,
230                target_generic_arity,
231                target_overload_signature,
232                span_start_line,
233                span_start_char,
234                span_end_line,
235                span_end_char,
236                document_content_identity,
237                target_document_content_identities,
238            })
239        })
240        .collect()
241}
242
243/// Parses the parameter count out of an overload signature like
244/// `"()->void"` or `"(bool, string)->void"` -- the producer's own
245/// `overloadSignature` encoding. Depth-aware: a comma inside a generic
246/// argument list (`"(Dictionary<string, int>)->void"`) never splits, the
247/// same discipline `split_qualified_type` already applies to a qualified
248/// type name. `None` when the string does not have the `"(...)->..."` shape
249/// at all -- treated as "cannot disambiguate by arity", never as zero.
250fn overload_arg_count(signature: &str) -> Option<usize> {
251    let after_open = signature.strip_prefix('(')?;
252    let close = after_open.find(")->")?;
253    let params = &after_open[..close];
254    if params.trim().is_empty() {
255        return Some(0);
256    }
257    let mut count = 1;
258    let mut depth = 0i32;
259    for ch in params.chars() {
260        match ch {
261            '<' | '(' | '[' => depth += 1,
262            '>' | ')' | ']' => depth -= 1,
263            ',' if depth == 0 => count += 1,
264            _ => {}
265        }
266    }
267    Some(count)
268}
269
270fn parse_raw(bytes: &[u8]) -> Option<RawFacts> {
271    let root: Value = serde_json::from_slice(bytes).ok()?;
272    let source_head_sha = root
273        .get("sourceSnapshot")
274        .and_then(as_obj)
275        .and_then(|s| s.get("headSha"))
276        .and_then(Value::as_str)
277        .map(str::to_string);
278    // Absent `dirty` cannot be trusted to mean clean -- see freshness.rs's
279    // own header comment.
280    let source_dirty = root
281        .get("sourceSnapshot")
282        .and_then(as_obj)
283        .and_then(|s| s.get("dirty"))
284        .and_then(Value::as_bool)
285        .unwrap_or(true);
286    Some(RawFacts {
287        source_head_sha,
288        source_dirty,
289        compilations: parse_compilations(&root),
290        occurrences: parse_occurrences(&root),
291    })
292}
293
294/// Splits a Roslyn fully-qualified type display string into its dotted
295/// segments, stripping each segment's own trailing balanced `<...>`
296/// generic-argument suffix (present only on a segment whose own type
297/// declares generic parameters) and never splitting on a `.` that sits
298/// inside one -- a generic argument can itself be a qualified type name.
299fn split_qualified_type(raw: &str) -> Vec<String> {
300    let mut segments = Vec::new();
301    let mut current = String::new();
302    let mut depth = 0i32;
303    for ch in raw.chars() {
304        match ch {
305            '<' => {
306                depth += 1;
307                current.push(ch);
308            }
309            '>' => {
310                depth -= 1;
311                current.push(ch);
312            }
313            '.' if depth == 0 => {
314                segments.push(current.clone());
315                current.clear();
316            }
317            _ => current.push(ch),
318        }
319    }
320    if !current.is_empty() {
321        segments.push(current);
322    }
323    segments
324        .into_iter()
325        .map(|seg| match seg.find('<') {
326            Some(i) => seg[..i].to_string(),
327            None => seg,
328        })
329        .collect()
330}
331
332/// Translates one admitted-artifact type identity into a devscout def index,
333/// trying the shortest possible outer-type head first and walking the
334/// remaining segments as nested-type `+` links -- the resolver's own ladder
335/// performs the equivalent walk for extractor-emitted references; this
336/// reapplies the same shape to a fully-qualified string that carries no
337/// using or alias ambiguity to resolve. `None` when no split point's walk
338/// consumes every segment, which the caller treats as "no confirmed answer"
339/// rather than an error.
340fn translate_type_id(raw: &str, index: &DefIndex) -> Option<usize> {
341    let segments = split_qualified_type(raw);
342    for k in 1..=segments.len() {
343        let head = segments[..k].join(".");
344        let Some(&start) = index.qualified_name_to_def.get(&head) else {
345            continue;
346        };
347        let mut cur = start;
348        let mut complete = true;
349        for seg in &segments[k..] {
350            let candidate = format!("{}+{}", index.defs[cur].id, seg);
351            match index.qualified_name_to_def.get(&candidate) {
352                Some(&next) => cur = next,
353                None => {
354                    complete = false;
355                    break;
356                }
357            }
358        }
359        if complete {
360            return Some(cur);
361        }
362    }
363    None
364}
365
366/// One translated, override-ready target: the devscout def id/file the
367/// compiler's own confirmed occurrence names, resolved to the specific enum
368/// member def when the containing type is an enum, the same way the
369/// resolver's own enum branch does for an extractor-emitted reference.
370/// `overload_signature` carries the admitted occurrence's own signature
371/// alongside `to_def_id`/`to_file` -- never folded into the compatibility
372/// key, but part of THIS target's own exact identity, so `dedup` (both here
373/// and in `lookup`'s own `resolved_targets.dedup()`) keys on the full fact
374/// rather than only on which TYPE it names: two same-line overloads of one
375/// member on the SAME type carry the SAME `to_def_id`/`to_file` and must
376/// stay two distinct targets, while two admitted records of the genuinely
377/// SAME occurrence (same type, same overload) must still collapse to one.
378#[derive(Debug, Clone, PartialEq, Eq)]
379pub struct SemanticTarget {
380    /// The devscout def id the compiler's target translates to.
381    pub to_def_id: String,
382    /// That def's own declaring file.
383    pub to_file: String,
384    /// The admitted occurrence's own overload signature, when it named one.
385    pub overload_signature: Option<String>,
386}
387
388fn resolve_target(
389    type_idx: usize,
390    member: &str,
391    overload_signature: Option<&str>,
392    index: &DefIndex,
393) -> SemanticTarget {
394    let def = &index.defs[type_idx];
395    let overload_signature = overload_signature.map(str::to_string);
396    if def.kind == "enum" {
397        let key = format!("{}.{member}", def.id);
398        if let Some(&member_idx) = index.qualified_name_to_def.get(&key) {
399            let member_def = &index.defs[member_idx];
400            return SemanticTarget {
401                to_def_id: member_def.id.clone(),
402                to_file: member_def.file.clone(),
403                overload_signature,
404            };
405        }
406    }
407    SemanticTarget {
408        to_def_id: def.id.clone(),
409        to_file: def.file.clone(),
410        overload_signature,
411    }
412}
413
414/// What a same-context lookup at one reference's `(file, name_line, member)`
415/// key found.
416#[derive(Debug, Clone, PartialEq, Eq)]
417pub enum LookupOutcome {
418    /// No admitted occurrence site at this key -- the ordinary absent case,
419    /// never treated as a negative fact.
420    NoFact,
421    /// Exactly one same-context confirmed target, ready to override the
422    /// ladder.
423    Confirmed(SemanticTarget),
424    /// A fact exists but is not confirmed-and-translatable; every other
425    /// consumer-visible state.
426    Other(Uncertainty),
427    /// More than one distinct confirmed target survived (no caller-side
428    /// arity narrowed the set to one, or there was none to narrow with in
429    /// the first place). Never itself an ambiguity: it is the caller's own
430    /// choice whether "more than one confirmed target, nothing left to pick
431    /// between them" projects every one of them or degrades to
432    /// `Other(Uncertainty::Ambiguous)` -- see `discovered.rs` (which
433    /// projects same-type overloads, since it carries no caller argument
434    /// count to disambiguate with at all) and `precedence.rs` (which
435    /// degrades, since an extractor-emitted reference already has its own
436    /// single-target slot to fill and a real caller-side tie there is
437    /// genuine ambiguity, not a projection opportunity).
438    ConfirmedMany(Vec<SemanticTarget>),
439}
440
441/// The resolve-time consumption path's built-once layer: freshness, plus a
442/// `(file, name_line, member)` compatibility join index over every admitted
443/// occurrence, with identity translation applied lazily per lookup. Built by
444/// `load`.
445pub struct SemanticLayer {
446    freshness: Freshness,
447    by_site: HashMap<(String, usize, String), Vec<usize>>,
448    occurrences: Vec<RawOccurrence>,
449    compilations: Vec<RawCompilation>,
450}
451
452impl SemanticLayer {
453    /// Reads this checkout's admitted compiler-facts artifact and builds the
454    /// layer. `None` when no artifact is admitted (the ordinary "no engine
455    /// ever ran here" state) or its bytes do not parse as this module
456    /// expects; both are fail-open, not errors -- the whole run proceeds as
457    /// a syntax-only build.
458    pub fn load(root: &Path) -> Option<SemanticLayer> {
459        let admitted = read_compiler_facts(root)?;
460        let raw = parse_raw(&admitted.bytes)?;
461        let imports: Vec<freshness::ImportRef> = raw
462            .compilations
463            .iter()
464            .flat_map(|c| c.imports.iter().cloned())
465            .collect();
466        let freshness = freshness::evaluate(
467            root,
468            raw.source_head_sha.as_deref(),
469            raw.source_dirty,
470            &imports,
471        );
472
473        let mut by_site: HashMap<(String, usize, String), Vec<usize>> = HashMap::new();
474        for (i, occ) in raw.occurrences.iter().enumerate() {
475            if let Some(member) = &occ.target_member {
476                by_site
477                    .entry((occ.file.clone(), occ.name_line, member.clone()))
478                    .or_default()
479                    .push(i);
480            }
481        }
482
483        Some(SemanticLayer {
484            freshness,
485            by_site,
486            occurrences: raw.occurrences,
487            compilations: raw.compilations,
488        })
489    }
490
491    /// Whether the whole artifact is fresh -- consulted once per `map` run,
492    /// not per reference.
493    pub fn is_fresh(&self) -> bool {
494        self.freshness.is_fresh()
495    }
496
497    /// Every distinct `(file, name_line, member)` compatibility key this
498    /// layer indexes -- `discovered.rs`'s own iteration surface, walked once
499    /// per admitted artifact rather than once per extractor reference.
500    pub fn site_keys(&self) -> impl Iterator<Item = &(String, usize, String)> {
501        self.by_site.keys()
502    }
503
504    fn compilation_state(&self, identity: &Value) -> Option<(&str, &str)> {
505        self.compilations
506            .iter()
507            .find(|c| &c.identity == identity)
508            .map(|c| (c.state.as_str(), c.reason.as_str()))
509    }
510
511    /// Looks up the compatibility join key `(file, name_line, member)`.
512    /// Freshness is checked first (a stale artifact never confirms anything,
513    /// regardless of what any individual site says). More than one matching
514    /// site is not automatically an ambiguity: `caller_arg_count`, the
515    /// reference's own argument count when the call syntax carries one
516    /// (`None` for a property read, which never disambiguates), is compared
517    /// against each candidate's `target_overload_signature` -- the exact
518    /// identity carried alongside this compatibility key, not folded into
519    /// it. When exactly one candidate's own arity matches, that candidate
520    /// alone is resolved, the same way a single hit would be; two same-line
521    /// overloads of one member survive as distinct facts through this path
522    /// rather than collapsing together. Only when arity cannot narrow the
523    /// set to one (no caller arg count, no signature, more than one
524    /// survivor, or a genuine tie) do disagreeing targets collapse to an
525    /// ambiguous outcome rather than picking one arbitrarily -- the key is
526    /// a compatibility join, not the fact's own identity, so more than one
527    /// unresolved hit at it is a real ambiguity this consumer introduces,
528    /// not one the compiler itself reported.
529    pub fn lookup(
530        &self,
531        index: &DefIndex,
532        file: &str,
533        line: usize,
534        member: &str,
535        caller_arg_count: Option<usize>,
536    ) -> LookupOutcome {
537        if !self.freshness.is_fresh() {
538            let Freshness::Stale { reason } = &self.freshness else {
539                unreachable!("is_fresh() already returned false");
540            };
541            return LookupOutcome::Other(Uncertainty::Stale {
542                reason: reason.clone(),
543            });
544        }
545        let Some(indices) = self
546            .by_site
547            .get(&(file.to_string(), line, member.to_string()))
548        else {
549            return LookupOutcome::NoFact;
550        };
551
552        // Arity narrowing: only ever REMOVES candidates, and only when it
553        // lands on exactly one -- see this function's own doc comment.
554        let narrowed: Vec<usize>;
555        let indices: &[usize] = if indices.len() > 1 {
556            if let Some(want) = caller_arg_count {
557                let matching: Vec<usize> = indices
558                    .iter()
559                    .copied()
560                    .filter(|&i| {
561                        self.occurrences[i]
562                            .target_overload_signature
563                            .as_deref()
564                            .and_then(overload_arg_count)
565                            == Some(want)
566                    })
567                    .collect();
568                if matching.len() == 1 {
569                    narrowed = matching;
570                    &narrowed
571                } else {
572                    indices.as_slice()
573                }
574            } else {
575                indices.as_slice()
576            }
577        } else {
578            indices.as_slice()
579        };
580
581        let mut resolved_targets: Vec<SemanticTarget> = Vec::new();
582        let mut other: Option<Uncertainty> = None;
583        for &i in indices {
584            let occ = &self.occurrences[i];
585            let state = self.compilation_state(&occ.compilation_identity);
586            let uncertainty = match state {
587                Some((cstate, creason)) => uncertainty::from_compilation_state(cstate, creason)
588                    .unwrap_or_else(|| uncertainty::from_resolution(&occ.resolution)),
589                None => uncertainty::from_resolution(&occ.resolution),
590            };
591            if uncertainty.is_confirmed() {
592                if let Some(type_id) = occ.target_type.as_deref() {
593                    if let Some(type_idx) = translate_type_id(type_id, index) {
594                        resolved_targets.push(resolve_target(
595                            type_idx,
596                            member,
597                            occ.target_overload_signature.as_deref(),
598                            index,
599                        ));
600                        continue;
601                    }
602                }
603                // A confirmed fact this consumer's identity translation
604                // cannot place in the graph at all: never silently dropped,
605                // never treated as a negative fact either -- surfaced as its
606                // own explicit state.
607                other.get_or_insert(Uncertainty::Candidate);
608            } else {
609                other.get_or_insert(uncertainty);
610            }
611        }
612
613        resolved_targets.dedup();
614        match resolved_targets.len() {
615            0 => other
616                .map(LookupOutcome::Other)
617                .unwrap_or(LookupOutcome::NoFact),
618            1 => LookupOutcome::Confirmed(resolved_targets.remove(0)),
619            _ => LookupOutcome::ConfirmedMany(resolved_targets),
620        }
621    }
622}
623
624#[cfg(test)]
625mod tests {
626    use super::*;
627
628    #[test]
629    fn overload_arg_count_reads_a_zero_arg_signature() {
630        assert_eq!(overload_arg_count("()->void"), Some(0));
631    }
632
633    #[test]
634    fn overload_arg_count_counts_top_level_commas() {
635        assert_eq!(overload_arg_count("(bool, string)->void"), Some(2));
636    }
637
638    #[test]
639    fn overload_arg_count_never_splits_inside_a_generic_argument_list() {
640        assert_eq!(
641            overload_arg_count("(System.Collections.Generic.Dictionary<string, int>)->void"),
642            Some(1)
643        );
644    }
645
646    #[test]
647    fn overload_arg_count_is_none_for_a_string_with_no_parenthesized_shape() {
648        assert_eq!(overload_arg_count("void"), None);
649        assert_eq!(overload_arg_count(""), None);
650    }
651
652    #[test]
653    fn splits_plain_dotted_segments() {
654        assert_eq!(
655            split_qualified_type("Ns.Outer.Widget"),
656            vec!["Ns", "Outer", "Widget"]
657        );
658    }
659
660    #[test]
661    fn strips_a_trailing_generic_suffix_per_segment() {
662        assert_eq!(
663            split_qualified_type("Ns.Outer.Nested<T>"),
664            vec!["Ns", "Outer", "Nested"]
665        );
666    }
667
668    #[test]
669    fn never_splits_inside_a_generic_argument_list() {
670        assert_eq!(
671            split_qualified_type("Ns.Holder<System.String>.Leaf"),
672            vec!["Ns", "Holder", "Leaf"]
673        );
674    }
675
676    #[test]
677    fn a_doubly_nested_generic_strips_every_level() {
678        assert_eq!(
679            split_qualified_type("Ns.Outer<K>.Middle<V>.Leaf"),
680            vec!["Ns", "Outer", "Middle", "Leaf"]
681        );
682    }
683}