Skip to main content

devscout_rs/
markup.rs

1// The one source read in this crate that is not a tree-sitter parse. Markup and
2// resource files carry names no C# declaration does (`x:Class`, `x:Name`, a
3// `.resw`/`.resx` key), and both grammars are too small to justify a parser
4// dependency, so the scan is a literal-prefix walk over each line.
5//
6// It is a hand-rolled scan rather than a regex so the exact match set is fixed
7// and self-evident. The scan records declarations, instantiations and bindings
8// but never widens how it reads -- still positional, still literal-anchored,
9// still "read to the next `"` on the SAME line" -- so an attribute split across
10// lines is simply not recorded.
11
12use crate::extract::NameRecord;
13
14// `.resx` is .NET's desktop/web resource format; `.resw` is its WinRT
15// counterpart. Both carry the identical `<data name="Key" ...>` schema, so
16// `.resx` gets the exact same treatment as `.resw` everywhere below -- same
17// scan function, same resource-key kind, same tier.
18/// Returns whether a repository-relative path has a supported markup extension.
19pub fn is_markup(rel: &str) -> bool {
20    rel.ends_with(".xaml") || is_resource(rel)
21}
22
23fn is_resource(rel: &str) -> bool {
24    rel.ends_with(".resw") || rel.ends_with(".resx")
25}
26
27const RESW_KEY_ATTR: &str = "name=\"";
28
29const X_CLASS_ATTR: &str = "x:Class=\"";
30const X_NAME_ATTR: &str = "x:Name=\"";
31const XMLNS_PREFIX: &str = "xmlns:";
32const BINDING_OPEN: &str = "{Binding";
33const XBIND_OPEN: &str = "{x:Bind";
34const PATH_PROPERTY: &str = "Path=";
35
36// The two spellings a XAML prefix declaration uses for a CLR namespace:
37// WPF/Xamarin write `clr-namespace:Ns[;assembly=A]`, WinUI/UWP write
38// `using:Ns`. Anything else (a schema URL, a design-time prefix) declares a
39// namespace this index cannot name a type in, so its elements are skipped
40// rather than guessed at.
41const CLR_NAMESPACE: &str = "clr-namespace:";
42const USING_NAMESPACE: &str = "using:";
43
44/// One markup-declared type: the `x:Class` value, already split the way a C#
45/// def is. This is the `defs` entry `markup_facts` produces.
46#[derive(Debug, Clone, PartialEq)]
47pub struct MarkupDef {
48    /// The id value.
49    pub id: String,
50    /// The name value.
51    pub name: String,
52    /// The namespace value.
53    pub namespace: String,
54    /// The kind value.
55    pub kind: String,
56    /// The line value.
57    pub line: usize,
58}
59
60/// One markup-contributed ref in the shape the C# extractor emits, so
61/// `resolve_graph` needs no markup branch: `qualified` is `Some` only when the
62/// source text was dotted (the ladder's exact-qualified step keys off exactly
63/// that), `name` is the tail segment, and the ref SITE's namespace is always
64/// empty -- markup has no enclosing namespace of its own.
65#[derive(Debug, Clone, PartialEq)]
66pub struct MarkupRef {
67    /// The kind value.
68    pub kind: String,
69    /// The name value.
70    pub name: String,
71    /// The qualified value.
72    pub qualified: Option<String>,
73    /// The member value.
74    pub member: Option<String>,
75    /// The line value.
76    pub line: usize,
77}
78
79#[derive(Debug, Clone, Default, PartialEq)]
80/// Represents `MarkupFacts`.
81pub struct MarkupFacts {
82    /// The defs value.
83    pub defs: Vec<MarkupDef>,
84    /// The refs value.
85    pub refs: Vec<MarkupRef>,
86    /// The names value.
87    pub names: Vec<NameRecord>,
88}
89
90fn is_name_char(ch: u8) -> bool {
91    ch.is_ascii_alphanumeric() || ch == b'_'
92}
93
94// A prefix may carry `-` and `.` (XML names do); a type's own local name may
95// not, which is what stops `<local:Panel.Items>` (property-element syntax)
96// being read as a type called `Panel.Items` -- it reads as the type `Panel`,
97// which is what the element actually references.
98fn is_prefix_char(ch: u8) -> bool {
99    is_name_char(ch) || ch == b'-' || ch == b'.'
100}
101
102fn is_path_char(ch: u8) -> bool {
103    is_name_char(ch) || ch == b'.'
104}
105
106// Every literal this scan matches is ASCII, and every character class above
107// rejects a byte with the high bit set, so a multi-byte character can never
108// sit at a match boundary -- a slice taken at any index this scan produces is
109// always on a char boundary. The `is_char_boundary` guard in the walk keeps
110// that true for the one index the scan does NOT choose: the raw `pos + 1`
111// step over an unmatched byte.
112fn quoted(line: &str, start: usize) -> Option<(&str, usize)> {
113    let offset = line[start..].find('"')?;
114    Some((&line[start..start + offset], start + offset + 1))
115}
116
117// A binding expression's PATH, reading from just past `{Binding` / `{x:Bind`.
118// Handles the three spellings that name a member: bare (`{Binding Caption}`),
119// explicit (`{Binding Path=Caption}`) and dotted (`{x:Bind Vm.Caption}`).
120// A run that turns out to be a named property rather than a path
121// (`{Binding RelativeSource=...}`) is discarded on the `=` that follows it --
122// without that test every markup extension's first argument name would be
123// indexed as if it were a member.
124fn binding_path(line: &str, from: usize) -> (&str, usize) {
125    let b = line.as_bytes();
126    let mut i = from;
127    while i < b.len() && b[i] == b' ' {
128        i += 1;
129    }
130    if line[i..].starts_with(PATH_PROPERTY) {
131        i += PATH_PROPERTY.len();
132    }
133    let start = i;
134    while i < b.len() && is_path_char(b[i]) {
135        i += 1;
136    }
137    if i == start || (i < b.len() && b[i] == b'=') {
138        return ("", i);
139    }
140    (&line[start..i], i)
141}
142
143// `<prefix:Local` -- the element form that names a type from a CLR namespace.
144// `None` for an unprefixed element (`<Grid`), for a closing/processing/comment
145// marker, and for anything that is not a name at all.
146fn prefixed_element(line: &str, from: usize) -> Option<(&str, &str, usize)> {
147    let b = line.as_bytes();
148    let mut i = from;
149    let prefix_start = i;
150    while i < b.len() && is_prefix_char(b[i]) {
151        i += 1;
152    }
153    if i == prefix_start || i >= b.len() || b[i] != b':' {
154        return None;
155    }
156    let prefix = &line[prefix_start..i];
157    i += 1;
158    let local_start = i;
159    while i < b.len() && is_name_char(b[i]) {
160        i += 1;
161    }
162    if i == local_start {
163        return None;
164    }
165    Some((prefix, &line[local_start..i], i))
166}
167
168// What one scan pass found in source order, before prefixes are applied.
169enum Pending {
170    Element {
171        prefix: String,
172        local: String,
173        line: usize,
174    },
175    Bind {
176        path: String,
177        line: usize,
178    },
179}
180
181struct XamlScan {
182    names: Vec<NameRecord>,
183    pending: Vec<Pending>,
184    prefixes: Vec<(String, String)>,
185    decl: Option<(String, usize)>,
186}
187
188// One positional pass over a `.xaml` file, producing everything the graph needs
189// from it. `names` is emitted in COLUMN ORDER within a line and line order
190// across the file, with binding paths interleaved at the column they appear at.
191// `pending` keeps element and binding occurrences in that same scan order so the
192// resolution phase emits refs in a pinned, deterministic order too.
193//
194// Prefix declarations are collected during the scan but APPLIED after it:
195// `xmlns:` sits on an ancestor element, so a forward-only application would work
196// for well-formed markup and silently differ for anything else. Resolving once
197// at the end has no such dependence on where the declaration sits. The prefix
198// table is a Vec of pairs, not a map, because first-declaration-wins is the rule
199// and iteration order has to stay fixed.
200fn scan_xaml(text: &str) -> XamlScan {
201    let mut scan = XamlScan {
202        names: Vec::new(),
203        pending: Vec::new(),
204        prefixes: Vec::new(),
205        decl: None,
206    };
207
208    for (i, line) in text.split('\n').enumerate() {
209        let line_no = i + 1;
210        let bytes = line.as_bytes();
211        let mut pos = 0usize;
212        while pos < line.len() {
213            if !line.is_char_boundary(pos) {
214                pos += 1;
215                continue;
216            }
217            let rest = &line[pos..];
218            if rest.starts_with(X_CLASS_ATTR) {
219                let Some((value, next)) = quoted(line, pos + X_CLASS_ATTR.len()) else {
220                    break;
221                };
222                if !value.is_empty() {
223                    scan.names.push(NameRecord {
224                        name: value.to_string(),
225                        kind: "markup-class".to_string(),
226                        line: line_no,
227                        owner: String::new(),
228                    });
229                    if scan.decl.is_none() {
230                        scan.decl = Some((value.to_string(), line_no));
231                    }
232                }
233                pos = next;
234                continue;
235            }
236            if rest.starts_with(X_NAME_ATTR) {
237                let Some((value, next)) = quoted(line, pos + X_NAME_ATTR.len()) else {
238                    break;
239                };
240                if !value.is_empty() {
241                    scan.names.push(NameRecord {
242                        name: value.to_string(),
243                        kind: "markup-name".to_string(),
244                        line: line_no,
245                        owner: String::new(),
246                    });
247                }
248                pos = next;
249                continue;
250            }
251            if rest.starts_with(XMLNS_PREFIX) {
252                let mut j = pos + XMLNS_PREFIX.len();
253                let start = j;
254                while j < bytes.len() && is_prefix_char(bytes[j]) {
255                    j += 1;
256                }
257                if j == start || !line[j..].starts_with("=\"") {
258                    pos = if j > pos { j } else { pos + 1 };
259                    continue;
260                }
261                let prefix = line[start..j].to_string();
262                let Some((value, next)) = quoted(line, j + 2) else {
263                    break;
264                };
265                let ns = if let Some(rest) = value.strip_prefix(CLR_NAMESPACE) {
266                    Some(match rest.find(';') {
267                        Some(semi) => &rest[..semi],
268                        None => rest,
269                    })
270                } else {
271                    value.strip_prefix(USING_NAMESPACE)
272                };
273                if let Some(ns) = ns {
274                    if !ns.is_empty() && !scan.prefixes.iter().any(|(p, _)| p == &prefix) {
275                        scan.prefixes.push((prefix, ns.to_string()));
276                    }
277                }
278                pos = next;
279                continue;
280            }
281            if rest.starts_with(BINDING_OPEN) || rest.starts_with(XBIND_OPEN) {
282                let bind = rest.starts_with(XBIND_OPEN);
283                let from = pos
284                    + if bind {
285                        XBIND_OPEN.len()
286                    } else {
287                        BINDING_OPEN.len()
288                    };
289                let (path, next) = binding_path(line, from);
290                if !path.is_empty() {
291                    scan.names.push(NameRecord {
292                        name: path.to_string(),
293                        kind: "markup-binding".to_string(),
294                        line: line_no,
295                        owner: String::new(),
296                    });
297                    if bind {
298                        scan.pending.push(Pending::Bind {
299                            path: path.to_string(),
300                            line: line_no,
301                        });
302                    }
303                }
304                pos = if next > pos { next } else { pos + 1 };
305                continue;
306            }
307            if bytes[pos] == b'<' {
308                if let Some((prefix, local, next)) = prefixed_element(line, pos + 1) {
309                    scan.pending.push(Pending::Element {
310                        prefix: prefix.to_string(),
311                        local: local.to_string(),
312                        line: line_no,
313                    });
314                    pos = next;
315                    continue;
316                }
317            }
318            pos += 1;
319        }
320    }
321    scan
322}
323
324// `<data name="Key" xml:space="preserve">` -- the `<data` anchor is what keeps
325// this off every other `name="` attribute a resource file can carry.
326fn scan_resw(text: &str) -> Vec<NameRecord> {
327    let mut out = Vec::new();
328    for (i, line) in text.split('\n').enumerate() {
329        let mut pos = 0usize;
330        loop {
331            let Some(data) = line[pos..].find("<data") else {
332                break;
333            };
334            let data = pos + data;
335            let Some(attr) = line[data..].find(RESW_KEY_ATTR) else {
336                break;
337            };
338            let start = data + attr + RESW_KEY_ATTR.len();
339            let Some(offset) = line[start..].find('"') else {
340                break;
341            };
342            let name = &line[start..start + offset];
343            if !name.is_empty() {
344                out.push(NameRecord {
345                    name: name.to_string(),
346                    kind: "resource-key".to_string(),
347                    line: i + 1,
348                    owner: String::new(),
349                });
350            }
351            pos = start + offset + 1;
352        }
353    }
354    out
355}
356
357/// The name facts a markup or resource file contributes. `owner` is empty on
358/// every entry -- nothing in a markup file declares a member of a graph def.
359pub fn markup_names(rel: &str, text: &str) -> Vec<NameRecord> {
360    if is_resource(rel) {
361        scan_resw(text)
362    } else {
363        scan_xaml(text).names
364    }
365}
366
367// A dotted declaration splits into the namespace and the simple name the C#
368// half of the same partial type carries; an undotted one is a type in the
369// global namespace, exactly as a C# def with an empty `namespace` is.
370fn split_qualified(qualified: &str) -> (&str, &str) {
371    match qualified.rfind('.') {
372        Some(dot) => (&qualified[..dot], &qualified[dot + 1..]),
373        None => ("", qualified),
374    }
375}
376
377fn type_ref(kind: &str, qualified: &str, member: Option<String>, line: usize) -> MarkupRef {
378    let (_, name) = split_qualified(qualified);
379    MarkupRef {
380        kind: kind.to_string(),
381        name: name.to_string(),
382        qualified: if qualified.contains('.') {
383            Some(qualified.to_string())
384        } else {
385            None
386        },
387        member,
388        line,
389    }
390}
391
392/// The graph facts a markup file contributes.
393///
394///   `x:Class="Ns.TheControl"` -> a def with the SAME id the code-behind's
395///   `partial class TheControl` produces. `build_def_index` merges two defs
396///   sharing an id into one record with two declaring sites, so the markup and
397///   the code-behind become two locations of one symbol for free -- which is
398///   what makes `refs` list both and `impact` seed from either.
399///
400///   `<local:TheControl .../>` where `xmlns:local` names a CLR namespace -> an
401///   ordinary `uses-type` ref to `Ns.TheControl`. Without this edge a control
402///   instantiated only in markup would have no inbound reference at all.
403///
404///   `{x:Bind Prop}` -> a `uses-member` ref against the file's OWN `x:Class`
405///   type. `x:Bind`'s root is that type by language rule, so the binding target
406///   is statically known and earns an edge. `{Binding Prop}` binds against a
407///   DataContext assigned at runtime, which markup alone cannot name, so it
408///   contributes the `markup-binding` NAME fact only -- the never-guess rule
409///   applies to a binding exactly as it applies to a call.
410pub fn markup_facts(rel: &str, text: &str) -> MarkupFacts {
411    if is_resource(rel) {
412        return MarkupFacts {
413            defs: Vec::new(),
414            refs: Vec::new(),
415            names: scan_resw(text),
416        };
417    }
418    let scan = scan_xaml(text);
419
420    let mut defs = Vec::new();
421    if let Some((qualified, line)) = &scan.decl {
422        let (namespace, name) = split_qualified(qualified);
423        defs.push(MarkupDef {
424            id: qualified.clone(),
425            name: name.to_string(),
426            namespace: namespace.to_string(),
427            kind: "class".to_string(),
428            line: *line,
429        });
430    }
431
432    let mut refs = Vec::new();
433    for p in &scan.pending {
434        match p {
435            Pending::Element {
436                prefix,
437                local,
438                line,
439            } => {
440                let Some((_, ns)) = scan.prefixes.iter().find(|(name, _)| name == prefix) else {
441                    continue;
442                };
443                refs.push(type_ref("uses-type", &format!("{ns}.{local}"), None, *line));
444            }
445            Pending::Bind { path, line } => {
446                let Some((qualified, _)) = &scan.decl else {
447                    continue;
448                };
449                // The bound member is the FIRST path segment: `{x:Bind Vm.Name}`
450                // reads `Vm` off the x:Class type and everything after it off
451                // whatever `Vm` returns, which is a type this scan cannot name.
452                let member = match path.find('.') {
453                    Some(dot) => &path[..dot],
454                    None => path.as_str(),
455                };
456                refs.push(type_ref(
457                    "uses-member",
458                    qualified,
459                    Some(member.to_string()),
460                    *line,
461                ));
462            }
463        }
464    }
465
466    MarkupFacts {
467        defs,
468        refs,
469        names: scan.names,
470    }
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476
477    fn names(rel: &str, text: &str) -> Vec<(String, String, usize)> {
478        markup_names(rel, text)
479            .into_iter()
480            .map(|n| (n.name, n.kind, n.line))
481            .collect()
482    }
483
484    #[test]
485    fn xaml_records_class_and_every_element_name_in_source_order() {
486        let src = "<UserControl\n\tx:Class=\"Gadgets.PanelView\">\n\t<Button x:Name=\"ShipButton\" />\n</UserControl>\n";
487        assert_eq!(
488            names("src/Panel.xaml", src),
489            vec![
490                (
491                    "Gadgets.PanelView".to_string(),
492                    "markup-class".to_string(),
493                    2
494                ),
495                ("ShipButton".to_string(), "markup-name".to_string(), 3),
496            ]
497        );
498    }
499
500    #[test]
501    fn xaml_takes_two_names_on_one_line_left_to_right() {
502        let src = "<Grid x:Name=\"Outer\"><Grid x:Name=\"Inner\" /></Grid>\n";
503        assert_eq!(
504            names("a.xaml", src),
505            vec![
506                ("Outer".to_string(), "markup-name".to_string(), 1),
507                ("Inner".to_string(), "markup-name".to_string(), 1)
508            ]
509        );
510    }
511
512    #[test]
513    fn xaml_skips_an_empty_value_and_an_unterminated_attribute() {
514        assert!(names("a.xaml", "<Grid x:Name=\"\" />\n").is_empty());
515        assert!(names("a.xaml", "<Grid x:Name=\"Unclosed\n").is_empty());
516    }
517
518    #[test]
519    fn resw_keys_come_from_data_elements_only() {
520        let src = "<root>\n\t<resheader name=\"resmimetype\" />\n\t<data name=\"Ship.Content\" xml:space=\"preserve\">\n\t\t<value>Ship</value>\n\t</data>\n</root>\n";
521        assert_eq!(
522            names("a.resw", src),
523            vec![("Ship.Content".to_string(), "resource-key".to_string(), 3)]
524        );
525    }
526
527    // (2026-08-23) -- `.resx` (.NET desktop/web resources) carries the exact
528    // same `<data name="Key">` schema as `.resw` (WinRT resources) and gets
529    // identical treatment: same scan, same `resource-key` kind, same tier.
530    #[test]
531    fn resx_keys_come_from_data_elements_only_same_as_resw() {
532        let src = "<root>\n\t<resheader name=\"resmimetype\" />\n\t<data name=\"Ship.Content\" xml:space=\"preserve\">\n\t\t<value>Ship</value>\n\t</data>\n</root>\n";
533        assert_eq!(
534            names("a.resx", src),
535            vec![("Ship.Content".to_string(), "resource-key".to_string(), 3)]
536        );
537    }
538
539    #[test]
540    fn markup_extensions_are_xaml_resw_and_resx_only() {
541        assert!(is_markup("a/b.xaml"));
542        assert!(is_markup("a/b.resw"));
543        assert!(is_markup("a/b.resx"));
544        assert!(!is_markup("a/b.xaml.cs"));
545        assert!(!is_markup("a/b.xml"));
546    }
547
548    // --- markup graph facts ------------------------------------------------
549
550    const CONTROL: &str = "<UserControl\n    x:Class=\"Demo.Controls.ShipPanel\"\n    xmlns:x=\"http://schemas.microsoft.com/winfx/2006/xaml\">\n    <TextBlock Text=\"{x:Bind Caption}\" />\n</UserControl>\n";
551    const PAGE: &str = "<Page\n    x:Class=\"Demo.Pages.HomePage\"\n    xmlns:local=\"clr-namespace:Demo.Controls\">\n    <local:ShipPanel x:Name=\"Panel\" Header=\"{Binding Title}\" />\n</Page>\n";
552
553    #[test]
554    fn x_class_becomes_a_def_with_the_code_behind_id() {
555        let facts = markup_facts("Controls/ShipPanel.xaml", CONTROL);
556        assert_eq!(
557            facts.defs,
558            vec![MarkupDef {
559                id: "Demo.Controls.ShipPanel".to_string(),
560                name: "ShipPanel".to_string(),
561                namespace: "Demo.Controls".to_string(),
562                kind: "class".to_string(),
563                line: 2,
564            }]
565        );
566    }
567
568    #[test]
569    fn a_prefixed_element_becomes_a_uses_type_ref_at_the_mapped_namespace() {
570        let facts = markup_facts("Pages/HomePage.xaml", PAGE);
571        assert_eq!(
572            facts.refs,
573            vec![MarkupRef {
574                kind: "uses-type".to_string(),
575                name: "ShipPanel".to_string(),
576                qualified: Some("Demo.Controls.ShipPanel".to_string()),
577                member: None,
578                line: 4,
579            }]
580        );
581    }
582
583    #[test]
584    fn x_bind_earns_a_member_ref_against_the_files_own_class_and_binding_does_not() {
585        let control = markup_facts("Controls/ShipPanel.xaml", CONTROL);
586        assert_eq!(
587            control.refs,
588            vec![MarkupRef {
589                kind: "uses-member".to_string(),
590                name: "ShipPanel".to_string(),
591                qualified: Some("Demo.Controls.ShipPanel".to_string()),
592                member: Some("Caption".to_string()),
593                line: 4,
594            }]
595        );
596        let page = markup_facts("Pages/HomePage.xaml", PAGE);
597        assert!(
598            page.refs.iter().all(|r| r.kind != "uses-member"),
599            "a runtime DataContext is not statically known, so `{{Binding Title}}` earns a name fact and no edge"
600        );
601        assert!(page
602            .names
603            .iter()
604            .any(|n| n.name == "Title" && n.kind == "markup-binding" && n.line == 4));
605    }
606
607    #[test]
608    fn an_unmapped_prefix_and_a_bare_element_contribute_nothing() {
609        let facts = markup_facts("a.xaml", "<Page xmlns:d=\"http://schemas.microsoft.com/expression/blend/2008\">\n  <d:DesignHost />\n  <Grid />\n</Page>\n");
610        assert!(
611            facts.refs.is_empty(),
612            "a prefix that names no CLR namespace is skipped, never guessed at"
613        );
614    }
615
616    #[test]
617    fn a_closing_tag_never_doubles_the_instantiation_and_a_property_element_reads_as_its_type() {
618        let src = "<Page\n    xmlns:local=\"using:Demo.Controls\">\n    <local:ShipPanel>\n        <local:ShipPanel.Header>x</local:ShipPanel.Header>\n    </local:ShipPanel>\n</Page>\n";
619        let facts = markup_facts("a.xaml", src);
620        let lines: Vec<usize> = facts.refs.iter().map(|r| r.line).collect();
621        assert_eq!(
622            lines,
623            vec![3, 4],
624            "the open tag and the property element, never a closing tag"
625        );
626        assert!(facts
627            .refs
628            .iter()
629            .all(|r| r.qualified.as_deref() == Some("Demo.Controls.ShipPanel")));
630    }
631
632    #[test]
633    fn a_clr_namespace_drops_its_assembly_suffix() {
634        let src = "<Page xmlns:local=\"clr-namespace:Demo.Controls;assembly=Demo\">\n  <local:ShipPanel />\n</Page>\n";
635        let facts = markup_facts("a.xaml", src);
636        assert_eq!(
637            facts.refs[0].qualified.as_deref(),
638            Some("Demo.Controls.ShipPanel")
639        );
640    }
641
642    #[test]
643    fn a_named_markup_extension_property_is_not_a_binding_path() {
644        let src = "<Page>\n  <T A=\"{Binding RelativeSource={RelativeSource Self}}\" B=\"{Binding Path=Vm.Name, Mode=OneWay}\" />\n</Page>\n";
645        let facts = markup_facts("a.xaml", src);
646        assert_eq!(
647            facts.names.iter().map(|n| n.name.as_str()).collect::<Vec<_>>(),
648            vec!["Vm.Name"],
649            "`RelativeSource=` is a property name, not a path; `Path=` is consumed and the dotted path kept whole"
650        );
651    }
652
653    #[test]
654    fn a_markup_file_with_no_x_class_declares_nothing_and_binds_nothing() {
655        let facts = markup_facts(
656            "a.xaml",
657            "<ResourceDictionary>\n  <T V=\"{x:Bind Orphan}\" />\n</ResourceDictionary>\n",
658        );
659        assert!(facts.defs.is_empty());
660        assert!(
661            facts.refs.is_empty(),
662            "x:Bind with no x:Class root has no type to resolve against"
663        );
664        assert!(facts
665            .names
666            .iter()
667            .any(|n| n.name == "Orphan" && n.kind == "markup-binding"));
668    }
669}