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
188fn 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
324fn 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
357pub 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
367fn 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
392pub 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 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 #[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 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}