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