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