just_mcp_lib/
parser.rs

1use snafu::prelude::*;
2use std::collections::HashMap;
3use std::fs;
4use std::path::Path;
5
6use crate::{Justfile, Parameter, Recipe};
7
8#[derive(Debug, Snafu)]
9pub enum ParserError {
10    #[snafu(display("Failed to read file {}: {}", path.display(), source))]
11    FileRead {
12        path: std::path::PathBuf,
13        source: std::io::Error,
14    },
15
16    #[snafu(display("Parse error at line {}: {}", line, message))]
17    ParseError { line: usize, message: String },
18
19    #[snafu(display("Invalid recipe syntax: {}", message))]
20    InvalidRecipe { message: String },
21}
22
23pub type Result<T> = std::result::Result<T, ParserError>;
24
25pub fn parse_justfile(path: &Path) -> Result<Justfile> {
26    let content = fs::read_to_string(path).context(FileReadSnafu { path })?;
27    parse_justfile_str(&content)
28}
29
30pub fn parse_justfile_str(content: &str) -> Result<Justfile> {
31    let mut recipes = Vec::new();
32    let mut variables = HashMap::new();
33    let mut current_recipe: Option<Recipe> = None;
34    let mut current_doc: Option<String> = None;
35    for (line_number, line) in content.lines().enumerate() {
36        let line_number = line_number + 1;
37        let trimmed = line.trim();
38
39        // Skip empty lines
40        if trimmed.is_empty() {
41            continue;
42        }
43
44        // Handle comments and documentation
45        if let Some(stripped) = trimmed.strip_prefix('#') {
46            let comment = stripped.trim();
47            if !comment.is_empty() {
48                current_doc = Some(comment.to_string());
49            }
50            continue;
51        }
52
53        // Handle variable assignments
54        if let Some((key, value)) = parse_variable_assignment(trimmed) {
55            variables.insert(key, value);
56            continue;
57        }
58
59        // Handle recipe definitions
60        if let Some(recipe) = parse_recipe_line(trimmed, current_doc.take())? {
61            // If we have a current recipe, save it
62            if let Some(existing_recipe) = current_recipe.take() {
63                recipes.push(existing_recipe);
64            }
65
66            current_recipe = Some(recipe);
67            continue;
68        }
69
70        // Handle recipe body lines (indented)
71        if line.starts_with('\t') || line.starts_with("    ") {
72            if let Some(ref mut recipe) = current_recipe {
73                if !recipe.body.is_empty() {
74                    recipe.body.push('\n');
75                }
76                recipe.body.push_str(line);
77            }
78            continue;
79        }
80
81        // If we reach here with a non-empty line that doesn't match patterns, it's an error
82        if !trimmed.is_empty() {
83            return Err(ParserError::ParseError {
84                line: line_number,
85                message: format!("Unexpected content: {trimmed}"),
86            });
87        }
88    }
89
90    // Don't forget the last recipe
91    if let Some(recipe) = current_recipe {
92        recipes.push(recipe);
93    }
94
95    Ok(Justfile { recipes, variables })
96}
97
98fn parse_recipe_header(header: &str) -> Result<Vec<String>> {
99    let mut parts = Vec::new();
100    let mut current_part = String::new();
101    let mut in_quotes = false;
102    let mut quote_char = '\0';
103
104    for ch in header.chars() {
105        match ch {
106            '"' | '\'' if !in_quotes => {
107                in_quotes = true;
108                quote_char = ch;
109                current_part.push(ch);
110            }
111            c if c == quote_char && in_quotes => {
112                in_quotes = false;
113                current_part.push(ch);
114                quote_char = '\0';
115            }
116            ' ' if !in_quotes => {
117                if !current_part.is_empty() {
118                    parts.push(current_part.trim().to_string());
119                    current_part.clear();
120                }
121            }
122            _ => {
123                current_part.push(ch);
124            }
125        }
126    }
127
128    // Add the last part if not empty
129    if !current_part.is_empty() {
130        parts.push(current_part.trim().to_string());
131    }
132
133    Ok(parts)
134}
135
136fn parse_variable_assignment(line: &str) -> Option<(String, String)> {
137    if let Some((key, value)) = line.split_once('=') {
138        let key = key.trim();
139        let value = value.trim();
140
141        // Basic validation - key must be a valid identifier
142        if key.chars().all(|c| c.is_alphanumeric() || c == '_') && !key.is_empty() {
143            return Some((key.to_string(), value.to_string()));
144        }
145    }
146    None
147}
148
149fn parse_recipe_line(line: &str, documentation: Option<String>) -> Result<Option<Recipe>> {
150    // Recipe format: name param1 param2='default' *param3: dependency1 dependency2
151    if let Some(colon_pos) = line.find(':') {
152        let (header, deps_part) = line.split_at(colon_pos);
153        let deps_part = deps_part[1..].trim(); // Remove the ':'
154
155        let header = header.trim();
156        let parts = parse_recipe_header(header)?;
157
158        if parts.is_empty() {
159            return Ok(None);
160        }
161
162        let name = parts[0].to_string();
163        let mut parameters = Vec::new();
164
165        // Parse parameters
166        for param_str in &parts[1..] {
167            let parameter = parse_parameter(param_str)?;
168            parameters.push(parameter);
169        }
170
171        // Parse dependencies
172        let dependencies: Vec<String> = if deps_part.is_empty() {
173            Vec::new()
174        } else {
175            deps_part
176                .split_whitespace()
177                .map(|s| s.to_string())
178                .collect()
179        };
180
181        return Ok(Some(Recipe {
182            name,
183            parameters,
184            documentation,
185            body: String::new(),
186            dependencies,
187        }));
188    }
189
190    Ok(None)
191}
192
193fn parse_parameter(param_str: &str) -> Result<Parameter> {
194    if let Some((name, default)) = param_str.split_once('=') {
195        // Parameter with default value
196        let name = name.trim();
197        let default = default.trim().trim_matches('"').trim_matches('\'');
198
199        Ok(Parameter {
200            name: name.to_string(),
201            default_value: Some(default.to_string()),
202        })
203    } else {
204        // Parameter without default
205        let name = param_str.trim();
206
207        // Handle variadic parameters (prefixed with *)
208        let name = if let Some(stripped) = name.strip_prefix('*') {
209            stripped
210        } else {
211            name
212        };
213
214        Ok(Parameter {
215            name: name.to_string(),
216            default_value: None,
217        })
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    #[test]
226    fn test_parse_simple_recipe() {
227        let content = r#"
228# Build the project
229build:
230    cargo build
231"#;
232
233        let justfile = parse_justfile_str(content).unwrap();
234        assert_eq!(justfile.recipes.len(), 1);
235
236        let recipe = &justfile.recipes[0];
237        assert_eq!(recipe.name, "build");
238        assert_eq!(recipe.documentation, Some("Build the project".to_string()));
239        assert!(recipe.parameters.is_empty());
240        assert!(recipe.dependencies.is_empty());
241        assert!(recipe.body.contains("cargo build"));
242    }
243
244    #[test]
245    fn test_parse_recipe_with_parameters() {
246        let content = r#"
247deploy env target='production':
248    echo "Deploying to {{ env }} {{ target }}"
249"#;
250
251        let justfile = parse_justfile_str(content).unwrap();
252        assert_eq!(justfile.recipes.len(), 1);
253
254        let recipe = &justfile.recipes[0];
255        assert_eq!(recipe.name, "deploy");
256        assert_eq!(recipe.parameters.len(), 2);
257        assert_eq!(recipe.parameters[0].name, "env");
258        assert_eq!(recipe.parameters[0].default_value, None);
259        assert_eq!(recipe.parameters[1].name, "target");
260        assert_eq!(
261            recipe.parameters[1].default_value,
262            Some("production".to_string())
263        );
264    }
265
266    #[test]
267    fn test_parse_recipe_with_dependencies() {
268        let content = r#"
269test: build
270    cargo test
271
272build:
273    cargo build
274"#;
275
276        let justfile = parse_justfile_str(content).unwrap();
277        assert_eq!(justfile.recipes.len(), 2);
278
279        let test_recipe = &justfile.recipes[0];
280        assert_eq!(test_recipe.name, "test");
281        assert_eq!(test_recipe.dependencies, vec!["build"]);
282    }
283
284    #[test]
285    fn test_parse_variables() {
286        let content = r#"
287version = "1.0.0"
288debug = true
289
290build:
291    echo "Building version {{ version }}"
292"#;
293
294        let justfile = parse_justfile_str(content).unwrap();
295        assert_eq!(justfile.variables.len(), 2);
296        assert_eq!(
297            justfile.variables.get("version"),
298            Some(&"\"1.0.0\"".to_string())
299        );
300        assert_eq!(justfile.variables.get("debug"), Some(&"true".to_string()));
301    }
302
303    #[test]
304    fn test_parse_recipe_with_quoted_parameters() {
305        let content = r#"
306write_file filename content="Hello from just-mcp!":
307    @echo "{{content}}" > {{filename}}
308"#;
309
310        let justfile = parse_justfile_str(content).unwrap();
311        assert_eq!(justfile.recipes.len(), 1);
312
313        let recipe = &justfile.recipes[0];
314        assert_eq!(recipe.name, "write_file");
315        assert_eq!(recipe.parameters.len(), 2);
316
317        assert_eq!(recipe.parameters[0].name, "filename");
318        assert_eq!(recipe.parameters[0].default_value, None);
319
320        assert_eq!(recipe.parameters[1].name, "content");
321        assert_eq!(
322            recipe.parameters[1].default_value,
323            Some("Hello from just-mcp!".to_string())
324        );
325    }
326}