Skip to main content

fallow_engine/mutation/
layout.rs

1//! The code layout of a file that `fallow mutation` can instrument.
2//!
3//! A JavaScript or TypeScript file is one program. A Vue or Svelte
4//! single-file component and an Astro component hold their code in script
5//! blocks. The extract crate finds the blocks, so the plan and inspect use
6//! the same blocks as the analysis.
7
8use std::borrow::Cow;
9
10use fallow_extract::sfc::SfcScript;
11use fallow_mutation::{
12    Instrumented, MutationError, MutationOptions, ScriptBlock, ScriptRole, SourceKind,
13};
14
15/// The framework of a component file.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub(super) enum Component {
18    Vue,
19    Svelte,
20    Astro,
21}
22
23/// How a file holds its code.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub(super) enum Layout {
26    /// The whole file is one program.
27    Script(SourceKind),
28    /// A Svelte module file (`.svelte.js`, `.svelte.ts`): one program that
29    /// can use runes.
30    SvelteModule(SourceKind),
31    /// The code is in the script blocks of a component file.
32    Component(Component),
33}
34
35impl Layout {
36    /// Selects the layout from the file extension of `path`.
37    pub(super) fn from_path(path: &str) -> Option<Self> {
38        if let Some(kind) = SourceKind::from_path(path) {
39            let stem = path.rsplit_once('.').map_or(path, |(stem, _)| stem);
40            let runes =
41                stem.ends_with(".svelte") && matches!(kind, SourceKind::Js | SourceKind::Ts);
42            return Some(if runes {
43                Self::SvelteModule(kind)
44            } else {
45                Self::Script(kind)
46            });
47        }
48        let component = match path.rsplit_once('.')?.1 {
49            "vue" => Component::Vue,
50            "svelte" => Component::Svelte,
51            "astro" => Component::Astro,
52            _ => return None,
53        };
54        Some(Self::Component(component))
55    }
56
57    /// Instruments `source`. `path` is the root-relative path.
58    pub(super) fn instrument(
59        self,
60        source: &str,
61        path: &str,
62        options: &MutationOptions,
63    ) -> Result<Instrumented, MutationError> {
64        fallow_extract::parse_bounded(source, || self.instrument_unbounded(source, path, options))
65            .unwrap_or_else(|| {
66                Err(MutationError::Parse {
67                    messages: vec!["the source nests brackets too deep to parse".to_owned()],
68                })
69            })
70    }
71
72    fn instrument_unbounded(
73        self,
74        source: &str,
75        path: &str,
76        options: &MutationOptions,
77    ) -> Result<Instrumented, MutationError> {
78        match self {
79            Self::Script(kind) => fallow_mutation::instrument(source, path, kind, options),
80            Self::SvelteModule(kind) => {
81                let block = ScriptBlock {
82                    span: (
83                        0,
84                        u32::try_from(source.len()).map_err(|_| MutationError::TooLarge)?,
85                    ),
86                    kind,
87                    role: ScriptRole::SvelteModule,
88                };
89                fallow_mutation::instrument_blocks(source, path, &[block], options)
90            }
91            Self::Component(component) => fallow_mutation::instrument_blocks(
92                source,
93                path,
94                &script_blocks(component, source),
95                options,
96            ),
97        }
98    }
99
100    /// The text and language for a parse of the whole file with file
101    /// offsets. For a component file, each byte outside the script blocks
102    /// becomes a space, and each line break stays. The blocks then parse as
103    /// one program at their own offsets.
104    pub(super) fn parse_view(self, source: &str) -> (Cow<'_, str>, SourceKind) {
105        let component = match self {
106            Self::Script(kind) | Self::SvelteModule(kind) => return (Cow::Borrowed(source), kind),
107            Self::Component(component) => component,
108        };
109        let blocks = script_blocks(component, source);
110        let mut masked: Vec<u8> = source
111            .bytes()
112            .map(|byte| if byte == b'\n' { b'\n' } else { b' ' })
113            .collect();
114        for block in &blocks {
115            let (start, end) = (block.span.0 as usize, block.span.1 as usize);
116            masked[start..end].copy_from_slice(&source.as_bytes()[start..end]);
117        }
118        let typescript = blocks
119            .iter()
120            .any(|block| matches!(block.kind, SourceKind::Ts | SourceKind::Tsx));
121        let jsx = blocks
122            .iter()
123            .any(|block| matches!(block.kind, SourceKind::Jsx | SourceKind::Tsx));
124        let kind = match (typescript, jsx) {
125            (true, true) => SourceKind::Tsx,
126            (true, false) => SourceKind::Ts,
127            (false, true) => SourceKind::Jsx,
128            (false, false) => SourceKind::Js,
129        };
130        // Each block starts and ends on a character boundary, and each
131        // replaced byte is ASCII, so the text stays UTF-8.
132        let text = String::from_utf8(masked).unwrap_or_default();
133        (Cow::Owned(text), kind)
134    }
135
136    /// The byte spans of the script blocks, or `None` when the whole file is
137    /// one program.
138    pub(super) fn script_spans(self, source: &str) -> Option<Vec<(u32, u32)>> {
139        match self {
140            Self::Script(_) | Self::SvelteModule(_) => None,
141            Self::Component(component) => Some(
142                script_blocks(component, source)
143                    .iter()
144                    .map(|block| block.span)
145                    .collect(),
146            ),
147        }
148    }
149}
150
151/// The inline script blocks of a component file. A block with a `src`
152/// attribute has no inline code.
153fn script_blocks(component: Component, source: &str) -> Vec<ScriptBlock> {
154    match component {
155        Component::Vue | Component::Svelte => fallow_extract::sfc::extract_sfc_scripts(source)
156            .iter()
157            .filter(|script| script.src.is_none())
158            .filter_map(|script| {
159                let role = match (component, script.is_setup, script.is_context_module) {
160                    (Component::Vue, true, _) => ScriptRole::VueSetup,
161                    (Component::Vue, false, _) => ScriptRole::VueScript,
162                    (_, _, true) => ScriptRole::SvelteModule,
163                    (_, _, false) => ScriptRole::SvelteInstance,
164                };
165                block(script, role)
166            })
167            .collect(),
168        Component::Astro => fallow_extract::astro::extract_astro_frontmatter(source)
169            .iter()
170            .filter_map(|script| block(script, ScriptRole::AstroFrontmatter))
171            .collect(),
172    }
173}
174
175fn block(script: &SfcScript, role: ScriptRole) -> Option<ScriptBlock> {
176    let start = u32::try_from(script.byte_offset).ok()?;
177    let end = u32::try_from(script.byte_offset + script.body.len()).ok()?;
178    let kind = match (script.is_typescript, script.is_jsx) {
179        (true, true) => SourceKind::Tsx,
180        (true, false) => SourceKind::Ts,
181        (false, true) => SourceKind::Jsx,
182        (false, false) => SourceKind::Js,
183    };
184    Some(ScriptBlock {
185        span: (start, end),
186        kind,
187        role,
188    })
189}
190
191/// True when `path` is a Vue, Svelte or Astro component file, whose code is
192/// in script blocks. A Svelte module file (`.svelte.ts`) is not a component
193/// file.
194#[must_use]
195pub fn is_component_file(path: &str) -> bool {
196    matches!(Layout::from_path(path), Some(Layout::Component(_)))
197}
198
199/// Instruments a source file of any layout that `fallow mutation` supports:
200/// JavaScript, TypeScript, and the script blocks of Vue, Svelte and Astro
201/// components. Returns `None` for another file type.
202///
203/// `path` is the root-relative path. It selects the layout and is part of
204/// each mutant id.
205#[must_use]
206pub fn instrument_source(
207    source: &str,
208    path: &str,
209    options: &MutationOptions,
210) -> Option<Result<Instrumented, MutationError>> {
211    Layout::from_path(path).map(|layout| layout.instrument(source, path, options))
212}