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.
200#[allow(
201    clippy::too_many_lines,
202    reason = "one single-pass scan collecting every markup fact in the fixed scan order later resolution depends on"
203)]
204fn scan_xaml(text: &str) -> XamlScan {
205    let mut scan = XamlScan {
206        names: Vec::new(),
207        pending: Vec::new(),
208        prefixes: Vec::new(),
209        decl: None,
210    };
211
212    for (i, line) in text.split('\n').enumerate() {
213        let line_no = i + 1;
214        let bytes = line.as_bytes();
215        let mut pos = 0usize;
216        while pos < line.len() {
217            if !line.is_char_boundary(pos) {
218                pos += 1;
219                continue;
220            }
221            let rest = &line[pos..];
222            if rest.starts_with(X_CLASS_ATTR) {
223                let Some((value, next)) = quoted(line, pos + X_CLASS_ATTR.len()) else {
224                    break;
225                };
226                if !value.is_empty() {
227                    scan.names.push(NameRecord {
228                        name: value.to_string(),
229                        kind: "markup-class".to_string(),
230                        line: line_no,
231                        owner: String::new(),
232                    });
233                    if scan.decl.is_none() {
234                        scan.decl = Some((value.to_string(), line_no));
235                    }
236                }
237                pos = next;
238                continue;
239            }
240            if rest.starts_with(X_NAME_ATTR) {
241                let Some((value, next)) = quoted(line, pos + X_NAME_ATTR.len()) else {
242                    break;
243                };
244                if !value.is_empty() {
245                    scan.names.push(NameRecord {
246                        name: value.to_string(),
247                        kind: "markup-name".to_string(),
248                        line: line_no,
249                        owner: String::new(),
250                    });
251                }
252                pos = next;
253                continue;
254            }
255            if rest.starts_with(XMLNS_PREFIX) {
256                let mut j = pos + XMLNS_PREFIX.len();
257                let start = j;
258                while j < bytes.len() && is_prefix_char(bytes[j]) {
259                    j += 1;
260                }
261                if j == start || !line[j..].starts_with("=\"") {
262                    pos = if j > pos { j } else { pos + 1 };
263                    continue;
264                }
265                let prefix = line[start..j].to_string();
266                let Some((value, next)) = quoted(line, j + 2) else {
267                    break;
268                };
269                let ns = if let Some(rest) = value.strip_prefix(CLR_NAMESPACE) {
270                    Some(match rest.find(';') {
271                        Some(semi) => &rest[..semi],
272                        None => rest,
273                    })
274                } else {
275                    value.strip_prefix(USING_NAMESPACE)
276                };
277                if let Some(ns) = ns {
278                    if !ns.is_empty() && !scan.prefixes.iter().any(|(p, _)| p == &prefix) {
279                        scan.prefixes.push((prefix, ns.to_string()));
280                    }
281                }
282                pos = next;
283                continue;
284            }
285            if rest.starts_with(BINDING_OPEN) || rest.starts_with(XBIND_OPEN) {
286                let bind = rest.starts_with(XBIND_OPEN);
287                let from = pos
288                    + if bind {
289                        XBIND_OPEN.len()
290                    } else {
291                        BINDING_OPEN.len()
292                    };
293                let (path, next) = binding_path(line, from);
294                if !path.is_empty() {
295                    scan.names.push(NameRecord {
296                        name: path.to_string(),
297                        kind: "markup-binding".to_string(),
298                        line: line_no,
299                        owner: String::new(),
300                    });
301                    if bind {
302                        scan.pending.push(Pending::Bind {
303                            path: path.to_string(),
304                            line: line_no,
305                        });
306                    }
307                }
308                pos = if next > pos { next } else { pos + 1 };
309                continue;
310            }
311            if bytes[pos] == b'<' {
312                if let Some((prefix, local, next)) = prefixed_element(line, pos + 1) {
313                    scan.pending.push(Pending::Element {
314                        prefix: prefix.to_string(),
315                        local: local.to_string(),
316                        line: line_no,
317                    });
318                    pos = next;
319                    continue;
320                }
321            }
322            pos += 1;
323        }
324    }
325    scan
326}
327
328// `<data name="Key" xml:space="preserve">` -- the `<data` anchor is what keeps
329// this off every other `name="` attribute a resource file can carry.
330fn scan_resw(text: &str) -> Vec<NameRecord> {
331    let mut out = Vec::new();
332    for (i, line) in text.split('\n').enumerate() {
333        let mut pos = 0usize;
334        loop {
335            let Some(data) = line[pos..].find("<data") else {
336                break;
337            };
338            let data = pos + data;
339            let Some(attr) = line[data..].find(RESW_KEY_ATTR) else {
340                break;
341            };
342            let start = data + attr + RESW_KEY_ATTR.len();
343            let Some(offset) = line[start..].find('"') else {
344                break;
345            };
346            let name = &line[start..start + offset];
347            if !name.is_empty() {
348                out.push(NameRecord {
349                    name: name.to_string(),
350                    kind: "resource-key".to_string(),
351                    line: i + 1,
352                    owner: String::new(),
353                });
354            }
355            pos = start + offset + 1;
356        }
357    }
358    out
359}
360
361/// The name facts a markup or resource file contributes. `owner` is empty on
362/// every entry -- nothing in a markup file declares a member of a graph def.
363pub fn markup_names(rel: &str, text: &str) -> Vec<NameRecord> {
364    if is_resource(rel) {
365        scan_resw(text)
366    } else {
367        scan_xaml(text).names
368    }
369}
370
371// A dotted declaration splits into the namespace and the simple name the C#
372// half of the same partial type carries; an undotted one is a type in the
373// global namespace, exactly as a C# def with an empty `namespace` is.
374fn split_qualified(qualified: &str) -> (&str, &str) {
375    match qualified.rfind('.') {
376        Some(dot) => (&qualified[..dot], &qualified[dot + 1..]),
377        None => ("", qualified),
378    }
379}
380
381fn type_ref(kind: &str, qualified: &str, member: Option<String>, line: usize) -> MarkupRef {
382    let (_, name) = split_qualified(qualified);
383    MarkupRef {
384        kind: kind.to_string(),
385        name: name.to_string(),
386        qualified: if qualified.contains('.') {
387            Some(qualified.to_string())
388        } else {
389            None
390        },
391        member,
392        line,
393    }
394}
395
396/// The graph facts a markup file contributes.
397///
398///   `x:Class="Ns.TheControl"` -> a def with the SAME id the code-behind's
399///   `partial class TheControl` produces. `build_def_index` merges two defs
400///   sharing an id into one record with two declaring sites, so the markup and
401///   the code-behind become two locations of one symbol for free -- which is
402///   what makes `refs` list both and `impact` seed from either.
403///
404///   `<local:TheControl .../>` where `xmlns:local` names a CLR namespace -> an
405///   ordinary `uses-type` ref to `Ns.TheControl`. Without this edge a control
406///   instantiated only in markup would have no inbound reference at all.
407///
408///   `{x:Bind Prop}` -> a `uses-member` ref against the file's OWN `x:Class`
409///   type. `x:Bind`'s root is that type by language rule, so the binding target
410///   is statically known and earns an edge. `{Binding Prop}` binds against a
411///   DataContext assigned at runtime, which markup alone cannot name, so it
412///   contributes the `markup-binding` NAME fact only -- the never-guess rule
413///   applies to a binding exactly as it applies to a call.
414pub fn markup_facts(rel: &str, text: &str) -> MarkupFacts {
415    if is_resource(rel) {
416        return MarkupFacts {
417            defs: Vec::new(),
418            refs: Vec::new(),
419            names: scan_resw(text),
420        };
421    }
422    let scan = scan_xaml(text);
423
424    let mut defs = Vec::new();
425    if let Some((qualified, line)) = &scan.decl {
426        let (namespace, name) = split_qualified(qualified);
427        defs.push(MarkupDef {
428            id: qualified.clone(),
429            name: name.to_string(),
430            namespace: namespace.to_string(),
431            kind: "class".to_string(),
432            line: *line,
433        });
434    }
435
436    let mut refs = Vec::new();
437    for p in &scan.pending {
438        match p {
439            Pending::Element {
440                prefix,
441                local,
442                line,
443            } => {
444                let Some((_, ns)) = scan.prefixes.iter().find(|(name, _)| name == prefix) else {
445                    continue;
446                };
447                refs.push(type_ref("uses-type", &format!("{ns}.{local}"), None, *line));
448            }
449            Pending::Bind { path, line } => {
450                let Some((qualified, _)) = &scan.decl else {
451                    continue;
452                };
453                // The bound member is the FIRST path segment: `{x:Bind Vm.Name}`
454                // reads `Vm` off the x:Class type and everything after it off
455                // whatever `Vm` returns, which is a type this scan cannot name.
456                let member = match path.find('.') {
457                    Some(dot) => &path[..dot],
458                    None => path.as_str(),
459                };
460                refs.push(type_ref(
461                    "uses-member",
462                    qualified,
463                    Some(member.to_string()),
464                    *line,
465                ));
466            }
467        }
468    }
469
470    MarkupFacts {
471        defs,
472        refs,
473        names: scan.names,
474    }
475}
476
477#[cfg(test)]
478mod tests {
479    use super::*;
480
481    fn names(rel: &str, text: &str) -> Vec<(String, String, usize)> {
482        markup_names(rel, text)
483            .into_iter()
484            .map(|n| (n.name, n.kind, n.line))
485            .collect()
486    }
487
488    #[test]
489    fn xaml_records_class_and_every_element_name_in_source_order() {
490        let src = "<UserControl\n\tx:Class=\"Gadgets.PanelView\">\n\t<Button x:Name=\"ShipButton\" />\n</UserControl>\n";
491        assert_eq!(
492            names("src/Panel.xaml", src),
493            vec![
494                (
495                    "Gadgets.PanelView".to_string(),
496                    "markup-class".to_string(),
497                    2
498                ),
499                ("ShipButton".to_string(), "markup-name".to_string(), 3),
500            ]
501        );
502    }
503
504    #[test]
505    fn xaml_takes_two_names_on_one_line_left_to_right() {
506        let src = "<Grid x:Name=\"Outer\"><Grid x:Name=\"Inner\" /></Grid>\n";
507        assert_eq!(
508            names("a.xaml", src),
509            vec![
510                ("Outer".to_string(), "markup-name".to_string(), 1),
511                ("Inner".to_string(), "markup-name".to_string(), 1)
512            ]
513        );
514    }
515
516    #[test]
517    fn xaml_skips_an_empty_value_and_an_unterminated_attribute() {
518        assert!(names("a.xaml", "<Grid x:Name=\"\" />\n").is_empty());
519        assert!(names("a.xaml", "<Grid x:Name=\"Unclosed\n").is_empty());
520    }
521
522    #[test]
523    fn resw_keys_come_from_data_elements_only() {
524        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";
525        assert_eq!(
526            names("a.resw", src),
527            vec![("Ship.Content".to_string(), "resource-key".to_string(), 3)]
528        );
529    }
530
531    // (2026-08-23) -- `.resx` (.NET desktop/web resources) carries the exact
532    // same `<data name="Key">` schema as `.resw` (WinRT resources) and gets
533    // identical treatment: same scan, same `resource-key` kind, same tier.
534    #[test]
535    fn resx_keys_come_from_data_elements_only_same_as_resw() {
536        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";
537        assert_eq!(
538            names("a.resx", src),
539            vec![("Ship.Content".to_string(), "resource-key".to_string(), 3)]
540        );
541    }
542
543    #[test]
544    fn markup_extensions_are_xaml_resw_and_resx_only() {
545        assert!(is_markup("a/b.xaml"));
546        assert!(is_markup("a/b.resw"));
547        assert!(is_markup("a/b.resx"));
548        assert!(!is_markup("a/b.xaml.cs"));
549        assert!(!is_markup("a/b.xml"));
550    }
551
552    // --- markup graph facts ------------------------------------------------
553
554    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";
555    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";
556
557    #[test]
558    fn x_class_becomes_a_def_with_the_code_behind_id() {
559        let facts = markup_facts("Controls/ShipPanel.xaml", CONTROL);
560        assert_eq!(
561            facts.defs,
562            vec![MarkupDef {
563                id: "Demo.Controls.ShipPanel".to_string(),
564                name: "ShipPanel".to_string(),
565                namespace: "Demo.Controls".to_string(),
566                kind: "class".to_string(),
567                line: 2,
568            }]
569        );
570    }
571
572    #[test]
573    fn a_prefixed_element_becomes_a_uses_type_ref_at_the_mapped_namespace() {
574        let facts = markup_facts("Pages/HomePage.xaml", PAGE);
575        assert_eq!(
576            facts.refs,
577            vec![MarkupRef {
578                kind: "uses-type".to_string(),
579                name: "ShipPanel".to_string(),
580                qualified: Some("Demo.Controls.ShipPanel".to_string()),
581                member: None,
582                line: 4,
583            }]
584        );
585    }
586
587    #[test]
588    fn x_bind_earns_a_member_ref_against_the_files_own_class_and_binding_does_not() {
589        let control = markup_facts("Controls/ShipPanel.xaml", CONTROL);
590        assert_eq!(
591            control.refs,
592            vec![MarkupRef {
593                kind: "uses-member".to_string(),
594                name: "ShipPanel".to_string(),
595                qualified: Some("Demo.Controls.ShipPanel".to_string()),
596                member: Some("Caption".to_string()),
597                line: 4,
598            }]
599        );
600        let page = markup_facts("Pages/HomePage.xaml", PAGE);
601        assert!(
602            page.refs.iter().all(|r| r.kind != "uses-member"),
603            "a runtime DataContext is not statically known, so `{{Binding Title}}` earns a name fact and no edge"
604        );
605        assert!(page
606            .names
607            .iter()
608            .any(|n| n.name == "Title" && n.kind == "markup-binding" && n.line == 4));
609    }
610
611    #[test]
612    fn an_unmapped_prefix_and_a_bare_element_contribute_nothing() {
613        let facts = markup_facts("a.xaml", "<Page xmlns:d=\"http://schemas.microsoft.com/expression/blend/2008\">\n  <d:DesignHost />\n  <Grid />\n</Page>\n");
614        assert!(
615            facts.refs.is_empty(),
616            "a prefix that names no CLR namespace is skipped, never guessed at"
617        );
618    }
619
620    #[test]
621    fn a_closing_tag_never_doubles_the_instantiation_and_a_property_element_reads_as_its_type() {
622        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";
623        let facts = markup_facts("a.xaml", src);
624        let lines: Vec<usize> = facts.refs.iter().map(|r| r.line).collect();
625        assert_eq!(
626            lines,
627            vec![3, 4],
628            "the open tag and the property element, never a closing tag"
629        );
630        assert!(facts
631            .refs
632            .iter()
633            .all(|r| r.qualified.as_deref() == Some("Demo.Controls.ShipPanel")));
634    }
635
636    #[test]
637    fn a_clr_namespace_drops_its_assembly_suffix() {
638        let src = "<Page xmlns:local=\"clr-namespace:Demo.Controls;assembly=Demo\">\n  <local:ShipPanel />\n</Page>\n";
639        let facts = markup_facts("a.xaml", src);
640        assert_eq!(
641            facts.refs[0].qualified.as_deref(),
642            Some("Demo.Controls.ShipPanel")
643        );
644    }
645
646    #[test]
647    fn a_named_markup_extension_property_is_not_a_binding_path() {
648        let src = "<Page>\n  <T A=\"{Binding RelativeSource={RelativeSource Self}}\" B=\"{Binding Path=Vm.Name, Mode=OneWay}\" />\n</Page>\n";
649        let facts = markup_facts("a.xaml", src);
650        assert_eq!(
651            facts.names.iter().map(|n| n.name.as_str()).collect::<Vec<_>>(),
652            vec!["Vm.Name"],
653            "`RelativeSource=` is a property name, not a path; `Path=` is consumed and the dotted path kept whole"
654        );
655    }
656
657    #[test]
658    fn a_markup_file_with_no_x_class_declares_nothing_and_binds_nothing() {
659        let facts = markup_facts(
660            "a.xaml",
661            "<ResourceDictionary>\n  <T V=\"{x:Bind Orphan}\" />\n</ResourceDictionary>\n",
662        );
663        assert!(facts.defs.is_empty());
664        assert!(
665            facts.refs.is_empty(),
666            "x:Bind with no x:Class root has no type to resolve against"
667        );
668        assert!(facts
669            .names
670            .iter()
671            .any(|n| n.name == "Orphan" && n.kind == "markup-binding"));
672    }
673}