790 lines
23 KiB
Rust
790 lines
23 KiB
Rust
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<String> {
|
|
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<Vec<Param>> {
|
|
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<Vec<FunctionDeclaration>> {
|
|
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<'_>> {
|
|
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<String> {
|
|
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<String, String> {
|
|
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<Node<'_>>, src: &str) -> Result<String> {
|
|
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<String> {
|
|
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<Node<'_>> {
|
|
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<String> {
|
|
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<String> {
|
|
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<Vec<FunctionDeclaration>> {
|
|
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<String, JsonSchema> {
|
|
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>,
|
|
): 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");
|
|
}
|
|
}
|