Skip to main content

fallow_mutation/
blocks.rs

1//! Instrumentation of the script blocks in a component file.
2//!
3//! A Vue or Svelte single-file component and an Astro component hold their
4//! code in script blocks. The instrumented file is the whole component file
5//! with each script block replaced by its instrumented text, so the framework
6//! compiler sees the same template and styles. The caller finds the blocks;
7//! this crate has no component parser.
8//!
9//! The framework compilers move some code out of the block scope. A mutation
10//! switch in that code cannot reach the prelude, or the compiler refuses the
11//! switch. These positions give no mutant:
12//!
13//! - Vue `<script setup>`: a compiler macro call and its arguments
14//!   (`defineProps`, `defineExpose`, `withDefaults` and the other `define*`
15//!   macros), and the destructure defaults of a declaration with a macro
16//!   call. The compiler moves them out of `setup()` or replaces the call, so
17//!   a switch around the call hides the macro.
18//! - Svelte: an expression that holds a rune call (`$state(...)`,
19//!   `$effect(...)` and the other runes). A rune must stay in its own
20//!   position. The rune arguments still give mutants.
21//! - Astro frontmatter: an exported declaration. The compiler moves it out of
22//!   the render function.
23
24use std::collections::BTreeSet;
25
26use oxc_ast::ast::{CallExpression, Expression, Program, Statement, VariableDeclarator};
27use oxc_ast_visit::{Visit, walk};
28use oxc_span::GetSpan;
29
30use crate::{
31    Instrumented, MutantId, MutationError, MutationOptions, SourceKind, SpliceMap, SpliceSegment,
32    TextInput, instrument_text, line_column, line_starts,
33};
34
35/// The role of a script block in a component file.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum ScriptRole {
38    /// A Vue `<script>` block without `setup`. The code runs at module scope.
39    VueScript,
40    /// A Vue `<script setup>` block. The code runs in `setup()`.
41    VueSetup,
42    /// A Svelte module script (`<script module>` or
43    /// `<script context="module">`). The code runs at module scope.
44    SvelteModule,
45    /// A Svelte instance script. The code runs once for each component
46    /// instance.
47    SvelteInstance,
48    /// The frontmatter of an Astro component. The code runs in the render
49    /// function.
50    AstroFrontmatter,
51}
52
53impl ScriptRole {
54    /// True when the block code runs at module scope.
55    const fn is_module_scope(self) -> bool {
56        matches!(self, Self::VueScript | Self::SvelteModule)
57    }
58}
59
60/// One script block of a component file.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub struct ScriptBlock {
63    /// The byte span of the block body in the component file.
64    pub span: (u32, u32),
65    /// The language of the block body.
66    pub kind: SourceKind,
67    /// The role of the block.
68    pub role: ScriptRole,
69}
70
71/// Finds the mutants in the script blocks of a component file and returns
72/// the instrumented file.
73///
74/// The spans, lines and columns of the mutants, the ranges of `options` and
75/// the splice map use the coordinates of the whole file. The text outside
76/// the blocks stays the same. A block that is outside the file, or that
77/// overlaps an earlier block, is ignored.
78///
79/// The prelude goes into each block with module scope. When the file has no
80/// such block, it goes into each block, because each block then has its own
81/// scope.
82pub fn instrument_blocks(
83    source: &str,
84    path: &str,
85    blocks: &[ScriptBlock],
86    options: &MutationOptions,
87) -> Result<Instrumented, MutationError> {
88    if u32::try_from(source.len()).is_err() {
89        return Err(MutationError::TooLarge);
90    }
91    let ordered = valid_blocks(source, blocks);
92    let module_scope = ordered.iter().any(|block| block.role.is_module_scope());
93    let starts = line_starts(source);
94    let mut seen_ids: BTreeSet<MutantId> = BTreeSet::new();
95    let mut output = Instrumented {
96        mutants: Vec::new(),
97        code: String::with_capacity(source.len() * 2),
98        map: SpliceMap::default(),
99    };
100    let mut cursor = 0;
101    for block in ordered {
102        copy_original(source, (cursor, block.span.0), &mut output);
103        let (start, end) = block.span;
104        let input = TextInput {
105            source: &source[start as usize..end as usize],
106            path,
107            kind: block.kind,
108            role: Some(block.role),
109            prelude: !module_scope || block.role.is_module_scope(),
110        };
111        let local_options = MutationOptions {
112            operators: options.operators.clone(),
113            ranges: options
114                .ranges
115                .as_ref()
116                .map(|ranges| local_ranges(ranges, block.span)),
117        };
118        let result = instrument_text(&input, &local_options, &mut seen_ids)?;
119        append_block(result, start, &starts, &mut output)?;
120        cursor = end;
121    }
122    copy_original(source, (cursor, source.len() as u32), &mut output);
123    output.mutants.sort_by_key(|mutant| mutant.span);
124    if u32::try_from(output.code.len()).is_err() {
125        return Err(MutationError::TooLarge);
126    }
127    Ok(output)
128}
129
130/// The blocks in file order, without a block that is outside the file, that
131/// does not start and end on a character boundary, or that overlaps an
132/// earlier block.
133fn valid_blocks(source: &str, blocks: &[ScriptBlock]) -> Vec<ScriptBlock> {
134    let mut ordered: Vec<ScriptBlock> = blocks
135        .iter()
136        .copied()
137        .filter(|block| {
138            let (start, end) = (block.span.0 as usize, block.span.1 as usize);
139            start <= end && source.get(start..end).is_some()
140        })
141        .collect();
142    ordered.sort_by_key(|block| block.span);
143    let mut end = 0;
144    ordered.retain(|block| {
145        let keep = block.span.0 >= end;
146        if keep {
147            end = block.span.1;
148        }
149        keep
150    });
151    ordered
152}
153
154/// The parts of `ranges` inside `block`, relative to the block start.
155fn local_ranges(ranges: &[(u32, u32)], block: (u32, u32)) -> Vec<(u32, u32)> {
156    ranges
157        .iter()
158        .filter_map(|&(from, to)| {
159            let start = from.max(block.0);
160            let end = to.min(block.1);
161            (start < end).then(|| (start - block.0, end - block.0))
162        })
163        .collect()
164}
165
166fn copy_original(source: &str, span: (u32, u32), output: &mut Instrumented) {
167    let (from, to) = span;
168    if to <= from {
169        return;
170    }
171    output.map.segments.push(SpliceSegment {
172        generated_start: output.code.len() as u32,
173        original_start: from,
174        len: to - from,
175    });
176    output.code.push_str(&source[from as usize..to as usize]);
177}
178
179/// Appends the instrumented block at the end of `output` and moves its
180/// mutants and map to the coordinates of the whole file.
181fn append_block(
182    block: Instrumented,
183    start: u32,
184    starts: &[u32],
185    output: &mut Instrumented,
186) -> Result<(), MutationError> {
187    let base = u32::try_from(output.code.len()).map_err(|_| MutationError::TooLarge)?;
188    output
189        .map
190        .segments
191        .extend(block.map.segments.into_iter().map(|segment| SpliceSegment {
192            generated_start: segment.generated_start + base,
193            original_start: segment.original_start + start,
194            len: segment.len,
195        }));
196    output.code.push_str(&block.code);
197    for mut mutant in block.mutants {
198        mutant.span = (mutant.span.0 + start, mutant.span.1 + start);
199        (mutant.line, mutant.column) = line_column(starts, mutant.span.0);
200        if let Some(function) = &mut mutant.enclosing_function {
201            function.span = (function.span.0 + start, function.span.1 + start);
202        }
203        output.mutants.push(mutant);
204    }
205    Ok(())
206}
207
208/// The spans that limit the sites of one block.
209#[derive(Debug, Default)]
210pub struct Protected {
211    /// A site that overlaps one of these spans gives no mutant.
212    overlap: Vec<(u32, u32)>,
213    /// A site that contains one of these spans gives no mutant.
214    contain: Vec<(u32, u32)>,
215}
216
217impl Protected {
218    /// True when the site with `span` gives no mutant.
219    pub fn excludes(&self, span: (u32, u32)) -> bool {
220        self.overlap
221            .iter()
222            .any(|&(start, end)| start < span.1 && span.0 < end)
223            || self
224                .contain
225                .iter()
226                .any(|&(start, end)| span.0 <= start && end <= span.1)
227    }
228}
229
230/// The Vue compiler macros. The compiler moves their arguments out of
231/// `setup()`, or replaces the call. A switch around a macro call hides it
232/// from the compiler, so the call and its arguments give no mutant.
233const VUE_MACROS: [&str; 7] = [
234    "defineProps",
235    "defineEmits",
236    "defineExpose",
237    "defineModel",
238    "defineOptions",
239    "defineSlots",
240    "withDefaults",
241];
242
243/// The Svelte runes. A rune call must stay in its own position.
244const SVELTE_RUNES: [&str; 7] = [
245    "$state",
246    "$derived",
247    "$effect",
248    "$props",
249    "$bindable",
250    "$inspect",
251    "$host",
252];
253
254/// The spans of `program` that limit the sites of a block with `role`.
255pub fn protected_spans(program: &Program<'_>, role: ScriptRole) -> Protected {
256    let mut protected = Protected::default();
257    match role {
258        ScriptRole::VueScript => {}
259        ScriptRole::VueSetup => {
260            let mut finder = CallFinder {
261                names: &VUE_MACROS,
262                declarators: true,
263                spans: Vec::new(),
264            };
265            finder.visit_program(program);
266            protected.overlap = finder.spans;
267        }
268        ScriptRole::SvelteModule | ScriptRole::SvelteInstance => {
269            let mut finder = CallFinder {
270                names: &SVELTE_RUNES,
271                declarators: false,
272                spans: Vec::new(),
273            };
274            finder.visit_program(program);
275            protected.contain = finder.spans;
276        }
277        ScriptRole::AstroFrontmatter => {
278            protected.overlap = program
279                .body
280                .iter()
281                .filter(|statement| {
282                    matches!(
283                        statement,
284                        Statement::ExportDeclaration(_) | Statement::ExportDefaultDeclaration(_)
285                    )
286                })
287                .map(|statement| {
288                    let span = statement.span();
289                    (span.start, span.end)
290                })
291                .collect();
292        }
293    }
294    protected
295}
296
297/// Finds the calls whose callee is one of `names`, or a member of one of
298/// `names` (`$state.raw(...)`, `$effect.pre(...)`).
299struct CallFinder {
300    names: &'static [&'static str],
301    /// Also gives the whole declarator when such a call is its initializer.
302    /// The Vue compiler moves the destructure defaults of
303    /// `const { size = 1 } = defineProps()` into the props options.
304    declarators: bool,
305    spans: Vec<(u32, u32)>,
306}
307
308impl CallFinder {
309    fn matches(&self, callee: &Expression<'_>) -> bool {
310        let mut current = callee;
311        loop {
312            match current {
313                Expression::Identifier(identifier) => {
314                    return self.names.contains(&identifier.name.as_str());
315                }
316                Expression::StaticMemberExpression(member) => current = &member.object,
317                Expression::ParenthesizedExpression(inner) => current = &inner.expression,
318                _ => return false,
319            }
320        }
321    }
322}
323
324impl<'a> Visit<'a> for CallFinder {
325    fn visit_variable_declarator(&mut self, it: &VariableDeclarator<'a>) {
326        let call = it
327            .init
328            .as_ref()
329            .and_then(|init| match init.without_parentheses() {
330                Expression::CallExpression(call) => Some(call),
331                _ => None,
332            });
333        if self.declarators && call.is_some_and(|call| self.matches(&call.callee)) {
334            self.spans.push((it.span.start, it.span.end));
335        }
336        walk::walk_variable_declarator(self, it);
337    }
338
339    fn visit_call_expression(&mut self, it: &CallExpression<'a>) {
340        if self.matches(&it.callee) {
341            self.spans.push((it.span.start, it.span.end));
342        }
343        walk::walk_call_expression(self, it);
344    }
345}