use crate::function::FunctionDeclaration; use crate::parsers::common::{self, Param, ScriptedLanguage}; use anyhow::{Context, Result, anyhow, bail}; use indexmap::IndexMap; use serde_json::Value; use std::fs::File; use std::io::Read; use std::path::Path; use tree_sitter::Node; pub(crate) struct TypeScriptLanguage; impl ScriptedLanguage for TypeScriptLanguage { fn ts_language(&self) -> tree_sitter::Language { tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into() } fn lang_name(&self) -> &str { "typescript" } fn find_functions<'a>(&self, root: Node<'a>, _src: &str) -> Vec<(Node<'a>, Node<'a>)> { let mut cursor = root.walk(); root.named_children(&mut cursor) .filter_map(|stmt| match stmt.kind() { "export_statement" => unwrap_exported_function(stmt).map(|fd| (stmt, fd)), _ => None, }) .collect() } fn function_name<'a>(&self, func_node: Node<'a>, src: &'a str) -> Result<&'a str> { let name_node = func_node .child_by_field_name("name") .ok_or_else(|| anyhow!("function_declaration missing name"))?; common::node_text(name_node, src) } fn extract_description( &self, wrapper_node: Node<'_>, func_node: Node<'_>, src: &str, ) -> Option { let text = jsdoc_text(wrapper_node, func_node, src)?; let lines = clean_jsdoc_lines(text); let mut description = Vec::new(); for line in lines { if line.starts_with('@') { break; } description.push(line); } let description = description.join("\n").trim().to_string(); (!description.is_empty()).then_some(description) } fn extract_params( &self, func_node: Node<'_>, src: &str, _description: &str, ) -> Result> { let parameters = func_node .child_by_field_name("parameters") .ok_or_else(|| anyhow!("function_declaration missing parameters"))?; let mut out = Vec::new(); let mut cursor = parameters.walk(); for param in parameters.named_children(&mut cursor) { match param.kind() { "required_parameter" | "optional_parameter" => { let name = parameter_name(param, src)?; let ty = get_arg_type(param.child_by_field_name("type"), src)?; let required = param.kind() == "required_parameter" && param.child_by_field_name("value").is_none(); let default = param.child_by_field_name("value").map(|_| Value::Null); out.push(common::build_param(name, ty, required, default)); } "rest_parameter" => { let line = param.start_position().row + 1; bail!("line {line}: rest parameters (...) are not supported in tool functions") } "object_pattern" => { let line = param.start_position().row + 1; bail!( "line {line}: destructured object parameters (e.g. '{{ a, b }}: {{ a: string }}') \ are not supported in tool functions. Use flat parameters instead (e.g. 'a: string, b: string')." ) } other => { let line = param.start_position().row + 1; bail!("line {line}: unsupported parameter type: {other}") } } } let wrapper = match func_node.parent() { Some(parent) if parent.kind() == "export_statement" => parent, _ => func_node, }; if let Some(doc) = jsdoc_text(wrapper, func_node, src) { let meta = parse_jsdoc_params(doc); for p in &mut out { if let Some(desc) = meta.get(&p.name) && !desc.is_empty() { p.doc_desc = Some(desc.clone()); } } } Ok(out) } } pub fn generate_typescript_declarations( mut tool_file: File, file_name: &str, parent: Option<&Path>, ) -> Result> { let mut src = String::new(); tool_file .read_to_string(&mut src) .with_context(|| format!("Failed to load script at '{tool_file:?}'"))?; let is_tool = parent .and_then(|p| p.file_name()) .is_some_and(|n| n == "tools"); common::generate_declarations(&TypeScriptLanguage, &src, file_name, is_tool) } fn unwrap_exported_function(node: Node<'_>) -> Option> { node.child_by_field_name("declaration") .filter(|child| child.kind() == "function_declaration") .or_else(|| { let mut cursor = node.walk(); node.named_children(&mut cursor) .find(|child| child.kind() == "function_declaration") }) } fn jsdoc_text<'a>(wrapper_node: Node<'_>, func_node: Node<'_>, src: &'a str) -> Option<&'a str> { wrapper_node .prev_named_sibling() .or_else(|| func_node.prev_named_sibling()) .filter(|node| node.kind() == "comment") .and_then(|node| common::node_text(node, src).ok()) .filter(|text| text.trim_start().starts_with("/**")) } fn clean_jsdoc_lines(doc: &str) -> Vec { let trimmed = doc.trim(); let inner = trimmed .strip_prefix("/**") .unwrap_or(trimmed) .strip_suffix("*/") .unwrap_or(trimmed); inner .lines() .map(|line| { let line = line.trim(); let line = line.strip_prefix('*').unwrap_or(line).trim_start(); line.to_string() }) .collect() } fn parse_jsdoc_params(doc: &str) -> IndexMap { let mut out = IndexMap::new(); for line in clean_jsdoc_lines(doc) { let Some(rest) = line.strip_prefix("@param") else { continue; }; let mut rest = rest.trim(); if rest.starts_with('{') && let Some(end) = rest.find('}') { rest = rest[end + 1..].trim_start(); } if rest.is_empty() { continue; } let name_end = rest.find(char::is_whitespace).unwrap_or(rest.len()); let mut name = rest[..name_end].trim(); if let Some(stripped) = name.strip_suffix('?') { name = stripped; } if name.is_empty() { continue; } let mut desc = rest[name_end..].trim(); if let Some(stripped) = desc.strip_prefix('-') { desc = stripped.trim_start(); } out.insert(name.to_string(), desc.to_string()); } out } fn parameter_name<'a>(node: Node<'_>, src: &'a str) -> Result<&'a str> { if let Some(name) = node.child_by_field_name("name") { return match name.kind() { "identifier" => common::node_text(name, src), "rest_pattern" => { let line = node.start_position().row + 1; bail!("line {line}: rest parameters (...) are not supported in tool functions") } "object_pattern" | "array_pattern" => { let line = node.start_position().row + 1; bail!( "line {line}: destructured parameters are not supported in tool functions. \ Use flat parameters instead (e.g. 'a: string, b: string')." ) } other => { let line = node.start_position().row + 1; bail!("line {line}: unsupported parameter type: {other}") } }; } let pattern = node .child_by_field_name("pattern") .ok_or_else(|| anyhow!("parameter missing pattern"))?; match pattern.kind() { "identifier" => common::node_text(pattern, src), "rest_pattern" => { let line = node.start_position().row + 1; bail!("line {line}: rest parameters (...) are not supported in tool functions") } "object_pattern" | "array_pattern" => { let line = node.start_position().row + 1; bail!( "line {line}: destructured parameters are not supported in tool functions. \ Use flat parameters instead (e.g. 'a: string, b: string')." ) } other => { let line = node.start_position().row + 1; bail!("line {line}: unsupported parameter type: {other}") } } } fn get_arg_type(annotation: Option>, src: &str) -> Result { let Some(annotation) = annotation else { return Ok(String::new()); }; match annotation.kind() { "type_annotation" | "type" => get_arg_type(common::named_child(annotation, 0), src), "predefined_type" => Ok(match common::node_text(annotation, src)? { "string" => "str", "number" => "float", "boolean" => "bool", "any" | "unknown" | "void" | "undefined" => "any", _ => "any", } .to_string()), "type_identifier" | "nested_type_identifier" => Ok("any".to_string()), "generic_type" => { let name = annotation .child_by_field_name("name") .ok_or_else(|| anyhow!("generic_type missing name"))?; let type_name = common::node_text(name, src)?; let type_args = annotation .child_by_field_name("type_arguments") .ok_or_else(|| anyhow!("generic_type missing type arguments"))?; let inner = common::named_child(type_args, 0) .ok_or_else(|| anyhow!("generic_type missing inner type"))?; match type_name { "Array" => Ok(format!("list[{}]", get_arg_type(Some(inner), src)?)), _ => Ok("any".to_string()), } } "array_type" => { let inner = common::named_child(annotation, 0) .ok_or_else(|| anyhow!("array_type missing inner type"))?; Ok(format!("list[{}]", get_arg_type(Some(inner), src)?)) } "union_type" => resolve_union_type(annotation, src), "literal_type" => resolve_literal_type(annotation, src), "parenthesized_type" => get_arg_type(common::named_child(annotation, 0), src), _ => Ok("any".to_string()), } } fn resolve_union_type(annotation: Node<'_>, src: &str) -> Result { let members = flatten_union_members(annotation); let has_null = members.iter().any(|member| is_nullish_type(*member, src)); let mut literal_values = Vec::new(); let mut all_string_literals = true; for member in &members { match string_literal_member(*member, src) { Some(value) => literal_values.push(value), None => { all_string_literals = false; break; } } } if all_string_literals && !literal_values.is_empty() { return Ok(format!("literal:{}", literal_values.join("|"))); } let mut first_non_null = None; for member in members { if is_nullish_type(member, src) { continue; } first_non_null = Some(get_arg_type(Some(member), src)?); break; } let mut ty = first_non_null.unwrap_or_else(|| "any".to_string()); if has_null && !ty.ends_with('?') { ty.push('?'); } Ok(ty) } fn flatten_union_members(node: Node<'_>) -> Vec> { let node = if node.kind() == "type" { match common::named_child(node, 0) { Some(inner) => inner, None => return vec![], } } else { node }; if node.kind() != "union_type" { return vec![node]; } let mut cursor = node.walk(); let mut out = Vec::new(); for child in node.named_children(&mut cursor) { out.extend(flatten_union_members(child)); } out } fn resolve_literal_type(annotation: Node<'_>, src: &str) -> Result { let inner = common::named_child(annotation, 0) .ok_or_else(|| anyhow!("literal_type missing inner literal"))?; match inner.kind() { "string" | "number" | "true" | "false" | "unary_expression" => { Ok(format!("literal:{}", common::node_text(inner, src)?.trim())) } "null" | "undefined" => Ok("any".to_string()), _ => Ok("any".to_string()), } } fn string_literal_member(node: Node<'_>, src: &str) -> Option { let node = if node.kind() == "type" { common::named_child(node, 0)? } else { node }; if node.kind() != "literal_type" { return None; } let inner = common::named_child(node, 0)?; if inner.kind() != "string" { return None; } Some(common::node_text(inner, src).ok()?.to_string()) } fn is_nullish_type(node: Node<'_>, src: &str) -> bool { let node = if node.kind() == "type" { match common::named_child(node, 0) { Some(inner) => inner, None => return false, } } else { node }; match node.kind() { "literal_type" => common::named_child(node, 0) .is_some_and(|inner| matches!(inner.kind(), "null" | "undefined")), "predefined_type" => common::node_text(node, src) .map(|text| matches!(text, "undefined" | "void")) .unwrap_or(false), _ => false, } } #[cfg(test)] mod tests { use super::*; use crate::function::JsonSchema; use std::fs; use std::time::{SystemTime, UNIX_EPOCH}; fn parse_ts_source( source: &str, file_name: &str, parent: &Path, ) -> Result> { let unique = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("time") .as_nanos(); let path = std::env::temp_dir().join(format!("loki_ts_parser_{file_name}_{unique}.ts")); fs::write(&path, source).expect("write"); let file = File::open(&path).expect("open"); let result = generate_typescript_declarations(file, file_name, Some(parent)); let _ = fs::remove_file(&path); result } fn properties(schema: &JsonSchema) -> &IndexMap { schema .properties .as_ref() .expect("missing schema properties") } fn property<'a>(schema: &'a JsonSchema, name: &str) -> &'a JsonSchema { properties(schema) .get(name) .unwrap_or_else(|| panic!("missing property: {name}")) } #[test] fn test_ts_tool_demo() { let source = r#" /** * Demonstrates how to create a tool using TypeScript. * * @param query - The search query string * @param format - Output format * @param count - Maximum results to return * @param verbose - Enable verbose output * @param tags - List of tags to filter by * @param language - Optional language filter * @param extra_tags - Optional extra tags */ export function run( query: string, format: "json" | "csv" | "xml", count: number, verbose: boolean, tags: string[], language?: string, extra_tags?: Array, ): string { return "result"; } "#; let declarations = parse_ts_source(source, "demo_ts", Path::new("tools")).unwrap(); assert_eq!(declarations.len(), 1); let decl = &declarations[0]; assert_eq!(decl.name, "demo_ts"); assert!(!decl.agent); let params = &decl.parameters; assert_eq!(params.type_value.as_deref(), Some("object")); assert_eq!( params.required.as_ref().unwrap(), &vec![ "query".to_string(), "format".to_string(), "count".to_string(), "verbose".to_string(), "tags".to_string(), ] ); assert_eq!( property(params, "query").type_value.as_deref(), Some("string") ); let format = property(params, "format"); assert_eq!(format.type_value.as_deref(), Some("string")); assert_eq!( format.enum_value.as_ref().unwrap(), &vec!["json".to_string(), "csv".to_string(), "xml".to_string()] ); assert_eq!( property(params, "count").type_value.as_deref(), Some("number") ); assert_eq!( property(params, "verbose").type_value.as_deref(), Some("boolean") ); let tags = property(params, "tags"); assert_eq!(tags.type_value.as_deref(), Some("array")); assert_eq!( tags.items.as_ref().unwrap().type_value.as_deref(), Some("string") ); let language = property(params, "language"); assert_eq!(language.type_value.as_deref(), Some("string")); assert!( !params .required .as_ref() .unwrap() .contains(&"language".to_string()) ); let extra_tags = property(params, "extra_tags"); assert_eq!(extra_tags.type_value.as_deref(), Some("array")); assert_eq!( extra_tags.items.as_ref().unwrap().type_value.as_deref(), Some("string") ); assert!( !params .required .as_ref() .unwrap() .contains(&"extra_tags".to_string()) ); } #[test] fn test_ts_tool_simple() { let source = r#" /** * Execute the given code. * * @param code - The code to execute */ export function run(code: string): string { return eval(code); } "#; let declarations = parse_ts_source(source, "execute_code", Path::new("tools")).unwrap(); assert_eq!(declarations.len(), 1); let decl = &declarations[0]; assert_eq!(decl.name, "execute_code"); assert!(!decl.agent); let params = &decl.parameters; assert_eq!(params.required.as_ref().unwrap(), &vec!["code".to_string()]); assert_eq!( property(params, "code").type_value.as_deref(), Some("string") ); } #[test] fn test_ts_agent_tools() { let source = r#" /** Get user info by ID */ export function get_user(id: string): string { return ""; } /** List all users */ export function list_users(): string { return ""; } "#; let declarations = parse_ts_source(source, "tools", Path::new("demo")).unwrap(); assert_eq!(declarations.len(), 2); assert_eq!(declarations[0].name, "get_user"); assert_eq!(declarations[1].name, "list_users"); assert!(declarations[0].agent); assert!(declarations[1].agent); } #[test] fn test_ts_reject_rest_params() { let source = r#" /** * Has rest params */ export function run(...args: string[]): string { return ""; } "#; let err = parse_ts_source(source, "rest_params", Path::new("tools")).unwrap_err(); let msg = format!("{err:#}"); assert!(msg.contains("rest parameters")); assert!(msg.contains("in function 'run'")); } #[test] fn test_ts_missing_jsdoc() { let source = r#" export function run(x: string): string { return x; } "#; let err = parse_ts_source(source, "missing_jsdoc", Path::new("tools")).unwrap_err(); assert!( err.to_string() .contains("Missing or empty description on function: run") ); } #[test] fn test_ts_syntax_error() { let source = "export function run(: broken"; let err = parse_ts_source(source, "syntax_error", Path::new("tools")).unwrap_err(); assert!(err.to_string().contains("failed to parse typescript")); } #[test] fn test_ts_underscore_skipped() { let source = r#" /** Private helper */ function _helper(): void {} /** Public function */ export function do_stuff(): string { return ""; } "#; let declarations = parse_ts_source(source, "tools", Path::new("demo")).unwrap(); assert_eq!(declarations.len(), 1); assert_eq!(declarations[0].name, "do_stuff"); assert!(declarations[0].agent); } #[test] fn test_ts_non_exported_helpers_skipped() { let source = r#" #!/usr/bin/env tsx import { appendFileSync } from 'fs'; /** * Get the current weather in a given location * @param location - The city */ export function get_current_weather(location: string): string { return fetchSync("https://example.com/" + location); } function fetchSync(url: string): string { return "sunny"; } "#; let declarations = parse_ts_source(source, "tools", Path::new("demo")).unwrap(); assert_eq!(declarations.len(), 1); assert_eq!(declarations[0].name, "get_current_weather"); } #[test] fn test_ts_instructions_not_skipped() { let source = r#" /** Help text for the agent */ export function _instructions(): string { return ""; } "#; let declarations = parse_ts_source(source, "tools", Path::new("demo")).unwrap(); assert_eq!(declarations.len(), 1); assert_eq!(declarations[0].name, "instructions"); assert!(declarations[0].agent); } #[test] fn test_ts_optional_with_null_union() { let source = r#" /** * Fetch data with optional filter * * @param url - The URL to fetch * @param filter - Optional filter string */ export function run(url: string, filter: string | null): string { return ""; } "#; let declarations = parse_ts_source(source, "fetch_data", Path::new("tools")).unwrap(); let params = &declarations[0].parameters; assert!( params .required .as_ref() .unwrap() .contains(&"url".to_string()) ); assert!( !params .required .as_ref() .unwrap() .contains(&"filter".to_string()) ); assert_eq!( property(params, "filter").type_value.as_deref(), Some("string") ); } #[test] fn test_ts_optional_with_default() { let source = r#" /** * Search with limit * * @param query - Search query * @param limit - Max results */ export function run(query: string, limit: number = 10): string { return ""; } "#; let declarations = parse_ts_source(source, "search_with_limit", Path::new("tools")).unwrap(); let params = &declarations[0].parameters; assert!( params .required .as_ref() .unwrap() .contains(&"query".to_string()) ); assert!( !params .required .as_ref() .unwrap() .contains(&"limit".to_string()) ); assert_eq!( property(params, "limit").type_value.as_deref(), Some("number") ); } #[test] fn test_ts_shebang_parses() { let source = r#"#!/usr/bin/env tsx /** * Get weather * @param location - The city */ export function run(location: string): string { return location; } "#; let result = parse_ts_source(source, "get_weather", Path::new("tools")); eprintln!("shebang parse result: {result:?}"); assert!(result.is_ok(), "shebang should not cause parse failure"); let declarations = result.unwrap(); assert_eq!(declarations.len(), 1); assert_eq!(declarations[0].name, "get_weather"); } }