Repository navigation
Expand file tree
/
Copy pathtranspile.rs
More file actions
227 lines (204 loc) · 7.3 KB
/
Copy pathtranspile.rs
File metadata and controls
227 lines (204 loc) · 7.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
//! The TypeScript fast path. Guest source is transpiled to JavaScript with oxc
//! (types erased, esnext target — a near-identity transform) before it ever
//! reaches QuickJS, and the resulting source map is kept host-side so guest
//! errors can be remapped from generated-JS back to original-TS coordinates.
//!
//! Transpilation is content-addressed and cached: re-evaluating the same guest
//! is free, and the source map never enters the sandbox.
use lru::LruCache;
use oxc::codegen::CodegenReturn;
use oxc::diagnostics::Diagnostics;
use oxc::parser::ParseOptions;
use oxc::span::SourceType;
use oxc::transformer::TransformOptions;
use oxc::CompilerInterface;
use std::cell::RefCell;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::num::NonZeroUsize;
use std::path::Path;
use std::rc::Rc;
/// A transpiled guest module: the JS QuickJS will run, plus the source map
/// (as JSON) for remapping errors. `map` is `None` only if codegen emitted none.
#[derive(Clone)]
pub struct Transpiled {
pub module_id: String,
pub js: Rc<str>,
pub map_json: Option<Rc<str>>,
}
/// A transpile (parse/transform) failure, located in the original TS source.
#[derive(Debug, Clone)]
pub struct TranspileError {
pub message: String,
/// 1-based line/column of the first diagnostic (0 if unknown).
pub line: u32,
pub col: u32,
}
/// Transpile TS -> JS. `module_id` names the module (used as the codegen source
/// path and the QuickJS filename, so stack frames are attributable). Returns a
/// located [`TranspileError`] on parse/transform failure.
pub fn transpile(
source: &str,
module_id: &str,
) -> Result<(String, Option<String>), TranspileError> {
let options = TransformOptions::from_target("esnext").map_err(|e| TranspileError {
message: format!("invalid transform target: {e}"),
line: 0,
col: 0,
})?;
let mut compiler = TsCompiler {
options,
code: None,
map_json: None,
errors: Vec::new(),
};
compiler.compile(source, SourceType::ts(), Path::new(module_id));
if let Some((message, offset)) = compiler.errors.into_iter().next() {
let (line, col) = offset.map_or((0, 0), |o| line_col(source, o));
return Err(TranspileError { message, line, col });
}
let code = compiler.code.ok_or_else(|| TranspileError {
message: "transpiler produced no output".to_owned(),
line: 0,
col: 0,
})?;
Ok((code, compiler.map_json))
}
/// Compute the 1-based (line, column) of a byte offset within `source`.
fn line_col(source: &str, offset: usize) -> (u32, u32) {
let mut line = 1u32;
let mut col = 1u32;
for (i, ch) in source.char_indices() {
if i >= offset {
break;
}
if ch == '\n' {
line += 1;
col = 1;
} else {
col += 1;
}
}
(line, col)
}
/// An oxc compiler pipeline configured for type-stripping + source maps. The
/// trait runs parse -> semantic -> transform -> codegen; we capture the
/// generated code, the map, and any diagnostics.
struct TsCompiler {
options: TransformOptions,
code: Option<String>,
map_json: Option<String>,
/// First-seen-first diagnostics as `(message, byte offset of its span)`.
errors: Vec<(String, Option<usize>)>,
}
impl CompilerInterface for TsCompiler {
fn parse_options(&self) -> ParseOptions {
ParseOptions::default()
}
fn transform_options(&self) -> Option<&TransformOptions> {
Some(&self.options)
}
fn enable_sourcemap(&self) -> bool {
true
}
fn handle_errors(&mut self, errors: Diagnostics) {
for e in errors {
// The first label's span gives the source location of the problem.
let offset = (&e.labels).into_iter().next().map(|l| l.offset() as usize);
self.errors.push((e.to_string(), offset));
}
}
fn after_codegen(&mut self, ret: CodegenReturn<'_>) {
self.code = Some(ret.code);
self.map_json = ret.map.map(|m| m.to_json_string());
}
}
// ---------------------------------------------------------------------------
// content-addressed cache
// ---------------------------------------------------------------------------
struct CachedModule {
source: String,
transpiled: Transpiled,
}
/// A small LRU mapping `hash(source)` -> transpiled output. Single-threaded
/// (PHP NTS), so a `RefCell` is sufficient.
pub struct TranspileCache {
inner: RefCell<LruCache<u64, CachedModule>>,
}
impl TranspileCache {
pub fn new(capacity: usize) -> Self {
let cap = NonZeroUsize::new(capacity.max(1)).unwrap();
TranspileCache {
inner: RefCell::new(LruCache::new(cap)),
}
}
/// Return the transpiled form of `source`, transpiling and caching on miss.
/// The cache key is a content hash; the stored source is compared on a hit
/// to rule out a hash collision returning the wrong JS.
pub fn get_or_transpile(&self, source: &str) -> Result<Transpiled, TranspileError> {
let key = hash(source);
if let Some(hit) = self.inner.borrow_mut().get(&key) {
if hit.source == source {
return Ok(hit.transpiled.clone());
}
}
// A clean, stable label for stack frames (the hash is the cache key,
// not user-facing). Single-source guests share one filename.
let module_id = "guest.ts".to_owned();
let (js, map_json) = transpile(source, &module_id)?;
let transpiled = Transpiled {
module_id,
js: Rc::from(js.as_str()),
map_json: map_json.map(|m| Rc::from(m.as_str())),
};
self.inner.borrow_mut().put(
key,
CachedModule {
source: source.to_owned(),
transpiled: transpiled.clone(),
},
);
Ok(transpiled)
}
}
fn hash(source: &str) -> u64 {
let mut h = DefaultHasher::new();
source.hash(&mut h);
h.finish()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strips_types() {
let (js, map) = transpile("const x: number = 41;\nconst y = x + 1;", "m.ts").unwrap();
assert!(
!js.contains(": number"),
"type annotation not stripped: {js}"
);
assert!(js.contains("41"));
assert!(map.is_some(), "source map should be emitted");
}
#[test]
fn keeps_private_fields_native() {
// esnext target must NOT downlevel #private to WeakMaps.
let (js, _) = transpile("class C { #id = 1; get(){ return this.#id; } }", "m.ts").unwrap();
assert!(js.contains("#id"), "private field downleveled: {js}");
}
#[test]
fn syntax_error_is_located() {
// The error should carry a message and a 1-based line/col.
let err = transpile("const a = 1;\nconst = ;", "m.ts").unwrap_err();
assert!(!err.message.is_empty());
assert_eq!(err.line, 2, "error is on the second line");
assert!(err.col > 0);
}
#[test]
fn cache_hits_return_same_output() {
let cache = TranspileCache::new(8);
let a = cache.get_or_transpile("const a: number = 1; a;").unwrap();
let b = cache.get_or_transpile("const a: number = 1; a;").unwrap();
assert_eq!(a.js, b.js);
assert_eq!(a.module_id, b.module_id);
}
}