use std::fs;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use abnf_oracle::{
CheckedGrammar, DEFAULT_MAX_DEPTH, GenOptions, Generator, Grammar, MatchError, MatchOptions,
MinLen, Recognizer,
};
use anyhow::{Context, Result, bail};
use clap::{Parser, Subcommand};
const OK: u8 = 0;
const NO: u8 = 1;
const ERROR: u8 = 2;
#[derive(Parser)]
#[command(name = "abnf-oracle", version, about)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Check {
grammar: PathBuf,
#[arg(long = "start", value_name = "RULE")]
starts: Vec<String>,
},
Match {
grammar: PathBuf,
#[arg(long)]
rule: String,
#[arg(long, group = "source")]
input: Option<String>,
#[arg(long, group = "source")]
file: Option<PathBuf>,
#[arg(long, group = "source")]
dir: Option<PathBuf>,
#[arg(long, value_name = "N")]
max_steps: Option<u64>,
#[arg(long, value_name = "N")]
max_depth: Option<usize>,
},
Gen {
grammar: PathBuf,
#[arg(long)]
rule: String,
#[arg(long, default_value_t = 0)]
seed: u64,
#[arg(long, default_value_t = 1, value_name = "N")]
count: usize,
#[arg(long)]
coverage: bool,
#[arg(long, value_name = "N")]
depth: Option<usize>,
#[arg(long, value_name = "N")]
spread: Option<usize>,
#[arg(long)]
preserve_case: bool,
#[arg(long, value_name = "N")]
max_output_len: Option<usize>,
#[arg(long, value_name = "N")]
max_steps: Option<u64>,
#[arg(long, value_name = "DIR")]
out: Option<PathBuf>,
},
Rules {
grammar: PathBuf,
},
}
fn main() -> ExitCode {
match run() {
Ok(code) => ExitCode::from(code),
Err(error) => {
eprintln!("error: {error:#}");
ExitCode::from(ERROR)
}
}
}
fn run() -> Result<u8> {
match Cli::parse().command {
Command::Check { grammar, starts } => check(&grammar, &starts),
Command::Rules { grammar } => rules(&grammar),
Command::Match {
grammar,
rule,
input,
file,
dir,
max_steps,
max_depth,
} => {
let options = MatchOptions {
max_steps,
max_depth: max_depth.or(Some(DEFAULT_MAX_DEPTH)),
};
let source = match (input, file, dir) {
(Some(text), None, None) => Source::Literal(text),
(None, Some(path), None) => Source::File(path),
(None, None, Some(path)) => Source::Dir(path),
_ => bail!("one of --input, --file or --dir is required"),
};
matching(&grammar, &rule, source, &options)
}
Command::Gen {
grammar,
rule,
seed,
count,
coverage,
depth,
spread,
preserve_case,
max_output_len,
max_steps,
out,
} => {
let defaults = GenOptions::default();
let options = GenOptions {
coverage,
preserve_case,
max_depth: depth.unwrap_or(defaults.max_depth),
spread: spread.unwrap_or(defaults.spread),
max_output_len: max_output_len
.map_or(defaults.max_output_len, |n| (n > 0).then_some(n)),
max_steps: max_steps.map_or(defaults.max_steps, |n| (n > 0).then_some(n)),
};
generating(&grammar, &rule, seed, count, &options, out.as_deref())
}
}
}
fn load(path: &Path) -> Result<Option<(String, CheckedGrammar)>> {
let src = fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
let grammar = match Grammar::parse(&src) {
Ok(grammar) => grammar,
Err(error) => {
eprintln!("{}", error.render(&src));
return Ok(None);
}
};
match grammar.check() {
Ok(checked) => Ok(Some((src, checked))),
Err(errors) => {
eprintln!("{}", errors.render(&src));
Ok(None)
}
}
}
fn check(path: &Path, starts: &[String]) -> Result<u8> {
let Some((_, grammar)) = load(path)? else {
return Ok(ERROR);
};
let warnings = if starts.is_empty() {
grammar.lint()
} else {
let starts: Vec<&str> = starts.iter().map(String::as_str).collect();
grammar.lint_from(&starts)
};
for warning in &warnings {
println!("warning: {warning}");
}
println!(
"{} {}, {} {}",
grammar.rules().len(),
plural(grammar.rules().len(), "rule", "rules"),
warnings.len(),
plural(warnings.len(), "warning", "warnings")
);
if starts.is_empty() && !warnings.is_empty() {
println!("note: pass --start to name entry points and enable unreachable-rule warnings");
}
Ok(OK)
}
enum Source {
Literal(String),
File(PathBuf),
Dir(PathBuf),
}
enum Verdict {
Accept,
Reject,
Error(String),
}
impl Verdict {
fn label(&self) -> String {
match self {
Self::Accept => "ACCEPT".to_owned(),
Self::Reject => "REJECT".to_owned(),
Self::Error(why) => format!("ERROR {why}"),
}
}
}
fn matching(path: &Path, rule: &str, source: Source, options: &MatchOptions) -> Result<u8> {
let Some((_, grammar)) = load(path)? else {
return Ok(ERROR);
};
if let Err(why) = grammar.can_recognize(rule) {
eprintln!("error: {why}");
return Ok(ERROR);
}
match source {
Source::Literal(text) => Ok(report_one(&grammar, rule, &text, options)),
Source::File(file) => {
let bytes = fs::read(&file).with_context(|| format!("reading {}", file.display()))?;
match decode(&bytes) {
Ok(text) => Ok(report_one(&grammar, rule, &text, options)),
Err(why) => {
eprintln!("error: {}: {why}", file.display());
Ok(ERROR)
}
}
}
Source::Dir(dir) => matching_dir(&grammar, rule, &dir, options),
}
}
fn report_one(grammar: &CheckedGrammar, rule: &str, text: &str, options: &MatchOptions) -> u8 {
match decide(grammar, rule, text, options) {
Verdict::Accept => OK,
Verdict::Reject => NO,
Verdict::Error(why) => {
eprintln!("error: {why}");
ERROR
}
}
}
fn decide(grammar: &CheckedGrammar, rule: &str, text: &str, options: &MatchOptions) -> Verdict {
let mut recognizer = Recognizer::new(grammar, text).with_options(options.clone());
match recognizer.accepts(rule) {
Ok(true) => Verdict::Accept,
Ok(false) => Verdict::Reject,
Err(error @ (MatchError::StepLimit | MatchError::DepthLimit)) => {
Verdict::Error(error.to_string())
}
Err(error) => Verdict::Error(error.to_string()),
}
}
fn decode(bytes: &[u8]) -> Result<String, &'static str> {
core::str::from_utf8(bytes)
.map(ToOwned::to_owned)
.map_err(|_| "not valid UTF-8")
}
fn matching_dir(
grammar: &CheckedGrammar,
rule: &str,
dir: &Path,
options: &MatchOptions,
) -> Result<u8> {
let mut entries: Vec<PathBuf> = fs::read_dir(dir)
.with_context(|| format!("reading {}", dir.display()))?
.map(|entry| entry.map(|e| e.path()))
.collect::<std::io::Result<Vec<_>>>()?
.into_iter()
.filter(|path| path.is_file())
.collect();
entries.sort();
if entries.is_empty() {
bail!("{} holds no files", dir.display());
}
let width = entries
.iter()
.map(|path| file_name(path).len())
.max()
.unwrap_or(0);
let mut rejected = false;
let mut errored = false;
for path in &entries {
let verdict = match fs::read(path) {
Ok(bytes) => match decode(&bytes) {
Ok(text) => decide(grammar, rule, &text, options),
Err(why) => Verdict::Error(why.to_owned()),
},
Err(error) => Verdict::Error(error.to_string()),
};
match verdict {
Verdict::Accept => {}
Verdict::Reject => rejected = true,
Verdict::Error(_) => errored = true,
}
println!("{:<width$} {}", file_name(path), verdict.label());
}
Ok(if errored {
ERROR
} else if rejected {
NO
} else {
OK
})
}
fn file_name(path: &Path) -> String {
path.file_name()
.and_then(|name| name.to_str())
.unwrap_or("?")
.to_owned()
}
fn generating(
path: &Path,
rule: &str,
seed: u64,
count: usize,
options: &GenOptions,
out: Option<&Path>,
) -> Result<u8> {
let Some((_, grammar)) = load(path)? else {
return Ok(ERROR);
};
if let Err(why) = grammar.can_generate(rule) {
eprintln!("error: {why}");
return Ok(ERROR);
}
if let Some(dir) = out {
fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
}
let mut generator = Generator::new(&grammar, seed).with_options(options.clone());
for index in 0..count {
let produced = match generator.generate(rule) {
Ok(text) => text,
Err(error) => {
eprintln!("error: string {index}: {error}");
return Ok(ERROR);
}
};
match out {
Some(dir) => {
let file = dir.join(format!("{index:04}.txt"));
fs::write(&file, produced.as_bytes())
.with_context(|| format!("writing {}", file.display()))?;
}
None => println!("{produced}"),
}
}
if options.coverage {
let left = generator.uncovered(rule).unwrap_or(0);
eprintln!(
"{count} {}, {left} coverage {} remaining",
plural(count, "string", "strings"),
plural(left, "unit", "units")
);
}
Ok(OK)
}
fn rules(path: &Path) -> Result<u8> {
let Some((_, grammar)) = load(path)? else {
return Ok(ERROR);
};
let width = grammar
.rules()
.iter()
.map(|rule| rule.name.as_str().len())
.max()
.unwrap_or(4)
.max(4);
println!(
"{:<width$} {:<8} {:>7} notes",
"rule", "nullable", "min-len"
);
for rule in grammar.rules() {
let name = rule.name.as_str();
let nullable = if grammar.is_nullable(rule.body) {
"yes"
} else {
"no"
};
let min_len = match grammar.min_len(rule.body) {
MinLen::Finite(length) => length.to_string(),
MinLen::Infinite => "-".to_owned(),
};
let mut notes = Vec::new();
if grammar
.core_rules()
.iter()
.any(|core| core.name == rule.name)
{
notes.push("shadows a core rule".to_owned());
}
if !grammar.min_len(rule.body).is_finite() {
notes.push("matches nothing".to_owned());
}
if let Err(why) = grammar.can_recognize(name) {
notes.push(format!("not a usable start rule: {why}"));
}
let row = format!(
"{name:<width$} {nullable:<8} {min_len:>7} {}",
notes.join("; ")
);
println!("{}", row.trim_end());
}
Ok(OK)
}
fn plural(count: usize, one: &'static str, many: &'static str) -> &'static str {
if count == 1 { one } else { many }
}