1use std::path::Path;
11use std::sync::LazyLock;
12
13use oxc_span::Span;
14
15use crate::asset_url::normalize_asset_url;
16use crate::sfc_template::angular;
17use crate::{
18 AngularTemplateMemberAccessFact, ImportInfo, ImportedName, MemberAccess, ModuleInfo,
19 SemanticFact,
20};
21use fallow_types::discover::FileId;
22
23static HTML_COMMENT_RE: LazyLock<regex::Regex> =
25 LazyLock::new(|| crate::static_regex(r"(?s)<!--.*?-->"));
26
27static SCRIPT_SRC_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
31 crate::static_regex(r#"(?si)<script\b(?:[^>"']|"[^"]*"|'[^']*')*?\bsrc\s*=\s*["']([^"']+)["']"#)
32});
33
34static LINK_HREF_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
38 crate::static_regex(
39 r#"(?si)<link\b(?:[^>"']|"[^"]*"|'[^']*')*?\brel\s*=\s*["'](stylesheet|modulepreload)["'](?:[^>"']|"[^"]*"|'[^']*')*?\bhref\s*=\s*["']([^"']+)["']"#,
40 )
41});
42
43static LINK_HREF_REVERSE_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
45 crate::static_regex(
46 r#"(?si)<link\b(?:[^>"']|"[^"]*"|'[^']*')*?\bhref\s*=\s*["']([^"']+)["'](?:[^>"']|"[^"]*"|'[^']*')*?\brel\s*=\s*["'](stylesheet|modulepreload)["']"#,
47 )
48});
49
50pub(crate) fn is_html_file(path: &Path) -> bool {
52 path.extension()
53 .and_then(|e| e.to_str())
54 .is_some_and(|ext| ext == "html")
55}
56
57pub(crate) fn is_remote_url(src: &str) -> bool {
59 src.starts_with("http://")
60 || src.starts_with("https://")
61 || src.starts_with("//")
62 || src.starts_with("data:")
63}
64
65pub(crate) fn is_template_placeholder(value: &str) -> bool {
80 value.contains("{{") || value.contains("###")
81}
82
83pub(crate) fn collect_asset_refs(source: &str) -> Vec<String> {
90 let stripped = HTML_COMMENT_RE.replace_all(source, "");
91 let mut refs: Vec<String> = Vec::new();
92
93 for cap in SCRIPT_SRC_RE.captures_iter(&stripped) {
94 if let Some(m) = cap.get(1) {
95 let src = m.as_str().trim();
96 if !src.is_empty() && !is_remote_url(src) && !is_template_placeholder(src) {
97 refs.push(src.to_string());
98 }
99 }
100 }
101
102 for cap in LINK_HREF_RE.captures_iter(&stripped) {
103 if let Some(m) = cap.get(2) {
104 let href = m.as_str().trim();
105 if !href.is_empty() && !is_remote_url(href) && !is_template_placeholder(href) {
106 refs.push(href.to_string());
107 }
108 }
109 }
110 for cap in LINK_HREF_REVERSE_RE.captures_iter(&stripped) {
111 if let Some(m) = cap.get(1) {
112 let href = m.as_str().trim();
113 if !href.is_empty() && !is_remote_url(href) && !is_template_placeholder(href) {
114 refs.push(href.to_string());
115 }
116 }
117 }
118
119 refs
120}
121
122static CUSTOM_ELEMENT_TAG_RE: std::sync::LazyLock<regex::Regex> =
127 std::sync::LazyLock::new(|| crate::static_regex(r"</?\s*([a-z][a-z0-9]*-[a-z0-9-]*)"));
128
129pub(crate) fn collect_custom_element_tags(source: &str) -> Vec<String> {
135 let stripped = HTML_COMMENT_RE.replace_all(source, "");
136 let mut tags: Vec<String> = Vec::new();
137 for cap in CUSTOM_ELEMENT_TAG_RE.captures_iter(&stripped) {
138 if let Some(m) = cap.get(1) {
139 let tag = m.as_str();
140 if !tags.iter().any(|t| t == tag) {
141 tags.push(tag.to_string());
142 }
143 }
144 }
145 tags
146}
147
148#[cfg(test)]
150pub(crate) fn parse_html_to_module(file_id: FileId, source: &str, content_hash: u64) -> ModuleInfo {
151 parse_html_to_module_with_complexity(file_id, source, content_hash, false)
152}
153
154struct HtmlModuleParts {
157 imports: Vec<ImportInfo>,
158 member_accesses: Vec<MemberAccess>,
159 semantic_facts: Vec<SemanticFact>,
160 security_sinks: Vec<fallow_types::extract::SinkSite>,
161 angular_used_selectors: Vec<String>,
162 has_dynamic_component_render: bool,
163 complexity: Vec<fallow_types::extract::FunctionComplexity>,
164}
165
166fn collect_html_module_parts(source: &str, need_complexity: bool) -> HtmlModuleParts {
170 let mut imports: Vec<ImportInfo> = collect_asset_refs(source)
171 .into_iter()
172 .map(|raw| ImportInfo {
173 source: normalize_asset_url(&raw),
174 imported_name: ImportedName::SideEffect,
175 local_name: String::new(),
176 is_type_only: false,
177 from_style: false,
178 span: Span::default(),
179 source_span: Span::default(),
180 })
181 .collect();
182
183 imports.sort_unstable_by(|a, b| a.source.cmp(&b.source));
184 imports.dedup_by(|a, b| a.source == b.source);
185
186 let angular::AngularTemplateRefs {
187 identifiers,
188 member_accesses: template_member_accesses,
189 security_sinks,
190 } = angular::collect_angular_template_refs(source);
191 let identifiers: Vec<String> = identifiers.into_iter().collect();
192 let semantic_facts: Vec<SemanticFact> = identifiers
193 .iter()
194 .cloned()
195 .map(|member| {
196 SemanticFact::AngularTemplateMemberAccess(AngularTemplateMemberAccessFact { member })
197 })
198 .collect();
199 let member_accesses = template_member_accesses;
200
201 let angular_used_selectors = angular::collect_angular_used_selectors(source);
206 let has_dynamic_component_render = source.contains("ngComponentOutlet");
207
208 let complexity = if need_complexity {
209 crate::template_complexity::compute_angular_template_complexity(source)
210 .into_iter()
211 .collect()
212 } else {
213 Vec::new()
214 };
215
216 HtmlModuleParts {
217 imports,
218 member_accesses,
219 semantic_facts,
220 security_sinks,
221 angular_used_selectors,
222 has_dynamic_component_render,
223 complexity,
224 }
225}
226
227pub(crate) fn parse_html_to_module_with_complexity(
229 file_id: FileId,
230 source: &str,
231 content_hash: u64,
232 need_complexity: bool,
233) -> ModuleInfo {
234 let parsed_suppressions = crate::suppress::parse_suppressions_from_source(source);
235 let parts = collect_html_module_parts(source, need_complexity);
236 html_module_info(file_id, content_hash, source, parsed_suppressions, parts)
237}
238
239fn html_module_info(
243 file_id: FileId,
244 content_hash: u64,
245 source: &str,
246 parsed_suppressions: crate::suppress::ParsedSuppressions,
247 parts: HtmlModuleParts,
248) -> ModuleInfo {
249 let HtmlModuleParts {
250 imports,
251 member_accesses,
252 semantic_facts,
253 security_sinks,
254 angular_used_selectors,
255 has_dynamic_component_render,
256 complexity,
257 } = parts;
258
259 ModuleInfo {
260 file_id,
261 exports: Vec::new(),
262 imports,
263 re_exports: Vec::new(),
264 dynamic_imports: Vec::new(),
265 dynamic_import_patterns: Vec::new(),
266 require_calls: Vec::new(),
267 package_path_references: Box::default(),
268 member_accesses,
269 semantic_facts: semantic_facts.into(),
270 whole_object_uses: Box::default(),
271 has_cjs_exports: false,
272 has_angular_component_template_url: false,
273 content_hash,
274 suppressions: parsed_suppressions.suppressions,
275 unknown_suppression_kinds: parsed_suppressions.unknown_kinds,
276 unused_import_bindings: Vec::new(),
277 type_referenced_import_bindings: Vec::new(),
278 value_referenced_import_bindings: Vec::new(),
279 line_offsets: fallow_types::extract::compute_line_offsets(source),
280 complexity,
281 flag_uses: Vec::new(),
282 class_heritage: vec![],
283 exported_factory_returns: Box::default(),
284 type_member_types: Box::default(),
285 injection_tokens: vec![],
286 local_type_declarations: Vec::new(),
287 public_signature_type_references: Vec::new(),
288 namespace_object_aliases: Vec::new(),
289 iconify_prefixes: Vec::new(),
290 iconify_icon_names: Vec::new(),
291 auto_import_candidates: Vec::new(),
292 directives: Vec::new(),
293 client_only_dynamic_import_spans: Vec::new(),
294 security_sinks,
295 security_sinks_skipped: 0,
296 security_unresolved_callee_sites: Vec::new(),
297 tainted_bindings: Vec::new(),
298 sanitized_sink_args: Vec::new(),
299 security_control_sites: Vec::new(),
300 callee_uses: Vec::new(),
301 misplaced_directives: Vec::new(),
302 inline_server_action_exports: Vec::new(),
303 di_key_sites: Vec::new(),
304 has_dynamic_provide: false,
305 referenced_import_bindings: Vec::new(),
306 component_props: Vec::new(),
307 has_props_attrs_fallthrough: false,
308 has_define_expose: false,
309 has_define_model: false,
310 has_unharvestable_props: false,
311 component_emits: Vec::new(),
312 angular_inputs: Vec::new(),
313 angular_outputs: Vec::new(),
314 angular_component_selectors: Vec::new(),
315 registered_custom_elements: Vec::new(),
316 used_custom_element_tags: collect_custom_element_tags(source),
321 angular_used_selectors,
322 angular_entry_component_refs: Vec::new(),
323 has_dynamic_component_render,
324 has_unharvestable_emits: false,
325 has_dynamic_emit: false,
326 has_emit_whole_object_use: false,
327 load_return_keys: Vec::new(),
328 has_unharvestable_load: false,
329 has_load_data_whole_use: false,
330 has_page_data_store_whole_use: false,
331 has_route_loader_data_whole_use: false,
332 component_functions: Vec::new(),
333 react_props: Vec::new(),
334 hook_uses: Vec::new(),
335 render_edges: Vec::new(),
336 svelte_dispatched_events: Vec::new(),
337 svelte_listened_events: Vec::new(),
338 has_dynamic_dispatch: false,
339 }
340}
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345
346 #[test]
347 fn is_html_file_html() {
348 assert!(is_html_file(Path::new("index.html")));
349 }
350
351 #[test]
352 fn is_html_file_nested() {
353 assert!(is_html_file(Path::new("pages/about.html")));
354 }
355
356 #[test]
357 fn is_html_file_rejects_htm() {
358 assert!(!is_html_file(Path::new("index.htm")));
359 }
360
361 #[test]
362 fn is_html_file_rejects_js() {
363 assert!(!is_html_file(Path::new("app.js")));
364 }
365
366 #[test]
367 fn is_html_file_rejects_ts() {
368 assert!(!is_html_file(Path::new("app.ts")));
369 }
370
371 #[test]
372 fn is_html_file_rejects_vue() {
373 assert!(!is_html_file(Path::new("App.vue")));
374 }
375
376 #[test]
377 fn remote_url_http() {
378 assert!(is_remote_url("http://example.com/script.js"));
379 }
380
381 #[test]
382 fn remote_url_https() {
383 assert!(is_remote_url("https://cdn.example.com/style.css"));
384 }
385
386 #[test]
387 fn remote_url_protocol_relative() {
388 assert!(is_remote_url("//cdn.example.com/lib.js"));
389 }
390
391 #[test]
392 fn remote_url_data() {
393 assert!(is_remote_url("data:text/javascript;base64,abc"));
394 }
395
396 #[test]
397 fn local_relative_not_remote() {
398 assert!(!is_remote_url("./src/entry.js"));
399 }
400
401 #[test]
402 fn local_root_relative_not_remote() {
403 assert!(!is_remote_url("/src/entry.js"));
404 }
405
406 #[test]
407 fn extracts_module_script_src() {
408 let info = parse_html_to_module(
409 FileId(0),
410 r#"<script type="module" src="./src/entry.js"></script>"#,
411 0,
412 );
413 assert_eq!(info.imports.len(), 1);
414 assert_eq!(info.imports[0].source, "./src/entry.js");
415 }
416
417 #[test]
418 fn extracts_plain_script_src() {
419 let info = parse_html_to_module(
420 FileId(0),
421 r#"<script src="./src/polyfills.js"></script>"#,
422 0,
423 );
424 assert_eq!(info.imports.len(), 1);
425 assert_eq!(info.imports[0].source, "./src/polyfills.js");
426 }
427
428 #[test]
429 fn extracts_multiple_scripts() {
430 let info = parse_html_to_module(
431 FileId(0),
432 r#"
433 <script type="module" src="./src/entry.js"></script>
434 <script src="./src/polyfills.js"></script>
435 "#,
436 0,
437 );
438 assert_eq!(info.imports.len(), 2);
439 }
440
441 #[test]
442 fn skips_inline_script() {
443 let info = parse_html_to_module(FileId(0), r#"<script>console.log("hello");</script>"#, 0);
444 assert!(info.imports.is_empty());
445 }
446
447 #[test]
448 fn skips_handlebars_placeholder_in_script_src() {
449 let info = parse_html_to_module(
450 FileId(0),
451 r#"<script src="{{rootURL}}assets/app.js"></script>
452 <script src="{{config.assetsPath}}vendor.js"></script>"#,
453 0,
454 );
455 assert!(
456 info.imports.is_empty(),
457 "Handlebars-placeholder script srcs should not enter the import graph; got {:?}",
458 info.imports
459 );
460 }
461
462 #[test]
463 fn skips_handlebars_placeholder_in_link_href() {
464 let info = parse_html_to_module(
465 FileId(0),
466 r#"<link rel="stylesheet" href="{{rootURL}}assets/app.css">"#,
467 0,
468 );
469 assert!(info.imports.is_empty());
470 }
471
472 #[test]
473 fn skips_ember_cli_blueprint_placeholder() {
474 let info = parse_html_to_module(
475 FileId(0),
476 r####"<script src="###APPNAME###/app.js"></script>"####,
477 0,
478 );
479 assert!(info.imports.is_empty());
480 }
481
482 #[test]
483 fn extracts_normal_specifier_alongside_placeholders() {
484 let info = parse_html_to_module(
485 FileId(0),
486 r#"<script src="{{rootURL}}assets/app.js"></script>
487 <script src="./src/main.ts"></script>"#,
488 0,
489 );
490 assert_eq!(info.imports.len(), 1);
491 assert_eq!(info.imports[0].source, "./src/main.ts");
492 }
493
494 #[test]
495 fn skips_remote_script() {
496 let info = parse_html_to_module(
497 FileId(0),
498 r#"<script src="https://cdn.example.com/lib.js"></script>"#,
499 0,
500 );
501 assert!(info.imports.is_empty());
502 }
503
504 #[test]
505 fn skips_protocol_relative_script() {
506 let info = parse_html_to_module(
507 FileId(0),
508 r#"<script src="//cdn.example.com/lib.js"></script>"#,
509 0,
510 );
511 assert!(info.imports.is_empty());
512 }
513
514 #[test]
515 fn extracts_stylesheet_link() {
516 let info = parse_html_to_module(
517 FileId(0),
518 r#"<link rel="stylesheet" href="./src/global.css" />"#,
519 0,
520 );
521 assert_eq!(info.imports.len(), 1);
522 assert_eq!(info.imports[0].source, "./src/global.css");
523 }
524
525 #[test]
526 fn extracts_modulepreload_link() {
527 let info = parse_html_to_module(
528 FileId(0),
529 r#"<link rel="modulepreload" href="./src/vendor.js" />"#,
530 0,
531 );
532 assert_eq!(info.imports.len(), 1);
533 assert_eq!(info.imports[0].source, "./src/vendor.js");
534 }
535
536 #[test]
537 fn extracts_link_with_reversed_attrs() {
538 let info = parse_html_to_module(
539 FileId(0),
540 r#"<link href="./src/global.css" rel="stylesheet" />"#,
541 0,
542 );
543 assert_eq!(info.imports.len(), 1);
544 assert_eq!(info.imports[0].source, "./src/global.css");
545 }
546
547 #[test]
548 fn bare_script_src_normalized_to_relative() {
549 let info = parse_html_to_module(FileId(0), r#"<script src="app.js"></script>"#, 0);
550 assert_eq!(info.imports.len(), 1);
551 assert_eq!(info.imports[0].source, "./app.js");
552 }
553
554 #[test]
555 fn bare_module_script_src_normalized_to_relative() {
556 let info = parse_html_to_module(
557 FileId(0),
558 r#"<script type="module" src="main.ts"></script>"#,
559 0,
560 );
561 assert_eq!(info.imports.len(), 1);
562 assert_eq!(info.imports[0].source, "./main.ts");
563 }
564
565 #[test]
566 fn bare_stylesheet_link_href_normalized_to_relative() {
567 let info = parse_html_to_module(
568 FileId(0),
569 r#"<link rel="stylesheet" href="styles.css" />"#,
570 0,
571 );
572 assert_eq!(info.imports.len(), 1);
573 assert_eq!(info.imports[0].source, "./styles.css");
574 }
575
576 #[test]
577 fn bare_link_href_reversed_attrs_normalized_to_relative() {
578 let info = parse_html_to_module(
579 FileId(0),
580 r#"<link href="styles.css" rel="stylesheet" />"#,
581 0,
582 );
583 assert_eq!(info.imports.len(), 1);
584 assert_eq!(info.imports[0].source, "./styles.css");
585 }
586
587 #[test]
588 fn bare_modulepreload_link_href_normalized_to_relative() {
589 let info = parse_html_to_module(
590 FileId(0),
591 r#"<link rel="modulepreload" href="vendor.js" />"#,
592 0,
593 );
594 assert_eq!(info.imports.len(), 1);
595 assert_eq!(info.imports[0].source, "./vendor.js");
596 }
597
598 #[test]
599 fn bare_asset_with_subdir_normalized_to_relative() {
600 let info = parse_html_to_module(FileId(0), r#"<script src="assets/app.js"></script>"#, 0);
601 assert_eq!(info.imports.len(), 1);
602 assert_eq!(info.imports[0].source, "./assets/app.js");
603 }
604
605 #[test]
606 fn root_absolute_script_src_unchanged() {
607 let info = parse_html_to_module(FileId(0), r#"<script src="/src/main.ts"></script>"#, 0);
608 assert_eq!(info.imports.len(), 1);
609 assert_eq!(info.imports[0].source, "/src/main.ts");
610 }
611
612 #[test]
613 fn parent_relative_script_src_unchanged() {
614 let info = parse_html_to_module(
615 FileId(0),
616 r#"<script src="../shared/vendor.js"></script>"#,
617 0,
618 );
619 assert_eq!(info.imports.len(), 1);
620 assert_eq!(info.imports[0].source, "../shared/vendor.js");
621 }
622
623 #[test]
624 fn skips_preload_link() {
625 let info = parse_html_to_module(
626 FileId(0),
627 r#"<link rel="preload" href="./src/font.woff2" as="font" />"#,
628 0,
629 );
630 assert!(info.imports.is_empty());
631 }
632
633 #[test]
634 fn skips_icon_link() {
635 let info =
636 parse_html_to_module(FileId(0), r#"<link rel="icon" href="./favicon.ico" />"#, 0);
637 assert!(info.imports.is_empty());
638 }
639
640 #[test]
641 fn skips_remote_stylesheet() {
642 let info = parse_html_to_module(
643 FileId(0),
644 r#"<link rel="stylesheet" href="https://fonts.googleapis.com/css" />"#,
645 0,
646 );
647 assert!(info.imports.is_empty());
648 }
649
650 #[test]
651 fn skips_commented_out_script() {
652 let info = parse_html_to_module(
653 FileId(0),
654 r#"<!-- <script src="./old.js"></script> -->
655 <script src="./new.js"></script>"#,
656 0,
657 );
658 assert_eq!(info.imports.len(), 1);
659 assert_eq!(info.imports[0].source, "./new.js");
660 }
661
662 #[test]
663 fn skips_commented_out_link() {
664 let info = parse_html_to_module(
665 FileId(0),
666 r#"<!-- <link rel="stylesheet" href="./old.css" /> -->
667 <link rel="stylesheet" href="./new.css" />"#,
668 0,
669 );
670 assert_eq!(info.imports.len(), 1);
671 assert_eq!(info.imports[0].source, "./new.css");
672 }
673
674 #[test]
675 fn handles_multiline_script_tag() {
676 let info = parse_html_to_module(
677 FileId(0),
678 "<script\n type=\"module\"\n src=\"./src/entry.js\"\n></script>",
679 0,
680 );
681 assert_eq!(info.imports.len(), 1);
682 assert_eq!(info.imports[0].source, "./src/entry.js");
683 }
684
685 #[test]
686 fn handles_multiline_link_tag() {
687 let info = parse_html_to_module(
688 FileId(0),
689 "<link\n rel=\"stylesheet\"\n href=\"./src/global.css\"\n/>",
690 0,
691 );
692 assert_eq!(info.imports.len(), 1);
693 assert_eq!(info.imports[0].source, "./src/global.css");
694 }
695
696 #[test]
697 fn full_vite_html() {
698 let info = parse_html_to_module(
699 FileId(0),
700 r#"<!doctype html>
701<html>
702 <head>
703 <link rel="stylesheet" href="./src/global.css" />
704 <link rel="icon" href="/favicon.ico" />
705 </head>
706 <body>
707 <div id="app"></div>
708 <script type="module" src="./src/entry.js"></script>
709 </body>
710</html>"#,
711 0,
712 );
713 assert_eq!(info.imports.len(), 2);
714 let sources: Vec<&str> = info.imports.iter().map(|i| i.source.as_str()).collect();
715 assert!(sources.contains(&"./src/global.css"));
716 assert!(sources.contains(&"./src/entry.js"));
717 }
718
719 #[test]
720 fn empty_html() {
721 let info = parse_html_to_module(FileId(0), "", 0);
722 assert!(info.imports.is_empty());
723 }
724
725 #[test]
726 fn html_with_no_assets() {
727 let info = parse_html_to_module(
728 FileId(0),
729 r"<!doctype html><html><body><h1>Hello</h1></body></html>",
730 0,
731 );
732 assert!(info.imports.is_empty());
733 }
734
735 #[test]
736 fn single_quoted_attributes() {
737 let info = parse_html_to_module(FileId(0), r"<script src='./src/entry.js'></script>", 0);
738 assert_eq!(info.imports.len(), 1);
739 assert_eq!(info.imports[0].source, "./src/entry.js");
740 }
741
742 #[test]
743 fn all_imports_are_side_effect() {
744 let info = parse_html_to_module(
745 FileId(0),
746 r#"<script src="./entry.js"></script>
747 <link rel="stylesheet" href="./style.css" />"#,
748 0,
749 );
750 for imp in &info.imports {
751 assert!(matches!(imp.imported_name, ImportedName::SideEffect));
752 assert!(imp.local_name.is_empty());
753 assert!(!imp.is_type_only);
754 }
755 }
756
757 #[test]
758 fn suppression_comments_extracted() {
759 let info = parse_html_to_module(
760 FileId(0),
761 "<!-- fallow-ignore-file -->\n<script src=\"./entry.js\"></script>",
762 0,
763 );
764 assert_eq!(info.imports.len(), 1);
765 }
766
767 #[test]
768 fn angular_template_extracts_member_refs() {
769 let info = parse_html_to_module(
770 FileId(0),
771 "<h1>{{ title() }}</h1>\n\
772 <p [class.highlighted]=\"isHighlighted\">{{ greeting() }}</p>\n\
773 <button (click)=\"onButtonClick()\">Toggle</button>",
774 0,
775 );
776 let fact_names: rustc_hash::FxHashSet<&str> = info
777 .semantic_facts
778 .iter()
779 .filter_map(|fact| {
780 if let SemanticFact::AngularTemplateMemberAccess(access) = fact {
781 Some(access.member.as_str())
782 } else {
783 None
784 }
785 })
786 .collect();
787 assert!(fact_names.contains("title"), "should contain 'title'");
788 assert!(
789 fact_names.contains("isHighlighted"),
790 "should contain 'isHighlighted'"
791 );
792 assert!(fact_names.contains("greeting"), "should contain 'greeting'");
793 assert!(
794 fact_names.contains("onButtonClick"),
795 "should contain 'onButtonClick'"
796 );
797 assert!(
798 info.member_accesses.is_empty(),
799 "Angular template refs should emit typed facts instead of member accesses: {:?}",
800 info.member_accesses
801 );
802 }
803
804 #[test]
805 fn plain_html_no_angular_refs() {
806 let info = parse_html_to_module(
807 FileId(0),
808 "<!doctype html><html><body><h1>Hello</h1></body></html>",
809 0,
810 );
811 assert!(info.member_accesses.is_empty());
812 }
813}