Skip to main content

bamts_compiler/
script.rs

1//! Filesystem-free classic-script compilation.
2
3use std::sync::Arc;
4
5use bamts_bytecode::{
6    ConstantId, EcmaString, ModuleId, Program, ProgramModule, ProgramVerifyError, Verified,
7};
8
9use crate::{
10    diagnostic::DiagnosticSeverity,
11    lower::{self, LowerError, LowerErrorKind, LowerOptions},
12    parser, scanner,
13    source::{ScriptKind, SourceId, SourceText, Utf16Pos},
14};
15
16const DEFAULT_MODULE_NAME: &str = "evalmachine.<anonymous>";
17
18/// The closed set of classic-script compilation failures, in compiler terms.
19#[derive(Clone, Debug, Eq, PartialEq)]
20pub enum ScriptCompileError {
21    /// The source contained an unpaired UTF-16 surrogate at this code-unit offset.
22    IllFormedSource { unit_offset: usize },
23    /// Parsing or lowering found invalid JavaScript syntax.
24    Syntax {
25        message: String,
26        line: u32,
27        column: u32,
28    },
29    /// The source used syntax outside the supported classic-script profile.
30    Unsupported {
31        message: String,
32        line: u32,
33        column: u32,
34    },
35    /// A fixed compiler or bytecode capacity was exhausted.
36    Capacity { message: String },
37}
38
39/// Compiles exact UTF-16 source into a one-module verified classic-script program.
40///
41/// This entrypoint performs no filesystem access, project resolution, type checking,
42/// or lossy UTF-16 conversion.
43pub fn compile_classic_script(
44    source: &[u16],
45    resource_name: &str,
46) -> Result<Program<Verified>, ScriptCompileError> {
47    let text = EcmaString::from_units(source)
48        .to_utf8_strict()
49        .map_err(|error| ScriptCompileError::IllFormedSource {
50            unit_offset: error.unit_offset,
51        })?;
52    let source = Arc::new(SourceText::new(text));
53    let parsed = parser::parse(scanner::scan(
54        SourceId::new(0),
55        ScriptKind::JavaScript,
56        Arc::clone(&source),
57    ));
58    if let Some(diagnostic) = parsed
59        .diagnostics()
60        .iter()
61        .find(|diagnostic| diagnostic.severity() == DiagnosticSeverity::Error)
62    {
63        let (line, column) = line_column(&source, diagnostic.range().start());
64        return Err(ScriptCompileError::Syntax {
65            message: diagnostic.message().to_owned(),
66            line,
67            column,
68        });
69    }
70
71    let module_name = normalized_module_name(resource_name).unwrap_or(DEFAULT_MODULE_NAME);
72    let options = LowerOptions {
73        javascript_compatibility: true,
74    };
75    let assembled = if module_name == DEFAULT_MODULE_NAME {
76        lower::assemble_classic_script(parsed.product(), options)
77    } else {
78        lower::assemble_classic_script_named(parsed.product(), options, module_name)
79    };
80    let module = assembled
81        .map_err(|error| map_lower_error(&source, error))?
82        .verify()
83        .map_err(|error| ScriptCompileError::Capacity {
84            message: error.to_string(),
85        })?;
86
87    Program::link(
88        vec![ProgramModule {
89            name: ConstantId::new(0),
90            code: module,
91            edges: Vec::new(),
92            bindings: Vec::new(),
93            exports: Vec::new(),
94        }],
95        ModuleId::new(0),
96    )
97    .map_err(map_program_error)
98}
99
100fn map_lower_error(source: &SourceText, error: LowerError) -> ScriptCompileError {
101    let (line, column) = line_column(source, error.range.start());
102    match error.kind {
103        LowerErrorKind::Unsupported(construct) => ScriptCompileError::Unsupported {
104            message: construct.to_string(),
105            line,
106            column,
107        },
108        LowerErrorKind::Capacity(limit) => ScriptCompileError::Capacity {
109            message: limit.to_string(),
110        },
111        kind => ScriptCompileError::Syntax {
112            message: kind.to_string(),
113            line,
114            column,
115        },
116    }
117}
118
119fn map_program_error(error: ProgramVerifyError) -> ScriptCompileError {
120    ScriptCompileError::Capacity {
121        message: error.to_string(),
122    }
123}
124
125fn line_column(source: &SourceText, position: Utf16Pos) -> (u32, u32) {
126    source
127        .line_column(position)
128        .map(|(line, column)| {
129            (
130                u32::try_from(line).unwrap_or(u32::MAX),
131                u32::try_from(column).unwrap_or(u32::MAX),
132            )
133        })
134        .unwrap_or((0, 0))
135}
136
137fn normalized_module_name(resource_name: &str) -> Option<&str> {
138    if resource_name.is_empty()
139        || resource_name.starts_with('/')
140        || resource_name.contains('\\')
141        || resource_name.contains('\0')
142    {
143        return None;
144    }
145    let mut segments = resource_name.split('/');
146    let first = segments.next()?;
147    if first.contains(':') || first.is_empty() || first == "." || first == ".." {
148        return None;
149    }
150    if segments.any(|segment| segment.is_empty() || segment == "." || segment == "..") {
151        return None;
152    }
153    Some(resource_name)
154}
155
156#[cfg(test)]
157mod tests {
158    use bamts_bytecode::Instruction;
159
160    use super::{ScriptCompileError, compile_classic_script};
161
162    #[test]
163    fn classic_script_has_a_single_linkage_free_module() {
164        let program = compile_classic_script(
165            "1 + 1".encode_utf16().collect::<Vec<_>>().as_slice(),
166            "script.js",
167        )
168        .expect("classic script compiles");
169
170        assert_eq!(program.entry().get(), 0);
171        assert_eq!(program.modules().len(), 1);
172        let module = &program.modules()[0];
173        assert!(module.edges().is_empty());
174        assert!(module.bindings().is_empty());
175        assert!(module.exports().is_empty());
176        assert!(matches!(
177            module.code().functions()[module.code().entry().get() as usize]
178                .code()
179                .last(),
180            Some(Instruction::Return { .. })
181        ));
182        assert!(
183            module
184                .code()
185                .functions()
186                .iter()
187                .flat_map(|function| function.code())
188                .all(|instruction| !matches!(
189                    instruction,
190                    Instruction::Import { .. } | Instruction::Export { .. }
191                ))
192        );
193    }
194
195    #[test]
196    fn classic_script_rejects_module_syntax_before_program_linking() {
197        for source in [
198            "import x from 'y'",
199            "export const a = 1",
200            "export default 1",
201            "import('y')",
202        ] {
203            assert!(matches!(
204                compile_classic_script(&source.encode_utf16().collect::<Vec<_>>(), "script.js"),
205                Err(ScriptCompileError::Unsupported { .. })
206            ));
207        }
208    }
209
210    #[test]
211    fn ill_formed_utf16_source_is_typed() {
212        assert_eq!(
213            compile_classic_script(&[0xD800], "script.js"),
214            Err(ScriptCompileError::IllFormedSource { unit_offset: 0 })
215        );
216    }
217
218    #[test]
219    fn syntax_diagnostics_are_typed() {
220        assert!(matches!(
221            compile_classic_script(&"(".encode_utf16().collect::<Vec<_>>(), "script.js"),
222            Err(ScriptCompileError::Syntax { .. })
223        ));
224    }
225
226    #[test]
227    fn non_normalized_resource_name_is_advisory() {
228        assert!(compile_classic_script(&[], "/tmp/script.js").is_ok());
229    }
230
231    #[test]
232    fn completion_cases_compile_to_verified_returning_scripts() {
233        for source in [
234            "",
235            "var x = 5",
236            "1 + 1",
237            "if (true) { 42 }",
238            "1; if (true) {}",
239            "{ 7 }",
240            "for (let i = 0; i < 3; i++) { i }",
241            "1; while (false) { 2 }",
242            "try { 1 } finally { 2 }",
243            "switch (1) { case 1: 5 }",
244            "function f() {}",
245        ] {
246            let program =
247                compile_classic_script(&source.encode_utf16().collect::<Vec<_>>(), "script.js")
248                    .unwrap_or_else(|error| panic!("{source:?} did not compile: {error:?}"));
249            let entry = &program.modules()[0].code().functions()[0];
250            assert!(matches!(
251                entry.code().last(),
252                Some(Instruction::Return { .. })
253            ));
254        }
255    }
256}