Created
March 30, 2025 15:12
-
-
Save jmsdnns/b47fca5c42b292eba80a2cca616acffe to your computer and use it in GitHub Desktop.
my first try at building a parser for execution plans in killabeez
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
use nom::{ | |
IResult, Parser, | |
branch::alt, | |
bytes::complete::{tag, take_until}, | |
character::complete::{char, multispace0, space0}, | |
multi::many0, | |
sequence::delimited, | |
}; | |
pub mod errors; | |
use crate::errors::nom_utils::{convert_nom_result, handle_remaining}; | |
use crate::errors::{ParseError, ParseErrorKind, Result}; | |
#[derive(Debug, PartialEq)] | |
pub enum Command { | |
Execute(String), | |
Upload { | |
local_path: String, | |
remote_path: String, | |
}, | |
Download { | |
remote_path: String, | |
local_path: String, | |
}, | |
} | |
fn parse_quoted_string(input: &str) -> IResult<&str, String> { | |
let (input, _) = char('"').parse(input)?; | |
let (input, content) = take_until("\"").parse(input)?; | |
let (input, _) = char('"').parse(input)?; | |
Ok((input, content.to_string())) | |
} | |
fn parse_key_value<'a>(input: &'a str, expected_key: &str) -> IResult<&'a str, String> { | |
let (input, _) = multispace0.parse(input)?; | |
let (input, key) = take_until(":").parse(input)?; | |
if key.trim() != expected_key { | |
return Err(nom::Err::Error(nom::error::make_error( | |
input, | |
nom::error::ErrorKind::Tag, | |
))); | |
} | |
let (input, _) = char(':').parse(input)?; | |
let (input, _) = space0.parse(input)?; | |
let (input, value) = parse_quoted_string(input)?; | |
Ok((input, value)) | |
} | |
fn parse_execute(input: &str) -> IResult<&str, Command> { | |
let (input, _) = tag("execute").parse(input)?; | |
let (input, _) = char(':').parse(input)?; | |
let (input, _) = space0.parse(input)?; | |
let (input, cmd) = parse_quoted_string(input)?; | |
Ok((input, Command::Execute(cmd))) | |
} | |
fn parse_upload(input: &str) -> IResult<&str, Command> { | |
let (input, _) = tag("upload").parse(input)?; | |
let (input, _) = char(':').parse(input)?; | |
let (input, _) = multispace0.parse(input)?; | |
let (input, local_path) = parse_key_value(input, "local_path")?; | |
let (input, remote_path) = parse_key_value(input, "remote_path")?; | |
Ok(( | |
input, | |
Command::Upload { | |
local_path, | |
remote_path, | |
}, | |
)) | |
} | |
fn parse_download(input: &str) -> IResult<&str, Command> { | |
let (input, _) = tag("download").parse(input)?; | |
let (input, _) = char(':').parse(input)?; | |
let (input, _) = multispace0.parse(input)?; | |
let (input, remote_path) = parse_key_value(input, "remote_path")?; | |
let (input, local_path) = parse_key_value(input, "local_path")?; | |
Ok(( | |
input, | |
Command::Download { | |
remote_path, | |
local_path, | |
}, | |
)) | |
} | |
fn parse_command(input: &str) -> IResult<&str, Command> { | |
let mut parser = alt((parse_execute, parse_upload, parse_download)); | |
parser.parse(input) | |
} | |
fn parse_commands(input: &str) -> IResult<&str, Vec<Command>> { | |
let mut parser = many0(delimited(multispace0, parse_command, multispace0)); | |
parser.parse(input) | |
} | |
pub fn parse(input: &str) -> Result<Vec<Command>> { | |
let result = parse_commands(input); | |
let (commands, remaining) = convert_nom_result(result)?; | |
handle_remaining(commands, remaining) | |
} | |
#[cfg(test)] | |
mod tests { | |
use super::*; | |
#[test] | |
fn test_parse_execute() { | |
let input = r#"execute: "echo Take Five!""#; | |
let result = parse_command(input); | |
assert!(result.is_ok()); | |
let (_, command) = result.unwrap(); | |
assert_eq!(command, Command::Execute("echo Take Five!".to_string())); | |
} | |
#[test] | |
fn test_parse_upload() { | |
let input = r#"upload: | |
local_path: "thedata-mycopy.txt" | |
remote_path: "/tmp/thedata.txt""#; | |
let result = parse_command(input); | |
assert!(result.is_ok()); | |
let (_, command) = result.unwrap(); | |
assert_eq!( | |
command, | |
Command::Upload { | |
local_path: "thedata-mycopy.txt".to_string(), | |
remote_path: "/tmp/thedata.txt".to_string(), | |
} | |
); | |
} | |
#[test] | |
fn test_parse_download() { | |
let input = r#"download: | |
remote_path: "/tmp/remote_file.txt" | |
local_path: "downloaded.txt""#; | |
let result = parse_command(input); | |
assert!(result.is_ok()); | |
let (_, command) = result.unwrap(); | |
assert_eq!( | |
command, | |
Command::Download { | |
remote_path: "/tmp/remote_file.txt".to_string(), | |
local_path: "downloaded.txt".to_string(), | |
} | |
); | |
} | |
#[test] | |
fn test_whole_ass_config() { | |
let input = r#" | |
execute: "echo Take Five!" | |
upload: | |
local_path: "myscript.sh" | |
remote_path: "/tmp/myscript.sh" | |
execute: "chmod 755 /tmp/myscript.sh" | |
execute: "/tmp/myscript.sh" | |
download: | |
remote_path: "/tmp/all-the-output.tar.gz" | |
local_path: "takefive.tar.gz" | |
"#; | |
let result = parse(input); | |
assert!(result.is_ok()); | |
let commands = result.unwrap(); | |
assert_eq!(commands.len(), 5); | |
} | |
#[test] | |
fn test_error_handling() { | |
// malformed input | |
let input = r#"execute: echo without quotes"#; | |
let result = parse(input); | |
assert!(result.is_err()); | |
// unconsumed input | |
let input = r#" | |
execute: "echo hello" | |
invalid command here | |
"#; | |
let result = parse(input); | |
assert!(result.is_err()); | |
let err = result.unwrap_err(); | |
assert!(matches!(err.kind(), ParseErrorKind::UnconsumedInput(_))); | |
// missing field | |
let input = r#"upload: | |
local_path: "file.txt" | |
"#; | |
let result = parse(input); | |
assert!(result.is_err()); | |
} | |
#[test] | |
fn test_malformed_execute() { | |
// missing quotes | |
let input = r#"execute: echo hello"#; | |
let result = parse_command(input); | |
assert!( | |
result.is_err(), | |
"Parser should reject execute without quotes" | |
); | |
// unterminated quotes | |
let input = r#"execute: "echo hello"#; | |
let result = parse_command(input); | |
assert!(result.is_err(), "Parser should reject unterminated quotes"); | |
} | |
#[test] | |
fn test_empty_execute_command() { | |
// empty command (valid) | |
let input = r#"execute: """#; | |
let result = parse_command(input); | |
assert!(result.is_ok(), "Parser should accept empty quoted command"); | |
if let Ok((_, command)) = result { | |
assert_eq!(command, Command::Execute("".to_string())); | |
} | |
} | |
#[test] | |
fn test_malformed_upload() { | |
// missing fields | |
let input = r#"upload:"#; | |
let result = parse_command(input); | |
assert!( | |
result.is_err(), | |
"Parser should reject upload with missing fields" | |
); | |
// missing one field | |
let input = r#"upload: | |
local_path: "file.txt""#; | |
let result = parse_command(input); | |
assert!( | |
result.is_err(), | |
"Parser should reject upload with missing remote_path" | |
); | |
// wrong field order | |
let input = r#"upload: | |
remote_path: "remote.txt" | |
local_path: "local.txt""#; | |
let result = parse_command(input); | |
assert!( | |
result.is_err(), | |
"Parser should reject upload with wrong field order" | |
); | |
// empty field values (valid) | |
let input = r#"upload: | |
local_path: "" | |
remote_path: "/tmp/remote.txt""#; | |
let result = parse_command(input); | |
assert!(result.is_ok(), "Parser should accept empty field values"); | |
// malformed key names | |
let input = r#"upload: | |
localpath: "file.txt" | |
remote_path: "/tmp/file.txt""#; | |
let result = parse_command(input); | |
assert!(result.is_err(), "Parser should reject malformed key names"); | |
} | |
#[test] | |
fn test_malformed_download() { | |
// missing fields | |
let input = r#"download: | |
remote_path: "/tmp/file.txt""#; | |
let result = parse_command(input); | |
assert!( | |
result.is_err(), | |
"Parser should reject download with missing local_path" | |
); | |
// unquoted values | |
let input = r#"download: | |
remote_path: /tmp/file.txt | |
local_path: "local.txt""#; | |
let result = parse_command(input); | |
assert!( | |
result.is_err(), | |
"Parser should reject unquoted field values" | |
); | |
// extra fields | |
let input = r#"download: | |
remote_path: "/tmp/file.txt" | |
local_path: "local.txt" | |
extra_field: "should not be here""#; | |
let result = parse(input); | |
assert!( | |
result.is_err(), | |
"Parser should reject or not consume extra fields" | |
); | |
let err = result.unwrap_err(); | |
assert!(matches!(err.kind(), ParseErrorKind::UnconsumedInput(_))); | |
} | |
#[test] | |
fn test_unknown_command() { | |
let input = r#"unknown: "this is not a valid command""#; | |
let result = parse(input); | |
assert!(result.is_err(), "Parser should reject unknown commands"); | |
} | |
#[test] | |
fn test_mixed_valid_invalid() { | |
// valid command followed by invalid | |
let input = r#" | |
execute: "echo valid" | |
not_a_command: "invalid" | |
"#; | |
let result = parse(input); | |
assert!( | |
result.is_err(), | |
"Parser should reject or not consume invalid commands" | |
); | |
let err = result.unwrap_err(); | |
assert!(matches!(err.kind(), ParseErrorKind::UnconsumedInput(_))); | |
} | |
#[test] | |
fn test_empty_input() { | |
let input = ""; | |
let result = parse(input); | |
assert!(result.is_ok(), "Parser should accept empty input"); | |
let commands = result.unwrap(); | |
assert_eq!( | |
commands.len(), | |
0, | |
"Empty input should result in empty command vector" | |
); | |
let input = " \n \t "; | |
let result = parse(input); | |
assert!(result.is_ok(), "Parser should accept whitespace-only input"); | |
let commands = result.unwrap(); | |
assert_eq!( | |
commands.len(), | |
0, | |
"Whitespace-only input should result in empty command vector" | |
); | |
} | |
#[test] | |
fn test_special_characters() { | |
// without escaped quotes since they're not supported yet | |
let input = r#"execute: "echo special chars: !@#$%^&*()_+{}[]|\\:;'<>,.?/""#; | |
let result = parse(input); | |
assert!( | |
result.is_ok(), | |
"Parser should accept special characters in quoted strings" | |
); | |
} | |
#[test] | |
fn test_multiline_commands() { | |
let input = r#"execute: "echo line1 && | |
echo line2 && | |
echo line3""#; | |
let result = parse(input); | |
assert!(result.is_ok(), "Parser should accept multiline commands"); | |
let input = r#"execute: "echo 'this is | |
a multiline | |
command'""#; | |
let result = parse(input); | |
assert!( | |
result.is_ok(), | |
"Parser should accept multiline commands with nested quotes" | |
); | |
} | |
#[test] | |
fn test_escaped_quotes() { | |
// NOTE: the current parser doesn't handle escaped quotes correctly, | |
// so this test expects failure | |
let input = r#"execute: "echo \"quoted text\"""#; | |
let result = parse(input); | |
assert!( | |
result.is_err(), | |
"Parser should reject escaped quotes (current limitation)" | |
); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment