// modbus_lint — a small Modbus point-table linter written in MoonBit.
//
// This file holds the shared data model and pure helper functions used by the
// parser, validator and reporter. Everything here is side-effect free so it is
// easy to unit test.
///|
/// Register data types we understand. Word count follows the Modbus convention
/// (1 word = 2 bytes = 16 bits).
pub enum RegisterType {
TBit // single coil / discrete input
TInt16
TUInt16
TInt32 // 2 words
TUInt32 // 2 words
TFloat32 // 2 words
TFloat64 // 4 words
}
///|
/// Access mode of a register.
pub enum Access {
Read
Write
ReadWrite
}
///|
/// Severity of a lint finding.
pub enum Severity {
Error
Warning
}
///|
/// A single lint finding.
pub struct Issue {
severity : Severity
line : Int
message : String
}
///|
/// A single register described in a point table.
pub struct Register {
name : String
address : Int
rtype : RegisterType
access : Access
unit : String
jsonb : String
line : Int
}
///|
/// A parsed device: an ordered list of registers.
pub struct Device {
registers : Array[Register]
}
///|
/// Number of 16-bit words a register type occupies.
pub fn word_count(rtype : RegisterType) -> Int {
match rtype {
TBit => 1
TInt16 => 1
TUInt16 => 1
TInt32 => 2
TUInt32 => 2
TFloat32 => 2
TFloat64 => 4
}
}
///|
/// Map a 1-based Modbus address to its area id.
/// 0 = coils (1..9999), 1 = discrete inputs (10001..19999),
/// 3 = input registers (30001..39999), 4 = holding registers (40001..49999).
/// Returns -1 when the address is outside any standard area.
fn area_of(addr : Int) -> Int {
if addr >= 40001 && addr <= 49999 {
4
} else if addr >= 30001 && addr <= 39999 {
3
} else if addr >= 10001 && addr <= 19999 {
1
} else if addr >= 1 && addr <= 9999 {
0
} else {
-1
}
}
///|
/// First address of the area that `area_of` returns (-1 -> 0).
fn area_base(area : Int) -> Int {
match area {
0 => 1
1 => 10001
3 => 30001
4 => 40001
_ => 0
}
}
///|
/// Human-readable name of an area id (see `area_of`).
pub fn area_name(area : Int) -> String {
match area {
0 => "coils"
1 => "discrete inputs"
3 => "input registers"
4 => "holding registers"
_ => "outside any standard area"
}
}
///|
/// Right-pad a string to at least `14` columns so columns line up when the CLI
/// prints a sorted point table.
pub fn pad(s : String) -> String {
let mut out = s
while out.length() < 14 {
out = out + " "
}
out
}
///|
/// Left-pad an integer to at least `7` columns (right-aligned) for the CLI
/// point-table view.
pub fn pad_num(n : Int) -> String {
let s = n.to_string()
let mut out = ""
while out.length() + s.length() < 7 {
out = out + " "
}
out + s
}
///|
/// True when the area holds inputs only (discrete inputs / input registers).
pub fn area_is_input(area : Int) -> Bool {
area == 1 || area == 3
}
///|
/// True when the area is bit-addressable (coils / discrete inputs).
pub fn area_is_bit(area : Int) -> Bool {
area == 0 || area == 1
}
///|
/// Parse a non-negative / signed decimal integer without relying on stdlib
/// number parsing, so the behaviour is predictable across targets.
fn parse_int(s : String) -> Result[Int, String] {
if s.length() == 0 {
return Err("empty number")
}
let neg = s[0] == '-'
let start = if neg { 1 } else { 0 }
if neg && s.length() == 1 {
return Err("invalid number: " + s)
}
let mut v = 0
let zero = '0'.to_int()
for i = start; i < s.length(); i = i + 1 {
let c = s[i]
if c < '0' || c > '9' {
return Err("not a number: " + s)
}
v = v * 10 + (c.to_int() - zero)
}
if neg {
v = -v
}
Ok(v)
}
///|
/// Parse a register type keyword.
fn parse_type(s : String) -> Result[RegisterType, String] {
if s == "bool" {
Ok(TBit)
} else if s == "int16" {
Ok(TInt16)
} else if s == "uint16" {
Ok(TUInt16)
} else if s == "int32" {
Ok(TInt32)
} else if s == "uint32" {
Ok(TUInt32)
} else if s == "float32" {
Ok(TFloat32)
} else if s == "float64" {
Ok(TFloat64)
} else {
Err(
"unknown type: " +
s +
" (want bool|int16|uint16|int32|uint32|float32|float64)",
)
}
}
///|
/// Parse an access keyword.
fn parse_access(s : String) -> Result[Access, String] {
if s == "R" {
Ok(Read)
} else if s == "W" {
Ok(Write)
} else if s == "RW" {
Ok(ReadWrite)
} else {
Err("unknown access: " + s + " (want R|W|RW)")
}
}
///|
/// True when `s` is a usable JSONB field path such as `jsonb->coil_temp`.
fn is_ident(s : String) -> Bool {
if s.length() == 0 {
return false
}
for i = 0; i < s.length(); i = i + 1 {
let c = s[i]
let ok = (c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') ||
c == '_' ||
c == '-' ||
c == '.' ||
c == '>'
if !ok {
return false
}
}
true
}
///|
/// Convenience constructor for an Issue.
fn mk_issue(sev : Severity, line : Int, message : String) -> Issue {
{ severity: sev, line, message, }
}
///|
/// Human-readable keyword for a register type, symmetric with `parse_type`.
pub fn type_name(rtype : RegisterType) -> String {
match rtype {
TBit => "bool"
TInt16 => "int16"
TUInt16 => "uint16"
TInt32 => "int32"
TUInt32 => "uint32"
TFloat32 => "float32"
TFloat64 => "float64"
}
}
///|
/// Keyword for an access mode, symmetric with `parse_access`.
pub fn access_name(access : Access) -> String {
match access {
Read => "R"
Write => "W"
ReadWrite => "RW"
}
}
///|
/// Lower-case name of a severity, used in JSON reports.
pub fn severity_name(sev : Severity) -> String {
match sev {
Error => "error"
Warning => "warning"
}
}
///|
/// Split text into lines, tolerating CRLF, preserving order so the returned
/// index (plus one) matches the file line number.
fn split_lines(s : String) -> Array[String] {
let out : Array[String] = []
let mut cur = StringBuilder()
for i = 0; i < s.length(); i = i + 1 {
let c = s[i].to_int().unsafe_to_char()
if c == '\n' {
out.push(cur.to_string())
cur = StringBuilder()
} else if c != '\r' {
cur.write_char(c)
}
}
out.push(cur.to_string())
out
}
///|
/// Split a line on runs of whitespace, dropping empty tokens.
fn split_ws(s : String) -> Array[String] {
let out : Array[String] = []
let mut cur = StringBuilder()
for i = 0; i < s.length(); i = i + 1 {
let c = s[i].to_int().unsafe_to_char()
if c == ' ' || c == '\t' {
if !cur.is_empty() {
out.push(cur.to_string())
cur = StringBuilder()
}
} else {
cur.write_char(c)
}
}
if !cur.is_empty() {
out.push(cur.to_string())
}
out
}
///|
test "word_count matches Modbus convention" {
assert_eq(word_count(TBit), 1)
assert_eq(word_count(TInt16), 1)
assert_eq(word_count(TFloat32), 2)
assert_eq(word_count(TFloat64), 4)
}
///|
test "area_of classifies standard Modbus addresses" {
assert_eq(area_of(40001), 4)
assert_eq(area_of(30001), 3)
assert_eq(area_of(10001), 1)
assert_eq(area_of(1), 0)
assert_eq(area_of(50001), -1)
assert_eq(area_of(25000), -1)
}
///|
test "parse_int handles signs and rejects garbage" {
assert_eq(parse_int("123").unwrap(), 123)
assert_eq(parse_int("-7").unwrap(), -7)
assert_eq(parse_int("") is Ok(_), false)
assert_eq(parse_int("12x") is Ok(_), false)
}
///|
test "is_ident accepts dotted and dashed names" {
assert_eq(is_ident("jsonb->coil_temp"), true)
assert_eq(is_ident(""), false)
assert_eq(is_ident("bad name"), false)
}