use crate::extract::{extract_functions, extractor_for};
use crate::test_code::{inline_test, rust_test_modules};
use cpd_core::models::{Location, Token};
use cpd_tokenizer::line_index::LineIndex;
use cpd_tokenizer::tokenizer::{Mode, tokenize};
#[derive(Debug, Clone, PartialEq)]
pub struct RawUnit {
pub grammar: &'static str,
pub name: String,
pub start: Location,
pub end: Location,
pub text: String,
pub test: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct UnitMap {
pub format: String,
pub units: Vec<RawUnit>,
}
const COMPONENT_FORMATS: &[&str] = &["vue", "svelte", "astro"];
pub fn supports_units(format: &str) -> bool {
COMPONENT_FORMATS.contains(&format) || extractor_for(format).is_some()
}
pub fn extract_units(source: &str, format: &str) -> Vec<UnitMap> {
if COMPONENT_FORMATS.contains(&format) {
return component_units(source, format);
}
let units = units_in(source, format, 0, None);
if units.is_empty() {
return Vec::new();
}
vec![UnitMap {
format: format.to_string(),
units,
}]
}
fn component_units(source: &str, file_format: &str) -> Vec<UnitMap> {
let host = LineIndex::new(source.as_bytes());
let mut maps: Vec<UnitMap> = Vec::new();
for (format, range) in cpd_tokenizer::sfc::script_blocks(source, file_format) {
if extractor_for(&format).is_none() {
continue;
}
let units = units_in(&source[range.clone()], &format, range.start, Some(&host));
if units.is_empty() {
continue;
}
match maps.iter_mut().find(|m| m.format == format) {
Some(map) => map.units.extend(units),
None => maps.push(UnitMap { format, units }),
}
}
maps
}
fn units_in(code: &str, format: &str, shift: usize, host: Option<&LineIndex>) -> Vec<RawUnit> {
let functions = extract_functions(code, format);
if functions.is_empty() {
return Vec::new();
}
let tokens = tokenize(format, code, Mode::Weak);
let rust_modules = match functions.iter().any(|f| f.grammar == "rust") {
true => rust_test_modules(code, &tokens),
false => Vec::new(),
};
let place = |loc: &Location| match host {
Some(index) => index.location(shift + loc.offset as usize),
None => loc.clone(),
};
functions
.into_iter()
.filter_map(|f| {
let text = code_text(code, &tokens, f.head.offset as usize, f.end.offset as usize);
let test = inline_test(
f.grammar,
code,
f.head.offset as usize,
f.start.offset as usize,
&rust_modules,
);
(!text.is_empty()).then(|| RawUnit {
test,
grammar: f.grammar,
start: place(&f.head),
end: place(&f.end),
name: f.name,
text,
})
})
.collect()
}
fn code_text(code: &str, tokens: &[Token], start: usize, end: usize) -> String {
let first = tokens.partition_point(|t| (t.start.offset as usize) < start);
let mut out = String::new();
let mut prev_end: Option<usize> = None;
for token in &tokens[first..] {
let (from, to) = (token.start.offset as usize, token.end.offset as usize);
if to > end {
break;
}
if from < to && to <= code.len() && code.is_char_boundary(from) && code.is_char_boundary(to)
{
if let Some(prev) = prev_end.filter(|&p| p <= from) {
let gap = &code[prev..from];
match gap.rfind('\n') {
Some(nl) => {
out.push('\n');
let line_start = &gap[nl + 1..];
let indent = line_start.len() - line_start.trim_start().len();
out.push_str(&line_start[..indent]);
}
None if !gap.is_empty() => out.push(' '),
None => {}
}
}
out.push_str(&code[from..to]);
prev_end = Some(to);
}
}
let line_start = code[..start.min(code.len())]
.rfind('\n')
.map_or(0, |nl| nl + 1);
let base = code[line_start..start.min(code.len())]
.chars()
.take_while(|c| c.is_whitespace())
.count();
dedent(&out, base)
}
fn dedent(text: &str, base: usize) -> String {
let mut out = String::with_capacity(text.len());
for (i, line) in text.split('\n').enumerate() {
if i > 0 {
out.push('\n');
let strip: usize = line
.chars()
.take(base)
.take_while(|c| c.is_whitespace())
.map(char::len_utf8)
.sum();
out.push_str(&line[strip..]);
} else {
out.push_str(line);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rust_units_lose_comments_and_shared_indentation() {
let src = "impl Cart {\n /// Sum of the lines.\n pub fn total(&self) -> i64 {\n // cents\n self.lines.iter().map(|l| l.price /* each */ * l.qty).sum()\n }\n}\n";
let maps = extract_units(src, "rust");
assert_eq!(maps.len(), 1);
assert_eq!(maps[0].format, "rust");
let unit = &maps[0].units[0];
assert_eq!(unit.name, "total");
assert_eq!(unit.grammar, "rust");
assert_eq!((unit.start.line, unit.end.line), (3, 6));
assert_eq!(
unit.text,
"pub fn total(&self) -> i64 {\n self.lines.iter().map(|l| l.price * l.qty).sum()\n}"
);
}
#[test]
fn typescript_units_keep_code_and_drop_comments() {
let src = "// helpers\nexport function slug(t: string): string {\n /* lower */\n return t.toLowerCase().replace(/[^a-z0-9]+/g, '-'); // dash\n}\n";
let maps = extract_units(src, "typescript");
let unit = &maps[0].units[0];
assert_eq!(
(unit.name.as_str(), unit.start.line, unit.end.line),
("slug", 2, 5)
);
assert_eq!(
unit.text,
"function slug(t: string): string {\n return t.toLowerCase().replace(/[^a-z0-9]+/g, '-');\n}"
);
}
#[test]
fn typescript_units_start_at_the_name_they_are_declared_under() {
let src = "class Stream {\n async segment(n: bigint): Promise<void> {\n return run(n);\n }\n handle = (e: Event) => log(e);\n}\nexport const total = (xs: number[]) => xs.reduce((a, b) => a + b, 0);\nconst api = { fetchAll(url: string) { return get(url); }, save: async (x: number) => put(x) };\nconst later = debounce(() => refresh(), 300);\n";
let maps = extract_units(src, "typescript");
let texts: Vec<(&str, &str)> = maps[0]
.units
.iter()
.map(|u| (u.name.as_str(), u.text.as_str()))
.collect();
assert_eq!(
texts,
vec![
(
"segment",
"segment(n: bigint): Promise<void> {\n return run(n);\n}"
),
("handle", "handle = (e: Event) => log(e)"),
("<arrow>", "(a, b) => a + b"),
(
"total",
"total = (xs: number[]) => xs.reduce((a, b) => a + b, 0)"
),
("fetchAll", "fetchAll(url: string) { return get(url); }"),
("save", "save: async (x: number) => put(x)"),
("later", "() => refresh()"),
]
);
let start = &maps[0].units[0].start;
assert_eq!(start.line, 2);
assert!(src[start.offset as usize..].starts_with("segment("));
}
#[test]
fn svelte_script_functions_are_placed_in_the_host_file() {
let src = "<script lang=\"ts\">\n let n = $state(0);\n\n function double(x: number): number {\n return x * 2;\n }\n</script>\n\n<button onclick={() => (n = double(n))}>{n}</button>\n";
let maps = extract_units(src, "svelte");
assert_eq!(maps.len(), 1);
assert_eq!(maps[0].format, "typescript");
let unit = &maps[0].units[0];
assert_eq!(unit.name, "double");
assert_eq!((unit.start.line, unit.end.line), (4, 6));
assert_eq!(
&src[unit.start.offset as usize..unit.start.offset as usize + 8],
"function"
);
assert_eq!(
unit.text,
"function double(x: number): number {\n return x * 2;\n}"
);
}
#[test]
fn vue_blocks_of_one_format_share_a_map() {
let src = "<template><p>{{ a }}</p></template>\n<script>\nexport function one() { return 1; }\n</script>\n<script setup>\nfunction two() { return 2; }\n</script>\n";
let maps = extract_units(src, "vue");
assert_eq!(maps.len(), 1);
assert_eq!(maps[0].format, "javascript");
let names: Vec<&str> = maps[0].units.iter().map(|u| u.name.as_str()).collect();
assert_eq!(names, vec!["one", "two"]);
assert_eq!(maps[0].units[1].start.line, 6);
}
#[test]
fn formats_without_an_extractor_have_no_units() {
assert!(supports_units("rust") && supports_units("svelte") && supports_units("tsx"));
assert!(supports_units("python") && supports_units("ruby") && supports_units("go"));
assert!(!supports_units("haskell") && !supports_units("markdown"));
assert!(extract_units("f x = x + 1\n", "haskell").is_empty());
assert!(extract_units("<style>p { color: red }</style>", "svelte").is_empty());
}
#[test]
fn crlf_sources_give_the_same_text() {
let lf = "pub fn alpha(x: u32) -> u32 {\n let y = x + 1; // one\n y * 2\n}\n";
let crlf = lf.replace('\n', "\r\n");
let text = |src: &str| extract_units(src, "rust")[0].units[0].text.clone();
assert_eq!(text(&crlf), text(lf));
assert_eq!(
text(lf),
"pub fn alpha(x: u32) -> u32 {\n let y = x + 1;\n y * 2\n}"
);
}
#[test]
fn python_units_drop_comments_but_keep_docstrings() {
let src = "class Cart:\n def total(self):\n \"\"\"Sum of the lines.\"\"\"\n # cents\n return sum(l.price * l.qty for l in self.lines) # all\n";
let unit = &extract_units(src, "python")[0].units[0];
assert_eq!(unit.grammar, "python");
assert_eq!(
unit.text,
"def total(self):\n \"\"\"Sum of the lines.\"\"\"\n return sum(l.price * l.qty for l in self.lines)"
);
}
#[test]
fn grammar_languages_give_units_without_comments() {
let src = "package cart\n\n// Total sums the lines.\nfunc (c *Cart) Total() int {\n\t// cents\n\tsum := 0\n\tfor _, l := range c.lines {\n\t\tsum += l.price * l.qty // each\n\t}\n\treturn sum\n}\n";
let maps = extract_units(src, "go");
assert_eq!(maps[0].format, "go");
let unit = &maps[0].units[0];
assert_eq!((unit.name.as_str(), unit.grammar), ("Total", "go"));
assert_eq!((unit.start.line, unit.end.line), (4, 11));
assert_eq!(
unit.text,
"func (c *Cart) Total() int {\n\tsum := 0\n\tfor _, l := range c.lines {\n\t\tsum += l.price * l.qty\n\t}\n\treturn sum\n}"
);
}
#[test]
fn dedent_strips_the_first_line_indentation() {
assert_eq!(
dedent(
"fn f() {\n if x {\n y\n }\n }",
4
),
"fn f() {\n if x {\n y\n }\n}"
);
assert_eq!(dedent("one line", 8), "one line");
assert_eq!(
dedent("def f():\n x\n\ty", 4),
"def f():\nx\ny",
"never more than the line has"
);
assert_eq!(
dedent("f() {\n\u{a0}\u{a0}x\n\u{3000}y\n}", 1),
"f() {\n\u{a0}x\ny\n}",
"whitespace wider than a byte is stripped by character"
);
}
}