// Parser: turn the line-based point-table text into a Device.
//
// Point table format (one register per line, `#` starts a comment):
// [] []
// Because both optional fields are positional, a fifth field of the form
// `jsonb->...` is read as the JSONB mapping (unit omitted).
// Example:
// coil_temp 40001 float32 R degC jsonb->coil_temp
// conveyor 40003 bool RW jsonb->conveyor_run
//
// The Modbus area (coil / discrete input / input register / holding register)
// is inferred from the address prefix, so no extra header is required.
///|
/// Drop a leading UTF-8 byte-order mark that some Windows editors prepend.
fn strip_bom(s : String) -> String {
if s.length() > 0 && s[0].to_int() == 0xFEFF {
let out = StringBuilder()
for i = 1; i < s.length(); i = i + 1 {
out.write_char(s[i].to_int().unsafe_to_char())
}
out.to_string()
} else {
s
}
}
///|
/// Remove an inline `#` comment, i.e. everything from the first `#` onwards.
fn strip_inline_comment(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 == '#' {
break
}
out.write_char(c)
}
out.to_string()
}
///|
/// Parse a whole point-table document.
pub fn parse_device(text : String) -> Result[Device, String] {
let lines = split_lines(text)
let regs : Array[Register] = []
for i = 0; i < lines.length(); i = i + 1 {
let raw = strip_bom(lines[i]).trim().to_owned()
if raw.length() == 0 {
continue
}
if raw[0] == '#' {
continue
}
let code = strip_inline_comment(raw).trim().to_owned()
if code.length() == 0 {
continue
}
let r = match parse_line(code, i + 1) {
Ok(v) => v
Err(e) => return Err(e)
}
regs.push(r)
}
Ok({ registers: regs, })
}
///|
/// Parse a single register line.
fn parse_line(line : String, line_no : Int) -> Result[Register, String] {
let toks = split_ws(line)
if toks.length() < 4 {
return Err(
"line " +
line_no.to_string() +
": expected ' [unit] [jsonb]', got " +
toks.length().to_string() +
" field(s)",
)
}
if toks.length() > 6 {
return Err(
"line " +
line_no.to_string() +
": expected at most 6 fields ' [unit] [jsonb]', got " +
toks.length().to_string(),
)
}
let name = toks[0]
let addr = match parse_int(toks[1]) {
Ok(v) => v
Err(e) => return Err("line " + line_no.to_string() + ": bad address: " + e)
}
let rtype = match parse_type(toks[2]) {
Ok(v) => v
Err(e) => return Err("line " + line_no.to_string() + ": " + e)
}
let access = match parse_access(toks[3]) {
Ok(v) => v
Err(e) => return Err("line " + line_no.to_string() + ": " + e)
}
// `[unit] [jsonb]` are both optional. When the unit is omitted the writer
// often puts the JSONB mapping directly after the access mode, so treat a
// fifth field that starts with `jsonb->` as the JSONB mapping rather than a
// unit; otherwise it is the unit and a sixth field (if any) is the JSONB.
let fifth_is_jsonb = toks.length() > 4 && is_jsonb_prefixed(toks[4])
let unit = if toks.length() > 4 && !fifth_is_jsonb { toks[4] } else { "" }
let jsonb = if toks.length() > 5 {
toks[5]
} else if fifth_is_jsonb {
toks[4]
} else {
""
}
Ok({ name, address: addr, rtype, access, unit, jsonb, line: line_no, })
}
///|
test "parse_device reads comments and registers" {
let text = "# example point table\n" +
"coil_temp 40001 float32 R degC jsonb->coil_temp\n" +
"\n" +
"conveyor 40003 bool RW jsonb->conveyor_run\n"
let dev = parse_device(text).unwrap()
assert_eq(dev.registers.length(), 2)
assert_eq(dev.registers[0].name, "coil_temp")
assert_eq(dev.registers[1].address, 40003)
}
///|
test "parse_device rejects unknown type" {
let res = parse_device("x 40001 unknown R")
assert_eq(res is Ok(_), false)
}
///|
test "parse_device rejects too-few fields" {
let res = parse_device("x 40001 float32")
assert_eq(res is Ok(_), false)
}
///|
test "parse_device reads a jsonb field when the unit is omitted" {
let dev = parse_device("conveyor 40003 bool RW jsonb->conveyor_run").unwrap()
assert_eq(dev.registers[0].unit, "")
assert_eq(dev.registers[0].jsonb, "jsonb->conveyor_run")
}
///|
test "parse_device keeps unit and jsonb distinct when both are given" {
let dev = parse_device("coil_temp 40001 float32 R degC jsonb->coil_temp").unwrap()
assert_eq(dev.registers[0].unit, "degC")
assert_eq(dev.registers[0].jsonb, "jsonb->coil_temp")
}
///|
test "parse_device tolerates CRLF and tab separators" {
let dev = parse_device(
"a\t40001\tint16\tR\r\n# comment\r\nb\t40003\tint16\tR\r\n",
).unwrap()
assert_eq(dev.registers.length(), 2)
assert_eq(dev.registers[1].name, "b")
assert_eq(dev.registers[1].address, 40003)
}
///|
test "parse_device accepts every type and access keyword" {
let text = "a 40001 bool R\n" +
"b 40002 int16 W\n" +
"c 40003 uint16 RW\n" +
"d 40004 int32 R\n" +
"e 40006 uint32 R\n" +
"f 40008 float32 R\n" +
"g 40010 float64 R\n"
let dev = parse_device(text).unwrap()
assert_eq(dev.registers.length(), 7)
assert_eq(type_name(dev.registers[6].rtype), "float64")
assert_eq(access_name(dev.registers[1].access), "W")
assert_eq(access_name(dev.registers[2].access), "RW")
}
///|
test "parse_device strips a leading byte-order mark" {
let dev = parse_device("\u{FEFF}# header\na 40001 int16 R\n").unwrap()
assert_eq(dev.registers.length(), 1)
assert_eq(dev.registers[0].name, "a")
}
///|
test "parse_device rejects more than six fields" {
let res = parse_device("a 40001 int16 R degC jsonb->a extra")
assert_eq(res is Ok(_), false)
}
///|
test "parse_device strips inline comments" {
let dev = parse_device("a 40001 int16 R # temperature input\n").unwrap()
assert_eq(dev.registers.length(), 1)
assert_eq(dev.registers[0].unit, "")
assert_eq(dev.registers[0].jsonb, "")
}
///|
test "parse_device handles a trailing comment after spaces" {
let dev = parse_device("a 40001 int16 R # trailing comment\n").unwrap()
assert_eq(dev.registers.length(), 1)
assert_eq(dev.registers[0].name, "a")
}