1use serde_json::{Map, Value};
3use std::path::Path;
4
5#[derive(Default)]
8struct Section {
9 title: String, text: String, items: Vec<String>, in_list: bool, }
14
15#[derive(Default)]
17struct Doc {
18 title: Option<String>, section: Option<String>, date: Option<String>, name: Option<String>, desc: Option<String>, envs: Vec<String>, xrefs: Vec<String>, sections: Vec<Section>, }
27
28fn trim_macro_arg(s: &str) -> String {
31 s.trim().trim_matches('"').to_string()
32}
33
34pub 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
40fn 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
50fn 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 if chars[i] == '\\' {
63 i += 1;
64 if i < chars.len() {
65 if chars[i] == 'f' && i + 1 < chars.len() {
67 let font = chars[i + 1];
68 match font {
69 'B' | 'b' | '3' => {
70 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 if bold_open {
87 result.push_str("**");
88 bold_open = false;
89 }
90 i += 2;
91 continue;
92 }
93 'I' | 'i' | '2' => {
94 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 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 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 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 i += 1;
165 if i < chars.len() {
166 result.push(chars[i]);
167 }
168 } else if chars[i] == 'e' {
169 result.push('\\');
171 } else {
172 result.push(chars[i]);
173 }
174 }
175 i += 1;
176 continue;
177 }
178
179 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 if bold_open {
201 result.push_str("**");
202 }
203 if italic_open {
204 result.push('*');
205 }
206
207 result
208}
209
210fn format_macro(macro_name: &str, arg: &str) -> String {
214 match macro_name {
215 "Op" => format!("[{}]", format_nested_macros(arg)), "Ar" => format!("_{}_", format_nested_macros(arg)), "Fl" => format!("-{}", format_nested_macros(arg).trim_start_matches('-')), "Pa" => format_nested_macros(arg), "Xr" => {
220 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)), "Va" => format!("_{}_", format_nested_macros(arg)), "Ev" => format!("_{}_", format_nested_macros(arg)), "Cm" => format!("**{}**", format_nested_macros(arg)), "Tn" => format_nested_macros(arg), "Sq" => format!("'{}'", format_nested_macros(arg)), "Ql" => format!("`{}`", format_nested_macros(arg)), "Dq" => format!("\"{}\"", format_nested_macros(arg)), "Em" => format!("_{}_", format_nested_macros(arg)), "Sy" => format!("**{}**", format_nested_macros(arg)), "Pq" => format!("({})", format_nested_macros(arg)), "Nm" => format!("**{}**", format_nested_macros(arg)), "St" => String::new(), _ => format_nested_macros(arg), }
245}
246
247fn 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
266fn 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
289pub fn parse_to_json(input: &str) -> Value {
292 let mut doc = Doc::default(); let mut current = Section::default(); let mut have_section = bool::default(); let mut found_header = false; for raw in input.lines() {
299 let line = raw.trim_end();
300
301 if line.starts_with(".\"") {
303 continue;
304 }
305
306 if line.starts_with(".Dt ") || line.starts_with(".TH ") {
309 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 if !found_header {
326 continue;
327 }
328
329 if line.starts_with(".Dd ") {
331 doc.date = Some(trim_macro_arg(&line[4..]));
332 continue;
333 }
334
335 if line.starts_with(".Os") {
337 continue;
338 }
339
340 if line.starts_with(".St") {
342 continue;
343 }
344
345 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 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 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 if line.starts_with(".Nd ") {
391 doc.desc = Some(trim_macro_arg(&line[4..]));
392 continue;
393 }
394
395 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 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 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 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 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 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 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}