// Reporter: render lint findings into a human-readable, copy-pasteable report.
///|
/// Render all findings. Returns a short "OK" message when there are none.
pub fn render(issues : Array[Issue]) -> String {
if issues.length() == 0 {
return "OK: no issues found."
}
let mut buf = ""
let mut errs = 0
let mut warns = 0
for it in issues {
match it.severity {
Error => {
errs = errs + 1
buf = buf +
"[ERROR] line " +
it.line.to_string() +
": " +
it.message +
"\n"
}
Warning => {
warns = warns + 1
buf = buf +
"[WARN] line " +
it.line.to_string() +
": " +
it.message +
"\n"
}
}
}
buf +
"\nsummary: " +
errs.to_string() +
" error(s), " +
warns.to_string() +
" warning(s)."
}
///|
/// Count findings with Error severity (used to decide a non-zero exit code).
pub fn error_count(issues : Array[Issue]) -> Int {
let mut n = 0
for it in issues {
match it.severity {
Error => n = n + 1
Warning => n = n
}
}
n
}
///|
/// One lower-case hexadecimal digit as a `Char`.
fn hex_digit_lower(n : Int) -> Char {
if n < 10 {
('0'.to_int() + n).unsafe_to_char()
} else {
('a'.to_int() + n - 10).unsafe_to_char()
}
}
///|
/// 转义 JSON 字符串中的引号、反斜杠与控制字符。
fn escape(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 == '"' {
out.write_string("\\\"")
} else if c == '\\' {
out.write_string("\\\\")
} else if c == '\n' {
out.write_string("\\n")
} else if c == '\r' {
out.write_string("\\r")
} else if c == '\t' {
out.write_string("\\t")
} else if c.to_int() < 0x20 {
out.write_string("\\u00")
out.write_char(hex_digit_lower(c.to_int() / 16))
out.write_char(hex_digit_lower(c.to_int() % 16))
} else {
out.write_char(c)
}
}
out.to_string()
}
///|
/// 以 JSON 数组形式输出,便于 CI 或其他工具消费。
pub fn render_json(issues : Array[Issue]) -> String {
let mut buf = "[\n"
for i = 0; i < issues.length(); i = i + 1 {
let it = issues[i]
let sev = match it.severity {
Error => "error"
Warning => "warning"
}
buf = buf +
" {\"severity\": \"" +
sev +
"\", \"line\": " +
it.line.to_string() +
", \"message\": \"" +
escape(it.message) +
"\"}"
if i + 1 < issues.length() {
buf = buf + ","
}
buf = buf + "\n"
}
buf + "]"
}
///|
test "render reports OK when clean" {
assert_eq(render([]), "OK: no issues found.")
}
///|
test "render counts errors and warnings" {
let issues = [
mk_issue(Error, 1, "dup name"),
mk_issue(Warning, 2, "weird jsonb"),
]
assert_eq(error_count(issues), 1)
assert_eq(render(issues).length() > 0, true)
assert_eq(error_count([mk_issue(Warning, 3, "only a warning")]), 0)
}
///|
test "render_json emits one object per finding" {
let issues = [mk_issue(Error, 7, "dup name")]
assert_eq(
render_json(issues),
"[\n {\"severity\": \"error\", \"line\": 7, \"message\": \"dup name\"}\n]",
)
}
///|
test "render_json on empty findings is an empty array" {
assert_eq(render_json([]), "[\n]")
}
///|
/// 转义 Markdown 表格单元格中的竖线与换行,避免破坏表格结构。
fn escape_md(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 == '|' {
out.write_string("\\|")
} else if c == '\n' || c == '\r' {
out.write_string(" ")
} else {
out.write_char(c)
}
}
out.to_string()
}
///|
/// 转义 HTML 文本中的 & < > " ',避免报告页被点位名破坏。
fn escape_html(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 == '&' {
out.write_string("&")
} else if c == '<' {
out.write_string("<")
} else if c == '>' {
out.write_string(">")
} else if c == '"' {
out.write_string(""")
} else if c == '\'' {
out.write_string("'")
} else {
out.write_char(c)
}
}
out.to_string()
}
///|
/// Renders the findings as a Markdown table (one row per finding), which is
/// convenient to paste into issue trackers or MR descriptions.
pub fn render_markdown(issues : Array[Issue]) -> String {
if issues.length() == 0 {
return "| no findings |"
}
let out = StringBuilder()
out.write_string("| severity | line | message |\n| --- | ---: | --- |\n")
for it in issues {
out.write_string(
"| " +
severity_name(it.severity) +
" | " +
it.line.to_string() +
" | " +
escape_md(it.message) +
" |\n",
)
}
out.to_string()
}
///|
/// Append the Markdown table for one area (heading, header row, register rows).
fn write_area_table(out : StringBuilder, dev : Device, area : Int) -> Unit {
out.write_string("## " + area_name(area) + "\n")
out.write_string("| name | address | type | access | unit | jsonb |\n")
out.write_string("| --- | ---: | --- | --- | --- | --- |\n")
for r in dev.registers {
if area_of(r.address) == area {
out.write_string(
"| " +
escape_md(r.name) +
" | " +
r.address.to_string() +
" | " +
type_name(r.rtype) +
" | " +
access_name(r.access) +
" | " +
escape_md(r.unit) +
" | " +
escape_md(r.jsonb) +
" |\n",
)
}
}
}
///|
/// Renders the point table grouped by Modbus area, with the registers sorted by
/// address within each area. Addresses outside every standard area are listed in
/// a final section so no register silently disappears from the report.
pub fn render_by_area(dev : Device) -> String {
if dev.registers.length() == 0 {
return "(empty point table)"
}
let sorted = sort_by_address(dev)
let out = StringBuilder()
for area = 0; area <= 4; area = area + 1 {
if area == 2 {
continue // area 2 does not exist
}
write_area_table(out, sorted, area)
}
let mut has_outside = false
for r in sorted.registers {
if area_of(r.address) < 0 {
has_outside = true
}
}
if has_outside {
write_area_table(out, sorted, -1)
}
out.to_string()
}
///|
/// A compact summary: total registers, how many errors/warnings, the areas
/// touched, and the number of registers with a jsonb mapping.
pub fn render_summary(dev : Device, issues : Array[Issue]) -> String {
let mut errs = 0
let mut warns = 0
for it in issues {
match it.severity {
Error => errs = errs + 1
Warning => warns = warns + 1
}
}
let areas : Array[Int] = []
let mut mapped = 0
for r in dev.registers {
let ar = area_of(r.address)
if !areas.contains(ar) {
areas.push(ar)
}
if !jsonb_is_unset(r.jsonb) {
mapped = mapped + 1
}
}
let mut area_names = ""
for ar in areas {
if area_names.length() > 0 {
area_names = area_names + ", "
}
area_names = area_names + area_name(ar)
}
let out = StringBuilder()
out.write_string("registers: " + dev.registers.length().to_string() + "\n")
out.write_string(
"areas: " + (if area_names == "" { "-" } else { area_names }) + "\n",
)
out.write_string("errors: " + errs.to_string() + "\n")
out.write_string("warnings: " + warns.to_string() + "\n")
out.write_string("jsonb-mapped: " + mapped.to_string() + "\n")
out.to_string()
}
///|
test "render_markdown emits a table when there are findings" {
let issues = [mk_issue(Error, 5, "dup name")]
let md = render_markdown(issues)
assert_eq(md.has_prefix("| severity | line | message |"), true)
assert_eq(md.contains("| error | 5 | dup name |"), true)
}
///|
test "render_markdown handles an empty findings list" {
assert_eq(render_markdown([]).has_prefix("| no findings |"), true)
}
///|
test "render_by_area lists holding registers" {
let dev : Device = {
registers: [
{
name: "a",
address: 40001,
rtype: TInt16,
access: Read,
unit: "u",
jsonb: "jsonb->a",
line: 1,
},
{
name: "b",
address: 1001,
rtype: TBit,
access: Read,
unit: "",
jsonb: "jsonb->b",
line: 2,
},
],
}
let s = render_by_area(dev)
assert_eq(s.contains("## holding registers"), true)
assert_eq(s.contains("## coils"), true)
assert_eq(s.contains("a | 40001 | int16 | R | u | jsonb->a"), true)
}
///|
test "render_by_area sorts rows and keeps out-of-area registers" {
let dev : Device = {
registers: [
{
name: "late",
address: 40003,
rtype: TInt16,
access: Read,
unit: "",
jsonb: "",
line: 1,
},
{
name: "early",
address: 40001,
rtype: TInt16,
access: Read,
unit: "",
jsonb: "",
line: 2,
},
{
name: "bad",
address: 50001,
rtype: TInt16,
access: Read,
unit: "",
jsonb: "",
line: 3,
},
],
}
let s = render_by_area(dev)
let early_at = s.find("| early | 40001").unwrap()
let late_at = s.find("| late | 40003").unwrap()
assert_eq(early_at < late_at, true)
assert_eq(s.contains("## outside any standard area"), true)
assert_eq(s.contains("| bad | 50001"), true)
}
///|
test "render_summary counts errors, warnings and areas" {
let dev : Device = {
registers: [
{
name: "a",
address: 40001,
rtype: TInt16,
access: Read,
unit: "",
jsonb: "jsonb->a",
line: 1,
},
{
name: "b",
address: 1001,
rtype: TBit,
access: Read,
unit: "",
jsonb: "",
line: 2,
},
],
}
let issues = [mk_issue(Error, 1, "e"), mk_issue(Warning, 2, "w")]
let s = render_summary(dev, issues)
assert_eq(s.contains("registers: 2"), true)
assert_eq(s.contains("errors: 1"), true)
assert_eq(s.contains("warnings: 1"), true)
assert_eq(s.contains("jsonb-mapped: 1"), true)
}
///|
test "render_json escapes double quotes and backslashes" {
let m = "say \"hi\" and \\ done"
let s = render_json([mk_issue(Error, 1, m)])
assert_eq(s.contains("say \\\"hi\\\""), true)
assert_eq(s.contains("and \\\\ done"), true)
}
///|
test "render_json escapes control characters and stays valid" {
let s = render_json([mk_issue(Error, 1, "first\nsecond\tthird")])
assert_eq(@json.valid(s), true)
assert_eq(s.contains("first\\nsecond\\tthird"), true)
}
///|
test "render_json escapes other C0 control characters" {
let s = render_json([mk_issue(Error, 1, "bell:\u{1}")])
assert_eq(@json.valid(s), true)
assert_eq(s.contains("bell:\\u0001"), true)
}
///|
test "render_summary on an empty device" {
let dev : Device = { registers: [], }
let s = render_summary(dev, [])
assert_eq(s.contains("registers: 0"), true)
assert_eq(s.contains("areas: -"), true)
assert_eq(s.contains("jsonb-mapped: 0"), true)
}
///|
test "render_summary reports addresses outside standard areas" {
let dev : Device = {
registers: [
{
name: "bad",
address: 50001,
rtype: TInt16,
access: Read,
unit: "",
jsonb: "",
line: 1,
},
],
}
let s = render_summary(dev, [])
assert_eq(s.contains("areas: outside any standard area"), true)
}
///|
/// 转义 CSV 字段:把 `"` 写成 `""`、把换行写成空格,保证单行可解析。
fn escape_csv(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 == '"' {
out.write_string("\"\"")
} else if c == '\n' || c == '\r' {
out.write_string(" ")
} else {
out.write_char(c)
}
}
out.to_string()
}
///|
/// 以 CSV 形式输出(含表头),适合导入 Excel / 电子表格继续分析。
pub fn render_csv(issues : Array[Issue]) -> String {
let out = StringBuilder()
out.write_string("severity,line,message\n")
for it in issues {
out.write_string(
"\"" +
severity_name(it.severity) +
"\"," +
it.line.to_string() +
",\"" +
escape_csv(it.message) +
"\"\n",
)
}
out.to_string()
}
///|
/// 以 HTML 表格形式输出,便于在浏览器中直接查看或嵌入到报告页。
pub fn render_html(issues : Array[Issue]) -> String {
let out = StringBuilder()
out.write_string("\n\n\n")
out.write_string("\n")
out.write_string("modbus_lint report \n")
out.write_string("\n\n")
out.write_string("modbus_lint findings
\n")
if issues.length() == 0 {
out.write_string("OK: no issues found.
\n")
} else {
out.write_string("\n")
out.write_string(
"severity line message \n",
)
for it in issues {
let cls = if it.severity is Error { "error" } else { "warning" }
out.write_string(
"" +
severity_name(it.severity) +
" " +
it.line.to_string() +
" " +
escape_html(it.message) +
" \n",
)
}
out.write_string("
\n")
}
out.write_string("\n\n")
out.to_string()
}
///|
test "render_csv emits a header and one row per finding" {
let issues = [mk_issue(Error, 1, "dup"), mk_issue(Warning, 2, "gap")]
let s = render_csv(issues)
assert_eq(s.has_prefix("severity,line,message\n"), true)
assert_eq(s.contains("\"error\",1,\"dup\""), true)
assert_eq(s.contains("\"warning\",2,\"gap\""), true)
}
///|
test "render_csv escapes embedded quotes and newlines" {
let m = "say \"x\"\nnext"
let s = render_csv([mk_issue(Error, 3, m)])
assert_eq(s.contains("say \"\"x\"\" next"), true)
}
///|
test "render_html wraps findings in a table" {
let s = render_html([mk_issue(Error, 5, "dup name")])
assert_eq(s.contains(""), true)
assert_eq(s.contains("severity "), true)
assert_eq(s.contains("dup name"), true)
}
///|
test "render_html on clean findings reports OK" {
let s = render_html([])
assert_eq(s.contains("OK:"), true)
}
///|
test "render_html escapes markup in findings" {
let s = render_html([mk_issue(Error, 1, "x & \"q\"")])
assert_eq(s.contains("<b>x</b> & "q""), true)
}
///|
test "render_markdown escapes table separators in findings" {
let s = render_markdown([mk_issue(Error, 1, "a|b")])
assert_eq(s.contains("a\\|b"), true)
}