diff --git a/Makefile b/Makefile index ae5a7e0..2a8169f 100644 --- a/Makefile +++ b/Makefile @@ -37,6 +37,30 @@ run: build ## Build and run the binary @echo "Running the $(BINARY) binary..." @DEBUG_PROJ=$(DEBUG_PROJ) ./$(BINARY) +.PHONY: repl +repl: format ## Start the interactive REPL + @echo "Starting chase-rs REPL..." + @DEBUG_PROJ=$(DEBUG_PROJ) cargo run -- repl + +.PHONY: gui +gui: format ## Start the local GUI at 127.0.0.1:7878 + @echo "Starting chase-rs GUI on http://127.0.0.1:7878..." + @DEBUG_PROJ=$(DEBUG_PROJ) cargo run -- gui + +.PHONY: gui-addr +gui-addr: format ## Start the local GUI at GUI_ADDR= + @echo "Starting chase-rs GUI on http://$(or $(GUI_ADDR),127.0.0.1:7878)..." + @DEBUG_PROJ=$(DEBUG_PROJ) cargo run -- gui $(or $(GUI_ADDR),127.0.0.1:7878) + +.PHONY: script +script: format ## Run a frontend script with SCRIPT= + @if [ -z "$(SCRIPT)" ]; then \ + echo "Usage: make script SCRIPT="; \ + exit 1; \ + fi + @echo "Running script $(SCRIPT)..." + @DEBUG_PROJ=$(DEBUG_PROJ) cargo run -- script $(SCRIPT) + .PHONY: clean clean: ## Remove generated and temporary files @echo "Cleaning up..." diff --git a/README.md b/README.md index f8a5a39..6a34c89 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ This implementation provides a **restricted chase** that guarantees termination - **Existential Quantification**: Automatic generation of labeled nulls - **Restricted Chase**: Termination guarantees via trigger tracking - **Fluent API**: `RuleBuilder` for readable rule construction +- **Interactive Frontends**: REPL, script runner, and a local GUI - **Zero Dependencies**: Pure Rust with no external runtime dependencies See [ROADMAP.md](ROADMAP.md) for the list of implemented and planned features. @@ -76,7 +77,42 @@ let rule = RuleBuilder::new() .build(); ``` -#### Usful Commands +#### Frontends + +The binary now supports three entrypoints: + +```bash +# Start the interactive REPL +cargo run -- repl + +# Start the local GUI at http://127.0.0.1:7878 +cargo run -- gui + +# Run a script file +cargo run -- script examples/session.chase +``` + +The REPL and GUI share a minimal command language: + +```text +fact Parent(alice, bob). +rule Parent(?X, ?Y) -> Ancestor(?X, ?Y). +run. +query Ancestor(?X, ?Y)? +show facts +show rules +reset +help +``` + +Rules: + +- facts and rules end with `.` +- queries end with `?` +- variables are prefixed with `?` +- quoted constants are supported, for example `"alice smith"` + +#### Useful Commands ```bash # Install dependencies diff --git a/ROADMAP.md b/ROADMAP.md index 7f93fd3..0ca7afb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -68,7 +68,7 @@ This document outlines the implemented features and the future goals for the pro - [x] Unit tests (in-module) - [x] Integration tests -- [ ] Property-based tests (QuickCheck/proptest) +- [x] Property-based tests (QuickCheck/proptest) - [ ] Regression tests - [ ] Benchmarks - [ ] Fuzzing @@ -78,5 +78,5 @@ This document outlines the implemented features and the future goals for the pro - [ ] API documentation (rustdoc) - [ ] User guide - [ ] Example programs -- [ ] CLI interface -- [ ] REPL for interactive queries +- [x] CLI interface +- [x] REPL for interactive queries diff --git a/src/frontend/language.rs b/src/frontend/language.rs new file mode 100644 index 0000000..1d0b9df --- /dev/null +++ b/src/frontend/language.rs @@ -0,0 +1,369 @@ +//! Minimal command language for the chase-rs REPL and GUI. + +use crate::chase::rule::RuleBuilder; +use crate::{Atom, Rule, Term}; + +#[derive(Debug, Clone)] +pub enum Command { + Fact(Atom), + Rule(Rule), + Run, + Query(Vec), + ShowFacts, + ShowRules, + Reset, + Help, +} + +pub fn parse_script(input: &str) -> Result, String> { + let mut commands = Vec::new(); + + for (index, raw_line) in input.lines().enumerate() { + let line = raw_line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + let command = parse_command(line).map_err(|err| format!("line {}: {}", index + 1, err))?; + commands.push(command); + } + + Ok(commands) +} + +pub fn parse_command(input: &str) -> Result { + let trimmed = input.trim(); + + if trimmed.eq_ignore_ascii_case("run") || trimmed.eq_ignore_ascii_case("run.") { + return Ok(Command::Run); + } + if trimmed.eq_ignore_ascii_case("show facts") || trimmed.eq_ignore_ascii_case("show facts.") { + return Ok(Command::ShowFacts); + } + if trimmed.eq_ignore_ascii_case("show rules") || trimmed.eq_ignore_ascii_case("show rules.") { + return Ok(Command::ShowRules); + } + if trimmed.eq_ignore_ascii_case("reset") || trimmed.eq_ignore_ascii_case("reset.") { + return Ok(Command::Reset); + } + if trimmed.eq_ignore_ascii_case("help") || trimmed.eq_ignore_ascii_case("help.") { + return Ok(Command::Help); + } + + if let Some(rest) = strip_keyword(trimmed, "fact") { + let atom = parse_atom(trim_suffix(rest, '.')?)?; + return Ok(Command::Fact(atom)); + } + + if let Some(rest) = strip_keyword(trimmed, "rule") { + let rule_text = trim_suffix(rest, '.')?; + let arrow = find_top_level_arrow(rule_text) + .ok_or_else(|| "rule must contain a top-level `->`".to_string())?; + let body_text = rule_text[..arrow].trim(); + let head_text = rule_text[arrow + 2..].trim(); + if body_text.is_empty() || head_text.is_empty() { + return Err("rule body and head must both be non-empty".to_string()); + } + + let body = parse_atom_list(body_text)?; + let head = parse_atom_list(head_text)?; + let mut builder = RuleBuilder::new(); + for atom in body { + builder = builder.when(&atom.predicate, atom.terms); + } + for atom in head { + builder = builder.then(&atom.predicate, atom.terms); + } + return Ok(Command::Rule(builder.build())); + } + + if let Some(rest) = strip_keyword(trimmed, "query") { + let atoms = parse_atom_list(trim_suffix(rest, '?')?)?; + return Ok(Command::Query(atoms)); + } + + Err("unknown command; try `help`".to_string()) +} + +fn strip_keyword<'a>(input: &'a str, keyword: &str) -> Option<&'a str> { + let prefix = input.get(..keyword.len())?; + if !prefix.eq_ignore_ascii_case(keyword) { + return None; + } + + let rest = input.get(keyword.len()..)?; + if rest.is_empty() { + return Some(rest); + } + + let mut chars = rest.chars(); + let first = chars.next()?; + if first.is_whitespace() { + Some(rest.trim_start()) + } else { + None + } +} + +fn trim_suffix(input: &str, suffix: char) -> Result<&str, String> { + let trimmed = input.trim(); + if let Some(stripped) = trimmed.strip_suffix(suffix) { + Ok(stripped.trim_end()) + } else { + Err(format!("command must end with `{}`", suffix)) + } +} + +fn parse_atom_list(input: &str) -> Result, String> { + split_top_level(input, ',')? + .into_iter() + .map(parse_atom) + .collect() +} + +fn parse_atom(input: &str) -> Result { + let trimmed = input.trim(); + let open = trimmed + .find('(') + .ok_or_else(|| format!("expected `(` in atom `{}`", trimmed))?; + let close = trimmed + .rfind(')') + .ok_or_else(|| format!("expected `)` in atom `{}`", trimmed))?; + if close <= open { + return Err(format!("malformed atom `{}`", trimmed)); + } + if close != trimmed.len() - 1 { + return Err(format!("unexpected content after atom `{}`", trimmed)); + } + + let predicate = trimmed[..open].trim(); + validate_identifier(predicate, "predicate")?; + + let args = trimmed[open + 1..close].trim(); + let terms = if args.is_empty() { + Vec::new() + } else { + split_top_level(args, ',')? + .into_iter() + .map(parse_term) + .collect::, _>>()? + }; + + Ok(Atom::new(predicate, terms)) +} + +fn parse_term(input: &str) -> Result { + let trimmed = input.trim(); + if trimmed.is_empty() { + return Err("empty term".to_string()); + } + + if let Some(var) = trimmed.strip_prefix('?') { + validate_identifier(var, "variable")?; + return Ok(Term::var(var)); + } + + if trimmed.starts_with('"') { + return parse_string_literal(trimmed).map(Term::constant); + } + + if trimmed.chars().any(char::is_whitespace) { + return Err(format!( + "constants with spaces must be quoted: `{}`", + trimmed + )); + } + + validate_identifier(trimmed, "constant")?; + Ok(Term::constant(trimmed)) +} + +fn parse_string_literal(input: &str) -> Result { + if !input.ends_with('"') || input.len() < 2 { + return Err(format!("unterminated string literal `{}`", input)); + } + + let inner = &input[1..input.len() - 1]; + let mut value = String::new(); + let mut escaped = false; + + for ch in inner.chars() { + if escaped { + let translated = match ch { + '\\' => '\\', + '"' => '"', + 'n' => '\n', + 't' => '\t', + other => { + return Err(format!("unsupported escape sequence `\\{}`", other)); + } + }; + value.push(translated); + escaped = false; + continue; + } + + if ch == '\\' { + escaped = true; + } else { + value.push(ch); + } + } + + if escaped { + return Err("string literal ends with a trailing escape".to_string()); + } + + Ok(value) +} + +fn validate_identifier(value: &str, label: &str) -> Result<(), String> { + if value.is_empty() { + return Err(format!("{} cannot be empty", label)); + } + + if value.chars().all(is_identifier_char) { + Ok(()) + } else { + Err(format!("invalid {} `{}`", label, value)) + } +} + +fn is_identifier_char(ch: char) -> bool { + ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | ':') +} + +fn find_top_level_arrow(input: &str) -> Option { + let bytes = input.as_bytes(); + let mut depth = 0usize; + let mut in_string = false; + let mut escaped = false; + let mut index = 0usize; + + while index < bytes.len() { + let ch = bytes[index] as char; + if in_string { + if escaped { + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if ch == '"' { + in_string = false; + } + index += 1; + continue; + } + + match ch { + '"' => in_string = true, + '(' => depth += 1, + ')' => depth = depth.saturating_sub(1), + '-' if depth == 0 && bytes.get(index + 1).copied() == Some(b'>') => { + return Some(index); + } + _ => {} + } + index += 1; + } + + None +} + +fn split_top_level(input: &str, separator: char) -> Result, String> { + let mut parts = Vec::new(); + let mut depth = 0usize; + let mut in_string = false; + let mut escaped = false; + let mut start = 0usize; + + for (index, ch) in input.char_indices() { + if in_string { + if escaped { + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if ch == '"' { + in_string = false; + } + continue; + } + + match ch { + '"' => in_string = true, + '(' => depth += 1, + ')' => { + if depth == 0 { + return Err(format!("unexpected `)` in `{}`", input)); + } + depth -= 1; + } + ch if ch == separator && depth == 0 => { + let part = input[start..index].trim(); + if part.is_empty() { + return Err(format!("empty element in `{}`", input)); + } + parts.push(part); + start = index + ch.len_utf8(); + } + _ => {} + } + } + + if in_string { + return Err(format!("unterminated string literal in `{}`", input)); + } + if depth != 0 { + return Err(format!("unbalanced parentheses in `{}`", input)); + } + + let tail = input[start..].trim(); + if tail.is_empty() { + return Err(format!("empty element in `{}`", input)); + } + parts.push(tail); + Ok(parts) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_fact_command() { + let command = parse_command(r#"fact Parent(alice, "bob smith")."#).unwrap(); + match command { + Command::Fact(atom) => { + assert_eq!(atom.predicate, "Parent"); + assert_eq!(atom.terms.len(), 2); + } + other => panic!("unexpected command: {:?}", other), + } + } + + #[test] + fn parse_rule_command() { + let command = parse_command("rule P(?X), Q(?X, a) -> R(?X).").unwrap(); + match command { + Command::Rule(rule) => { + assert_eq!(rule.body.len(), 2); + assert_eq!(rule.head.len(), 1); + } + other => panic!("unexpected command: {:?}", other), + } + } + + #[test] + fn parse_query_command() { + let command = parse_command("query Ancestor(?X, ?Y), Parent(?Y, ?Z)?").unwrap(); + match command { + Command::Query(atoms) => assert_eq!(atoms.len(), 2), + other => panic!("unexpected command: {:?}", other), + } + } + + #[test] + fn parse_script_reports_line_numbers() { + let error = parse_script("help\nbogus\nrun.").unwrap_err(); + assert!(error.contains("line 2")); + } +} diff --git a/src/frontend/mod.rs b/src/frontend/mod.rs new file mode 100644 index 0000000..84f00b4 --- /dev/null +++ b/src/frontend/mod.rs @@ -0,0 +1,10 @@ +//! Frontend utilities for interacting with chase-rs through scripts, a REPL, or a GUI. + +pub mod language; +pub mod repl; +pub mod session; +pub mod web; + +pub use repl::run_repl; +pub use session::Session; +pub use web::serve_gui; diff --git a/src/frontend/repl.rs b/src/frontend/repl.rs new file mode 100644 index 0000000..f7b1d5a --- /dev/null +++ b/src/frontend/repl.rs @@ -0,0 +1,39 @@ +//! Interactive REPL for the minimal chase-rs frontend language. + +use std::io::{self, BufRead, Write}; + +use super::Session; + +pub fn run_repl() -> io::Result<()> { + let stdin = io::stdin(); + let mut stdout = io::stdout(); + let mut session = Session::new(); + + writeln!(stdout, "chase-rs REPL")?; + writeln!(stdout, "Type `help` for commands and `quit` to exit.")?; + + let mut lines = stdin.lock().lines(); + loop { + write!(stdout, "chase> ")?; + stdout.flush()?; + + let Some(line) = lines.next() else { + writeln!(stdout)?; + return Ok(()); + }; + let line = line?; + let trimmed = line.trim(); + if trimmed.eq_ignore_ascii_case("quit") || trimmed.eq_ignore_ascii_case("exit") { + writeln!(stdout, "bye")?; + return Ok(()); + } + if trimmed.is_empty() { + continue; + } + + match session.execute_script(trimmed) { + Ok(output) => writeln!(stdout, "{}", output)?, + Err(err) => writeln!(stdout, "error: {}", err)?, + } + } +} diff --git a/src/frontend/session.rs b/src/frontend/session.rs new file mode 100644 index 0000000..86083ac --- /dev/null +++ b/src/frontend/session.rs @@ -0,0 +1,251 @@ +//! Session state and command execution shared by the REPL and GUI. + +use std::fmt; + +use crate::chase::substitution::unify_atom; +use crate::{Atom, ChaseResult, Instance, Rule, Substitution, chase}; + +use super::language::{Command, parse_script}; + +#[derive(Debug, Default)] +pub struct Session { + base_instance: Instance, + rules: Vec, + materialized: Option, +} + +impl Session { + pub fn new() -> Self { + Self::default() + } + + pub fn execute_script(&mut self, script: &str) -> Result { + let commands = parse_script(script)?; + let mut output = Vec::new(); + + for command in commands { + let message = self.execute(command)?; + if !message.is_empty() { + output.push(message); + } + } + + if output.is_empty() { + Ok("No commands executed.".to_string()) + } else { + Ok(output.join("\n")) + } + } + + pub fn execute(&mut self, command: Command) -> Result { + match command { + Command::Fact(atom) => { + self.materialized = None; + let inserted = self.base_instance.add(atom.clone()); + let action = if inserted { + "Added" + } else { + "Skipped duplicate" + }; + Ok(format!("{} fact: {}", action, atom)) + } + Command::Rule(rule) => { + self.materialized = None; + self.rules.push(rule.clone()); + Ok(format!("Added rule #{}: {}", self.rules.len(), rule)) + } + Command::Run => Ok(self.run_chase()), + Command::Query(query) => Ok(self.run_query(&query)), + Command::ShowFacts => Ok(self.show_facts()), + Command::ShowRules => Ok(self.show_rules()), + Command::Reset => { + *self = Self::default(); + Ok("Session reset.".to_string()) + } + Command::Help => Ok(help_text().to_string()), + } + } + + pub fn reset(&mut self) { + *self = Self::default(); + } + + fn run_chase(&mut self) -> String { + let result = chase(self.base_instance.clone(), &self.rules); + let message = if result.terminated { + format!( + "Chase completed in {} step(s); {} fact(s) available.", + result.steps, + result.instance.len() + ) + } else { + format!( + "Chase stopped after {} step(s); result may be incomplete.", + result.steps + ) + }; + self.materialized = Some(result); + message + } + + fn run_query(&self, query: &[Atom]) -> String { + let instance = self.active_instance(); + let matches = find_matches(instance, query); + let variables = query_variables(query); + + if variables.is_empty() { + return if matches.is_empty() { + "false".to_string() + } else { + "true".to_string() + }; + } + + if matches.is_empty() { + return "0 rows".to_string(); + } + + let mut rows = matches + .iter() + .map(|subst| format_substitution(subst, &variables)) + .collect::>(); + rows.sort(); + + let mut rendered = Vec::with_capacity(rows.len() + 1); + rendered.push(format!("{} row(s)", rows.len())); + rendered.extend(rows); + rendered.join("\n") + } + + fn show_facts(&self) -> String { + let facts = sorted_render(self.active_instance().iter()); + if facts.is_empty() { + return "No facts loaded.".to_string(); + } + + facts.join("\n") + } + + fn show_rules(&self) -> String { + if self.rules.is_empty() { + return "No rules loaded.".to_string(); + } + + self.rules + .iter() + .enumerate() + .map(|(index, rule)| format!("{}: {}", index + 1, rule)) + .collect::>() + .join("\n") + } + + fn active_instance(&self) -> &Instance { + if let Some(result) = &self.materialized { + &result.instance + } else { + &self.base_instance + } + } +} + +fn help_text() -> &'static str { + "Commands: +fact Parent(alice, bob). +rule Parent(?X, ?Y) -> Ancestor(?X, ?Y). +run. +query Ancestor(?X, ?Y)? +show facts +show rules +reset +help" +} + +fn sorted_render<'a, T>(items: impl Iterator) -> Vec +where + T: fmt::Display + 'a, +{ + let mut rendered = items.map(ToString::to_string).collect::>(); + rendered.sort(); + rendered +} + +fn query_variables(query: &[Atom]) -> Vec { + let mut variables = query + .iter() + .flat_map(|atom| atom.variables()) + .cloned() + .collect::>(); + variables.sort(); + variables.dedup(); + variables +} + +fn format_substitution(subst: &Substitution, variables: &[String]) -> String { + variables + .iter() + .filter_map(|var| subst.get(var).map(|term| format!("?{} = {}", var, term))) + .collect::>() + .join(", ") +} + +fn find_matches(instance: &Instance, body: &[Atom]) -> Vec { + if body.is_empty() { + return vec![Substitution::new()]; + } + + let mut results = vec![Substitution::new()]; + + for body_atom in body { + let mut new_results = Vec::new(); + + for subst in &results { + let pattern = subst.apply_atom(body_atom); + for fact in instance.facts_for_predicate(&pattern.predicate) { + if let Some(next_subst) = unify_atom(&pattern, fact) { + let mut combined = subst.clone(); + for (var, term) in next_subst.iter() { + combined.bind(var.clone(), term.clone()); + } + new_results.push(combined); + } + } + } + + results = new_results; + } + + results +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn session_runs_chase_and_query() { + let mut session = Session::new(); + let output = session + .execute_script( + "fact Parent(alice, bob).\n\ + fact Parent(bob, carol).\n\ + rule Parent(?X, ?Y) -> Ancestor(?X, ?Y).\n\ + rule Ancestor(?X, ?Y), Parent(?Y, ?Z) -> Ancestor(?X, ?Z).\n\ + run.\n\ + query Ancestor(?X, ?Y)?", + ) + .unwrap(); + + assert!(output.contains("Chase completed")); + assert!(output.contains("?X = alice, ?Y = bob")); + assert!(output.contains("?X = alice, ?Y = carol")); + } + + #[test] + fn boolean_query_returns_truth_value() { + let mut session = Session::new(); + let output = session + .execute_script("fact Parent(alice, bob).\nquery Parent(alice, bob)?") + .unwrap(); + assert!(output.ends_with("true")); + } +} diff --git a/src/frontend/web.rs b/src/frontend/web.rs new file mode 100644 index 0000000..4f25335 --- /dev/null +++ b/src/frontend/web.rs @@ -0,0 +1,286 @@ +//! Minimal local web UI for the chase-rs frontend language. + +use std::io::{self, BufRead, BufReader, Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::{Arc, Mutex}; + +use super::Session; + +pub fn serve_gui(address: &str) -> io::Result<()> { + let listener = TcpListener::bind(address)?; + let session = Arc::new(Mutex::new(Session::new())); + + println!("GUI available at http://{}", address); + + for stream in listener.incoming() { + match stream { + Ok(stream) => { + let shared = Arc::clone(&session); + if let Err(err) = handle_connection(stream, &shared) { + eprintln!("gui error: {}", err); + } + } + Err(err) => eprintln!("gui accept error: {}", err), + } + } + + Ok(()) +} + +fn handle_connection(mut stream: TcpStream, session: &Arc>) -> io::Result<()> { + let mut reader = BufReader::new(stream.try_clone()?); + let mut request_line = String::new(); + reader.read_line(&mut request_line)?; + if request_line.trim().is_empty() { + return Ok(()); + } + + let mut parts = request_line.split_whitespace(); + let method = parts.next().unwrap_or_default(); + let path = parts.next().unwrap_or("/"); + + let mut content_length = 0usize; + loop { + let mut header = String::new(); + reader.read_line(&mut header)?; + if header == "\r\n" || header.is_empty() { + break; + } + if let Some((name, value)) = header.split_once(':') + && name.eq_ignore_ascii_case("content-length") + { + let parsed = value.trim().parse::().ok(); + if let Some(length) = parsed { + content_length = length; + } + } + } + + let mut body = vec![0u8; content_length]; + if content_length > 0 { + reader.read_exact(&mut body)?; + } + + let response = match (method, path) { + ("GET", "/") => http_response("200 OK", "text/html; charset=utf-8", INDEX_HTML), + ("POST", "/execute") => { + let script = String::from_utf8_lossy(&body); + let output = { + let mut locked = session + .lock() + .map_err(|_| io::Error::other("session lock poisoned"))?; + match locked.execute_script(script.as_ref()) { + Ok(output) => output, + Err(err) => format!("error: {}", err), + } + }; + http_response("200 OK", "text/plain; charset=utf-8", &output) + } + ("POST", "/reset") => { + let mut locked = session + .lock() + .map_err(|_| io::Error::other("session lock poisoned"))?; + locked.reset(); + http_response("200 OK", "text/plain; charset=utf-8", "Session reset.") + } + _ => http_response("404 Not Found", "text/plain; charset=utf-8", "Not found"), + }; + + stream.write_all(response.as_bytes())?; + stream.flush()?; + Ok(()) +} + +fn http_response(status: &str, content_type: &str, body: &str) -> String { + format!( + "HTTP/1.1 {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + status, + content_type, + body.len(), + body + ) +} + +const INDEX_HTML: &str = r#" + + + + + chase-rs GUI + + + +
+
+
+
+

