Skip to main content

shape_ext_typescript/
runtime.rs

1//! V8/TypeScript runtime management via deno_core.
2//!
3//! This module owns the V8 isolate lifecycle and implements the core
4//! LanguageRuntime operations: init, compile, invoke, dispose.
5
6use crate::marshaling;
7use shape_abi_v1::{LanguageRuntimeLspConfig, PluginError};
8use std::collections::HashMap;
9use std::ffi::c_void;
10
11/// Opaque handle to a compiled TypeScript function.
12pub struct CompiledFunction {
13    /// The function name in Shape.
14    pub name: String,
15    /// Generated JavaScript source for the wrapper function.
16    /// This is the transpiled TS wrapped in a callable form.
17    pub js_source: String,
18    /// Parameter names in call order.
19    pub param_names: Vec<String>,
20    /// Shape source line where the foreign block body starts (for error mapping).
21    pub shape_body_start_line: u32,
22    /// Whether the function was declared `async` in Shape.
23    pub is_async: bool,
24    /// Declared return type string from Shape (e.g. "Result<int>").
25    pub return_type: String,
26    /// The name of the global wrapper function registered in V8.
27    pub v8_fn_name: String,
28}
29
30/// The TypeScript runtime instance. One per `init()` call.
31///
32/// Wraps a `deno_core::JsRuntime` that embeds V8 with TypeScript
33/// transpilation support via deno_core's built-in facilities.
34pub struct TsRuntime {
35    /// The deno_core JS runtime (owns the V8 isolate).
36    js_runtime: deno_core::JsRuntime,
37    /// Compiled function handles, keyed by an incrementing ID.
38    functions: HashMap<usize, CompiledFunction>,
39    /// Next handle ID.
40    next_id: usize,
41}
42
43impl TsRuntime {
44    /// Initialize a new TypeScript runtime backed by V8.
45    ///
46    /// `_config_msgpack` is the MessagePack-encoded configuration from the
47    /// host. Currently unused -- reserved for future settings like
48    /// tsconfig overrides, module resolution paths, etc.
49    pub fn new(_config_msgpack: &[u8]) -> Result<Self, String> {
50        let js_runtime = deno_core::JsRuntime::new(deno_core::RuntimeOptions {
51            ..Default::default()
52        });
53
54        Ok(TsRuntime {
55            js_runtime,
56            functions: HashMap::new(),
57            next_id: 1,
58        })
59    }
60
61    /// Register Shape type schemas for TypeScript declaration generation.
62    ///
63    /// The runtime receives the full set of Shape types so it can generate
64    /// TypeScript interface declarations that the user's code can reference.
65    pub fn register_types(&mut self, _types_msgpack: &[u8]) -> Result<(), String> {
66        // Stub: the real implementation will deserialize TypeSchemaExport[]
67        // and generate TypeScript interface declarations injected into the
68        // runtime's global scope.
69        Ok(())
70    }
71
72    /// Compile a foreign function body into a callable JavaScript function.
73    ///
74    /// Wraps the user's TypeScript body in a JavaScript function definition
75    /// (deno_core handles TS->JS transpilation). The wrapper is evaluated
76    /// in the V8 isolate so it can be called later via `invoke()`.
77    ///
78    /// When `is_async` is false, wraps the user's body in:
79    /// ```js
80    /// function __shape_ts_<id>(param1, param2) {
81    ///     <body>
82    /// }
83    /// ```
84    ///
85    /// When `is_async` is true, wraps in an async function:
86    /// ```js
87    /// async function __shape_ts_<id>(param1, param2) {
88    ///     <body>
89    /// }
90    /// ```
91    ///
92    /// Returns a handle that can be passed to `invoke()`.
93    pub fn compile(
94        &mut self,
95        name: &str,
96        source: &str,
97        param_names: &[String],
98        _param_types: &[String],
99        return_type: &str,
100        is_async: bool,
101    ) -> Result<*mut c_void, String> {
102        let id = self.next_id;
103        self.next_id += 1;
104
105        let v8_fn_name = format!("__shape_ts_{id}");
106        let params_str = param_names.join(", ");
107
108        // Indent the user body by 4 spaces.
109        let indented_body: String = source
110            .lines()
111            .map(|line| format!("    {line}"))
112            .collect::<Vec<_>>()
113            .join("\n");
114
115        let js_source = if is_async {
116            format!("async function {v8_fn_name}({params_str}) {{\n{indented_body}\n}}")
117        } else {
118            format!("function {v8_fn_name}({params_str}) {{\n{indented_body}\n}}")
119        };
120
121        // Evaluate the function definition in V8 so it is available for later calls.
122        self.js_runtime
123            .execute_script("<shape-ts-compile>", js_source.clone())
124            .map_err(|e| format!("TypeScript compilation error in '{}': {}", name, e))?;
125
126        let func = CompiledFunction {
127            name: name.to_string(),
128            js_source,
129            param_names: param_names.to_vec(),
130            shape_body_start_line: 0,
131            is_async,
132            return_type: return_type.to_string(),
133            v8_fn_name,
134        };
135
136        self.functions.insert(id, func);
137
138        // The handle is the function ID cast to a pointer.
139        Ok(id as *mut c_void)
140    }
141
142    /// Invoke a previously compiled function with msgpack-encoded arguments.
143    ///
144    /// Deserializes args from msgpack, calls the V8 function, and serializes
145    /// the result back to msgpack.
146    pub fn invoke(&mut self, handle: *mut c_void, args_msgpack: &[u8]) -> Result<Vec<u8>, String> {
147        let id = handle as usize;
148        let func = self
149            .functions
150            .get(&id)
151            .ok_or_else(|| format!("invalid function handle: {id}"))?;
152
153        let v8_fn_name = func.v8_fn_name.clone();
154        let func_name = func.name.clone();
155        let is_async = func.is_async;
156
157        // Deserialize msgpack args to rmpv values first, before entering V8 scope.
158        let arg_values: Vec<rmpv::Value> = if args_msgpack.is_empty() {
159            Vec::new()
160        } else {
161            rmp_serde::from_slice(args_msgpack)
162                .map_err(|e| format!("Failed to deserialize args: {}", e))?
163        };
164
165        // Build a JS expression that calls the function with serialized arguments.
166        // We pass args by building a JS literal expression from the rmpv values.
167        let args_js = arg_values
168            .iter()
169            .map(|v| rmpv_to_js_literal(v))
170            .collect::<Vec<_>>()
171            .join(", ");
172
173        let call_expr = if is_async {
174            // For async functions, wrap in an immediately-invoked async context.
175            // deno_core's execute_script returns a value, but async functions return
176            // a Promise. We need to use the async runtime to resolve it.
177            format!("(async () => await {v8_fn_name}({args_js}))()")
178        } else {
179            format!("{v8_fn_name}({args_js})")
180        };
181
182        if is_async {
183            // Use the tokio runtime that deno_core manages internally.
184            let result = self
185                .js_runtime
186                .execute_script("<shape-ts-invoke>", call_expr)
187                .map_err(|e| format!("TypeScript error in '{}': {}", func_name, e))?;
188
189            // For async, we need to poll the event loop to resolve the promise.
190            let rt = tokio::runtime::Builder::new_current_thread()
191                .enable_all()
192                .build()
193                .map_err(|e| format!("Failed to create async runtime: {}", e))?;
194
195            let resolved = rt.block_on(async {
196                let resolved = self.js_runtime.resolve(result);
197                self.js_runtime
198                    .with_event_loop_promise(resolved, deno_core::PollEventLoopOptions::default())
199                    .await
200            });
201
202            let global = resolved
203                .map_err(|e| format!("TypeScript async error in '{}': {}", func_name, e))?;
204
205            // Convert the resolved value to msgpack
206            let scope = &mut self.js_runtime.handle_scope();
207            let local = deno_core::v8::Local::new(scope, global);
208            marshaling::v8_to_msgpack(scope, local)
209        } else {
210            let result = self
211                .js_runtime
212                .execute_script("<shape-ts-invoke>", call_expr)
213                .map_err(|e| format!("TypeScript error in '{}': {}", func_name, e))?;
214
215            let scope = &mut self.js_runtime.handle_scope();
216            let local = deno_core::v8::Local::new(scope, result);
217            marshaling::v8_to_msgpack(scope, local)
218        }
219    }
220
221    /// Dispose a compiled function handle, removing it from V8.
222    pub fn dispose_function(&mut self, handle: *mut c_void) {
223        let id = handle as usize;
224        if let Some(func) = self.functions.remove(&id) {
225            // Delete the global function from V8 to free resources.
226            let delete_script = format!("delete globalThis.{};", func.v8_fn_name);
227            let _ = self
228                .js_runtime
229                .execute_script("<shape-ts-dispose>", delete_script);
230        }
231    }
232
233    /// Return the language identifier.
234    pub fn language_id() -> &'static str {
235        "typescript"
236    }
237
238    /// Return LSP configuration for TypeScript.
239    pub fn lsp_config() -> LanguageRuntimeLspConfig {
240        LanguageRuntimeLspConfig {
241            language_id: "typescript".into(),
242            server_command: vec!["typescript-language-server".into(), "--stdio".into()],
243            file_extension: ".ts".into(),
244            extra_paths: Vec::new(),
245        }
246    }
247}
248
249/// Convert an rmpv::Value to a JavaScript literal string.
250///
251/// This is used to inline argument values into the call expression.
252fn rmpv_to_js_literal(value: &rmpv::Value) -> String {
253    match value {
254        rmpv::Value::Nil => "null".to_string(),
255        rmpv::Value::Boolean(b) => if *b { "true" } else { "false" }.to_string(),
256        rmpv::Value::Integer(i) => {
257            if let Some(n) = i.as_i64() {
258                n.to_string()
259            } else if let Some(n) = i.as_u64() {
260                n.to_string()
261            } else {
262                "0".to_string()
263            }
264        }
265        rmpv::Value::F32(f) => format!("{}", f),
266        rmpv::Value::F64(f) => format!("{}", f),
267        rmpv::Value::String(s) => {
268            if let Some(s) = s.as_str() {
269                format!("\"{}\"", escape_js_string(s))
270            } else {
271                "null".to_string()
272            }
273        }
274        rmpv::Value::Array(arr) => {
275            let items: Vec<String> = arr.iter().map(rmpv_to_js_literal).collect();
276            format!("[{}]", items.join(", "))
277        }
278        rmpv::Value::Map(entries) => {
279            let pairs: Vec<String> = entries
280                .iter()
281                .map(|(k, v)| {
282                    let key_str = match k {
283                        rmpv::Value::String(s) => {
284                            if let Some(s) = s.as_str() {
285                                format!("\"{}\"", escape_js_string(s))
286                            } else {
287                                "\"\"".to_string()
288                            }
289                        }
290                        _ => rmpv_to_js_literal(k),
291                    };
292                    format!("{}: {}", key_str, rmpv_to_js_literal(v))
293                })
294                .collect();
295            format!("{{{}}}", pairs.join(", "))
296        }
297        rmpv::Value::Binary(b) => {
298            // Encode as a Uint8Array literal
299            let items: Vec<String> = b.iter().map(|byte| byte.to_string()).collect();
300            format!("new Uint8Array([{}])", items.join(", "))
301        }
302        rmpv::Value::Ext(_, _) => "null".to_string(),
303    }
304}
305
306/// Escape a string for inclusion in a JavaScript string literal.
307fn escape_js_string(s: &str) -> String {
308    let mut out = String::with_capacity(s.len());
309    for ch in s.chars() {
310        match ch {
311            '\\' => out.push_str("\\\\"),
312            '"' => out.push_str("\\\""),
313            '\n' => out.push_str("\\n"),
314            '\r' => out.push_str("\\r"),
315            '\t' => out.push_str("\\t"),
316            '\0' => out.push_str("\\0"),
317            c => out.push(c),
318        }
319    }
320    out
321}
322
323// ============================================================================
324// C ABI callback functions (wired from lib.rs vtable)
325// ============================================================================
326
327pub unsafe extern "C" fn ts_init(config: *const u8, config_len: usize) -> *mut c_void {
328    let config_slice = if config.is_null() || config_len == 0 {
329        &[]
330    } else {
331        unsafe { std::slice::from_raw_parts(config, config_len) }
332    };
333
334    match TsRuntime::new(config_slice) {
335        Ok(runtime) => Box::into_raw(Box::new(runtime)) as *mut c_void,
336        Err(_) => std::ptr::null_mut(),
337    }
338}
339
340pub unsafe extern "C" fn ts_register_types(
341    instance: *mut c_void,
342    types_msgpack: *const u8,
343    types_len: usize,
344) -> i32 {
345    if instance.is_null() {
346        return PluginError::NotInitialized as i32;
347    }
348    let runtime = unsafe { &mut *(instance as *mut TsRuntime) };
349    let types_slice = if types_msgpack.is_null() || types_len == 0 {
350        &[]
351    } else {
352        unsafe { std::slice::from_raw_parts(types_msgpack, types_len) }
353    };
354
355    match runtime.register_types(types_slice) {
356        Ok(()) => PluginError::Success as i32,
357        Err(_) => PluginError::InternalError as i32,
358    }
359}
360
361pub unsafe extern "C" fn ts_compile(
362    instance: *mut c_void,
363    name: *const u8,
364    name_len: usize,
365    source: *const u8,
366    source_len: usize,
367    param_names_msgpack: *const u8,
368    param_names_len: usize,
369    param_types_msgpack: *const u8,
370    param_types_len: usize,
371    return_type: *const u8,
372    return_type_len: usize,
373    is_async: bool,
374    out_error: *mut *mut u8,
375    out_error_len: *mut usize,
376) -> *mut c_void {
377    if instance.is_null() {
378        return std::ptr::null_mut();
379    }
380    let runtime = unsafe { &mut *(instance as *mut TsRuntime) };
381
382    let name_str = match str_from_raw(name, name_len) {
383        Some(s) => s,
384        None => {
385            write_error(out_error, out_error_len, "invalid function name");
386            return std::ptr::null_mut();
387        }
388    };
389    let source_str = match str_from_raw(source, source_len) {
390        Some(s) => s,
391        None => {
392            write_error(out_error, out_error_len, "invalid source text");
393            return std::ptr::null_mut();
394        }
395    };
396    let return_type_str = match str_from_raw(return_type, return_type_len) {
397        Some(s) => s,
398        None => "any",
399    };
400
401    let param_names: Vec<String> = if param_names_msgpack.is_null() || param_names_len == 0 {
402        Vec::new()
403    } else {
404        let slice = unsafe { std::slice::from_raw_parts(param_names_msgpack, param_names_len) };
405        match rmp_serde::from_slice(slice) {
406            Ok(v) => v,
407            Err(_) => {
408                write_error(out_error, out_error_len, "invalid param names msgpack");
409                return std::ptr::null_mut();
410            }
411        }
412    };
413
414    let param_types: Vec<String> = if param_types_msgpack.is_null() || param_types_len == 0 {
415        Vec::new()
416    } else {
417        let slice = unsafe { std::slice::from_raw_parts(param_types_msgpack, param_types_len) };
418        match rmp_serde::from_slice(slice) {
419            Ok(v) => v,
420            Err(_) => {
421                write_error(out_error, out_error_len, "invalid param types msgpack");
422                return std::ptr::null_mut();
423            }
424        }
425    };
426
427    match runtime.compile(
428        name_str,
429        source_str,
430        &param_names,
431        &param_types,
432        return_type_str,
433        is_async,
434    ) {
435        Ok(handle) => handle,
436        Err(msg) => {
437            write_error(out_error, out_error_len, &msg);
438            std::ptr::null_mut()
439        }
440    }
441}
442
443/// Write a UTF-8 error message to out_error/out_error_len for the caller to free.
444fn write_error(out_error: *mut *mut u8, out_error_len: *mut usize, msg: &str) {
445    if out_error.is_null() || out_error_len.is_null() {
446        return;
447    }
448    let mut bytes = msg.as_bytes().to_vec();
449    let len = bytes.len();
450    let ptr = bytes.as_mut_ptr();
451    std::mem::forget(bytes);
452    unsafe {
453        *out_error = ptr;
454        *out_error_len = len;
455    }
456}
457
458pub unsafe extern "C" fn ts_invoke(
459    instance: *mut c_void,
460    handle: *mut c_void,
461    args_msgpack: *const u8,
462    args_len: usize,
463    out_ptr: *mut *mut u8,
464    out_len: *mut usize,
465) -> i32 {
466    if instance.is_null() || out_ptr.is_null() || out_len.is_null() {
467        return PluginError::InvalidArgument as i32;
468    }
469    let runtime = unsafe { &mut *(instance as *mut TsRuntime) };
470    let args_slice = if args_msgpack.is_null() || args_len == 0 {
471        &[]
472    } else {
473        unsafe { std::slice::from_raw_parts(args_msgpack, args_len) }
474    };
475
476    match runtime.invoke(handle, args_slice) {
477        Ok(mut bytes) => {
478            let len = bytes.len();
479            let ptr = bytes.as_mut_ptr();
480            std::mem::forget(bytes);
481            unsafe {
482                *out_ptr = ptr;
483                *out_len = len;
484            }
485            PluginError::Success as i32
486        }
487        Err(msg) => {
488            // Write error message to output buffer so the host can read it
489            let mut err_bytes = msg.into_bytes();
490            let len = err_bytes.len();
491            let ptr = err_bytes.as_mut_ptr();
492            std::mem::forget(err_bytes);
493            unsafe {
494                *out_ptr = ptr;
495                *out_len = len;
496            }
497            PluginError::NotImplemented as i32
498        }
499    }
500}
501
502pub unsafe extern "C" fn ts_dispose_function(instance: *mut c_void, handle: *mut c_void) {
503    if instance.is_null() {
504        return;
505    }
506    let runtime = unsafe { &mut *(instance as *mut TsRuntime) };
507    runtime.dispose_function(handle);
508}
509
510pub unsafe extern "C" fn ts_language_id(_instance: *mut c_void) -> *const std::ffi::c_char {
511    // "typescript\0" -- static, owned by the extension.
512    c"typescript".as_ptr()
513}
514
515pub unsafe extern "C" fn ts_get_lsp_config(
516    _instance: *mut c_void,
517    out_ptr: *mut *mut u8,
518    out_len: *mut usize,
519) -> i32 {
520    if out_ptr.is_null() || out_len.is_null() {
521        return PluginError::InvalidArgument as i32;
522    }
523    let config = TsRuntime::lsp_config();
524    match rmp_serde::to_vec(&config) {
525        Ok(mut bytes) => {
526            let len = bytes.len();
527            let ptr = bytes.as_mut_ptr();
528            std::mem::forget(bytes);
529            unsafe {
530                *out_ptr = ptr;
531                *out_len = len;
532            }
533            PluginError::Success as i32
534        }
535        Err(_) => PluginError::InternalError as i32,
536    }
537}
538
539pub unsafe extern "C" fn ts_free_buffer(ptr: *mut u8, len: usize) {
540    if !ptr.is_null() && len > 0 {
541        let _ = unsafe { Vec::from_raw_parts(ptr, len, len) };
542    }
543}
544
545pub unsafe extern "C" fn ts_drop(instance: *mut c_void) {
546    if !instance.is_null() {
547        let _ = unsafe { Box::from_raw(instance as *mut TsRuntime) };
548    }
549}
550
551// ============================================================================
552// Helpers
553// ============================================================================
554
555fn str_from_raw<'a>(ptr: *const u8, len: usize) -> Option<&'a str> {
556    if ptr.is_null() || len == 0 {
557        return None;
558    }
559    let slice = unsafe { std::slice::from_raw_parts(ptr, len) };
560    std::str::from_utf8(slice).ok()
561}
562
563#[cfg(test)]
564mod tests {
565    use super::*;
566
567    #[test]
568    fn lsp_config_exposes_typescript_defaults() {
569        let config = TsRuntime::lsp_config();
570        assert_eq!(config.language_id, "typescript");
571        assert_eq!(
572            config.server_command,
573            vec![
574                "typescript-language-server".to_string(),
575                "--stdio".to_string()
576            ]
577        );
578        assert_eq!(config.file_extension, ".ts");
579        assert!(config.extra_paths.is_empty());
580    }
581
582    #[test]
583    fn ts_get_lsp_config_returns_valid_msgpack_payload() {
584        let mut out_ptr: *mut u8 = std::ptr::null_mut();
585        let mut out_len: usize = 0;
586
587        let code = unsafe { ts_get_lsp_config(std::ptr::null_mut(), &mut out_ptr, &mut out_len) };
588        assert_eq!(code, PluginError::Success as i32);
589        assert!(!out_ptr.is_null());
590        assert!(out_len > 0);
591
592        let bytes = unsafe { std::slice::from_raw_parts(out_ptr, out_len) };
593        let decoded: LanguageRuntimeLspConfig =
594            rmp_serde::from_slice(bytes).expect("payload should decode");
595        assert_eq!(decoded.language_id, "typescript");
596        assert_eq!(decoded.file_extension, ".ts");
597
598        unsafe { ts_free_buffer(out_ptr, out_len) };
599    }
600}