use std::collections::{HashMap, HashSet};
use std::sync::{Mutex, OnceLock};
use crate::error::{CaError, CaResult};
use crate::runtime::log::ERL_ERROR;
use crate::server::record::{FieldDesc, Record, check_link_text};
use crate::types::{DbfCode, EpicsValue, PvString};
mod include;
mod source;
mod substitution;
#[cfg(test)]
pub(crate) use include::parse_include_directive;
pub use include::{
DbLoadConfig, DbOpenedFile, PATH_LIST_SEPARATOR, db_add_path, db_open_file,
db_open_file_located, db_path, expand_includes, loaded_path, parse_db_file,
parse_db_file_with_breaktables, parse_db_opened_with_breaktables, set_loaded_path,
};
pub use source::{DbIncludeFrame, DbSource};
pub use substitution::{
RowMacro, SubstitutionEvent, SubstitutionFault, Substitutions, TemplateLoad,
db_template_max_vars, parse_substitutions, set_db_template_max_vars,
};
pub type RecordFactory = Box<dyn Fn() -> Box<dyn Record> + Send + Sync>;
static RECORD_FACTORY_REGISTRY: OnceLock<Mutex<HashMap<String, RecordFactory>>> = OnceLock::new();
fn get_registry() -> &'static Mutex<HashMap<String, RecordFactory>> {
RECORD_FACTORY_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
}
pub fn register_record_type(name: &str, factory: RecordFactory) {
snapshot_declared_fields(name, &factory);
let mut reg = get_registry()
.lock()
.expect("record factory registry mutex poisoned");
reg.insert(name.to_string(), factory);
}
pub(crate) fn snapshot_declared_fields(name: &str, factory: &RecordFactory) {
use crate::server::record::FieldDeclaration;
let fields = factory().field_list();
if !fields.is_empty() {
declared_types()
.lock()
.expect("declared record type set mutex poisoned")
.insert(name.to_string());
}
crate::server::record::register_declared_fields(name, fields);
}
static DECLARED_TYPES: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
fn declared_types() -> &'static Mutex<HashSet<String>> {
DECLARED_TYPES.get_or_init(|| Mutex::new(HashSet::new()))
}
fn record_type_field_exists(record_type: &str, field: &str) -> Option<bool> {
use crate::server::record::dbd_generated;
let visible = dbd_generated::record_fields(record_type).is_some()
|| declared_types()
.lock()
.expect("declared record type set mutex poisoned")
.contains(record_type);
if !visible {
return None;
}
if crate::server::record::declared_field(record_type, field).is_some_and(|d| d.name == field) {
return Some(true);
}
let listed = |names: &[&str]| names.contains(&field);
if dbd_generated::record_noaccess_fields(record_type).is_some_and(listed)
|| listed(dbd_generated::DB_COMMON_NOACCESS)
{
return Some(true);
}
Some(matches!(field, "RTYP" | "VERS"))
}
const FIELD_CONFUSION_MAP: [&str; 14] = [
"INP", "OUT", "DOL", "INP", "ZNAM", "ZRST", "ONAM", "ONST", "INPA-J", "DOL0-9", "INPK-P",
"DOLA-F", "INP0-9", "INPA-J",
];
fn confusion_guess(name: &str, i: usize) -> Option<String> {
let fieldname = FIELD_CONFUSION_MAP[i].as_bytes();
let replacement = FIELD_CONFUSION_MAP[i ^ 1].as_bytes();
let n = name.as_bytes();
let l = fieldname.len();
if l >= 3 && fieldname[l - 2] == b'-' {
let k = l - 3;
if n.len() < k || n[..k] != fieldname[..k] {
return None;
}
let c = n.get(k).copied().unwrap_or(0);
if c < fieldname[k] || c > fieldname[l - 1] {
return None;
}
let l2 = replacement.len();
let mut buf = replacement[..l2 - 2].to_vec();
buf[l2 - 3] += c - fieldname[k];
return String::from_utf8(buf).ok();
}
(n == fieldname).then(|| String::from_utf8_lossy(replacement).into_owned())
}
fn str_similarity(a: &str, b: &str) -> f64 {
let (a, b) = (a.as_bytes(), b.as_bytes());
let norm = 2 * a.len().max(b.len());
let mut dist0: Vec<usize> = (0..=b.len()).map(|i| 2 * i).collect();
let mut dist1 = vec![0usize; b.len() + 1];
for (ai, &ca) in a.iter().enumerate() {
dist1[0] = 2 * (ai + 1);
for (bi, &cb) in b.iter().enumerate() {
let delcost = dist0[bi + 1] + 2;
let inscost = dist1[bi] + 2;
let mut subcost = dist0[bi];
if ca != cb {
subcost += 1;
}
if !ca.eq_ignore_ascii_case(&cb) {
subcost += 1;
}
dist1[bi + 1] = delcost.min(inscost).min(subcost);
}
std::mem::swap(&mut dist0, &mut dist1);
}
if norm == 0 {
1.0
} else {
(norm - dist0[b.len()]) as f64 / norm as f64
}
}
fn value_type_weight(record_type: &str, desc: &FieldDesc, value: &str) -> f64 {
use crate::types::c_parse::{self, NumericField};
let numeric = match desc.declared_dbf {
DbfCode::Char => NumericField::Char,
DbfCode::UChar => NumericField::UChar,
DbfCode::Short => NumericField::Short,
DbfCode::UShort | DbfCode::Enum => NumericField::UShort,
DbfCode::Long => NumericField::Long,
DbfCode::ULong => NumericField::ULong,
DbfCode::Int64 => NumericField::Int64,
DbfCode::UInt64 => NumericField::UInt64,
DbfCode::Float => NumericField::Float,
DbfCode::Double => NumericField::Double,
DbfCode::Menu | DbfCode::Device => return menu_type_weight(record_type, desc, value),
DbfCode::String
| DbfCode::Inlink
| DbfCode::Outlink
| DbfCode::Fwdlink
| DbfCode::NoAccess => return 1.0,
};
if c_parse::parse_auto_base_units_null(numeric, value).is_some() {
1.0
} else {
0.1
}
}
fn menu_type_weight(record_type: &str, desc: &FieldDesc, value: &str) -> f64 {
use crate::types::c_parse::{self, NumericField};
let device = matches!(desc.declared_dbf, DbfCode::Device);
let contributed;
let choices: &[&str] = if device {
contributed = crate::server::record::merged_device_menu(record_type);
&contributed
} else {
desc.menu
.or_else(|| crate::server::record::shared_menu_choices(desc.name))
.unwrap_or(&[])
};
if let Some(EpicsValue::UShort(index)) =
c_parse::parse_auto_base_units_null(NumericField::UShort, value)
{
if usize::from(index) < choices.len() {
return if device { 0.5 } else { 1.0 };
}
}
if choices.contains(&value) { 1.5 } else { 0.1 }
}
fn suggestion_line(desc: &FieldDesc) -> String {
let mut line = did_you_mean(desc.name);
if let Some(prompt) = desc.prompt {
line.push_str(&format!(" ({prompt})"));
}
line
}
fn did_you_mean(name: &str) -> String {
format!(" Did you mean \"{name}\"?")
}
fn suggest_field(record_type: &str, name: &str, value: &str) -> Option<&'static FieldDesc> {
use crate::server::record::{Special, declared_field, declared_fields};
for i in 0..FIELD_CONFUSION_MAP.len() {
let Some(guess) = confusion_guess(name, i) else {
continue;
};
if let Some(desc) = declared_field(record_type, &guess).filter(|d| d.name == guess) {
return Some(desc);
}
}
let mut best: Option<&'static FieldDesc> = None;
let mut best_sim = -1.0_f64;
for desc in declared_fields(record_type) {
if matches!(desc.declared_special, Special::NoMod | Special::DbAddr) {
continue;
}
let mut sim = str_similarity(name, desc.name);
if desc.promptgroup.is_none() {
sim *= 0.5;
}
if desc.interest != 0 {
sim *= 1.0 - 0.1 * f64::from(desc.interest);
}
if sim == 0.0 {
continue;
}
if !value.is_empty() {
sim *= value_type_weight(record_type, desc, value);
}
if sim > best_sim {
best_sim = sim;
best = Some(desc);
}
}
best
}
fn load_menu_of(desc: &FieldDesc) -> Option<(Option<&'static str>, &'static [&'static str])> {
use crate::server::record::shared_menu_choices;
if desc.declared_dbf != DbfCode::Menu
&& (desc.menu.is_none() || desc.declared_dbf == DbfCode::Device)
{
return None;
}
let choices = desc
.menu
.or_else(|| shared_menu_choices(desc.name))
.unwrap_or(&[]);
if choices.is_empty() {
return None;
}
Some((menu_name_of(choices), choices))
}
fn menu_name_of(choices: &'static [&'static str]) -> Option<&'static str> {
use crate::server::record::dbd_generated::MENUS;
if std::ptr::eq(choices, crate::server::record::menu_scan().choices()) {
return Some("menuScan");
}
MENUS
.iter()
.find(|(_, _, table)| std::ptr::eq(*table, choices))
.map(|(name, _, _)| *name)
}
fn cant_set_line(record: &str, field: &str, value: &str, detail: &str, status: &str) -> String {
format!("{ERL_ERROR}: Can't set '{record}.{field}' to '{value}' {detail} : {status}")
}
pub(crate) fn menu_value_refusal(
record_type: &str,
record_name: &str,
field: &str,
value: &str,
) -> Option<MenuRefusal> {
use crate::server::record::declared_field;
let desc = declared_field(record_type, field)?;
if value.contains("$(") || value.contains("${") {
return Some(MenuRefusal {
notice: Some(format!("{record_name}.{field} Has unexpanded macro")),
line: cant_set_line(record_name, field, value, "", "Bad Field value"),
suggestion: load_menu_of(desc).and_then(|(_, choices)| suggest_choice(choices, value)),
});
}
let probe = db_put_string_probe(value);
let (detail, status, suggestion) = match load_menu_of(desc) {
Some((menu_name, choices)) => menu_arm(desc, field, menu_name, choices, probe, value)?,
None => (
String::new(),
numeric_value_refusal(desc.declared_dbf, field, probe)?,
None,
),
};
Some(MenuRefusal {
notice: None,
line: cant_set_line(record_name, field, value, &detail, status),
suggestion,
})
}
fn menu_arm(
desc: &FieldDesc,
field: &str,
menu_name: Option<&str>,
choices: &[&'static str],
probe: &str,
value: &str,
) -> Option<(String, &'static str, Option<String>)> {
use crate::server::record::resolve_menu_field_string_db_load;
use crate::types::c_parse::{self, NumericField};
if resolve_menu_field_string_db_load(field, choices, desc.dbf_type, probe).is_ok() {
return None;
}
let (detail, status) = match c_parse::parse_auto_base_units_null(NumericField::UShort, probe) {
Some(_) => (String::new(), "Bad Field value"),
None => (
format!("using menu {}", menu_name.unwrap_or(field)),
"Illegal choice",
),
};
Some((detail, status, suggest_choice(choices, value)))
}
const S_STDLIB_NO_CONVERSION: &str = "No digits to convert";
const S_STDLIB_EXTRANEOUS: &str = "Extraneous characters";
const S_STDLIB_UNDERFLOW: &str = "Too small to represent";
const S_STDLIB_OVERFLOW: &str = "Too large to represent";
fn db_put_string_probe(value: &str) -> &str {
if value.is_empty() { "0" } else { value }
}
fn numeric_value_refusal(declared: DbfCode, field: &str, s: &str) -> Option<&'static str> {
db_load_numeric_value(declared, field, s).err()
}
fn db_load_numeric_value(
declared: DbfCode,
field: &str,
s: &str,
) -> Result<Option<EpicsValue>, &'static str> {
use crate::runtime::stdlib::{ParseDoubleError, epics_parse_double};
use crate::types::c_parse::{self, NumericField};
let s = db_put_string_probe(s);
let (width, cast): (NumericField, fn(u64) -> EpicsValue) = match declared {
DbfCode::Char => (NumericField::Int64, |v| EpicsValue::Char(v as u8)),
DbfCode::Short => (NumericField::Int64, |v| EpicsValue::Short(v as i16)),
DbfCode::Long => (NumericField::Int64, |v| EpicsValue::Long(v as i32)),
DbfCode::Int64 => (NumericField::Int64, |v| EpicsValue::Int64(v as i64)),
DbfCode::UChar => (NumericField::UInt64, |v| EpicsValue::UChar(v as u8)),
DbfCode::Enum | DbfCode::UShort => (NumericField::UInt64, |v| EpicsValue::UShort(v as u16)),
DbfCode::ULong => (NumericField::UInt64, |v| EpicsValue::ULong(v as u32)),
DbfCode::UInt64 => (NumericField::UInt64, |v| EpicsValue::UInt64(v)),
DbfCode::Float | DbfCode::Double => {
let v = epics_parse_double(s).map_err(|e| match e {
ParseDoubleError::NoConversion => S_STDLIB_NO_CONVERSION,
ParseDoubleError::Extraneous => S_STDLIB_EXTRANEOUS,
ParseDoubleError::Underflow => S_STDLIB_UNDERFLOW,
ParseDoubleError::Overflow => S_STDLIB_OVERFLOW,
})?;
if declared == DbfCode::Double {
return Ok(Some(EpicsValue::Double(v)));
}
return match c_parse::narrow_to_f32(v) {
Some(f) => Ok(Some(EpicsValue::Float(f))),
None if v.abs() <= f64::from(f32::MIN_POSITIVE) => Err(S_STDLIB_UNDERFLOW),
None => Err(S_STDLIB_OVERFLOW),
};
}
DbfCode::String
| DbfCode::Menu
| DbfCode::Device
| DbfCode::Inlink
| DbfCode::Outlink
| DbfCode::Fwdlink
| DbfCode::NoAccess => return Ok(None),
};
let bits = match c_parse::parse_auto_base_units_null(width, s) {
Some(EpicsValue::Int64(v)) => v as u64,
Some(EpicsValue::UInt64(v)) => v,
_ => return Err(integer_refusal(width, field, s)),
};
Ok(Some(cast(bits)))
}
fn integer_refusal(
width: crate::types::c_parse::NumericField,
field: &str,
s: &str,
) -> &'static str {
let scanned = s.trim_start_matches(crate::runtime::stdlib::c_isspace);
let scanned = scanned.strip_prefix(['-', '+']).unwrap_or(scanned);
if !scanned.starts_with(|c: char| c.is_ascii_digit()) {
return S_STDLIB_NO_CONVERSION;
}
if crate::types::c_parse::put_string_element(field, width, s).is_err() {
S_STDLIB_OVERFLOW
} else {
S_STDLIB_EXTRANEOUS
}
}
pub(crate) struct MenuRefusal {
pub notice: Option<String>,
pub line: String,
pub suggestion: Option<String>,
}
fn suggest_choice(choices: &[&'static str], value: &str) -> Option<String> {
let mut best: Option<&str> = None;
let mut max_dist = 0.0_f64;
for choice in choices {
let dist = str_similarity(value, choice);
if dist > max_dist {
max_dist = dist;
best = Some(choice);
}
}
best.map(did_you_mean)
}
pub(crate) fn registered_record_type_entries() -> Vec<(String, usize)> {
let reg = get_registry()
.lock()
.expect("record factory registry mutex poisoned");
reg.iter()
.map(|(name, factory)| (name.clone(), (&**factory) as *const _ as *const () as usize))
.collect()
}
#[derive(Debug, Clone)]
pub struct DbRecordDef {
pub record_type: String,
pub name: String,
pub fields: Vec<DbFieldDef>,
pub aliases: Vec<String>,
pub info_tags: Vec<(String, String)>,
pub line: u32,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DbFieldDef {
pub name: String,
pub value: PvString,
pub line: u32,
}
impl DbFieldDef {
pub fn new(name: impl Into<String>, value: impl Into<PvString>) -> Self {
Self {
name: name.into(),
value: value.into(),
line: 0,
}
}
}
pub(crate) fn validate_record_name(name: &str, line: usize, _col: usize) -> CaResult<()> {
if name.is_empty() {
return Err(CaError::DbParseError {
line,
token: String::from(")"),
message: "record/alias name can't be empty".into(),
});
}
for (i, c) in name.chars().enumerate() {
if i == 0 && matches!(c, '-' | '+' | '[' | '{') {
tracing::warn!(name, "record/alias name should not begin with '{}'", c);
}
if (c as u32) < 0x20 {
tracing::warn!(
name,
"record/alias name should not contain non-printable 0x{:02X}",
c as u32
);
continue;
}
if matches!(c, ' ' | '\t' | '"' | '\'' | '.' | '$') {
return Err(CaError::DbParseError {
line,
token: String::from(")"),
message: format!("bad character '{c}' in record/alias name \"{name}\""),
});
}
}
Ok(())
}
#[derive(Debug)]
pub struct DbDiagnostic {
pub notice: Option<String>,
pub message: String,
pub suggestion: Option<String>,
}
impl DbDiagnostic {
pub fn new(message: String) -> Self {
Self {
notice: None,
message,
suggestion: None,
}
}
pub fn suggesting(message: String, suggestion: Option<String>) -> Self {
Self {
notice: None,
message,
suggestion,
}
}
}
#[derive(Debug, Default)]
pub struct DbFaults {
messages: Vec<String>,
source: Option<source::DbSource>,
park: Option<(u32, String)>,
yy_failed: bool,
}
impl DbFaults {
pub fn bind_source(&mut self, source: source::DbSource) {
self.source = Some(source);
}
pub fn seek(&mut self, line: u32, yytext: &str) {
self.park = Some((line, yytext.to_owned()));
}
pub fn seek_end(&mut self) {
self.park = None;
}
pub fn abort(&mut self, err: &CaError) {
let message = match err {
CaError::DbParseError {
line,
token,
message,
} => {
self.park = (*line > 0).then(|| (*line as u32, token.clone()));
message.clone()
}
_ => {
self.park = None;
err.to_string()
}
};
self.yyerror(Some(&message));
self.messages.push(message);
}
pub fn recoverable(&mut self, message: String) {
self.report(DbDiagnostic::new(message));
}
pub fn report(&mut self, diagnostic: DbDiagnostic) {
let DbDiagnostic {
notice,
message,
suggestion,
} = diagnostic;
if let Some(notice) = notice {
eprintln!("{notice}");
}
eprintln!("{message}");
if let Some(suggestion) = suggestion {
eprintln!("{suggestion}");
}
self.yyerror(None);
self.messages.push(message);
}
fn yyerror(&mut self, str: Option<&str>) {
if let Some(str) = str {
eprintln!("{ERL_ERROR}: {str}");
}
if let Some(context) = self.position_context() {
if str.is_none() {
eprint!("{ERL_ERROR}: ");
}
eprint!("{context}");
}
}
fn position_context(&mut self) -> Option<String> {
let (line, token) = self.park.as_ref()?;
let line = *line;
let clause = format!(" at or before '{token}'");
let (frames, text) = self.source.as_ref()?.at(line)?;
let mut out = String::new();
if !self.yy_failed {
out.push_str(&clause);
out.push_str(&source::include_print(frames));
self.yy_failed = true;
}
out.push_str(&format!("\n {line} | {text}\n"));
Some(out)
}
pub fn absorb(&mut self, other: DbFaults) {
self.messages.extend(other.messages);
self.yy_failed |= other.yy_failed;
if self.source.is_none() {
self.source = other.source;
}
}
pub fn is_empty(&self) -> bool {
self.messages.is_empty()
}
#[cfg(test)]
pub fn first_diagnostic(&self) -> Option<String> {
match self.messages.split_first() {
None => None,
Some((first, [])) => Some(first.clone()),
Some((first, rest)) => Some(format!("{first} (+{} more)", rest.len())),
}
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct DbdMenu {
pub name: String,
pub choices: Vec<(String, String)>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct DbdField {
pub name: String,
pub dbf_type: String,
pub items: Vec<(String, String)>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct DbdRecordType {
pub name: String,
pub fields: Vec<DbdField>,
pub cdefs: Vec<String>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct DbdDevice {
pub record_type: String,
pub link_type: String,
pub dset: String,
pub choice: String,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct DbdLinkType {
pub key: String,
pub lset: String,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct DbdVariable {
pub name: String,
pub dtype: String,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct DbdDefs {
pub menus: Vec<DbdMenu>,
pub record_types: Vec<DbdRecordType>,
pub devices: Vec<DbdDevice>,
pub drivers: Vec<String>,
pub link_types: Vec<DbdLinkType>,
pub registrars: Vec<String>,
pub functions: Vec<String>,
pub variables: Vec<DbdVariable>,
}
impl DbdDefs {
#[must_use]
pub fn is_empty(&self) -> bool {
self.menus.is_empty()
&& self.record_types.is_empty()
&& self.devices.is_empty()
&& self.drivers.is_empty()
&& self.link_types.is_empty()
&& self.registrars.is_empty()
&& self.functions.is_empty()
&& self.variables.is_empty()
}
#[must_use]
pub fn menu(&self, name: &str) -> Option<&DbdMenu> {
self.menus.iter().find(|m| m.name == name)
}
}
pub struct ParsedDb {
pub records: Vec<DbRecordDef>,
pub breaktables: Vec<crate::server::cvt_bpt::BrkTable>,
pub unresolved_aliases: Vec<(String, String)>,
pub faults: DbFaults,
pub dbd: DbdDefs,
}
impl ParsedDb {
pub fn load_status(self, source: &str) -> CaResult<Self> {
if self.faults.is_empty() {
Ok(self)
} else {
Err(CaError::DbLoadFailed(source.to_string()))
}
}
}
pub const DB_STRING_SOURCE: &str = "<db string>";
pub fn unknown_alias_message(alias: &str, target: &str) -> String {
format!("{ERL_ERROR}: Alias '{alias}' names an unknown record '{target}'")
}
pub fn parse_db(input: &str, macros: impl Into<MacroDefs>) -> CaResult<Vec<DbRecordDef>> {
let parsed = parse_db_with_breaktables(input, macros)?.load_status(DB_STRING_SOURCE)?;
for (target, alias) in &parsed.unresolved_aliases {
eprintln!("{}", unknown_alias_message(alias, target));
}
Ok(parsed.records)
}
static DB_QUIET_MACRO_WARNINGS: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
pub fn db_quiet_macro_warnings() -> bool {
DB_QUIET_MACRO_WARNINGS.load(std::sync::atomic::Ordering::Relaxed)
}
pub fn set_db_quiet_macro_warnings(quiet: bool) {
DB_QUIET_MACRO_WARNINGS.store(quiet, std::sync::atomic::Ordering::Relaxed);
}
pub(crate) fn db_read_lines(
text: &str,
macros: impl Into<MacroDefs>,
filename: Option<&str>,
) -> (String, DbSource) {
let mut table = MacroTable::new(
macros,
MacroExpandOptions {
suppress_warnings: db_quiet_macro_warnings(),
..MacroExpandOptions::default()
},
);
let mut out = String::with_capacity(text.len());
let mut lines = Vec::new();
let mut frames = Vec::new();
for (i, raw) in text.split_inclusive('\n').enumerate() {
let line_num = i as u32 + 1;
let expanded = db_expand_line_in(&mut table, raw, filename, line_num);
out.push_str(&expanded);
lines.push(expanded);
frames.push(std::sync::Arc::from(vec![DbIncludeFrame {
path: None,
filename: filename.map(str::to_string),
line: line_num,
}]));
}
(out, DbSource::new(lines, frames))
}
pub(crate) fn db_expand_line_in(
table: &mut MacroTable,
raw: &str,
filename: Option<&str>,
line_num: u32,
) -> String {
let expansion = table.expand(raw);
if expansion.errored()
&& let Some(name) = filename
{
eprintln!(
"{}: '{name}' line {line_num} has undefined macros",
crate::runtime::log::ERL_WARNING
);
}
expansion.text
}
pub fn parse_db_with_breaktables(input: &str, macros: impl Into<MacroDefs>) -> CaResult<ParsedDb> {
let (expanded, source) = db_read_lines(input, macros, None);
parse_db_expanded(&expanded, source)
}
pub(crate) fn parse_db_expanded(expanded: &str, source: DbSource) -> CaResult<ParsedDb> {
let mut faults = DbFaults::default();
faults.bind_source(source);
match parse_db_items(expanded, &mut faults) {
Ok(items) => Ok(ParsedDb {
records: items.records,
breaktables: items.breaktables,
unresolved_aliases: items.unresolved_aliases,
faults,
dbd: items.dbd,
}),
Err(e) => {
faults.abort(&e);
Err(e)
}
}
}
struct DbItems {
records: Vec<DbRecordDef>,
breaktables: Vec<crate::server::cvt_bpt::BrkTable>,
unresolved_aliases: Vec<(String, String)>,
dbd: DbdDefs,
}
fn parse_db_items(expanded: &str, faults: &mut DbFaults) -> CaResult<DbItems> {
let mut records = Vec::new();
let mut breaktables: Vec<crate::server::cvt_bpt::BrkTable> = Vec::new();
let mut dbd = DbdDefs::default();
let mut global_aliases: Vec<(String, String)> = Vec::new();
let chars: Vec<char> = expanded.chars().collect();
let mut pos = 0;
let mut line = 1;
let mut col = 1;
while pos < chars.len() {
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
if pos >= chars.len() {
break;
}
let word = read_word(&chars, &mut pos, &mut col);
if word.is_empty() {
pos += 1;
col += 1;
continue;
}
if word == "path" || word == "addpath" {
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
let _dir = read_token_string(&chars, &mut pos, &mut line, &mut col)?;
continue;
}
if word == "include" {
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
let _file = read_token_string(&chars, &mut pos, &mut line, &mut col)?;
continue;
}
if word == "alias" {
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
expect_char(&chars, &mut pos, &mut col, '(', line)?;
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
let target = read_token_string(&chars, &mut pos, &mut line, &mut col)?;
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
expect_char(&chars, &mut pos, &mut col, ',', line)?;
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
let alias_name = read_token_string(&chars, &mut pos, &mut line, &mut col)?;
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
expect_char(&chars, &mut pos, &mut col, ')', line)?;
validate_record_name(&alias_name, line, col)?;
global_aliases.push((target, alias_name));
continue;
}
if word == "breaktable" {
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
expect_char(&chars, &mut pos, &mut col, '(', line)?;
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
let bt_name = read_token_string(&chars, &mut pos, &mut line, &mut col)?;
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
expect_char(&chars, &mut pos, &mut col, ')', line)?;
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
expect_char(&chars, &mut pos, &mut col, '{', line)?;
let is_number_char =
|c: char| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '+' | ':' | '.');
let mut nums: Vec<f64> = Vec::new();
loop {
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
if pos >= chars.len() {
return Err(CaError::DbParseError {
line,
token: String::new(),
message: "unexpected end of file in breaktable body".into(),
});
}
if chars[pos] == '}' {
pos += 1;
col += 1;
break;
}
if chars[pos] == ',' {
pos += 1;
col += 1;
continue;
}
let mut tok = String::new();
while pos < chars.len() && is_number_char(chars[pos]) {
tok.push(chars[pos]);
pos += 1;
col += 1;
}
if tok.is_empty() {
return Err(CaError::DbParseError {
line,
token: yytext_at(&chars, pos),
message: format!(
"breaktable {bt_name}: expected a number, got '{}'",
chars[pos]
),
});
}
let num: f64 = tok.parse().map_err(|_| CaError::DbParseError {
line,
token: tok.clone(),
message: format!("breaktable {bt_name}: non-numeric value '{tok}'"),
})?;
nums.push(num);
}
if nums.len() % 2 != 0 {
return Err(CaError::DbParseError {
line,
token: String::from("}"),
message: format!("breaktable {bt_name}: Raw value missing"),
});
}
let pairs: Vec<(f64, f64)> = nums.chunks_exact(2).map(|c| (c[0], c[1])).collect();
let table = crate::server::cvt_bpt::BrkTable::build(bt_name, &pairs).map_err(|e| {
CaError::DbParseError {
line,
token: String::from("}"),
message: e,
}
})?;
breaktables.push(table);
continue;
}
if word == "menu" {
dbd.menus
.push(parse_menu(&chars, &mut pos, &mut line, &mut col)?);
continue;
}
if word == "recordtype" {
dbd.record_types
.push(parse_recordtype(&chars, &mut pos, &mut line, &mut col)?);
continue;
}
if word == "device" {
let a = parse_arg_list(&chars, &mut pos, &mut line, &mut col, 4, 4)?;
dbd.devices.push(DbdDevice {
record_type: a[0].clone(),
link_type: a[1].clone(),
dset: a[2].clone(),
choice: a[3].clone(),
});
continue;
}
if word == "driver" {
let a = parse_arg_list(&chars, &mut pos, &mut line, &mut col, 1, 1)?;
dbd.drivers.push(a[0].clone());
continue;
}
if word == "link" {
let a = parse_arg_list(&chars, &mut pos, &mut line, &mut col, 2, 2)?;
dbd.link_types.push(DbdLinkType {
key: a[0].clone(),
lset: a[1].clone(),
});
continue;
}
if word == "registrar" {
let a = parse_arg_list(&chars, &mut pos, &mut line, &mut col, 1, 1)?;
dbd.registrars.push(a[0].clone());
continue;
}
if word == "function" {
let a = parse_arg_list(&chars, &mut pos, &mut line, &mut col, 1, 1)?;
dbd.functions.push(a[0].clone());
continue;
}
if word == "variable" {
let a = parse_arg_list(&chars, &mut pos, &mut line, &mut col, 1, 2)?;
dbd.variables.push(DbdVariable {
name: a[0].clone(),
dtype: a.get(1).cloned().unwrap_or_else(|| "int".to_string()),
});
continue;
}
if word != "record" && word != "grecord" {
return Err(CaError::DbParseError {
line,
token: word.clone(),
message: SYNTAX_ERROR.into(),
});
}
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
expect_char(&chars, &mut pos, &mut col, '(', line)?;
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
let rec_type = read_token_string(&chars, &mut pos, &mut line, &mut col)?;
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
expect_char(&chars, &mut pos, &mut col, ',', line)?;
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
let name = read_token_string(&chars, &mut pos, &mut line, &mut col)?;
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
expect_char(&chars, &mut pos, &mut col, ')', line)?;
let head_line = line as u32;
validate_record_name(&name, line, col)?;
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
let mut fields = Vec::new();
let mut aliases: Vec<String> = Vec::new();
let mut info_tags: Vec<(String, String)> = Vec::new();
if pos < chars.len() && chars[pos] == '{' {
pos += 1;
col += 1;
loop {
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
if pos >= chars.len() {
return Err(CaError::DbParseError {
line,
token: String::new(),
message: "unexpected end of file in record body".into(),
});
}
if chars[pos] == '}' {
pos += 1;
col += 1;
break;
}
let kw = read_word(&chars, &mut pos, &mut col);
if kw == "include" {
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
let _file = read_token_string(&chars, &mut pos, &mut line, &mut col)?;
continue;
}
if kw != "field" && kw != "info" && kw != "alias" {
return Err(CaError::DbParseError {
line,
token: kw.clone(),
message: SYNTAX_ERROR.into(),
});
}
if kw == "alias" {
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
expect_char(&chars, &mut pos, &mut col, '(', line)?;
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
let alias_name = read_token_string(&chars, &mut pos, &mut line, &mut col)?;
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
expect_char(&chars, &mut pos, &mut col, ')', line)?;
validate_record_name(&alias_name, line, col)?;
aliases.push(alias_name);
continue;
}
if kw == "info" {
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
expect_char(&chars, &mut pos, &mut col, '(', line)?;
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
let tag = read_token_string(&chars, &mut pos, &mut line, &mut col)?;
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
expect_char(&chars, &mut pos, &mut col, ',', line)?;
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
let value = read_field_value(&chars, &mut pos, &mut line, &mut col)?;
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
expect_char(&chars, &mut pos, &mut col, ')', line)?;
info_tags.push((tag, value.as_str_lossy().into_owned()));
continue;
}
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
expect_char(&chars, &mut pos, &mut col, '(', line)?;
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
let field_name = read_token_string(&chars, &mut pos, &mut line, &mut col)?;
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
expect_char(&chars, &mut pos, &mut col, ',', line)?;
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
let field_value = read_field_value(&chars, &mut pos, &mut line, &mut col)?;
skip_whitespace_and_comments(&chars, &mut pos, &mut line, &mut col);
expect_char(&chars, &mut pos, &mut col, ')', line)?;
let field_line = line as u32;
faults.seek(field_line, ")");
match record_type_field_exists(&rec_type, &field_name) {
Some(false) => {
faults.report(DbDiagnostic::suggesting(
format!(
"{ERL_ERROR}: {rec_type} record '{name}' doesn't have a field \
'{field_name}'"
),
suggest_field(&rec_type, &field_name, &field_value.as_str_lossy())
.map(suggestion_line),
));
continue;
}
Some(true) if field_name == "NAME" => {
faults.recoverable(format!(
"{ERL_ERROR}: Can't set 'NAME' field of record '{name}'"
));
continue;
}
_ => {}
}
fields.push(DbFieldDef {
name: field_name,
value: field_value,
line: field_line,
});
}
}
records.push(DbRecordDef {
record_type: rec_type,
name,
fields,
aliases,
info_tags,
line: head_line,
});
}
let mut unresolved_aliases = Vec::new();
for (target, alias_name) in global_aliases {
match records.iter_mut().find(|r| r.name == target) {
Some(rec) => rec.aliases.push(alias_name),
None => unresolved_aliases.push((target, alias_name)),
}
}
faults.seek_end();
Ok(DbItems {
records,
breaktables,
unresolved_aliases,
dbd,
})
}
#[derive(Clone, Copy, Debug, Default)]
pub struct MacroExpandOptions {
pub env_fallback: bool,
pub dollar_escape: bool,
pub suppress_warnings: bool,
}
#[derive(Clone, Debug, Default)]
pub struct MacroExpansion {
pub text: String,
faults: MacroFaults,
}
#[derive(Clone, Debug, Default)]
struct MacroFaults {
undefined: Vec<String>,
recursive: Vec<String>,
unterminated: Vec<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MacroFault<'a> {
Undefined(&'a str),
Recursive(&'a str),
Unterminated(&'a str),
}
impl MacroExpansion {
#[must_use]
pub fn fault(&self) -> Option<MacroFault<'_>> {
if let Some(name) = self.faults.undefined.first() {
return Some(MacroFault::Undefined(name.as_str()));
}
if let Some(name) = self.faults.recursive.first() {
return Some(MacroFault::Recursive(name.as_str()));
}
self.faults
.unterminated
.first()
.map(|raw| MacroFault::Unterminated(raw.as_str()))
}
#[must_use]
pub fn errored(&self) -> bool {
self.fault().is_some()
}
}
const KIND_STRING: &str = "string";
const KIND_MACRO: &str = "macro";
const KIND_ENVIRONMENT: &str = "environment variable";
const KIND_SCOPE_MARKER: &str = "scope marker";
const KIND_SCOPED_MACRO: &str = "scoped macro";
#[derive(Clone, Debug)]
enum TableFault {
Undefined(String),
Recursive(String),
Unterminated(String),
}
impl MacroFaults {
fn raise(&mut self, fault: TableFault) {
match fault {
TableFault::Undefined(name) => self.undefined.push(name),
TableFault::Recursive(name) => self.recursive.push(name),
TableFault::Unterminated(text) => self.unterminated.push(text),
}
}
fn merge(&mut self, other: &MacroFaults) {
self.undefined.extend_from_slice(&other.undefined);
self.recursive.extend_from_slice(&other.recursive);
self.unterminated.extend_from_slice(&other.unterminated);
}
fn rename_recursive(&mut self, name: &str) {
for entry in &mut self.recursive {
name.clone_into(entry);
}
}
}
#[derive(Clone, Debug)]
struct MacEntry {
name: String,
kind: &'static str,
rawval: String,
value: Option<String>,
faults: MacroFaults,
visited: bool,
special: bool,
level: usize,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct MacroDefs {
defs: Vec<(String, String)>,
}
impl MacroDefs {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn put(&mut self, name: impl Into<String>, value: impl Into<String>) {
let name = name.into();
match self.defs.iter_mut().find(|(n, _)| *n == name) {
Some(slot) => slot.1 = value.into(),
None => self.defs.push((name, value.into())),
}
}
pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
self.defs.iter().map(|(n, v)| (n.as_str(), v.as_str()))
}
#[must_use]
pub fn len(&self) -> usize {
self.defs.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.defs.is_empty()
}
}
impl FromIterator<(String, String)> for MacroDefs {
fn from_iter<I: IntoIterator<Item = (String, String)>>(iter: I) -> Self {
let mut defs = Self::new();
for (name, value) in iter {
defs.put(name, value);
}
defs
}
}
impl From<Vec<(String, String)>> for MacroDefs {
fn from(pairs: Vec<(String, String)>) -> Self {
pairs.into_iter().collect()
}
}
impl From<&MacroDefs> for MacroDefs {
fn from(defs: &MacroDefs) -> Self {
defs.clone()
}
}
impl From<&HashMap<String, String>> for MacroDefs {
fn from(macros: &HashMap<String, String>) -> Self {
let mut names: Vec<&String> = macros.keys().collect();
names.sort_unstable();
names
.into_iter()
.map(|name| (name.clone(), macros[name].clone()))
.collect()
}
}
pub struct MacroTable {
entries: Vec<MacEntry>,
level: usize,
dirty: bool,
opts: MacroExpandOptions,
}
impl MacroTable {
#[must_use]
pub fn new(defs: impl Into<MacroDefs>, opts: MacroExpandOptions) -> Self {
let entries = defs
.into()
.defs
.into_iter()
.map(|(name, rawval)| MacEntry {
name,
kind: KIND_MACRO,
rawval,
value: None,
faults: MacroFaults::default(),
visited: false,
special: false,
level: 0,
})
.collect();
Self {
entries,
level: 0,
dirty: true,
opts,
}
}
#[must_use]
pub fn expand(&mut self, src: &str) -> MacroExpansion {
let suppressed = self.opts.suppress_warnings;
let mut ctx = ExpandCtx {
table: self,
seat: Seat {
kind: KIND_STRING,
name: src.to_string(),
faults: MacroFaults::default(),
},
suppressed,
};
expand_table(&mut ctx);
let chars: Vec<char> = src.chars().collect();
let mut out = String::with_capacity(src.len());
trans(&chars, 0, &mut ctx, &mut out);
MacroExpansion {
text: out,
faults: ctx.seat.faults,
}
}
pub fn define(&mut self, name: &str, rawval: String) {
self.put(name, KIND_MACRO, rawval);
}
pub fn undefine(&mut self, name: &str) {
let Some(idx) = self.lookup(name) else {
return;
};
self.entries.remove(idx);
self.dirty = true;
}
fn lookup(&self, name: &str) -> Option<usize> {
self.entries
.iter()
.rposition(|e| !e.special && e.name == name)
}
fn lookup_or_env(&mut self, name: &str) -> Option<usize> {
if let Some(i) = self.lookup(name) {
return Some(i);
}
if !self.opts.env_fallback || name.is_empty() {
return None;
}
let value = crate::runtime::env::get(name)?;
Some(self.put(name, KIND_ENVIRONMENT, value))
}
fn put(&mut self, name: &str, kind: &'static str, rawval: String) -> usize {
let idx = match self.lookup(name) {
Some(i) if self.entries[i].level >= self.level => i,
_ => {
self.entries.push(MacEntry {
name: name.to_string(),
kind,
rawval: String::new(),
value: None,
faults: MacroFaults::default(),
visited: false,
special: false,
level: self.level,
});
self.entries.len() - 1
}
};
let entry = &mut self.entries[idx];
entry.kind = kind;
entry.rawval = rawval;
entry.value = None;
entry.faults = MacroFaults::default();
self.dirty = true;
idx
}
fn push_scope(&mut self) {
self.level += 1;
self.entries.push(MacEntry {
name: String::from("<scope>"),
kind: KIND_SCOPE_MARKER,
rawval: String::new(),
value: None,
faults: MacroFaults::default(),
visited: false,
special: true,
level: self.level,
});
}
fn pop_scope(&mut self) {
let at = self
.entries
.iter()
.rposition(|e| e.special)
.expect("refer pushes the scope marker before it can pop one");
self.entries.truncate(at);
self.level -= 1;
self.dirty = true;
}
}
struct Seat {
kind: &'static str,
name: String,
faults: MacroFaults,
}
struct ExpandCtx<'a> {
table: &'a mut MacroTable,
seat: Seat,
suppressed: bool,
}
impl ExpandCtx<'_> {
fn suppressing<R>(&mut self, body: impl FnOnce(&mut Self) -> R) -> R {
let saved = std::mem::replace(&mut self.suppressed, true);
let out = body(self);
self.suppressed = saved;
out
}
fn seated<R>(&mut self, seat: Seat, body: impl FnOnce(&mut Self) -> R) -> (R, MacroFaults) {
let saved = std::mem::replace(&mut self.seat, seat);
let out = body(self);
let raised = std::mem::replace(&mut self.seat, saved).faults;
(out, raised)
}
fn detached<R>(&mut self, name: String, body: impl FnOnce(&mut Self) -> R) -> R {
let seat = Seat {
kind: KIND_SCOPED_MACRO,
name,
faults: MacroFaults::default(),
};
let (out, _discarded) = self.suppressing(|ctx| ctx.seated(seat, body));
out
}
}
fn expand_table(ctx: &mut ExpandCtx) {
if !ctx.table.dirty {
return;
}
let mut i = 0;
while i < ctx.table.entries.len() {
if ctx.table.entries[i].special {
i += 1;
continue;
}
let raw: Vec<char> = ctx.table.entries[i].rawval.chars().collect();
let seat = Seat {
kind: ctx.table.entries[i].kind,
name: ctx.table.entries[i].name.clone(),
faults: MacroFaults::default(),
};
let mut value = String::new();
let (_, raised) = ctx.seated(seat, |ctx| trans(&raw, 1, ctx, &mut value));
let entry = &mut ctx.table.entries[i];
entry.value = Some(value);
entry.faults = raised;
i += 1;
}
ctx.table.dirty = false;
}
#[must_use]
pub fn expand_macros(
input: &str,
macros: impl Into<MacroDefs>,
opts: MacroExpandOptions,
) -> MacroExpansion {
MacroTable::new(macros, opts).expand(input)
}
pub fn substitute_macros(input: &str, macros: impl Into<MacroDefs>) -> String {
expand_macros(input, macros, MacroExpandOptions::default()).text
}
pub fn substitute_macros_per_line(input: &str, macros: impl Into<MacroDefs>) -> String {
let mut table = MacroTable::new(macros, MacroExpandOptions::default());
input
.split_inclusive('\n')
.map(|line| table.expand(line).text)
.collect()
}
fn trans(chars: &[char], level: usize, ctx: &mut ExpandCtx, out: &mut String) {
let discard = level > 0;
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;
if discard {
i += 1;
continue;
}
}
} else if c == '"' || c == '\'' {
quote = Some(c);
if discard {
i += 1;
continue;
}
}
if ctx.table.opts.dollar_escape && c == '$' && i + 1 < chars.len() && chars[i + 1] == '$' {
out.push('$');
i += 2;
continue;
}
if c == '\\' && i + 1 < chars.len() {
if !discard {
out.push('\\');
}
out.push(chars[i + 1]);
i += 2;
continue;
}
let mac_ref =
c == '$' && i + 1 < chars.len() && (chars[i + 1] == '(' || chars[i + 1] == '{');
if mac_ref && quote != Some('\'') {
i = refer(chars, i, level, ctx, out);
continue;
}
out.push(c);
i += 1;
}
}
fn refer(
chars: &[char],
start: usize,
level: usize,
ctx: &mut ExpandCtx,
out: &mut String,
) -> usize {
let close = if chars[start + 1] == '(' { ')' } else { '}' };
let body_start = start + 2;
let mut depth = 1usize;
let mut j = body_start;
while j < chars.len() && depth > 0 {
if j + 1 < chars.len() && chars[j] == '$' && (chars[j + 1] == '(' || chars[j + 1] == '{') {
depth += 1;
j += 2;
continue;
}
if depth == 1 && chars[j] == close || depth > 1 && (chars[j] == ')' || chars[j] == '}') {
depth -= 1;
if depth == 0 {
break;
}
}
j += 1;
}
if depth != 0 {
let verbatim: String = chars[start..].iter().collect();
ctx.seat
.faults
.raise(TableFault::Unterminated(verbatim.clone()));
if !ctx.suppressed {
let unterminated = if crate::runtime::log::errlog_console_paints() {
"\x1b[35;1munterminated\x1b[0m"
} else {
"unterminated"
};
crate::runtime::log::errlog_printf(&format!(
"macLib: {unterminated} macro reference in {} {}\n",
ctx.seat.kind, ctx.seat.name
));
}
out.push_str(&verbatim);
return chars.len();
}
let body = &chars[body_start..j];
let after = j + 1;
let split = top_level_terminator(body);
let (name_chars, rest) = match split {
Some(k) => (&body[..k], &body[k..]),
None => (body, &body[body.len()..]),
};
let mut name = String::new();
ctx.suppressing(|ctx| trans(name_chars, level + 1, ctx, &mut name));
let mut default: Option<&[char]> = None;
let mut scoped: Option<&[char]> = None;
if let Some(first) = rest.first() {
if *first == '=' {
let dflt = &rest[1..];
match top_level_comma(dflt) {
Some(k) => {
default = Some(&dflt[..k]);
scoped = Some(&dflt[k..]);
}
None => default = Some(dflt),
}
} else if *first == ',' {
scoped = Some(rest);
}
}
let pop = scoped.is_some();
if let Some(defs) = scoped {
ctx.table.push_scope();
parse_scoped(defs, level, ctx, &name);
}
match ctx.table.lookup_or_env(&name) {
Some(idx) => {
if ctx.table.entries[idx].visited {
let refkind = ctx.table.entries[idx].kind;
ctx.seat.faults.raise(TableFault::Recursive(name.clone()));
if !ctx.suppressed {
let recursive = if crate::runtime::log::errlog_console_paints() {
"\x1b[35;1mrecursive\x1b[0m"
} else {
"recursive"
};
crate::runtime::log::errlog_printf(&format!(
"macLib: {} {} is {recursive} (expanding {refkind} {name})\n",
ctx.seat.kind, ctx.seat.name
));
}
out.push('$');
out.push('(');
out.push_str(&name);
if ctx.suppressed {
out.push(')');
} else {
out.push_str(",recursive)");
}
} else if ctx.table.dirty {
let raw: Vec<char> = ctx.table.entries[idx].rawval.chars().collect();
ctx.table.entries[idx].visited = true;
trans(&raw, level + 1, ctx, out);
ctx.table.entries[idx].visited = false;
} else {
let entry = &ctx.table.entries[idx];
let value = entry.value.clone().unwrap_or_default();
let mut faults = entry.faults.clone();
faults.rename_recursive(&name);
out.push_str(&value);
ctx.seat.faults.merge(&faults);
}
}
None => match default {
Some(def_chars) => {
trans(def_chars, level + 1, ctx, out);
}
None => {
ctx.seat.faults.raise(TableFault::Undefined(name.clone()));
if !ctx.suppressed {
let undefined = if crate::runtime::log::errlog_console_paints() {
"\x1b[35;1mundefined\x1b[0m"
} else {
"undefined"
};
crate::runtime::log::errlog_printf(&format!(
"macLib: macro {name} is {undefined} (expanding {} {})\n",
ctx.seat.kind, ctx.seat.name
));
}
out.push('$');
out.push('(');
out.push_str(&name);
if ctx.suppressed {
out.push(')');
} else {
out.push_str(",undefined)");
}
}
},
}
if pop {
ctx.table.pop_scope();
}
after
}
fn parse_scoped(rest: &[char], level: usize, ctx: &mut ExpandCtx, refname: &str) {
let mut k = 0;
while k < rest.len() {
if rest[k] != ',' {
break;
}
k += 1; let seg = &rest[k..];
let (name_part, tail) = match top_level_terminator(seg) {
Some(t) => (&seg[..t], &seg[t..]),
None => (seg, &seg[seg.len()..]),
};
let mut sname = String::new();
ctx.detached(refname.to_string(), |ctx| {
trans(name_part, level + 1, ctx, &mut sname);
});
k += name_part.len();
if let Some('=') = tail.first() {
let valseg = &tail[1..];
let (val_part, _) = match top_level_comma(valseg) {
Some(t) => (&valseg[..t], &valseg[t..]),
None => (valseg, &valseg[valseg.len()..]),
};
let mut sval = String::new();
ctx.detached(sname.clone(), |ctx| {
trans(val_part, level + 1, ctx, &mut sval);
});
ctx.table.put(&sname, KIND_MACRO, sval);
k += 1 + val_part.len();
}
}
}
fn top_level_terminator(body: &[char]) -> Option<usize> {
let mut depth = 0usize;
let mut i = 0;
while i < body.len() {
let c = body[i];
if c == '$' && i + 1 < body.len() && (body[i + 1] == '(' || body[i + 1] == '{') {
depth += 1;
i += 2;
continue;
}
if (c == ')' || c == '}') && depth > 0 {
depth -= 1;
} else if depth == 0 && (c == '=' || c == ',') {
return Some(i);
}
i += 1;
}
None
}
fn top_level_comma(body: &[char]) -> Option<usize> {
let mut depth = 0usize;
let mut i = 0;
while i < body.len() {
let c = body[i];
if c == '$' && i + 1 < body.len() && (body[i + 1] == '(' || body[i + 1] == '{') {
depth += 1;
i += 2;
continue;
}
if (c == ')' || c == '}') && depth > 0 {
depth -= 1;
} else if depth == 0 && c == ',' {
return Some(i);
}
i += 1;
}
None
}
fn parse_arg_list(
chars: &[char],
pos: &mut usize,
line: &mut usize,
col: &mut usize,
min: usize,
max: usize,
) -> CaResult<Vec<String>> {
skip_whitespace_and_comments(chars, pos, line, col);
let open_line = *line;
expect_char(chars, pos, col, '(', open_line)?;
let mut args = Vec::new();
loop {
skip_whitespace_and_comments(chars, pos, line, col);
args.push(read_token_string(chars, pos, line, col)?);
skip_whitespace_and_comments(chars, pos, line, col);
match chars.get(*pos) {
Some(',') => {
if args.len() >= max {
return Err(CaError::DbParseError {
line: *line,
token: String::from(","),
message: SYNTAX_ERROR.into(),
});
}
*pos += 1;
*col += 1;
}
_ => break,
}
}
skip_whitespace_and_comments(chars, pos, line, col);
expect_char(chars, pos, col, ')', *line)?;
if args.len() < min {
return Err(CaError::DbParseError {
line: open_line,
token: String::from(")"),
message: SYNTAX_ERROR.into(),
});
}
Ok(args)
}
fn parse_menu(
chars: &[char],
pos: &mut usize,
line: &mut usize,
col: &mut usize,
) -> CaResult<DbdMenu> {
let name = parse_arg_list(chars, pos, line, col, 1, 1)?.remove(0);
skip_whitespace_and_comments(chars, pos, line, col);
expect_char(chars, pos, col, '{', *line)?;
let mut choices = Vec::new();
loop {
skip_whitespace_and_comments(chars, pos, line, col);
match chars.get(*pos) {
None => {
return Err(CaError::DbParseError {
line: *line,
token: String::new(),
message: format!("unexpected end of file in menu({name})"),
});
}
Some('}') => {
*pos += 1;
*col += 1;
break;
}
_ => {}
}
let kw = read_word(chars, pos, col);
match kw.as_str() {
"choice" => {
let mut a = parse_arg_list(chars, pos, line, col, 2, 2)?;
let value = a.remove(1);
choices.push((a.remove(0), value));
}
"include" => {
skip_whitespace_and_comments(chars, pos, line, col);
let _file = read_token_string(chars, pos, line, col)?;
}
_ => {
return Err(CaError::DbParseError {
line: *line,
token: kw.clone(),
message: SYNTAX_ERROR.into(),
});
}
}
}
if name == "menuScan" {
let values: Vec<String> = choices.iter().map(|(_, value)| value.clone()).collect();
crate::server::record::menu_scan::install(&values).map_err(|e| CaError::DbParseError {
line: *line,
token: String::from("}"),
message: format!("menu({name}): {e}"),
})?;
}
Ok(DbdMenu { name, choices })
}
fn parse_recordtype(
chars: &[char],
pos: &mut usize,
line: &mut usize,
col: &mut usize,
) -> CaResult<DbdRecordType> {
let name = parse_arg_list(chars, pos, line, col, 1, 1)?.remove(0);
skip_whitespace_and_comments(chars, pos, line, col);
expect_char(chars, pos, col, '{', *line)?;
let mut fields = Vec::new();
let mut cdefs = Vec::new();
loop {
skip_whitespace_and_comments(chars, pos, line, col);
match chars.get(*pos) {
None => {
return Err(CaError::DbParseError {
line: *line,
token: String::new(),
message: format!("unexpected end of file in recordtype({name})"),
});
}
Some('}') => {
*pos += 1;
*col += 1;
break;
}
Some('%') => {
*pos += 1;
*col += 1;
let mut text = String::new();
while let Some(&c) = chars.get(*pos) {
if c == '\n' {
break;
}
text.push(c);
*pos += 1;
*col += 1;
}
cdefs.push(text);
continue;
}
_ => {}
}
let kw = read_word(chars, pos, col);
match kw.as_str() {
"field" => fields.push(parse_recordtype_field(chars, pos, line, col, &name)?),
"include" => {
skip_whitespace_and_comments(chars, pos, line, col);
let _file = read_token_string(chars, pos, line, col)?;
}
_ => {
return Err(CaError::DbParseError {
line: *line,
token: kw.clone(),
message: SYNTAX_ERROR.into(),
});
}
}
}
Ok(DbdRecordType {
name,
fields,
cdefs,
})
}
fn parse_recordtype_field(
chars: &[char],
pos: &mut usize,
line: &mut usize,
col: &mut usize,
rtype: &str,
) -> CaResult<DbdField> {
let mut head = parse_arg_list(chars, pos, line, col, 2, 2)?;
let dbf_type = head.remove(1);
let name = head.remove(0);
skip_whitespace_and_comments(chars, pos, line, col);
expect_char(chars, pos, col, '{', *line)?;
let mut items = Vec::new();
loop {
skip_whitespace_and_comments(chars, pos, line, col);
match chars.get(*pos) {
None => {
return Err(CaError::DbParseError {
line: *line,
token: String::new(),
message: format!("unexpected end of file in {rtype}.{name}"),
});
}
Some('}') => {
*pos += 1;
*col += 1;
break;
}
_ => {}
}
let key = read_word(chars, pos, col);
if key.is_empty() {
return Err(CaError::DbParseError {
line: *line,
token: yytext_at(chars, *pos),
message: SYNTAX_ERROR.into(),
});
}
let value = parse_arg_list(chars, pos, line, col, 1, 1)?.remove(0);
items.push((key, value));
}
Ok(DbdField {
name,
dbf_type,
items,
})
}
fn skip_whitespace_and_comments(
chars: &[char],
pos: &mut usize,
line: &mut usize,
col: &mut usize,
) {
while *pos < chars.len() {
match chars[*pos] {
' ' | '\t' | '\r' => {
*pos += 1;
*col += 1;
}
'\n' => {
*pos += 1;
*line += 1;
*col = 1;
}
'#' => {
while *pos < chars.len() && chars[*pos] != '\n' {
*pos += 1;
}
}
_ => break,
}
}
}
fn read_word(chars: &[char], pos: &mut usize, col: &mut usize) -> String {
let mut word = String::new();
while *pos < chars.len() && (chars[*pos].is_ascii_alphanumeric() || chars[*pos] == '_') {
word.push(chars[*pos]);
*pos += 1;
*col += 1;
}
word
}
fn read_quoted_string(
chars: &[char],
pos: &mut usize,
line: &mut usize,
col: &mut usize,
) -> CaResult<String> {
if *pos >= chars.len() || chars[*pos] != '"' {
return Err(CaError::DbParseError {
line: *line,
token: yytext_at(chars, *pos),
message: SYNTAX_ERROR.into(),
});
}
*pos += 1;
*col += 1;
let mut s = String::new();
while *pos < chars.len() && chars[*pos] != '"' {
if chars[*pos] == '\\' && *pos + 1 < chars.len() && chars[*pos + 1] != '\n' {
s.push('\\');
s.push(chars[*pos + 1]);
*pos += 2;
*col += 2;
} else if chars[*pos] == '\n' {
return Err(CaError::DbParseError {
line: *line,
token: String::from("\""),
message: "Newline in string, closing quote missing".into(),
});
} else {
s.push(chars[*pos]);
*pos += 1;
*col += 1;
}
}
if *pos >= chars.len() {
return Err(CaError::DbParseError {
line: *line,
token: String::new(),
message: "unterminated string".into(),
});
}
*pos += 1; *col += 1;
Ok(s)
}
fn read_json_string(
chars: &[char],
pos: &mut usize,
line: &mut usize,
col: &mut usize,
) -> CaResult<PvString> {
let quote = match chars.get(*pos) {
Some(&c @ ('"' | '\'')) => c,
_ => {
return Err(CaError::DbParseError {
line: *line,
token: yytext_at(chars, *pos),
message: SYNTAX_ERROR.into(),
});
}
};
let quote_start = *pos;
*pos += 1;
*col += 1;
let err = |line: usize, message: &str| CaError::DbParseError {
line,
token: yytext_at(chars, quote_start),
message: message.into(),
};
let mut escaped = String::new();
loop {
let Some(&c) = chars.get(*pos) else {
return Err(err(*line, "unterminated string"));
};
if c == quote {
*pos += 1;
*col += 1;
break;
}
if c == '\n' {
return Err(err(*line, "Newline in string, closing quote missing"));
}
if c == '\\' {
let Some(&esc) = chars.get(*pos + 1) else {
return Err(err(*line, "unterminated string"));
};
let hex = |n: usize| {
chars
.get(*pos + 2..*pos + 2 + n)
.is_some_and(|ds| ds.iter().all(|d| d.is_ascii_hexdigit()))
};
let width = match esc {
'x' if hex(2) => 4,
'u' if hex(4) => 6,
'x' | 'u' | '1'..='9' => {
return Err(err(
*line,
"invalid escape sequence (\\x needs 2 hex digits, \
\\u needs 4, and \\1..\\9 are not escapes)",
));
}
_ => 2,
};
let consumed = &chars[*pos..*pos + width];
escaped.extend(consumed);
*pos += width;
if consumed.contains(&'\n') {
*line += 1;
*col = 0;
} else {
*col += width;
}
continue;
}
if (c as u32) < 0x20 {
return Err(err(
*line,
"a control character must be escaped inside a quoted value",
));
}
escaped.push(c);
*pos += 1;
*col += 1;
}
Ok(PvString::from_bytes(
crate::runtime::epics_string::raw_from_escaped(&escaped),
))
}
fn read_json_value(
chars: &[char],
pos: &mut usize,
line: &mut usize,
col: &mut usize,
) -> CaResult<String> {
let start_line = *line;
let close = if chars.get(*pos) == Some(&'[') {
']'
} else {
'}'
};
let mut s = String::new();
let mut depth = 0usize;
let mut in_string = false;
let mut escaped = false;
while *pos < chars.len() {
let c = chars[*pos];
s.push(c);
*pos += 1;
if c == '\n' {
*line += 1;
*col = 0;
} else {
*col += 1;
}
if in_string {
if escaped {
escaped = false;
} else if c == '\\' {
escaped = true;
} else if c == '"' {
in_string = false;
}
continue;
}
match c {
'"' => in_string = true,
'{' | '[' => depth += 1,
'}' | ']' => {
depth -= 1;
if depth == 0 {
return Ok(s);
}
}
_ => {}
}
}
Err(CaError::DbParseError {
line: start_line,
token: String::new(),
message: format!("unterminated JSON value (missing '{close}')"),
})
}
fn read_token_string(
chars: &[char],
pos: &mut usize,
line: &mut usize,
col: &mut usize,
) -> CaResult<String> {
if matches!(chars.get(*pos), Some('"')) {
return read_quoted_string(chars, pos, line, col);
}
let mut s = String::new();
while let Some(&c) = chars.get(*pos) {
if c.is_ascii_alphanumeric()
|| matches!(c, '_' | '-' | '+' | ':' | '.' | '[' | ']' | '<' | '>' | ';')
{
s.push(c);
*pos += 1;
*col += 1;
} else {
break;
}
}
if s.is_empty() {
return Err(CaError::DbParseError {
line: *line,
token: yytext_at(chars, *pos),
message: SYNTAX_ERROR.into(),
});
}
Ok(s)
}
fn read_field_value(
chars: &[char],
pos: &mut usize,
line: &mut usize,
col: &mut usize,
) -> CaResult<PvString> {
if matches!(chars.get(*pos), Some('"' | '\'')) {
return read_json_string(chars, pos, line, col);
}
if matches!(chars.get(*pos), Some('{' | '[')) {
return read_json_value(chars, pos, line, col).map(PvString::from);
}
let is_bareword = |c: char| {
c.is_ascii_alphanumeric()
|| matches!(c, '_' | '-' | '+' | ':' | '.' | '[' | ']' | '<' | '>' | ';')
};
let mut s = String::new();
while *pos < chars.len() && is_bareword(chars[*pos]) {
s.push(chars[*pos]);
*pos += 1;
*col += 1;
}
while *pos < chars.len() && matches!(chars[*pos], ' ' | '\t' | '\r' | '\n') {
if chars[*pos] == '\n' {
*line += 1;
*col = 0;
}
*pos += 1;
*col += 1;
}
if *pos < chars.len() && chars[*pos] != ')' && chars[*pos] != ',' {
return Err(CaError::DbParseError {
line: *line,
token: yytext_at(chars, *pos),
message: SYNTAX_ERROR.into(),
});
}
Ok(PvString::from(s))
}
const SYNTAX_ERROR: &str = "syntax error";
fn yytext_at(chars: &[char], pos: usize) -> String {
let Some(&c) = chars.get(pos) else {
return String::new();
};
if c == '"' || c == '\'' {
let mut out = String::from(c);
let mut i = pos + 1;
while let Some(&d) = chars.get(i) {
out.push(d);
i += 1;
if d == '\\' {
if let Some(&e) = chars.get(i) {
out.push(e);
i += 1;
}
continue;
}
if d == c || d == '\n' {
break;
}
}
return out;
}
if is_bareword_char(c) {
return chars[pos..]
.iter()
.take_while(|&&d| is_bareword_char(d))
.collect();
}
c.to_string()
}
fn is_bareword_char(c: char) -> bool {
c.is_ascii_alphanumeric()
|| matches!(c, '_' | '-' | '+' | ':' | '.' | '[' | ']' | '<' | '>' | ';')
}
fn expect_char(
chars: &[char],
pos: &mut usize,
col: &mut usize,
expected: char,
line: usize,
) -> CaResult<()> {
if *pos >= chars.len() || chars[*pos] != expected {
return Err(CaError::DbParseError {
line,
token: yytext_at(chars, *pos),
message: SYNTAX_ERROR.into(),
});
}
*pos += 1;
*col += 1;
Ok(())
}
fn apply_dbd_initials(record: &mut Box<dyn Record>) -> CaResult<()> {
let Some(fields) = crate::server::record::dbd_generated::record_fields(record.record_type())
else {
return Ok(()); };
let initials: Vec<(&'static str, &'static str)> = fields
.iter()
.filter(|f| record.implements_field(f.name))
.filter_map(|f| f.initial.map(|v| (f.name, v)))
.collect();
for (name, initial) in initials {
let spec = fields.iter().find(|f| f.name == name);
let dbf_type = match record
.get_field(name)
.map(|v| v.db_field_type())
.or_else(|| spec.map(|f| f.dbf_type))
{
Some(t) => t,
None => continue,
};
let parsed = if let Some(choices) = spec
.and_then(|f| f.menu)
.or_else(|| record.menu_field_choices(name))
.or_else(|| crate::server::record::shared_menu_choices(name))
{
crate::server::record::resolve_menu_field_string_db_load(
name, choices, dbf_type, initial,
)?
} else {
EpicsValue::parse_bytes(dbf_type, initial.as_bytes()).map_err(|e| {
CaError::InvalidValue(format!(
"{}.{name}: cannot parse .dbd initial(\"{initial}\") as {dbf_type:?}: {e}",
record.record_type()
))
})?
};
if record.get_field(name).as_ref() == Some(&parsed) {
continue;
}
record.put_field_internal(name, parsed)?;
}
Ok(())
}
pub fn create_record(record_type: &str) -> CaResult<Box<dyn Record>> {
let mut record = create_record_raw(record_type)?;
apply_dbd_initials(&mut record)?;
Ok(record)
}
fn create_record_raw(record_type: &str) -> CaResult<Box<dyn Record>> {
if let Ok(reg) = get_registry().lock() {
if let Some(factory) = reg.get(record_type) {
return Ok(factory());
}
}
use crate::server::records::*;
match record_type {
"ai" => Ok(Box::new(ai::AiRecord::default())),
"ao" => Ok(Box::new(ao::AoRecord::default())),
"bi" => Ok(Box::new(bi::BiRecord::default())),
"bo" => Ok(Box::new(bo::BoRecord::default())),
"stringin" => Ok(Box::new(stringin::StringinRecord::default())),
"stringout" => Ok(Box::new(stringout::StringoutRecord::default())),
"longin" => Ok(Box::new(longin::LonginRecord::default())),
"longout" => Ok(Box::new(longout::LongoutRecord::default())),
"int64in" => Ok(Box::new(int64in::Int64inRecord::default())),
"int64out" => Ok(Box::new(int64out::Int64outRecord::default())),
"lsi" => Ok(Box::new(lsi::LsiRecord::default())),
"lso" => Ok(Box::new(lso::LsoRecord::default())),
"mbbi" => Ok(Box::new(mbbi::MbbiRecord::default())),
"mbbo" => Ok(Box::new(mbbo::MbboRecord::default())),
"mbbiDirect" => Ok(Box::new(mbbi_direct::MbbiDirectRecord::default())),
"mbboDirect" => Ok(Box::new(mbbo_direct::MbboDirectRecord::default())),
"event" => Ok(Box::new(event::EventRecord::default())),
"printf" => Ok(Box::new(printf::PrintfRecord::default())),
"waveform" => Ok(Box::new(waveform::WaveformRecord::with_kind(
waveform::ArrayKind::Waveform,
))),
"aai" => Ok(Box::new(waveform::WaveformRecord::with_kind(
waveform::ArrayKind::Aai,
))),
"aao" => Ok(Box::new(waveform::WaveformRecord::with_kind(
waveform::ArrayKind::Aao,
))),
"subArray" => Ok(Box::new(waveform::WaveformRecord::with_kind(
waveform::ArrayKind::SubArray,
))),
"calc" => Ok(Box::new(calc::CalcRecord::default())),
"fanout" => Ok(Box::new(fanout::FanoutRecord::default())),
"seq" => Ok(Box::new(seq::SeqRecord::default())),
"calcout" => Ok(Box::new(calcout::CalcoutRecord::default())),
"dfanout" => Ok(Box::new(dfanout::DfanoutRecord::default())),
"compress" => Ok(Box::new(compress::CompressRecord::default())),
"histogram" => Ok(Box::new(histogram::HistogramRecord::default())),
"sel" => Ok(Box::new(sel::SelRecord::default())),
"sub" => Ok(Box::new(sub_record::SubRecord::default())),
"aSub" => Ok(Box::new(asub_record::ASubRecord::default())),
"permissive" => Ok(Box::new(permissive::PermissiveRecord::default())),
"state" => Ok(Box::new(state::StateRecord::default())),
_ => Err(unregistered_record_type(record_type)),
}
}
fn unregistered_record_type(record_type: &str) -> CaError {
let message = if crate::server::record::dbd_generated::record_fields(record_type).is_some() {
format!(
"record type '{record_type}' is not an EPICS Base record type \
(it is not in stdRecords.dbd) and is not registered; register it \
with register_record_type(\"{record_type}\", ...) before loading \
a .db that uses it"
)
} else {
format!("unknown record type: '{record_type}'")
};
CaError::DbParseError {
line: 0,
token: String::new(),
message,
}
}
pub fn create_record_with_factories(
record_type: &str,
extra_factories: &std::collections::HashMap<String, super::RecordFactory>,
) -> CaResult<Box<dyn Record>> {
if let Some(factory) = extra_factories.get(record_type) {
let mut record = factory();
apply_dbd_initials(&mut record)?;
return Ok(record);
}
create_record(record_type)
}
pub fn resolve_linr_breaktable_names(
record_type: &str,
fields: &mut [DbFieldDef],
registry: &crate::server::cvt_bpt::BreakTableRegistry,
) {
if registry.is_empty() || !matches!(record_type, "ai" | "ao") {
return;
}
for DbFieldDef {
name: fname,
value: fvalue,
..
} in fields.iter_mut()
{
if fname.eq_ignore_ascii_case("LINR") {
if let Some(idx) = registry.linr_index_of(&fvalue.as_str_lossy()) {
*fvalue = PvString::from(idx.to_string());
}
}
}
}
fn is_common_link_field(upper_name: &str) -> bool {
matches!(upper_name, "INP" | "OUT")
}
fn check_link_syntax(record_type: &str, fields: &[DbFieldDef]) -> CaResult<()> {
for DbFieldDef { name, value, .. } in fields {
check_link_text(record_type, &name.to_uppercase(), &value.as_str_lossy())?;
}
Ok(())
}
fn is_declared_no_access(
record_type: &str,
field: &str,
spec: Option<&'static crate::server::record::FieldDesc>,
) -> bool {
if spec.is_some_and(|f| f.no_access()) {
return true;
}
let named = |list: &[&str]| list.iter().any(|f| f.eq_ignore_ascii_case(field));
crate::server::record::dbd_generated::record_noaccess_fields(record_type).is_some_and(named)
|| named(crate::server::record::dbd_generated::DB_COMMON_NOACCESS)
}
fn lax_numeric(declared: DbfCode, field: &str, value: &str) -> Option<EpicsValue> {
db_load_numeric_value(declared, field, value).ok().flatten()
}
pub fn apply_fields(
record: &mut Box<dyn Record>,
fields: &[DbFieldDef],
common_fields: &mut Vec<(String, EpicsValue)>,
) -> CaResult<()> {
check_link_syntax(record.record_type(), fields)?;
for DbFieldDef { name, value, .. } in fields {
let upper_name = name.to_uppercase();
let value_str = &value.as_str_lossy();
let owned = record.implements_field(&upper_name);
let spec = crate::server::record::record_instance::field_desc_of(
record.as_ref(),
upper_name.as_str(),
);
if is_declared_no_access(record.record_type(), &upper_name, spec) {
return Err(CaError::InvalidValue(format!(
"field {upper_name}: can't set array field before iocInit() \
(a DBF_NOACCESS field is not settable from a .db file, as in C)"
)));
}
if owned {
let dbf_type = record
.get_field(&upper_name)
.map(|v| v.db_field_type())
.or_else(|| spec.map(|f| f.dbf_type))
.ok_or_else(|| {
CaError::InvalidValue(format!("field {upper_name}: no type to parse it as"))
})?;
let parsed = if let Some(choices) = spec
.and_then(|f| f.menu)
.or_else(|| record.menu_field_choices(&upper_name))
.or_else(|| crate::server::record::shared_menu_choices(&upper_name))
{
crate::server::record::resolve_menu_field_string_db_load(
&upper_name,
choices,
dbf_type,
value_str,
)?
} else if let Some(v) =
spec.and_then(|f| lax_numeric(f.declared_dbf, &upper_name, value_str))
{
v.convert_to(dbf_type)
} else {
EpicsValue::parse_bytes(dbf_type, value.as_bytes()).map_err(|e| {
CaError::InvalidValue(format!(
"field {upper_name} (type {dbf_type:?}): cannot parse '{value_str}': {e}"
))
})?
};
record.put_field(&upper_name, parsed)?;
if is_common_link_field(&upper_name) {
common_fields.push((upper_name.clone(), EpicsValue::String(value.clone())));
}
} else {
let converted = spec.and_then(|f| lax_numeric(f.declared_dbf, &upper_name, value_str));
common_fields.push((
upper_name.clone(),
converted.unwrap_or_else(|| EpicsValue::String(value.clone())),
));
}
if upper_name == "VAL" {
common_fields.push(("UDF".to_string(), EpicsValue::Char(0)));
}
}
Ok(())
}
#[cfg(test)]
pub(crate) fn macro_safe_path(path: &std::path::Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_simple_db() {
let input = r#"
record(ai, "TEMP") {
field(DESC, "Temperature")
field(SCAN, "1 second")
field(HOPR, "100")
field(LOPR, "0")
}
"#;
let records = parse_db(input, &HashMap::new()).unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0].record_type, "ai");
assert_eq!(records[0].name, "TEMP");
assert_eq!(records[0].fields.len(), 4);
assert_eq!(
records[0].fields[0],
DbFieldDef {
name: "DESC".into(),
value: "Temperature".into(),
line: 3
}
);
}
#[test]
fn test_macro_substitution() {
let input = r#"
record(ai, "$(P)TEMP") {
field(DESC, "$(D=Default Desc)")
}
"#;
let mut macros = HashMap::new();
macros.insert("P".to_string(), "IOC:".to_string());
let records = parse_db(input, ¯os).unwrap();
assert_eq!(records[0].name, "IOC:TEMP");
assert_eq!(records[0].fields[0].value, "Default Desc");
}
#[test]
fn test_multiple_records() {
let input = r#"
record(ai, "TEMP1") {
field(VAL, "25.0")
}
record(bo, "SWITCH") {
field(VAL, "1")
field(ZNAM, "Off")
field(ONAM, "On")
}
"#;
let records = parse_db(input, &HashMap::new()).unwrap();
assert_eq!(records.len(), 2);
assert_eq!(records[0].record_type, "ai");
assert_eq!(records[1].record_type, "bo");
}
#[test]
fn test_comments() {
let input = r#"
# This is a comment
record(ai, "TEMP") {
# Another comment
field(VAL, "25.0")
}
"#;
let records = parse_db(input, &HashMap::new()).unwrap();
assert_eq!(records.len(), 1);
}
#[test]
fn test_unknown_record_type() {
let result = create_record("nonexistent");
assert!(result.is_err());
}
#[test]
fn test_quoted_string_escape() {
let input = r#"
record(stringin, "TEST") {
field(VAL, "hello \"world\"")
}
"#;
let records = parse_db(input, &HashMap::new()).unwrap();
assert_eq!(records[0].fields[0].value, r#"hello "world""#);
}
#[test]
fn an_undeclared_field_is_dropped_and_the_rest_of_the_load_survives() {
let parsed = parse_db_with_breaktables(
r#"
record(calc, "C:ONE") { field(CALC, "1") }
record(calc, "C:TWO") {
field(OUT, "C:ONE.VAL PP")
field(CALC, "2")
}
record(calc, "C:THREE") { field(CALC, "3") }
"#,
&HashMap::new(),
)
.expect("an undeclared field must not abort the parse");
let names: Vec<&str> = parsed.records.iter().map(|r| r.name.as_str()).collect();
assert_eq!(
names,
["C:ONE", "C:TWO", "C:THREE"],
"every record survives"
);
let two = &parsed.records[1];
assert_eq!(
two.fields
.iter()
.map(|f| f.name.as_str())
.collect::<Vec<_>>(),
["CALC"],
"OUT is dropped, CALC is kept"
);
assert_eq!(
parsed.faults.first_diagnostic().unwrap(),
format!("{ERL_ERROR}: calc record 'C:TWO' doesn't have a field 'OUT'"),
"C's message, verbatim"
);
}
#[test]
fn the_name_field_is_refused_with_its_own_message() {
let parsed = parse_db_with_breaktables(
r#"record(calc, "C:N") { field(NAME, "C:OTHER") field(CALC, "5") }"#,
&HashMap::new(),
)
.expect("NAME must not abort the parse");
assert_eq!(parsed.records[0].name, "C:N");
assert_eq!(
parsed.records[0]
.fields
.iter()
.map(|f| f.name.as_str())
.collect::<Vec<_>>(),
["CALC"]
);
assert_eq!(
parsed.faults.first_diagnostic().unwrap(),
format!("{ERL_ERROR}: Can't set 'NAME' field of record 'C:N'")
);
}
#[test]
fn a_field_name_in_the_wrong_case_is_not_the_field() {
let parsed = parse_db_with_breaktables(
r#"record(calc, "C:CASE") { field(calc, "9") }"#,
&HashMap::new(),
)
.unwrap();
assert!(parsed.records[0].fields.is_empty());
assert_eq!(
parsed.faults.first_diagnostic().unwrap(),
format!("{ERL_ERROR}: calc record 'C:CASE' doesn't have a field 'calc'")
);
}
#[test]
fn the_rtyp_and_vers_record_attributes_load() {
let parsed = parse_db_with_breaktables(
r#"record(calc, "C:A") { field(RTYP, "zz") field(VERS, "9") field(CALC, "1") }"#,
&HashMap::new(),
)
.unwrap();
assert_eq!(parsed.records[0].fields.len(), 3);
assert!(parsed.faults.is_empty(), "C reports nothing here");
}
#[test]
fn a_noaccess_field_passes_the_name_gate() {
let parsed = parse_db_with_breaktables(
r#"record(waveform, "W:ONE") { field(VAL, "1") }"#,
&HashMap::new(),
)
.unwrap();
assert_eq!(parsed.records[0].fields.len(), 1);
assert!(parsed.faults.is_empty());
assert_eq!(
record_type_field_exists("waveform", "VAL"),
Some(true),
"waveform.VAL is declared DBF_NOACCESS, not absent"
);
}
#[test]
fn a_no_access_field_is_refused_from_a_db_file_by_either_source() {
use crate::server::record::Record;
let load = |rt: &str, field: &str| -> CaResult<Box<dyn Record>> {
let mut rec = create_record(rt).unwrap();
let mut common = Vec::new();
apply_fields(&mut rec, &[DbFieldDef::new(field, "1")], &mut common)?;
Ok(rec)
};
for field in ["BKPT", "TIME"] {
assert!(
!crate::server::record::dbd_generated::DB_COMMON_NOACCESS.contains(&field),
"{field} is carried as a descriptor, so the name list must not \
be what refuses it"
);
let Err(err) = load("ai", field) else {
panic!("ai.{field} is DBF_NOACCESS and must not be settable from a .db");
};
assert!(err.to_string().contains("DBF_NOACCESS"), "{field}: {err}");
}
let Err(err) = load("ai", "MLOK") else {
panic!("ai.MLOK is DBF_NOACCESS and must not be settable from a .db");
};
assert!(err.to_string().contains("DBF_NOACCESS"), "MLOK: {err}");
assert!(load("ai", "PREC").is_ok());
}
#[test]
fn a_record_type_with_no_visible_declarations_is_not_judged() {
assert_eq!(record_type_field_exists("noSuchRecordType", "ANY"), None);
let parsed = parse_db_with_breaktables(
r#"record(noSuchRecordType, "X:ONE") { field(WHATEVER, "1") }"#,
&HashMap::new(),
)
.unwrap();
assert_eq!(parsed.records[0].fields.len(), 1);
assert!(parsed.faults.is_empty());
}
#[test]
fn test_quoted_value_translates_escapes() {
let input = r#"
record(stringin, "TEST") {
field(DESC, "line1\nline2")
field(VAL, "a\\b\tc")
field(SIOL, "hex\x41end")
}
"#;
let records = parse_db(input, &HashMap::new()).unwrap();
assert_eq!(records[0].fields[0].value, "line1\nline2");
assert_eq!(records[0].fields[1].value, "a\\b\tc");
assert_eq!(records[0].fields[2].value, "hexAend");
}
#[test]
fn test_record_name_keeps_escapes_raw() {
let input = r#"
record(stringin, "T\tEST") {
field(VAL, "x")
}
"#;
let records = parse_db(input, &HashMap::new()).unwrap();
assert_eq!(records[0].name, r"T\tEST");
}
#[test]
fn test_info_value_translates_escapes() {
let input = r#"
record(stringin, "TEST") {
info("Q:group", "tab\there")
}
"#;
let records = parse_db(input, &HashMap::new()).unwrap();
assert_eq!(records[0].info_tags[0].0, "Q:group");
assert_eq!(records[0].info_tags[0].1, "tab\there");
}
#[test]
fn test_single_quoted_value_translates_escapes() {
let input = "record(stringin, \"TEST\") {\n field(VAL, 'sq:\\tx')\n}\n";
let records = parse_db(input, &HashMap::new()).unwrap();
assert_eq!(records[0].fields[0].value, "sq:\tx");
}
#[test]
fn test_octal_escape_in_value_is_rejected() {
let input = r#"
record(stringin, "TEST") {
field(VAL, "octal\101x")
}
"#;
assert!(matches!(
parse_db(input, &HashMap::new()),
Err(CaError::DbParseError { .. })
));
}
#[test]
fn test_link_field_value_is_translated() {
let input = r#"
record(stringin, "TEST") {
field(INP, "@drvUser(\"chan1\")")
}
"#;
let records = parse_db(input, &HashMap::new()).unwrap();
assert_eq!(records[0].fields[0].value, r#"@drvUser("chan1")"#);
}
#[test]
fn test_quoted_string_newline_aborts() {
let input = "record(stringin, \"TEST\") {\n field(DESC, \"line1\nline2\")\n}\n";
let res = parse_db(input, &HashMap::new());
assert!(
matches!(res, Err(CaError::DbParseError { ref message, .. })
if message.contains("Newline in string")),
"expected newline-in-string abort, got {res:?}"
);
}
#[test]
fn test_quoted_string_backslash_before_newline_aborts() {
let input = "record(stringin, \"TE\\\nST\") {\n field(DESC, \"x\")\n}\n";
let res = parse_db(input, &HashMap::new());
assert!(
matches!(res, Err(CaError::DbParseError { ref message, .. })
if message.contains("Newline in string")),
"expected newline-in-string abort, got {res:?}"
);
}
#[test]
fn test_value_backslash_before_newline_is_a_newline() {
let input = "record(stringin, \"TEST\") {\n field(DESC, \"line1\\\nline2\")\n}\n";
let records = parse_db(input, &HashMap::new()).unwrap();
assert_eq!(records[0].fields[0].value, "line1\nline2");
}
#[test]
fn test_macro_with_quoted_default_in_string() {
let input = r#"
record(longout, "$(P)$(R)PositionXLink") {
field(DOL, "$(XPOS="") CP MS")
}
"#;
let mut macros = HashMap::new();
macros.insert("P".to_string(), "SIM1:".to_string());
macros.insert("R".to_string(), "Over1:1:".to_string());
macros.insert("XPOS".to_string(), "SIM1:ROI1:MinX_RBV".to_string());
let records = parse_db(input, ¯os).unwrap();
assert_eq!(records[0].fields[0].value, "SIM1:ROI1:MinX_RBV CP MS");
}
#[test]
fn test_macro_with_quoted_default_unset() {
let input = r#"
record(longout, "TEST:Link") {
field(DOL, "$(XPOS="") CP MS")
}
"#;
let macros = HashMap::new();
let records = parse_db(input, ¯os).unwrap();
assert!(records[0].fields[0].value.as_str_lossy().contains("CP MS"));
}
#[test]
fn test_recursive_macro_default() {
let input = r#"
record(stringin, "TEST") {
field(VAL, "$(TS_PORT=$(PORT)_TS)")
}
"#;
let mut macros = HashMap::new();
macros.insert("PORT".to_string(), "ATTR1".to_string());
let records = parse_db(input, ¯os).unwrap();
assert_eq!(records[0].fields[0].value, "ATTR1_TS");
}
#[test]
fn test_substitute_directive_in_expand() {
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let child = dir.path().join("child.db");
let mut f = std::fs::File::create(&child).unwrap();
writeln!(f, r#"record(ai, "$(P)$(R)Val") {{"#).unwrap();
writeln!(f, r#" field(VAL, "$(ADDR)")"#).unwrap();
writeln!(f, r#"}}"#).unwrap();
let parent = dir.path().join("parent.db");
let mut f = std::fs::File::create(&parent).unwrap();
writeln!(f, r#"substitute "R=A:,ADDR=0""#).unwrap();
writeln!(f, r#"include "child.db""#).unwrap();
writeln!(f, r#"substitute "R=B:,ADDR=1""#).unwrap();
writeln!(f, r#"include "child.db""#).unwrap();
let mut macros = HashMap::new();
macros.insert("P".to_string(), "IOC:".to_string());
let config = DbLoadConfig {
include_paths: vec![dir.path().to_path_buf()],
max_include_depth: 10,
};
let records = parse_db_file(&parent, ¯os, &config).unwrap();
assert_eq!(records.len(), 2);
assert_eq!(records[0].name, "IOC:A:Val");
assert_eq!(records[0].fields[0].value, "0");
assert_eq!(records[1].name, "IOC:B:Val");
assert_eq!(records[1].fields[0].value, "1");
}
#[test]
fn test_empty_string_numeric_parse() {
let input = r#"
record(longin, "TEST:Int") {
field(VAL, "")
}
"#;
let records = parse_db(input, &HashMap::new()).unwrap();
assert_eq!(records.len(), 1);
}
#[test]
fn test_calcout_process() {
use crate::server::record::Record;
use crate::server::records::calcout::CalcoutRecord;
let mut rec = CalcoutRecord::default();
rec.put_field("CALC", EpicsValue::String("A+B".into()))
.unwrap();
rec.special("CALC", true).unwrap();
rec.put_field("A", EpicsValue::Double(3.0)).unwrap();
rec.put_field("B", EpicsValue::Double(4.0)).unwrap();
rec.process().unwrap();
match rec.get_field("VAL") {
Some(EpicsValue::Double(v)) => assert!((v - 7.0).abs() < 1e-10),
other => panic!("expected Double(7.0), got {:?}", other),
}
}
#[test]
fn test_calcout_oopt() {
use crate::server::record::Record;
use crate::server::records::calcout::CalcoutRecord;
let mut rec = CalcoutRecord::default();
rec.put_field("CALC", EpicsValue::String("A".into()))
.unwrap();
rec.special("CALC", true).unwrap();
rec.put_field("OOPT", EpicsValue::Short(1)).unwrap(); rec.put_field("A", EpicsValue::Double(5.0)).unwrap();
rec.process().unwrap();
assert!((rec.oval - 5.0).abs() < 1e-10);
rec.process().unwrap();
}
#[test]
fn test_calcout_dopt() {
use crate::server::record::Record;
use crate::server::records::calcout::CalcoutRecord;
let mut rec = CalcoutRecord::default();
rec.put_field("CALC", EpicsValue::String("A+B".into()))
.unwrap();
rec.special("CALC", true).unwrap();
rec.put_field("OCAL", EpicsValue::String("A*B".into()))
.unwrap();
rec.special("OCAL", true).unwrap();
rec.put_field("DOPT", EpicsValue::Short(1)).unwrap(); rec.put_field("A", EpicsValue::Double(3.0)).unwrap();
rec.put_field("B", EpicsValue::Double(4.0)).unwrap();
rec.process().unwrap();
match rec.get_field("VAL") {
Some(EpicsValue::Double(v)) => assert!((v - 7.0).abs() < 1e-10),
other => panic!("expected Double(7.0), got {:?}", other),
}
match rec.get_field("OVAL") {
Some(EpicsValue::Double(v)) => assert!((v - 12.0).abs() < 1e-10),
other => panic!("expected Double(12.0), got {:?}", other),
}
}
#[test]
fn test_dfanout_basic() {
use crate::server::record::Record;
use crate::server::records::dfanout::DfanoutRecord;
let mut rec = DfanoutRecord::default();
rec.put_field("VAL", EpicsValue::Double(42.0)).unwrap();
assert_eq!(rec.record_type(), "dfanout");
match rec.get_field("VAL") {
Some(EpicsValue::Double(v)) => assert!((v - 42.0).abs() < 1e-10),
other => panic!("expected Double(42.0), got {:?}", other),
}
}
#[test]
fn test_dfanout_output_links() {
use crate::server::record::Record;
use crate::server::records::dfanout::DfanoutRecord;
let mut rec = DfanoutRecord::default();
rec.put_field("OUTA", EpicsValue::String("REC_A".into()))
.unwrap();
rec.put_field("OUTB", EpicsValue::String("REC_B".into()))
.unwrap();
let links = rec.output_links();
assert_eq!(links.len(), 2);
}
#[test]
fn test_compress_circular_buffer() {
use crate::server::record::Record;
use crate::server::records::compress::CompressRecord;
let mut rec = CompressRecord::new(5, 4); for i in 0..7 {
rec.push_value(i as f64);
}
match rec.get_field("VAL") {
Some(EpicsValue::DoubleArray(arr)) => {
assert_eq!(arr, vec![2.0, 3.0, 4.0, 5.0, 6.0]);
}
other => panic!("expected DoubleArray, got {:?}", other),
}
}
#[test]
fn test_compress_n_to_1_mean() {
use crate::server::record::Record;
use crate::server::records::compress::CompressRecord;
let mut rec = CompressRecord::new(10, 2); rec.put_field("N", EpicsValue::Long(3)).unwrap();
rec.push_value(3.0);
rec.push_value(6.0);
rec.push_value(9.0); match rec.get_field("VAL") {
Some(EpicsValue::DoubleArray(arr)) => {
assert!((arr[0] - 6.0).abs() < 1e-10);
}
other => panic!("expected DoubleArray, got {:?}", other),
}
}
#[test]
fn test_histogram_bucket_count() {
use crate::server::records::histogram::HistogramRecord;
let mut rec = HistogramRecord::new(10, 0.0, 10.0);
rec.add_sample(2.5); rec.add_sample(2.7); rec.add_sample(7.0); assert_eq!(rec.val[2], 2);
assert_eq!(rec.val[6], 1);
}
#[test]
fn test_histogram_out_of_range() {
use crate::server::records::histogram::HistogramRecord;
let mut rec = HistogramRecord::new(10, 0.0, 10.0);
rec.add_sample(-1.0); rec.add_sample(10.0); rec.add_sample(15.0); let total: u32 = rec.val.iter().sum();
assert_eq!(total, 0);
}
#[test]
fn test_sel_specified() {
use crate::server::record::Record;
use crate::server::records::sel::SelRecord;
let mut rec = SelRecord::default();
rec.put_field("SELM", EpicsValue::Short(0)).unwrap(); rec.put_field("SELN", EpicsValue::Short(2)).unwrap(); rec.put_field("C", EpicsValue::Double(99.0)).unwrap();
rec.process().unwrap();
match rec.get_field("VAL") {
Some(EpicsValue::Double(v)) => assert!((v - 99.0).abs() < 1e-10),
other => panic!("expected Double(99.0), got {:?}", other),
}
}
#[test]
fn test_sel_high_low_median() {
use crate::server::record::Record;
use crate::server::records::sel::SelRecord;
let mut rec = SelRecord::default();
rec.put_field("A", EpicsValue::Double(10.0)).unwrap();
rec.put_field("B", EpicsValue::Double(30.0)).unwrap();
rec.put_field("C", EpicsValue::Double(20.0)).unwrap();
rec.put_field("SELM", EpicsValue::Short(1)).unwrap();
rec.process().unwrap();
match rec.get_field("VAL") {
Some(EpicsValue::Double(v)) => assert!((v - 30.0).abs() < 1e-10),
other => panic!("expected Double(30.0), got {:?}", other),
}
rec.put_field("SELM", EpicsValue::Short(2)).unwrap();
rec.process().unwrap();
match rec.get_field("VAL") {
Some(EpicsValue::Double(v)) => assert!((v - 10.0).abs() < 1e-10), other => panic!("expected near 0.0, got {:?}", other),
}
}
#[test]
fn db_load_menu_labels_resolve_against_field_menu() {
use crate::server::record::Record;
let apply = |rt: &str, field: &str, value: &str| -> CaResult<Box<dyn Record>> {
let mut rec = create_record(rt).unwrap();
let mut common = Vec::new();
apply_fields(&mut rec, &[DbFieldDef::new(field, value)], &mut common)?;
Ok(rec)
};
let rec = apply("sel", "SELM", "Specified").unwrap();
assert_eq!(rec.get_field("SELM"), Some(EpicsValue::Enum(0)));
let rec = apply("sel", "SELM", "High Signal").unwrap();
assert_eq!(rec.get_field("SELM"), Some(EpicsValue::Enum(1)));
let rec = apply("sel", "SELM", "2").unwrap();
assert_eq!(rec.get_field("SELM"), Some(EpicsValue::Enum(2)));
let rec = apply("ai", "LINR", "LINEAR").unwrap();
assert_eq!(rec.get_field("LINR"), Some(EpicsValue::Short(2)));
assert!(apply("sel", "SELM", "Bogus").is_err());
}
#[test]
fn test_parse_breaktable_basic() {
let input = r#"
breaktable(typeJdegC) {
0 0
365 67.0
1000 178.0
}
record(ai, "T") { field(LINR, "typeJdegC") }
"#;
let parsed = parse_db_with_breaktables(input, &HashMap::new()).unwrap();
let breaktables = parsed.breaktables;
assert_eq!(parsed.records.len(), 1);
assert_eq!(breaktables.len(), 1);
let t = &breaktables[0];
assert_eq!(t.name, "typeJdegC");
assert_eq!(t.points.len(), 3);
assert_eq!(t.points[0].raw, 0.0);
assert_eq!(t.points[0].eng, 0.0);
assert!((t.points[0].slope - (67.0 / 365.0)).abs() < 1e-12);
}
#[test]
fn test_parse_breaktable_quoted_name_and_commas() {
let input = r#"breaktable("tbl") { 0,0, 10,100 }"#;
let breaktables = parse_db_with_breaktables(input, &HashMap::new())
.unwrap()
.breaktables;
assert_eq!(breaktables.len(), 1);
assert_eq!(breaktables[0].name, "tbl");
assert_eq!(breaktables[0].points.len(), 2);
assert!((breaktables[0].points[0].slope - 10.0).abs() < 1e-12);
}
#[test]
fn test_parse_breaktable_odd_count_errors() {
let input = r#"breaktable(bad) { 0 0 10 }"#;
let Err(err) = parse_db_with_breaktables(input, &HashMap::new()) else {
panic!("odd point count must be a parse error");
};
match err {
CaError::DbParseError { message, .. } => {
assert!(message.contains("Raw value missing"), "{message}")
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn test_resolve_linr_breaktable_names_rewrites_to_index() {
use crate::server::cvt_bpt::{BreakTableRegistry, BrkTable};
let mut reg = BreakTableRegistry::new();
reg.insert(BrkTable::build("alpha", &[(0.0, 0.0), (1.0, 1.0)]).unwrap());
let mut fields = vec![DbFieldDef::new("LINR", PvString::from("alpha"))];
resolve_linr_breaktable_names("ai", &mut fields, ®);
assert_eq!(fields[0].value, "15");
let mut fixed = vec![DbFieldDef::new("LINR", PvString::from("LINEAR"))];
resolve_linr_breaktable_names("ai", &mut fixed, ®);
assert_eq!(fixed[0].value, "LINEAR");
let mut other = vec![DbFieldDef::new("LINR", PvString::from("alpha"))];
resolve_linr_breaktable_names("bo", &mut other, ®);
assert_eq!(other[0].value, "alpha");
}
#[test]
fn test_sub_record_register_and_call() {
use crate::server::record::{Record, RecordInstance, SubroutineFn};
use crate::server::records::sub_record::SubRecord;
use std::sync::Arc;
let mut rec = SubRecord::default();
rec.put_field("SNAM", EpicsValue::String("double_val".into()))
.unwrap();
rec.put_field("VAL", EpicsValue::Double(5.0)).unwrap();
let mut instance = RecordInstance::new("TEST_SUB".into(), rec);
let sub_fn: SubroutineFn = Box::new(|record: &mut dyn Record| {
if let Some(EpicsValue::Double(v)) = record.get_field("VAL") {
record.put_field("VAL", EpicsValue::Double(v * 2.0))?;
}
Ok(0)
});
instance.subroutine = Some(Arc::new(sub_fn));
instance.process_local().unwrap();
match instance.record.get_field("VAL") {
Some(EpicsValue::Double(v)) => assert!((v - 10.0).abs() < 1e-10),
other => panic!("expected Double(10.0), got {:?}", other),
}
}
#[test]
fn test_new_record_types_in_db() {
let input = r#"
record(calcout, "TEST_CO") {
field(CALC, "A+1")
}
record(dfanout, "TEST_DF") {
field(VAL, "5.0")
}
record(compress, "TEST_CMP") {
field(DESC, "test compress")
}
record(histogram, "TEST_HIST") {
field(DESC, "test hist")
}
record(sel, "TEST_SEL") {
field(SELM, "0")
}
record(sub, "TEST_SUB") {
field(SNAM, "my_sub")
}
"#;
let records = parse_db(input, &HashMap::new()).unwrap();
assert_eq!(records.len(), 6);
for def in &records {
create_record(&def.record_type).unwrap();
}
}
#[test]
fn test_parse_include_directive() {
assert_eq!(
parse_include_directive(r#"include "foo.template""#),
Some("foo.template".to_string())
);
assert_eq!(
parse_include_directive(r#" include "bar.db""#),
Some("bar.db".to_string())
);
assert_eq!(
parse_include_directive(r#"include "baz.template" # a comment"#),
Some("baz.template".to_string())
);
assert_eq!(parse_include_directive("include something"), None);
assert_eq!(parse_include_directive(r#"# include "ignored.db""#), None);
assert_eq!(parse_include_directive("record(ai, \"X\") {"), None);
assert_eq!(parse_include_directive(r#"includes "nope.db""#), None);
}
#[test]
fn test_commented_include_ignored() {
assert_eq!(parse_include_directive(r#"# include "file.db""#), None);
assert_eq!(parse_include_directive(r#" # include "file.db""#), None);
}
#[test]
fn test_expand_includes() {
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let child_path = dir.path().join("child.db");
let mut f = std::fs::File::create(&child_path).unwrap();
writeln!(f, r#"record(ai, "CHILD") {{"#).unwrap();
writeln!(f, r#" field(VAL, "1.0")"#).unwrap();
writeln!(f, r#"}}"#).unwrap();
let parent_path = dir.path().join("parent.db");
let mut f = std::fs::File::create(&parent_path).unwrap();
writeln!(f, r#"record(ao, "PARENT") {{"#).unwrap();
writeln!(f, r#" field(VAL, "2.0")"#).unwrap();
writeln!(f, r#"}}"#).unwrap();
writeln!(f, r#"include "child.db""#).unwrap();
let config = DbLoadConfig {
include_paths: vec![dir.path().to_path_buf()],
max_include_depth: 32,
};
let result = expand_includes(
&parent_path,
&HashMap::new(),
&config,
&mut DbFaults::default(),
)
.unwrap();
assert!(result.contains(r#"record(ao, "PARENT")"#));
assert!(result.contains(r#"record(ai, "CHILD")"#));
let records = parse_db(&result, &HashMap::new()).unwrap();
assert_eq!(records.len(), 2);
}
#[test]
fn test_circular_include_error() {
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let a_path = dir.path().join("a.template");
let b_path = dir.path().join("b.template");
let mut fa = std::fs::File::create(&a_path).unwrap();
writeln!(fa, r#"include "b.template""#).unwrap();
let mut fb = std::fs::File::create(&b_path).unwrap();
writeln!(fb, r#"include "a.template""#).unwrap();
let config = DbLoadConfig {
include_paths: vec![dir.path().to_path_buf()],
max_include_depth: 32,
};
let result = expand_includes(&a_path, &HashMap::new(), &config, &mut DbFaults::default());
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("circular include"), "error was: {err}");
}
#[test]
fn test_duplicate_include_allowed() {
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let shared_path = dir.path().join("shared.db");
let mut f = std::fs::File::create(&shared_path).unwrap();
writeln!(f, r#"record(ai, "SHARED") {{"#).unwrap();
writeln!(f, r#" field(VAL, "0")"#).unwrap();
writeln!(f, r#"}}"#).unwrap();
let main_path = dir.path().join("main.db");
let mut f = std::fs::File::create(&main_path).unwrap();
writeln!(f, r#"include "shared.db""#).unwrap();
writeln!(f, r#"include "shared.db""#).unwrap();
let config = DbLoadConfig {
include_paths: vec![dir.path().to_path_buf()],
max_include_depth: 32,
};
let result = expand_includes(
&main_path,
&HashMap::new(),
&config,
&mut DbFaults::default(),
)
.unwrap();
assert_eq!(result.matches(r#"record(ai, "SHARED")"#).count(), 2);
}
#[test]
fn test_include_depth_limit() {
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
for i in 0..34 {
let path = dir.path().join(format!("file{i}.db"));
let mut f = std::fs::File::create(&path).unwrap();
if i < 33 {
writeln!(f, r#"include "file{}.db""#, i + 1).unwrap();
} else {
writeln!(f, r#"record(ai, "DEEP") {{"#).unwrap();
writeln!(f, r#" field(VAL, "0")"#).unwrap();
writeln!(f, r#"}}"#).unwrap();
}
}
let config = DbLoadConfig {
include_paths: vec![dir.path().to_path_buf()],
max_include_depth: 32,
};
let result = expand_includes(
&dir.path().join("file0.db"),
&HashMap::new(),
&config,
&mut DbFaults::default(),
);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("depth limit"), "error was: {err}");
}
#[test]
fn test_include_not_found_is_recoverable() {
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("main.db");
let mut f = std::fs::File::create(&path).unwrap();
writeln!(f, r#"record(ai, "SIM:A") {{ field(VAL, "1") }}"#).unwrap();
writeln!(f, r#"include "nonexistent.db""#).unwrap();
writeln!(f, r#"record(ai, "SIM:B") {{ field(VAL, "2") }}"#).unwrap();
let config = DbLoadConfig::default();
let mut faults = DbFaults::default();
let text = expand_includes(&path, &HashMap::new(), &config, &mut faults)
.expect("a missing include must not fail the expansion");
assert!(text.contains("SIM:A") && text.contains("SIM:B"));
let err = faults
.first_diagnostic()
.expect("the load's status must go non-zero");
assert_eq!(
err,
format!("{ERL_ERROR}: Can't open include file 'nonexistent.db'")
);
}
#[test]
fn test_include_with_macro_filename() {
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let subdir = dir.path().join("sub");
std::fs::create_dir(&subdir).unwrap();
let child_path = subdir.join("child.db");
let mut f = std::fs::File::create(&child_path).unwrap();
writeln!(f, r#"record(ai, "CHILD") {{"#).unwrap();
writeln!(f, r#" field(VAL, "0")"#).unwrap();
writeln!(f, r#"}}"#).unwrap();
let main_path = dir.path().join("main.db");
let mut f = std::fs::File::create(&main_path).unwrap();
writeln!(f, r#"include "$(DIR)/child.db""#).unwrap();
let mut macros = HashMap::new();
macros.insert("DIR".to_string(), macro_safe_path(&subdir));
let config = DbLoadConfig::default();
let result =
expand_includes(&main_path, ¯os, &config, &mut DbFaults::default()).unwrap();
assert!(result.contains(r#"record(ai, "CHILD")"#));
}
#[test]
fn test_include_search_order() {
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let inc_dir = dir.path().join("inc");
std::fs::create_dir(&inc_dir).unwrap();
let child_path = inc_dir.join("child.db");
let mut f = std::fs::File::create(&child_path).unwrap();
writeln!(f, r#"record(ai, "FROM_INC") {{"#).unwrap();
writeln!(f, r#" field(VAL, "0")"#).unwrap();
writeln!(f, r#"}}"#).unwrap();
let main_path = dir.path().join("main.db");
let mut f = std::fs::File::create(&main_path).unwrap();
writeln!(f, r#"include "child.db""#).unwrap();
let config = DbLoadConfig {
include_paths: vec![inc_dir.clone()],
max_include_depth: 32,
};
let result = expand_includes(
&main_path,
&HashMap::new(),
&config,
&mut DbFaults::default(),
)
.unwrap();
assert!(result.contains(r#"record(ai, "FROM_INC")"#));
let local_child = dir.path().join("child.db");
let mut f = std::fs::File::create(&local_child).unwrap();
writeln!(f, r#"record(ai, "FROM_LOCAL") {{"#).unwrap();
writeln!(f, r#" field(VAL, "0")"#).unwrap();
writeln!(f, r#"}}"#).unwrap();
let result = expand_includes(
&main_path,
&HashMap::new(),
&config,
&mut DbFaults::default(),
)
.unwrap();
assert!(result.contains(r#"record(ai, "FROM_INC")"#));
assert!(!result.contains(r#"record(ai, "FROM_LOCAL")"#));
let sep_main = dir.path().join("sep_main.db");
let mut f = std::fs::File::create(&sep_main).unwrap();
writeln!(f, r#"include "{}""#, local_child.display()).unwrap();
let result = expand_includes(
&sep_main,
&HashMap::new(),
&config,
&mut DbFaults::default(),
)
.unwrap();
assert!(result.contains(r#"record(ai, "FROM_LOCAL")"#));
}
#[test]
fn test_addpath_directive_resolves_include() {
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let inc_dir = dir.path().join("extra");
std::fs::create_dir(&inc_dir).unwrap();
let child = inc_dir.join("child.db");
let mut f = std::fs::File::create(&child).unwrap();
writeln!(f, r#"record(ai, "FROM_ADDPATH") {{ field(VAL, "0") }}"#).unwrap();
let main = dir.path().join("main.db");
let mut f = std::fs::File::create(&main).unwrap();
writeln!(f, r#"addpath "{}""#, inc_dir.display()).unwrap();
writeln!(f, r#"include "child.db""#).unwrap();
let config = DbLoadConfig::default();
let result =
expand_includes(&main, &HashMap::new(), &config, &mut DbFaults::default()).unwrap();
assert!(result.contains(r#"record(ai, "FROM_ADDPATH")"#));
}
#[test]
fn test_path_directive_replaces_search_path() {
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let inc_dir = dir.path().join("p");
std::fs::create_dir(&inc_dir).unwrap();
let child = inc_dir.join("c.db");
let mut f = std::fs::File::create(&child).unwrap();
writeln!(f, r#"record(ai, "VIA_PATH") {{ field(VAL, "0") }}"#).unwrap();
let main = dir.path().join("main.db");
let mut f = std::fs::File::create(&main).unwrap();
writeln!(f, r#"path "{}""#, inc_dir.display()).unwrap();
writeln!(f, r#"include "c.db""#).unwrap();
let config = DbLoadConfig::default();
let result =
expand_includes(&main, &HashMap::new(), &config, &mut DbFaults::default()).unwrap();
assert!(result.contains(r#"record(ai, "VIA_PATH")"#));
}
#[test]
fn dtyp_macro_substitutes_references_and_leaves_literals_alone() {
let input = r#"
record(bo, "$(P)_calcEnable") {
field(DTYP, "Soft Channel")
field(ZNAM, "ENABLE")
}
record(scaler, "$(P)") {
field(DTYP, "$(DTYP)")
field(FREQ, "10000000")
}
record(ao, "$(P)_noDtyp") {
field(VAL, "1")
}
"#;
let mut macros = HashMap::new();
macros.insert("P".to_string(), "SCALER1".to_string());
macros.insert("DTYP".to_string(), "Scaler-rs".to_string());
let records = parse_db(input, ¯os).unwrap();
assert_eq!(records.len(), 3);
let dtyp_of = |rec: &DbRecordDef| -> Option<String> {
rec.fields
.iter()
.find(|f| f.name == "DTYP")
.map(|f| f.value.as_str_lossy().into_owned())
};
assert_eq!(dtyp_of(&records[0]).as_deref(), Some("Soft Channel"));
assert_eq!(dtyp_of(&records[1]).as_deref(), Some("Scaler-rs"));
assert_eq!(dtyp_of(&records[2]), None);
}
#[test]
fn test_parse_db_file_no_includes() {
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("simple.db");
let mut f = std::fs::File::create(&path).unwrap();
writeln!(f, r#"record(ai, "$(P)TEMP") {{"#).unwrap();
writeln!(f, r#" field(VAL, "25.0")"#).unwrap();
writeln!(f, r#"}}"#).unwrap();
let mut macros = HashMap::new();
macros.insert("P".to_string(), "IOC:".to_string());
let config = DbLoadConfig::default();
let records = parse_db_file(&path, ¯os, &config).unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0].name, "IOC:TEMP");
}
#[test]
fn name_validation_accepts_typical_names() {
for n in [
"IOC:TEMP",
"MOTOR-1",
"X[1]",
"scan_5",
"BL3:STD:01",
"abc.xyz_record",
]
.iter()
.copied()
{
if n.contains('.') {
assert!(validate_record_name(n, 1, 1).is_err());
continue;
}
validate_record_name(n, 1, 1).unwrap_or_else(|e| panic!("'{n}' should pass: {e:?}"));
}
}
#[test]
fn name_validation_rejects_empty() {
assert!(validate_record_name("", 1, 1).is_err());
}
#[test]
fn name_validation_rejects_bad_chars() {
for bad in ["spa ce", "do.t", "qu\"ot", "ap'os", "do$llar"] {
assert!(
validate_record_name(bad, 1, 1).is_err(),
"'{bad}' must be rejected"
);
}
}
#[test]
fn name_validation_warns_but_passes_on_nonprintable() {
validate_record_name("ta\tb", 1, 1).expect("TAB is warn-only per base spec");
validate_record_name("hello\x01world", 1, 1).expect("0x01 is warn-only");
}
#[test]
fn name_validation_warns_on_leading_special_but_passes() {
for warn in ["-x", "+y", "[arr", "{obj"] {
validate_record_name(warn, 1, 1).expect("leading special is warn-only");
}
}
#[test]
fn parse_db_accepts_a_bare_array_constant() {
let src = r#"
record(waveform, "wfrec") {
field(NELM, "10")
field(FTVL, "LONG")
field(INP, [1, 2, 3])
}
record(lsi, "longstr4") {
field(SIZV, "100")
field(INP, ["One","Two","Three","Four"])
}
"#;
let recs = parse_db(src, &HashMap::new()).expect("base's own test database must load");
assert_eq!(recs.len(), 2);
let inp = |r: &DbRecordDef| {
r.fields
.iter()
.find(|f| f.name == "INP")
.map(|f| f.value.as_str_lossy().into_owned())
.unwrap()
};
assert_eq!(inp(&recs[0]), "[1, 2, 3]");
assert_eq!(inp(&recs[1]), r#"["One","Two","Three","Four"]"#);
}
#[test]
fn parse_db_propagates_name_validation_error() {
let bad = r#"record(ai, "BAD NAME") { }"#;
let res = parse_db(bad, &HashMap::new());
assert!(matches!(res, Err(CaError::DbParseError { .. })));
}
#[test]
fn parse_db_captures_aliases() {
let src = r#"record(ai, "TARGET") {
alias("ALIAS1")
alias("ALIAS2")
field(VAL, 42)
}"#;
let recs = parse_db(src, &HashMap::new()).unwrap();
assert_eq!(recs.len(), 1);
assert_eq!(recs[0].name, "TARGET");
assert_eq!(recs[0].aliases, vec!["ALIAS1", "ALIAS2"]);
assert_eq!(recs[0].fields.len(), 1);
}
#[test]
fn parse_db_rejects_alias_with_bad_name() {
let src = r#"record(ai, "TARGET") {
alias("BAD ALIAS")
}"#;
let res = parse_db(src, &HashMap::new());
assert!(matches!(res, Err(CaError::DbParseError { .. })));
}
#[test]
fn parse_db_accepts_path_and_addpath() {
let src = r#"
path "/opt/epics/db"
addpath "/extra/db"
record(ai, "REC") { field(VAL, "1") }
"#;
let recs = parse_db(src, &HashMap::new()).unwrap();
assert_eq!(recs.len(), 1);
assert_eq!(recs[0].name, "REC");
}
#[test]
fn parse_db_accepts_top_level_include() {
let src = r#"
include "common.db"
record(ai, "REC") { field(VAL, "1") }
"#;
let recs = parse_db(src, &HashMap::new()).unwrap();
assert_eq!(recs.len(), 1);
}
#[test]
fn parse_db_global_alias_two_arg() {
let src = r#"
record(ai, "TARGET") { field(VAL, "1") }
alias("TARGET", "TARGET_ALIAS")
"#;
let recs = parse_db(src, &HashMap::new()).unwrap();
assert_eq!(recs.len(), 1);
assert_eq!(recs[0].aliases, vec!["TARGET_ALIAS"]);
}
#[test]
fn parse_db_global_alias_forward_reference() {
let src = r#"
alias("TARGET", "EARLY_ALIAS")
record(ai, "TARGET") { field(VAL, "1") }
"#;
let recs = parse_db(src, &HashMap::new()).unwrap();
assert_eq!(recs[0].aliases, vec!["EARLY_ALIAS"]);
}
#[test]
fn parse_db_record_without_a_body_is_valid() {
let src = r#"
record(ai, "TEMP")
record(ai, "PRESS") { field(SCAN, "1 second") }
grecord(ai, "GTEMP")
record(ai, "EMPTY") { }
"#;
let recs = parse_db(src, &HashMap::new()).unwrap();
let names: Vec<&str> = recs.iter().map(|r| r.name.as_str()).collect();
assert_eq!(names, vec!["TEMP", "PRESS", "GTEMP", "EMPTY"]);
assert!(recs[0].fields.is_empty(), "bodyless record takes defaults");
assert_eq!(recs[1].fields.len(), 1);
assert!(recs[2].fields.is_empty(), "grecord shares record_body");
assert!(recs[3].fields.is_empty());
let recs = parse_db(r#"record(ai, "LAST")"#, &HashMap::new()).unwrap();
assert_eq!(recs.len(), 1);
assert_eq!(recs[0].name, "LAST");
}
#[test]
fn parse_db_global_alias_unknown_record_is_deferred_not_rejected() {
let src = r#"
alias("NOSUCH", "X")
record(ai, "KEPT") { field(VAL, "1") }
"#;
let parsed = parse_db_with_breaktables(src, &HashMap::new()).unwrap();
assert_eq!(
parsed.unresolved_aliases,
vec![("NOSUCH".to_string(), "X".to_string())]
);
assert_eq!(parsed.records.len(), 1);
assert_eq!(parsed.records[0].name, "KEPT");
assert_eq!(parse_db(src, &HashMap::new()).unwrap().len(), 1);
}
#[test]
fn parse_db_unquoted_bareword_value_ok() {
let src = r#"record(ai, "REC") { field(VAL, 42) field(EGU, deg-C) }"#;
let recs = parse_db(src, &HashMap::new()).unwrap();
assert_eq!(recs[0].fields[0].value, "42");
assert_eq!(recs[0].fields[1].value, "deg-C");
}
#[test]
fn parse_db_unquoted_value_with_space_rejected() {
let src = r#"record(ai, "REC") { field(DESC, hello world) }"#;
let res = parse_db(src, &HashMap::new());
assert!(
matches!(res, Err(CaError::DbParseError { .. })),
"unquoted value with space must be rejected, got {res:?}"
);
}
#[test]
fn parse_db_unquoted_value_with_illegal_char_rejected() {
let src = r#"record(ai, "REC") { field(DESC, a*b) }"#;
let res = parse_db(src, &HashMap::new());
assert!(matches!(res, Err(CaError::DbParseError { .. })));
}
#[test]
fn parse_db_record_without_alias_has_empty_aliases() {
let src = r#"record(ai, "PLAIN") { field(VAL, 1) }"#;
let recs = parse_db(src, &HashMap::new()).unwrap();
assert!(recs[0].aliases.is_empty());
}
#[test]
fn parse_db_info_accepts_unquoted_tag() {
let src = r#"
record(ai, "REC") {
field(VAL, "0")
info(asyn:READBACK, "1")
info("Q:group", "demo")
info(autosaveFields, "VAL DESC")
}
"#;
let recs = parse_db(src, &HashMap::new()).unwrap();
assert_eq!(recs.len(), 1);
let tags = &recs[0].info_tags;
assert!(
tags.iter().any(|(k, v)| k == "asyn:READBACK" && v == "1"),
"unquoted tag must parse: {tags:?}"
);
assert!(tags.iter().any(|(k, v)| k == "Q:group" && v == "demo"));
assert!(
tags.iter()
.any(|(k, v)| k == "autosaveFields" && v == "VAL DESC"),
"unquoted multi-word value must parse: {tags:?}"
);
}
#[test]
fn substitute_macros_backslash_escapes_dollar() {
let mut macros = HashMap::new();
macros.insert("a".to_string(), "foo".to_string());
macros.insert("b".to_string(), "baz".to_string());
assert_eq!(substitute_macros(r"$(a)\$(b)", ¯os), r"foo\$(b)");
assert_eq!(substitute_macros(r"\${a}", ¯os), r"\${a}");
assert_eq!(substitute_macros("$(a)$(b)", ¯os), "foobaz");
assert_eq!(substitute_macros(r"\\$(a)", ¯os), r"\\foo");
assert_eq!(
substitute_macros(r"path\file $(a)", ¯os),
r"path\file foo"
);
}
#[test]
fn substitute_macros_chained_expansion() {
let mut macros = HashMap::new();
macros.insert("P".to_string(), "$(Q)".to_string());
macros.insert("Q".to_string(), "IOC:".to_string());
assert_eq!(substitute_macros("$(P)TEMP", ¯os), "IOC:TEMP");
}
#[test]
fn substitute_macros_scoped_definitions() {
let mut macros = HashMap::new();
macros.insert("INNER".to_string(), "$(A)-$(B)".to_string());
assert_eq!(substitute_macros("$(INNER,A=1,B=2)", ¯os), "1-2");
}
#[test]
fn expand_macros_reports_undefined_names() {
let macros = HashMap::new();
let r = expand_macros("$(A)$(B=def)$(C)", ¯os, MacroExpandOptions::default());
assert_eq!(r.text, "$(A,undefined)def$(C,undefined)");
assert_eq!(r.faults.undefined, vec!["A".to_string(), "C".to_string()]);
}
#[test]
fn quotes_and_escapes_survive_only_at_level_zero() {
let mut macros = HashMap::new();
macros.insert("X".to_string(), "1".to_string());
let l0 = |src: &str| expand_macros(src, ¯os, MacroExpandOptions::default()).text;
let l1 = |raw: &str| {
let mut m = macros.clone();
m.insert("M".to_string(), raw.to_string());
expand_macros("$(M)", &m, MacroExpandOptions::default()).text
};
for (src, at_l0, at_l1) in [
(r#""abc""#, r#""abc""#, "abc"),
("'abc'", "'abc'", "abc"),
(r"a\b", r"a\b", "ab"),
(r"a\\b", r"a\\b", r"a\b"),
(r#""$(X)""#, r#""1""#, "1"),
("'$(X)'", "'$(X)'", "$(X)"),
("Beam's energy", "Beam's energy", "Beams energy"),
(r#"a"b"#, r#"a"b"#, "ab"),
("$(X)'s", "1's", "1s"),
] {
assert_eq!(l0(src), at_l0, "level 0 keeps the user's own: {src}");
assert_eq!(l1(src), at_l1, "level 1 discards: {src}");
}
assert_eq!(l0("$(NOSUCH=Beam's energy)"), "Beams energy");
assert_eq!(l0(r"$(NOSUCH=a\b)"), "ab");
assert_eq!(l0(r#"$(NOSUCH="q")"#), "q");
}
#[test]
fn a_db_field_default_loses_its_apostrophe_like_c() {
let recs = parse_db(
"record(ai,\"X:B\") { field(DESC, \"$(BEAM=Beam's energy)\") }",
&HashMap::new(),
)
.expect("record must parse");
assert_eq!(recs.len(), 1);
let desc = recs[0]
.fields
.iter()
.find(|f| f.name == "DESC")
.map(|f| f.value.to_string());
assert_eq!(desc.as_deref(), Some("Beams energy"));
}
#[test]
fn expand_macros_default_opts_leave_dollar_dollar_verbatim() {
let macros = HashMap::new();
assert_eq!(substitute_macros("$$100", ¯os), "$$100");
let r = expand_macros("$$100", ¯os, MacroExpandOptions::default());
assert_eq!(r.text, "$$100");
assert!(r.faults.undefined.is_empty());
}
#[test]
fn expand_macros_env_fallback_opt_in() {
let var = "_EPICS_BASE_RS_MACRO_ENV_TEST";
unsafe { std::env::set_var(var, "FROM_ENV") };
let macros = HashMap::new();
let off = expand_macros(&format!("$({var})"), ¯os, MacroExpandOptions::default());
assert_eq!(off.text, format!("$({var},undefined)"));
let on = expand_macros(
&format!("$({var})"),
¯os,
MacroExpandOptions {
env_fallback: true,
..MacroExpandOptions::default()
},
);
assert_eq!(on.text, "FROM_ENV");
assert!(on.faults.undefined.is_empty());
unsafe { std::env::remove_var(var) };
}
#[test]
fn substitute_macros_scoped_not_leaking() {
let mut macros = HashMap::new();
macros.insert("INNER".to_string(), "$(A)".to_string());
let out = substitute_macros("$(INNER,A=9)|$(A)", ¯os);
assert_eq!(out, "9|$(A,undefined)");
}
#[test]
fn substitute_macros_per_line_resets_quote_state_at_newline() {
let mut macros = HashMap::new();
macros.insert("X".to_string(), "VAL".to_string());
assert_eq!(
substitute_macros("don't\n$(X)", ¯os),
"don't\n$(X)",
"precondition: the whole-file engine suppresses across the newline"
);
assert_eq!(
substitute_macros_per_line("don't\n$(X)", ¯os),
"don't\nVAL"
);
assert_eq!(
substitute_macros_per_line("'$(X)' $(X)\n$(X)", ¯os),
"'$(X)' VAL\nVAL"
);
}
#[test]
fn parse_db_expands_macros_after_a_comment_apostrophe() {
let mut macros = HashMap::new();
macros.insert("P".to_string(), "T:".to_string());
let db = "# this comment's apostrophe must stay harmless\n\
record(ai, \"$(P)X\") {\n}\n";
let records = parse_db(db, ¯os).expect("the comment must not break expansion");
assert_eq!(records.len(), 1);
assert_eq!(records[0].name, "T:X");
}
#[test]
fn substitute_macros_suppressed_in_single_quotes() {
let mut macros = HashMap::new();
macros.insert("X".to_string(), "VAL".to_string());
assert_eq!(substitute_macros("'$(X)'", ¯os), "'$(X)'");
assert_eq!(substitute_macros("\"$(X)\"", ¯os), "\"VAL\"");
}
#[test]
fn substitute_macros_indirect_name() {
let mut macros = HashMap::new();
macros.insert("WHICH".to_string(), "SEL".to_string());
macros.insert("SEL".to_string(), "chosen".to_string());
assert_eq!(substitute_macros("$($(WHICH))", ¯os), "chosen");
}
#[test]
fn substitute_macros_undefined_placeholder() {
let macros = HashMap::new();
assert_eq!(
substitute_macros("$(MISSING)", ¯os),
"$(MISSING,undefined)"
);
}
#[test]
fn substitute_macros_default_with_comma_is_c_parity() {
let macros = HashMap::new();
assert_eq!(substitute_macros("$(LIST=a,b,c)", ¯os), "a");
}
#[test]
fn substitute_macros_self_reference_terminates() {
let mut macros = HashMap::new();
macros.insert("A".to_string(), "$(A)".to_string());
assert_eq!(substitute_macros("$(A)", ¯os), "$(A,recursive)");
}
#[test]
fn substitute_macros_mutual_reference_terminates() {
let mut macros = HashMap::new();
macros.insert("A".to_string(), "$(B)".to_string());
macros.insert("B".to_string(), "$(A)".to_string());
assert_eq!(substitute_macros("$(A)", ¯os), "$(B,recursive)");
}
#[test]
fn a_menu_declaration_keeps_its_choices_in_declaration_order() {
let src = r#"
menu(menuScan) {
choice(menuScanPassive,"Passive")
choice(menuScanEvent,"Event")
choice(menuScanI_O_Intr,"I/O Intr")
# Periodic scans follow, ordered from slowest to fastest
choice(menuScan10_second,"10 second")
choice(menuScan_1_second,".1 second")
}
"#;
let parsed = parse_db_with_breaktables(src, &HashMap::new()).expect("menu must parse");
assert!(parsed.records.is_empty());
let menu = parsed.dbd.menu("menuScan").expect("menuScan declared");
assert_eq!(
menu.choices,
vec![
("menuScanPassive".to_string(), "Passive".to_string()),
("menuScanEvent".to_string(), "Event".to_string()),
("menuScanI_O_Intr".to_string(), "I/O Intr".to_string()),
("menuScan10_second".to_string(), "10 second".to_string()),
("menuScan_1_second".to_string(), ".1 second".to_string()),
],
"the `#` line inside the body is a comment, not a choice"
);
}
#[test]
fn a_recordtype_keeps_its_field_items_its_menu_item_and_its_cdefs() {
let src = r#"
recordtype(ai) {
%#include "callback.h"
field(SCAN,DBF_MENU) {
prompt("Scan Mechanism")
promptgroup("10 - Common")
special(SPC_SCAN)
menu(menuScan)
}
field(PREC,DBF_SHORT) {
prompt("Display Precision")
}
}
"#;
let parsed =
parse_db_with_breaktables(src, &HashMap::new()).expect("recordtype must parse");
let rt = &parsed.dbd.record_types[0];
assert_eq!(rt.name, "ai");
assert_eq!(rt.cdefs, vec!["#include \"callback.h\"".to_string()]);
assert_eq!(rt.fields.len(), 2);
assert_eq!(rt.fields[0].name, "SCAN");
assert_eq!(rt.fields[0].dbf_type, "DBF_MENU");
assert_eq!(
rt.fields[0].items,
vec![
("prompt".to_string(), "Scan Mechanism".to_string()),
("promptgroup".to_string(), "10 - Common".to_string()),
("special".to_string(), "SPC_SCAN".to_string()),
("menu".to_string(), "menuScan".to_string()),
]
);
assert_eq!(rt.fields[1].name, "PREC");
}
#[test]
fn an_empty_recordtype_body_is_not_a_syntax_error() {
let parsed = parse_db_with_breaktables("recordtype(stub) {}", &HashMap::new())
.expect("the empty body is a production of its own");
assert_eq!(parsed.dbd.record_types[0].name, "stub");
assert!(parsed.dbd.record_types[0].fields.is_empty());
}
#[test]
fn the_single_line_dbd_declarations_all_parse() {
let src = r#"
device(ai,CONSTANT,devAiSoft,"Soft Channel")
driver(drvVxi)
link(const,lsetConst)
registrar(asSub)
function(myCalcFunc)
variable(dbRecordsOnceOnly)
variable(CASDEBUG,int)
"#;
let d = parse_db_with_breaktables(src, &HashMap::new())
.expect("every dbd declaration must parse")
.dbd;
assert_eq!(
d.devices,
vec![DbdDevice {
record_type: "ai".into(),
link_type: "CONSTANT".into(),
dset: "devAiSoft".into(),
choice: "Soft Channel".into(),
}]
);
assert_eq!(d.drivers, vec!["drvVxi".to_string()]);
assert_eq!(
d.link_types,
vec![DbdLinkType {
key: "const".into(),
lset: "lsetConst".into()
}]
);
assert_eq!(d.registrars, vec!["asSub".to_string()]);
assert_eq!(d.functions, vec!["myCalcFunc".to_string()]);
assert_eq!(
d.variables,
vec![
DbdVariable {
name: "dbRecordsOnceOnly".into(),
dtype: "int".into()
},
DbdVariable {
name: "CASDEBUG".into(),
dtype: "int".into()
},
],
"the one-argument form defaults to int, as C's action does"
);
}
#[test]
fn a_declaration_with_the_wrong_argument_count_is_refused() {
for src in [
"driver(a,b)",
"link(only)",
"device(ai,CONSTANT,devAiSoft)",
"variable(a,b,c)",
] {
assert!(
parse_db_with_breaktables(src, &HashMap::new()).is_err(),
"{src} must not parse"
);
}
}
#[test]
fn records_and_dbd_declarations_interleave_in_one_file() {
let src = r#"
menu(menuYesNo) { choice(menuYesNoNO,"NO") choice(menuYesNoYES,"YES") }
record(ai, "A") { field(VAL, "1") }
driver(drvSoft)
record(bo, "B") {}
"#;
let parsed = parse_db_with_breaktables(src, &HashMap::new()).expect("mixed file");
let names: Vec<&str> = parsed.records.iter().map(|r| r.name.as_str()).collect();
assert_eq!(names, vec!["A", "B"]);
assert_eq!(parsed.dbd.menus.len(), 1);
assert_eq!(parsed.dbd.drivers, vec!["drvSoft".to_string()]);
}
#[test]
fn an_ordinary_db_carries_no_dbd_declarations() {
let parsed =
parse_db_with_breaktables(r#"record(ai, "A") { field(VAL, "1") }"#, &HashMap::new())
.unwrap();
assert!(parsed.dbd.is_empty());
}
#[test]
fn an_include_inside_a_record_body_is_accepted() {
let src = r#"record(ai, "A") { field(VAL, "1") include "more.db" field(PREC, "3") }"#;
let recs = parse_db(src, &HashMap::new()).expect("include is a record_field alternative");
assert_eq!(recs.len(), 1);
assert_eq!(recs[0].fields.len(), 2);
}
}
#[cfg(test)]
mod suggestion_tests {
use super::*;
fn line(record_type: &str, field: &str, value: &str) -> Option<String> {
assert_eq!(
record_type_field_exists(record_type, field),
Some(false),
"{record_type}.{field} must be a REFUSED field for this to mean anything"
);
suggest_field(record_type, field, value).map(suggestion_line)
}
#[test]
fn similarity_matches_the_c_test_table() {
for (expect, a, b) in [
(1.00, "", ""),
(1.00, "A", "A"),
(0.00, "A", "B"),
(1.00, "exact", "exact"),
(0.90, "10 second", "10 seconds"),
(0.71, "Passive", "Pensive"),
(0.11, "10 second", "Pensive"),
(0.97, "Set output to IVOV", "Set output To IVOV"),
(0.06, "Passive", "I/O Intr"),
(0.06, "I/O Intr", "Pensive"),
(0.50, "YES", "yes"),
(0.00, "YES", "NO"),
(0.67, "YES", "Yes"),
(0.67, "Tes", "yes"),
] {
let got = str_similarity(a, b);
assert!(
(got - expect).abs() < 0.01,
"similarity({a:?}, {b:?}) = {got}, C says {expect}"
);
}
}
#[test]
fn the_suggestion_is_byte_exact_against_the_reference_ioc() {
for (rt, field, value, expect) in [
(
"bo",
"INP",
"x",
Some(" Did you mean \"OUT\"? (Output Specification)"),
),
(
"ao",
"INP",
"1",
Some(" Did you mean \"OUT\"? (Output Specification)"),
),
(
"ai",
"OUT",
"1",
Some(" Did you mean \"INP\"? (Input Specification)"),
),
(
"ai",
"DOL",
"1",
Some(" Did you mean \"INP\"? (Input Specification)"),
),
(
"bi",
"ZRST",
"off",
Some(" Did you mean \"ZNAM\"? (Zero Name)"),
),
(
"mbbi",
"ZNAM",
"off",
Some(" Did you mean \"ZRST\"? (Zero String)"),
),
(
"mbbo",
"ONAM",
"on",
Some(" Did you mean \"ONST\"? (One String)"),
),
(
"calc",
"DOLA",
"1",
Some(" Did you mean \"INPK\"? (Input K)"),
),
(
"calc",
"DOLF",
"1",
Some(" Did you mean \"INPP\"? (Input P)"),
),
(
"calc",
"INP0",
"1",
Some(" Did you mean \"INPA\"? (Input A)"),
),
(
"calc",
"INP9",
"1",
Some(" Did you mean \"INPJ\"? (Input J)"),
),
(
"ao",
"DOL0",
"1",
Some(" Did you mean \"DOL\"? (Desired Output Link)"),
),
(
"seq",
"INPA",
"1",
Some(" Did you mean \"DOL0\"? (Input link 0)"),
),
(
"ai",
"ASL",
"1",
Some(" Did you mean \"ASLO\"? (Adjustment Slope)"),
),
(
"ai",
"PRE",
"3",
Some(" Did you mean \"PREC\"? (Display Precision)"),
),
(
"ai",
"DES",
"hello",
Some(" Did you mean \"DESC\"? (Descriptor)"),
),
(
"ai",
"FLNKK",
"X",
Some(" Did you mean \"FLNK\"? (Forward Process Link)"),
),
(
"calc",
"CALCC",
"A+B",
Some(" Did you mean \"CALC\"? (Calculation)"),
),
(
"permissive",
"LABE",
"hi",
Some(" Did you mean \"LABL\"? (Button Label)"),
),
(
"stringin",
"VALL",
"hello",
Some(" Did you mean \"VAL\"? (Current Value)"),
),
(
"ai",
"ASL",
"abc",
Some(" Did you mean \"ASG\"? (Access Security Group)"),
),
(
"ai",
"HIH",
"1",
Some(" Did you mean \"HIHI\"? (Hihi Alarm Limit)"),
),
(
"ai",
"HIH",
"abc",
Some(" Did you mean \"SDIS\"? (Scanning Disable)"),
),
(
"ai",
"DTY",
"1",
Some(" Did you mean \"SDLY\"? (Sim. Mode Async Delay)"),
),
(
"ai",
"DTY",
"Soft Channel",
Some(" Did you mean \"DTYP\"? (Device Type)"),
),
(
"ai",
"DTYX",
"1",
Some(" Did you mean \"DTYP\"? (Device Type)"),
),
(
"ai",
"SCA",
"Passive",
Some(" Did you mean \"SCAN\"? (Scan Mechanism)"),
),
(
"ai",
"SCANN",
"Passive",
Some(" Did you mean \"SCAN\"? (Scan Mechanism)"),
),
(
"lsi",
"oval",
"2 second",
Some(" Did you mean \"SCAN\"? (Scan Mechanism)"),
),
("ai", "ZZZZ", "1", None),
("ai", "Q", "1", None),
] {
assert_eq!(
line(rt, field, value).as_deref(),
expect,
"{rt}.{field} = {value:?}"
);
}
}
#[test]
fn a_record_type_with_no_device_support_still_answers() {
let expect = Some(" Did you mean \"LOLO\"? (Lolo Alarm Limit)");
assert_eq!(line("calc", "DOL", "").as_deref(), expect);
assert_eq!(line("calc", "DOL", "1").as_deref(), expect);
}
#[test]
fn a_confusion_map_proposal_the_type_does_not_declare_is_dropped() {
assert_eq!(confusion_guess("DOL", 2).as_deref(), Some("INP"));
assert!(crate::server::record::declared_field("calc", "INP").is_none());
assert_eq!(
line("calc", "DOL", "").as_deref(),
Some(" Did you mean \"LOLO\"? (Lolo Alarm Limit)")
);
}
#[test]
fn the_range_map_ignores_the_tail_of_the_name() {
assert_eq!(confusion_guess("INPAX", 8).as_deref(), Some("DOL0"));
assert_eq!(confusion_guess("INPA", 8).as_deref(), Some("DOL0"));
assert_eq!(confusion_guess("INP", 8), None);
assert_eq!(confusion_guess("INPZ", 8), None);
assert_eq!(confusion_guess("XNPA", 8), None);
}
#[test]
fn a_field_without_a_prompt_gets_the_bare_line() {
let desc = FieldDesc::new("ZZ", crate::types::DbFieldType::Long, false);
assert_eq!(suggestion_line(&desc), " Did you mean \"ZZ\"?");
}
}
#[cfg(test)]
mod menu_refusal_tests {
use super::{ERL_ERROR, menu_value_refusal};
fn line(rt: &str, rec: &str, field: &str, value: &str) -> Option<String> {
menu_value_refusal(rt, rec, field, value).map(|r| r.line)
}
fn hint(rt: &str, field: &str, value: &str) -> Option<String> {
menu_value_refusal(rt, "R", field, value).and_then(|r| r.suggestion)
}
#[test]
fn an_unexpanded_macro_is_refused_before_the_field_type_is_read() {
for (field, value, suggestion) in [
("INP", "$(INP,undefined) NPP", None),
(
"ALG",
"$(ALG,undefined)",
Some(" Did you mean \"Average\"?"),
),
(
"BALG",
"$(BALG,undefined)",
Some(" Did you mean \"FIFO Buffer\"?"),
),
("NSAM", "$(NSAM,undefined)", None),
] {
let refusal = menu_value_refusal("compress", "comp", field, value)
.unwrap_or_else(|| panic!("compress.{field} = {value:?} must be refused"));
assert_eq!(
refusal.notice,
Some(format!("comp.{field} Has unexpanded macro"))
);
assert_eq!(
refusal.line,
format!("{ERL_ERROR}: Can't set 'comp.{field}' to '{value}' : Bad Field value")
);
assert_eq!(refusal.suggestion.as_deref(), suggestion);
}
}
#[test]
fn both_macro_spellings_refuse_and_a_plain_value_still_does_not() {
assert!(menu_value_refusal("compress", "comp", "INP", "${INP} NPP").is_some());
assert!(menu_value_refusal("compress", "comp", "INP", "some:pv NPP").is_none());
assert_eq!(
hint("ai", "SCAN", "Passiv"),
Some(String::from(" Did you mean \"Passive\"?"))
);
}
#[test]
fn the_refusal_is_byte_exact_against_the_reference_ioc() {
for (rt, rec, field, value, expect) in [
(
"ai",
"M1",
"SCAN",
"Passiv",
Some(format!(
"{ERL_ERROR}: Can't set 'M1.SCAN' to 'Passiv' using menu menuScan : Illegal choice"
)),
),
(
"ai",
"M3",
"PINI",
"YESS",
Some(format!(
"{ERL_ERROR}: Can't set 'M3.PINI' to 'YESS' using menu menuPini : Illegal choice"
)),
),
(
"ai",
"N1",
"PRIO",
"HIGHH",
Some(format!(
"{ERL_ERROR}: Can't set 'N1.PRIO' to 'HIGHH' using menu menuPriority : Illegal choice",
)),
),
(
"ai",
"L3",
"LINR",
"NoSuchTable",
Some(format!(
"{ERL_ERROR}: Can't set 'L3.LINR' to 'NoSuchTable' using menu menuConvert : Illegal choice",
)),
),
(
"sel",
"Y1",
"SELM",
"Bogus",
Some(format!(
"{ERL_ERROR}: Can't set 'Y1.SELM' to 'Bogus' using menu selSELM : Illegal choice"
)),
),
(
"ai",
"P2",
"PINI",
"7",
Some(format!(
"{ERL_ERROR}: Can't set 'P2.PINI' to '7' : Bad Field value"
)),
),
("ai", "P1", "PINI", "6", None),
("ai", "P3", "PINI", "65535", None),
("ai", "P4", "PINI", "", None),
("ai", "P5", "PINI", "0x2", None),
(
"ai",
"P6",
"PINI",
"yes",
Some(format!(
"{ERL_ERROR}: Can't set 'P6.PINI' to 'yes' using menu menuPini : Illegal choice"
)),
),
("ai", "OK", "PINI", "RUNNING", None),
("ai", "OK", "PINI", "3", None),
("ai", "OK", "SCAN", "1 second", None),
] {
assert_eq!(
line(rt, rec, field, value).as_deref(),
expect.as_deref(),
"{rt} {rec}.{field} = {value:?}"
);
}
}
#[test]
fn a_non_menu_field_is_not_judged_by_the_menu_arm() {
assert_eq!(
line("ai", "R", "PREC", "not a number"),
Some(format!(
"{ERL_ERROR}: Can't set 'R.PREC' to 'not a number' : No digits to convert"
))
);
assert_eq!(hint("ai", "PREC", "not a number"), None);
assert_eq!(line("ai", "R", "DESC", "anything at all"), None);
assert_eq!(line("ai", "R", "DTYP", "Soft Chanel"), None);
}
#[test]
fn the_choice_suggestion_is_byte_exact_against_the_reference_ioc() {
for (rt, field, value, expect) in [
(
"ai",
"SCAN",
"Passiv",
Some(" Did you mean \"Passive\"?"),
),
("ai", "PINI", "YESS", Some(" Did you mean \"YES\"?")),
("ai", "PRIO", "HIGHH", Some(" Did you mean \"HIGH\"?")),
(
"ai",
"LINR",
"NoSuchTable",
Some(" Did you mean \"SLOPE\"?"),
),
(
"sel",
"SELM",
"Bogus",
Some(" Did you mean \"Low Signal\"?"),
),
("ai", "PINI", "yes", Some(" Did you mean \"YES\"?")),
("ai", "PINI", "7", None),
] {
assert_eq!(
hint(rt, field, value).as_deref(),
expect,
"{rt}.{field} = {value:?}"
);
}
}
#[test]
fn every_common_menu_field_names_its_menu() {
use crate::server::record::{dbd_generated::DB_COMMON_FIELDS, declared_field};
use crate::types::DbfCode;
for desc in DB_COMMON_FIELDS {
if desc.declared_dbf != DbfCode::Menu {
continue;
}
assert!(
super::load_menu_of(desc).is_some_and(|(name, _)| name.is_some()),
"{}: menu name did not resolve",
desc.name
);
}
let linr = declared_field("ai", "LINR").expect("ai declares LINR");
assert_eq!(
super::load_menu_of(linr).and_then(|(n, _)| n),
Some("menuConvert")
);
}
#[test]
fn an_external_choice_table_is_a_menu_without_a_name() {
use crate::server::record::FieldDesc;
use crate::types::{DbFieldType, DbfCode};
static EXT_CHOICES: &[&str] = &["Local", "Remote"];
let mut desc = FieldDesc::new("MODE", DbFieldType::Enum, false);
desc.menu = Some(EXT_CHOICES);
assert_eq!(desc.declared_dbf, DbfCode::Enum);
assert_eq!(super::load_menu_of(&desc), Some((None, EXT_CHOICES)));
assert_eq!(
super::menu_arm(&desc, "MODE", None, EXT_CHOICES, "Remote", "Remote"),
None
);
let (detail, status, _) =
super::menu_arm(&desc, "MODE", None, EXT_CHOICES, "Bogus", "Bogus").unwrap();
assert_eq!(detail, "using menu MODE");
assert_eq!(status, "Illegal choice");
let mut dtyp = FieldDesc::new("DTYP", DbFieldType::Enum, false);
dtyp.declared_dbf = DbfCode::Device;
dtyp.menu = Some(EXT_CHOICES);
assert_eq!(super::load_menu_of(&dtyp), None);
}
}
#[cfg(test)]
mod numeric_refusal_tests {
use super::{
DbfCode, ERL_ERROR, db_load_numeric_value, menu_value_refusal, numeric_value_refusal,
};
use crate::types::EpicsValue;
fn status(rt: &str, field: &str, value: &str) -> Option<String> {
let r = menu_value_refusal(rt, "R", field, value)?;
assert_eq!(r.suggestion, None, "{rt}.{field} = {value:?}");
let head = format!("{ERL_ERROR}: Can't set 'R.{field}' to '{value}' : ");
Some(
r.line
.strip_prefix(&head)
.unwrap_or_else(|| panic!("{:?} does not open with {head:?}", r.line))
.to_string(),
)
}
#[test]
fn the_refusals_are_byte_exact_against_the_reference_ioc() {
for (rt, field, value, expect) in [
("ao", "OROC", "5e-324", "Too large to represent"),
("ao", "HOPR", "1e-310", "Too large to represent"),
("ao", "HOPR", "-1e-310", "Too large to represent"),
("ao", "LOPR", "1e400", "Too large to represent"),
("ao", "EGUF", "1e-400", "Too small to represent"),
("ao", "EGUL", "abc", "No digits to convert"),
("ao", "AOFF", "1.5x", "Extraneous characters"),
(
"ai",
"PHAS",
"99999999999999999999",
"Too large to represent",
),
("ai", "PHAS", "zz", "No digits to convert"),
("ai", "PHAS", "3 x", "Extraneous characters"),
(
"ai",
"UTAG",
"99999999999999999999999",
"Too large to represent",
),
(
"int64out",
"VAL",
"9223372036854775808",
"Too large to represent",
),
("longout", "VAL", "1.0", "Extraneous characters"),
("ai", "PREC", "3.0", "Extraneous characters"),
("mbbo", "VAL", "ONE", "No digits to convert"),
] {
assert_eq!(
status(rt, field, value).as_deref(),
Some(expect),
"{rt}.{field} = {value:?}"
);
}
}
#[test]
fn the_values_c_stores_are_not_refused() {
for (rt, field, value) in [
("ao", "ASLO", " 2.5 "),
("ai", "PREC", " 4"),
("ai", "PREC", "4 "),
("ao", "ROFF", "0x10"),
("ao", "HOPR", "0x10"),
("longout", "VAL", "0x10"),
("waveform", "NELM", "0b11"),
("ai", "PREC", "010"),
("ai", "PHAS", "70000"),
("ai", "PHAS", "-40000"),
("ai", "TPRO", "-1"),
("ai", "TPRO", "300"),
("ai", "UTAG", "-1"),
("longout", "VAL", "5000000000"),
("int64out", "VAL", "-9223372036854775808"),
("mbbo", "VAL", "5"),
("ai", "TSE", "-2"),
("ao", "HOPR", "1e3"),
("ao", "HOPR", "nan"),
("ao", "HOPR", "inf"),
("ao", "HOPR", ".5"),
("ai", "PREC", ""),
("ao", "HOPR", ""),
] {
assert_eq!(
menu_value_refusal(rt, "R", field, value).map(|r| r.line),
None,
"{rt}.{field} = {value:?}"
);
}
}
#[test]
fn the_float_gate_boundaries() {
let float = |s: &str| numeric_value_refusal(DbfCode::Float, "F", s);
let flt_min = format!("{:e}", f64::from(f32::MIN_POSITIVE));
let flt_max = format!("{:e}", f64::from(f32::MAX));
assert_eq!(float("1e-40"), Some("Too small to represent"));
assert_eq!(float(&flt_min), Some("Too small to represent"));
assert_eq!(float("-1e-40"), None);
assert_eq!(float("1e39"), Some("Too large to represent"));
assert_eq!(float("-1e39"), Some("Too large to represent"));
assert_eq!(float(&flt_max), Some("Too large to represent"));
assert_eq!(float("inf"), None);
assert_eq!(float("-inf"), None);
assert_eq!(float("nan"), None);
assert_eq!(float("1.5"), None);
assert_eq!(float("1e-400"), Some("Too small to represent"));
assert_eq!(float("1e400"), Some("Too large to represent"));
assert_eq!(float("zz"), Some("No digits to convert"));
let ch = |s: &str| numeric_value_refusal(DbfCode::Char, "C", s);
assert_eq!(ch("300"), None); assert_eq!(ch("-1"), None);
assert_eq!(ch("abc"), Some("No digits to convert"));
assert_eq!(ch("1.0"), Some("Extraneous characters"));
}
#[test]
fn the_stored_value_is_the_cast_c_makes() {
for (declared, text, expect) in [
(DbfCode::Short, "70000", EpicsValue::Short(4464)),
(DbfCode::Short, "-40000", EpicsValue::Short(25536u16 as i16)),
(DbfCode::Short, "010", EpicsValue::Short(8)),
(DbfCode::Short, "-2", EpicsValue::Short(-2)),
(DbfCode::UChar, "-1", EpicsValue::UChar(255)),
(DbfCode::UChar, "300", EpicsValue::UChar(44)),
(DbfCode::Long, "5000000000", EpicsValue::Long(705032704)),
(DbfCode::Long, "0x10", EpicsValue::Long(16)),
(DbfCode::ULong, "0b11", EpicsValue::ULong(3)),
(DbfCode::UInt64, "-1", EpicsValue::UInt64(u64::MAX)),
(
DbfCode::Int64,
"-9223372036854775808",
EpicsValue::Int64(i64::MIN),
),
(DbfCode::Enum, "5", EpicsValue::UShort(5)),
(DbfCode::Char, "300", EpicsValue::Char(44)),
(DbfCode::Char, "-1", EpicsValue::Char(255)),
(DbfCode::Double, "0x10", EpicsValue::Double(16.0)),
(DbfCode::Double, " 2.5 ", EpicsValue::Double(2.5)),
(DbfCode::Float, "1.5", EpicsValue::Float(1.5)),
(DbfCode::Short, "", EpicsValue::Short(0)),
(DbfCode::Double, "", EpicsValue::Double(0.0)),
] {
assert_eq!(
db_load_numeric_value(declared, "F", text),
Ok(Some(expect)),
"{declared:?} = {text:?}"
);
}
}
#[test]
fn the_non_numeric_declarations_decline() {
for code in [
DbfCode::String,
DbfCode::Menu,
DbfCode::Device,
DbfCode::Inlink,
DbfCode::Outlink,
DbfCode::Fwdlink,
DbfCode::NoAccess,
] {
assert_eq!(numeric_value_refusal(code, "X", "not a number"), None);
}
}
}