chase-rs

+

Minimal REPL language exposed through a local browser session.

+
+
+ +
+ + +
+

Try show facts, show rules, or boolean queries like query Parent(alice, bob)?.

+
+ +
+
Session ready.
+
+
+ + + + +"#; diff --git a/src/lib.rs b/src/lib.rs index 67f7d0d..6e5d103 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ pub mod chase; +pub mod frontend; // Re-export main types for convenience pub use chase::{Atom, ChaseResult, Instance, Rule, Substitution, Term, chase}; diff --git a/src/main.rs b/src/main.rs index cef01df..763e27b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,42 @@ -fn main() { - // TODO: Implement CLI for chase-rs - println!("chase-rs: An implementation of the chase algorithm"); +use std::env; +use std::fs; +use std::io; + +use chase_rs::frontend::{Session, run_repl, serve_gui}; + +fn main() -> io::Result<()> { + let args = env::args().skip(1).collect::>(); + if args.is_empty() { + return run_repl(); + } + + match args.as_slice() { + [cmd] if cmd.eq_ignore_ascii_case("repl") => run_repl(), + [cmd] if cmd.eq_ignore_ascii_case("gui") => serve_gui("127.0.0.1:7878"), + [cmd, address] if cmd.eq_ignore_ascii_case("gui") => serve_gui(address), + [cmd, path] if cmd.eq_ignore_ascii_case("script") => run_script(path), + _ => { + print_usage(); + Ok(()) + } + } +} + +fn run_script(path: &str) -> io::Result<()> { + let script = fs::read_to_string(path)?; + let mut session = Session::new(); + match session.execute_script(&script) { + Ok(output) => { + println!("{}", output); + Ok(()) + } + Err(err) => Err(io::Error::new(io::ErrorKind::InvalidInput, err)), + } +} + +fn print_usage() { + println!("Usage:"); + println!(" chase-rs repl"); + println!(" chase-rs gui [host:port]"); + println!(" chase-rs script "); }