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