Skip to main content

cpd_tokenizer/
sfc.rs

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