Skip to main content

roff/
lib.rs

1// 引入 serde_json 用于 JSON 序列化,std::path 用于文件路径处理
2use serde_json::{Map, Value};
3use std::path::Path;
4
5/// 表示 man 文档中的一个 section(章节)
6/// 例如:NAME, SYNOPSIS, DESCRIPTION 等
7#[derive(Default)]
8struct Section {
9    title: String,      // 章节标题,如 "NAME", "DESCRIPTION"
10    text: String,       // 章节内的普通文本内容
11    items: Vec<String>, // 章节内的列表项(如 OPTIONS 下的 -a, -l 等)
12    in_list: bool,      // 标记当前是否在列表中(.Bl/.El 之间)
13}
14
15/// 表示整个 man 文档的结构
16#[derive(Default)]
17struct Doc {
18    title: Option<String>,   // 文档标题,如 "LS"
19    section: Option<String>, // 手册 section,如 "1"
20    date: Option<String>,    // 文档日期
21    name: Option<String>,    // 命令/函数名称
22    desc: Option<String>,    // 简短描述
23    envs: Vec<String>,       // 环境变量列表
24    xrefs: Vec<String>,      // 交叉引用列表 (Xr)
25    sections: Vec<Section>,  // 所有章节的列表
26}
27
28/// 去除 macro 参数的首尾空白和双引号
29/// 例如: `"  hello  "` -> `hello`
30fn trim_macro_arg(s: &str) -> String {
31    s.trim().trim_matches('"').to_string()
32}
33
34/// 读取文件内容,使用 lossy 方式处理 UTF-8(用于处理 mac 目录的 ISO-8859 编码文件)
35pub fn read_to_string_lossy<P: AsRef<Path>>(path: P) -> std::io::Result<String> {
36    let bytes = std::fs::read(path)?;
37    Ok(String::from_utf8_lossy(&bytes).to_string())
38}
39
40/// 将文本推送到当前 section 的 text 字段
41/// 如果 text 非空,会先添加一个空格再追加内容
42/// 会对文本进行转义序列处理
43fn push_text(sec: &mut Section, line: &str) {
44    if !sec.text.is_empty() {
45        sec.text.push(' ');
46    }
47    sec.text.push_str(&format_inline_macros(line));
48}
49
50/// 处理行内的转义字符和 & 引用
51/// 处理 \\& -> (移除), \\e -> \, &. -> . 等
52/// 处理 \fB -> **, \fR -> 关闭粗体, \fI -> *, \fP -> 关闭斜体, \f(CW -> `, \f4 -> `
53fn format_inline_macros(arg: &str) -> String {
54    let mut result = String::new();
55    let mut i = 0;
56    let chars: Vec<char> = arg.chars().collect();
57    let mut bold_open = false;
58    let mut italic_open = false;
59
60    while i < chars.len() {
61        // 处理反斜杠转义
62        if chars[i] == '\\' {
63            i += 1;
64            if i < chars.len() {
65                // 处理 \fX 字体转义序列
66                if chars[i] == 'f' && i + 1 < chars.len() {
67                    let font = chars[i + 1];
68                    match font {
69                        'B' | 'b' | '3' => {
70                            // 开启粗体
71                            if italic_open {
72                                result.push('*');
73                                italic_open = false;
74                            }
75                            if bold_open {
76                                result.push_str("**");
77                            } else {
78                                result.push_str("**");
79                                bold_open = true;
80                            }
81                            i += 2;
82                            continue;
83                        }
84                        'R' | 'r' | '1' => {
85                            // 关闭粗体,回到普通字体
86                            if bold_open {
87                                result.push_str("**");
88                                bold_open = false;
89                            }
90                            i += 2;
91                            continue;
92                        }
93                        'I' | 'i' | '2' => {
94                            // 开启斜体
95                            if bold_open {
96                                result.push_str("**");
97                                bold_open = false;
98                            }
99                            if !italic_open {
100                                result.push('*');
101                                italic_open = true;
102                            }
103                            i += 2;
104                            continue;
105                        }
106                        'P' | 'p' => {
107                            // 关闭当前字体(斜体或粗体),回到普通字体
108                            if italic_open {
109                                result.push('*');
110                                italic_open = false;
111                            }
112                            if bold_open {
113                                result.push_str("**");
114                                bold_open = false;
115                            }
116                            i += 2;
117                            continue;
118                        }
119                        '(' => {
120                            // \f(CW - 等宽字体
121                            if bold_open {
122                                result.push_str("**");
123                                bold_open = false;
124                            }
125                            if italic_open {
126                                result.push('*');
127                                italic_open = false;
128                            }
129                            if i + 3 < chars.len() {
130                                let cw: String = chars[i + 2..i + 4].iter().collect();
131                                if cw == "CW" || cw == "cw" {
132                                    result.push('`');
133                                    i += 4;
134                                    continue;
135                                }
136                            }
137                            result.push('\\');
138                            i += 1;
139                            continue;
140                        }
141                        '4' => {
142                            // \f4 - 等宽字体 (VT100)
143                            if bold_open {
144                                result.push_str("**");
145                                bold_open = false;
146                            }
147                            if italic_open {
148                                result.push('*');
149                                italic_open = false;
150                            }
151                            result.push('`');
152                            i += 2;
153                            continue;
154                        }
155                        _ => {
156                            result.push('\\');
157                            i += 1;
158                            continue;
159                        }
160                    }
161                }
162                if chars[i] == '&' {
163                    // \\& 表示输出 & 后的字符(如 &. 输出 .)
164                    i += 1;
165                    if i < chars.len() {
166                        result.push(chars[i]);
167                    }
168                } else if chars[i] == 'e' {
169                    // \\e 转义为 \
170                    result.push('\\');
171                } else {
172                    result.push(chars[i]);
173                }
174            }
175            i += 1;
176            continue;
177        }
178
179        // 处理 & 引用(&后跟标点符号时输出该符号)
180        if chars[i] == '&' {
181            i += 1;
182            if i < chars.len() {
183                let next = chars[i];
184                if next == '.' || next == ',' || next == ';' || next == ':' {
185                    result.push(next);
186                } else {
187                    result.push('&');
188                    result.push(next);
189                }
190            }
191            i += 1;
192            continue;
193        }
194
195        result.push(chars[i]);
196        i += 1;
197    }
198
199    // 关闭未闭合的字体标记
200    if bold_open {
201        result.push_str("**");
202    }
203    if italic_open {
204        result.push('*');
205    }
206
207    result
208}
209
210/// 根据 macro 名称格式化参数
211/// 将 roff macro 转换为 Markdown 格式
212/// 例如: .Fl a -> -a, .Ar file -> _file_, .Pq x -> (x)
213fn format_macro(macro_name: &str, arg: &str) -> String {
214    match macro_name {
215        "Op" => format!("[{}]", format_nested_macros(arg)), // [可选参数]
216        "Ar" => format!("_{}_", format_nested_macros(arg)), // _参数_
217        "Fl" => format!("-{}", format_nested_macros(arg).trim_start_matches('-')), // -选项
218        "Pa" => format_nested_macros(arg),                  // 文件路径(保持原样)
219        "Xr" => {
220            // 交叉引用,如 ls(1)
221            let parts: Vec<&str> = arg.trim().split_whitespace().collect();
222            if parts.len() >= 2 {
223                format!("**{}**({})", parts[0], parts[1])
224            } else if !parts.is_empty() {
225                format!("**{}**", parts[0])
226            } else {
227                String::new()
228            }
229        }
230        "Li" => format!("`{}`", format_nested_macros(arg)), // `代码`
231        "Va" => format!("_{}_", format_nested_macros(arg)), // _变量_
232        "Ev" => format!("_{}_", format_nested_macros(arg)), // _环境变量_
233        "Cm" => format!("**{}**", format_nested_macros(arg)), // **命令**
234        "Tn" => format_nested_macros(arg),                  // 技术术语(保持原样)
235        "Sq" => format!("'{}'", format_nested_macros(arg)), // '单引号'
236        "Ql" => format!("`{}`", format_nested_macros(arg)), // `原义`
237        "Dq" => format!("\"{}\"", format_nested_macros(arg)), // "双引号"
238        "Em" => format!("_{}_", format_nested_macros(arg)), // _强调_
239        "Sy" => format!("**{}**", format_nested_macros(arg)), // **粗体**
240        "Pq" => format!("({})", format_nested_macros(arg)), // (圆括号)
241        "Nm" => format!("**{}**", format_nested_macros(arg)), // **名称**
242        "St" => String::new(),                              // 标准(不输出)
243        _ => format_nested_macros(arg),                     // 其他 macro 递归处理
244    }
245}
246
247/// 处理嵌套的 inline macro
248/// 例如: .Pq Sq Pa \&. -> (. ')
249/// 先检查第一个词是否是 macro,如果是则递归处理
250fn format_nested_macros(arg: &str) -> String {
251    let trimmed = arg.trim();
252    let words: Vec<&str> = trimmed.split_whitespace().collect();
253    if words.is_empty() {
254        return format_inline_macros(arg);
255    }
256
257    let first = words[0];
258    if is_inline_macro(first) {
259        let rest = words[1..].join(" ");
260        format_macro(first, &rest)
261    } else {
262        format_inline_macros(arg)
263    }
264}
265
266/// 检查是否是 inline macro(不需要换行的行内 macro)
267fn is_inline_macro(name: &str) -> bool {
268    matches!(
269        name,
270        "Fl" | "Ar"
271            | "Nm"
272            | "Pa"
273            | "Cm"
274            | "Va"
275            | "Ev"
276            | "Li"
277            | "Sy"
278            | "Em"
279            | "Sq"
280            | "Ql"
281            | "Dq"
282            | "Tn"
283            | "Xr"
284            | "Op"
285            | "Pq"
286    )
287}
288
289/// 将 man 文档内容解析为 JSON Value
290/// 这是核心解析函数,逐行处理 roff macro
291pub fn parse_to_json(input: &str) -> Value {
292    let mut doc = Doc::default(); // 整个文档
293    let mut current = Section::default(); // 当前正在处理的章节
294    let mut have_section = bool::default(); // 是否已经有章节
295    let mut found_header = false; // 是否已经找到文档标题 (.Dt/.TH)
296
297    // 逐行解析输入
298    for raw in input.lines() {
299        let line = raw.trim_end();
300
301        // 跳过注释行 .\" 开头的行
302        if line.starts_with(".\"") {
303            continue;
304        }
305
306        // .Dt TITLE SECTION - 文档标题和 section
307        // .TH TITLE SECTION - BSD/macOS 风格,等同于 .Dt
308        if line.starts_with(".Dt ") || line.starts_with(".TH ") {
309            // 找到标题后,清除之前解析的所有内容(版权声明等)
310            doc = Doc::default();
311            current = Section::default();
312            have_section = false;
313            found_header = true;
314
315            let rest = line[4..].trim();
316            let mut parts = rest.split_whitespace();
317            let t = parts.next().map(|s| s.to_string());
318            let sec = parts.next().map(|s| s.to_string());
319            doc.title = t;
320            doc.section = sec;
321            continue;
322        }
323
324        // 如果还没有找到标题,先跳过所有内容
325        if !found_header {
326            continue;
327        }
328
329        // .Dd DATE - 文档日期
330        if line.starts_with(".Dd ") {
331            doc.date = Some(trim_macro_arg(&line[4..]));
332            continue;
333        }
334
335        // .Os - 操作系统(跳过)
336        if line.starts_with(".Os") {
337            continue;
338        }
339
340        // .St - 标准(跳过,不输出)
341        if line.starts_with(".St") {
342            continue;
343        }
344
345        // 忽略格式化指令:.ad .na .hy .br .sp .nr 等
346        if line == ".ad"
347            || line == ".na"
348            || line.starts_with(".hy")
349            || line == ".br"
350            || line == ".sp"
351            || line.starts_with(".nr")
352            || line == ".ns"
353            || line == ".rs"
354            || line.starts_with(".ll")
355            || line.starts_with(".ta")
356            || line == ".fi"
357            || line == ".nf"
358        {
359            continue;
360        }
361
362        // .Sh TITLE - 开始新章节 (支持 .Sh 和 .SH)
363        let line_upper = line.to_uppercase();
364        if line.starts_with(".Sh ") || line_upper.starts_with(".SH ") {
365            if have_section {
366                doc.sections.push(current);
367                current = Section::default();
368            } else {
369                have_section = true;
370            }
371            current.title = trim_macro_arg(&line[4..]);
372            continue;
373        }
374        // .Nm NAME - 命令/函数名称
375        if line.starts_with(".Nm") {
376            let arg = line.get(3..).unwrap_or("").trim();
377            if !arg.is_empty() {
378                if doc.name.is_none() {
379                    doc.name = Some(trim_macro_arg(arg));
380                } else {
381                    push_text(&mut current, &format!("**{}**", trim_macro_arg(arg)));
382                }
383            } else if let Some(ref n) = doc.name {
384                push_text(&mut current, &format!("**{}**", n));
385            }
386            continue;
387        }
388
389        // .Nd DESCRIPTION - 简短描述
390        if line.starts_with(".Nd ") {
391            doc.desc = Some(trim_macro_arg(&line[4..]));
392            continue;
393        }
394
395        // .Ev ENV_VAR - 环境变量(收集到列表中)
396        if line.starts_with(".Ev ") {
397            let env = trim_macro_arg(&line[4..]);
398            if !env.is_empty() && !doc.envs.contains(&env) {
399                doc.envs.push(env);
400            }
401            continue;
402        }
403
404        // .Xr NAME SECTION - 交叉引用(收集到列表中)
405        if line.starts_with(".Xr ") {
406            let mut xref = trim_macro_arg(&line[4..]);
407            xref = xref.trim_end_matches(',').trim().to_string();
408            if !xref.is_empty() && !doc.xrefs.contains(&xref) {
409                doc.xrefs.push(xref);
410            }
411            continue;
412        }
413
414        // .Bl - 开始列表(tagged list, enum 等)
415        if line.starts_with(".Bl")
416            || (line.len() >= 3 && line.starts_with(".") && &line[1..3] == "Bl")
417        {
418            current.in_list = true;
419            continue;
420        }
421
422        // .El - 结束列表
423        if line.starts_with(".El")
424            || (line.len() >= 3 && line.starts_with(".") && &line[1..3] == "El")
425        {
426            current.in_list = false;
427            continue;
428        }
429
430        // .It - 列表项
431        if line.starts_with(".It")
432            || (line.len() >= 3 && line.starts_with(".") && &line[1..3] == "It")
433        {
434            let arg = line.get(3..).unwrap_or("").trim();
435            if current.in_list {
436                if !arg.is_empty() {
437                    let formatted = format_nested_macros(arg);
438                    current.items.push(formatted);
439                } else {
440                    current.items.push(String::new());
441                }
442            } else {
443                if !arg.is_empty() {
444                    push_text(&mut current, arg.trim());
445                }
446            }
447            continue;
448        }
449        // .Pp - 段落分隔
450        if line.starts_with(".Pp") {
451            if !current.text.is_empty() && !current.text.ends_with('\n') {
452                current.text.push_str("\n\n");
453            }
454            continue;
455        }
456
457        // 在列表内处理 macro 行(重要:添加到 items 而不是 text)
458        if current.in_list && line.starts_with('.') && line.len() > 2 {
459            let macro_part = &line[1..3];
460            let rest = if line.len() > 3 { line[3..].trim() } else { "" };
461            let formatted = format_macro(macro_part, rest);
462            if !formatted.is_empty() {
463                if let Some(last) = current.items.last_mut() {
464                    if !last.is_empty() {
465                        last.push(' ');
466                    }
467                    last.push_str(&formatted);
468                }
469                continue;
470            }
471        }
472        if line.starts_with('.') && line.len() > 2 {
473            let macro_part = &line[1..3];
474            let rest = if line.len() > 3 { line[3..].trim() } else { "" };
475            let formatted = format_macro(macro_part, rest);
476            if !formatted.is_empty() {
477                push_text(&mut current, &formatted);
478                continue;
479            }
480        }
481        if line.starts_with('.') {
482            continue;
483        }
484        if current.in_list {
485            if let Some(last) = current.items.last_mut() {
486                let trimmed = line.trim();
487                if trimmed.starts_with('.') && trimmed.len() > 2 {
488                    let macro_part = &trimmed[1..3];
489                    let rest = if trimmed.len() > 3 { &trimmed[3..] } else { "" };
490                    let formatted = format_macro(macro_part, rest.trim());
491                    if !formatted.is_empty() {
492                        if !last.is_empty() {
493                            last.push(' ');
494                        }
495                        last.push_str(&formatted);
496                    }
497                } else if !trimmed.is_empty() {
498                    let formatted = format_inline_macros(trimmed);
499                    if !last.is_empty() {
500                        last.push(' ');
501                    }
502                    last.push_str(&formatted);
503                }
504            } else {
505                let trimmed = line.trim();
506                let formatted = format_inline_macros(trimmed);
507                current.items.push(formatted);
508            }
509        } else {
510            if !line.trim().is_empty() {
511                push_text(&mut current, line.trim());
512            }
513        }
514    }
515    if have_section {
516        doc.sections.push(current);
517    }
518
519    let mut sections_json = Vec::new();
520    for s in doc.sections {
521        let mut o = Map::new();
522        o.insert("title".to_string(), Value::String(s.title));
523        if !s.text.trim().is_empty() {
524            o.insert("text".to_string(), Value::String(s.text.trim().to_string()));
525        }
526        if !s.items.is_empty() {
527            let arr = s
528                .items
529                .into_iter()
530                .map(|v| Value::String(v.trim().to_string()))
531                .collect::<Vec<_>>();
532            o.insert("items".to_string(), Value::Array(arr));
533        }
534        sections_json.push(Value::Object(o));
535    }
536
537    let mut root = Map::new();
538    if let Some(t) = doc.title {
539        root.insert("title".to_string(), Value::String(t));
540    }
541    if let Some(s) = doc.section {
542        root.insert("section".to_string(), Value::String(s));
543    }
544    if let Some(d) = doc.date {
545        root.insert("date".to_string(), Value::String(d));
546    }
547    if let Some(n) = doc.name {
548        root.insert("name".to_string(), Value::String(n));
549    }
550    if let Some(d) = doc.desc {
551        root.insert("description".to_string(), Value::String(d));
552    }
553    if !doc.envs.is_empty() {
554        let arr = doc.envs.into_iter().map(Value::String).collect();
555        root.insert("envs".to_string(), Value::Array(arr));
556    }
557    if !doc.xrefs.is_empty() {
558        let arr = doc.xrefs.into_iter().map(Value::String).collect();
559        root.insert("xrefs".to_string(), Value::Array(arr));
560    }
561    root.insert("sections".to_string(), Value::Array(sections_json));
562    Value::Object(root)
563}
564
565pub fn parse_to_string(input: &str, pretty: bool) -> String {
566    let v = parse_to_json(input);
567    if pretty {
568        serde_json::to_string_pretty(&v).unwrap()
569    } else {
570        serde_json::to_string(&v).unwrap()
571    }
572}
573
574pub fn to_markdown(json: &Value) -> String {
575    let mut out = String::new();
576
577    out.push_str("---\n");
578    if let Some(t) = json.get("title").and_then(|v| v.as_str()) {
579        out.push_str("title: ");
580        out.push_str(t);
581        out.push('\n');
582    }
583    if let Some(s) = json.get("section").and_then(|v| v.as_str()) {
584        out.push_str("section: ");
585        out.push_str(s);
586        out.push('\n');
587    }
588    if let Some(n) = json.get("name").and_then(|v| v.as_str()) {
589        out.push_str("name: ");
590        out.push_str(n);
591        out.push('\n');
592    }
593    if let Some(d) = json.get("description").and_then(|v| v.as_str()) {
594        out.push_str("description: ");
595        out.push_str(d);
596        out.push('\n');
597    }
598    if let Some(date) = json.get("date").and_then(|v| v.as_str()) {
599        out.push_str("date: ");
600        out.push_str(date);
601        out.push('\n');
602    }
603    if let Some(envs) = json.get("envs").and_then(|v| v.as_array()) {
604        if !envs.is_empty() {
605            out.push_str("env:\n");
606            for env in envs {
607                if let Some(e) = env.as_str() {
608                    out.push_str("  ");
609                    out.push_str(e);
610                    out.push_str(": true\n");
611                }
612            }
613        }
614    }
615    if let Some(xrefs) = json.get("xrefs").and_then(|v| v.as_array()) {
616        if !xrefs.is_empty() {
617            out.push_str("xref:\n");
618            for xref in xrefs {
619                if let Some(x) = xref.as_str() {
620                    out.push_str("  - ");
621                    out.push_str(x);
622                    out.push('\n');
623                }
624            }
625        }
626    }
627    out.push_str("---\n\n");
628
629    if let Some(t) = json.get("title").and_then(|v| v.as_str()) {
630        out.push_str("# ");
631        out.push_str(t);
632        if let Some(s) = json.get("section").and_then(|v| v.as_str()) {
633            out.push('(');
634            out.push_str(s);
635            out.push(')');
636        }
637        out.push('\n');
638    }
639    if let Some(n) = json.get("name").and_then(|v| v.as_str()) {
640        if !out.ends_with('\n') {
641            out.push('\n');
642        }
643        out.push_str("\n**");
644        out.push_str(n);
645        out.push_str("**");
646        if let Some(d) = json.get("description").and_then(|v| v.as_str()) {
647            out.push_str(" - ");
648            out.push_str(d);
649        }
650        out.push('\n');
651    }
652    if let Some(sections) = json.get("sections").and_then(|v| v.as_array()) {
653        for sec in sections {
654            if let Some(title) = sec.get("title").and_then(|v| v.as_str()) {
655                if !out.ends_with('\n') {
656                    out.push('\n');
657                }
658                out.push_str("\n## ");
659                out.push_str(title);
660                out.push('\n');
661            }
662            if let Some(text) = sec.get("text").and_then(|v| v.as_str()) {
663                if !text.trim().is_empty() {
664                    if !out.ends_with('\n') {
665                        out.push('\n');
666                    }
667                    for para in text.split('\n') {
668                        let p = para.trim();
669                        if !p.is_empty() {
670                            out.push_str(p);
671                            out.push_str("\n\n");
672                        }
673                    }
674                }
675            }
676            if let Some(items) = sec.get("items").and_then(|v| v.as_array()) {
677                for item in items {
678                    if let Some(s) = item.as_str() {
679                        if !s.trim().is_empty() {
680                            out.push_str("- ");
681                            out.push_str(s.trim());
682                            out.push('\n');
683                        }
684                    }
685                }
686            }
687        }
688    }
689    out.trim_end().to_string()
690}