1use crate::extract::NameRecord;
13
14pub 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
36const CLR_NAMESPACE: &str = "clr-namespace:";
42const USING_NAMESPACE: &str = "using:";
43
44#[derive(Debug, Clone, PartialEq)]
47pub struct MarkupDef {
48 pub id: String,
50 pub name: String,
52 pub namespace: String,
54 pub kind: String,
56 pub line: usize,
58}
59
60#[derive(Debug, Clone, PartialEq)]
66pub struct MarkupRef {
67 pub kind: String,
69 pub name: String,
71 pub qualified: Option<String>,
73 pub member: Option<String>,
75 pub line: usize,
77}
78
79#[derive(Debug, Clone, Default, PartialEq)]
80pub struct MarkupFacts {
82 pub defs: Vec<MarkupDef>,
84 pub refs: Vec<MarkupRef>,
86 pub names: Vec<NameRecord>,
88}
89
90fn is_name_char(ch: u8) -> bool {
91 ch.is_ascii_alphanumeric() || ch == b'_'
92}
93
94fn 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
106fn 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
117fn 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
143fn 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
168enum 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#[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
328fn 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
361pub 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
371fn 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
396pub 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 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 #[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 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}