// Validator: apply Modbus point-table lint rules to a parsed Device.
//
// Rules:
// 1. Register names must be unique.
// 2. Addresses must fall inside a standard Modbus area (1..49999).
// 3. A multi-word register must not span past the end of its area.
// 4. Two registers in the same area must not overlap in address space
// (accounting for the word count of each type).
// 5. A non-empty jsonb field should be a plain identifier (warning).
///|
/// Run every rule and return all findings (with the default gap threshold).
pub fn lint(dev : Device) -> Array[Issue] {
lint_with_gap(dev, max_gap())
}
///|
/// Run every rule and return all findings, using `threshold` as the max allowed
/// address gap (in words) for Rule 6. `lint(dev)` is `lint_with_gap(dev, 16)`.
pub fn lint_with_gap(dev : Device, threshold : Int) -> Array[Issue] {
let issues : Array[Issue] = []
let regs = dev.registers
// Rule 1: duplicate names.
for i = 0; i < regs.length(); i = i + 1 {
for j = i + 1; j < regs.length(); j = j + 1 {
if regs[i].name == regs[j].name {
issues.push(
mk_issue(
Error,
regs[j].line,
"duplicate register name '" +
regs[j].name +
"' (first defined at line " +
regs[i].line.to_string() +
")",
),
)
}
}
}
// Rules 2-4: address range and overlap.
for i = 0; i < regs.length(); i = i + 1 {
let a = regs[i]
let area = area_of(a.address)
if area < 0 {
issues.push(
mk_issue(
Error,
a.line,
"address " +
a.address.to_string() +
" is outside any standard Modbus area (coils 1-9999, DI 10001-19999, IR 30001-39999, HR 40001-49999)",
),
)
continue
}
let (lo, hi) = local_range(a.address, a.rtype)
if hi > 9999 {
issues.push(
mk_issue(
Error,
a.line,
"register '" +
a.name +
"' spans past the end of its Modbus area (local " +
lo.to_string() +
".." +
hi.to_string() +
", max 9999)",
),
)
}
for j = 0; j < regs.length(); j = j + 1 {
if j == i {
continue
}
let b = regs[j]
if area_of(b.address) != area {
continue
}
let (lo2, hi2) = local_range(b.address, b.rtype)
if hi < lo2 || hi2 < lo {
// disjoint, ok
} else {
issues.push(
mk_issue(
Error,
a.line,
"register '" +
a.name +
"' (local " +
lo.to_string() +
".." +
hi.to_string() +
") overlaps '" +
b.name +
"' (local " +
lo2.to_string() +
".." +
hi2.to_string() +
")",
),
)
}
}
}
// Rule 5: jsonb field sanity (parsed path: non-empty values must start with
// "jsonb->" and contain only valid dotted identifier segments).
for r in regs {
if !jsonb_is_unset(r.jsonb) {
match parse_jsonb_path(r.jsonb) {
Err(e) =>
issues.push(
mk_issue(
Warning,
r.line,
"register '" + r.name + "' has an invalid jsonb field: " + e,
),
)
Ok(_) => ()
}
}
}
// Rule 6: 地址间隙过大,可能漏配或地址笔误(Warning)。
for i = 0; i < regs.length(); i = i + 1 {
let a = regs[i]
let area = area_of(a.address)
if area < 0 {
continue
}
// hi = 该寄存器占用的最后一个本地地址
let hi = a.address - area_base(area) + word_count(a.rtype)
let mut next_lo = -1
for j = 0; j < regs.length(); j = j + 1 {
if j == i {
continue
}
let b = regs[j]
if area_of(b.address) != area {
continue
}
let lo2 = b.address - area_base(area) + 1
if lo2 > hi {
if next_lo < 0 || lo2 < next_lo {
next_lo = lo2
}
}
}
if next_lo > 0 {
let g = next_lo - hi - 1
if g > threshold {
issues.push(
mk_issue(
Warning,
a.line,
"register '" +
a.name +
"' 与下一个寄存器之间有 " +
g.to_string() +
" 字地址间隙(阈值 " +
threshold.to_string() +
"),可能存在漏配或地址笔误",
),
)
}
}
}
// Rule 7: 多个寄存器映射到同一个 JSONB 字段,采集时会互相覆盖(Warning)。
for i = 0; i < regs.length(); i = i + 1 {
if regs[i].jsonb.length() == 0 {
continue
}
for j = i + 1; j < regs.length(); j = j + 1 {
if regs[i].jsonb == regs[j].jsonb {
issues.push(
mk_issue(
Warning,
regs[j].line,
"register '" +
regs[j].name +
"' 与 '" +
regs[i].name +
"' 映射到同一个 JSONB 字段 '" +
regs[j].jsonb +
"',采集时会互相覆盖",
),
)
}
}
}
// Rule 8: register name sanity (empty name is an error; odd characters warn).
for r in regs {
if r.name.length() == 0 {
issues.push(mk_issue(Error, r.line, "register with an empty name"))
} else if !valid_name(r.name) {
issues.push(
mk_issue(
Warning,
r.line,
"register name '" +
r.name +
"' contains characters outside [A-Za-z0-9_.-]",
),
)
}
}
// Rule 9: a multi-word type used at a bit-addressed location (coils / DI).
for r in regs {
let ar = area_of(r.address)
if area_is_bit(ar) && word_count(r.rtype) > 1 {
issues.push(
mk_issue(
Warning,
r.line,
"register '" +
r.name +
"' uses multi-word type " +
type_name(r.rtype) +
" in a bit-addressed area (" +
area_name(ar) +
")",
),
)
}
}
// Rule 10: write access on an input-only area (DI / IR).
for r in regs {
let ar = area_of(r.address)
let writable = match r.access {
Write => true
ReadWrite => true
_ => false
}
if area_is_input(ar) && writable {
issues.push(
mk_issue(
Warning,
r.line,
"register '" +
r.name +
"' is declared " +
access_name(r.access) +
" but lives in an input area (" +
area_name(ar) +
")",
),
)
}
}
// Rule 11: names that differ only by letter case are easy to mis-use.
for i = 0; i < regs.length(); i = i + 1 {
for j = i + 1; j < regs.length(); j = j + 1 {
let ni = lowercase(regs[i].name)
let nj = lowercase(regs[j].name)
if ni != "" && ni == nj && regs[i].name != regs[j].name {
issues.push(
mk_issue(
Warning,
regs[j].line,
"register name '" +
regs[j].name +
"' differs from '" +
regs[i].name +
"' only by case",
),
)
}
}
}
// Rule 12: jsonb path deeper than the supported nesting is poor practice.
for r in regs {
if jsonb_depth(r.jsonb) >= 8 {
issues.push(
mk_issue(
Warning,
r.line,
"register '" +
r.name +
"' has a jsonb path nested " +
jsonb_depth(r.jsonb).to_string() +
" levels deep; consider flattening (limit 8)",
),
)
}
}
// Rule 13: registering under a reserved protocol/special key is error-prone.
for r in regs {
if reserved_name(lowercase(r.name)) {
issues.push(
mk_issue(
Warning,
r.line,
"register name '" +
r.name +
"' collides with a reserved key; rename it",
),
)
}
}
// Rule 14: 32-bit types should start on an even word offset inside the area
// so that 2-word blocks do not cross natural 32-bit boundaries on many PLCs.
for r in regs {
let ar = area_of(r.address)
if ar < 0 {
continue
}
let off = r.address - area_base(ar)
if word_count(r.rtype) == 2 && off % 2 != 0 {
issues.push(
mk_issue(
Warning,
r.line,
"register '" +
r.name +
"' (" +
type_name(r.rtype) +
", 2 words) starts at an odd word offset " +
off.to_string() +
" in the " +
area_name(ar) +
" area",
),
)
}
}
issues
}
///|
/// 允许的最大地址间隙(字);超过则提示可能漏配。
fn max_gap() -> Int {
16
}
///|
/// True when every character of `s` is one of A-Za-z0-9_.- (used for names).
fn valid_name(s : String) -> Bool {
if s.length() == 0 {
return false
}
for i = 0; i < s.length(); i = i + 1 {
let c = s[i].to_int().unsafe_to_char()
let ok = (c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') ||
c == '_' ||
c == '.' ||
c == '-'
if !ok {
return false
}
}
true
}
///|
/// ASCII lower-casing helper (used by the case-duplicate rule).
fn lowercase(s : String) -> String {
let out = StringBuilder()
for i = 0; i < s.length(); i = i + 1 {
let c = s[i].to_int().unsafe_to_char()
if c >= 'A' && c <= 'Z' {
let low = c.to_int() + 32
out.write_char(low.unsafe_to_char())
} else {
out.write_char(c)
}
}
out.to_string()
}
///|
/// True when `name` (already lower-cased) equals a reserved protocol key that
/// upper layers reserve for their own use, so point tables should not use it.
fn reserved_name(name_lower : String) -> Bool {
name_lower == "modbus" ||
name_lower == "status" ||
name_lower == "error" ||
name_lower == "alarm" ||
name_lower == "fault"
}
///|
/// Convert an address + type into its [low, high] local-word range (1-based).
fn local_range(addr : Int, rtype : RegisterType) -> (Int, Int) {
let base = area_base(area_of(addr))
let lo = addr - base + 1
let hi = lo + word_count(rtype) - 1
(lo, hi)
}
///|
test "lint flags an overlap between a float32 and an int16" {
let r1 = {
name: "a",
address: 40001,
rtype: TFloat32,
access: Read,
unit: "",
jsonb: "",
line: 1,
}
let r2 = {
name: "b",
address: 40002,
rtype: TInt16,
access: Read,
unit: "",
jsonb: "",
line: 2,
}
assert_eq(lint({ registers: [r1, r2], }).length() > 0, true)
}
///|
test "lint passes disjoint registers" {
let r1 = {
name: "a",
address: 40001,
rtype: TFloat32,
access: Read,
unit: "",
jsonb: "",
line: 1,
}
let r2 = {
name: "b",
address: 40003,
rtype: TInt16,
access: Read,
unit: "",
jsonb: "",
line: 2,
}
assert_eq(lint({ registers: [r1, r2], }).length(), 0)
}
///|
test "lint flags duplicate names" {
let r1 = {
name: "x",
address: 40001,
rtype: TInt16,
access: Read,
unit: "",
jsonb: "",
line: 1,
}
let r2 = {
name: "x",
address: 40003,
rtype: TInt16,
access: Read,
unit: "",
jsonb: "",
line: 2,
}
assert_eq(lint({ registers: [r1, r2], }).length() > 0, true)
}
///|
test "lint flags out-of-range address" {
let r = {
name: "x",
address: 50001,
rtype: TInt16,
access: Read,
unit: "",
jsonb: "",
line: 1,
}
assert_eq(lint({ registers: [r], }).length() > 0, true)
}
///|
test "lint accepts a float64 that fits exactly at the boundary" {
let r = {
name: "x",
address: 49996,
rtype: TFloat64,
access: Read,
unit: "",
jsonb: "",
line: 1,
}
assert_eq(lint({ registers: [r], }).length(), 0)
}
///|
test "lint flags a float64 that overflows the area" {
let r = {
name: "x",
address: 49998,
rtype: TFloat64,
access: Read,
unit: "",
jsonb: "",
line: 1,
}
assert_eq(lint({ registers: [r], }).length() > 0, true)
}
///|
test "lint warns about a large address gap" {
// 40001 之后直接跳到 40050,中间 48 字没有任何点,应提示漏配
let r1 = {
name: "a",
address: 40001,
rtype: TInt16,
access: Read,
unit: "",
jsonb: "jsonb->a",
line: 1,
}
let r2 = {
name: "b",
address: 40050,
rtype: TInt16,
access: Read,
unit: "",
jsonb: "jsonb->b",
line: 2,
}
assert_eq(lint({ registers: [r1, r2], }).length() > 0, true)
}
///|
test "lint does not warn about a small address gap" {
let r1 = {
name: "a",
address: 40001,
rtype: TInt16,
access: Read,
unit: "",
jsonb: "jsonb->a",
line: 1,
}
let r2 = {
name: "b",
address: 40005,
rtype: TInt16,
access: Read,
unit: "",
jsonb: "jsonb->b",
line: 2,
}
assert_eq(lint({ registers: [r1, r2], }).length(), 0)
}
///|
test "lint warns when two registers share one jsonb field" {
let r1 = {
name: "a",
address: 40001,
rtype: TInt16,
access: Read,
unit: "",
jsonb: "jsonb->same",
line: 1,
}
let r2 = {
name: "b",
address: 40002,
rtype: TInt16,
access: Read,
unit: "",
jsonb: "jsonb->same",
line: 2,
}
assert_eq(lint({ registers: [r1, r2], }).length() > 0, true)
}
///|
test "lint flags an empty register name and warns on odd name characters" {
let r1 = {
name: "",
address: 40001,
rtype: TInt16,
access: Read,
unit: "",
jsonb: "",
line: 1,
}
let r2 = {
name: "bad name!",
address: 40002,
rtype: TInt16,
access: Read,
unit: "",
jsonb: "",
line: 2,
}
assert_eq(lint({ registers: [r1, r2], }).length(), 2)
}
///|
test "lint accepts a well-formed name" {
let r = {
name: "Temp_1.ok",
address: 40001,
rtype: TInt16,
access: Read,
unit: "",
jsonb: "",
line: 1,
}
assert_eq(lint({ registers: [r], }).length(), 0)
}
///|
test "lint warns about a multi-word type in a bit-addressed area" {
let r = {
name: "x",
address: 1001, // coils area
rtype: TFloat32,
access: Read,
unit: "",
jsonb: "",
line: 1,
}
assert_eq(lint({ registers: [r], }).length() > 0, true)
}
///|
test "lint warns about write access on an input area" {
let r = {
name: "x",
address: 10001, // discrete inputs area
rtype: TBit,
access: ReadWrite,
unit: "",
jsonb: "",
line: 1,
}
assert_eq(lint({ registers: [r], }).length() > 0, true)
}
///|
test "lint flags names that differ only by case" {
let r1 = {
name: "temp",
address: 40001,
rtype: TInt16,
access: Read,
unit: "",
jsonb: "",
line: 1,
}
let r2 = {
name: "TEMP",
address: 40002,
rtype: TInt16,
access: Read,
unit: "",
jsonb: "",
line: 2,
}
assert_eq(lint({ registers: [r1, r2], }).length() > 0, true)
}
///|
test "lint warns on an invalid jsonb path" {
let r = {
name: "x",
address: 40001,
rtype: TInt16,
access: Read,
unit: "",
jsonb: "jsonb->a b",
line: 1,
}
assert_eq(lint({ registers: [r], }).length() > 0, true)
}
///|
test "lint accepts an empty jsonb path" {
let r = {
name: "x",
address: 40001,
rtype: TInt16,
access: Read,
unit: "",
jsonb: "",
line: 1,
}
assert_eq(lint({ registers: [r], }).length(), 0)
}
///|
test "lint_with_gap uses the caller-supplied gap threshold" {
let regs = [
{
name: "a",
address: 40001,
rtype: TInt16,
access: Read,
unit: "",
jsonb: "",
line: 1,
},
{
name: "b",
address: 40020,
rtype: TInt16,
access: Read,
unit: "",
jsonb: "",
line: 2,
},
]
let dev : Device = { registers: regs, }
// 17-word gap exceeds the default 16 so it should flag the large-gap warning.
assert_eq(lint(dev).length() > 0, true)
// With a higher threshold the same two registers are fine.
assert_eq(lint_with_gap(dev, 32).length(), 0)
}
///|
test "lint flags address 0 and negative addresses" {
let dev : Device = {
registers: [
{
name: "z",
address: 0,
rtype: TInt16,
access: Read,
unit: "",
jsonb: "",
line: 1,
},
{
name: "n",
address: -5,
rtype: TInt16,
access: Read,
unit: "",
jsonb: "",
line: 2,
},
],
}
assert_eq(lint(dev).length() > 0, true)
}
///|
test "lint flags a multi-word register spilling past the end of its area" {
let dev : Device = {
registers: [
{
name: "x",
address: 49999,
rtype: TFloat32,
access: Read,
unit: "",
jsonb: "",
line: 1,
},
],
}
assert_eq(lint(dev).length() > 0, true)
}
///|
test "lint reports two registers mapping to the same jsonb field" {
let dev : Device = {
registers: [
{
name: "a",
address: 40001,
rtype: TInt16,
access: Read,
unit: "",
jsonb: "jsonb->same",
line: 1,
},
{
name: "b",
address: 40002,
rtype: TInt16,
access: Read,
unit: "",
jsonb: "jsonb->same",
line: 2,
},
],
}
assert_eq(lint(dev).length() > 0, true)
}
///|
test "lint flags write access on an input-only area" {
let dev : Device = {
registers: [
{
name: "di",
address: 10001,
rtype: TBit,
access: Write,
unit: "",
jsonb: "",
line: 1,
},
],
}
assert_eq(lint(dev).length() > 0, true)
}
///|
test "lint warns when a bit area holds a multi-word type" {
let dev : Device = {
registers: [
{
name: "c1",
address: 1,
rtype: TFloat32,
access: Read,
unit: "",
jsonb: "",
line: 1,
},
],
}
assert_eq(lint(dev).length() > 0, true)
}
///|
test "a clean device produces no issues" {
let dev : Device = {
registers: [
{
name: "v",
address: 40001,
rtype: TInt16,
access: Read,
unit: "V",
jsonb: "jsonb->v",
line: 1,
},
{
name: "w",
address: 40002,
rtype: TInt16,
access: Read,
unit: "",
jsonb: "jsonb->w",
line: 2,
},
],
}
assert_eq(lint(dev).length(), 0)
}
///|
test "lint flags an over-deep jsonb path" {
let dev : Device = {
registers: [
{
name: "deep",
address: 40001,
rtype: TInt16,
access: Read,
unit: "",
jsonb: "jsonb->a.b.c.d.e.f.g.h.i",
line: 1,
},
],
}
assert_eq(lint(dev).length() > 0, true)
}
///|
test "lint accepts a moderately nested jsonb path" {
let dev : Device = {
registers: [
{
name: "ok",
address: 40001,
rtype: TInt16,
access: Read,
unit: "",
jsonb: "jsonb->a.b.c",
line: 1,
},
],
}
assert_eq(lint(dev).length(), 0)
}
///|
test "lint flags a reserved register name" {
let dev : Device = {
registers: [
{
name: "STATUS",
address: 40001,
rtype: TInt16,
access: Read,
unit: "",
jsonb: "",
line: 1,
},
],
}
assert_eq(lint(dev).length() > 0, true)
}
///|
test "lint lets an ordinary name pass" {
let dev : Device = {
registers: [
{
name: "pump_rpm",
address: 40001,
rtype: TInt16,
access: Read,
unit: "",
jsonb: "",
line: 1,
},
],
}
assert_eq(lint(dev).length(), 0)
}
///|
test "lint warns when a 32-bit type starts at an odd word offset" {
let dev : Device = {
registers: [
{
name: "odd32",
address: 40002,
rtype: TInt32,
access: Read,
unit: "",
jsonb: "",
line: 1,
},
],
}
assert_eq(lint(dev).length() > 0, true)
}
///|
test "lint accepts a 32-bit type on an even word offset" {
let dev : Device = {
registers: [
{
name: "even32",
address: 40001,
rtype: TInt32,
access: Read,
unit: "",
jsonb: "",
line: 1,
},
],
}
assert_eq(lint(dev).length(), 0)
}