1package main
2
3import (
4 "bufio"
5 "fmt"
6 "os"
7 "regexp"
8 "slices"
9 "strings"
10)
11
12var xrefPattern = regexp.MustCompile(`^\s*([A-Za-z0-9][A-Za-z0-9+_.-]*)\((\d+[A-Za-z]*)\)\s*$`)
13var groupPattern = regexp.MustCompile(`\{([A-Za-z0-9_-]+)\}`)
14
15func parseNameSummary(line string) (string, string, bool) {
16 line = strings.TrimSpace(line)
17 if idx := strings.Index(line, ":"); idx >= 0 {
18 return strings.TrimSpace(line[:idx]), strings.TrimSpace(line[idx+1:]), true
19 }
20 if idx := strings.Index(line, " \\- "); idx >= 0 {
21 return strings.TrimSpace(line[:idx]), strings.TrimSpace(line[idx+4:]), true
22 }
23 if idx := strings.Index(line, " - "); idx >= 0 {
24 return strings.TrimSpace(line[:idx]), strings.TrimSpace(line[idx+3:]), true
25 }
26 return "", "", false
27}
28
29func extractManLines(path string, cfg Config) ([]string, error) {
30 contentBytes, err := os.ReadFile(path)
31 if err != nil {
32 return nil, err
33 }
34
35 var lines []string
36 stack := &ifStack{}
37 sc := bufio.NewScanner(strings.NewReader(string(contentBytes)))
38 sc.Buffer(make([]byte, 1<<20), 1<<20)
39 inBlockComment := false
40
41 for sc.Scan() {
42 line := sc.Text()
43 trimmed := strings.TrimSpace(line)
44
45 if inBlockComment {
46 if strings.Contains(trimmed, "*/") {
47 inBlockComment = false
48 idx := strings.Index(trimmed, "*/")
49 before := strings.TrimSpace(trimmed[:idx])
50 if strings.HasPrefix(before, "* ") {
51 before = before[2:]
52 } else if before == "*" {
53 before = ""
54 }
55 if before != "" {
56 lines = append(lines, stripManPrefix(before))
57 }
58 continue
59 }
60
61 stripped := trimmed
62 if strings.HasPrefix(stripped, "* ") {
63 stripped = stripped[2:]
64 } else if stripped == "*" {
65 stripped = ""
66 }
67 lines = append(lines, stripManPrefix(stripped))
68 continue
69 }
70
71 if strings.HasPrefix(trimmed, "#") {
72 directive := trimmed[1:]
73 if ci := strings.Index(directive, "//"); ci >= 0 {
74 directive = directive[:ci]
75 }
76 directive = strings.TrimSpace(directive)
77
78 switch {
79 case strings.HasPrefix(directive, "ifdef "):
80 key := strings.TrimSpace(directive[6:])
81 _, defined := cfg[key]
82 pa := stack.parentActive()
83 stack.push(pa && defined, defined)
84 case strings.HasPrefix(directive, "ifndef "):
85 key := strings.TrimSpace(directive[7:])
86 _, defined := cfg[key]
87 pa := stack.parentActive()
88 stack.push(pa && !defined, !defined)
89 case strings.HasPrefix(directive, "if "):
90 expr := strings.TrimSpace(directive[3:])
91 result := evalCondition(expr, cfg)
92 pa := stack.parentActive()
93 stack.push(pa && result, result)
94 case directive == "else":
95 if top := stack.top(); top != nil {
96 pa := stack.parentActive()
97 top.active = pa && !top.seen
98 top.seen = true
99 }
100 case strings.HasPrefix(directive, "elif "):
101 if top := stack.top(); top != nil {
102 expr := strings.TrimSpace(directive[5:])
103 result := evalCondition(expr, cfg)
104 pa := stack.parentActive()
105 top.active = pa && !top.seen && result
106 if result {
107 top.seen = true
108 }
109 }
110 case directive == "endif":
111 stack.pop()
112 }
113 continue
114 }
115
116 if !stack.globallyActive() {
117 continue
118 }
119
120 if strings.Contains(trimmed, "/* ?man") {
121 startIdx := strings.Index(trimmed, "/* ?man")
122 rest := strings.TrimSpace(trimmed[startIdx+7:])
123 if strings.HasSuffix(rest, "*/") {
124 lines = append(lines, strings.TrimSpace(rest[:len(rest)-2]))
125 } else {
126 lines = append(lines, rest)
127 inBlockComment = true
128 }
129 continue
130 }
131
132 if idx := strings.Index(trimmed, "// ?man"); idx >= 0 {
133 lines = append(lines, strings.TrimSpace(trimmed[idx+7:]))
134 }
135 }
136
137 if err := sc.Err(); err != nil {
138 return nil, err
139 }
140 return lines, nil
141}
142
143func stripManPrefix(line string) string {
144 if strings.HasPrefix(line, "// ?man ") {
145 return line[8:]
146 }
147 if strings.HasPrefix(line, "// ?man") {
148 return line[7:]
149 }
150 return line
151}
152
153func ParsePage(path string, cfg Config, section int, date string) (*Page, error) {
154 lines, err := extractManLines(path, cfg)
155 if err != nil {
156 return nil, err
157 }
158
159 page := &Page{Section: section, Date: date}
160
161 var descriptionLines []string
162 var sections []rawSection
163 var currentSection *rawSection
164 var sawContent bool
165 var fallbackArgs string
166 var rawOptions []rawOption
167 var currentOption *rawOption
168 optionIndex := make(map[string]int)
169
170 for _, raw := range lines {
171 line := strings.TrimSpace(raw)
172 if line == "" {
173 if currentSection != nil {
174 currentSection.Lines = append(currentSection.Lines, "")
175 } else if currentOption != nil {
176 currentOption.Lines = append(currentOption.Lines, "")
177 } else if sawContent {
178 descriptionLines = append(descriptionLines, "")
179 }
180 continue
181 }
182
183 if page.Name == "" && !strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "#") {
184 if name, summary, ok := parseNameSummary(line); ok {
185 page.Name = name
186 page.Summary = summary
187 sawContent = true
188 continue
189 }
190 }
191
192 lower := strings.ToLower(line)
193 switch {
194 case strings.HasPrefix(lower, "synopsis:"):
195 currentOption = nil
196 form := strings.TrimSpace(line[len("synopsis:"):])
197 if form != "" {
198 page.Synopsis = append(page.Synopsis, parseSynopsis(form, true))
199 }
200 sawContent = true
201 continue
202 case strings.HasPrefix(lower, "arguments:"):
203 currentOption = nil
204 fallbackArgs = strings.TrimSpace(line[len("arguments:"):])
205 sawContent = true
206 continue
207 case strings.HasPrefix(line, "## "):
208 currentOption = nil
209 title := strings.TrimSpace(line[3:])
210 sections = append(sections, rawSection{Title: title})
211 currentSection = §ions[len(sections)-1]
212 sawContent = true
213 continue
214 }
215
216 if currentSection == nil {
217 if opt, ok := parseOption(line); ok {
218 rawOptions = append(rawOptions, rawOption{
219 Option: opt,
220 Lines: []string{opt.Desc},
221 })
222 currentOption = &rawOptions[len(rawOptions)-1]
223 } else if isCodeLabel(line) || isCodeLabelPrefix(line) {
224 continue
225 } else if currentOption != nil {
226 currentOption.Lines = append(currentOption.Lines, line)
227 } else {
228 descriptionLines = append(descriptionLines, line)
229 }
230 sawContent = true
231 continue
232 }
233
234 currentSection.Lines = append(currentSection.Lines, line)
235 sawContent = true
236 }
237
238 if !sawContent || page.Name == "" {
239 return nil, nil
240 }
241
242 // explicit synopsis forms are authoritative. for simple pages we can produce
243 // a synopsis from the parsed option specs plus the arguments line
244 for _, raw := range rawOptions {
245 raw.Option.Body = parseBlocks(raw.Lines, "")
246 addOption(page, optionIndex, raw.Option)
247 }
248
249 if len(page.Synopsis) == 0 && fallbackArgs != "" {
250 page.Synopsis = append(page.Synopsis, synthesizeSynopsis(page.Options, fallbackArgs))
251 }
252
253 page.Description = parseBlocks(descriptionLines, "")
254 for _, section := range sections {
255 page.Sections = append(page.Sections, Section{
256 Title: section.Title,
257 Blocks: parseBlocks(section.Lines, section.Title),
258 })
259 }
260 normalizePageInlines(page)
261
262 return page, nil
263}
264
265type rawSection struct {
266 Title string
267 Lines []string
268}
269
270type rawOption struct {
271 Option Option
272 Lines []string
273}
274
275func parseOption(line string) (Option, bool) {
276 if !strings.HasPrefix(line, "-") {
277 return Option{}, false
278 }
279
280 parts := strings.Split(line, ":")
281 if len(parts) < 2 {
282 return Option{}, false
283 }
284
285 specParts := []string{strings.TrimSpace(parts[0])}
286 i := 1
287 for i < len(parts)-1 {
288 part := strings.TrimSpace(parts[i])
289 if !looksLikeOptionArg(part) {
290 break
291 }
292 specParts = append(specParts, part)
293 i++
294 }
295
296 desc := strings.TrimSpace(strings.Join(parts[i:], ":"))
297 flag, typ, group, required := splitOptionSpec(strings.Join(specParts, ":"))
298 if flag == "" {
299 return Option{}, false
300 }
301
302 rawSpec := flag
303 if typ != "" {
304 rawSpec += " " + typ
305 }
306 spec := normalizeOptionSpec(rawSpec)
307 desc = stripRepeatedSpec(spec, desc)
308 if spec == "" || desc == "" {
309 return Option{}, false
310 }
311
312 return Option{
313 Spec: parseSynopsis(spec, false).Items,
314 Desc: desc,
315 Key: synopsisKey(spec) + "\x00" + group,
316 Group: group,
317 Required: required,
318 }, true
319}
320
321// splitOptionSpec peels the required marker (!) and the mutual exclusion
322// group marker ({name}) off a raw option spec, leaving the bare flag and
323// its optional colon-delimited type suffix
324func splitOptionSpec(raw string) (flag, typ, group string, required bool) {
325 raw = strings.TrimSpace(raw)
326 if strings.HasSuffix(raw, "!") {
327 required = true
328 raw = raw[:len(raw)-1]
329 }
330 if m := groupPattern.FindStringSubmatchIndex(raw); m != nil {
331 group = raw[m[2]:m[3]]
332 raw = raw[:m[0]] + raw[m[1]:]
333 }
334 if idx := strings.Index(raw, ":"); idx >= 0 {
335 flag = raw[:idx]
336 typ = raw[idx+1:]
337 } else {
338 flag = raw
339 }
340 return flag, typ, group, required
341}
342
343func looksLikeOptionArg(s string) bool {
344 if s == "" {
345 return false
346 }
347 return !strings.ContainsAny(s, " \t")
348}
349
350func normalizeOptionSpec(spec string) string {
351 spec = strings.TrimSpace(spec)
352 if spec == "" {
353 return ""
354 }
355
356 parts := strings.Split(spec, ":")
357 if len(parts) == 1 {
358 return strings.Join(strings.Fields(spec), " ")
359 }
360
361 normalized := []string{strings.TrimSpace(parts[0])}
362 for _, part := range parts[1:] {
363 part = strings.TrimSpace(part)
364 if part == "" {
365 continue
366 }
367 normalized = append(normalized, part)
368 }
369 return strings.Join(normalized, " ")
370}
371
372func stripRepeatedSpec(spec, desc string) string {
373 spec = strings.TrimSpace(spec)
374 desc = strings.TrimSpace(desc)
375 candidates := []string{
376 spec + ":",
377 strings.ReplaceAll(spec, " ", ":") + ":",
378 }
379 for _, candidate := range candidates {
380 if strings.HasPrefix(desc, candidate) {
381 return strings.TrimSpace(desc[len(candidate):])
382 }
383 }
384 return desc
385}
386
387func addOption(page *Page, index map[string]int, opt Option) {
388 if idx, ok := index[opt.Key]; ok {
389 if betterOptionDesc(opt.Desc, page.Options[idx].Desc) {
390 page.Options[idx].Desc = opt.Desc
391 page.Options[idx].Spec = opt.Spec
392 page.Options[idx].Body = opt.Body
393 }
394 return
395 }
396
397 primary := optionPrimaryFlag(opt)
398 if primary != "" {
399 for idx, existing := range page.Options {
400 if optionPrimaryFlag(existing) != primary {
401 continue
402 }
403 if betterOptionDesc(opt.Desc, existing.Desc) {
404 page.Options[idx].Desc = opt.Desc
405 page.Options[idx].Spec = opt.Spec
406 page.Options[idx].Body = opt.Body
407 delete(index, existing.Key)
408 index[opt.Key] = idx
409 }
410 return
411 }
412 }
413
414 index[opt.Key] = len(page.Options)
415 page.Options = append(page.Options, opt)
416}
417
418func betterOptionDesc(newDesc, oldDesc string) bool {
419 return optionDescScore(newDesc) > optionDescScore(oldDesc)
420}
421
422func optionDescScore(desc string) int {
423 desc = strings.TrimSpace(desc)
424 score := len(desc)
425 lower := strings.ToLower(desc)
426 if strings.HasPrefix(lower, "specify ") && strings.HasSuffix(lower, " option") {
427 score -= 1000
428 }
429 return score
430}
431
432func optionPrimaryFlag(opt Option) string {
433 for _, item := range opt.Spec {
434 if item.Kind == SynFlag {
435 return item.Text
436 }
437 }
438 return ""
439}
440
441func synopsisKey(spec string) string {
442 return strings.Join(strings.Fields(spec), " ")
443}
444
445func isCodeLabel(line string) bool {
446 if !strings.HasSuffix(line, ":") {
447 return false
448 }
449 for _, r := range line[:len(line)-1] {
450 if (r < 'A' || r > 'Z') && (r < '0' || r > '9') && r != '_' {
451 return false
452 }
453 }
454 return len(line) > 1
455}
456
457func isCodeLabelPrefix(line string) bool {
458 idx := strings.IndexByte(line, ':')
459 if idx <= 0 {
460 return false
461 }
462 return isCodeLabel(line[:idx+1])
463}
464
465func parseBlocks(lines []string, title string) []Block {
466 lines = trimBlankLines(lines)
467 if len(lines) == 0 {
468 return nil
469 }
470
471 if blocks, ok := parseSubsections(lines, title); ok {
472 return blocks
473 }
474
475 if items, ok := parseHeadingItems(lines); ok {
476 return []Block{{Kind: BlockTaggedList, Items: items}}
477 }
478
479 if items, ok := parseSequentialTaggedItems(lines); ok {
480 return []Block{{Kind: BlockTaggedList, Items: items}}
481 }
482
483 paragraphs := splitParagraphs(lines)
484 var blocks []Block
485
486 for _, paragraph := range paragraphs {
487 if len(paragraph) == 0 {
488 continue
489 }
490
491 if isSeeAlsoTitle(title) {
492 if refs, ok := parseXRefs(strings.Join(paragraph, " ")); ok {
493 slices.SortFunc(refs, func(a, b XRef) int {
494 if a.Section != b.Section {
495 return strings.Compare(a.Section, b.Section)
496 }
497 return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
498 })
499 blocks = append(blocks, Block{Kind: BlockSeeAlso, Refs: refs})
500 continue
501 }
502 }
503
504 if item, ok := parseTaggedItem(paragraph); ok {
505 if len(blocks) != 0 && blocks[len(blocks)-1].Kind == BlockTaggedList {
506 blocks[len(blocks)-1].Items = append(blocks[len(blocks)-1].Items, item)
507 } else {
508 blocks = append(blocks, Block{Kind: BlockTaggedList, Items: []Item{item}})
509 }
510 continue
511 }
512
513 blocks = append(blocks, Block{
514 Kind: BlockParagraph,
515 Inlines: parseInlines(strings.Join(paragraph, " ")),
516 })
517 }
518
519 return blocks
520}
521
522func parseSubsections(lines []string, title string) ([]Block, bool) {
523 hasHeading := false
524 for _, line := range lines {
525 if strings.HasPrefix(strings.TrimSpace(line), "### ") {
526 hasHeading = true
527 break
528 }
529 }
530 if !hasHeading {
531 return nil, false
532 }
533
534 var blocks []Block
535 var preamble []string
536 for len(lines) != 0 && !strings.HasPrefix(strings.TrimSpace(lines[0]), "### ") {
537 preamble = append(preamble, lines[0])
538 lines = lines[1:]
539 }
540 if parsed := parseBlocks(preamble, title); len(parsed) != 0 {
541 blocks = append(blocks, parsed...)
542 }
543
544 for len(lines) != 0 {
545 head := strings.TrimSpace(lines[0])
546 if !strings.HasPrefix(head, "### ") {
547 return nil, false
548 }
549 subtitle := strings.TrimSpace(head[4:])
550 lines = lines[1:]
551 var body []string
552 for len(lines) != 0 && !strings.HasPrefix(strings.TrimSpace(lines[0]), "### ") {
553 body = append(body, lines[0])
554 lines = lines[1:]
555 }
556 blocks = append(blocks, Block{
557 Kind: BlockSubsection,
558 Title: subtitle,
559 Blocks: parseBlocks(body, subtitle),
560 })
561 }
562
563 return blocks, true
564}
565
566func trimBlankLines(lines []string) []string {
567 start := 0
568 for start < len(lines) && strings.TrimSpace(lines[start]) == "" {
569 start++
570 }
571 end := len(lines)
572 for end > start && strings.TrimSpace(lines[end-1]) == "" {
573 end--
574 }
575 return lines[start:end]
576}
577
578func splitParagraphs(lines []string) [][]string {
579 var paragraphs [][]string
580 var current []string
581 for _, line := range lines {
582 if strings.TrimSpace(line) == "" {
583 if len(current) != 0 {
584 paragraphs = append(paragraphs, current)
585 current = nil
586 }
587 continue
588 }
589 current = append(current, line)
590 }
591 if len(current) != 0 {
592 paragraphs = append(paragraphs, current)
593 }
594 return paragraphs
595}
596
597func parseHeadingItems(lines []string) ([]Item, bool) {
598 var items []Item
599 var current *Item
600 for _, line := range lines {
601 if strings.TrimSpace(line) == "" {
602 continue
603 }
604 if strings.HasPrefix(line, "### ") {
605 items = append(items, Item{Label: parseInlines(strings.TrimSpace(line[4:]))})
606 current = &items[len(items)-1]
607 continue
608 }
609 if current == nil {
610 return nil, false
611 }
612 if len(current.Body) != 0 {
613 current.Body = append(current.Body, Inline{Kind: InlineText, Text: " "})
614 }
615 current.Body = append(current.Body, parseInlines(strings.TrimSpace(line))...)
616 }
617 return items, len(items) != 0
618}
619
620func parseSequentialTaggedItems(lines []string) ([]Item, bool) {
621 var items []Item
622 for i := 0; i < len(lines); {
623 for i < len(lines) && strings.TrimSpace(lines[i]) == "" {
624 i++
625 }
626 if i >= len(lines) {
627 break
628 }
629
630 label := strings.TrimSpace(lines[i])
631 if label == "" || i+1 >= len(lines) {
632 return nil, false
633 }
634 first := strings.TrimSpace(lines[i+1])
635 if !strings.HasPrefix(first, ":") {
636 return nil, false
637 }
638
639 bodyLines := []string{strings.TrimSpace(strings.TrimPrefix(first, ":"))}
640 i += 2
641 for i < len(lines) {
642 line := strings.TrimSpace(lines[i])
643 if line == "" {
644 i++
645 break
646 }
647 if i+1 < len(lines) && strings.TrimSpace(lines[i]) != "" &&
648 strings.HasPrefix(strings.TrimSpace(lines[i+1]), ":") {
649 break
650 }
651 bodyLines = append(bodyLines, line)
652 i++
653 }
654
655 items = append(items, Item{
656 Label: parseInlines(label),
657 Body: parseInlines(strings.Join(compactLines(bodyLines), " ")),
658 })
659 }
660 return items, len(items) != 0
661}
662
663func parseTaggedItem(lines []string) (Item, bool) {
664 if len(lines) < 2 {
665 return Item{}, false
666 }
667 label := strings.TrimSpace(lines[0])
668 bodyLines := lines[1:]
669 if label == "" {
670 return Item{}, false
671 }
672
673 first := strings.TrimSpace(bodyLines[0])
674 if !strings.HasPrefix(first, ":") {
675 return Item{}, false
676 }
677
678 bodyLines[0] = strings.TrimSpace(strings.TrimPrefix(first, ":"))
679 return Item{
680 Label: parseInlines(label),
681 Body: parseInlines(strings.Join(compactLines(bodyLines), " ")),
682 }, true
683}
684
685func parseInlines(s string) []Inline {
686 var out []Inline
687 var text strings.Builder
688 flushText := func() {
689 if text.Len() == 0 {
690 return
691 }
692 out = append(out, Inline{Kind: InlineText, Text: text.String()})
693 text.Reset()
694 }
695
696 // inline semantics are parsed here so the renderer never has to recover
697 // any richness like paths, flags, emphasis, or xrefs by reparsing raw strings
698 for i := 0; i < len(s); {
699 switch s[i] {
700 case '`':
701 end := strings.IndexByte(s[i+1:], '`')
702 if end < 0 {
703 text.WriteByte(s[i])
704 i++
705 continue
706 }
707 end += i + 1
708 flushText()
709 out = append(out, classifyInlineLiteral(s[i+1:end]))
710 i = end + 1
711 case '_':
712 end := strings.IndexByte(s[i+1:], '_')
713 if end < 0 {
714 text.WriteByte(s[i])
715 i++
716 continue
717 }
718 end += i + 1
719 flushText()
720 out = append(out, Inline{
721 Kind: InlineEmph,
722 Children: parseInlines(s[i+1 : end]),
723 })
724 i = end + 1
725 default:
726 text.WriteByte(s[i])
727 i++
728 }
729 }
730
731 flushText()
732 return out
733}
734
735func classifyInlineLiteral(s string) Inline {
736 s = strings.TrimSpace(s)
737 if match := xrefPattern.FindStringSubmatch(s); match != nil {
738 return Inline{Kind: InlineXRef, Text: match[1], Section: match[2]}
739 }
740 if strings.HasPrefix(s, "/") {
741 return Inline{Kind: InlinePath, Text: s}
742 }
743 if strings.HasPrefix(s, "-") {
744 return Inline{Kind: InlineFlag, Text: strings.TrimPrefix(s, "-")}
745 }
746 return Inline{Kind: InlineLiteral, Text: s}
747}
748
749func synthesizeSynopsis(options []Option, args string) SynopsisForm {
750 type unit struct {
751 sortKey string
752 items []SynopsisItem
753 }
754
755 groups := make(map[string][]Option)
756 var groupOrder []string
757 var units []unit
758
759 for _, opt := range options {
760 if opt.Group == "" {
761 if opt.Required {
762 units = append(units, unit{
763 sortKey: optionSortKey(opt),
764 items: cloneSynopsisItems(opt.Spec),
765 })
766 } else {
767 units = append(units, unit{
768 sortKey: optionSortKey(opt),
769 items: []SynopsisItem{{
770 Kind: SynOptional,
771 Children: cloneSynopsisItems(opt.Spec),
772 }},
773 })
774 }
775 continue
776 }
777 if _, seen := groups[opt.Group]; !seen {
778 groupOrder = append(groupOrder, opt.Group)
779 }
780 groups[opt.Group] = append(groups[opt.Group], opt)
781 }
782
783 for _, name := range groupOrder {
784 members := groups[name]
785 required := false
786 for _, m := range members {
787 if m.Required {
788 required = true
789 break
790 }
791 }
792
793 var children []SynopsisItem
794 for i, m := range members {
795 if i != 0 {
796 children = append(children, SynopsisItem{Kind: SynPipe, Text: "|"})
797 }
798 children = append(children, cloneSynopsisItems(m.Spec)...)
799 }
800
801 kind := SynOptional
802 if required {
803 kind = SynRequiredGroup
804 }
805 units = append(units, unit{
806 sortKey: optionSortKey(members[0]),
807 items: []SynopsisItem{{Kind: kind, Children: children}},
808 })
809 }
810
811 slices.SortFunc(units, func(a, b unit) int {
812 return strings.Compare(a.sortKey, b.sortKey)
813 })
814
815 var items []SynopsisItem
816 for _, u := range units {
817 items = append(items, u.items...)
818 }
819 items = append(items, parseSynopsis(args, false).Items...)
820 return SynopsisForm{Items: items}
821}
822
823func optionSortKey(opt Option) string {
824 flag := optionPrimaryFlag(opt)
825 if flag == "" {
826 return "zzzz"
827 }
828 r := flag[0]
829 prefix := "2"
830 switch {
831 case r >= '0' && r <= '9':
832 prefix = "0"
833 case r >= 'A' && r <= 'Z':
834 prefix = "1"
835 }
836 return prefix + flag
837}
838
839func cloneSynopsisItems(items []SynopsisItem) []SynopsisItem {
840 out := make([]SynopsisItem, len(items))
841 for i, item := range items {
842 out[i] = item
843 if len(item.Children) != 0 {
844 out[i].Children = cloneSynopsisItems(item.Children)
845 }
846 }
847 return out
848}
849
850func compactLines(lines []string) []string {
851 var out []string
852 for _, line := range lines {
853 line = strings.TrimSpace(line)
854 if line != "" {
855 out = append(out, line)
856 }
857 }
858 return out
859}
860
861func isSeeAlsoTitle(title string) bool {
862 return strings.EqualFold(strings.TrimSpace(title), "SEE ALSO")
863}
864
865func parseXRefs(s string) ([]XRef, bool) {
866 var refs []XRef
867 for _, part := range strings.Split(s, ",") {
868 part = strings.TrimSpace(part)
869 match := xrefPattern.FindStringSubmatch(part)
870 if match == nil {
871 return nil, false
872 }
873 refs = append(refs, XRef{Name: match[1], Section: match[2]})
874 }
875 return refs, len(refs) != 0
876}
877
878func parseSynopsis(raw string, explicit bool) SynopsisForm {
879 tokens := tokenizeSynopsis(raw)
880 items, _ := parseSynopsisSeq(tokens, 0, explicit, true)
881 return SynopsisForm{Items: items}
882}
883
884func tokenizeSynopsis(raw string) []string {
885 var tokens []string
886 var current strings.Builder
887 flush := func() {
888 if current.Len() != 0 {
889 tokens = append(tokens, current.String())
890 current.Reset()
891 }
892 }
893
894 for i := 0; i < len(raw); i++ {
895 ch := raw[i]
896 switch ch {
897 case '[', ']', '{', '}', '|':
898 flush()
899 tokens = append(tokens, string(ch))
900 case ' ', '\t', '\n':
901 flush()
902 default:
903 current.WriteByte(ch)
904 }
905 }
906 flush()
907 return tokens
908}
909
910func parseSynopsisSeq(tokens []string, start int, explicit bool, topLevel bool) ([]SynopsisItem, int) {
911 var items []SynopsisItem
912 seenNonFlag := false
913
914 for i := start; i < len(tokens); i++ {
915 switch tokens[i] {
916 case "]", "}":
917 return items, i
918 case "[":
919 children, end := parseSynopsisSeq(tokens, i+1, explicit, false)
920 items = append(items, SynopsisItem{Kind: SynOptional, Children: children})
921 i = end
922 case "{":
923 children, end := parseSynopsisSeq(tokens, i+1, explicit, false)
924 items = append(items, SynopsisItem{Kind: SynRequiredGroup, Children: children})
925 i = end
926 case "|":
927 items = append(items, SynopsisItem{Kind: SynPipe, Text: "|"})
928 default:
929 if flags, ok := splitGroupedFlags(tokens[i]); ok {
930 items = append(items, flags...)
931 continue
932 }
933 kind := classifySynopsisToken(tokens, i, explicit, topLevel, seenNonFlag)
934 text := tokens[i]
935 if text == "..." && len(items) != 0 {
936 items[len(items)-1].Text += " ..."
937 continue
938 }
939 items = append(items, SynopsisItem{Kind: kind, Text: text})
940 if kind != SynFlag && kind != SynPipe {
941 seenNonFlag = true
942 }
943 }
944 }
945
946 return items, len(tokens)
947}
948
949func splitGroupedFlags(tok string) ([]SynopsisItem, bool) {
950 // we expand compacted synopsis tokens like some -abcDef into separate
951 // semantic flags so the renderer can emit a separate Fl for each one
952 if len(tok) < 3 || tok[0] != '-' {
953 return nil, false
954 }
955 for i := 1; i < len(tok); i++ {
956 c := tok[i]
957 if (c < '0' || c > '9') && (c < 'A' || c > 'Z') && (c < 'a' || c > 'z') {
958 return nil, false
959 }
960 }
961
962 items := make([]SynopsisItem, 0, len(tok)-1)
963 for i := 1; i < len(tok); i++ {
964 items = append(items, SynopsisItem{
965 Kind: SynFlag,
966 Text: "-" + string(tok[i]),
967 })
968 }
969 return items, true
970}
971
972func classifySynopsisToken(tokens []string, i int, explicit, topLevel, seenNonFlag bool) SynopsisItemKind {
973 tok := tokens[i]
974 if strings.HasPrefix(tok, "-") {
975 return SynFlag
976 }
977 if strings.HasPrefix(tok, "<") && strings.HasSuffix(tok, ">") {
978 return SynArg
979 }
980 if strings.Contains(tok, "...") || strings.ContainsAny(tok, "/=:") {
981 return SynArg
982 }
983 if i > 0 && tokens[i-1] == "|" {
984 if explicit {
985 return SynCommand
986 }
987 return SynArg
988 }
989 if i+1 < len(tokens) && tokens[i+1] == "|" {
990 if explicit {
991 return SynCommand
992 }
993 return SynArg
994 }
995 if i > 0 && strings.HasPrefix(tokens[i-1], "-") {
996 return SynArg
997 }
998 if explicit && topLevel && !seenNonFlag && i+1 < len(tokens) && tokens[i+1] == "[" {
999 return SynCommand
1000 }
1001 return SynArg
1002}
1003
1004func (p *Page) validate() error {
1005 if p.Name == "" {
1006 return fmt.Errorf("missing manpage name")
1007 }
1008 if p.Summary == "" {
1009 return fmt.Errorf("missing manpage summary")
1010 }
1011 return nil
1012}
1013
1014func normalizePageInlines(page *Page) {
1015 for i := range page.Description {
1016 page.Description[i] = mergeAdjacentTextInBlock(page.Description[i])
1017 }
1018 for i := range page.Options {
1019 for j := range page.Options[i].Body {
1020 page.Options[i].Body[j] = mergeAdjacentTextInBlock(page.Options[i].Body[j])
1021 }
1022 }
1023 for i := range page.Sections {
1024 for j := range page.Sections[i].Blocks {
1025 page.Sections[i].Blocks[j] = mergeAdjacentTextInBlock(page.Sections[i].Blocks[j])
1026 }
1027 }
1028}
1029
1030func mergeAdjacentTextInBlock(block Block) Block {
1031 block.Inlines = mergeAdjacentText(block.Inlines)
1032 for i := range block.Items {
1033 block.Items[i].Label = mergeAdjacentText(block.Items[i].Label)
1034 block.Items[i].Body = mergeAdjacentText(block.Items[i].Body)
1035 }
1036 return block
1037}
1038
1039func mergeAdjacentText(inlines []Inline) []Inline {
1040 if len(inlines) == 0 {
1041 return nil
1042 }
1043 out := []Inline{inlines[0]}
1044 for _, inline := range inlines[1:] {
1045 last := &out[len(out)-1]
1046 if last.Kind == InlineText && inline.Kind == InlineText {
1047 last.Text += inline.Text
1048 continue
1049 }
1050 out = append(out, inline)
1051 }
1052 return out
1053}