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 injection_tokens: vec![],
285 local_type_declarations: Vec::new(),
286 public_signature_type_references: Vec::new(),
287 namespace_object_aliases: Vec::new(),
288 iconify_prefixes: Vec::new(),
289 iconify_icon_names: Vec::new(),
290 auto_import_candidates: Vec::new(),
291 directives: Vec::new(),
292 client_only_dynamic_import_spans: Vec::new(),
293 security_sinks,
294 security_sinks_skipped: 0,
295 security_unresolved_callee_sites: Vec::new(),
296 tainted_bindings: Vec::new(),
297 sanitized_sink_args: Vec::new(),
298 security_control_sites: Vec::new(),
299 callee_uses: Vec::new(),
300 misplaced_directives: Vec::new(),
301 inline_server_action_exports: Vec::new(),
302 di_key_sites: Vec::new(),
303 has_dynamic_provide: false,
304 referenced_import_bindings: Vec::new(),
305 component_props: Vec::new(),
306 has_props_attrs_fallthrough: false,
307 has_define_expose: false,
308 has_define_model: false,
309 has_unharvestable_props: false,
310 component_emits: Vec::new(),
311 angular_inputs: Vec::new(),
312 angular_outputs: Vec::new(),
313 angular_component_selectors: Vec::new(),
314 registered_custom_elements: Vec::new(),
315 used_custom_element_tags: collect_custom_element_tags(source),
320 angular_used_selectors,
321 angular_entry_component_refs: Vec::new(),
322 has_dynamic_component_render,
323 has_unharvestable_emits: false,
324 has_dynamic_emit: false,
325 has_emit_whole_object_use: false,
326 load_return_keys: Vec::new(),
327 has_unharvestable_load: false,
328 has_load_data_whole_use: false,
329 has_page_data_store_whole_use: false,
330 component_functions: Vec::new(),
331 react_props: Vec::new(),
332 hook_uses: Vec::new(),
333 render_edges: Vec::new(),
334 svelte_dispatched_events: Vec::new(),
335 svelte_listened_events: Vec::new(),
336 has_dynamic_dispatch: false,
337 }
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343
344 #[test]
345 fn is_html_file_html() {
346 assert!(is_html_file(Path::new("index.html")));
347 }
348
349 #[test]
350 fn is_html_file_nested() {
351 assert!(is_html_file(Path::new("pages/about.html")));
352 }
353
354 #[test]
355 fn is_html_file_rejects_htm() {
356 assert!(!is_html_file(Path::new("index.htm")));
357 }
358
359 #[test]
360 fn is_html_file_rejects_js() {
361 assert!(!is_html_file(Path::new("app.js")));
362 }
363
364 #[test]
365 fn is_html_file_rejects_ts() {
366 assert!(!is_html_file(Path::new("app.ts")));
367 }
368
369 #[test]
370 fn is_html_file_rejects_vue() {
371 assert!(!is_html_file(Path::new("App.vue")));
372 }
373
374 #[test]
375 fn remote_url_http() {
376 assert!(is_remote_url("http://example.com/script.js"));
377 }
378
379 #[test]
380 fn remote_url_https() {
381 assert!(is_remote_url("https://cdn.example.com/style.css"));
382 }
383
384 #[test]
385 fn remote_url_protocol_relative() {
386 assert!(is_remote_url("//cdn.example.com/lib.js"));
387 }
388
389 #[test]
390 fn remote_url_data() {
391 assert!(is_remote_url("data:text/javascript;base64,abc"));
392 }
393
394 #[test]
395 fn local_relative_not_remote() {
396 assert!(!is_remote_url("./src/entry.js"));
397 }
398
399 #[test]
400 fn local_root_relative_not_remote() {
401 assert!(!is_remote_url("/src/entry.js"));
402 }
403
404 #[test]
405 fn extracts_module_script_src() {
406 let info = parse_html_to_module(
407 FileId(0),
408 r#"<script type="module" src="./src/entry.js"></script>"#,
409 0,
410 );
411 assert_eq!(info.imports.len(), 1);
412 assert_eq!(info.imports[0].source, "./src/entry.js");
413 }
414
415 #[test]
416 fn extracts_plain_script_src() {
417 let info = parse_html_to_module(
418 FileId(0),
419 r#"<script src="./src/polyfills.js"></script>"#,
420 0,
421 );
422 assert_eq!(info.imports.len(), 1);
423 assert_eq!(info.imports[0].source, "./src/polyfills.js");
424 }
425
426 #[test]
427 fn extracts_multiple_scripts() {
428 let info = parse_html_to_module(
429 FileId(0),
430 r#"
431 <script type="module" src="./src/entry.js"></script>
432 <script src="./src/polyfills.js"></script>
433 "#,
434 0,
435 );
436 assert_eq!(info.imports.len(), 2);
437 }
438
439 #[test]
440 fn skips_inline_script() {
441 let info = parse_html_to_module(FileId(0), r#"<script>console.log("hello");</script>"#, 0);
442 assert!(info.imports.is_empty());
443 }
444
445 #[test]
446 fn skips_handlebars_placeholder_in_script_src() {
447 let info = parse_html_to_module(
448 FileId(0),
449 r#"<script src="{{rootURL}}assets/app.js"></script>
450 <script src="{{config.assetsPath}}vendor.js"></script>"#,
451 0,
452 );
453 assert!(
454 info.imports.is_empty(),
455 "Handlebars-placeholder script srcs should not enter the import graph; got {:?}",
456 info.imports
457 );
458 }
459
460 #[test]
461 fn skips_handlebars_placeholder_in_link_href() {
462 let info = parse_html_to_module(
463 FileId(0),
464 r#"<link rel="stylesheet" href="{{rootURL}}assets/app.css">"#,
465 0,
466 );
467 assert!(info.imports.is_empty());
468 }
469
470 #[test]
471 fn skips_ember_cli_blueprint_placeholder() {
472 let info = parse_html_to_module(
473 FileId(0),
474 r####"<script src="###APPNAME###/app.js"></script>"####,
475 0,
476 );
477 assert!(info.imports.is_empty());
478 }
479
480 #[test]
481 fn extracts_normal_specifier_alongside_placeholders() {
482 let info = parse_html_to_module(
483 FileId(0),
484 r#"<script src="{{rootURL}}assets/app.js"></script>
485 <script src="./src/main.ts"></script>"#,
486 0,
487 );
488 assert_eq!(info.imports.len(), 1);
489 assert_eq!(info.imports[0].source, "./src/main.ts");
490 }
491
492 #[test]
493 fn skips_remote_script() {
494 let info = parse_html_to_module(
495 FileId(0),
496 r#"<script src="https://cdn.example.com/lib.js"></script>"#,
497 0,
498 );
499 assert!(info.imports.is_empty());
500 }
501
502 #[test]
503 fn skips_protocol_relative_script() {
504 let info = parse_html_to_module(
505 FileId(0),
506 r#"<script src="//cdn.example.com/lib.js"></script>"#,
507 0,
508 );
509 assert!(info.imports.is_empty());
510 }
511
512 #[test]
513 fn extracts_stylesheet_link() {
514 let info = parse_html_to_module(
515 FileId(0),
516 r#"<link rel="stylesheet" href="./src/global.css" />"#,
517 0,
518 );
519 assert_eq!(info.imports.len(), 1);
520 assert_eq!(info.imports[0].source, "./src/global.css");
521 }
522
523 #[test]
524 fn extracts_modulepreload_link() {
525 let info = parse_html_to_module(
526 FileId(0),
527 r#"<link rel="modulepreload" href="./src/vendor.js" />"#,
528 0,
529 );
530 assert_eq!(info.imports.len(), 1);
531 assert_eq!(info.imports[0].source, "./src/vendor.js");
532 }
533
534 #[test]
535 fn extracts_link_with_reversed_attrs() {
536 let info = parse_html_to_module(
537 FileId(0),
538 r#"<link href="./src/global.css" rel="stylesheet" />"#,
539 0,
540 );
541 assert_eq!(info.imports.len(), 1);
542 assert_eq!(info.imports[0].source, "./src/global.css");
543 }
544
545 #[test]
546 fn bare_script_src_normalized_to_relative() {
547 let info = parse_html_to_module(FileId(0), r#"<script src="app.js"></script>"#, 0);
548 assert_eq!(info.imports.len(), 1);
549 assert_eq!(info.imports[0].source, "./app.js");
550 }
551
552 #[test]
553 fn bare_module_script_src_normalized_to_relative() {
554 let info = parse_html_to_module(
555 FileId(0),
556 r#"<script type="module" src="main.ts"></script>"#,
557 0,
558 );
559 assert_eq!(info.imports.len(), 1);
560 assert_eq!(info.imports[0].source, "./main.ts");
561 }
562
563 #[test]
564 fn bare_stylesheet_link_href_normalized_to_relative() {
565 let info = parse_html_to_module(
566 FileId(0),
567 r#"<link rel="stylesheet" href="styles.css" />"#,
568 0,
569 );
570 assert_eq!(info.imports.len(), 1);
571 assert_eq!(info.imports[0].source, "./styles.css");
572 }
573
574 #[test]
575 fn bare_link_href_reversed_attrs_normalized_to_relative() {
576 let info = parse_html_to_module(
577 FileId(0),
578 r#"<link href="styles.css" rel="stylesheet" />"#,
579 0,
580 );
581 assert_eq!(info.imports.len(), 1);
582 assert_eq!(info.imports[0].source, "./styles.css");
583 }
584
585 #[test]
586 fn bare_modulepreload_link_href_normalized_to_relative() {
587 let info = parse_html_to_module(
588 FileId(0),
589 r#"<link rel="modulepreload" href="vendor.js" />"#,
590 0,
591 );
592 assert_eq!(info.imports.len(), 1);
593 assert_eq!(info.imports[0].source, "./vendor.js");
594 }
595
596 #[test]
597 fn bare_asset_with_subdir_normalized_to_relative() {
598 let info = parse_html_to_module(FileId(0), r#"<script src="assets/app.js"></script>"#, 0);
599 assert_eq!(info.imports.len(), 1);
600 assert_eq!(info.imports[0].source, "./assets/app.js");
601 }
602
603 #[test]
604 fn root_absolute_script_src_unchanged() {
605 let info = parse_html_to_module(FileId(0), r#"<script src="/src/main.ts"></script>"#, 0);
606 assert_eq!(info.imports.len(), 1);
607 assert_eq!(info.imports[0].source, "/src/main.ts");
608 }
609
610 #[test]
611 fn parent_relative_script_src_unchanged() {
612 let info = parse_html_to_module(
613 FileId(0),
614 r#"<script src="../shared/vendor.js"></script>"#,
615 0,
616 );
617 assert_eq!(info.imports.len(), 1);
618 assert_eq!(info.imports[0].source, "../shared/vendor.js");
619 }
620
621 #[test]
622 fn skips_preload_link() {
623 let info = parse_html_to_module(
624 FileId(0),
625 r#"<link rel="preload" href="./src/font.woff2" as="font" />"#,
626 0,
627 );
628 assert!(info.imports.is_empty());
629 }
630
631 #[test]
632 fn skips_icon_link() {
633 let info =
634 parse_html_to_module(FileId(0), r#"<link rel="icon" href="./favicon.ico" />"#, 0);
635 assert!(info.imports.is_empty());
636 }
637
638 #[test]
639 fn skips_remote_stylesheet() {
640 let info = parse_html_to_module(
641 FileId(0),
642 r#"<link rel="stylesheet" href="https://fonts.googleapis.com/css" />"#,
643 0,
644 );
645 assert!(info.imports.is_empty());
646 }
647
648 #[test]
649 fn skips_commented_out_script() {
650 let info = parse_html_to_module(
651 FileId(0),
652 r#"<!-- <script src="./old.js"></script> -->
653 <script src="./new.js"></script>"#,
654 0,
655 );
656 assert_eq!(info.imports.len(), 1);
657 assert_eq!(info.imports[0].source, "./new.js");
658 }
659
660 #[test]
661 fn skips_commented_out_link() {
662 let info = parse_html_to_module(
663 FileId(0),
664 r#"<!-- <link rel="stylesheet" href="./old.css" /> -->
665 <link rel="stylesheet" href="./new.css" />"#,
666 0,
667 );
668 assert_eq!(info.imports.len(), 1);
669 assert_eq!(info.imports[0].source, "./new.css");
670 }
671
672 #[test]
673 fn handles_multiline_script_tag() {
674 let info = parse_html_to_module(
675 FileId(0),
676 "<script\n type=\"module\"\n src=\"./src/entry.js\"\n></script>",
677 0,
678 );
679 assert_eq!(info.imports.len(), 1);
680 assert_eq!(info.imports[0].source, "./src/entry.js");
681 }
682
683 #[test]
684 fn handles_multiline_link_tag() {
685 let info = parse_html_to_module(
686 FileId(0),
687 "<link\n rel=\"stylesheet\"\n href=\"./src/global.css\"\n/>",
688 0,
689 );
690 assert_eq!(info.imports.len(), 1);
691 assert_eq!(info.imports[0].source, "./src/global.css");
692 }
693
694 #[test]
695 fn full_vite_html() {
696 let info = parse_html_to_module(
697 FileId(0),
698 r#"<!doctype html>
699<html>
700 <head>
701 <link rel="stylesheet" href="./src/global.css" />
702 <link rel="icon" href="/favicon.ico" />
703 </head>
704 <body>
705 <div id="app"></div>
706 <script type="module" src="./src/entry.js"></script>
707 </body>
708</html>"#,
709 0,
710 );
711 assert_eq!(info.imports.len(), 2);
712 let sources: Vec<&str> = info.imports.iter().map(|i| i.source.as_str()).collect();
713 assert!(sources.contains(&"./src/global.css"));
714 assert!(sources.contains(&"./src/entry.js"));
715 }
716
717 #[test]
718 fn empty_html() {
719 let info = parse_html_to_module(FileId(0), "", 0);
720 assert!(info.imports.is_empty());
721 }
722
723 #[test]
724 fn html_with_no_assets() {
725 let info = parse_html_to_module(
726 FileId(0),
727 r"<!doctype html><html><body><h1>Hello</h1></body></html>",
728 0,
729 );
730 assert!(info.imports.is_empty());
731 }
732
733 #[test]
734 fn single_quoted_attributes() {
735 let info = parse_html_to_module(FileId(0), r"<script src='./src/entry.js'></script>", 0);
736 assert_eq!(info.imports.len(), 1);
737 assert_eq!(info.imports[0].source, "./src/entry.js");
738 }
739
740 #[test]
741 fn all_imports_are_side_effect() {
742 let info = parse_html_to_module(
743 FileId(0),
744 r#"<script src="./entry.js"></script>
745 <link rel="stylesheet" href="./style.css" />"#,
746 0,
747 );
748 for imp in &info.imports {
749 assert!(matches!(imp.imported_name, ImportedName::SideEffect));
750 assert!(imp.local_name.is_empty());
751 assert!(!imp.is_type_only);
752 }
753 }
754
755 #[test]
756 fn suppression_comments_extracted() {
757 let info = parse_html_to_module(
758 FileId(0),
759 "<!-- fallow-ignore-file -->\n<script src=\"./entry.js\"></script>",
760 0,
761 );
762 assert_eq!(info.imports.len(), 1);
763 }
764
765 #[test]
766 fn angular_template_extracts_member_refs() {
767 let info = parse_html_to_module(
768 FileId(0),
769 "<h1>{{ title() }}</h1>\n\
770 <p [class.highlighted]=\"isHighlighted\">{{ greeting() }}</p>\n\
771 <button (click)=\"onButtonClick()\">Toggle</button>",
772 0,
773 );
774 let fact_names: rustc_hash::FxHashSet<&str> = info
775 .semantic_facts
776 .iter()
777 .filter_map(|fact| {
778 if let SemanticFact::AngularTemplateMemberAccess(access) = fact {
779 Some(access.member.as_str())
780 } else {
781 None
782 }
783 })
784 .collect();
785 assert!(fact_names.contains("title"), "should contain 'title'");
786 assert!(
787 fact_names.contains("isHighlighted"),
788 "should contain 'isHighlighted'"
789 );
790 assert!(fact_names.contains("greeting"), "should contain 'greeting'");
791 assert!(
792 fact_names.contains("onButtonClick"),
793 "should contain 'onButtonClick'"
794 );
795 assert!(
796 info.member_accesses.is_empty(),
797 "Angular template refs should emit typed facts instead of member accesses: {:?}",
798 info.member_accesses
799 );
800 }
801
802 #[test]
803 fn plain_html_no_angular_refs() {
804 let info = parse_html_to_module(
805 FileId(0),
806 "<!doctype html><html><body><h1>Hello</h1></body></html>",
807 0,
808 );
809 assert!(info.member_accesses.is_empty());
810 }
811}