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`) blockquoteRe = regexp.MustCompile(`^\s*>\s?`) refDefRe = regexp.MustCompile(`^\s*\[[^\]]+\]:\s*`) // [ref]: url // Git merge-conflict markers, anchored at column 0. Git writes exactly seven // marker characters followed by a space and a label (or end of line). conflictOursRe = regexp.MustCompile(`^<{7}( |$)`) // <<<<<<< HEAD conflictBaseRe = regexp.MustCompile(`^\|{7}( |$)`) // ||||||| base (diff3) conflictSepRe = regexp.MustCompile(`^={7}\s*$`) // ======= conflictTheirsRe = regexp.MustCompile(`^>{7}( |$)`) // >>>>>>> branch ) // conflictPhase tracks which side of a merge conflict a line falls in. type conflictPhase int const ( conflictNone conflictPhase = iota conflictOurs // between <<<<<<< and ||||||| / ======= conflictBase // between ||||||| and ======= (diff3) conflictTheirs // between ======= and >>>>>>> ) // applyConflictHighlight restyles conflict marker lines and tints the two // version blocks between them, mutating spans in place. It runs as a post-pass // over already-scanned spans so it works for both the prose and code scanners. // Marker characters are only recognized as markers once inside a conflict block // (opened by <<<<<<<), so a setext ===== underline or a lone ======= elsewhere // is never mistaken for a separator. The markup-visible invariant is preserved: // marker lines become one whole-line span, content lines only gain a background. func applyConflictHighlight(all [][]Span, lines []string, th theme.Theme) { if len(all) != len(lines) { return } marker := lipgloss.NewStyle().Foreground(th.ConflictMarker).Bold(true) phase := conflictNone for i, line := range lines { switch { case phase == conflictNone && conflictOursRe.MatchString(line): phase = conflictOurs all[i] = wholeLine(line, marker) case phase != conflictNone && conflictBaseRe.MatchString(line): phase = conflictBase all[i] = wholeLine(line, marker) case phase != conflictNone && conflictSepRe.MatchString(line): phase = conflictTheirs all[i] = wholeLine(line, marker) case phase != conflictNone && conflictTheirsRe.MatchString(line): phase = conflictNone all[i] = wholeLine(line, marker) case phase == conflictOurs || phase == conflictBase: all[i] = withBackground(all[i], th.ConflictOurs) case phase == conflictTheirs: all[i] = withBackground(all[i], th.ConflictTheirs) } } } // 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 (the markup-visible invariant). // // Highlighting model: prose stays at Text; all markup punctuation is Muted; // color is reserved for headings, code, links, list markers, comments, and a // highlight background; bold/italic render as real styling in the Emphasis tone. 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 { muted := lipgloss.NewStyle().Foreground(th.Muted) code := lipgloss.NewStyle().Foreground(th.Code) trimmed := strings.TrimSpace(line) // Fenced code block: delimiters muted, body entirely code-colored. if st.inFence { if strings.HasPrefix(trimmed, "```") { st.inFence = false return wholeLine(line, muted) } return wholeLine(line, code) } if strings.HasPrefix(trimmed, "```") { st.inFence = true return wholeLine(line, muted) } // 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) } // Comment line: visible, not dimmed. if strings.HasPrefix(trimmed, "