openfunctions_rs/parser/
bash.rs

1//! Bash script parser for extracting tool definitions from comments.
2//!
3//! This parser uses a simple, comment-based annotation syntax to define
4//! tools within Bash scripts. The syntax is designed to be readable and
5//! easy to write.
6//!
7//! The following annotations are supported:
8//! - `@describe <description>`: A description of the tool.
9//! - `@option --<name>...`: Defines a parameter for the tool. See below for details.
10//! - `@flag --<name>...`: Defines a boolean flag for the tool.
11//! - `@env <VAR_NAME>...`: Defines a required environment variable.
12//! - `@meta require-tools <tool1> <tool2>...`: Lists required external tools.
13//!
14//! For `@option`, the format is: `--<name>[!][<enum_values>][<type_hint>] <description>`
15//! - `!` indicates a required parameter.
16//! - `[...|...]` provides a list of enum values.
17//! - `<TYPE>` provides a type hint (e.g., INT, NUM).
18
19use crate::models::{EnvVarDefinition, ParameterDefinition, ParameterType, ToolDefinition};
20use anyhow::Result;
21use regex::Regex;
22use std::collections::HashMap;
23
24/// Parses a Bash script and extracts a `ToolDefinition`.
25///
26/// The parser scans for specially formatted comments (`# @...`) to build the
27/// tool's definition. A tool description (`@describe`) is mandatory.
28pub fn parse(source: &str) -> Result<ToolDefinition> {
29    let mut description = String::new();
30    let mut parameters = Vec::new();
31    let mut env_vars = Vec::new();
32    let mut required_tools = Vec::new();
33    let metadata = HashMap::new();
34
35    let describe_re = Regex::new(r"^#\s*@describe\s+(.+)$")?;
36    let option_re =
37        Regex::new(r"^#\s*@option\s+--([a-z0-9-]+)(!?)(?:\[([^\]]+)\])?(?:<([^>]+)>)?\s+(.*)$")?;
38    let flag_re = Regex::new(r"^#\s*@flag\s+--([a-z0-9-]+)\s+(.*)$")?;
39    let env_re = Regex::new(r"^#\s*@env\s+([A-Z0-9_]+)(!?)(?:=([^\s]+))?\s+(.*)$")?;
40    let meta_re = Regex::new(r"^#\s*@meta\s+require-tools\s+(.+)$")?;
41
42    for line in source.lines() {
43        let line = line.trim();
44
45        if let Some(caps) = describe_re.captures(line) {
46            description = caps.get(1).unwrap().as_str().to_string();
47        } else if let Some(caps) = option_re.captures(line) {
48            let name = caps.get(1).unwrap().as_str().replace('-', "_");
49            let required = !caps.get(2).unwrap().as_str().is_empty();
50            let enum_values = caps.get(3).map(|m| {
51                m.as_str()
52                    .split('|')
53                    .map(|s| s.to_string())
54                    .collect::<Vec<_>>()
55            });
56            let type_hint = caps.get(4).map(|m| m.as_str());
57            let desc = caps.get(5).unwrap().as_str().to_string();
58
59            let (param_type, enum_values_for_def) = if let Some(values) = enum_values {
60                (ParameterType::Enum(values.clone()), Some(values))
61            } else if let Some(hint) = type_hint {
62                let p_type = match hint.to_uppercase().as_str() {
63                    "INT" => ParameterType::Integer,
64                    "NUM" => ParameterType::Number,
65                    _ => ParameterType::String,
66                };
67                (p_type, None)
68            } else {
69                (ParameterType::String, None)
70            };
71
72            parameters.push(ParameterDefinition {
73                name,
74                param_type,
75                description: desc,
76                required,
77                default: None,
78                enum_values: enum_values_for_def,
79            });
80        } else if let Some(caps) = flag_re.captures(line) {
81            let name = caps.get(1).unwrap().as_str().replace('-', "_");
82            let desc = caps.get(2).unwrap().as_str().to_string();
83
84            parameters.push(ParameterDefinition {
85                name,
86                param_type: ParameterType::Boolean,
87                description: desc,
88                required: false,
89                default: Some(serde_json::Value::Bool(false)),
90                enum_values: None,
91            });
92        } else if let Some(caps) = env_re.captures(line) {
93            let name = caps.get(1).unwrap().as_str().to_string();
94            let required = !caps.get(2).unwrap().as_str().is_empty();
95            let default = caps.get(3).map(|m| m.as_str().to_string());
96            let desc = caps.get(4).unwrap().as_str().to_string();
97
98            env_vars.push(EnvVarDefinition {
99                name,
100                description: desc,
101                required,
102                default,
103            });
104        } else if let Some(caps) = meta_re.captures(line) {
105            required_tools = caps
106                .get(1)
107                .unwrap()
108                .as_str()
109                .split_whitespace()
110                .map(|s| s.to_string())
111                .collect();
112        }
113    }
114
115    if description.is_empty() {
116        anyhow::bail!("No @describe annotation found in bash script. A description is required.");
117    }
118
119    Ok(ToolDefinition {
120        description,
121        parameters,
122        env_vars,
123        required_tools,
124        metadata,
125    })
126}