diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..d1a25cf --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,94 @@ +name: Run tests +name: CI Checks (Linting and Tests) + +on: + push: + branches: + - master + pull_request: + branches: + - master + types: + - opened + - reopened + - synchronize + - ready_for_review + +concurrency: + group: tests-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +env: + JWT_SECRET: test + AWS_ACCESS_KEY_ID: test + AWS_SECRET_ACCESS_KEY: test + AWS_ENDPOINT_URL_DYNAMODB: http://localhost:8000 + AWS_REGION: us-east-1 + +jobs: + check: + name: Check + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Check out repository + uses: actions/checkout@v3 + + - name: Install stable toolchain + uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: stable + override: true + + - name: Run cargo check + uses: actions-rs/cargo@v1 + with: + command: check + + test: + name: Test Suite + runs-on: ubuntu-latest + steps: + - name: Checkout sources + uses: actions/checkout@v2 + + - name: Install stable toolchain + uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: stable + override: true + + - name: Run cargo test + uses: actions-rs/cargo@v1 + with: + command: test + + lints: + name: Lints + runs-on: ubuntu-latest + steps: + - name: Checkout sources + uses: actions/checkout@v2 + + - name: Install stable toolchain + uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: stable + override: true + components: rustfmt, clippy + + - name: Run cargo fmt + uses: actions-rs/cargo@v1 + with: + command: fmt + args: --all -- --check + + - name: Run cargo clippy + uses: actions-rs/cargo@v1 + with: + command: clippy + args: -- -D warnings diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..11710f5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# .gitignore + +# Rust +/target +Cargo.lock +**/*.rs.bk diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..1ed12f8 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,3 @@ +[workspace] + +members = ["klang"] diff --git a/examples/clean_up_cans.k b/examples/clean_up_cans.k new file mode 100644 index 0000000..c7fdefc --- /dev/null +++ b/examples/clean_up_cans.k @@ -0,0 +1,59 @@ +# /usr/bin/env klang +# Defines a simple klang program. +# This program is designed to get a robot to clean up soda cans. + +action { + outcomes { + success [Success] + failure [Failure] + } +} + +action { + notes { + prefer [Move quickly] + avoid [Bumping into other objects] + avoid [Moving too hastily] + avoid [Moving slowly or staying in the same place for a long time] + } + + outcomes { + success [Found {item}] + failure [Haven't moved in a while] + } +} + +action { + outcomes { + success [Holding {item}] + failure [Knocked over {item}] + retry [Not holding {item}] + } +} + +action { + outcomes { + success [{item} is inside {container} and we are not holding it] + failure [Dropped {item} outside the {container}] + retry [Still holding {item}] + } +} + +action { + outcomes { + success [Successfully emptied the soda can into the sink] + failure [Spilled soda outside the sink] + retry [There is still soda in the can] + } +} + +loop { + + + + + + +} until { + [No more soda cans] +} diff --git a/klang/Cargo.toml b/klang/Cargo.toml new file mode 100644 index 0000000..5aaa81d --- /dev/null +++ b/klang/Cargo.toml @@ -0,0 +1,17 @@ +[package] + +name = "klang" +version = "0.1.0" +authors = ["K-Scale Labs"] +edition = "2021" + +[dependencies] + +pest = "2.6" +pest_derive = "2.6" +thiserror = "1.0" + +[[bin]] + +name = "klang" +path = "src/main.rs" diff --git a/klang/src/ast.rs b/klang/src/ast.rs new file mode 100644 index 0000000..9d215cb --- /dev/null +++ b/klang/src/ast.rs @@ -0,0 +1,284 @@ +use pest::error::Error as PestError; +use pest::error::LineColLocation; +use pest::Parser; +use pest::Position; +use pest_derive::Parser; +use std::fs; +use std::path::Path; +use thiserror::Error; + +#[derive(Parser)] +#[grammar = "klang.pest"] +pub struct Klang; + +#[derive(Debug, PartialEq, Eq)] +pub struct Program { + pub statements: Vec, +} + +impl Program { + pub fn new(statements: Vec) -> Self { + Program { statements } + } +} + +#[derive(Debug, PartialEq, Eq)] +pub enum Stmt { + ActionDef(ActionDefStmt), + ActionCall(ActionCallStmt), + Loop(LoopStmt), +} + +#[derive(Debug, PartialEq, Eq)] +pub struct ActionDefStmt { + pub notes: Option, + pub outcomes: Option, +} + +impl ActionDefStmt { + pub fn new(notes: Option, outcomes: Option) -> Self { + ActionDefStmt { notes, outcomes } + } +} + +#[derive(Debug, PartialEq, Eq)] +pub struct ActionCallStmt { + pub name: String, +} + +impl ActionCallStmt { + pub fn new(name: String) -> Self { + ActionCallStmt { name } + } +} + +#[derive(Debug, PartialEq, Eq)] +pub struct LoopStmt { + pub actions: Vec, + pub condition: Option>, +} + +impl LoopStmt { + pub fn new(actions: Vec, condition: Option>) -> Self { + LoopStmt { actions, condition } + } +} + +#[derive(Debug, PartialEq, Eq)] +pub struct NotesBlock { + pub notes: Vec, +} + +impl NotesBlock { + pub fn new(notes: Vec) -> Self { + NotesBlock { notes } + } +} + +#[derive(Debug, PartialEq, Eq)] +pub enum Note { + Prefer(String), + Avoid(String), +} + +#[derive(Debug, PartialEq, Eq)] +pub struct OutcomesBlock { + pub outcomes: Vec, +} + +impl OutcomesBlock { + pub fn new(outcomes: Vec) -> Self { + OutcomesBlock { outcomes } + } +} + +#[derive(Debug, PartialEq, Eq)] +pub enum Outcome { + Success(String), + Failure(String), + Retry(String), + Handler(String), +} + +#[derive(Debug, Error)] +pub enum ParseError { + #[error("File error: {0}")] + FileError(#[from] std::io::Error), + + #[error("Parsing error: {0}")] + ParsingError(PestError), + + #[error("Semantic error: {0}")] + SemanticError(String), +} + +impl ParseError { + pub fn from_pest_error(error: PestError, input: &str) -> Self { + // Extract line and column numbers from LineColLocation + let (line, col) = match error.line_col { + LineColLocation::Pos((line, col)) => (line, col), + LineColLocation::Span((start_line, start_col), _) => (start_line, start_col), // Use start of the span + }; + + let line_str = error.line(); + + let error_message = format!( + "Parsing error at line {}, column {}: {}\n{}", + line, + col, + error.variant.message().as_ref(), + line_str.parse().unwrap_or("".to_string()) + ); + + // Obtain the span and create a Position from it + let span = match error.location { + pest::error::InputLocation::Pos(pos) => pos, + pest::error::InputLocation::Span((start, _)) => start, // Use the start of the span + }; + + let position = Position::new(input, span).unwrap(); // Create a Position<'_> from the span and input + + ParseError::ParsingError(PestError::new_from_pos( + pest::error::ErrorVariant::CustomError { + message: error_message, + }, + position, + )) + } +} + +pub fn parse_file_to_ast>(path: P) -> Result { + // Read the file content + let file_content = fs::read_to_string(path)?; + + // Parse the file content using the KlangParser + match Klang::parse(Rule::file_input, &file_content) { + Ok(mut parsed_file) => { + let parsed = parsed_file.next().unwrap(); + match builders::build_ast(parsed) { + Ok(ast) => Ok(ast), + Err(e) => Err(ParseError::SemanticError(e)), + } + } + Err(error) => Err(ParseError::from_pest_error(error, &file_content)), + } +} + +mod builders { + use super::*; + + pub fn build_ast(pair: pest::iterators::Pair) -> Result { + let mut statements = Vec::new(); + + for inner_pair in pair.into_inner() { + match inner_pair.as_rule() { + Rule::stmt => { + let mut inner_rules = inner_pair.into_inner(); + let stmt = build_stmt(inner_rules.next().unwrap())?; + statements.push(stmt); + } + Rule::EOI => {} + _ => unreachable!(), + } + } + + Ok(Program::new(statements)) + } + + fn build_stmt(pair: pest::iterators::Pair) -> Result { + match pair.as_rule() { + Rule::action_def_stmt => build_action_def_stmt(pair).map(Stmt::ActionDef), + Rule::action_call_stmt => build_action_call_stmt(pair).map(Stmt::ActionCall), + Rule::loop_stmt => build_loop_stmt(pair).map(Stmt::Loop), + _ => unreachable!(), + } + } + + fn build_action_def_stmt(pair: pest::iterators::Pair) -> Result { + for inner_pair in pair.into_inner() { + match inner_pair.as_rule() { + Rule::action_def_body => { + let mut inner_rules = inner_pair.into_inner(); + let notes = inner_rules + .clone() // Clone the iterator to use it later + .find(|p| p.as_rule() == Rule::notes_block) + .map(build_notes_block) + .transpose()?; + let outcomes = inner_rules + .find(|p| p.as_rule() == Rule::outcomes_block) + .map(build_outcomes_block) + .transpose()?; + return Ok(ActionDefStmt::new(notes, outcomes)); + } + _ => {} + } + } + + unreachable!() + } + + fn build_notes_block(pair: pest::iterators::Pair) -> Result { + let notes = pair + .into_inner() + .filter(|p| p.as_rule() == Rule::note) + .map(|note_pair| { + let mut inner_rules = note_pair.into_inner(); + let note_type = inner_rules.next().unwrap().as_rule(); + let note_name = inner_rules.next().unwrap().as_str().to_string(); + match note_type { + Rule::PREFER => Note::Prefer(note_name), + Rule::AVOID => Note::Avoid(note_name), + _ => unreachable!(), + } + }) + .collect(); + + Ok(NotesBlock::new(notes)) + } + + fn build_outcomes_block(pair: pest::iterators::Pair) -> Result { + let outcomes = pair + .into_inner() + .filter(|p| p.as_rule() == Rule::outcome) + .map(|outcome_pair| { + let mut inner_rules = outcome_pair.into_inner(); + let outcome_type = inner_rules.next().unwrap().as_rule(); + let outcome_name = inner_rules.next().unwrap().as_str().to_string(); + match outcome_type { + Rule::SUCCESS => Outcome::Success(outcome_name), + Rule::FAILURE => Outcome::Failure(outcome_name), + Rule::RETRY => Outcome::Retry(outcome_name), + Rule::HANDLER => Outcome::Handler(outcome_name), + _ => unreachable!(), + } + }) + .collect(); + + Ok(OutcomesBlock::new(outcomes)) + } + + fn build_action_call_stmt(pair: pest::iterators::Pair) -> Result { + let name = pair.into_inner().next().unwrap().as_str().to_string(); + Ok(ActionCallStmt::new(name)) + } + + fn build_loop_stmt(pair: pest::iterators::Pair) -> Result { + let mut inner_rules = pair.into_inner(); + + let actions: Vec<_> = inner_rules + .clone() // Clone the iterator to use it later + .filter(|p| p.as_rule() == Rule::action_call_stmt) + .map(build_action_call_stmt) + .collect::, _>>()?; + + let condition = inner_rules + .find(|p| p.as_rule() == Rule::condition) + .map(|p| { + p.into_inner() + .map(|outcome_name| outcome_name.as_str().to_string()) + .collect() + }); + + Ok(LoopStmt::new(actions, condition)) + } +} diff --git a/klang/src/klang.pest b/klang/src/klang.pest new file mode 100644 index 0000000..bf883e6 --- /dev/null +++ b/klang/src/klang.pest @@ -0,0 +1,59 @@ +// Whitespace and comments. +WHITESPACE = _{ " " | "\n" | "\t" } +COMMENT = _{ ("//" ~ (!"\n" ~ ANY)* ~ "\n") | ("/*" ~ (!"*/" ~ ANY)* ~ "*/") | ("#" ~ (!"\n" ~ ANY)* ~ "\n") } + +// Actions. +ACTION = { "action" } + +// Outcomes +OUTCOMES = { "outcomes" } +SUCCESS = { "success" } +FAILURE = { "failure" } +RETRY = { "retry" } +HANDLER = { "handler" } + +// Notes +NOTES = { "notes" } +PREFER = { "prefer" } +AVOID = { "avoid" } + +// Loops +LOOP = { "loop" } +ACTIONS = { "actions" } +UNTIL = { "until" } + +// Names +ACTION_NAME = { "<" ~ NAME ~ ">" } +OUTCOME_NAME = { "[" ~ NAME ~ "]" } +CHAR = { ASCII_ALPHANUMERIC | "." | "_" | "/" | "!" | "?" | " " | "," | "\"" | "\'" | "/" | "\\" | "{" | "}" } +NAME = { CHAR+ } + +// Braces. +LBRACE = { "{" } +RBRACE = { "}" } + +// File input. +file_input = { SOI ~ stmt* ~ EOI } + +// Statements. +stmt = { action_def_stmt | action_call_stmt | loop_stmt } + +// Action statements. +action_def_stmt = { ACTION ~ ACTION_NAME ~ LBRACE ~ action_def_body ~ RBRACE } +action_def_body = { (notes_block | outcomes_block)+ } + +// Notes. +notes_block = { NOTES ~ LBRACE ~ note* ~ RBRACE } +note = { (PREFER | AVOID) ~ OUTCOME_NAME } + +// Outcomes. +outcomes_block = { OUTCOMES ~ LBRACE ~ (outcome)* ~ RBRACE } +outcome = { (SUCCESS | FAILURE | RETRY | HANDLER) ~ OUTCOME_NAME } + +// Action calls. +action_call_stmt = { ACTION_NAME } + +// Loops. +loop_stmt = { LOOP ~ LBRACE ~ actions_block ~ (RBRACE ~ UNTIL ~ LBRACE ~ condition)? ~ RBRACE } +actions_block = { action_call_stmt* } +condition = { OUTCOME_NAME* } diff --git a/klang/src/main.rs b/klang/src/main.rs new file mode 100644 index 0000000..de618c8 --- /dev/null +++ b/klang/src/main.rs @@ -0,0 +1,25 @@ +pub mod ast; +pub mod tests; + +use ast::parse_file_to_ast; + +fn main() { + // Throw an error if incorrect number of arguments. + if std::env::args().count() != 2 { + eprintln!( + "Incorrect number of arguments (got {}, expected 1)", + std::env::args().count() - 1 + ); + std::process::exit(1); + } + + let filename = std::env::args() + .nth(1) + .expect("Missing filename argument! Usage: klang "); + let ast = parse_file_to_ast(filename); + + match ast { + Ok(ast) => println!("{:#?}", ast), + Err(e) => eprintln!("{}", e), + } +} diff --git a/klang/src/tests.rs b/klang/src/tests.rs new file mode 100644 index 0000000..eca4ef1 --- /dev/null +++ b/klang/src/tests.rs @@ -0,0 +1,19 @@ +// For each file in the `examples` directory, test that we can get an AST from it. + +#[test] +fn test_examples() { + use crate::ast::parse_file_to_ast; + use std::fs; + use std::path::Path; + + let examples_dir = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("examples"); + for entry in fs::read_dir(examples_dir).unwrap() { + let ast = parse_file_to_ast(entry.unwrap().path()); + match ast { + Ok(_ast) => (), + Err(e) => panic!("{}", e), + } + } +}