// CLI entry point for modbus_lint.
//
// Usage:
// moon run cmd/main -- --demo lint the built-in demo table
// moon run cmd/main -- --format summary --demo demo report in summary form
// moon run cmd/main -- "a 40001 int16 R" lint register lines given as args
// moon run cmd/main -- --json-in "$(cat table.json)" lint a JSON point table
// moon run cmd/main -- --gap 32 --show "a 40001..." raise gap threshold + show sorted table
// moon run cmd/main -- --encode float32 1.5 pack a float into words + bytes
// moon run cmd/main -- --encode uint32 4294967295 pack an unsigned 32-bit value
// moon run cmd/main -- --decode int32 ffff 8000 rebuild a signed value from words
// moon run cmd/main -- --help show usage
//
// Options:
// --json alias for --format json
// --format FMT report format: text | json | markdown | summary | by_area | csv | html (default text)
// --gap N override the Rule-6 max address gap threshold (words)
// --json-in J read the point table from a JSON document instead of text lines
// --show print the point table sorted by address before the report
// --encode T V encode VALUE V of TYPE T (int16|uint16|int32|uint32|float32|float64)
// --decode T W decode hex tokens: 4-digit words (MSW first) or 2-digit wire bytes
// --order BO byte order for the *bytes* view: big | little (default big)
// --word-order WO word order for int32/uint32/float32/float64: hi-lo | lo-hi (default hi-lo)
// --demo lint the built-in demo point table
// --help show this help
//
// Exit status is non-zero when the point table has Error-level findings or fails
// to parse, so a CI job can block delivery on it.
//
// The CLI depends only on moonbitlang/core (`env`, `abort` + `println`), so it
// runs anywhere MoonBit runs, without extra dependencies.
///|
/// Output format of the report.
enum OutputFormat {
Text
Json
Markdown
Summary
ByArea
Csv
Html
}
///|
fn main {
let args = @env.args()
let mut fmt = Text
let mut gap_opt : Int? = None
let mut json_in = ""
let mut json_in_set = false
let mut show = false
let mut use_demo = false
let mut want_stats = false
// Value-encoding command state: `--encode` packs a number into register
// words/bytes, `--decode` rebuilds a number from words given as hex tokens.
let mut enc_is_encode = true
let mut enc_type = ""
let mut enc_val = ""
let mut byte_order = "big"
let mut word_order = "hi-lo"
let lines : Array[String] = []
// args[0] is the executable path itself, so user arguments start at index 1.
let mut i = 1
while i < args.length() {
let a = args[i]
if a == "--json" {
fmt = Json
} else if a == "--format" {
if i + 1 < args.length() {
match parse_format(args[i + 1]) {
Some(f) => fmt = f
None => {
println(
"unknown --format: " +
args[i + 1] +
" (text|json|markdown|summary|by_area|csv|html)",
)
return
}
}
i = i + 1
} else {
println(
"--format requires an argument (text|json|markdown|summary|by_area|csv|html)",
)
return
}
} else if a == "--gap" {
if i + 1 < args.length() {
match parse_int(args[i + 1]) {
Some(n) => gap_opt = Some(n)
None => {
println(
"--gap requires a non-negative integer, got: " + args[i + 1],
)
return
}
}
i = i + 1
} else {
println("--gap requires an integer argument")
return
}
} else if a == "--json-in" {
if i + 1 < args.length() {
json_in = args[i + 1]
json_in_set = true
i = i + 1
} else {
println("--json-in requires a JSON document as its argument")
return
}
} else if a == "--show" {
show = true
} else if a == "--demo" {
use_demo = true
} else if a == "--stats" {
want_stats = true
} else if a == "--encode" {
if i + 2 < args.length() {
enc_is_encode = true
enc_type = args[i + 1]
enc_val = args[i + 2]
i = i + 2
} else {
println("--encode requires TYPE and VALUE (e.g. --encode float32 1.5)")
return
}
} else if a == "--decode" {
if i + 1 < args.length() {
enc_is_encode = false
enc_type = args[i + 1]
i = i + 1
} else {
println("--decode requires a TYPE (e.g. --decode float32 3fc0 0000)")
return
}
} else if a == "--order" {
if i + 1 < args.length() {
if !valid_byte_order(args[i + 1]) {
println("--order requires big or little, got: " + args[i + 1])
return
}
byte_order = args[i + 1]
i = i + 1
} else {
println("--order requires big or little")
return
}
} else if a == "--word-order" {
if i + 1 < args.length() {
if !valid_word_order(args[i + 1]) {
println("--word-order requires hi-lo or lo-hi, got: " + args[i + 1])
return
}
word_order = args[i + 1]
i = i + 1
} else {
println("--word-order requires hi-lo or lo-hi")
return
}
} else if a == "--help" {
print_usage()
return
} else {
lines.push(a)
}
i = i + 1
}
// A value-encoding request short-circuits the linter entirely.
if enc_type != "" {
run_encode(enc_is_encode, enc_type, enc_val, lines, byte_order, word_order)
return
}
if use_demo {
run_device(
@modbus_lint.parse_device(example_text()),
fmt,
gap_opt,
show,
want_stats,
)
return
}
if json_in_set {
run_device(
@modbus_lint.parse_device_json(json_in),
fmt,
gap_opt,
show,
want_stats,
)
return
}
if lines.length() == 0 {
println("no input given; running the built-in demo point table:")
println("")
run_device(
@modbus_lint.parse_device(example_text()),
fmt,
gap_opt,
show,
want_stats,
)
return
}
run_device(
@modbus_lint.parse_device(join_lines(lines)),
fmt,
gap_opt,
show,
want_stats,
)
}
///|
/// Print the CLI help text.
fn print_usage() -> Unit {
println("modbus_lint - Modbus point table linter")
println("")
println("usage: moon run cmd/main -- [options] [register lines...]")
println("")
println("options:")
println(
" --format FMT 报告格式: text | json | markdown | summary | by_area | csv | html(默认 text)",
)
println(" --json --format json 的别名")
println(
" --gap N 覆盖 Rule 6 最大地址间隙阈值(字,默认 16)",
)
println(" --json-in J 从 JSON 文档读取点表(而非文本行)")
println(" --show 在报告前打印按地址排序后的点表")
println(" --stats 打印点表统计摘要并退出")
println(" --demo 校验内置演示点表")
println(
" --encode T V 将数值 V(类型 T=int16|uint16|int32|uint32|float32|float64)编码为寄存器字/字节",
)
println(
" --decode T W 反解数值:4 位十六进制字,或 2 位十六进制线上字节",
)
println(
" --order BO *字节视图*字节序: big | little(默认 big)",
)
println(
" --word-order WO int32/uint32/float32/float64 字序: hi-lo | lo-hi(默认 hi-lo)",
)
println(" --help 显示本帮助")
println(
"退出码: 存在 Error 级别问题(或解析失败)时以非零状态退出,便于 CI 阻断交付。",
)
}
///|
/// Parse the `--format` argument into an `OutputFormat`, or `None` when the
/// value is not one of the documented formats.
fn parse_format(s : String) -> OutputFormat? {
if s == "text" {
Some(Text)
} else if s == "json" {
Some(Json)
} else if s == "markdown" {
Some(Markdown)
} else if s == "summary" {
Some(Summary)
} else if s == "by_area" || s == "by-area" {
Some(ByArea)
} else if s == "csv" {
Some(Csv)
} else if s == "html" {
Some(Html)
} else {
None
}
}
///|
/// ASCII lower-case copy, used for case-insensitive option validation.
fn ascii_lower(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' {
out.write_char((c.to_int() + 32).unsafe_to_char())
} else {
out.write_char(c)
}
}
out.to_string()
}
///|
/// True when `s` names a byte order this CLI understands.
fn valid_byte_order(s : String) -> Bool {
let n = ascii_lower(s)
n == "big" ||
n == "be" ||
n == "bigendian" ||
n == "little" ||
n == "le" ||
n == "littleendian"
}
///|
/// True when `s` names a word order this CLI understands.
fn valid_word_order(s : String) -> Bool {
let n = ascii_lower(s)
n == "hi-lo" ||
n == "lo-hi" ||
n == "hi" ||
n == "lo" ||
n == "highwordlowaddress" ||
n == "lowwordlowaddress"
}
///|
/// Parse a non-negative integer argument, returning `None` on bad input.
/// The upper bound keeps the threshold far below `Int` overflow.
fn parse_int(s : String) -> Int? {
if s.length() == 0 {
return None
}
let mut n = 0
for i = 0; i < s.length(); i = i + 1 {
let c = s[i].to_int().unsafe_to_char()
if c < '0' || c > '9' {
return None
}
n = n * 10 + (c.to_int() - '0'.to_int())
if n > 1000000 {
return None
}
}
Some(n)
}
///|
test "parse_format recognizes every documented format" {
for name in ["text", "json", "markdown", "summary", "by_area", "csv", "html"] {
assert_eq(parse_format(name) is Some(_), true)
}
assert_eq(parse_format("by-area") is Some(_), true)
assert_eq(parse_format("bogus") is None, true)
}
///|
test "order keyword validation is case-insensitive" {
assert_eq(valid_byte_order("LITTLE"), true)
assert_eq(valid_byte_order("LittleEndian"), true)
assert_eq(valid_byte_order("bogus"), false)
assert_eq(valid_word_order("LO-HI"), true)
assert_eq(valid_word_order("LowWordLowAddress"), true)
assert_eq(valid_word_order("bogus"), false)
}
///|
test "parse_int enforces digits and an upper bound" {
assert_eq(parse_int("0").unwrap(), 0)
assert_eq(parse_int("16").unwrap(), 16)
assert_eq(parse_int("abc") is None, true)
assert_eq(parse_int("1000001") is None, true)
}
///|
/// Resolve the `--order` argument to a `ByteOrder`.
fn resolve_order(s : String) -> @modbus_lint.ByteOrder {
@modbus_lint.byte_order_of(s)
}
///|
/// Parse a decimal `Double` literal (with optional sign, fraction, and `e`
/// exponent) into a value, or `None` if the text is not a valid number.
fn parse_double(s : String) -> Double? {
if s.length() == 0 {
return None
}
let mut i = 0
let mut neg = false
let c0 = s[i].to_int().unsafe_to_char()
if c0 == '-' {
neg = true
i = i + 1
} else if c0 == '+' {
i = i + 1
}
// Integer part.
let mut int_part = 0.0
let mut has_digits = false
while i < s.length() {
let c = s[i].to_int().unsafe_to_char()
if c >= '0' && c <= '9' {
int_part = int_part * 10.0 + (c.to_int() - '0'.to_int()).to_double()
has_digits = true
i = i + 1
} else {
break
}
}
// Fractional part.
let mut frac = 0.0
if i < s.length() && s[i].to_int().unsafe_to_char() == '.' {
i = i + 1
let mut scale = 0.1
while i < s.length() {
let c = s[i].to_int().unsafe_to_char()
if c >= '0' && c <= '9' {
frac = frac + (c.to_int() - '0'.to_int()).to_double() * scale
scale = scale * 0.1
has_digits = true
i = i + 1
} else {
break
}
}
}
if !has_digits {
return None
}
// Optional `e`/`E` exponent.
let mut e10 = 0
if i < s.length() {
let c = s[i].to_int().unsafe_to_char()
if c == 'e' || c == 'E' {
i = i + 1
let mut eneg = false
if i < s.length() {
let sc = s[i].to_int().unsafe_to_char()
if sc == '-' {
eneg = true
i = i + 1
} else if sc == '+' {
i = i + 1
}
}
let mut eint = 0
let mut ed = false
while i < s.length() {
let cc = s[i].to_int().unsafe_to_char()
if cc >= '0' && cc <= '9' {
eint = eint * 10 + (cc.to_int() - '0'.to_int())
ed = true
i = i + 1
} else {
break
}
}
if !ed {
return None
}
e10 = if eneg { -eint } else { eint }
} else {
return None
}
}
if i != s.length() {
return None
}
let mut v = (int_part + frac) * @math.pow(10.0, e10.to_double())
if neg {
v = -v
}
Some(v)
}
///|
/// Parse a signed 32-bit integer literal into an `Int`, or `None` if the text
/// is not an integral value in `(-2^31, 2^31)`.
fn parse_int32(s : String) -> Int? {
match parse_double(s) {
None => None
Some(d) => {
if d < -2147483648.0 || d > 2147483647.0 {
return None
}
if d.to_int().to_double() != d {
return None // not an integer
}
Some(d.to_int())
}
}
}
///|
/// Parse an unsigned 32-bit literal (`0..=4294967295`) into its two's-complement
/// `Int` form (values above 2^31 become negative), or `None` on bad input.
fn parse_uint32(s : String) -> Int? {
match parse_double(s) {
None => None
Some(d) => {
if d < 0.0 || d >= 4294967296.0 {
return None
}
if d != d.round() {
return None // not an integer
}
if d <= 2147483647.0 {
Some(d.to_int())
} else {
// Above 2^31: shift into the lower half so the result fits `Int` as a
// two's-complement negative (e.g. 4294967295 -> -1).
Some((d - 4294967296.0).to_int())
}
}
}
}
///|
/// Parse a signed 16-bit integer literal (-32768..=32767), or `None`.
fn parse_int16(s : String) -> Int? {
match parse_double(s) {
None => None
Some(d) => {
if d < -32768.0 || d > 32767.0 {
return None
}
if d != d.round() {
return None
}
Some(d.to_int())
}
}
}
///|
/// Parse an unsigned 16-bit literal (0..=65535), or `None`.
fn parse_uint16(s : String) -> Int? {
match parse_double(s) {
None => None
Some(d) => {
if d < 0.0 || d > 65535.0 {
return None
}
if d != d.round() {
return None
}
Some(d.to_int())
}
}
}
///|
/// Render an `Int` holding a 32-bit two's-complement value as its unsigned
/// decimal form (`0..=4294967295`).
fn unsigned32_str(v : Int) -> String {
if v >= 0 {
Int64::from_int(v).to_string()
} else {
(4294967296L + Int64::from_int(v)).to_string()
}
}
///|
/// Hex value of a character, or `None` for non-hex input.
fn hex_val(c : Char) -> Int? {
if c >= '0' && c <= '9' {
Some(c.to_int() - '0'.to_int())
} else if c >= 'a' && c <= 'f' {
Some(c.to_int() - 'a'.to_int() + 10)
} else if c >= 'A' && c <= 'F' {
Some(c.to_int() - 'A'.to_int() + 10)
} else {
None
}
}
///|
/// Parse a hex string that must contain exactly `n` hex digits.
fn parse_hex_n(s : String, n : Int) -> Int? {
if s.length() != n {
return None
}
let mut v = 0
for i = 0; i < n; i = i + 1 {
match hex_val(s[i].to_int().unsafe_to_char()) {
None => return None
Some(h) => v = v * 16 + h
}
}
Some(v)
}
///|
/// One hex digit as an uppercase character.
fn hex_digit(n : Int) -> String {
if n < 10 {
('0'.to_int() + n).unsafe_to_char().to_string()
} else {
('A'.to_int() + n - 10).unsafe_to_char().to_string()
}
}
///|
/// A 4-digit uppercase hex word, e.g. `0x3FC0` renders as `3FC0`.
fn hex_word(w : Int) -> String {
hex_digit((w >> 12) & 0xF) +
hex_digit((w >> 8) & 0xF) +
hex_digit((w >> 4) & 0xF) +
hex_digit(w & 0xF)
}
///|
/// A 2-digit uppercase hex byte.
fn hex_byte(b : Int) -> String {
hex_digit((b >> 4) & 0xF) + hex_digit(b & 0xF)
}
///|
/// Render an array of bytes as `0xHH 0xHH ...`.
fn bytes_str(bs : Array[Int]) -> String {
let mut out = ""
for b in bs {
if out.length() > 0 {
out = out + " "
}
out = out + "0x" + hex_byte(b)
}
out
}
///|
/// Parse every token as a two-digit hex byte, or `None` if any token is bad.
fn parse_hex_bytes(tokens : Array[String]) -> Array[Int]? {
let out : Array[Int] = []
for t in tokens {
match parse_hex_n(t, 2) {
None => return None
Some(b) => out.push(b)
}
}
Some(out)
}
///|
/// Encode a numeric value into (or decode it from) Modbus register words.
///
/// `--encode` takes a VALUE; `--decode` takes positional hex tokens. Decoded
/// tokens are read as words (4 hex digits, MSW first) OR, when the count points
/// to a byte payload, as individual wire bytes (2 hex digits each) reassembled
/// under the selected `--order` (and `--word-order` for 32-bit integers).
fn run_encode(
is_encode : Bool,
ty : String,
val : String,
tokens : Array[String],
order_s : String,
word_order_s : String,
) -> Unit {
let order = resolve_order(order_s)
let wo = @modbus_lint.word_order_of(word_order_s)
let woName = @modbus_lint.word_order_name(wo)
if is_encode {
match parse_double(val) {
None => {
println("cannot parse value: " + val)
return
}
Some(v) =>
if ty == "float32" {
let (hi, lo) = @modbus_lint.float32_words(v)
let bs = @modbus_lint.float32_bytes(v, order, wo)
println("float32 = " + val)
println(
" words (MSW first): 0x" + hex_word(hi) + " 0x" + hex_word(lo),
)
println(
" bytes (" +
@modbus_lint.byte_order_name(order) +
" + " +
woName +
"): " +
bytes_str(bs),
)
} else if ty == "float64" {
let (w0, w1, w2, w3) = @modbus_lint.float64_words(v)
let bs = @modbus_lint.float64_bytes(v, order, wo)
println("float64 = " + val)
println(
" words (MSW first): 0x" +
hex_word(w0) +
" 0x" +
hex_word(w1) +
" 0x" +
hex_word(w2) +
" 0x" +
hex_word(w3),
)
println(
" bytes (" +
@modbus_lint.byte_order_name(order) +
" + " +
woName +
"): " +
bytes_str(bs),
)
} else if ty == "int32" || ty == "uint32" {
let parsed = if ty == "int32" {
parse_int32(val)
} else {
parse_uint32(val)
}
match parsed {
None => {
println("invalid " + ty + ": " + val)
return
}
Some(v) => {
let (w0, w1) = @modbus_lint.int32_words(v, wo)
let bs = @modbus_lint.encode_int32_bytes(v, order, wo)
if ty == "int32" {
println("int32 = " + val)
} else {
println(
"uint32 = " + val + " (unsigned " + unsigned32_str(v) + ")",
)
}
println(
" words (" +
woName +
"): 0x" +
hex_word(w0) +
" 0x" +
hex_word(w1),
)
println(
" bytes (" +
@modbus_lint.byte_order_name(order) +
" + " +
woName +
"): " +
bytes_str(bs),
)
}
}
} else if ty == "int16" || ty == "uint16" {
let parsed = if ty == "int16" {
parse_int16(val)
} else {
parse_uint16(val)
}
match parsed {
None => {
println("invalid " + ty + ": " + val)
return
}
Some(v) => {
let w = @modbus_lint.encode_uint16(v)
let bs = @modbus_lint.int16_bytes(v, order)
if ty == "int16" {
println("int16 = " + val + " (signed " + v.to_string() + ")")
} else {
println("uint16 = " + val)
}
println(" word: 0x" + hex_word(w))
println(
" bytes (" +
@modbus_lint.byte_order_name(order) +
"): " +
bytes_str(bs),
)
}
}
} else {
println(
"unknown type: " +
ty +
" (expected int16|uint16|int32|uint32|float32|float64)",
)
}
}
return
}
// -------------------------------------------------------------------------
// Decode: tokens may be words (4 hex digits, MSW first) or, for a byte-count
// match, raw wire bytes (2 hex digits) reassembled under the selected order.
// -------------------------------------------------------------------------
if ty == "float32" {
if tokens.length() == 2 {
let hi = parse_hex_n(tokens[0], 4)
let lo = parse_hex_n(tokens[1], 4)
match (hi, lo) {
(Some(h), Some(l)) => {
let (mh, ml) = match wo {
HighWordLowAddress => (h, l)
LowWordLowAddress => (l, h)
}
println(
"float32 0x" +
tokens[0] +
" 0x" +
tokens[1] +
" = " +
@modbus_lint.float32_from_words(mh, ml).to_string(),
)
}
_ => println("float32 words must be four-digit hex")
}
} else if tokens.length() == 4 {
match parse_hex_bytes(tokens) {
Some(bs) =>
println(
"float32 [" +
@modbus_lint.byte_order_name(order) +
" + " +
woName +
"] = " +
@modbus_lint.float32_from_bytes(bs, order, wo).to_string(),
)
None => println("float32 bytes must be two-digit hex")
}
} else {
println("float32 decode needs 2 words or 4 bytes")
}
return
}
if ty == "float64" {
if tokens.length() == 4 {
let w0 = parse_hex_n(tokens[0], 4)
let w1 = parse_hex_n(tokens[1], 4)
let w2 = parse_hex_n(tokens[2], 4)
let w3 = parse_hex_n(tokens[3], 4)
match (w0, w1, w2, w3) {
(Some(a), Some(b), Some(c), Some(d)) => {
let (m0, m1, m2, m3) = match wo {
HighWordLowAddress => (a, b, c, d)
LowWordLowAddress => (d, c, b, a)
}
println(
"float64 = " +
@modbus_lint.float64_from_words(m0, m1, m2, m3).to_string(),
)
}
_ => println("float64 words must be four-digit hex")
}
} else if tokens.length() == 8 {
match parse_hex_bytes(tokens) {
Some(bs) =>
println(
"float64 [" +
@modbus_lint.byte_order_name(order) +
" + " +
woName +
"] = " +
@modbus_lint.float64_from_bytes(bs, order, wo).to_string(),
)
None => println("float64 bytes must be two-digit hex")
}
} else {
println("float64 decode needs 4 words or 8 bytes")
}
return
}
if ty == "int32" || ty == "uint32" {
if tokens.length() == 2 {
let hi = parse_hex_n(tokens[0], 4)
let lo = parse_hex_n(tokens[1], 4)
match (hi, lo) {
(Some(h), Some(l)) => {
let v = @modbus_lint.int32_fold_words(h, l, wo)
if ty == "int32" {
println(
"int32 0x" + tokens[0] + " 0x" + tokens[1] + " = " + v.to_string(),
)
} else {
println(
"uint32 0x" +
tokens[0] +
" 0x" +
tokens[1] +
" = " +
unsigned32_str(v),
)
}
}
_ => println(ty + " words must be four-digit hex")
}
return
}
if tokens.length() == 4 {
match parse_hex_bytes(tokens) {
Some(bs) => {
let v = @modbus_lint.decode_int32_bytes(bs, order, wo)
if ty == "int32" {
println(
"int32 [" +
@modbus_lint.byte_order_name(order) +
" " +
woName +
"] = " +
v.to_string(),
)
} else {
println(
"uint32 [" +
@modbus_lint.byte_order_name(order) +
" " +
woName +
"] = " +
unsigned32_str(v),
)
}
}
None => println(ty + " bytes must be two-digit hex")
}
return
}
println(ty + " decode needs 2 words or 4 bytes")
return
}
if ty == "int16" || ty == "uint16" {
if tokens.length() == 1 {
match parse_hex_n(tokens[0], 4) {
Some(w) => {
let wval = w & 0xFFFF
if ty == "int16" {
println(
"int16 0x" +
tokens[0] +
" = " +
@modbus_lint.decode_int16(wval).to_string(),
)
} else {
println(
"uint16 0x" +
tokens[0] +
" = " +
@modbus_lint.decode_uint16(wval).to_string(),
)
}
}
None => println("bad " + ty + " word (need 4 hex digits)")
}
} else if tokens.length() == 2 {
// Two two-digit tokens are read as a raw wire byte pair.
match parse_hex_bytes(tokens) {
Some(bs) => {
let wval = @modbus_lint.int16_from_bytes(bs, order) & 0xFFFF
if ty == "int16" {
println(
"int16 [" +
@modbus_lint.byte_order_name(order) +
"] = " +
@modbus_lint.decode_int16(wval).to_string(),
)
} else {
println(
"uint16 [" +
@modbus_lint.byte_order_name(order) +
"] = " +
@modbus_lint.decode_uint16(wval).to_string(),
)
}
}
None => println(ty + " bytes must be two-digit hex")
}
} else {
println(ty + " decode needs 1 word or 2 bytes")
}
return
}
println(
"unknown type: " +
ty +
" (expected int16|uint16|int32|uint32|float32|float64)",
)
}
///|
/// Join the leftover (non-flag) arguments into a single point-table line. The
/// tokens are re-joined with a single space so that a register line the shell
/// split on whitespace (e.g. `a 40001 int16 R`) is reconstructed correctly.
fn join_lines(lines : Array[String]) -> String {
let mut out = ""
for t in lines {
if out.length() > 0 {
out = out + " "
}
out = out + t
}
out
}
///|
/// Print `msg` and terminate with a non-zero status, so a CI build can block
/// delivery. `abort` always traps; its own message is dropped on non-native
/// backends, so we print the reason ourselves first. Generic in the return type
/// so it can be used both as a statement and in expression position.
fn[T] fail(msg : String) -> T {
println(msg)
@abort.abort(msg)
}
///|
/// Parse, optionally sort-and-show, then lint and print the device.
fn run_device(
parsed : Result[@modbus_lint.Device, String],
fmt : OutputFormat,
gap_opt : Int?,
show : Bool,
want_stats : Bool,
) -> Unit {
let dev = match parsed {
Err(e) => fail("parse error: " + e)
Ok(d) => d
}
if want_stats {
println("=== point-table statistics ===")
println(@modbus_lint.stats_text(dev))
return
}
if show {
print_table(@modbus_lint.sort_by_address(dev))
}
let issues = match gap_opt {
Some(n) => @modbus_lint.lint_with_gap(dev, n)
None => @modbus_lint.lint(dev)
}
let out = match fmt {
Text => @modbus_lint.render(issues)
Json => @modbus_lint.render_json(issues)
Markdown => @modbus_lint.render_markdown(issues)
Summary => @modbus_lint.render_summary(dev, issues)
ByArea => @modbus_lint.render_by_area(dev)
Csv => @modbus_lint.render_csv(issues)
Html => @modbus_lint.render_html(issues)
}
println(out)
// Error-severity findings block delivery: exit non-zero so a CI build fails.
let errs = @modbus_lint.error_count(issues)
if errs > 0 {
fail(
"modbus_lint: " + errs.to_string() + " error(s) found; blocking delivery",
)
}
}
///|
/// Right-pad `s` to `width` columns so table columns line up.
fn pad_to(s : String, width : Int) -> String {
let mut out = s
while out.length() < width {
out = out + " "
}
out
}
///|
/// Print a point table, sorted by address, as a small aligned text table.
fn print_table(dev : @modbus_lint.Device) -> Unit {
println(
pad_to("name", 14) +
" " +
pad_to("address", 7) +
" " +
pad_to("type", 8) +
" " +
pad_to("access", 6) +
" " +
pad_to("unit", 10) +
" jsonb",
)
for r in dev.registers {
println(
pad_to(r.name, 14) +
" " +
@modbus_lint.pad_num(r.address) +
" " +
pad_to(@modbus_lint.type_name(r.rtype), 8) +
" " +
pad_to(@modbus_lint.access_name(r.access), 6) +
" " +
pad_to(r.unit, 10) +
" " +
r.jsonb,
)
}
println("")
}
///|
/// A small demo table that intentionally contains one address overlap so the
/// linter output is easy to see.
fn example_text() -> String {
"# example point table - demo (contains one intentional overlap)\n" +
"coil_temp 40001 float32 R degC jsonb->coil_temp\n" +
"conveyor 40003 bool RW jsonb->conveyor_run\n" +
"tank_level 40004 uint16 R mm jsonb->tank_level\n" +
"spray_prs 40005 uint16 R kPa jsonb->spray_press\n" +
"big_val 40010 float32 R jsonb->big_val\n" +
"small_val 40011 int16 R jsonb->small_val\n"
}