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 if trimmed.is_empty() {
41 continue;
42 }
43
44 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 if let Some((key, value)) = parse_variable_assignment(trimmed) {
55 variables.insert(key, value);
56 continue;
57 }
58
59 if let Some(recipe) = parse_recipe_line(trimmed, current_doc.take())? {
61 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 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 !trimmed.is_empty() {
83 return Err(ParserError::ParseError {
84 line: line_number,
85 message: format!("Unexpected content: {trimmed}"),
86 });
87 }
88 }
89
90 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 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 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 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(); 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 for param_str in &parts[1..] {
167 let parameter = parse_parameter(param_str)?;
168 parameters.push(parameter);
169 }
170
171 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 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 let name = param_str.trim();
206
207 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}