master xplshn/aruu / scripts / mkman / parse.go
   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 = &sections[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}