use std::collections::{HashMap, HashSet};
use std::sync::{Mutex, PoisonError, RwLock};
use indexmap::IndexMap;
use serde_json::{Map, Value as Json};
use crate::config::{Config, py_repr};
pub type Settings = Map<String, Json>;
const RULE_KEYS: [&str; 9] = [
"writable",
"min",
"max",
"units",
"description",
"deadband",
"always",
"bits",
"together",
];
const INHERITED: [&str; 2] = ["rate_ms", "together"];
const NOT_FOR_BITS: [&str; 6] = ["min", "max", "deadband", "units", "bits", "together"];
fn is_decorator_key(key: &str) -> bool {
RULE_KEYS.contains(&key) || key == "rate_ms"
}
pub fn truthy(value: Option<&Json>) -> bool {
match value {
None | Some(Json::Null) => false,
Some(Json::Bool(b)) => *b,
Some(Json::Number(n)) => n.as_f64().is_some_and(|f| f != 0.0),
Some(Json::String(s)) => !s.is_empty(),
Some(Json::Array(a)) => !a.is_empty(),
Some(Json::Object(o)) => !o.is_empty(),
}
}
pub fn split_bit(tag: &str) -> Option<(&str, &str)> {
let (word, bit) = tag.rsplit_once('.')?;
(!word.is_empty() && !bit.is_empty() && bit.bytes().all(|b| b.is_ascii_digit()))
.then_some((word, bit))
}
fn map_indexes(path: &str, f: impl Fn(&str) -> Option<String>) -> String {
let mut out = String::with_capacity(path.len());
let mut rest = path;
while let Some(open) = rest.find('[') {
let Some(close) = rest[open..].find(']').map(|c| open + c) else {
break;
};
out.push_str(&rest[..open]);
match f(&rest[open + 1..close]) {
Some(text) => out.push_str(&text),
None => out.push_str(&rest[open..=close]),
}
rest = &rest[close + 1..];
}
out.push_str(rest);
out
}
pub fn canonical(path: &str) -> String {
map_indexes(path, |inside| {
let numbers: Option<Vec<String>> = inside
.split(',')
.map(|i| i.trim().parse::<i64>().ok().map(|n| n.to_string()))
.collect();
numbers.map(|n| format!("[{}]", n.join(",")))
})
}
fn strip_indexes(path: &str) -> String {
map_indexes(path, |_| Some(String::new()))
}
pub fn normalize(path: &str) -> String {
canonical(path).to_lowercase()
}
pub fn glob_match(pattern: &str, name: &str) -> bool {
let p: Vec<char> = pattern.to_lowercase().chars().collect();
let n: Vec<char> = name.to_lowercase().chars().collect();
let (mut pi, mut ni) = (0, 0);
let mut star: Option<(usize, usize)> = None;
while ni < n.len() {
if pi < p.len() && (p[pi] == '?' || (p[pi] != '*' && p[pi] == n[ni])) {
pi += 1;
ni += 1;
} else if pi < p.len() && p[pi] == '*' {
star = Some((pi, ni));
pi += 1;
} else if let Some((sp, sn)) = star {
pi = sp + 1;
ni = sn + 1;
star = Some((sp, sn + 1));
} else {
return false;
}
}
p[pi..].iter().all(|&c| c == '*')
}
pub fn patterns(table: &Map<String, Json>) -> Vec<&str> {
match table.get("match") {
Some(Json::String(s)) => vec![s.as_str()],
Some(Json::Array(items)) => items.iter().filter_map(Json::as_str).collect(),
_ => Vec::new(),
}
}
fn matches(table: &Map<String, Json>, name: &str) -> bool {
patterns(table).iter().any(|p| glob_match(p, name))
}
fn parts(rest: &str) -> Vec<&str> {
let mut out = Vec::new();
let mut i = 0;
let bytes = rest.as_bytes();
while i < bytes.len() {
match bytes[i] {
b'[' => match rest[i..].find(']') {
Some(close) => {
out.push(&rest[i..=i + close]);
i += close + 1;
}
None => i += 1,
},
b'.' | b']' => i += 1,
_ => {
let end = rest[i..]
.find(['.', '[', ']'])
.map_or(rest.len(), |e| i + e);
out.push(&rest[i..end]);
i = end;
}
}
}
out
}
fn program_split(tag: &str) -> (String, &str) {
if tag.len() >= 8 && tag.is_char_boundary(8) && tag[..8].eq_ignore_ascii_case("program:") {
match tag.find('.') {
None => (format!("{tag}."), ""),
Some(dot) => (tag[..=dot].to_owned(), &tag[dot + 1..]),
}
} else {
(String::new(), tag)
}
}
static WARNED: Mutex<Option<HashSet<(String, String)>>> = Mutex::new(None);
fn fields(table: &Map<String, Json>, where_: &str) -> Result<Settings, String> {
for key in table.keys() {
if !is_decorator_key(key) {
let mut warned = WARNED.lock().unwrap_or_else(PoisonError::into_inner);
if warned
.get_or_insert_with(HashSet::new)
.insert((where_.to_owned(), key.clone()))
{
log::warn!("{where_}: unknown key {} ignored", py_repr(key));
}
}
}
if let Some(rate) = table.get("rate_ms") {
if !rate.as_u64().is_some_and(|r| r > 0) {
return Err(format!("{where_}: rate_ms must be an integer > 0"));
}
}
Ok(table
.iter()
.filter(|(k, _)| is_decorator_key(k))
.map(|(k, v)| (k.clone(), v.clone()))
.collect())
}
fn entry(value: &Json, decorators: &Map<String, Json>, where_: &str) -> Result<Settings, String> {
let mut own = Settings::new();
let mut value = value;
let empty = Json::Array(Vec::new());
if let Json::Object(table) = value {
let rest: Map<String, Json> = table
.iter()
.filter(|(k, _)| k.as_str() != "use")
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
own = fields(&rest, where_)?;
value = table.get("use").unwrap_or(&empty);
}
let bad = || format!("{where_}: expected a decorator name, a list of names or a table");
let names: Vec<&str> = match value {
Json::String(s) => vec![s.as_str()],
Json::Array(items) => items
.iter()
.map(|n| n.as_str().ok_or_else(bad))
.collect::<Result<_, _>>()?,
_ => return Err(bad()),
};
let mut result = Settings::new();
for name in names {
let Some(Json::Object(decorator)) = decorators.get(name) else {
return Err(format!("{where_}: unknown decorator {}", py_repr(name)));
};
for (k, v) in decorator {
result.insert(k.clone(), v.clone());
}
if decorator.contains_key("rate_ms") {
result.insert("group".into(), Json::String(name.to_owned()));
}
}
let own_rate = own.contains_key("rate_ms");
result.extend(own);
if own_rate {
result.insert("group".into(), Json::String("tag".into()));
}
Ok(result)
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Group {
pub name: String,
pub rate_ms: u64,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Kind {
Tag,
Member,
Element,
Bit,
}
type TypeEntry = (String, String, Option<Vec<u64>>);
#[derive(Clone, Debug, Default)]
pub struct Resolver {
udt: HashMap<String, HashMap<String, Settings>>,
tags: IndexMap<String, Settings>,
names: HashMap<String, String>,
rules: Vec<Map<String, Json>>,
groups: Vec<Map<String, Json>>,
tag_types: IndexMap<String, TypeEntry>,
types: HashMap<String, IndexMap<String, TypeEntry>>,
}
fn dims_of(value: Option<&Json>) -> Option<Vec<u64>> {
value?.as_array()?.iter().map(Json::as_u64).collect()
}
impl Resolver {
pub fn new(
decorators: &Map<String, Json>,
udt: &Map<String, Json>,
tags: &Map<String, Json>,
rules: Vec<Map<String, Json>>,
groups: Vec<Map<String, Json>>,
where_: &str,
) -> Result<Self, String> {
let mut checked = Map::new();
for (name, d) in decorators {
let table = d
.as_object()
.ok_or_else(|| format!("decorator.{name} must be a table"))?;
checked.insert(
name.clone(),
Json::Object(fields(table, &format!("decorator.{name}"))?),
);
}
let mut udt_map = HashMap::new();
for (u, members) in udt {
let members = members
.as_object()
.ok_or_else(|| format!("{where_}.udt.{u} must be a table"))?;
let mut resolved = HashMap::new();
for (m, e) in members {
resolved.insert(
m.to_lowercase(),
entry(e, &checked, &format!("{where_}.udt.{u}.{m}"))?,
);
}
udt_map.insert(u.to_lowercase(), resolved);
}
let mut tag_map = IndexMap::new();
let mut names = HashMap::new();
for (k, e) in tags {
tag_map.insert(
normalize(k),
entry(e, &checked, &format!("{where_}.tags.{k}"))?,
);
names.insert(normalize(k), canonical(k));
}
Ok(Resolver {
udt: udt_map,
tags: tag_map,
names,
rules,
groups,
..Resolver::default()
})
}
pub fn from_body(body: &Json) -> Result<Self, String> {
let empty = Map::new();
let obj = |key: &str| body.get(key).and_then(Json::as_object).unwrap_or(&empty);
let list = |key: &str| -> Vec<Map<String, Json>> {
body.get(key)
.and_then(Json::as_array)
.map(|a| a.iter().filter_map(|t| t.as_object().cloned()).collect())
.unwrap_or_default()
};
let mut resolver = Resolver::new(
obj("decorators"),
obj("udt"),
obj("tag_settings"),
list("rules"),
list("groups"),
body.get("plc").and_then(Json::as_str).unwrap_or("plc"),
)?;
let tags = body
.get("tags")
.and_then(Json::as_array)
.cloned()
.unwrap_or_default();
resolver.set_types(&tags, obj("types"));
Ok(resolver)
}
pub fn set_types(&mut self, tag_list: &[Json], types: &Map<String, Json>) {
self.tag_types = tag_list
.iter()
.filter_map(|t| {
let name = t.get("name")?.as_str()?;
let type_ = t
.get("type")
.and_then(Json::as_str)
.unwrap_or("")
.to_lowercase();
Some((
name.to_lowercase(),
(name.to_owned(), type_, dims_of(t.get("dims"))),
))
})
.collect();
self.types = types
.iter()
.map(|(u, t)| {
let members = t
.get("members")
.and_then(Json::as_object)
.map(|members| {
members
.iter()
.map(|(m, i)| {
let type_ = i
.get("type")
.and_then(Json::as_str)
.unwrap_or("")
.to_lowercase();
(m.to_lowercase(), (m.clone(), type_, dims_of(i.get("dims"))))
})
.collect()
})
.unwrap_or_default();
(u.to_lowercase(), members)
})
.collect();
}
fn member_type(&self, udt: Option<&str>, member: &str) -> Option<&TypeEntry> {
self.types.get(udt?)?.get(&member.to_lowercase())
}
fn nodes(&self, tag: &str) -> Vec<(Kind, Settings)> {
let (prefix, rest) = program_split(tag);
let parts = parts(rest);
if parts.is_empty() {
return vec![(
Kind::Tag,
self.tags.get(&normalize(tag)).cloned().unwrap_or_default(),
)];
}
let mut path = format!("{prefix}{}", parts[0]);
let mut type_: Option<String> = self
.tag_types
.get(&path.to_lowercase())
.map(|t| t.1.clone());
let mut result = vec![(
Kind::Tag,
self.tags
.get(&normalize(&path))
.cloned()
.unwrap_or_default(),
)];
for (i, part) in parts.iter().enumerate().skip(1) {
let mut udt = None;
let kind = if part.starts_with('[') {
path.push_str(part);
Kind::Element
} else if part.bytes().all(|b| b.is_ascii_digit()) && i + 1 == parts.len() {
path = format!("{path}.{part}");
type_ = None;
Kind::Bit
} else {
path = format!("{path}.{part}");
udt = type_.take();
type_ = self.member_type(udt.as_deref(), part).map(|t| t.1.clone());
Kind::Member
};
let key = normalize(&path);
let mut found = udt
.as_deref()
.and_then(|u| self.udt.get(u))
.and_then(|members| members.get(&part.to_lowercase()))
.cloned()
.unwrap_or_default();
let stripped = strip_indexes(&key);
if stripped != key {
if let Some(s) = self.tags.get(&stripped) {
found.extend(s.clone());
}
}
if let Some(s) = self.tags.get(&key) {
found.extend(s.clone());
}
result.push((kind, found));
}
result
}
pub fn effective(&self, tag: &str) -> Settings {
let nodes = self.nodes(tag);
let mut result = Settings::new();
for (_, found) in &nodes {
for key in INHERITED {
if let Some(v) = found.get(key) {
result.insert(key.into(), v.clone());
if key == "rate_ms" {
result.insert(
"group".into(),
found.get("group").cloned().unwrap_or(Json::Null),
);
}
}
}
}
let mut i = nodes.len() - 1;
let mut chain = vec![&nodes[i]];
while nodes[i].0 == Kind::Element {
i -= 1;
chain.insert(0, &nodes[i]);
}
for (_, found) in chain {
for (k, v) in found {
if !INHERITED.contains(&k.as_str()) && k != "group" {
result.insert(k.clone(), v.clone());
}
}
}
for rule in &self.rules {
if matches(rule, tag) {
for key in RULE_KEYS {
if let Some(v) = rule.get(key) {
if !result.contains_key(key) {
result.insert(key.into(), v.clone());
}
}
}
}
}
if let Some((word, _)) = split_bit(tag) {
result.retain(|k, _| !NOT_FOR_BITS.contains(&k.as_str()));
if !result.contains_key("writable") {
if let Some(w) = self.effective(word).get("writable") {
result.insert("writable".into(), w.clone());
}
}
}
result
}
pub fn group(&self, tag: &str, settings: &Settings) -> Option<Group> {
if let Some(rate) = settings.get("rate_ms").and_then(Json::as_u64) {
let name = settings
.get("group")
.and_then(Json::as_str)
.unwrap_or("tag");
return Some(Group {
name: name.to_owned(),
rate_ms: rate,
});
}
self.groups.iter().find(|g| matches(g, tag)).and_then(|g| {
Some(Group {
name: match g.get("name")? {
Json::String(s) => s.clone(),
other => other.to_string(),
},
rate_ms: g.get("rate_ms")?.as_u64()?,
})
})
}
pub fn always(&self) -> Vec<String> {
let mut result: Vec<String> = Vec::new();
for rule in &self.rules {
if truthy(rule.get("always")) {
for name in patterns(rule) {
if name.contains(['*', '?']) {
log::warn!(
"always ignored for the pattern {}, it takes tag names only",
py_repr(name)
);
} else {
result.push(name.to_owned());
}
}
}
}
for (k, found) in &self.tags {
if truthy(found.get("always")) {
result.extend(self.expand(&self.names[k]));
}
}
let mut targets = HashSet::new();
for (u, members) in &self.udt {
for (m, found) in members {
if truthy(found.get("always")) {
targets.insert((u.clone(), m.clone()));
}
}
}
result.extend(self.instances(&targets));
let mut seen = HashSet::new();
result
.into_iter()
.filter(|name| seen.insert(name.clone()))
.filter(|name| truthy(self.effective(name).get("always")))
.collect()
}
fn expand(&self, key: &str) -> Vec<String> {
let (prefix, rest) = program_split(key);
let parts = parts(rest);
let found = parts
.first()
.and_then(|p| self.tag_types.get(&format!("{prefix}{p}").to_lowercase()));
let Some(found) = found.filter(|_| !key.contains('[')) else {
return vec![key.to_owned()];
};
let mut paths = vec![found.0.clone()];
let (mut type_, mut dims) = (Some(found.1.clone()), found.2.clone());
for part in &parts[1..] {
if let Some(d) = dims.as_deref().filter(|d| !d.is_empty()) {
paths = paths
.iter()
.flat_map(|p| {
product(d)
.into_iter()
.map(move |i| format!("{p}[{}]", join(&i)))
})
.collect();
}
let (name, t, d) = match self.member_type(type_.as_deref(), part) {
Some((name, t, d)) => (name.clone(), Some(t.clone()), d.clone()),
None => ((*part).to_owned(), None, None),
};
paths = paths.into_iter().map(|p| format!("{p}.{name}")).collect();
type_ = t;
dims = d;
}
paths
}
fn instances(&self, targets: &HashSet<(String, String)>) -> Vec<String> {
if targets.is_empty() {
return Vec::new();
}
let mut reach: HashMap<String, bool> = HashMap::new();
let mut found = Vec::new();
for (name, type_, dims) in self.tag_types.values() {
if self.reaches(type_, targets, &mut reach) {
self.walk(
name,
type_,
dims.as_deref(),
targets,
&mut reach,
&mut found,
);
}
}
found
}
fn reaches(
&self,
type_: &str,
targets: &HashSet<(String, String)>,
reach: &mut HashMap<String, bool>,
) -> bool {
if let Some(r) = reach.get(type_) {
return *r;
}
reach.insert(type_.to_owned(), false); let result = self.types.get(type_).is_some_and(|members| {
members.iter().any(|(m, (_, t, _))| {
targets.contains(&(type_.to_owned(), m.clone())) || self.reaches(t, targets, reach)
})
});
reach.insert(type_.to_owned(), result);
result
}
fn walk(
&self,
path: &str,
type_: &str,
dims: Option<&[u64]>,
targets: &HashSet<(String, String)>,
reach: &mut HashMap<String, bool>,
found: &mut Vec<String>,
) {
if let Some(d) = dims.filter(|d| !d.is_empty()) {
for index in product(d) {
self.walk(
&format!("{path}[{}]", join(&index)),
type_,
None,
targets,
reach,
found,
);
}
return;
}
let Some(members) = self.types.get(type_) else {
return;
};
for (m, (name, t, member_dims)) in members {
if targets.contains(&(type_.to_owned(), m.clone())) {
found.push(format!("{path}.{name}"));
}
if self.reaches(t, targets, reach) {
self.walk(
&format!("{path}.{name}"),
t,
member_dims.as_deref(),
targets,
reach,
found,
);
}
}
}
}
fn product(dims: &[u64]) -> Vec<Vec<u64>> {
let mut result = vec![Vec::new()];
for &n in dims {
result = result
.into_iter()
.flat_map(|prefix| {
(0..n).map(move |i| {
let mut next = prefix.clone();
next.push(i);
next
})
})
.collect();
}
result
}
fn join(index: &[u64]) -> String {
index
.iter()
.map(u64::to_string)
.collect::<Vec<_>>()
.join(",")
}
#[derive(Debug)]
pub struct Rules {
resolvers: HashMap<String, RwLock<Resolver>>,
groups: HashMap<String, Vec<Map<String, Json>>>,
memo: Mutex<HashMap<(String, String), Settings>>,
}
const MEMO_MAX: usize = 10_000;
impl Rules {
pub fn new(config: &Config) -> Result<Self, String> {
let mut resolvers = HashMap::new();
let mut groups = HashMap::new();
for (name, plc) in &config.plcs {
let resolver = Resolver::new(
&config.decorators,
&plc.udt,
&plc.tags,
plc.rules.clone(),
plc.groups.clone(),
&format!("plc.{name}"),
)?;
resolvers.insert(name.clone(), RwLock::new(resolver));
groups.insert(name.clone(), plc.groups.clone());
}
Ok(Rules {
resolvers,
groups,
memo: Mutex::new(HashMap::new()),
})
}
fn with<T>(&self, plc: &str, f: impl FnOnce(&Resolver) -> T) -> T {
match self.resolvers.get(plc) {
Some(r) => f(&r.read().unwrap_or_else(PoisonError::into_inner)),
None => f(&Resolver::default()),
}
}
pub fn set_types(&self, plc: &str, tag_list: &[Json], types: &Map<String, Json>) {
if let Some(r) = self.resolvers.get(plc) {
r.write()
.unwrap_or_else(PoisonError::into_inner)
.set_types(tag_list, types);
}
self.memo
.lock()
.unwrap_or_else(PoisonError::into_inner)
.clear();
}
pub fn effective(&self, plc: &str, tag: &str) -> Settings {
let key = (plc.to_owned(), tag.to_lowercase());
if let Some(s) = self
.memo
.lock()
.unwrap_or_else(PoisonError::into_inner)
.get(&key)
{
return s.clone();
}
let settings = self.with(plc, |r| r.effective(tag));
let mut memo = self.memo.lock().unwrap_or_else(PoisonError::into_inner);
if memo.len() >= MEMO_MAX {
memo.clear();
}
memo.insert(key, settings.clone());
settings
}
pub fn resolve(&self, plc: &str, tag: &str) -> String {
if !tag.contains('.') || split_bit(tag).is_some() {
return tag.to_owned();
}
let Some((word, name)) = tag.rsplit_once('.') else {
return tag.to_owned();
};
if let Some(Json::Object(bits)) = self.effective(plc, word).get("bits") {
for (bit_name, n) in bits {
if bit_name.to_lowercase() == name.to_lowercase() {
let n = match n {
Json::String(s) => s.clone(),
other => other.to_string(),
};
return format!("{word}.{n}");
}
}
}
tag.to_owned()
}
pub fn group(&self, plc: &str, tag: &str) -> Option<Group> {
let settings = self.effective(plc, tag);
self.with(plc, |r| r.group(tag, &settings))
}
pub fn groups(&self, plc: &str) -> &[Map<String, Json>] {
self.groups.get(plc).map_or(&[], Vec::as_slice)
}
pub fn always(&self, plc: &str) -> Vec<String> {
self.with(plc, Resolver::always)
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use serde_json::json;
fn obj(v: Json) -> Map<String, Json> {
v.as_object().cloned().unwrap_or_default()
}
#[test]
fn glob_patterns() {
assert!(glob_match("Tank[*].Level", "tank[3].LEVEL"));
assert!(glob_match("PLC1/Bench.*", "PLC1/Bench.SpeedSP"));
assert!(glob_match("A?C", "abc"));
assert!(!glob_match("A?C", "abbc"));
assert!(glob_match("*", ""));
assert!(!glob_match("Arr[[]3]", "Arr[3]"));
assert!(glob_match("a*b*c", "aXXbYYc"));
assert!(!glob_match("a*b*c", "aXXbYY"));
}
#[test]
fn canonical_indexes() {
assert_eq!(canonical("A[02].B[ 3, 4]"), "A[2].B[3,4]");
assert_eq!(canonical("A[x]"), "A[x]");
assert_eq!(normalize("Tank[01].Level"), "tank[1].level");
assert_eq!(strip_indexes("a[1].b[2,3]"), "a.b");
}
#[test]
fn parts_and_bits() {
assert_eq!(parts("A[1].B.3"), vec!["A", "[1]", "B", "3"]);
assert_eq!(split_bit("X.3"), Some(("X", "3")));
assert_eq!(split_bit("X.Y"), None);
assert_eq!(split_bit(".3"), None);
assert_eq!(
program_split("Program:Main.Tag.X"),
("Program:Main.".into(), "Tag.X")
);
assert_eq!(program_split("Program:Main"), ("Program:Main.".into(), ""));
}
fn resolver() -> Resolver {
let decorators = obj(json!({
"temperature": {"units": "°C", "rate_ms": 1000},
"speed": {"units": "rpm", "min": 0, "max": 3000},
"setpoint": {"writable": true},
"fast": {"rate_ms": 250},
}));
let udt = obj(json!({
"MotorData": {"Temp": "temperature", "SpeedSP": {"use": ["speed", "setpoint"], "description": "SP"}},
"PumpData": {"Motor": "fast"},
}));
let tags = obj(json!({
"Pump1": "fast",
"TankLevel": "temperature",
"Pumps[2].Motor.SpeedSP": {"max": 1500},
"Program:Main.StatusWord": {"bits": {"EStop": 0, "DoorOpen": 1}},
}));
let rules = vec![obj(
json!({"match": "Line.*", "units": "m", "writable": true}),
)];
let mut r = Resolver::new(&decorators, &udt, &tags, rules, vec![], "plc.P").unwrap();
let tag_list = vec![
json!({"name": "Pump1", "type": "PumpData"}),
json!({"name": "Pumps", "type": "PumpData", "dims": [3]}),
json!({"name": "TankLevel", "type": "REAL", "dims": [4]}),
];
let types = obj(json!({
"PumpData": {"members": {"Motor": {"type": "MotorData"}}},
"MotorData": {"members": {"Temp": {"type": "REAL"}, "SpeedSP": {"type": "REAL"}}},
}));
r.set_types(&tag_list, &types);
r
}
#[test]
fn decorators_resolve_like_the_python_server() {
let r = resolver();
let sp = r.effective("Pumps[1].Motor.SpeedSP");
assert_eq!(sp.get("max"), Some(&json!(3000)));
assert_eq!(sp.get("writable"), Some(&json!(true)));
assert_eq!(sp.get("rate_ms"), Some(&json!(250)));
assert_eq!(sp.get("group"), Some(&json!("fast")));
assert_eq!(
r.effective("Pumps[2].Motor.SpeedSP").get("max"),
Some(&json!(1500))
);
let temp = r.effective("Pump1.Motor.Temp");
assert_eq!(temp.get("rate_ms"), Some(&json!(1000)));
assert_eq!(temp.get("group"), Some(&json!("temperature")));
assert_eq!(r.effective("TankLevel[3]").get("units"), Some(&json!("°C")));
let bit = r.effective("Pumps[1].Motor.SpeedSP.3");
assert_eq!(bit.get("writable"), Some(&json!(true)));
assert!(!bit.contains_key("max"));
assert_eq!(r.effective("Line.Speed").get("units"), Some(&json!("m")));
}
#[test]
fn groups_from_decorators_then_tables() {
let mut r = resolver();
r.groups = vec![obj(
json!({"name": "slow", "rate_ms": 2000, "match": "Other*"}),
)];
let s = r.effective("OtherTag");
assert_eq!(
r.group("OtherTag", &s),
Some(Group {
name: "slow".into(),
rate_ms: 2000
})
);
let s = r.effective("Pump1.Motor.Temp");
assert_eq!(
r.group("Pump1.Motor.Temp", &s),
Some(Group {
name: "temperature".into(),
rate_ms: 1000
})
);
}
#[test]
fn always_expands_udt_members_and_keys() {
let decorators = obj(json!({"alarm": {"always": true}}));
let udt = obj(json!({"MotorData": {"Fault": "alarm"}}));
let tags = obj(json!({"Pumps.Motor.Temp": "alarm"}));
let rules = vec![obj(json!({"match": ["Status", "Bad*"], "always": true}))];
let mut r = Resolver::new(&decorators, &udt, &tags, rules, vec![], "plc.P").unwrap();
r.set_types(
&[
json!({"name": "Pumps", "type": "PumpData", "dims": [2]}),
json!({"name": "M", "type": "MotorData"}),
],
&obj(json!({
"PumpData": {"members": {"Motor": {"type": "MotorData"}}},
"MotorData": {"members": {"Temp": {"type": "REAL"}, "Fault": {"type": "BOOL"}}},
})),
);
assert_eq!(
r.always(),
vec![
"Status",
"Pumps[0].Motor.Temp",
"Pumps[1].Motor.Temp",
"Pumps[0].Motor.Fault",
"Pumps[1].Motor.Fault",
"M.Fault"
]
);
}
#[test]
fn bit_names_resolve() {
let config = crate::config::parse_config(
&toml::from_str::<Json>(
"[plc.P]\npath=\"x\"\n[[plc.P.rule]]\nmatch=\"W\"\nbits={EStop=0, Door=1}\n",
)
.unwrap(),
std::path::Path::new("."),
)
.unwrap();
let rules = Rules::new(&config).unwrap();
assert_eq!(rules.resolve("P", "W.estop"), "W.0");
assert_eq!(rules.resolve("P", "W.Door"), "W.1");
assert_eq!(rules.resolve("P", "W.Other"), "W.Other");
assert_eq!(rules.resolve("P", "W.3"), "W.3");
}
#[test]
fn match_patterns() {
for (pattern, name, expected) in [
("Bench.SpeedSP", "bench.speedsp", true), ("Tank[*].Level", "Tank[3].Level", true), ("Tank[*].Level", "Tank3.Level", false),
("Arr[[]3]", "Arr[3]", false), ("Bench.*", "Bench.Axis.Position", true), ("PLC1/Speed*", "PLC1/SpeedSP", true),
("Speed?", "Speed1", true),
("Speed?", "Speed12", false),
("Speed", "SpeedSP", false), ("a.b", "aXb", false), ("Program:Bench.*", "Program:Bench.Mode", true),
] {
assert_eq!(glob_match(pattern, name), expected, "{pattern} {name}");
}
}
fn rules(tables: Json, groups: Json) -> Rules {
let data = json!({"plc": {"P": {"path": "x", "rule": tables, "group": groups}}});
Rules::new(&crate::config::parse_config(&data, std::path::Path::new(".")).unwrap()).unwrap()
}
#[test]
fn effective_first_rule_per_field_wins() {
let r = rules(
json!([{"match": "Bench.SpeedSP", "writable": true, "max": 1800},
{"match": ["Bench.*", "Other"], "units": "rpm", "max": 5, "writable": false, "nonsense": 1}]),
json!([]),
);
let eff = |tag: &str| Json::Object(r.effective("P", tag));
assert_eq!(
eff("bench.speedsp"),
json!({"writable": true, "max": 1800, "units": "rpm"})
);
let other = json!({"units": "rpm", "max": 5, "writable": false});
assert_eq!(eff("Bench.Speed"), other);
assert_eq!(eff("Other"), other);
assert_eq!(eff("Nothing"), json!({}));
}
#[test]
fn effective_of_a_bit_takes_only_writable_from_its_word() {
let r = rules(
json!([{"match": "W", "writable": true, "min": 0, "bits": {"EStop": 0}, "units": "x", "description": "d"},
{"match": "V.*", "max": 3, "description": "bit"}, {"match": "V", "writable": true}]),
json!([]),
);
let eff = |tag: &str| Json::Object(r.effective("P", tag));
assert_eq!(eff("W.3"), json!({"writable": true}));
assert_eq!(eff("V.0"), json!({"description": "bit", "writable": true}));
assert_eq!(eff("Z.0"), json!({}));
}
#[test]
fn effective_memo_is_bounded() {
let r = rules(json!([{"match": "*", "units": "u"}]), json!([]));
for i in 0..MEMO_MAX + 10 {
assert_eq!(
Json::Object(r.effective("P", &format!("T{i}"))),
json!({"units": "u"})
);
}
assert!(r.memo.lock().unwrap().len() <= MEMO_MAX);
}
#[test]
fn resolve_bit_names() {
let r = rules(
json!([{"match": "Bench.StatusWord", "bits": {"EStop": 0, "DoorOpen": 1}},
{"match": "Tank[*].Status", "bits": {"Alarm": 7}}]),
json!([]),
);
assert_eq!(
r.resolve("P", "Bench.StatusWord.estop"),
"Bench.StatusWord.0"
);
assert_eq!(
r.resolve("P", "Bench.StatusWord.DoorOpen"),
"Bench.StatusWord.1"
);
assert_eq!(r.resolve("P", "Tank[2].Status.Alarm"), "Tank[2].Status.7");
assert_eq!(r.resolve("P", "Bench.StatusWord.5"), "Bench.StatusWord.5");
assert_eq!(
r.resolve("P", "Bench.StatusWord.Nope"),
"Bench.StatusWord.Nope"
);
assert_eq!(r.resolve("P", "Speed"), "Speed");
}
#[test]
fn groups() {
let r = rules(
json!([]),
json!([{"name": "fast", "rate_ms": 50, "match": ["Bench.Axis.*"]},
{"name": "also", "rate_ms": 10, "match": "Bench.Axis.Position"},
{"name": "slow", "rate_ms": 2000, "match": "Tank[*].Temp"}]),
);
let group = |name: &str, rate_ms| {
Some(Group {
name: name.into(),
rate_ms,
})
};
assert_eq!(r.group("P", "Bench.Axis.Position"), group("fast", 50));
assert_eq!(r.group("P", "tank[1].temp"), group("slow", 2000));
assert_eq!(r.group("P", "Bench.Speed"), None);
let names: Vec<&Json> = r.groups("P").iter().map(|g| &g["name"]).collect();
assert_eq!(names, [&json!("fast"), &json!("also"), &json!("slow")]);
}
#[test]
fn always_takes_literal_names() {
let r = rules(
json!([{"match": "Off", "always": false},
{"match": ["Bench.StatusWord", "Off", "Tank[*].Level"], "always": true},
{"match": "Alarm", "always": true}]),
json!([]),
);
assert_eq!(r.always("P"), ["Bench.StatusWord", "Alarm"]);
}
fn decorators() -> Json {
json!({
"temp": {"units": "C", "rate_ms": 1000},
"fast": {"rate_ms": 250},
"slow": {"rate_ms": 2000, "together": true},
"sp": {"writable": true, "min": 0, "max": 100, "units": "rpm"},
"cmd": {"writable": true},
"alarm": {"always": true},
})
}
fn tag_list() -> Vec<Json> {
vec![
json!({"name": "Line", "type": "LineData"}),
json!({"name": "Axes", "type": "Axis", "dims": [2]}),
json!({"name": "Zone", "type": "REAL", "dims": [8]}),
json!({"name": "Program:Main.Axis", "type": "Axis"}),
json!({"name": "Word", "type": "DINT"}),
]
}
fn types() -> Json {
json!({
"LineData": {"members": {"Pump": {"type": "Pump"}, "Axes": {"type": "Axis", "dims": [2]}}},
"Axis": {"members": {"Drive": {"type": "Drive"}, "Pos": {"type": "REAL"}, "Jog": {"type": "BOOL"}}},
"Drive": {"members": {"Temp": {"type": "REAL"}, "Amps": {"type": "REAL"},
"Faults": {"type": "DINT", "dims": [4]}}},
"Pump": {"members": {"SpeedSP": {"type": "REAL"}, "Speed": {"type": "REAL"}, "Alarm": {"type": "BOOL"}}},
})
}
fn resolver_of(udt: Json, tags: Json, rules: Json, groups: Json, with_types: bool) -> Resolver {
let list = |v: Json| -> Vec<Map<String, Json>> {
v.as_array()
.unwrap()
.iter()
.map(|t| obj(t.clone()))
.collect()
};
let mut r = Resolver::new(
&obj(decorators()),
&obj(udt),
&obj(tags),
list(rules),
list(groups),
"plc.P",
)
.unwrap();
if with_types {
r.set_types(&tag_list(), &obj(types()));
}
r
}
fn with(udt: Json, tags: Json) -> Resolver {
resolver_of(udt, tags, json!([]), json!([]), true)
}
fn eff(r: &Resolver, tag: &str) -> Json {
Json::Object(r.effective(tag))
}
fn group_of(r: &Resolver, tag: &str) -> Option<Group> {
r.group(tag, &r.effective(tag))
}
#[test]
fn entry_forms_list_order_and_inline_fields() {
let r = with(
json!({}),
json!({"A": "temp", "B": ["temp", "fast"], "C": ["fast", "temp"], "D": {"use": "sp", "max": 5},
"E": {"use": ["sp", "temp"], "units": "%", "rate_ms": 10}, "F": {"min": 1}}),
);
assert_eq!(
eff(&r, "A"),
json!({"units": "C", "rate_ms": 1000, "group": "temp"})
);
assert_eq!(eff(&r, "B")["rate_ms"], 250);
assert_eq!(
group_of(&r, "B"),
Some(Group {
name: "fast".into(),
rate_ms: 250
})
);
assert_eq!(eff(&r, "C")["rate_ms"], 1000);
assert_eq!(
eff(&r, "D"),
json!({"writable": true, "min": 0, "max": 5, "units": "rpm"})
);
assert_eq!(eff(&r, "e")["units"], "%");
assert_eq!(
group_of(&r, "E"),
Some(Group {
name: "tag".into(),
rate_ms: 10
})
);
assert_eq!(eff(&r, "F"), json!({"min": 1}));
}
#[test]
fn precedence_at_a_node() {
let r = with(
json!({"Axis": {"Pos": {"units": "udt", "min": 1, "max": 9}}}),
json!({"Axes.Pos": {"units": "stripped", "min": 2}, "Axes[1].Pos": {"units": "specific"}}),
);
assert_eq!(
eff(&r, "Axes[1].Pos"),
json!({"units": "specific", "min": 2, "max": 9})
);
assert_eq!(
eff(&r, "Axes[0].Pos"),
json!({"units": "stripped", "min": 2, "max": 9})
);
assert_eq!(
eff(&r, "Program:Main.Axis.Pos"),
json!({"units": "udt", "min": 1, "max": 9})
);
assert_eq!(eff(&r, "AXES[01].pos")["units"], "specific"); }
#[test]
fn inherited_fields_closest_wins() {
let r = with(
json!({"Axis": {"Drive": "fast"}, "Drive": {"Temp": "temp"}, "LineData": {"Pump": "slow"}}),
json!({"Line": {"rate_ms": 50, "together": false}}),
);
assert_eq!(
eff(&r, "Axes[1].Drive.Amps"),
json!({"rate_ms": 250, "group": "fast"})
);
assert_eq!(
eff(&r, "Axes[1].Drive.Faults[2]"),
json!({"rate_ms": 250, "group": "fast"})
);
assert_eq!(
eff(&r, "Axes[0].Drive.Temp"),
json!({"rate_ms": 1000, "group": "temp", "units": "C"})
);
assert_eq!(
eff(&r, "Line.Pump.Speed"),
json!({"rate_ms": 2000, "group": "slow", "together": true})
);
assert_eq!(
eff(&r, "Line.Axes[0].Pos"),
json!({"rate_ms": 50, "group": "tag", "together": false})
);
assert_eq!(
eff(&r, "Line.Axes[0].Drive.Amps"),
json!({"rate_ms": 250, "group": "fast", "together": false})
);
}
#[test]
fn value_fields_do_not_inherit_into_members() {
let r = with(
json!({}),
json!({"Line": ["sp", "alarm"], "Line.Pump": {"description": "pump", "bits": {"X": 1}}}),
);
assert_eq!(eff(&r, "Line")["writable"], true);
assert_eq!(
eff(&r, "Line.Pump"),
json!({"description": "pump", "bits": {"X": 1}})
);
assert_eq!(eff(&r, "Line.Pump.SpeedSP"), json!({}));
}
#[test]
fn array_elements_take_their_arrays_value_fields() {
let r = with(
json!({}),
json!({"Zone": "temp", "Zone[2]": {"units": "K"}, "Axes": "sp"}),
);
assert_eq!(
eff(&r, "Zone[3]"),
json!({"units": "C", "rate_ms": 1000, "group": "temp"})
);
assert_eq!(eff(&r, "Zone[2]")["units"], "K");
assert_eq!(eff(&r, "Axes[1]")["writable"], true);
assert!(eff(&r, "Axes[1].Pos").get("writable").is_none());
}
#[test]
fn nested_udts_and_program_scope() {
let r = with(
json!({"Drive": {"Temp": "temp"}, "Pump": {"SpeedSP": "sp"}}),
json!({"Program:Main.Axis.Jog": "cmd"}),
);
for path in [
"Axes[0].Drive.Temp",
"Line.Axes[1].Drive.Temp",
"Program:Main.Axis.Drive.Temp",
] {
assert_eq!(eff(&r, path)["units"], "C");
}
assert_eq!(eff(&r, "Line.Pump.SpeedSP")["writable"], true);
assert_eq!(eff(&r, "program:main.axis.jog"), json!({"writable": true}));
assert_eq!(eff(&r, "Program:Main.Axis.Pos"), json!({}));
}
#[test]
fn bits() {
let r = with(
json!({}),
json!({"Word": {"bits": {"EStop": 0}, "writable": true, "units": "x", "rate_ms": 30},
"Word.3": {"writable": false}}),
);
let data = json!({"decorator": decorators(), "plc": {"P": {"path": "x", "tags": {
"Word": {"bits": {"EStop": 0}}, "Program:Main.Axis.Pos": {"bits": {"Home": 4}}}}}});
let rules =
Rules::new(&crate::config::parse_config(&data, std::path::Path::new(".")).unwrap())
.unwrap();
assert_eq!(
eff(&r, "Word.0"),
json!({"writable": true, "rate_ms": 30, "group": "tag"})
);
assert_eq!(
eff(&r, "Word.3"),
json!({"writable": false, "rate_ms": 30, "group": "tag"})
);
assert_eq!(rules.resolve("P", "Word.estop"), "Word.0");
assert_eq!(
rules.resolve("P", "Program:Main.Axis.Pos.Home"),
"Program:Main.Axis.Pos.4"
);
}
#[test]
fn rules_are_the_fallback_and_groups_need_no_decorator_rate() {
let r = resolver_of(
json!({"Axis": {"Pos": {"units": "m"}, "Drive": "fast"}}),
json!({}),
json!([{"match": "Axes[*].*", "units": "rule", "max": 7, "description": "r"}]),
json!([{"name": "g", "rate_ms": 40, "match": "Axes[*].*"}]),
true,
);
assert_eq!(
eff(&r, "Axes[0].Pos"),
json!({"units": "m", "max": 7, "description": "r"})
);
assert_eq!(
group_of(&r, "Axes[0].Pos"),
Some(Group {
name: "g".into(),
rate_ms: 40
})
);
assert_eq!(
group_of(&r, "Axes[0].Drive"),
Some(Group {
name: "fast".into(),
rate_ms: 250
})
);
}
#[test]
fn types_unknown_before_connect_and_memo_cleared() {
let data = json!({"decorator": decorators(), "plc": {"P": {"path": "x", "udt": {"Axis": {"Pos": "sp"}},
"tags": {"Zone": "temp"}}}});
let rules =
Rules::new(&crate::config::parse_config(&data, std::path::Path::new(".")).unwrap())
.unwrap();
assert!(rules.effective("P", "Axes[0].Pos").is_empty()); assert_eq!(rules.effective("P", "Zone[1]")["units"], "C"); rules.set_types("P", &tag_list(), &obj(types()));
assert_eq!(rules.effective("P", "Axes[0].Pos")["writable"], true);
let tags: Vec<Json> = tag_list()
.into_iter()
.map(|mut t| {
if t["name"] == "Axes" {
t["type"] = json!("Axis2");
}
t
})
.collect();
let mut types = obj(types());
types.insert("Axis2".into(), types["Axis"].clone());
rules.set_types("P", &tags, &types);
assert!(rules.effective("P", "Axes[0].Pos").is_empty());
}
#[test]
fn always_literal_keys_and_udt_instances() {
let r = resolver_of(
json!({"Pump": {"Alarm": "alarm"}, "Axis": {"Jog": "alarm"}}),
json!({"Word": "alarm", "Line.Axes[1].Jog": {"always": false}}),
json!([{"match": "Zone[2]", "always": true}]),
json!([]),
true,
);
assert_eq!(
r.always(),
[
"Zone[2]",
"Word",
"Line.Pump.Alarm",
"Line.Axes[0].Jog",
"Axes[0].Jog",
"Axes[1].Jog",
"Program:Main.Axis.Jog"
]
);
let untyped = resolver_of(
json!({"Pump": {"Alarm": "alarm"}}),
json!({}),
json!([]),
json!([]),
false,
);
assert!(untyped.always().is_empty());
}
#[test]
fn always_index_less_key_takes_every_element() {
let tags = json!({"Axes.Jog": "alarm", "Line.Axes.Drive.Faults": "alarm", "Zone": "alarm",
"Axes[1].Pos": "alarm"});
assert_eq!(
with(json!({}), tags).always(),
[
"Axes[0].Jog",
"Axes[1].Jog",
"Line.Axes[0].Drive.Faults",
"Line.Axes[1].Drive.Faults",
"Zone",
"Axes[1].Pos"
]
);
let untyped = resolver_of(
json!({}),
json!({"Axes.Jog": "alarm"}),
json!([]),
json!([]),
false,
);
assert_eq!(untyped.always(), ["Axes.Jog"]);
}
#[test]
fn config_errors() {
for (plc, error) in [
(
json!({"udt": {"Axis": {"Pos": "nope"}}}),
"plc.P.udt.Axis.Pos: unknown decorator 'nope'",
),
(
json!({"tags": {"Zone": {"use": ["temp", "nope"]}}}),
"plc.P.tags.Zone: unknown decorator 'nope'",
),
(
json!({"tags": {"Zone": 3}}),
"plc.P.tags.Zone: expected a decorator name",
),
(
json!({"tags": {"Zone": {"rate_ms": 0}}}),
"plc.P.tags.Zone: rate_ms",
),
] {
let mut p = obj(plc);
p.insert("path".into(), json!("x"));
let data = json!({"decorator": decorators(), "plc": {"P": p}});
let err = crate::config::parse_config(&data, std::path::Path::new(".")).unwrap_err();
assert!(err.contains(error), "{err}");
}
}
#[test]
fn unknown_decorator_names_the_file() {
let dir = crate::testing::TempDir::new();
let path = dir.path().join("t.toml");
std::fs::write(
&path,
"[decorator.d]\nunit = \"x\"\nmin = 1\n[plc.P]\npath = \"x\"\n[plc.P.tags]\nA = \"d\"\nB = \"e\"\n",
)
.unwrap();
let err = crate::config::load_config(&path).unwrap_err();
assert!(
err.contains("t.toml: plc.P.tags.B: unknown decorator 'e'"),
"{err}"
);
std::fs::write(
&path,
"[decorator.d]\nunit = \"x\"\nmin = 1\n[plc.P]\npath = \"x\"\n[plc.P.tags]\nA = \"d\"\n",
)
.unwrap();
let rules = Rules::new(&crate::config::load_config(&path).unwrap()).unwrap();
assert_eq!(Json::Object(rules.effective("P", "A")), json!({"min": 1}));
}
#[test]
fn codegen_uses_the_same_resolution() {
let body = json!({"plc": "PLC1", "version": 1, "tags": tag_list(), "types": types(), "rules": [],
"groups": [], "decorators": decorators(), "udt": {"Axis": {"Jog": "cmd"}},
"tag_settings": {"Word": {"bits": {"EStop": 0}, "use": "cmd"}, "Word.0": {"writable": false}}});
let module = crate::codegen::tree_module(&[body], false).unwrap();
let tags = module.split("export interface PLC1Tags ").nth(1).unwrap();
assert!(tags.contains(" Jog: TagRef<boolean, true>;\n"));
assert!(tags.contains(" Pos: TagRef<number, false>;\n"));
assert!(tags.contains(" Word: IntRef<true> & { EStop: TagRef<boolean, false>; };\n"));
}
}