// mbt-typeset 命令行入口。
//
// 具体排版逻辑位于库包 `Mr-Houjie/mbt-typeset`,这里只负责:
// 参数解析、文件读写、配置加载、输出与退出码。
///|
/// 版本号。
const VERSION : String = "0.1.0"
///|
/// 用法说明。
fn usage() -> String {
let s = StringBuilder()
s.write_string("mbt-typeset \{VERSION} — 中文排版校对工具\n\n")
s.write_string("用法:\n mbt-typeset [选项] <文件...>\n\n")
s.write_string("选项:\n")
s.write_string(
" --fix 就地修复可自动修复的问题\n",
)
s.write_string(
" --format 输出格式(默认 text)\n",
)
s.write_string(" --config <路径> 读取 JSON 配置\n")
s.write_string(" --markdown 按 Markdown 文档处理\n")
s.write_string(
" --max-line-length 行宽上限(0 表示不检查)\n",
)
s.write_string(
" --rule 启用/关闭某条规则(可重复)\n",
)
s.write_string(" --terminology <源=标准> 追加术语(可重复)\n")
s.write_string(" --list-rules 列出全部规则\n")
s.write_string(" -v, --version 显示版本\n")
s.write_string(" -h, --help 显示本帮助\n\n")
s.write_string("退出码:\n")
s.write_string(" 0 未发现问题\n")
s.write_string(" 1 发现 warning 及以上问题\n")
s.write_string(" 2 用法错误或读写失败\n")
s.to_string()
}
///|
/// 命令行选项。
struct Options {
files : Array[String]
mut fix : Bool
mut format : String
mut config_path : String?
mut markdown : Bool
mut max_line_length : Int
rule_overrides : Array[(String, Bool)]
terminology : Array[(String, String)]
mut list_rules : Bool
mut help : Bool
mut version : Bool
}
///|
/// JSON 取值:字符串。
fn json_string_value(j : Json) -> String? {
match j {
String(s) => Some(s)
_ => None
}
}
///|
/// 输出一段文本,去掉末尾换行,避免 `println` 追加多余空行。
fn output(text : String) -> Unit {
let chars = text.iter().to_array()
let mut n = chars.length()
while n > 0 && (chars[n - 1] == '\n' || chars[n - 1] == '\r') {
n = n - 1
}
if n == 0 {
return
}
let buf = StringBuilder()
for i = 0; i < n; i = i + 1 {
buf.write_char(chars[i])
}
println(buf.to_string())
}
///|
/// 新建默认选项。
fn Options::new() -> Options {
{
files: [],
fix: false,
format: "text",
config_path: None,
markdown: false,
max_line_length: 0,
rule_overrides: [],
terminology: [],
list_rules: false,
help: false,
version: false,
}
}
///|
/// 用法错误:打印提示并退出。
fn usage_error(msg : String) -> Unit {
println("错误: \{msg}")
println("")
output(usage())
@sys.exit(2)
}
///|
/// 解析非负整数,失败返回 `None`。
fn parse_uint(s : String) -> Int? {
if s.is_empty() {
return None
}
let mut v = 0
for c in s {
if !c.is_ascii_digit() {
return None
}
v = v * 10 + (c.to_int() - 48)
}
Some(v)
}
///|
/// 按字符切分字符串。
fn split_char(s : String, sep : Char) -> Array[String] {
let out : Array[String] = []
let buf = StringBuilder()
for c in s {
if c == sep {
out.push(buf.to_string())
buf.reset()
} else {
buf.write_char(c)
}
}
out.push(buf.to_string())
out
}
///|
/// ASCII 转小写(用于术语表的键归一化)。
fn ascii_lower(s : String) -> String {
let buf = StringBuilder()
for c in s {
buf.write_char(c.to_ascii_lowercase())
}
buf.to_string()
}
///|
/// 取下一个参数,越界则报用法错误。
fn next_arg(args : Array[String], i : Int, name : String) -> String {
if i + 1 >= args.length() {
usage_error("\{name} 需要一个参数")
}
args[i + 1]
}
///|
/// 解析 `id=on` 形式的布尔开关。
fn parse_bool_switch(raw : String) -> Bool? {
match raw {
"on" | "true" | "yes" | "1" => Some(true)
"off" | "false" | "no" | "0" => Some(false)
_ => None
}
}
///|
/// 解析命令行参数。
fn parse_args(args : Array[String]) -> Options {
let opts = Options::new()
let mut i = 1
while i < args.length() {
let a = args[i]
match a {
"--fix" => opts.fix = true
"--markdown" => opts.markdown = true
"--list-rules" => opts.list_rules = true
"--help" | "-h" => opts.help = true
"--version" | "-v" => opts.version = true
"--format" => {
let v = next_arg(args, i, "--format")
opts.format = v
i = i + 1
}
"--config" => {
opts.config_path = Some(next_arg(args, i, "--config"))
i = i + 1
}
"--max-line-length" => {
let raw = next_arg(args, i, "--max-line-length")
match parse_uint(raw) {
Some(n) => opts.max_line_length = n
None =>
usage_error(
"--max-line-length 需要非负整数,收到「\{raw}」",
)
}
i = i + 1
}
"--rule" => {
let raw = next_arg(args, i, "--rule")
let parts = split_char(raw, '=')
if parts.length() != 2 {
usage_error("--rule 需要形如 = 的参数")
}
match parse_bool_switch(parts[1]) {
Some(b) =>
if !@lib.is_known_rule(parts[0]) {
usage_error(
"未知规则 id「\{parts[0]}」,可用 --list-rules 查看",
)
} else {
opts.rule_overrides.push((parts[0], b))
}
None => usage_error("--rule 的值只能是 on 或 off")
}
i = i + 1
}
"--terminology" => {
let raw = next_arg(args, i, "--terminology")
let parts = split_char(raw, '=')
if parts.length() != 2 {
usage_error(
"--terminology 需要形如 <源>=<标准写法> 的参数",
)
}
opts.terminology.push((parts[0], parts[1]))
i = i + 1
}
_ => {
if a.has_prefix("--") {
usage_error("未知选项「\{a}」")
}
opts.files.push(a)
}
}
i = i + 1
}
if opts.format != "text" && opts.format != "json" && opts.format != "github" {
usage_error("--format 只支持 text / json / github")
}
opts
}
// ---------------------------------------------------------------------------
// 配置
// ---------------------------------------------------------------------------
///|
/// 读取文本文件。
fn read_text_file(path : String) -> Result[String, String] {
Ok(@fs.read_file_to_string(path)) catch {
_ => Err("读取文件失败")
}
}
///|
/// 写入文本文件。
fn write_text_file(path : String, content : String) -> Result[Unit, String] {
@fs.write_string_to_file(path, content) catch {
_ => return Err("写入文件失败")
}
Ok(())
}
///|
/// JSON 取值:布尔。
fn json_bool(j : Json) -> Bool? {
match j {
True => Some(true)
False => Some(false)
_ => None
}
}
///|
/// JSON 取值:整数。
fn json_int(j : Json) -> Int? {
match j {
Number(d, ..) => Some(d.to_int())
_ => None
}
}
///|
/// 把 JSON 配置应用到 `Config`。
fn apply_json_config(config : @lib.Config, j : Json) -> Unit {
match j {
Object(map) => {
match map.get("markdown") {
Some(v) =>
match json_bool(v) {
Some(b) => config.set_markdown(b)
None => ()
}
None => ()
}
match map.get("maxLineLength") {
Some(v) =>
match json_int(v) {
Some(n) => config.set_max_line_length(n)
None => ()
}
None => ()
}
match map.get("rules") {
Some(Object(rules)) =>
for kv in rules.iter() {
match json_bool(kv.1) {
Some(b) => config.rules.set(kv.0, b)
None => ()
}
}
_ => ()
}
match map.get("terminology") {
Some(Object(terms)) =>
for kv in terms.iter() {
match json_string_value(kv.1) {
Some(v) => config.terminology.set(ascii_lower(kv.0), v)
None => ()
}
}
_ => ()
}
}
_ => ()
}
}
///|
/// 组装最终配置:文件配置 + 命令行覆盖。
fn build_config(opts : Options) -> @lib.Config {
let config = if opts.markdown {
@lib.Config::markdown()
} else {
@lib.Config::new()
}
match opts.config_path {
Some(path) =>
match read_text_file(path) {
Err(msg) => {
println("无法读取配置文件「\{path}」: \{msg}")
@sys.exit(2)
}
Ok(text) => {
let j = @json.parse(text) catch {
e => {
println("配置文件「\{path}」不是合法 JSON: \{e}")
@sys.exit(2)
Json::null()
}
}
apply_json_config(config, j)
}
}
None => ()
}
// 命令行覆盖优先级最高
if opts.max_line_length > 0 {
config.set_max_line_length(opts.max_line_length)
}
for kv in opts.rule_overrides {
config.rules.set(kv.0, kv.1)
}
for kv in opts.terminology {
config.terminology.set(ascii_lower(kv.0), kv.1)
}
config
}
// ---------------------------------------------------------------------------
// 主流程
// ---------------------------------------------------------------------------
///|
/// 处理单个文件,返回是否发现问题。
fn process_file(path : String, config : @lib.Config, opts : Options) -> Bool {
let text = match read_text_file(path) {
Ok(t) => t
Err(msg) => {
println("无法读取「\{path}」: \{msg}")
@sys.exit(2)
""
}
}
if opts.fix {
let outcome = @lib.fix(text, config)
if outcome.applied > 0 {
match write_text_file(path, outcome.output) {
Ok(_) => ()
Err(msg) => {
println("无法写入「\{path}」: \{msg}")
@sys.exit(2)
}
}
}
println(
"\{path}: 已应用 \{outcome.applied} 处修复,剩余 \{outcome.remaining.length()} 处问题",
)
if opts.format == "json" {
output(@lib.render_json(outcome.remaining, path))
}
@lib.has_problems(outcome.remaining)
} else {
let diags = @lib.lint(text, config)
match opts.format {
"json" => output(@lib.render_json(diags, path))
"github" => output(@lib.render_github(diags, path))
_ => output(@lib.render_text(diags, path))
}
@lib.has_problems(diags)
}
}
///|
/// 程序入口。
fn main {
let args = @env.args()
let opts = parse_args(args)
if opts.help {
output(usage())
@sys.exit(0)
return
}
if opts.version {
println("mbt-typeset \{VERSION}")
@sys.exit(0)
return
}
if opts.list_rules {
output(@lib.render_rule_list())
@sys.exit(0)
return
}
if opts.files.is_empty() {
usage_error("至少需要一个输入文件")
}
let config = build_config(opts)
let mut problems = false
for path in opts.files {
if process_file(path, config, opts) {
problems = true
}
}
if problems {
@sys.exit(1)
} else {
@sys.exit(0)
}
}