// 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") }