Skip to content

Instantly share code, notes, and snippets.

@jmsdnns
Created March 30, 2025 15:12
Show Gist options
  • Save jmsdnns/b47fca5c42b292eba80a2cca616acffe to your computer and use it in GitHub Desktop.
Save jmsdnns/b47fca5c42b292eba80a2cca616acffe to your computer and use it in GitHub Desktop.
my first try at building a parser for execution plans in killabeez
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