use std::borrow::Cow;
use crate::error::{CaError, CaResult};
use crate::runtime::json_string::{decode_json_string, decode_json_string_token};
use crate::types::{DbfLinkClass, EpicsValue};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum LinkProcessPolicy {
NoProcess,
#[default]
ProcessPassive,
ChannelProcess,
ChannelProcessPassive,
}
impl LinkProcessPolicy {
pub fn cp_passive_only(self) -> Option<bool> {
match self {
LinkProcessPolicy::ChannelProcess => Some(false),
LinkProcessPolicy::ChannelProcessPassive => Some(true),
_ => None,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HwLinkKind {
InstIo,
VmeIo,
CamacIo,
RfIo,
AbIo,
GpibIo,
BitbusIo,
BbgpibIo,
VxiIo,
}
impl HwLinkKind {
#[must_use]
pub fn db_link_type(self) -> DbLinkType {
match self {
HwLinkKind::InstIo => DbLinkType::InstIo,
HwLinkKind::VmeIo => DbLinkType::VmeIo,
HwLinkKind::CamacIo => DbLinkType::CamacIo,
HwLinkKind::RfIo => DbLinkType::RfIo,
HwLinkKind::AbIo => DbLinkType::AbIo,
HwLinkKind::GpibIo => DbLinkType::GpibIo,
HwLinkKind::BitbusIo => DbLinkType::BitbusIo,
HwLinkKind::BbgpibIo => DbLinkType::BbgpibIo,
HwLinkKind::VxiIo => DbLinkType::VxiIo,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum VxiAddr {
Dynamic { frame: i16, slot: i16 },
Static { la: i16 },
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum HwLink {
InstIo { string: String },
VmeIo {
card: i16,
signal: i16,
parm: String,
},
CamacIo {
b: i16,
c: i16,
n: i16,
a: i16,
f: i16,
parm: String,
},
RfIo {
cryo: i16,
micro: i16,
dataset: i16,
element: i16,
},
AbIo {
link: i16,
adapter: i16,
card: i16,
signal: i16,
parm: String,
},
GpibIo { link: i16, addr: i16, parm: String },
BitbusIo {
link: u8,
node: u8,
port: u8,
signal: u8,
parm: String,
},
BbgpibIo {
link: u8,
bbaddr: u8,
gpibaddr: u8,
parm: String,
},
VxiIo {
addr: VxiAddr,
signal: i16,
parm: String,
},
}
impl HwLink {
#[must_use]
pub fn kind(&self) -> HwLinkKind {
match self {
HwLink::InstIo { .. } => HwLinkKind::InstIo,
HwLink::VmeIo { .. } => HwLinkKind::VmeIo,
HwLink::CamacIo { .. } => HwLinkKind::CamacIo,
HwLink::RfIo { .. } => HwLinkKind::RfIo,
HwLink::AbIo { .. } => HwLinkKind::AbIo,
HwLink::GpibIo { .. } => HwLinkKind::GpibIo,
HwLink::BitbusIo { .. } => HwLinkKind::BitbusIo,
HwLink::BbgpibIo { .. } => HwLinkKind::BbgpibIo,
HwLink::VxiIo { .. } => HwLinkKind::VxiIo,
}
}
#[must_use]
pub fn render(&self) -> String {
match self {
HwLink::InstIo { string } => format!("@{string}"),
HwLink::VmeIo { card, signal, parm } => format!("#C{card} S{signal} @{parm}"),
HwLink::CamacIo {
b,
c,
n,
a,
f,
parm,
} => format!("#B{b} C{c} N{n} A{a} F{f} @{parm}"),
HwLink::RfIo {
cryo,
micro,
dataset,
element,
} => format!("#R{cryo} M{micro} D{dataset} E{element}"),
HwLink::AbIo {
link,
adapter,
card,
signal,
parm,
} => format!("#L{link} A{adapter} C{card} S{signal} @{parm}"),
HwLink::GpibIo { link, addr, parm } => format!("#L{link} A{addr} @{parm}"),
HwLink::BitbusIo {
link,
node,
port,
signal,
parm,
} => format!("#L{link} N{node} P{port} S{signal} @{parm}"),
HwLink::BbgpibIo {
link,
bbaddr,
gpibaddr,
parm,
} => format!("#L{link} B{bbaddr} G{gpibaddr} @{parm}"),
HwLink::VxiIo {
addr: VxiAddr::Dynamic { frame, slot },
signal,
parm,
} => format!("#V{frame} C{slot} S{signal} @{parm}"),
HwLink::VxiIo {
addr: VxiAddr::Static { la },
signal,
parm,
} => format!("#V{la} S{signal} @{parm}"),
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum ParsedLink {
None,
Constant(String),
Db(std::sync::Arc<DbLink>),
Ca(CaLink),
Pva(String),
PvaJson(PvaJsonLink),
Hw(HwLink),
Calc(CalcLink),
State(StateLink),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StateLink {
pub name: String,
pub invert: bool,
}
impl StateLink {
#[must_use]
pub fn from_link_value(value: &str) -> Self {
match value.strip_prefix('!') {
Some(name) if !name.is_empty() => Self {
name: name.to_string(),
invert: true,
},
_ => Self {
name: value.to_string(),
invert: false,
},
}
}
#[must_use]
pub fn read(&self, raw: bool) -> bool {
self.invert ^ raw
}
#[must_use]
pub fn write(&self, value: bool) -> bool {
self.invert ^ value
}
#[must_use]
pub fn truth_of(value: &crate::types::EpicsValue) -> Option<bool> {
use crate::types::EpicsValue as V;
match value {
V::String(s) => {
let text = s.as_str_lossy();
Some(!text.is_empty() && text != "0")
}
other => other.to_f64().map(|v| v != 0.0),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum JlinkValue {
Null,
Bool(bool),
Int(i64),
Str(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PvaJsonLink {
pub pv: String,
pub options: Vec<(String, JlinkValue)>,
}
impl PvaJsonLink {
pub fn link_identity_key(&self) -> String {
pvajson_identity_key(&self.pv, &self.options)
}
}
pub const PVAJSON_IDENTITY_SEP: char = '\u{1f}';
pub fn pvajson_identity_key(pv: &str, options: &[(String, JlinkValue)]) -> String {
if options.is_empty() {
return pv.to_string();
}
let mut pairs: Vec<String> = options
.iter()
.map(|(k, v)| match v {
JlinkValue::Null => format!("{k}=n"),
JlinkValue::Bool(b) => format!("{k}=b:{b}"),
JlinkValue::Int(n) => format!("{k}=i:{n}"),
JlinkValue::Str(s) => format!("{k}=s:{s}"),
})
.collect();
pairs.sort();
let mut key =
String::with_capacity(pv.len() + 1 + pairs.iter().map(|p| p.len() + 1).sum::<usize>());
key.push_str(pv);
for p in &pairs {
key.push(PVAJSON_IDENTITY_SEP);
key.push_str(p);
}
key
}
#[derive(Clone, Debug, PartialEq)]
pub enum CalcArg {
Literal(f64),
Link(Box<ParsedLink>),
}
#[derive(Clone, Debug, PartialEq)]
pub struct CalcLink {
pub expr: String,
pub args: Vec<CalcArg>,
pub time_source: Option<char>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum MonitorSwitch {
#[default]
NoMaximize,
Maximize,
MaximizeStatus,
MaximizeIfInvalid,
}
#[derive(Clone, Debug, PartialEq)]
pub struct DbLink {
record: String,
field: String,
pub policy: LinkProcessPolicy,
pub monitor_switch: MonitorSwitch,
channel: crate::server::database::filters::ChannelName,
}
impl DbLink {
pub fn new(pvname: &str, policy: LinkProcessPolicy, monitor_switch: MonitorSwitch) -> Self {
let (record, field) = match split_record_field(pvname) {
Some((record, field)) => (record.to_string(), field),
None => (pvname.to_string(), "VAL".to_string()),
};
let channel = crate::server::database::filters::parse_channel_name(&if field == "VAL" {
record.clone()
} else {
format!("{record}.{field}")
});
Self {
record,
field,
policy,
monitor_switch,
channel,
}
}
pub fn pvname(&self) -> String {
if self.field == "VAL" {
self.record.clone()
} else {
format!("{}.{}", self.record, self.field)
}
}
pub fn target(&self) -> &crate::server::database::filters::ChannelName {
&self.channel
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CaLink {
pub pv: String,
pub monitor_switch: MonitorSwitch,
pub policy: LinkProcessPolicy,
}
impl CaLink {
pub fn new(pv: impl Into<String>) -> Self {
Self {
pv: pv.into(),
monitor_switch: MonitorSwitch::NoMaximize,
policy: LinkProcessPolicy::NoProcess,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LinkType {
Empty,
Constant,
Db,
Ca,
Other,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DbLinkType {
Constant,
PvLink,
JsonLink,
VmeIo,
CamacIo,
AbIo,
GpibIo,
BitbusIo,
InstIo,
BbgpibIo,
RfIo,
VxiIo,
DbLink,
CaLink,
}
impl DbLinkType {
pub fn c_name(self) -> &'static str {
match self {
DbLinkType::Constant => "CONSTANT",
DbLinkType::PvLink => "PV_LINK",
DbLinkType::JsonLink => "JSON_LINK",
DbLinkType::VmeIo => "VME_IO",
DbLinkType::CamacIo => "CAMAC_IO",
DbLinkType::AbIo => "AB_IO",
DbLinkType::GpibIo => "GPIB_IO",
DbLinkType::BitbusIo => "BITBUS_IO",
DbLinkType::InstIo => "INST_IO",
DbLinkType::BbgpibIo => "BBGPIB_IO",
DbLinkType::RfIo => "RF_IO",
DbLinkType::VxiIo => "VXI_IO",
DbLinkType::DbLink => "DB_LINK",
DbLinkType::CaLink => "CA_LINK",
}
}
#[must_use]
pub fn empty_link_text(self) -> &'static str {
match self {
DbLinkType::Constant | DbLinkType::JsonLink => "",
DbLinkType::PvLink | DbLinkType::DbLink | DbLinkType::CaLink => "",
DbLinkType::VmeIo => "#C0 S0 @",
DbLinkType::CamacIo => "#B0 C0 N0 A0 F0 @",
DbLinkType::AbIo => "#L0 A0 C0 S0 @",
DbLinkType::GpibIo => "#L0 A0 @",
DbLinkType::BitbusIo => "#L0 N0 P0 S0 @",
DbLinkType::InstIo => "@",
DbLinkType::BbgpibIo => "#L0 B0 G0 @",
DbLinkType::RfIo => "#R0 M0 D0 E0",
DbLinkType::VxiIo => "#V0 C0 S0 @",
}
}
pub fn accepts(self, parsed: DbLinkType) -> bool {
if self == parsed {
return true;
}
let soft = |t: DbLinkType| {
matches!(
t,
DbLinkType::Constant | DbLinkType::JsonLink | DbLinkType::PvLink
)
};
soft(self) && soft(parsed)
}
}
pub fn declared_link_type(
record_type: &str,
dtyp: Option<&str>,
upper_field: &str,
) -> Option<DbLinkType> {
if !matches!(upper_field, "INP" | "OUT") {
return Some(DbLinkType::Constant);
}
let menu = super::dbd_generated::device_menu(record_type)?;
let dtyp = dtyp
.filter(|d| !d.is_empty())
.or_else(|| menu.first().copied())?;
super::dbd_generated::device_link_type(record_type, dtyp)
}
pub fn check_link_text(record_type: &str, upper_field: &str, text: &str) -> CaResult<()> {
if crate::types::dbf_link_class(record_type, upper_field).is_none() {
return Ok(());
}
check_json_link_text(text)?;
check_hw_link_text(text)
}
pub fn link_type_mismatch(
record_type: &str,
dtyp: Option<&str>,
upper_field: &str,
text: &str,
) -> Option<(DbLinkType, DbLinkType)> {
let class = crate::types::dbf_link_class(record_type, upper_field)?;
let expected = declared_link_type(record_type, dtyp, upper_field)?;
let parsed = parse_link_field(text, LinkFieldType::for_class(class)).db_link_type();
(!expected.accepts(parsed)).then_some((expected, parsed))
}
pub fn link_type_refusal(
record_name: &str,
record_type: &str,
dtyp: Option<&str>,
upper_field: &str,
text: &str,
) -> Option<String> {
let mismatch = link_type_mismatch(record_type, dtyp, upper_field, text)?;
Some(refusal_line(record_name, upper_field, text, mismatch))
}
fn refusal_line(
subject: &str,
upper_field: &str,
text: &str,
(expected, parsed): (DbLinkType, DbLinkType),
) -> String {
format!(
"{subject}.{upper_field}: can't initialize link type {} with \"{text}\" (type {})",
expected.c_name(),
parsed.c_name(),
)
}
pub fn check_link_assignment(
record_type: &str,
dtyp: Option<&str>,
upper_field: &str,
text: &str,
) -> CaResult<()> {
check_link_text(record_type, upper_field, text)?;
match link_type_mismatch(record_type, dtyp, upper_field, text) {
None => Ok(()),
Some(mismatch) => Err(CaError::InvalidValue(refusal_line(
record_type,
upper_field,
text,
mismatch,
))),
}
}
impl ParsedLink {
pub fn db(link: DbLink) -> Self {
ParsedLink::Db(std::sync::Arc::new(link))
}
#[must_use]
pub fn db_link_type(&self) -> DbLinkType {
match self {
ParsedLink::None | ParsedLink::Constant(_) => DbLinkType::Constant,
ParsedLink::Db(_) | ParsedLink::Ca(_) | ParsedLink::Pva(_) => DbLinkType::PvLink,
ParsedLink::PvaJson(_) | ParsedLink::Calc(_) | ParsedLink::State(_) => {
DbLinkType::JsonLink
}
ParsedLink::Hw(hw) => hw.kind().db_link_type(),
}
}
pub fn link_type_after_init(&self, raw: &str) -> DbLinkType {
if matches!(parse_json_link(raw.trim()), JsonLinkParse::Parsed(_)) {
return DbLinkType::JsonLink;
}
match self {
ParsedLink::None | ParsedLink::Constant(_) => DbLinkType::Constant,
ParsedLink::PvaJson(_) | ParsedLink::Calc(_) | ParsedLink::State(_) => {
DbLinkType::JsonLink
}
ParsedLink::Pva(_) | ParsedLink::Ca(_) => DbLinkType::CaLink,
ParsedLink::Db(link) => {
if link.policy.cp_passive_only().is_some() {
DbLinkType::CaLink
} else {
DbLinkType::DbLink
}
}
ParsedLink::Hw(hw) => hw.kind().db_link_type(),
}
}
pub fn link_type(&self) -> LinkType {
match self {
ParsedLink::None => LinkType::Empty,
ParsedLink::Constant(_) => LinkType::Constant,
ParsedLink::Db(_) => LinkType::Db,
ParsedLink::Ca(_) | ParsedLink::Pva(_) | ParsedLink::PvaJson(_) => LinkType::Ca,
ParsedLink::Hw(_) | ParsedLink::Calc(_) | ParsedLink::State(_) => LinkType::Other,
}
}
pub fn constant_value(&self) -> Option<EpicsValue> {
if let ParsedLink::Constant(s) = self {
if let Some(inner) = s
.trim()
.strip_prefix('[')
.and_then(|body| body.strip_suffix(']'))
{
return Some(constant_array_value(inner));
}
if let Some(v) = parse_c_double(s) {
Some(EpicsValue::Double(v))
} else {
Some(EpicsValue::String(s.clone().into()))
}
} else {
None
}
}
pub fn is_db(&self) -> bool {
matches!(self, ParsedLink::Db(_))
}
pub fn is_hw(&self) -> bool {
matches!(self, ParsedLink::Hw(_))
}
pub fn is_writable_out_link(&self) -> bool {
matches!(
self,
ParsedLink::Db(_)
| ParsedLink::Ca(_)
| ParsedLink::Pva(_)
| ParsedLink::PvaJson(_)
| ParsedLink::State(_)
)
}
pub fn external_pv_name(&self) -> Option<Cow<'_, str>> {
match self {
ParsedLink::Ca(ca) => Some(Cow::Borrowed(ca.pv.as_str())),
ParsedLink::Pva(name) => Some(Cow::Owned(format!("pva://{name}"))),
ParsedLink::PvaJson(j) => Some(Cow::Owned(format!(
"pva://{key}",
key = j.link_identity_key()
))),
_ => None,
}
}
pub fn required_link_set_scheme(&self) -> Option<&'static str> {
match self {
ParsedLink::Pva(_) | ParsedLink::PvaJson(_) => Some("pva"),
_ => None,
}
}
pub fn monitor_switch(&self) -> Option<MonitorSwitch> {
match self {
ParsedLink::Db(db) => Some(db.monitor_switch),
ParsedLink::Ca(ca) => Some(ca.monitor_switch),
_ => None,
}
}
}
enum PvaRootValue<'a> {
Object(&'a str),
StringName(&'a str),
}
fn classify_pva_root_value(value: &str) -> Option<PvaRootValue<'_>> {
let v = value.trim();
if v.starts_with('{') {
return Some(PvaRootValue::Object(v));
}
for quote in ['"', '\''] {
if let Some(rest) = v.strip_prefix(quote) {
if let Some(inner) = rest.strip_suffix(quote) {
return Some(PvaRootValue::StringName(inner));
}
}
}
None
}
fn constant_array_value(inner: &str) -> EpicsValue {
let json = format!("[{inner}]");
use crate::types::DbFieldType;
for target in [DbFieldType::Double, DbFieldType::String] {
if let Ok(v) =
crate::server::db_convert_json::db_put_convert_json(&json, target, usize::MAX)
{
return v;
}
}
EpicsValue::DoubleArray(Vec::new())
}
pub fn parse_c_double(s: &str) -> Option<f64> {
crate::runtime::stdlib::epics_parse_double(s).ok()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LsLoad {
Text(String),
LenOnly,
}
pub fn load_link_ls(text: &str) -> Option<LsLoad> {
let s = text.trim();
if s.is_empty() {
return None;
}
if s.starts_with('{') && s.ends_with('}') {
let normalized = crate::json5::relaxed_to_strict(s).ok()?;
let value = json_const_value(&normalized)?;
return json_string_value(value).map(LsLoad::Text);
}
parse_c_double(s).map(|_| LsLoad::LenOnly)
}
pub(crate) fn json_const_value(s: &str) -> Option<&str> {
let inner = s[1..s.len() - 1].trim_start();
let (key, rest) = inner.split_once(':')?;
let key = key.trim().trim_matches('"');
if !key.eq_ignore_ascii_case("const") {
return None;
}
Some(rest.trim().trim_end_matches(',').trim())
}
fn json_string_value(value: &str) -> Option<String> {
let v = value.trim();
let first = if let Some(rest) = v.strip_prefix('[') {
rest.trim_start().split(',').next()?.trim()
} else {
v
};
decode_json_string_token(first)
}
pub(crate) struct JsonLinkSup {
pub(crate) key: &'static str,
pub(crate) jlif_name: &'static str,
}
pub(crate) const PORTED_JSON_LINK_TYPES: &[JsonLinkSup] = &[
JsonLinkSup {
key: "const",
jlif_name: "lnkConstIf",
},
JsonLinkSup {
key: "calc",
jlif_name: "lnkCalcIf",
},
JsonLinkSup {
key: "state",
jlif_name: "lnkStateIf",
},
JsonLinkSup {
key: "pva",
jlif_name: "lsetPVX",
},
JsonLinkSup {
key: "ca",
jlif_name: "lnkCaIf",
},
];
const UNPORTED_C_JSON_LINK_TYPES: &[&str] = &["debug", "trace"];
pub enum JsonLinkParse {
NotJson,
Parsed(ParsedLink),
Rejected(String),
}
enum JsonLinkKey<'a> {
Named(&'a str),
Empty,
Malformed,
}
fn json_link_key(s: &str) -> JsonLinkKey<'_> {
let inner = s[1..s.len() - 1].trim();
if inner.is_empty() {
return JsonLinkKey::Empty;
}
let Some((key_raw, _)) = inner.split_once(':') else {
return JsonLinkKey::Malformed;
};
let key = key_raw.trim().trim_matches('"');
if key.is_empty() {
JsonLinkKey::Malformed
} else {
JsonLinkKey::Named(key)
}
}
pub fn parse_json_link(s: &str) -> JsonLinkParse {
let s = s.trim();
if !s.starts_with('{') || !s.ends_with('}') || s.len() < 2 {
return JsonLinkParse::NotJson;
}
let normalized = match crate::json5::relaxed_to_strict(s) {
Ok(normalized) => normalized,
Err(e) => {
return JsonLinkParse::Rejected(format!("dbJLinkInit: {s} is not a JSON link — {e}"));
}
};
let key = match json_link_key(&normalized) {
JsonLinkKey::Named(key) => key,
JsonLinkKey::Empty => return JsonLinkParse::Parsed(ParsedLink::None),
JsonLinkKey::Malformed => {
return JsonLinkParse::Rejected(format!(
"dbJLinkInit: {s} is not a JSON link — no link type key"
));
}
};
let lower = key.to_ascii_lowercase();
if UNPORTED_C_JSON_LINK_TYPES.contains(&lower.as_str()) {
return JsonLinkParse::Rejected(format!(
"dbJLinkInit: Link type '{key}' is registered by epics-base but not \
implemented by this port"
));
}
if !PORTED_JSON_LINK_TYPES.iter().any(|s| s.key == lower) {
return JsonLinkParse::Rejected(format!("dbJLinkInit: Link type '{key}' not found"));
}
match try_parse_json_link_body(&normalized) {
Some(parsed) => JsonLinkParse::Parsed(parsed),
None => JsonLinkParse::Rejected(format!(
"dbJLinkInit: Link type '{key}' rejected its value in {s}"
)),
}
}
pub fn check_json_link_text(text: &str) -> CaResult<()> {
match parse_json_link(text) {
JsonLinkParse::Rejected(msg) => Err(CaError::InvalidValue(msg)),
JsonLinkParse::NotJson | JsonLinkParse::Parsed(_) => Ok(()),
}
}
fn check_hw_link_text(text: &str) -> CaResult<()> {
let text = text.trim();
if text.starts_with('#') && try_parse_hw_link(text).is_none() {
return Err(CaError::InvalidValue(format!(
"dbParseLink: \"{text}\" names no hardware bus"
)));
}
Ok(())
}
fn try_parse_json_link_body(s: &str) -> Option<ParsedLink> {
let s = s.trim();
if !s.starts_with('{') || !s.ends_with('}') {
return None;
}
let inner = &s[1..s.len() - 1];
let inner_trim = inner.trim_start();
let (key_raw, rest) = match inner_trim.split_once(':') {
Some((k, r)) => (k.trim(), r.trim()),
None => return None,
};
let key = key_raw.trim_matches('"').to_ascii_lowercase();
match key.as_str() {
"const" => {
let v = rest.trim_end_matches(',').trim();
let decoded = match decode_json_string_token(v) {
Some(s) => s,
None => v.to_string(),
};
if decoded.is_empty() {
Some(ParsedLink::None)
} else {
Some(ParsedLink::Constant(decoded))
}
}
"ca" | "pva" => {
let value = rest.trim_end_matches(',').trim();
match classify_pva_root_value(value)? {
PvaRootValue::Object(obj) => {
if key == "ca" {
Some(ParsedLink::Ca(CaLink::new(
extract_pv_and_opts_from_subobject(obj)?.0,
)))
} else {
let (pv, options) = extract_pv_and_opts_from_subobject(obj)?;
if options.is_empty() {
Some(ParsedLink::Pva(pv))
} else {
Some(ParsedLink::PvaJson(PvaJsonLink { pv, options }))
}
}
}
PvaRootValue::StringName(name) => {
let name = decode_json_string(name.trim());
if name.is_empty() {
return None;
}
if key == "ca" {
Some(ParsedLink::Ca(CaLink::new(name)))
} else {
Some(ParsedLink::Pva(name))
}
}
}
}
"state" => {
let value = rest.trim_end_matches(',').trim();
let text = decode_json_string_token(value)?;
Some(ParsedLink::State(StateLink::from_link_value(&text)))
}
"calc" => {
let body = rest.trim();
if !body.ends_with('}') {
return None;
}
let val: serde_json::Value = serde_json::from_str(body).ok()?;
let obj = val.as_object()?;
let expr = obj.get("expr").and_then(|v| v.as_str())?.to_string();
let args = match obj.get("args") {
None => Vec::new(),
Some(v) => {
let elems: &[serde_json::Value] = v
.as_array()
.map_or(std::slice::from_ref(v), |a| a.as_slice());
if elems.len() > crate::calc::CALC_NARGS {
return None;
}
elems
.iter()
.map(json_calc_arg)
.collect::<Option<Vec<_>>>()?
}
};
let time_source = match obj.get("time") {
None => None,
Some(v) => {
let text = v.as_str()?;
let mut chars = text.chars();
let letter = chars.next()?.to_ascii_uppercase();
if chars.next().is_some()
|| !letter.is_ascii_uppercase()
|| (letter as u8 - b'A') as usize >= crate::calc::CALC_NARGS
{
return None;
}
Some(letter)
}
};
Some(ParsedLink::Calc(CalcLink {
expr,
args,
time_source,
}))
}
_ => None,
}
}
fn json_calc_arg(v: &serde_json::Value) -> Option<CalcArg> {
match v {
serde_json::Value::Number(n) => Some(CalcArg::Literal(n.as_f64()?)),
serde_json::Value::Object(_) => match parse_json_link(&serde_json::to_string(v).ok()?) {
JsonLinkParse::Parsed(parsed) => Some(CalcArg::Link(Box::new(parsed))),
JsonLinkParse::NotJson | JsonLinkParse::Rejected(_) => None,
},
serde_json::Value::String(name) => Some(CalcArg::Link(Box::new(parse_link_field(
name,
LinkFieldType::In,
)))),
_ => None,
}
}
fn extract_pv_and_opts_from_subobject(body: &str) -> Option<(String, Vec<(String, JlinkValue)>)> {
let body = body.trim_start_matches('{').trim_end_matches('}').trim();
let mut pv: Option<String> = None;
let mut opts: Vec<(String, JlinkValue)> = Vec::new();
for entry in body.split(',') {
let entry = entry.trim();
if entry.is_empty() {
continue;
}
let (k, v) = entry.split_once(':')?;
let k_raw = k.trim().trim_matches('"');
let v_trimmed = v.trim().trim_matches(',').trim();
if v_trimmed.is_empty() {
continue;
}
if k_raw.eq_ignore_ascii_case("pv") {
let name = decode_json_string_token(v_trimmed).unwrap_or_else(|| v_trimmed.to_string());
if !name.is_empty() {
pv = Some(name);
}
} else if let Some(val) = classify_jlink_value(v_trimmed) {
opts.push((k_raw.to_string(), val));
}
}
Some((pv?, opts))
}
fn classify_jlink_value(raw: &str) -> Option<JlinkValue> {
let t = raw.trim();
if let Some(decoded) = decode_json_string_token(t) {
return Some(JlinkValue::Str(decoded));
}
match t {
"" => None,
"true" => Some(JlinkValue::Bool(true)),
"false" => Some(JlinkValue::Bool(false)),
"null" => Some(JlinkValue::Null),
_ => match t.parse::<i64>() {
Ok(n) => Some(JlinkValue::Int(n)),
Err(_) => Some(JlinkValue::Str(t.to_string())),
},
}
}
fn try_parse_hw_link(s: &str) -> Option<ParsedLink> {
if s.is_empty() {
return None;
}
match s.as_bytes()[0] {
b'@' => Some(ParsedLink::Hw(HwLink::InstIo {
string: s[1..].to_string(),
})),
b'#' => hw_scan(&s[1..]).map(ParsedLink::Hw),
_ => None,
}
}
fn hw_scan(text: &str) -> Option<HwLink> {
let (head, parm) = match text.split_once('@') {
Some((head, parm)) => (head, Some(parm)),
None => (text, None),
};
let mut hwid = String::new();
let mut count = 0usize;
let mut nums = [0i64; 5];
let mut rest = head;
loop {
rest = rest.trim_start();
if rest.is_empty() {
break;
}
if count == 5 {
return None;
}
let mut chars = rest.chars();
let id = chars.next()?;
let (value, after) = scan_c_integer(chars.as_str())?;
nums[count] = value;
count += 1;
hwid.push(id);
rest = after;
}
let s = |i: usize| nums[i] as i16;
let u = |i: usize| nums[i] as u8;
let parm_string = || parm.unwrap_or_default().to_string();
Some(match hwid.as_str() {
"CS" => HwLink::VmeIo {
card: s(0),
signal: s(1),
parm: parm_string(),
},
"BCN" | "BCNA" | "BCNF" | "BCNAF" => HwLink::CamacIo {
b: s(0),
c: s(1),
n: s(2),
a: s(3),
f: s(4),
parm: parm_string(),
},
"RMDE" if parm.is_none() => HwLink::RfIo {
cryo: s(0),
micro: s(1),
dataset: s(2),
element: s(3),
},
"LACS" => HwLink::AbIo {
link: s(0),
adapter: s(1),
card: s(2),
signal: s(3),
parm: parm_string(),
},
"LA" => HwLink::GpibIo {
link: s(0),
addr: s(1),
parm: parm_string(),
},
"LNPS" => HwLink::BitbusIo {
link: u(0),
node: u(1),
port: u(2),
signal: u(3),
parm: parm_string(),
},
"LBG" => HwLink::BbgpibIo {
link: u(0),
bbaddr: u(1),
gpibaddr: u(2),
parm: parm_string(),
},
"VCS" => HwLink::VxiIo {
addr: VxiAddr::Dynamic {
frame: s(0),
slot: s(1),
},
signal: s(2),
parm: parm_string(),
},
"VS" => HwLink::VxiIo {
addr: VxiAddr::Static { la: s(0) },
signal: s(1),
parm: parm_string(),
},
_ => return None,
})
}
fn scan_c_integer(s: &str) -> Option<(i64, &str)> {
let s = s.trim_start();
let (negative, body) = match s.strip_prefix('-') {
Some(rest) => (true, rest),
None => (false, s.strip_prefix('+').unwrap_or(s)),
};
let (radix, digits) = if let Some(hex) = body
.strip_prefix("0x")
.or_else(|| body.strip_prefix("0X"))
.filter(|hex| hex.starts_with(|c: char| c.is_ascii_hexdigit()))
{
(16, hex)
} else if let Some(octal) = body.strip_prefix('0') {
(8, octal)
} else {
(10, body)
};
let end = digits
.find(|c: char| !c.is_digit(radix))
.unwrap_or(digits.len());
if end == 0 {
return if radix == 8 { Some((0, digits)) } else { None };
}
let magnitude = i64::from_str_radix(&digits[..end], radix).unwrap_or(i64::MAX);
Some((
if negative {
magnitude.wrapping_neg()
} else {
magnitude
},
&digits[end..],
))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
enum ProcessClass {
#[default]
Npp,
Cpp,
Pp,
Ca,
Cp,
}
impl ProcessClass {
fn resolve(self) -> (LinkProcessPolicy, bool) {
match self {
Self::Npp => (LinkProcessPolicy::NoProcess, false),
Self::Cpp => (LinkProcessPolicy::ChannelProcessPassive, false),
Self::Pp => (LinkProcessPolicy::ProcessPassive, false),
Self::Ca => (LinkProcessPolicy::NoProcess, true),
Self::Cp => (LinkProcessPolicy::ChannelProcess, false),
}
}
fn is_cp_or_cpp(self) -> bool {
matches!(self, Self::Cp | Self::Cpp)
}
fn from_modifier_text(mods: &str) -> Self {
if mods.contains("NPP") {
Self::Npp
} else if mods.contains("CPP") {
Self::Cpp
} else if mods.contains("PP") {
Self::Pp
} else if mods.contains("CA") {
Self::Ca
} else if mods.contains("CP") {
Self::Cp
} else {
Self::Npp
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LinkFieldType {
In,
Out,
Fwd,
}
impl LinkFieldType {
pub fn for_class(class: DbfLinkClass) -> Self {
match class {
DbfLinkClass::InLink => Self::In,
DbfLinkClass::OutLink => Self::Out,
DbfLinkClass::FwdLink => Self::Fwd,
}
}
fn mask(self, class: ProcessClass, ms: MonitorSwitch) -> (ProcessClass, MonitorSwitch) {
match self {
Self::In => (class, ms),
Self::Out => {
let class = if class.is_cp_or_cpp() {
ProcessClass::Npp
} else {
class
};
(class, ms)
}
Self::Fwd => {
let class = if class == ProcessClass::Ca {
ProcessClass::Ca
} else {
ProcessClass::Npp
};
(class, MonitorSwitch::NoMaximize)
}
}
}
}
pub fn out_link_discards_cp(s: &str) -> bool {
let s = s.trim();
if s.is_empty() || s.starts_with(['{', '@', '#', '"']) {
return false;
}
let Some((_, mods)) = s.split_once(' ') else {
return false;
};
ProcessClass::from_modifier_text(mods).is_cp_or_cpp()
}
fn split_link_modifiers(
s: &str,
ftype: LinkFieldType,
) -> (&str, LinkProcessPolicy, MonitorSwitch, bool) {
let Some((target, mods)) = s.split_once(' ') else {
let (policy, force_ca) = ProcessClass::default().resolve();
return (s, policy, MonitorSwitch::NoMaximize, force_ca);
};
let class = ProcessClass::from_modifier_text(mods);
let ms = if mods.contains("NMS") {
MonitorSwitch::NoMaximize
} else if mods.contains("MSI") {
MonitorSwitch::MaximizeIfInvalid
} else if mods.contains("MSS") {
MonitorSwitch::MaximizeStatus
} else if mods.contains("MS") {
MonitorSwitch::Maximize
} else {
MonitorSwitch::NoMaximize
};
let (class, ms) = ftype.mask(class, ms);
let (policy, force_ca) = class.resolve();
(target, policy, ms, force_ca)
}
pub fn parse_link_v2(s: &str) -> ParsedLink {
parse_link_field(s, LinkFieldType::In)
}
pub fn parse_link_field(s: &str, ftype: LinkFieldType) -> ParsedLink {
let s = s.trim();
match parse_json_link(s) {
JsonLinkParse::Parsed(ParsedLink::State(_)) if ftype == LinkFieldType::Fwd => {
return ParsedLink::None;
}
JsonLinkParse::Parsed(parsed) => return parsed,
JsonLinkParse::Rejected(_) => return ParsedLink::None,
JsonLinkParse::NotJson => {}
}
if let Some(parsed) = try_parse_hw_link(s) {
return parsed;
}
if s.is_empty() {
return ParsedLink::None;
}
if s.starts_with('#') {
return ParsedLink::None;
}
if let Some(rest) = s.strip_prefix("ca://") {
let (pv, policy, ms, _force_ca) = split_link_modifiers(rest, ftype);
return ParsedLink::Ca(CaLink {
pv: pv.to_string(),
monitor_switch: ms,
policy,
});
}
if let Some(rest) = s.strip_prefix("pva://") {
return ParsedLink::Pva(rest.to_string());
}
if parse_c_double(s).is_some() {
return ParsedLink::Constant(s.to_string());
}
if s.starts_with('[') && s.ends_with(']') && s.len() >= 2 {
return ParsedLink::Constant(s.to_string());
}
let (link_part, policy, ms, force_ca) = split_link_modifiers(s, ftype);
if force_ca {
return ParsedLink::Ca(CaLink {
pv: link_part.to_string(),
monitor_switch: ms,
policy,
});
}
ParsedLink::db(DbLink::new(link_part, policy, ms))
}
fn split_record_field(link_part: &str) -> Option<(&str, String)> {
let (rec, field) = link_part.split_once('.')?;
let mut chars = field.chars();
let first = chars.next()?;
if !(first.is_ascii_alphabetic() || first == '_') {
return None;
}
if !chars.all(|c| c.is_ascii_alphanumeric() || c == '_') {
return None;
}
Some((rec, field.to_ascii_uppercase()))
}
pub fn parse_output_link_v2(s: &str) -> ParsedLink {
parse_link_field(s, LinkFieldType::Out)
}
pub fn parse_forward_link_v2(s: &str) -> ParsedLink {
parse_link_field(s, LinkFieldType::Fwd)
}
pub fn link_field_type(s: &str) -> LinkType {
parse_link_v2(s).link_type()
}
#[cfg(test)]
mod json_link_tests {
use super::*;
#[test]
fn json_const_numeric() {
assert_eq!(
parse_link_v2("{const: 1.5}"),
ParsedLink::Constant("1.5".to_string())
);
}
#[test]
fn json_const_quoted_string() {
assert_eq!(
parse_link_v2(r#"{const: "hello"}"#),
ParsedLink::Constant("hello".to_string())
);
}
#[test]
fn json_const_empty_is_none() {
assert_eq!(parse_link_v2(r#"{const: ""}"#), ParsedLink::None);
}
#[test]
fn json_ca_link() {
assert_eq!(
parse_link_v2(r#"{ca: { pv: "FOO" }}"#),
ParsedLink::Ca(CaLink::new("FOO"))
);
}
#[test]
fn json_pva_link() {
assert_eq!(
parse_link_v2(r#"{pva: { pv: "FOO:bar" }}"#),
ParsedLink::Pva("FOO:bar".to_string())
);
}
#[test]
fn json_ca_link_unquoted_key() {
assert_eq!(
parse_link_v2(r#"{ca: { pv: 'BAR' }}"#),
ParsedLink::Ca(CaLink::new("BAR"))
);
}
#[test]
fn json_pva_link_string_shorthand() {
assert_eq!(
parse_link_v2(r#"{pva: "TARGET:AI"}"#),
ParsedLink::Pva("TARGET:AI".to_string())
);
}
#[test]
fn json_ca_link_string_shorthand() {
assert_eq!(
parse_link_v2(r#"{ca: "TARGET:AI"}"#),
ParsedLink::Ca(CaLink::new("TARGET:AI"))
);
}
#[test]
fn json_pva_link_string_shorthand_single_quotes() {
assert_eq!(
parse_link_v2(r#"{pva: 'invalid:pv:name'}"#),
ParsedLink::Pva("invalid:pv:name".to_string())
);
}
#[test]
fn json_pva_link_longhand_preserves_options_structurally() {
assert_eq!(
parse_link_v2(r#"{pva: { pv: "FOO:bar", Q: "4" }}"#),
ParsedLink::PvaJson(PvaJsonLink {
pv: "FOO:bar".to_string(),
options: vec![("Q".to_string(), JlinkValue::Str("4".to_string()))],
})
);
}
#[test]
fn json_pva_link_longhand_preserves_value_kind() {
let link = parse_link_v2(
r#"{pva: {pv: "X:AI", pipeline: true, retry: false, proc: "CP", Q: 4, sevr: true}}"#,
);
let j = match link {
ParsedLink::PvaJson(j) => j,
other => panic!("expected PvaJson, got {other:?}"),
};
assert_eq!(
j.options,
vec![
("pipeline".to_string(), JlinkValue::Bool(true)),
("retry".to_string(), JlinkValue::Bool(false)),
("proc".to_string(), JlinkValue::Str("CP".to_string())),
("Q".to_string(), JlinkValue::Int(4)),
("sevr".to_string(), JlinkValue::Bool(true)),
]
);
}
#[test]
fn json_pva_link_root_nonstring_rejected() {
assert_eq!(
parse_link_v2(r#"{pva: "TARGET"}"#),
ParsedLink::Pva("TARGET".to_string())
);
assert_eq!(
parse_link_v2(r#"{pva: { pv: "TARGET" }}"#),
ParsedLink::Pva("TARGET".to_string())
);
for src in [
r#"{pva: true}"#,
r#"{pva: false}"#,
r#"{pva: 5}"#,
r#"{pva: null}"#,
r#"{pva: [1,2]}"#,
] {
assert_eq!(
parse_link_v2(src),
ParsedLink::None,
"non-string pva root must not become any link: {src}"
);
}
for src in [
r#"{ca: true}"#,
r#"{ca: 5}"#,
r#"{ca: null}"#,
r#"{ca: [1,2]}"#,
] {
assert_eq!(
parse_link_v2(src),
ParsedLink::None,
"non-string ca root must not become any link: {src}"
);
}
}
fn hw(text: &str) -> HwLink {
match parse_link_v2(text) {
ParsedLink::Hw(hw) => hw,
other => panic!("expected Hw for {text:?}, got {other:?}"),
}
}
#[test]
fn an_inst_io_link_keeps_its_string_byte_for_byte() {
assert_eq!(
hw("@simDriver 0 INPUT"),
HwLink::InstIo {
string: "simDriver 0 INPUT".into()
}
);
assert_eq!(hw("@dev 0xFF mask=0x1A").render(), "@dev 0xFF mask=0x1A");
assert_eq!(hw("@ dev two ").render(), "@ dev two");
assert_eq!(
hw("@"),
HwLink::InstIo {
string: String::new()
}
);
}
#[test]
fn a_vme_link_reads_back_from_its_numbers_not_its_text() {
assert_eq!(
hw("#C0x10 S-2"),
HwLink::VmeIo {
card: 16,
signal: -2,
parm: String::new(),
}
);
assert_eq!(hw("#C0x10 S-2").render(), "#C16 S-2 @");
assert_eq!(hw("#C010 S2").render(), "#C8 S2 @");
assert_eq!(hw("#C1 S2 @sim").render(), "#C1 S2 @sim");
}
#[test]
fn identifier_letters_need_no_blank_between_the_pairs() {
assert_eq!(hw("#C1S2").render(), "#C1 S2 @");
assert_eq!(hw("# C 1 S 2").render(), "#C1 S2 @");
}
#[test]
fn numbers_the_scan_never_reached_are_zero() {
assert_eq!(hw("#B1 C2 N3").render(), "#B1 C2 N3 A0 F0 @");
assert_eq!(hw("#B1 C2 N3 A4").render(), "#B1 C2 N3 A4 F0 @");
assert_eq!(hw("#B1 C2 N3 A4 F5").render(), "#B1 C2 N3 A4 F5 @");
}
#[test]
fn the_camac_f_spelling_stores_its_number_in_a() {
assert_eq!(hw("#B1 C2 N3 F5").render(), "#B1 C2 N3 A5 F0 @");
}
#[test]
fn an_rf_link_carries_no_parm_at_all() {
assert_eq!(
hw("#R1 M2 D3 E4"),
HwLink::RfIo {
cryo: 1,
micro: 2,
dataset: 3,
element: 4,
}
);
assert_eq!(hw("#R1 M2 D3 E4").render(), "#R1 M2 D3 E4");
assert_eq!(parse_link_v2("#R1 M2 D3 E4 @x"), ParsedLink::None);
}
#[test]
fn every_remaining_bus_renders_its_own_format() {
assert_eq!(hw("#L1 A2 C3 S4").render(), "#L1 A2 C3 S4 @");
assert_eq!(hw("#L1 A2 @gpib").render(), "#L1 A2 @gpib");
assert_eq!(hw("#L1 N2 P3 S4").render(), "#L1 N2 P3 S4 @");
assert_eq!(hw("#L1 B2 G3").render(), "#L1 B2 G3 @");
}
#[test]
fn a_vxi_link_is_addressed_by_slot_or_by_logical_address() {
assert_eq!(
hw("#V1 C2 S3"),
HwLink::VxiIo {
addr: VxiAddr::Dynamic { frame: 1, slot: 2 },
signal: 3,
parm: String::new(),
}
);
assert_eq!(hw("#V1 C2 S3").render(), "#V1 C2 S3 @");
assert_eq!(
hw("#V7 S3 @p"),
HwLink::VxiIo {
addr: VxiAddr::Static { la: 7 },
signal: 3,
parm: "p".into(),
}
);
assert_eq!(hw("#V7 S3 @p").render(), "#V7 S3 @p");
}
#[test]
fn a_number_wider_than_its_bus_member_truncates_where_c_truncates() {
assert_eq!(hw("#C70000 S1").render(), "#C4464 S1 @");
assert_eq!(hw("#L300 N2 P3 S-1").render(), "#L44 N2 P3 S255 @");
assert_eq!(hw("#L256 B1 G2").render(), "#L0 B1 G2 @");
}
#[test]
fn the_parm_is_whatever_follows_the_first_at_sign() {
assert_eq!(hw("#C1 S2 @ a b ").render(), "#C1 S2 @ a b");
assert_eq!(hw("#C1 S2@x@y").render(), "#C1 S2 @x@y");
assert_eq!(hw("#C1 S2 @").render(), "#C1 S2 @");
}
#[test]
fn a_hash_form_the_identifier_table_rejects_is_not_a_link() {
for src in [
"#XY1 Z2", "#C1 S", "#C1 S2 J3", "#A1 B2 C3 D4 E5 F", "#C1 S2 junk",
"#",
"#C1 2",
] {
assert_eq!(
parse_link_v2(src),
ParsedLink::None,
"{src:?} must not become a link"
);
}
}
#[test]
fn link_type_constant_numeric() {
assert_eq!(link_field_type("3.14"), LinkType::Constant);
assert_eq!(link_field_type("{const: 7}"), LinkType::Constant);
}
#[test]
fn link_type_quoted_string_is_a_pv_link() {
assert_eq!(link_field_type(r#""hello""#), LinkType::Db);
assert_eq!(link_field_type(r#""""#), LinkType::Db);
}
#[test]
fn link_type_empty_is_empty() {
assert_eq!(link_field_type(""), LinkType::Empty);
assert_eq!(link_field_type(" "), LinkType::Empty);
}
#[test]
fn link_type_db_link() {
assert_eq!(link_field_type("REC.VAL"), LinkType::Db);
assert_eq!(link_field_type("REC"), LinkType::Db);
assert_eq!(link_field_type("REC.VAL PP"), LinkType::Db);
}
#[test]
fn link_type_ca_link() {
assert_eq!(link_field_type("ca://REMOTE:PV"), LinkType::Ca);
assert_eq!(link_field_type("pva://REMOTE:PV"), LinkType::Ca);
assert_eq!(link_field_type(r#"{ca: { pv: "REMOTE" }}"#), LinkType::Ca);
}
#[test]
fn a_comment_inside_a_link_body_parses() {
let JsonLinkParse::Parsed(ParsedLink::Calc(link)) =
parse_json_link(r#"{calc: {expr:"A*B" /* the product */, args:[2, 3]}}"#)
else {
panic!("a comment in a link body must not stop the parse");
};
assert_eq!(link.expr, "A*B");
assert_eq!(link.args.len(), 2);
}
#[test]
fn base_s_own_shipped_calc_link_parses_verbatim() {
let JsonLinkParse::Parsed(ParsedLink::Calc(link)) = parse_json_link(
"{calc:{\n expr:'A+5',\n args:5\n }}",
) else {
panic!("base's own linkRetargetLink.db calc link must parse");
};
assert_eq!(link.expr, "A+5");
assert_eq!(link.args.len(), 1, "`args:5` is ONE argument in C");
}
#[test]
fn a_scalar_args_value_is_one_argument() {
for body in [
"{calc:{expr:\"A+5\", args:5}}",
"{calc:{expr:\"A+5\", args:[5]}}",
] {
let JsonLinkParse::Parsed(ParsedLink::Calc(link)) = parse_json_link(body) else {
panic!("{body} must parse");
};
assert!(
matches!(link.args.as_slice(), [CalcArg::Literal(v)] if *v == 5.0),
"{body} -> {:?}",
link.args.len()
);
}
let JsonLinkParse::Parsed(ParsedLink::Calc(link)) =
parse_json_link(r#"{calc:{expr:"A", args:{pva:"REC:AI"}}}"#)
else {
panic!("a bare embedded link in `args` must parse");
};
assert!(matches!(link.args.as_slice(), [CalcArg::Link(_)]));
}
#[test]
fn a_single_quoted_pv_name_is_dequoted() {
let JsonLinkParse::Parsed(ParsedLink::Pva(pv)) =
parse_json_link(r#"{pva: {pv: 'REC:AI.VAL'}}"#)
else {
panic!("a single-quoted pv name must parse");
};
assert_eq!(pv, "REC:AI.VAL");
let JsonLinkParse::Parsed(ParsedLink::Pva(pv)) = parse_json_link(r"{pva: 'REC:AI.VAL'}")
else {
panic!("the single-quoted shorthand must parse");
};
assert_eq!(pv, "REC:AI.VAL");
}
#[test]
fn a_comment_before_the_link_type_key_parses() {
let JsonLinkParse::Parsed(ParsedLink::Calc(link)) =
parse_json_link(r#"{/* which */ calc: {expr:"A+1"}}"#)
else {
panic!("a comment before the link type must not stop the parse");
};
assert_eq!(link.expr, "A+1");
}
#[test]
fn an_unterminated_comment_in_a_link_body_is_rejected() {
assert!(matches!(
parse_json_link(r#"{calc: {expr:"A*B" /* never closed }}"#),
JsonLinkParse::Rejected(_)
));
}
#[test]
fn link_type_hw_and_calc_are_other() {
assert_eq!(link_field_type("@dev 0 IN"), LinkType::Other);
assert_eq!(link_field_type("#C0 S2"), LinkType::Other);
assert_eq!(
link_field_type(r#"{calc: {"expr": "A+1", "args": ["pv1"]}}"#),
LinkType::Other
);
}
#[test]
fn ca_modifier_classifies_as_ca() {
assert_eq!(
parse_link_v2("REC.FIELD CA"),
ParsedLink::Ca(CaLink::new("REC.FIELD"))
);
assert_eq!(link_field_type("REC.FIELD CA"), LinkType::Ca);
}
#[test]
fn ca_modifier_bare_pv_name() {
assert_eq!(
parse_link_v2("localPv CA"),
ParsedLink::Ca(CaLink::new("localPv"))
);
assert_eq!(link_field_type("localPv CA"), LinkType::Ca);
}
#[test]
fn ca_modifier_combined_with_ms() {
assert_eq!(
parse_link_v2("REC.VAL CA MS"),
ParsedLink::Ca(CaLink {
pv: "REC.VAL".to_string(),
monitor_switch: MonitorSwitch::Maximize,
policy: LinkProcessPolicy::NoProcess,
})
);
assert_eq!(
parse_link_v2("REC.VAL CA NMS"),
ParsedLink::Ca(CaLink {
pv: "REC.VAL".to_string(),
monitor_switch: MonitorSwitch::NoMaximize,
policy: LinkProcessPolicy::NoProcess,
})
);
assert_eq!(link_field_type("REC.VAL CA NMS"), LinkType::Ca);
}
#[test]
fn process_class_precedence_is_chain_order_not_token_order() {
assert_eq!(
parse_link_v2("REC CP NPP"),
ParsedLink::db(DbLink::new(
"REC",
LinkProcessPolicy::NoProcess,
MonitorSwitch::NoMaximize,
))
);
assert_eq!(
parse_link_v2("OTHER:PV CP CA"),
ParsedLink::Ca(CaLink {
pv: "OTHER:PV".to_string(),
monitor_switch: MonitorSwitch::NoMaximize,
policy: LinkProcessPolicy::NoProcess,
})
);
assert_eq!(
parse_link_v2("REC PP CA"),
ParsedLink::db(DbLink::new(
"REC",
LinkProcessPolicy::ProcessPassive,
MonitorSwitch::NoMaximize,
))
);
assert!(matches!(
parse_link_v2("REC CPP"),
ParsedLink::Db(l) if l.policy == LinkProcessPolicy::ChannelProcessPassive
));
assert!(matches!(
parse_link_v2("REC CP"),
ParsedLink::Db(l) if l.policy == LinkProcessPolicy::ChannelProcess
));
assert!(matches!(
parse_link_v2("REC"),
ParsedLink::Db(l) if l.policy == LinkProcessPolicy::NoProcess
));
}
#[test]
fn modifiers_are_substring_matched_not_space_delimited() {
assert_eq!(
parse_link_v2("REC QQCPPXMSITT"),
ParsedLink::db(DbLink::new(
"REC",
LinkProcessPolicy::ChannelProcessPassive,
MonitorSwitch::MaximizeIfInvalid,
))
);
}
#[test]
fn maximize_switch_precedence() {
for (link, expect) in [
("REC NMS", MonitorSwitch::NoMaximize),
("REC MSI", MonitorSwitch::MaximizeIfInvalid),
("REC MSS", MonitorSwitch::MaximizeStatus),
("REC MS", MonitorSwitch::Maximize),
("REC MS NMS", MonitorSwitch::NoMaximize),
] {
match parse_link_v2(link) {
ParsedLink::Db(db) => assert_eq!(db.monitor_switch, expect, "link {link}"),
other => panic!("link {link}: expected Db, got {other:?}"),
}
}
}
#[test]
fn ca_scheme_link_parses_ms_modifier() {
assert_eq!(
parse_link_v2("ca://SR:DCCT MS"),
ParsedLink::Ca(CaLink {
pv: "SR:DCCT".to_string(),
monitor_switch: MonitorSwitch::Maximize,
policy: LinkProcessPolicy::NoProcess,
})
);
assert_eq!(
parse_link_v2("ca://SR:DCCT MSI"),
ParsedLink::Ca(CaLink {
pv: "SR:DCCT".to_string(),
monitor_switch: MonitorSwitch::MaximizeIfInvalid,
policy: LinkProcessPolicy::NoProcess,
})
);
assert_eq!(
parse_link_v2("ca://SR:DCCT MSS"),
ParsedLink::Ca(CaLink {
pv: "SR:DCCT".to_string(),
monitor_switch: MonitorSwitch::MaximizeStatus,
policy: LinkProcessPolicy::NoProcess,
})
);
assert_eq!(
parse_link_v2("ca://SR:DCCT"),
ParsedLink::Ca(CaLink::new("SR:DCCT"))
);
}
#[test]
fn ca_modifier_does_not_affect_plain_db_link() {
assert_eq!(link_field_type("camera.VAL"), LinkType::Db);
assert_eq!(link_field_type("REC.VAL PP"), LinkType::Db);
}
#[test]
fn br_r10_json_pva_options_preserved_in_parsed_link() {
let link = parse_link_v2(
r#"{pva: {pv: "TARGET:AI", field: "display.precision", proc: "CPP", sevr: "MS", Q: 8}}"#,
);
let j = match link {
ParsedLink::PvaJson(j) => j,
other => panic!("expected PvaJson, got {other:?}"),
};
assert_eq!(j.pv, "TARGET:AI", "pv must be the bare channel name");
assert!(
!j.pv.contains('?'),
"pv must not carry a `?` query: {}",
j.pv
);
assert_eq!(
j.options,
vec![
(
"field".to_string(),
JlinkValue::Str("display.precision".to_string())
),
("proc".to_string(), JlinkValue::Str("CPP".to_string())),
("sevr".to_string(), JlinkValue::Str("MS".to_string())),
("Q".to_string(), JlinkValue::Int(8)),
]
);
}
#[test]
fn json_pva_string_shorthand_keeps_query_chars_verbatim() {
assert_eq!(
parse_link_v2(r#"{pva: "TARGET:AI?field=x"}"#),
ParsedLink::Pva("TARGET:AI?field=x".to_string())
);
}
#[test]
fn pva_scheme_keeps_query_chars_verbatim() {
assert_eq!(
parse_link_v2("pva://TARGET:AI?field=x"),
ParsedLink::Pva("TARGET:AI?field=x".to_string())
);
}
#[test]
fn pva_json_external_pv_name() {
let link = parse_link_v2(r#"{pva: {pv: "TARGET:AI", proc: "CP"}}"#);
let key = link.external_pv_name().expect("PvaJson carries a key");
assert_eq!(key.as_ref(), "pva://TARGET:AI\u{1f}proc=s:CP");
assert_eq!(link.link_type(), LinkType::Ca);
assert!(link.is_writable_out_link());
}
#[test]
fn pva_json_identity_key_separates_same_pv_links() {
let a = match parse_link_v2(r#"{pva: {pv: "SRC", field: "value", Q: 64}}"#) {
ParsedLink::PvaJson(j) => j,
other => panic!("expected PvaJson, got {other:?}"),
};
let b = match parse_link_v2(r#"{pva: {pv: "SRC", field: "alarm.severity", Q: 1}}"#) {
ParsedLink::PvaJson(j) => j,
other => panic!("expected PvaJson, got {other:?}"),
};
assert_ne!(
a.link_identity_key(),
b.link_identity_key(),
"same-PV links with different options must not collide"
);
assert_eq!(a.link_identity_key().split('\u{1f}').next(), Some("SRC"));
assert_eq!(b.link_identity_key().split('\u{1f}').next(), Some("SRC"));
let c = match parse_link_v2(r#"{pva: {pv: "SRC", Q: 64, field: "value"}}"#) {
ParsedLink::PvaJson(j) => j,
other => panic!("expected PvaJson, got {other:?}"),
};
assert_eq!(a.link_identity_key(), c.link_identity_key());
}
#[test]
fn br_r10_json_pva_bare_pv_unchanged() {
assert_eq!(
parse_link_v2(r#"{pva: { pv: "FOO:bar" }}"#),
ParsedLink::Pva("FOO:bar".to_string())
);
}
#[test]
fn json_unknown_key_falls_through_to_legacy() {
let result = parse_link_v2("{unknown: 42}");
assert!(matches!(
result,
ParsedLink::None | ParsedLink::Db(_) | ParsedLink::Constant(_)
));
}
#[test]
fn parse_output_link_bare_is_noprocess() {
match parse_output_link_v2("TARGET.VAL") {
ParsedLink::Db(db) => {
assert_eq!(db.policy, LinkProcessPolicy::NoProcess);
assert_eq!(db.record, "TARGET");
assert_eq!(db.field, "VAL");
}
other => panic!("expected Db link, got {other:?}"),
}
}
#[test]
fn parse_output_link_explicit_pp_processes() {
match parse_output_link_v2("TARGET.VAL PP") {
ParsedLink::Db(db) => {
assert_eq!(db.policy, LinkProcessPolicy::ProcessPassive);
}
other => panic!("expected Db link, got {other:?}"),
}
}
#[test]
fn parse_output_link_proc_field_is_noprocess() {
match parse_output_link_v2("TARGET.PROC") {
ParsedLink::Db(db) => {
assert_eq!(db.field, "PROC");
assert_eq!(db.policy, LinkProcessPolicy::NoProcess);
}
other => panic!("expected Db link, got {other:?}"),
}
}
#[test]
fn parse_input_link_bare_is_noprocess() {
match parse_link_v2("SRC.VAL") {
ParsedLink::Db(db) => {
assert_eq!(db.policy, LinkProcessPolicy::NoProcess);
assert_eq!(db.record, "SRC");
assert_eq!(db.field, "VAL");
}
other => panic!("expected Db link, got {other:?}"),
}
match parse_link_v2("SRC.VAL PP") {
ParsedLink::Db(db) => assert_eq!(db.policy, LinkProcessPolicy::ProcessPassive),
other => panic!("expected Db link, got {other:?}"),
}
}
#[test]
fn parse_output_link_explicit_npp_is_noprocess() {
match parse_output_link_v2("TARGET.VAL NPP") {
ParsedLink::Db(db) => {
assert_eq!(db.policy, LinkProcessPolicy::NoProcess);
}
other => panic!("expected Db link, got {other:?}"),
}
}
#[test]
fn parse_cp_and_cpp_are_distinct_policies() {
match parse_link_v2("SRC.VAL CP") {
ParsedLink::Db(db) => assert_eq!(db.policy, LinkProcessPolicy::ChannelProcess),
other => panic!("expected Db link for CP, got {other:?}"),
}
match parse_link_v2("SRC.VAL CPP") {
ParsedLink::Db(db) => {
assert_eq!(db.policy, LinkProcessPolicy::ChannelProcessPassive)
}
other => panic!("expected Db link for CPP, got {other:?}"),
}
}
#[test]
fn cp_passive_only_maps_cp_and_cpp() {
assert_eq!(
LinkProcessPolicy::ChannelProcess.cp_passive_only(),
Some(false)
);
assert_eq!(
LinkProcessPolicy::ChannelProcessPassive.cp_passive_only(),
Some(true)
);
assert_eq!(LinkProcessPolicy::ProcessPassive.cp_passive_only(), None);
assert_eq!(LinkProcessPolicy::NoProcess.cp_passive_only(), None);
}
#[test]
fn out_link_discards_cp_and_cpp_but_keeps_the_other_modifiers() {
for text in ["TARGET.VAL CP", "TARGET.VAL CPP", "TARGET CP MS"] {
match parse_output_link_v2(text) {
ParsedLink::Db(db) => {
assert_eq!(
db.policy,
LinkProcessPolicy::NoProcess,
"{text} must lose its CP/CPP class"
);
assert_eq!(
db.policy.cp_passive_only(),
None,
"{text} must not be a CP holder"
);
}
other => panic!("expected Db link for {text}, got {other:?}"),
}
}
match parse_output_link_v2("TARGET CP MS") {
ParsedLink::Db(db) => assert_eq!(db.monitor_switch, MonitorSwitch::Maximize),
other => panic!("expected Db link, got {other:?}"),
}
match parse_output_link_v2("TARGET PP CP") {
ParsedLink::Db(db) => assert_eq!(db.policy, LinkProcessPolicy::ProcessPassive),
other => panic!("expected Db link, got {other:?}"),
}
assert!(matches!(
parse_output_link_v2("OTHER:PV CA"),
ParsedLink::Ca(_)
));
}
#[test]
fn out_link_discards_cp_predicate_tracks_the_mask() {
for text in ["TARGET CP", "TARGET CPP", "TARGET CP MS", "TARGET MSI CPP"] {
assert!(out_link_discards_cp(text), "{text} loses its CP/CPP");
}
for text in [
"TARGET", "TARGET PP", "TARGET CA", "TARGET NPP", "TARGET PP CP", "TARGETCP", "\"CP\"", "{ca: {pv: \"X\"}}", ] {
assert!(!out_link_discards_cp(text), "{text} discards nothing");
}
}
#[test]
fn fwd_link_masks_everything_except_ca() {
for text in ["NEXT PP", "NEXT CP", "NEXT CPP", "NEXT NPP"] {
match parse_forward_link_v2(text) {
ParsedLink::Db(db) => {
assert_eq!(db.policy, LinkProcessPolicy::NoProcess, "{text}");
assert_eq!(db.monitor_switch, MonitorSwitch::NoMaximize, "{text}");
}
other => panic!("expected Db link for {text}, got {other:?}"),
}
}
match parse_forward_link_v2("NEXT MS") {
ParsedLink::Db(db) => assert_eq!(db.monitor_switch, MonitorSwitch::NoMaximize),
other => panic!("expected Db link, got {other:?}"),
}
match parse_forward_link_v2("OTHER:REC CA MS") {
ParsedLink::Ca(ca) => {
assert_eq!(ca.pv, "OTHER:REC");
assert_eq!(ca.monitor_switch, MonitorSwitch::NoMaximize);
}
other => panic!("expected Ca link, got {other:?}"),
}
}
#[test]
fn a_state_link_is_a_name_plus_a_sense() {
let plain = parse_link_field(r#"{state:"GREEN"}"#, LinkFieldType::In);
assert_eq!(
plain,
ParsedLink::State(StateLink {
name: "GREEN".into(),
invert: false
})
);
let inverted = parse_link_field(r#"{state:"!GREEN"}"#, LinkFieldType::In);
assert_eq!(
inverted,
ParsedLink::State(StateLink {
name: "GREEN".into(),
invert: true
})
);
}
#[test]
fn a_lone_bang_is_a_state_name_not_an_inversion() {
assert_eq!(
parse_link_field(r#"{state:"!"}"#, LinkFieldType::In),
ParsedLink::State(StateLink {
name: "!".into(),
invert: false
})
);
assert_eq!(
parse_link_field(r#"{state:""}"#, LinkFieldType::In),
ParsedLink::State(StateLink {
name: String::new(),
invert: false
})
);
}
#[test]
fn only_a_string_value_builds_a_state_link() {
for body in [
r#"{state:1}"#,
r#"{state:true}"#,
r#"{state:null}"#,
r#"{state:1.5}"#,
r#"{state:{name:"X"}}"#,
r#"{state:["X"]}"#,
] {
assert!(
matches!(parse_json_link(body), JsonLinkParse::Rejected(_)),
"C stops the parse for {body}"
);
}
}
#[test]
fn a_state_link_is_refused_on_a_forward_link() {
assert_eq!(
parse_link_field(r#"{state:"GREEN"}"#, LinkFieldType::Fwd),
ParsedLink::None
);
for ftype in [LinkFieldType::In, LinkFieldType::Out] {
assert!(matches!(
parse_link_field(r#"{state:"GREEN"}"#, ftype),
ParsedLink::State(_)
));
}
}
#[test]
fn inversion_is_one_flag_on_the_link_not_one_per_direction() {
let inverted = StateLink {
name: "X".into(),
invert: true,
};
for wanted in [false, true] {
let stored = inverted.write(wanted);
assert_eq!(inverted.read(stored), wanted);
}
let plain = StateLink {
name: "X".into(),
invert: false,
};
assert!(plain.write(true), "no inversion: the bit passes through");
assert!(plain.read(true), "no inversion: the bit passes through");
}
#[test]
fn a_string_is_false_only_when_it_is_empty_or_exactly_zero() {
use crate::types::EpicsValue as V;
let f = |s: &str| StateLink::truth_of(&V::String(s.into()));
assert_eq!(f(""), Some(false));
assert_eq!(f("0"), Some(false));
assert_eq!(f("0.0"), Some(true));
assert_eq!(f("00"), Some(true));
assert_eq!(f("hello"), Some(true));
assert_eq!(StateLink::truth_of(&V::Short(0)), Some(false));
assert_eq!(StateLink::truth_of(&V::Short(-1)), Some(true));
assert_eq!(StateLink::truth_of(&V::Double(0.0)), Some(false));
assert_eq!(StateLink::truth_of(&V::Double(-0.5)), Some(true));
assert_eq!(StateLink::truth_of(&V::Char(0)), Some(false));
}
#[test]
fn a_state_link_is_a_json_link() {
let link = parse_link_field(r#"{state:"GREEN"}"#, LinkFieldType::In);
assert_eq!(link.db_link_type(), DbLinkType::JsonLink);
assert!(
PORTED_JSON_LINK_TYPES
.iter()
.any(|s| s.key == "state" && s.jlif_name == "lnkStateIf"),
"`link(state, lnkStateIf)` is `links.dbd.pod:171`"
);
}
}