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 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 = &sections[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}