use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::error::{CaError, CaResult};
use super::DbRecordDef;
use super::include::{DbLoadConfig, parse_db_file};
#[derive(Debug, Clone, PartialEq)]
enum Tok {
Pattern,
File,
Global,
Str(String),
Equals,
Comma,
OBrace,
CBrace,
}
fn lex(input: &str) -> CaResult<Vec<(Tok, usize)>> {
let chars: Vec<char> = input.chars().collect();
let mut out: Vec<(Tok, usize)> = Vec::new();
let mut i = 0;
let mut line = 1usize;
let is_bareword = |c: char| {
c.is_ascii_alphanumeric()
|| matches!(
c,
'_' | '-' | '+' | ':' | '.' | '/' | '\\' | '[' | ']' | '<' | '>' | ';'
)
};
while i < chars.len() {
let c = chars[i];
match c {
'\n' => {
line += 1;
i += 1;
}
' ' | '\t' | '\r' => i += 1,
'#' => {
while i < chars.len() && chars[i] != '\n' {
i += 1;
}
}
'=' => {
out.push((Tok::Equals, line));
i += 1;
}
',' => {
out.push((Tok::Comma, line));
i += 1;
}
'{' => {
out.push((Tok::OBrace, line));
i += 1;
}
'}' => {
out.push((Tok::CBrace, line));
i += 1;
}
'"' | '\'' => {
let quote = c;
i += 1;
let mut s = String::new();
loop {
if i >= chars.len() {
return Err(CaError::DbParseError {
line,
column: 0,
message: "unterminated string in substitutions file".into(),
});
}
let ch = chars[i];
if ch == quote {
i += 1;
break;
}
if ch == '\n' {
return Err(CaError::DbParseError {
line,
column: 0,
message: "newline in string in substitutions file".into(),
});
}
if ch == '\\' && i + 1 < chars.len() {
s.push('\\');
s.push(chars[i + 1]);
i += 2;
continue;
}
s.push(ch);
i += 1;
}
out.push((Tok::Str(s), line));
}
_ if is_bareword(c) => {
let mut s = String::new();
while i < chars.len() && is_bareword(chars[i]) {
s.push(chars[i]);
i += 1;
}
let tok = match s.as_str() {
"pattern" => Tok::Pattern,
"file" => Tok::File,
"global" => Tok::Global,
_ => Tok::Str(s),
};
out.push((tok, line));
}
other => {
return Err(CaError::DbParseError {
line,
column: 0,
message: format!("invalid character '{other}' in substitutions file"),
});
}
}
}
Ok(out)
}
#[derive(Debug, Clone, PartialEq)]
pub struct TemplateLoad {
pub file: String,
pub macros: Vec<(String, String)>,
}
struct Parser {
toks: Vec<(Tok, usize)>,
pos: usize,
globals: Vec<(String, String)>,
loads: Vec<TemplateLoad>,
}
impl Parser {
fn new(toks: Vec<(Tok, usize)>) -> Self {
Self {
toks,
pos: 0,
globals: Vec::new(),
loads: Vec::new(),
}
}
fn peek(&self) -> Option<&Tok> {
self.toks.get(self.pos).map(|(t, _)| t)
}
fn line(&self) -> usize {
self.toks
.get(self.pos)
.or_else(|| self.toks.last())
.map(|(_, l)| *l)
.unwrap_or(0)
}
fn err(&self, msg: impl Into<String>) -> CaError {
CaError::DbParseError {
line: self.line(),
column: 0,
message: msg.into(),
}
}
fn next(&mut self) -> Option<Tok> {
let t = self.toks.get(self.pos).map(|(t, _)| t.clone());
if t.is_some() {
self.pos += 1;
}
t
}
fn expect(&mut self, want: &Tok) -> CaResult<()> {
match self.next() {
Some(ref got) if got == want => Ok(()),
Some(got) => Err(self.err(format!("expected {want:?}, got {got:?}"))),
None => Err(self.err(format!("expected {want:?}, got end of file"))),
}
}
fn expect_str(&mut self) -> CaResult<String> {
match self.next() {
Some(Tok::Str(s)) => Ok(s),
Some(got) => Err(self.err(format!("expected a name/value, got {got:?}"))),
None => Err(self.err("expected a name/value, got end of file")),
}
}
fn parse(&mut self) -> CaResult<Vec<TemplateLoad>> {
while let Some(tok) = self.peek() {
match tok {
Tok::Global => self.parse_global()?,
Tok::File => self.parse_file()?,
other => {
return Err(self.err(format!("expected 'global' or 'file', got {other:?}")));
}
}
}
Ok(std::mem::take(&mut self.loads))
}
fn parse_global(&mut self) -> CaResult<()> {
self.expect(&Tok::Global)?;
self.expect(&Tok::OBrace)?;
let defs = self.parse_variable_definitions()?;
self.expect(&Tok::CBrace)?;
self.globals.extend(defs);
Ok(())
}
fn parse_file(&mut self) -> CaResult<()> {
self.expect(&Tok::File)?;
let filename = self.expect_str()?;
self.expect(&Tok::OBrace)?;
if self.peek() == Some(&Tok::CBrace) {
self.next();
return Ok(());
}
match self.peek() {
Some(Tok::Pattern) => self.parse_pattern_block(&filename)?,
_ => self.parse_variable_substitutions(&filename)?,
}
self.expect(&Tok::CBrace)?;
Ok(())
}
fn parse_pattern_block(&mut self, filename: &str) -> CaResult<()> {
self.expect(&Tok::Pattern)?;
self.expect(&Tok::OBrace)?;
let mut names: Vec<String> = Vec::new();
while self.peek() != Some(&Tok::CBrace) {
match self.peek() {
Some(Tok::Comma) => {
self.next();
}
Some(Tok::Str(_)) => names.push(self.expect_str()?),
Some(other) => {
return Err(self.err(format!("expected pattern name, got {other:?}")));
}
None => return Err(self.err("unterminated pattern name list")),
}
}
self.expect(&Tok::CBrace)?;
while let Some(tok) = self.peek() {
match tok {
Tok::Global => self.parse_global()?,
Tok::OBrace => {
let row = self.parse_pattern_row()?;
self.emit_load(filename, self.pattern_macros(&names, &row));
}
Tok::Str(_) => {
let _extraneous = self.expect_str()?;
let row = self.parse_pattern_row()?;
self.emit_load(filename, self.pattern_macros(&names, &row));
}
Tok::CBrace => break,
other => return Err(self.err(format!("expected substitution row, got {other:?}"))),
}
}
Ok(())
}
fn parse_pattern_row(&mut self) -> CaResult<Vec<String>> {
self.expect(&Tok::OBrace)?;
let mut values: Vec<String> = Vec::new();
while self.peek() != Some(&Tok::CBrace) {
match self.peek() {
Some(Tok::Comma) => {
self.next();
}
Some(Tok::Str(_)) => values.push(self.expect_str()?),
Some(other) => {
return Err(self.err(format!("expected substitution value, got {other:?}")));
}
None => return Err(self.err("unterminated substitution row")),
}
}
self.expect(&Tok::CBrace)?;
Ok(values)
}
fn pattern_macros(&self, names: &[String], row: &[String]) -> Vec<(String, String)> {
names
.iter()
.zip(row.iter())
.map(|(n, v)| (n.clone(), v.clone()))
.collect()
}
fn parse_variable_substitutions(&mut self, filename: &str) -> CaResult<()> {
while let Some(tok) = self.peek() {
match tok {
Tok::Global => self.parse_global()?,
Tok::OBrace => {
self.next();
let defs = self.parse_variable_definitions()?;
self.expect(&Tok::CBrace)?;
self.emit_load(filename, defs);
}
Tok::Str(_) => {
let _extraneous = self.expect_str()?;
self.expect(&Tok::OBrace)?;
let defs = self.parse_variable_definitions()?;
self.expect(&Tok::CBrace)?;
self.emit_load(filename, defs);
}
Tok::CBrace => break,
other => return Err(self.err(format!("expected substitution row, got {other:?}"))),
}
}
Ok(())
}
fn parse_variable_definitions(&mut self) -> CaResult<Vec<(String, String)>> {
let mut defs: Vec<(String, String)> = Vec::new();
while self.peek() != Some(&Tok::CBrace) {
match self.peek() {
Some(Tok::Comma) => {
self.next();
}
Some(Tok::Str(_)) => {
let name = self.expect_str()?;
self.expect(&Tok::Equals)?;
let value = self.expect_str()?;
defs.push((name, value));
}
Some(other) => {
return Err(self.err(format!("expected 'name=value', got {other:?}")));
}
None => return Err(self.err("unterminated definition block")),
}
}
Ok(defs)
}
fn emit_load(&mut self, filename: &str, row: Vec<(String, String)>) {
let mut macros = self.globals.clone();
macros.extend(row);
self.loads.push(TemplateLoad {
file: filename.to_string(),
macros,
});
}
}
pub fn parse_substitutions(input: &str) -> CaResult<Vec<TemplateLoad>> {
let toks = lex(input)?;
Parser::new(toks).parse()
}
pub fn load_substitution_file(
path: &Path,
macros: &HashMap<String, String>,
config: &DbLoadConfig,
) -> CaResult<Vec<DbRecordDef>> {
let content = std::fs::read_to_string(path).map_err(|e| CaError::DbParseError {
line: 0,
column: 0,
message: format!("cannot read substitutions file '{}': {}", path.display(), e),
})?;
let loads = parse_substitutions(&content)?;
let base_dir = path.parent().map(Path::to_path_buf).unwrap_or_default();
let mut records: Vec<DbRecordDef> = Vec::new();
for load in loads {
let mut merged: HashMap<String, String> = macros.clone();
for (k, v) in &load.macros {
merged.insert(k.clone(), v.clone());
}
let template_path = resolve_template(&load.file, &base_dir, &config.include_paths)?;
let defs = parse_db_file(&template_path, &merged, config)?;
records.extend(defs);
}
Ok(records)
}
fn resolve_template(
filename: &str,
base_dir: &Path,
include_paths: &[PathBuf],
) -> CaResult<PathBuf> {
let file_path = Path::new(filename);
if file_path.is_absolute() {
if file_path.exists() {
return Ok(file_path.to_path_buf());
}
return Err(CaError::DbParseError {
line: 0,
column: 0,
message: format!("template file not found: '{filename}'"),
});
}
let local = base_dir.join(file_path);
if local.exists() {
return Ok(local);
}
for dir in include_paths {
let cand = dir.join(file_path);
if cand.exists() {
return Ok(cand);
}
}
Err(CaError::DbParseError {
line: 0,
column: 0,
message: format!("template file not found: '{filename}'"),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_pattern_substitution() {
let src = r#"
file "rec.db" {
pattern { P, N }
{ "IOC:", "1" }
{ "IOC:", "2" }
}
"#;
let loads = parse_substitutions(src).unwrap();
assert_eq!(loads.len(), 2);
assert_eq!(loads[0].file, "rec.db");
assert_eq!(
loads[0].macros,
vec![("P".into(), "IOC:".into()), ("N".into(), "1".into())]
);
assert_eq!(
loads[1].macros,
vec![("P".into(), "IOC:".into()), ("N".into(), "2".into())]
);
}
#[test]
fn parse_pattern_with_comma_separated_names() {
let src = r#"file "x.db" { pattern {A,B,C} {"1","2","3"} }"#;
let loads = parse_substitutions(src).unwrap();
assert_eq!(loads.len(), 1);
assert_eq!(
loads[0].macros,
vec![
("A".into(), "1".into()),
("B".into(), "2".into()),
("C".into(), "3".into())
]
);
}
#[test]
fn parse_variable_substitution() {
let src = r#"
file "rec.db" {
{ A=1, B=2 }
{ A=3, B=4 }
}
"#;
let loads = parse_substitutions(src).unwrap();
assert_eq!(loads.len(), 2);
assert_eq!(
loads[0].macros,
vec![("A".into(), "1".into()), ("B".into(), "2".into())]
);
assert_eq!(
loads[1].macros,
vec![("A".into(), "3".into()), ("B".into(), "4".into())]
);
}
#[test]
fn parse_global_block_applies_to_all_rows() {
let src = r#"
global { G="gval" }
file "rec.db" {
pattern { N }
{ "1" }
{ "2" }
}
"#;
let loads = parse_substitutions(src).unwrap();
assert_eq!(loads.len(), 2);
assert_eq!(
loads[0].macros,
vec![("G".into(), "gval".into()), ("N".into(), "1".into())]
);
assert_eq!(
loads[1].macros,
vec![("G".into(), "gval".into()), ("N".into(), "2".into())]
);
}
#[test]
fn parse_quoted_filename() {
let src = r#"file "path/to/rec.db" { { A=1 } }"#;
let loads = parse_substitutions(src).unwrap();
assert_eq!(loads[0].file, "path/to/rec.db");
}
#[test]
fn parse_bare_filename() {
let src = r#"file rec.db { { A=1 } }"#;
let loads = parse_substitutions(src).unwrap();
assert_eq!(loads[0].file, "rec.db");
}
#[test]
fn parse_empty_file_body() {
let src = r#"file "rec.db" { }"#;
let loads = parse_substitutions(src).unwrap();
assert!(loads.is_empty());
}
#[test]
fn parse_empty_pattern_row() {
let src = r#"file "rec.db" { pattern {N} {} }"#;
let loads = parse_substitutions(src).unwrap();
assert_eq!(loads.len(), 1);
assert!(loads[0].macros.is_empty());
}
#[test]
fn parse_comments_and_whitespace() {
let src = r#"
# header comment
file "rec.db" { # trailing comment
pattern { N }
{ "1" } # row comment
}
"#;
let loads = parse_substitutions(src).unwrap();
assert_eq!(loads.len(), 1);
}
#[test]
fn parse_deprecated_word_prefix_row() {
let src = r#"file "rec.db" { pattern {N} extra {"1"} }"#;
let loads = parse_substitutions(src).unwrap();
assert_eq!(loads.len(), 1);
assert_eq!(loads[0].macros, vec![("N".into(), "1".into())]);
}
#[test]
fn parse_multiple_files() {
let src = r#"
file "a.db" { { X=1 } }
file "b.db" { { Y=2 } }
"#;
let loads = parse_substitutions(src).unwrap();
assert_eq!(loads.len(), 2);
assert_eq!(loads[0].file, "a.db");
assert_eq!(loads[1].file, "b.db");
}
#[test]
fn parse_rejects_unterminated_string() {
let src = "file \"rec.db\" { { A=\"oops\nB=1 } }";
assert!(parse_substitutions(src).is_err());
}
#[test]
fn parse_rejects_missing_keyword() {
let src = r#"{ A=1 }"#;
assert!(parse_substitutions(src).is_err());
}
#[test]
fn load_substitution_file_drives_template_loads() {
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let tmpl = dir.path().join("rec.db");
let mut f = std::fs::File::create(&tmpl).unwrap();
writeln!(f, r#"record(ai, "$(P)$(N)") {{ field(VAL, "$(N)") }}"#).unwrap();
let subs = dir.path().join("test.substitutions");
let mut f = std::fs::File::create(&subs).unwrap();
writeln!(f, r#"global {{ P="IOC:" }}"#).unwrap();
writeln!(f, r#"file "rec.db" {{"#).unwrap();
writeln!(f, r#" pattern {{ N }}"#).unwrap();
writeln!(f, r#" {{ "1" }}"#).unwrap();
writeln!(f, r#" {{ "2" }}"#).unwrap();
writeln!(f, r#"}}"#).unwrap();
let config = DbLoadConfig::default();
let recs = load_substitution_file(&subs, &HashMap::new(), &config).unwrap();
assert_eq!(recs.len(), 2);
assert_eq!(recs[0].name, "IOC:1");
assert_eq!(recs[0].fields[0].1, "1");
assert_eq!(recs[1].name, "IOC:2");
assert_eq!(recs[1].fields[0].1, "2");
}
#[test]
fn load_substitution_file_caller_macros_overridden_by_row() {
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let tmpl = dir.path().join("rec.db");
let mut f = std::fs::File::create(&tmpl).unwrap();
writeln!(f, r#"record(ai, "$(N)") {{ field(VAL, "0") }}"#).unwrap();
let subs = dir.path().join("v.substitutions");
let mut f = std::fs::File::create(&subs).unwrap();
writeln!(f, r#"file "rec.db" {{ {{ N=ROW }} }}"#).unwrap();
let mut macros = HashMap::new();
macros.insert("N".to_string(), "CALLER".to_string());
let config = DbLoadConfig::default();
let recs = load_substitution_file(&subs, ¯os, &config).unwrap();
assert_eq!(recs.len(), 1);
assert_eq!(recs[0].name, "ROW");
}
}