use serde_json::{Map, Value};
use crate::client::ToolCall;
use crate::{Error, Result};
fn is_identifier(s: &str) -> bool {
let mut chars = s.chars();
match chars.next() {
Some(c) if c == '_' || c.is_ascii_alphabetic() => {}
_ => return false,
}
chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
}
pub(crate) fn parse_tool_code_call(line: &str, index: usize) -> Option<ToolCall> {
let line = line.trim();
let open = line.find('(')?;
if !line.ends_with(')') {
return None;
}
let close = line.len() - 1;
if close <= open {
return None;
}
let name = &line[..open];
if !is_identifier(name) {
return None;
}
let args_src = &line[open + 1..close];
let arguments = parse_tool_code_args(name, args_src)?;
Some(ToolCall {
id: format!("call_tool_code_{index}"),
name: name.to_string(),
arguments,
})
}
fn parse_tool_code_args(tool_name: &str, src: &str) -> Option<Value> {
let src = src.trim();
if src.is_empty() {
return Some(Value::Object(Map::new()));
}
let parts = split_top_level_commas(src)?;
if parts.iter().any(|part| part.trim().is_empty()) {
return None;
}
let assignments: Vec<Option<usize>> = parts
.iter()
.map(|part| top_level_assignment(part))
.collect();
let any_keyword = assignments.iter().any(Option::is_some);
let all_keyword = assignments.iter().all(Option::is_some);
if any_keyword && !all_keyword {
return None;
}
if all_keyword {
let mut map = Map::new();
for (part, eq) in parts.iter().zip(assignments) {
let eq = eq?;
let key = part[..eq].trim();
if !is_identifier(key) {
return None;
}
let value = parse_json_value(&part[eq + 1..])?;
if map.insert(key.to_string(), value).is_some() {
return None;
}
}
return Some(Value::Object(map));
}
let mut values = Vec::with_capacity(parts.len());
for part in &parts {
values.push(parse_json_value(part)?);
}
let keys = positional_arg_keys(tool_name, values.len())?;
let mut map = Map::new();
for (key, value) in keys.iter().zip(values) {
map.insert((*key).to_string(), value);
}
Some(Value::Object(map))
}
fn top_level_assignment(part: &str) -> Option<usize> {
let mut expected_closers: Vec<char> = Vec::new();
let mut in_quote: Option<char> = None;
let mut escaped = false;
for (idx, ch) in part.char_indices() {
if let Some(q) = in_quote {
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == q {
in_quote = None;
}
continue;
}
match ch {
'"' | '\'' => in_quote = Some(ch),
'(' => expected_closers.push(')'),
'[' => expected_closers.push(']'),
'{' => expected_closers.push('}'),
')' | ']' | '}' => {
expected_closers.pop();
}
'=' if expected_closers.is_empty() => return Some(idx),
_ => {}
}
}
None
}
fn parse_json_value(token: &str) -> Option<Value> {
let token = token.trim();
if token.is_empty() {
return None;
}
serde_json::from_str::<Value>(token).ok()
}
fn positional_arg_keys(tool_name: &str, count: usize) -> Option<&'static [&'static str]> {
match (tool_name, count) {
("search" | "web_search", 1) => Some(&["query"]),
("fetch" | "web_fetch", 1) => Some(&["url"]),
("echo", 1) => Some(&["value"]),
_ => None,
}
}
fn split_top_level_commas(src: &str) -> Option<Vec<&str>> {
let mut parts = Vec::new();
let mut start = 0;
let mut expected_closers: Vec<char> = Vec::new();
let mut in_quote: Option<char> = None;
let mut escaped = false;
for (idx, ch) in src.char_indices() {
if let Some(q) = in_quote {
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == q {
in_quote = None;
}
continue;
}
match ch {
'"' | '\'' => in_quote = Some(ch),
'(' => expected_closers.push(')'),
'[' => expected_closers.push(']'),
'{' => expected_closers.push('}'),
')' | ']' | '}' => {
if expected_closers.pop() != Some(ch) {
return None;
}
}
',' if expected_closers.is_empty() => {
parts.push(&src[start..idx]);
start = idx + ch.len_utf8();
}
_ => {}
}
}
if !expected_closers.is_empty() || in_quote.is_some() || escaped {
return None;
}
parts.push(&src[start..]);
Some(parts)
}
pub(crate) fn render_tool_code_fence(calls: &[ToolCall]) -> Result<String> {
let mut body = String::from("```tool_code\n");
for call in calls {
if !is_identifier(&call.name) {
return Err(Error::Internal(
"tool_code render: tool name is not a valid identifier",
));
}
body.push_str(&call.name);
body.push('(');
let Value::Object(map) = &call.arguments else {
return Err(Error::Internal(
"tool_code render: arguments must be a JSON object",
));
};
let mut first = true;
for (key, value) in map {
if !is_identifier(key) {
return Err(Error::Internal(
"tool_code render: argument key is not a valid identifier",
));
}
if !first {
body.push_str(", ");
}
first = false;
body.push_str(key);
body.push('=');
body.push_str(&render_json_value(value)?);
}
body.push_str(")\n");
}
body.push_str("```");
Ok(body)
}
fn render_json_value(value: &Value) -> Result<String> {
serde_json::to_string(value)
.map_err(|_| Error::Internal("tool_code render: value could not be serialized"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn split_top_level_commas_rejects_malformed_syntax() {
assert_eq!(
split_top_level_commas("a, b"),
Some(vec!["a", " b"]),
"balanced input splits"
);
assert_eq!(
split_top_level_commas("query=\"a\""),
Some(vec!["query=\"a\""])
);
assert_eq!(split_top_level_commas("a, (b"), None, "open delimiter");
assert_eq!(split_top_level_commas("a)b"), None, "unmatched close");
assert_eq!(split_top_level_commas("\"unterminated"), None, "open quote");
assert_eq!(split_top_level_commas("\"a\\"), None, "dangling escape");
assert_eq!(
split_top_level_commas("(a=[1)]"),
None,
"paren closed by bracket"
);
assert_eq!(
split_top_level_commas("[a}"),
None,
"bracket closed by brace"
);
assert_eq!(split_top_level_commas("{a)"), None, "brace closed by paren");
assert_eq!(
split_top_level_commas("([)]"),
None,
"interleaved delimiters"
);
assert_eq!(
split_top_level_commas("a=[1, 2], b={x: 1}"),
Some(vec!["a=[1, 2]", " b={x: 1}"]),
"correctly nested delimiters split only at top level"
);
}
#[test]
fn parse_tool_code_call_rejects_malformed_argument_syntax() {
assert!(parse_tool_code_call("search(query=\"a\", nested(b)", 0).is_none());
assert!(parse_tool_code_call("search(a, (b)", 0).is_none());
}
fn call(name: &str, args: Value) -> ToolCall {
ToolCall {
id: "id".into(),
name: name.into(),
arguments: args,
}
}
#[test]
fn value_grammar_round_trips_all_json_shapes() {
let original = call(
"tool",
serde_json::json!({
"s": "he said \"hi\"\nbye",
"n": 42,
"f": 3.5,
"b": true,
"nul": null,
"arr": [1, "two", false],
"obj": { "k": "v", "nested": [1, 2] }
}),
);
let fence = render_tool_code_fence(std::slice::from_ref(&original)).expect("renders");
let inner = fence
.trim_start_matches("```tool_code\n")
.trim_end_matches("\n```");
let reparsed = parse_tool_code_call(inner, 0).expect("re-parses");
assert_eq!(
reparsed.arguments, original.arguments,
"arguments must round-trip losslessly: {fence}"
);
}
#[test]
fn equals_inside_quoted_value_stays_positional() {
let parsed = parse_tool_code_call("search(\"a=b\")", 0).expect("parses");
assert_eq!(parsed.arguments, serde_json::json!({ "query": "a=b" }));
}
#[test]
fn string_escapes_are_decoded() {
let parsed = parse_tool_code_call("echo(value=\"line1\\nline2\")", 0).expect("parses");
assert_eq!(
parsed.arguments,
serde_json::json!({ "value": "line1\nline2" })
);
}
#[test]
fn mixed_positional_and_keyword_is_rejected() {
assert!(parse_tool_code_call("search(\"a\", count=3)", 0).is_none());
}
#[test]
fn duplicate_keyword_key_is_rejected() {
assert!(parse_tool_code_call("search(query=\"a\", query=\"b\")", 0).is_none());
}
#[test]
fn names_and_keys_must_be_identifiers_and_close_must_end_input() {
assert!(parse_tool_code_call("3search(query=\"a\")", 0).is_none());
assert!(parse_tool_code_call("search(a-b=\"x\")", 0).is_none());
assert!(parse_tool_code_call("search(query=\"a\") extra", 0).is_none());
assert!(parse_tool_code_call("search(query=bareword)", 0).is_none());
}
#[test]
fn render_rejects_shapes_the_grammar_cannot_represent() {
let bad_key = call("tool", serde_json::json!({ "a-b": 1 }));
assert!(matches!(
render_tool_code_fence(std::slice::from_ref(&bad_key)),
Err(Error::Internal(_))
));
let bad_name = call("3tool", serde_json::json!({}));
assert!(matches!(
render_tool_code_fence(std::slice::from_ref(&bad_name)),
Err(Error::Internal(_))
));
let bad_args = call("tool", serde_json::json!([1, 2]));
assert!(matches!(
render_tool_code_fence(std::slice::from_ref(&bad_args)),
Err(Error::Internal(_))
));
}
}