package editor import ( "regexp" "strings" "glint/internal/theme" "github.com/charmbracelet/lipgloss" ) var ( headingRe = regexp.MustCompile(`^\s*#{1,6}\s`) listRe = regexp.MustCompile(`^\s*([-*+]|\d+\.)\s`) ) // blockState carries cross-line context (fenced code, leading frontmatter). type blockState struct { inFence bool inFrontmatter bool frontmatterDone bool } // ScanLines styles every line, threading block state across lines. The returned // slice is parallel to the input: out[i] are the spans for lines[i], whose // concatenated text equals lines[i] exactly. func ScanLines(lines []string, th theme.Theme) [][]Span { st := &blockState{} out := make([][]Span, len(lines)) for i, ln := range lines { out[i] = scanLine(i, ln, st, th) } return out } func scanLine(row int, line string, st *blockState, th theme.Theme) []Span { code := lipgloss.NewStyle().Foreground(th.Code) muted := lipgloss.NewStyle().Foreground(th.Blockquote) trimmed := strings.TrimSpace(line) // Fenced code block: body lines are entirely code-colored. if st.inFence { if strings.HasPrefix(trimmed, "```") { st.inFence = false } return wholeLine(line, code) } if strings.HasPrefix(trimmed, "```") { st.inFence = true return wholeLine(line, code) } // Leading YAML frontmatter: only valid starting at row 0. if st.inFrontmatter { if trimmed == "---" { st.inFrontmatter = false st.frontmatterDone = true return wholeLine(line, muted) } return scanFrontmatter(line, th) } if row == 0 && trimmed == "---" { st.inFrontmatter = true return wholeLine(line, muted) } // Heading: color the whole line, hashes included. if headingRe.MatchString(line) { return wholeLine(line, lipgloss.NewStyle().Foreground(th.Heading).Bold(true)) } // Blockquote. if strings.HasPrefix(trimmed, ">") { return wholeLine(line, muted) } // List marker, then inline-scan the remainder. if loc := listRe.FindStringIndex(line); loc != nil { marker := line[:loc[1]] rest := line[loc[1]:] spans := []Span{{Text: marker, Style: lipgloss.NewStyle().Foreground(th.ListMarker)}} return append(spans, scanInline(rest, th)...) } return scanInline(line, th) } // wholeLine returns a single span for the whole line, or no spans if empty. func wholeLine(line string, style lipgloss.Style) []Span { if line == "" { return nil } return []Span{{Text: line, Style: style}} } // scanFrontmatter highlights one YAML frontmatter line, keeping every character // visible: the spans concatenate back to the raw line exactly. // - "# comment" -> whole line muted // - " - item" -> leading ws + dash (ListMarker) + item (Text) // - "key: value" -> "key:" (Accent) + " value" (Text) // - anything else -> whole line Text func scanFrontmatter(line string, th theme.Theme) []Span { muted := lipgloss.NewStyle().Foreground(th.Blockquote) accent := lipgloss.NewStyle().Foreground(th.Accent) plain := lipgloss.NewStyle().Foreground(th.Text) marker := lipgloss.NewStyle().Foreground(th.ListMarker) trimmed := strings.TrimSpace(line) // Comment line. if strings.HasPrefix(trimmed, "#") { return wholeLine(line, muted) } // List item: optional leading whitespace, then "- ...". if loc := listRe.FindStringIndex(line); loc != nil && strings.HasPrefix(trimmed, "-") { mk := line[:loc[1]] // leading ws + "- " rest := line[loc[1]:] // item text spans := []Span{{Text: mk, Style: marker}} if rest != "" { spans = append(spans, Span{Text: rest, Style: plain}) } return spans } // key: value — split at the first colon. Keep the colon with the key. if idx := strings.IndexByte(line, ':'); idx >= 0 { key := line[:idx+1] // includes the colon rest := line[idx+1:] // remainder (may start with a space) spans := []Span{{Text: key, Style: accent}} if rest != "" { spans = append(spans, Span{Text: rest, Style: plain}) } return spans } // Bare line (e.g. a continuation): plain text. return wholeLine(line, plain) } // scanInline emits styled spans for inline constructs, keeping all markup // characters visible. Plain text gets the theme's Text foreground. func scanInline(text string, th theme.Theme) []Span { plain := lipgloss.NewStyle().Foreground(th.Text) r := []rune(text) var spans []Span start, i := 0, 0 flush := func(end int) { if end > start { spans = append(spans, Span{Text: string(r[start:end]), Style: plain}) } } for i < len(r) { if tok, style, n, ok := matchToken(r, i, th); ok { flush(i) spans = append(spans, Span{Text: tok, Style: style}) i += n start = i continue } i++ } flush(len(r)) return spans } // matchToken tries to match an inline construct starting at r[i]. It returns the // token (including its markup), the style, the rune length, and whether matched. func matchToken(r []rune, i int, th theme.Theme) (string, lipgloss.Style, int, bool) { // Inline code: `...` if r[i] == '`' { if j := indexRune(r, '`', i+1); j > i { return string(r[i : j+1]), lipgloss.NewStyle().Foreground(th.Code), j + 1 - i, true } } // Wikilink: [[...]] if r[i] == '[' && i+1 < len(r) && r[i+1] == '[' { if j := indexSeq(r, "]]", i+2); j >= 0 { end := j + 2 return string(r[i:end]), lipgloss.NewStyle().Foreground(th.Wikilink), end - i, true } } // Link: [text](url) if r[i] == '[' { if c := indexRune(r, ']', i+1); c > i && c+1 < len(r) && r[c+1] == '(' { if p := indexRune(r, ')', c+2); p > c { end := p + 1 return string(r[i:end]), lipgloss.NewStyle().Foreground(th.Link), end - i, true } } } // Bold: ** or __ for _, d := range []string{"**", "__"} { if hasPrefixRunes(r, i, d) { if j := indexSeq(r, d, i+2); j >= 0 { end := j + 2 return string(r[i:end]), lipgloss.NewStyle().Foreground(th.Text).Bold(true), end - i, true } } } // Italic: * or _ if r[i] == '*' || r[i] == '_' { if j := indexRune(r, r[i], i+1); j > i { end := j + 1 return string(r[i:end]), lipgloss.NewStyle().Foreground(th.Text).Italic(true), end - i, true } } return "", lipgloss.Style{}, 0, false } func indexRune(r []rune, c rune, from int) int { for i := from; i < len(r); i++ { if r[i] == c { return i } } return -1 } func indexSeq(r []rune, seq string, from int) int { s := []rune(seq) for i := from; i+len(s) <= len(r); i++ { match := true for k := range s { if r[i+k] != s[k] { match = false break } } if match { return i } } return -1 } func hasPrefixRunes(r []rune, i int, prefix string) bool { p := []rune(prefix) if i+len(p) > len(r) { return false } for k := range p { if r[i+k] != p[k] { return false } } return true }