1use std::collections::BTreeMap;
2
3use cpd_core::models::{DetectionToken, Token};
4
5use crate::embedded::{blank_ranges_preserve_newlines, html_only_maps, tokenize_blocks_shifted};
6use crate::line_index::LineIndex;
7use crate::markdown::{offset_detection_tokens, tokens_to_detection};
8use crate::tokenizer::{Mode, TokenMap, TokenizeOptions, tokenize_format_to_detection};
9
10#[derive(Debug, Clone)]
11pub struct Block {
12 pub block_format: String,
13 pub content: String,
14 pub start_offset: usize,
15 pub start_line: u32,
16}
17
18struct SfcBlock {
19 block_format: String,
20 block_start: usize,
21 inner_start: usize,
22 inner_end: usize,
23 block_end: usize,
24}
25
26pub fn tokenize_sfc_maps(
27 source: &str,
28 file_format: &str,
29 options: &TokenizeOptions,
30) -> Vec<TokenMap> {
31 if source.is_empty() {
32 return Vec::new();
33 }
34
35 let blocks = find_sfc_blocks(source, file_format);
36 if blocks.is_empty() {
37 return html_only_maps(source, options);
38 }
39
40 let blank_ranges: Vec<[usize; 2]> = blocks
41 .iter()
42 .filter_map(|b| {
43 if b.inner_start < b.inner_end {
44 Some([b.inner_start, b.inner_end])
45 } else {
46 None
47 }
48 })
49 .collect();
50
51 let sanitized = blank_ranges_preserve_newlines(source, &blank_ranges);
52 let line_index = LineIndex::new(source.as_bytes());
53
54 let mut grouped: BTreeMap<String, Vec<DetectionToken>> = BTreeMap::new();
55
56 let wrapper_tags_only = file_format == "vue";
67
68 let markup_tokens = crate::generic::tokenize_generic(&sanitized, "html");
69 let mut markup_detection = tokens_to_detection(markup_tokens, options);
70 markup_detection.retain(|t| t.range[0] < t.range[1]);
71 if !markup_detection.is_empty() && !wrapper_tags_only {
72 grouped
73 .entry("html".to_string())
74 .or_default()
75 .extend(markup_detection);
76 }
77
78 for block in &blocks {
79 if block.inner_start >= block.inner_end {
80 continue;
81 }
82 let inner = &source[block.inner_start..block.inner_end];
83 let inner_start_loc = line_index.location(block.inner_start);
84
85 let mut inner_tokens = tokenize_format_to_detection(&block.block_format, inner, options);
86 offset_detection_tokens(&mut inner_tokens, block.inner_start, &inner_start_loc);
87
88 grouped
89 .entry(block.block_format.clone())
90 .or_default()
91 .extend(inner_tokens);
92 }
93
94 if let Some(tokens) = grouped.get_mut("html") {
97 tokens.sort_by_key(|token| token.range[0]);
98 }
99
100 grouped
101 .into_iter()
102 .filter(|(_, tokens)| !tokens.is_empty())
103 .map(|(format, tokens)| TokenMap { format, tokens })
104 .collect()
105}
106
107fn sfc_tag_names(file_format: &str) -> &[&'static str] {
108 match file_format {
109 "svelte" | "astro" => &["script", "style"],
110 _ => &["template", "script", "style"],
111 }
112}
113
114fn find_sfc_blocks(source: &str, file_format: &str) -> Vec<SfcBlock> {
115 let source_lower = source.to_ascii_lowercase();
116 let mut blocks = Vec::new();
117
118 if file_format == "astro"
119 && let Some(fm) = astro_frontmatter_block(source)
120 {
121 blocks.push(fm);
122 }
123
124 for tag in sfc_tag_names(file_format) {
125 let mut search_from = 0usize;
126 while let Some(block) = find_sfc_tag_block(source, &source_lower, tag, search_from) {
127 search_from = block.block_end;
128 blocks.push(block);
129 }
130 }
131
132 blocks.sort_by_key(|b| b.block_start);
133 let mut deduped = Vec::new();
134 for block in blocks {
135 let nested = deduped.iter().any(|existing: &SfcBlock| {
136 block.block_start >= existing.block_start && block.block_start < existing.block_end
137 });
138 if !nested {
139 deduped.push(block);
140 }
141 }
142 deduped
143}
144
145fn find_tag_bounds(
146 _source: &str,
147 source_lower: &str,
148 tag: &str,
149 from: usize,
150) -> Option<(usize, usize, usize)> {
151 let open_needle = format!("<{}", tag);
152 let close_needle = format!("</{}>", tag);
153
154 let open_start = source_lower[from..].find(&open_needle)? + from;
155 let after_tag_name = open_start + 1 + tag.len();
156 if source_lower
157 .as_bytes()
158 .get(after_tag_name)
159 .is_some_and(|b| b.is_ascii_alphabetic())
160 {
161 return None;
162 }
163 let tag_end = source_lower[open_start..].find('>')? + open_start + 1;
164 let close_start = source_lower[tag_end..].find(&close_needle)? + tag_end;
165
166 Some((open_start, tag_end, close_start))
167}
168
169fn find_sfc_tag_block(
170 source: &str,
171 source_lower: &str,
172 tag: &str,
173 from: usize,
174) -> Option<SfcBlock> {
175 let (open_start, tag_end, close_start) = find_tag_bounds(source, source_lower, tag, from)?;
176 let close_needle = format!("</{}>", tag);
177
178 let attrs = &source[open_start + 1 + tag.len()..tag_end];
179 let inner_start = tag_end;
180 let inner_end = close_start;
181 let block_end = source_lower[close_start..]
182 .find('>')
183 .map(|i| close_start + i + 1)
184 .unwrap_or(close_start + close_needle.len());
185 let block_end = block_end.min(source.len());
186
187 let block_format = detect_sfc_block_format(attrs, tag);
188
189 Some(SfcBlock {
190 block_format,
191 block_start: open_start,
192 inner_start,
193 inner_end: inner_end.max(inner_start),
194 block_end,
195 })
196}
197
198fn detect_block_format(attrs: &str, tag: &str, strict: bool) -> String {
199 let lang = extract_lang_attr_value(attrs);
200 match tag {
201 "script" => match lang.as_deref() {
202 Some("ts" | "typescript") => "typescript".to_string(),
203 Some("js" | "javascript") => "javascript".to_string(),
204 Some(other) if strict => {
205 if crate::formats::get_format_by_extension(other).is_some()
206 || crate::formats::SUPPORTED_FORMATS
207 .iter()
208 .any(|e| e.name == other)
209 {
210 other.to_string()
211 } else {
212 "javascript".to_string()
213 }
214 }
215 _ => "javascript".to_string(),
216 },
217 "style" => match lang.as_deref() {
218 Some("scss" | "sass") => "scss".to_string(),
219 Some("less") => "less".to_string(),
220 _ => "css".to_string(),
221 },
222 "template" => match lang.as_deref() {
223 Some(v) if v == "pug" || v == "jade" => "pug".to_string(),
224 _ => "html".to_string(),
225 },
226 _ => "html".to_string(),
227 }
228}
229
230fn detect_sfc_block_format(attrs: &str, tag: &str) -> String {
231 detect_block_format(attrs, tag, true)
232}
233
234fn detect_display_block_format(attrs: &str, tag: &str) -> String {
235 detect_block_format(attrs, tag, false)
236}
237
238fn astro_frontmatter_block(source: &str) -> Option<SfcBlock> {
239 if !(source.starts_with("---\n") || source.starts_with("---\r\n")) {
240 return None;
241 }
242 let lines = crate::markdown::line_spans(source);
243 let close_idx = lines
244 .iter()
245 .enumerate()
246 .skip(1)
247 .find(|(_, span)| source[span.start..span.end].trim() == "---")
248 .map(|(idx, _)| idx)?;
249 let inner_start = lines.get(1)?.start;
250 let inner_end = source[..lines[close_idx].start]
251 .strip_suffix('\n')
252 .map(|prefix: &str| prefix.len())
253 .unwrap_or(lines[close_idx].start);
254 let block_end = lines[close_idx].next_start.min(source.len());
255 Some(SfcBlock {
256 block_format: "typescript".to_string(),
257 block_start: 0,
258 inner_start,
259 inner_end: inner_end.max(inner_start),
260 block_end,
261 })
262}
263
264fn extract_lang_attr_value(attrs: &str) -> Option<String> {
265 let lower = attrs.to_ascii_lowercase();
266 let lang_pos = lower.find("lang=")?;
267 let rest = &attrs[lang_pos + 5..];
268 let quote = if rest.starts_with('"') {
269 '"'
270 } else if rest.starts_with('\'') {
271 '\''
272 } else {
273 return None;
274 };
275 let value_start = 1;
276 let value_end = rest[value_start..].find(quote)? + value_start;
277 Some(rest[value_start..value_end].to_ascii_lowercase())
278}
279
280pub fn extract_blocks(source: &str, file_format: &str) -> Vec<Block> {
282 let source_lower = source.to_ascii_lowercase();
283 let mut blocks = Vec::new();
284 for tag in sfc_tag_names(file_format) {
285 let mut search_from = 0;
286 while let Some((block, next_from)) =
287 find_display_block(source, &source_lower, tag, search_from)
288 {
289 search_from = next_from;
290 blocks.push(block);
291 }
292 }
293 blocks.sort_by_key(|b: &Block| b.start_offset);
294 blocks
295}
296
297fn find_display_block(
298 source: &str,
299 source_lower: &str,
300 tag: &str,
301 from: usize,
302) -> Option<(Block, usize)> {
303 let (open_start, tag_end, close_start) = find_tag_bounds(source, source_lower, tag, from)?;
304
305 let attrs = &source[open_start + 1 + tag.len()..tag_end];
306 let content = source[tag_end..close_start].to_string();
307 let content_len = content.len();
308 let start_line = source[..tag_end].lines().count() as u32 + 1;
309 let block_format = detect_display_block_format(attrs, tag);
310
311 Some((
312 Block {
313 block_format,
314 content,
315 start_offset: tag_end,
316 start_line,
317 },
318 tag_end + content_len,
319 ))
320}
321
322pub fn tokenize_sfc(source: &str, file_format: &str, mode: Mode) -> Vec<Token> {
323 let blocks = extract_blocks(source, file_format);
324 tokenize_blocks_shifted(
325 blocks
326 .iter()
327 .map(|b| (b.block_format.as_str(), b.content.as_str(), b.start_line)),
328 mode,
329 )
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335
336 const VUE_FILE: &str = r#"<template>
337 <div>Hello</div>
338</template>
339
340<script>
341export default { name: 'Foo' }
342</script>
343
344<style>
345.foo { color: red; }
346</style>
347"#;
348
349 const VUE_TS_FILE: &str = r#"<template>
350 <div>Hello</div>
351</template>
352
353<script lang="ts">
354const x: number = 5;
355</script>
356
357<style lang="scss">
358.foo { color: red; }
359</style>
360"#;
361
362 #[test]
363 fn vue_file_extracts_three_blocks() {
364 let blocks = extract_blocks(VUE_FILE, "vue");
365 assert_eq!(blocks.len(), 3, "must find template, script, style blocks");
366 }
367
368 #[test]
369 fn script_block_default_format_is_javascript() {
370 let blocks = extract_blocks(VUE_FILE, "vue");
371 let script = blocks.iter().find(|b| b.block_format == "javascript");
372 assert!(script.is_some(), "plain <script> must be javascript format");
373 }
374
375 #[test]
376 fn script_lang_ts_produces_typescript_format() {
377 let blocks = extract_blocks(VUE_TS_FILE, "vue");
378 let ts_block = blocks.iter().find(|b| b.block_format == "typescript");
379 assert!(
380 ts_block.is_some(),
381 "<script lang=\"ts\"> must produce typescript format"
382 );
383 }
384
385 #[test]
386 fn unknown_lang_does_not_panic() {
387 let source = "<script lang=\"unknownlang123\">\nconst x = 1;\n</script>\n";
388 let result = std::panic::catch_unwind(|| extract_blocks(source, "vue"));
389 assert!(result.is_ok(), "unknown lang must not panic");
390 }
391
392 #[test]
393 fn no_blocks_returns_empty() {
394 let source = "just plain text no tags";
395 let blocks = extract_blocks(source, "vue");
396 assert!(blocks.is_empty());
397 }
398
399 #[test]
400 fn start_offset_is_after_opening_tag() {
401 let blocks = extract_blocks(VUE_FILE, "vue");
402 for block in &blocks {
403 assert!(block.start_offset > 0);
404 }
405 }
406
407 #[test]
408 fn vue_sfc_maps_produces_multiple_formats() {
409 let options = TokenizeOptions::new(Mode::Mild);
410 let maps = tokenize_sfc_maps(VUE_FILE, "vue", &options);
411 let formats: Vec<&str> = maps.iter().map(|m| m.format.as_str()).collect();
412 assert!(formats.contains(&"javascript"), "must have javascript map");
413 assert!(formats.contains(&"css"), "must have css map");
414 assert!(formats.contains(&"html"), "must have html map");
415 }
416
417 #[test]
418 fn vue_html_map_tokens_remain_in_source_order() {
419 let maps = tokenize_sfc_maps(VUE_FILE, "vue", &TokenizeOptions::new(Mode::Mild));
420 let html = maps.iter().find(|map| map.format == "html").unwrap();
421
422 assert!(
423 html.tokens
424 .windows(2)
425 .all(|pair| pair[0].range[0] <= pair[1].range[0]),
426 "HTML tokens must remain in source order"
427 );
428 }
429
430 #[test]
431 fn vue_html_map_holds_only_the_template_body() {
432 let maps = tokenize_sfc_maps(VUE_FILE, "vue", &TokenizeOptions::new(Mode::Mild));
433 let html = maps.iter().find(|map| map.format == "html").unwrap();
434 let template_start = VUE_FILE.find("<template>").unwrap() + "<template>".len();
435 let template_end = VUE_FILE.find("</template>").unwrap();
436 assert!(
437 html.tokens
438 .iter()
439 .all(|t| t.range[0] >= template_start && t.range[1] <= template_end),
440 "wrapper tags must not enter the html stream: {:?}",
441 html.tokens.iter().map(|t| t.range).collect::<Vec<_>>()
442 );
443 assert_eq!(html.tokens.first().unwrap().start.line, 2);
444 assert_eq!(html.tokens.last().unwrap().end.line, 2);
445 }
446
447 #[test]
448 fn vue_without_template_has_no_html_map() {
449 let source = "<script>\nexport default { render: (h) => h('div', 'x') }\n</script>\n<style>\n.x { margin: 0; }\n</style>\n";
450 let maps = tokenize_sfc_maps(source, "vue", &TokenizeOptions::new(Mode::Mild));
451 assert!(
452 maps.iter().all(|map| map.format != "html"),
453 "wrapper tags alone must not form an html map: {:?}",
454 maps.iter().map(|m| m.format.as_str()).collect::<Vec<_>>()
455 );
456 }
457
458 #[test]
459 fn svelte_keeps_top_level_markup() {
460 let source = "<script>\nlet n = 1;\n</script>\n<h1>Count {n}</h1>\n<button on:click={() => n++}>add</button>\n";
461 let maps = tokenize_sfc_maps(source, "svelte", &TokenizeOptions::new(Mode::Mild));
462 let html = maps.iter().find(|map| map.format == "html").unwrap();
463 assert!(
464 html.tokens.iter().any(|t| t.start.line == 4),
465 "top-level markup is the html map"
466 );
467 }
468
469 #[test]
470 fn vue_ts_maps_produces_typescript() {
471 let options = TokenizeOptions::new(Mode::Mild);
472 let maps = tokenize_sfc_maps(VUE_TS_FILE, "vue", &options);
473 let formats: Vec<&str> = maps.iter().map(|m| m.format.as_str()).collect();
474 assert!(formats.contains(&"typescript"), "must have typescript map");
475 assert!(formats.contains(&"scss"), "must have scss map");
476 }
477
478 #[test]
479 fn empty_sfc_returns_empty() {
480 let options = TokenizeOptions::new(Mode::Mild);
481 let maps = tokenize_sfc_maps("", "vue", &options);
482 assert!(maps.is_empty());
483 }
484
485 #[test]
486 fn svelte_sfc_maps_produces_multiple_formats() {
487 let source = r#"<script>
488 let count = 0;
489</script>
490
491<style>
492 .count { color: blue; }
493</style>
494"#;
495 let options = TokenizeOptions::new(Mode::Mild);
496 let maps = tokenize_sfc_maps(source, "svelte", &options);
497 let formats: Vec<&str> = maps.iter().map(|m| m.format.as_str()).collect();
498 assert!(
499 formats.contains(&"javascript"),
500 "svelte must have javascript map"
501 );
502 assert!(formats.contains(&"css"), "svelte must have css map");
503 assert!(
504 formats.contains(&"html"),
505 "svelte must have html markup map"
506 );
507 }
508
509 #[test]
510 fn svelte_script_containing_style_text_no_panic() {
511 let source = r#"<script>
512 const x = "<style>.red{color:red}</style>";
513</script>
514
515<style>
516 .blue { color: blue; }
517</style>
518"#;
519 let result = std::panic::catch_unwind(|| {
520 let options = TokenizeOptions::new(Mode::Mild);
521 tokenize_sfc_maps(source, "svelte", &options)
522 });
523 assert!(
524 result.is_ok(),
525 "must not panic when <style> text appears inside <script>"
526 );
527 let maps = result.unwrap();
528 let formats: Vec<&str> = maps.iter().map(|m| m.format.as_str()).collect();
529 assert!(
530 formats.contains(&"javascript"),
531 "must have javascript block"
532 );
533 assert!(formats.contains(&"css"), "must have real css block");
534 assert!(formats.contains(&"html"), "must have html markup");
535 }
536}