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