use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use crate::error::{CaError, CaResult};
use crate::runtime::log::ERL_ERROR;
use super::{DbFaults, DbRecordDef, MacroDefs};
pub struct DbLoadConfig {
pub include_paths: Vec<PathBuf>,
pub max_include_depth: usize,
}
impl Default for DbLoadConfig {
fn default() -> Self {
Self {
include_paths: Vec::new(),
max_include_depth: 32,
}
}
}
pub fn parse_db_file(
path: &Path,
macros: impl Into<MacroDefs>,
config: &DbLoadConfig,
) -> CaResult<Vec<DbRecordDef>> {
let parsed = parse_db_file_with_breaktables(path, macros, config)?;
for (target, alias) in &parsed.unresolved_aliases {
eprintln!("{}", super::unknown_alias_message(alias, target));
}
Ok(parsed.records)
}
pub fn parse_db_file_with_breaktables(
path: &Path,
macros: impl Into<MacroDefs>,
config: &DbLoadConfig,
) -> CaResult<super::ParsedDb> {
parse_db_opened_with_breaktables(&DbOpenedFile::taken_outright(path), macros, config)
}
pub fn parse_db_opened_with_breaktables(
opened: &DbOpenedFile,
macros: impl Into<MacroDefs>,
config: &DbLoadConfig,
) -> CaResult<super::ParsedDb> {
let mut faults = DbFaults::default();
let expanded = match expand_includes_mapped(opened, macros, config, &mut faults) {
Ok(expanded) => expanded,
Err(e) => {
faults.abort(&e);
return Err(e);
}
};
let mut parsed = super::parse_db_expanded(&expanded.text, expanded.source)?;
faults.absorb(parsed.faults);
parsed.faults = faults;
Ok(parsed)
}
pub struct DbExpandedText {
pub text: String,
pub source: super::DbSource,
}
pub fn expand_includes_mapped(
opened: &DbOpenedFile,
macros: impl Into<MacroDefs>,
config: &DbLoadConfig,
faults: &mut DbFaults,
) -> CaResult<DbExpandedText> {
let mut table = super::MacroTable::new(
macros,
super::MacroExpandOptions {
suppress_warnings: super::db_quiet_macro_warnings(),
..super::MacroExpandOptions::default()
},
);
let mut stack = Vec::new();
let mut out = Lines::default();
expand_includes_inner(opened, &mut table, config, &mut stack, faults, &mut out)?;
Ok(DbExpandedText {
text: out.text,
source: super::DbSource::new(out.lines, out.frames),
})
}
#[derive(Default)]
struct Lines {
text: String,
lines: Vec<String>,
frames: Vec<std::sync::Arc<[super::DbIncludeFrame]>>,
open: Vec<super::DbIncludeFrame>,
}
impl Lines {
fn push(&mut self, text: String) {
self.text.push_str(&text);
self.lines.push(text);
self.frames.push(std::sync::Arc::from(
self.open.iter().rev().cloned().collect::<Vec<_>>(),
));
}
}
pub fn expand_includes(
path: &Path,
macros: impl Into<MacroDefs>,
config: &DbLoadConfig,
faults: &mut DbFaults,
) -> CaResult<String> {
Ok(expand_includes_mapped(&DbOpenedFile::taken_outright(path), macros, config, faults)?.text)
}
struct OpenFile {
identity: PathBuf,
named: String,
}
fn expand_includes_inner(
opened: &DbOpenedFile,
table: &mut super::MacroTable,
config: &DbLoadConfig,
stack: &mut Vec<OpenFile>,
faults: &mut DbFaults,
out: &mut Lines,
) -> CaResult<()> {
let identity = opened
.resolved
.canonicalize()
.unwrap_or_else(|_| opened.resolved.clone());
let named = &opened.named;
if stack.iter().any(|f| f.identity == identity) {
let chain: Vec<&str> = stack.iter().map(|f| f.named.as_str()).collect();
return Err(CaError::DbParseError {
line: 0,
token: String::new(),
message: format!("circular include: {} -> {named}", chain.join(" -> ")),
});
}
if stack.len() >= config.max_include_depth {
return Err(CaError::DbParseError {
line: 0,
token: String::new(),
message: format!(
"include depth limit ({}) exceeded at '{named}'",
config.max_include_depth,
),
});
}
let content = std::fs::read_to_string(&identity).map_err(|e| CaError::DbParseError {
line: 0,
token: String::new(),
message: format!("cannot read '{named}': {e}"),
})?;
stack.push(OpenFile {
identity,
named: named.clone(),
});
let mut local_paths: Vec<PathBuf> = config.include_paths.clone();
let file_name = opened.named.clone();
out.open.push(super::DbIncludeFrame {
path: opened.found_under.clone(),
filename: Some(file_name.clone()),
line: 0,
});
let emit = |out: &mut Lines, n: u32, text: String| {
if let Some(frame) = out.open.last_mut() {
frame.line = n;
}
out.push(text);
};
for (i, raw) in content.split_inclusive('\n').enumerate() {
let line_num = i as u32 + 1;
let line = raw.strip_suffix('\n').unwrap_or(raw);
if let Some(subst_str) = parse_substitute_directive(line) {
for (name, value) in parse_macro_defns(&subst_str) {
match value {
Some(rawval) => table.define(&name, rawval),
None => table.undefine(&name),
}
}
emit(out, line_num, String::from("\n"));
continue;
}
let expanded = super::db_expand_line_in(table, raw, Some(&file_name), line_num);
let line = expanded.strip_suffix('\n').unwrap_or(&expanded);
if let Some(dirs) = parse_path_directive(line, "path") {
local_paths = db_path(&dirs);
emit(out, line_num, String::from("\n"));
} else if let Some(dirs) = parse_path_directive(line, "addpath") {
local_paths.extend(db_add_path(&dirs));
emit(out, line_num, String::from("\n"));
} else if let Some(expanded_filename) = parse_include_directive(line) {
if let Some(frame) = out.open.last_mut() {
frame.line = line_num;
}
let Some(included) = db_open_file_located(&expanded_filename, &local_paths) else {
faults.recoverable(format!(
"{ERL_ERROR}: Can't open include file '{expanded_filename}'"
));
continue;
};
expand_includes_inner(&included, table, config, stack, faults, out)?;
} else {
emit(out, line_num, expanded);
}
}
out.open.pop();
stack.pop();
Ok(())
}
pub(crate) fn parse_include_directive(line: &str) -> Option<String> {
let trimmed = line.trim();
if trimmed.starts_with('#') {
return None;
}
if !trimmed.starts_with("include") {
return None;
}
let rest = &trimmed["include".len()..];
if rest.is_empty() {
return None;
}
let first = rest.chars().next().unwrap();
if !first.is_whitespace() && first != '"' {
return None;
}
let quote_start = rest.find('"')?;
let after_quote = &rest[quote_start + 1..];
let quote_end = after_quote.find('"')?;
Some(after_quote[..quote_end].to_string())
}
pub(crate) fn parse_substitute_directive(line: &str) -> Option<String> {
let trimmed = line.trim();
if trimmed.starts_with('#') {
return None;
}
let rest = trimmed.strip_prefix("substitute")?;
let first = rest.chars().next()?;
if !first.is_whitespace() && first != '"' {
return None;
}
let quote_start = rest.find('"')?;
let after_quote = &rest[quote_start + 1..];
let mut end = 0;
let bytes = after_quote.as_bytes();
while end < bytes.len() && bytes[end] != b'"' {
end += if bytes[end] == b'\\' && end + 1 < bytes.len() && bytes[end + 1] == b'"' {
2
} else {
1
};
}
if end >= bytes.len() {
return None;
}
if !after_quote[end + 1..].chars().all(|c| c == ' ') {
return None;
}
Some(after_quote[..end].to_string())
}
pub(crate) fn parse_path_directive(line: &str, keyword: &str) -> Option<String> {
let trimmed = line.trim();
if trimmed.starts_with('#') {
return None;
}
let rest = trimmed.strip_prefix(keyword)?;
let first = rest.chars().next()?;
if !first.is_whitespace() && first != '"' {
return None;
}
let quote_start = rest.find('"')?;
let after_quote = &rest[quote_start + 1..];
let quote_end = after_quote.find('"')?;
Some(after_quote[..quote_end].to_string())
}
pub const PATH_LIST_SEPARATOR: char = if cfg!(windows) { ';' } else { ':' };
pub fn db_add_path(list: &str) -> Vec<PathBuf> {
use crate::runtime::stdlib::c_isspace;
let mut out: Vec<PathBuf> = Vec::new();
let mut expecting_path = false;
let mut saw_missing_path = false;
let mut rest = list;
while let Some(c) = rest.chars().next() {
if c_isspace(c) {
rest = &rest[c.len_utf8()..];
continue;
}
match rest.find(PATH_LIST_SEPARATOR) {
Some(0) => {
saw_missing_path = true;
rest = &rest[PATH_LIST_SEPARATOR.len_utf8()..];
}
Some(i) => {
expecting_path = true;
out.push(PathBuf::from(rest[..i].trim_end_matches(c_isspace)));
rest = &rest[i + PATH_LIST_SEPARATOR.len_utf8()..];
}
None => {
expecting_path = false;
out.push(PathBuf::from(rest.trim_end_matches(c_isspace)));
rest = "";
}
}
}
if expecting_path || saw_missing_path {
out.push(PathBuf::from("."));
}
out
}
pub fn db_path(list: &str) -> Vec<PathBuf> {
if list.is_empty() {
return vec![PathBuf::from(".")];
}
db_add_path(list)
}
static LOADED_PATH: Mutex<Option<Vec<PathBuf>>> = Mutex::new(None);
pub fn set_loaded_path(paths: &[PathBuf]) {
*LOADED_PATH.lock().unwrap_or_else(|e| e.into_inner()) = Some(paths.to_vec());
}
pub fn loaded_path() -> Option<Vec<PathBuf>> {
LOADED_PATH
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()
}
pub(crate) fn parse_macro_defns(defns: &str) -> Vec<(String, Option<String>)> {
enum St {
PreName,
InName,
PreValue,
InValue,
}
let chars: Vec<char> = defns.chars().collect();
let trimmed = |from: usize, to: usize| -> String {
let mut end = to;
while end > from && chars[end - 1].is_whitespace() {
end -= 1;
}
chars[from..end].iter().collect()
};
let mut pairs: Vec<(String, Option<String>)> = Vec::new();
let mut name = String::new();
let mut del = false;
let mut start = 0;
let mut state = St::PreName;
let mut quote: Option<char> = None;
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if let Some(q) = quote {
if c == q {
quote = None;
}
} else if c == '\'' || c == '"' {
quote = Some(c);
}
let quoted = quote.is_some();
let escape = c == '\\' && i + 1 < chars.len();
loop {
match state {
St::PreName => {
if !quoted && !escape && (c.is_whitespace() || c == ',') {
break;
}
start = i;
state = St::InName;
}
St::InName => {
if quoted || escape || (c != '=' && c != ',') {
break;
}
name = trimmed(start, i);
del = c == ',';
state = St::PreValue;
if c != ',' {
break;
}
}
St::PreValue => {
if !quoted && !escape && c.is_whitespace() {
break;
}
start = i;
state = St::InValue;
}
St::InValue => {
if quoted || escape || c != ',' {
break;
}
let value = trimmed(start, i);
pairs.push((dequote(&name), (!del).then_some(value)));
del = false;
state = St::PreName;
break;
}
}
}
i += if escape { 2 } else { 1 };
}
match state {
St::PreName => {}
St::InName => pairs.push((dequote(&trimmed(start, chars.len())), None)),
St::PreValue => pairs.push((dequote(&name), Some(String::new()))),
St::InValue => {
let value = trimmed(start, chars.len());
pairs.push((dequote(&name), (!del).then_some(value)));
}
}
pairs
}
fn dequote(name: &str) -> String {
let chars: Vec<char> = name.chars().collect();
let mut out = String::with_capacity(name.len());
let mut quote: Option<char> = None;
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if let Some(q) = quote {
if c == q {
quote = None;
i += 1;
continue;
}
} else if c == '\'' || c == '"' {
quote = Some(c);
i += 1;
continue;
}
if c == '\\' && i + 1 < chars.len() {
i += 1;
}
out.push(chars[i]);
i += 1;
}
out
}
pub fn db_open_file(filename: &str, path_list: &[PathBuf]) -> Option<PathBuf> {
db_open_file_located(filename, path_list).map(|opened| opened.resolved)
}
#[derive(Clone, Debug)]
pub struct DbOpenedFile {
pub resolved: PathBuf,
pub named: String,
pub found_under: Option<String>,
}
impl DbOpenedFile {
pub fn taken_outright(path: &Path) -> Self {
Self {
resolved: path.to_path_buf(),
named: path.display().to_string(),
found_under: None,
}
}
}
pub fn db_open_file_located(filename: &str, path_list: &[PathBuf]) -> Option<DbOpenedFile> {
let named = db_expand_file_name(filename)?;
let filename = named.as_str();
if path_list.is_empty() || filename.contains('/') || filename.contains('\\') {
let direct = PathBuf::from(filename);
return direct.exists().then(|| DbOpenedFile {
resolved: direct,
named: named.clone(),
found_under: None,
});
}
path_list.iter().find_map(|dir| {
let candidate = dir.join(filename);
candidate.exists().then(|| DbOpenedFile {
resolved: candidate,
named: named.clone(),
found_under: Some(dir.display().to_string()),
})
})
}
pub fn db_expand_file_name(filename: &str) -> Option<String> {
let expanded = super::expand_macros(
filename,
&HashMap::new(),
super::MacroExpandOptions {
env_fallback: true,
..Default::default()
},
);
(!expanded.errored()).then_some(expanded.text)
}
#[cfg(test)]
mod macro_defns_tests {
use super::*;
fn def(name: &str, value: &str) -> (String, Option<String>) {
(name.to_string(), Some(value.to_string()))
}
fn del(name: &str) -> (String, Option<String>) {
(name.to_string(), None)
}
#[test]
fn simple_pairs() {
assert_eq!(
parse_macro_defns("A=1,B=2"),
vec![def("A", "1"), def("B", "2")]
);
}
#[test]
fn whitespace_trimmed() {
assert_eq!(
parse_macro_defns(" A = 1 , B = 2 "),
vec![def("A", "1"), def("B", "2")]
);
}
#[test]
fn quoted_comma_not_split() {
assert_eq!(
parse_macro_defns(r#"MSG="a,b",N=1"#),
vec![def("MSG", r#""a,b""#), def("N", "1")]
);
}
#[test]
fn quoted_equals_not_split() {
assert_eq!(
parse_macro_defns(r#"EXPR="x=y""#),
vec![def("EXPR", r#""x=y""#)]
);
}
#[test]
fn unquoted_comma_splits_and_the_tail_deletes() {
assert_eq!(
parse_macro_defns("MSG=a,b"),
vec![def("MSG", "a"), del("b")]
);
}
#[test]
fn escaped_separator_is_literal() {
assert_eq!(parse_macro_defns(r"K=a\,b"), vec![def("K", r"a\,b")]);
}
#[test]
fn quotes_are_removed_from_a_name_and_a_bare_name_deletes() {
assert_eq!(parse_macro_defns("'A'=1,B"), vec![def("A", "1"), del("B")]);
}
#[test]
fn an_escaped_quote_stays_in_the_name() {
assert_eq!(parse_macro_defns(r#"\"A\"=1"#), vec![def(r#""A""#, "1")]);
}
#[test]
fn an_empty_name_is_still_a_definition() {
assert_eq!(
parse_macro_defns("=1,B=2"),
vec![def("", "1"), def("B", "2")]
);
}
#[test]
fn a_trailing_comma_adds_nothing() {
assert_eq!(parse_macro_defns("A=1,"), vec![def("A", "1")]);
}
#[test]
fn an_empty_value_is_not_a_deletion() {
assert_eq!(parse_macro_defns("A="), vec![def("A", "")]);
}
#[test]
fn empty_input() {
assert!(parse_macro_defns("").is_empty());
}
}
#[cfg(test)]
mod db_add_path_tests {
use super::*;
fn sep(list: &str) -> String {
list.replace(':', &PATH_LIST_SEPARATOR.to_string())
}
fn p(list: &str) -> Vec<String> {
db_add_path(&sep(list))
.iter()
.map(|d| d.display().to_string())
.collect()
}
#[test]
fn an_empty_element_anywhere_appends_one_dot_at_the_end() {
assert_eq!(p("a:b"), ["a", "b"]);
assert_eq!(p(":a"), ["a", "."]);
assert_eq!(p("a:"), ["a", "."]);
assert_eq!(p("a::b"), ["a", "b", "."]);
assert_eq!(p(":a::b:"), ["a", "b", "."]);
assert_eq!(p(":"), ["."]);
}
#[test]
fn white_space_is_trimmed_and_a_blank_element_is_empty() {
assert_eq!(p(" a :\tb\t"), ["a", "b"]);
assert_eq!(p("a : : b"), ["a", "b", "."]);
assert!(p(" ").is_empty());
assert!(p("").is_empty());
}
#[cfg(not(windows))]
#[test]
fn only_the_platform_separator_splits() {
assert_eq!(p("a;b"), ["a;b"]);
assert_eq!(p(r"C:\epics\db"), ["C", r"\epics\db"]);
}
#[test]
fn db_path_replaces_an_empty_list_with_the_current_directory() {
assert_eq!(
db_path(""),
vec![PathBuf::from(".")],
"an empty list is the current directory"
);
assert!(db_path(" ").is_empty(), "a blank list is not empty");
assert_eq!(db_path(&sep("a:")), db_add_path(&sep("a:")));
}
}
#[cfg(test)]
mod db_open_file_tests {
use super::*;
#[test]
fn db_open_file_gate_matches_dbopenfile() {
assert!(
Path::new("Cargo.toml").exists(),
"test assumes CWD is the package root"
);
let on_list = tempfile::tempdir().unwrap();
std::fs::write(on_list.path().join("Cargo.toml"), "").unwrap();
let empty = tempfile::tempdir().unwrap();
assert_eq!(
db_open_file("Cargo.toml", &[on_list.path().to_path_buf()]),
Some(on_list.path().join("Cargo.toml"))
);
assert_eq!(
db_open_file("Cargo.toml", &[empty.path().to_path_buf()]),
None
);
assert_eq!(
db_open_file("Cargo.toml", &[]),
Some(PathBuf::from("Cargo.toml"))
);
assert_eq!(
db_open_file("./Cargo.toml", &[on_list.path().to_path_buf()]),
Some(PathBuf::from("./Cargo.toml"))
);
let nested = on_list.path().join("sub");
std::fs::create_dir(&nested).unwrap();
std::fs::write(nested.join("x.db"), "").unwrap();
assert_eq!(
db_open_file("sub/x.db", &[on_list.path().to_path_buf()]),
None
);
}
#[test]
#[serial_test::serial(epics_env)]
fn db_open_file_env_expands_the_name() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("common.db"), "").unwrap();
let key = "EPICS_RS_TEST_DB_TOP";
unsafe { std::env::set_var(key, super::super::macro_safe_path(dir.path())) };
let raw = format!("$({key})/common.db");
assert_eq!(
db_open_file(&raw, &[]),
Some(dir.path().join("common.db")),
"environment reference in a bare name must expand"
);
let placeholder = format!("$({key},undefined)/common.db");
assert_eq!(
db_open_file(&placeholder, &[]),
Some(dir.path().join("common.db")),
"the .db reader's undefined-placeholder must re-expand here"
);
assert_eq!(db_open_file("$(NO_SUCH_VAR_HERE)/common.db", &[]), None);
unsafe { std::env::remove_var(key) };
}
#[test]
#[serial_test::serial(epics_env)]
fn include_directive_env_expands_the_name() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("inc.db"),
"record(ai,\"SIM:INC\") { field(VAL,\"7\") }\n",
)
.unwrap();
let main = dir.path().join("main.db");
let key = "EPICS_RS_TEST_INC_TOP";
std::fs::write(&main, format!("include \"$({key})/inc.db\"\n")).unwrap();
unsafe { std::env::set_var(key, super::super::macro_safe_path(dir.path())) };
let out = expand_includes(
&main,
&HashMap::new(),
&DbLoadConfig::default(),
&mut DbFaults::default(),
)
.expect("include with an environment reference must resolve");
assert!(out.contains("SIM:INC"), "expanded output was {out:?}");
unsafe { std::env::remove_var(key) };
}
}