▍ humdrum codex / glint v1.0.2
license AGPL-3.0

feat: minimal syntax highlighting for code files (TASK-018)

62acab90c85ec40843ca200b9a28d2e71fc43c88
Kevin Kortum <kevinkortum@me.com> · 2026-06-29 17:23

parent 9db962e1

feat: minimal syntax highlighting for code files (TASK-018)

Route the editor scanner by file extension: markdown/text/no-extension keep
the prose scanner; other extensions use a new chroma-based code scanner.
ScanCode tokenizes via chroma's filename-matched lexer and colors only
strings (Code), comments (Comment), and numbers/literals (Accent), with
punctuation muted and everything else base — calm Alabaster styling, not a
full IDE theme. Token values are split on newlines into per-line spans so the
markup-visible invariant holds and multi-line tokens (block comments,
multi-line strings) render across lines. app.Load sets the language; chroma/v2
promoted to a direct dependency.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KjNrdAWUdkaxFyGdrPHaBj

10 files changed

README.md +3 −1
@@ -5,7 +5,9 @@ TUI built for a notes vault. Type in a centered iA-Writer-style writing canvas
 with soft-wrapping and live markdown coloring, flip to a [Glamour][glamour] read
 preview, fuzzy-find notes with a live preview pane, and theme it to match your
 terminal (Flexoki light/dark + charm). Open any file, jump to today's daily
-note, or start a fresh document from anywhere.
+note, or start a fresh document from anywhere. Code files open with calm,
+minimal syntax highlighting (strings, comments, and numbers only) so glint works
+as a quick `$EDITOR` too.
 
 ## Install
 
- → Wrap-selection-in-markdown-formatting.md +2 −2
@@ -1,10 +1,10 @@
 ---
 id: TASK-009
 title: Wrap selection in markdown formatting
-status: "\U0001F7E2 In progress"
+status: "\U0001F3C1 Done"
 assignee: []
 created_date: '2026-06-29 16:26'
-updated_date: '2026-06-29 23:12'
+updated_date: '2026-06-30 00:23'
 labels:
   - feature
   - release-1
- → Status-bar-line-col-and-word-count.md +2 −2
@@ -1,10 +1,10 @@
 ---
 id: TASK-010
 title: 'Status bar: directory, theme, line:col, word count, help, selection counts'
-status: "\U0001F7E2 In progress"
+status: "\U0001F3C1 Done"
 assignee: []
 created_date: '2026-06-29 16:26'
-updated_date: '2026-06-29 23:12'
+updated_date: '2026-06-30 00:23'
 labels:
   - feature
   - release-1
- → Minimal-Alabaster-syntax-highlighting-for-code-files.md +22 −6
@@ -1,10 +1,10 @@
 ---
 id: TASK-018
 title: Minimal Alabaster syntax highlighting for code files
-status: "\U0001F7E2 In progress"
+status: "\U0001F3C1 Done"
 assignee: []
 created_date: '2026-06-29 16:44'
-updated_date: '2026-06-29 17:55'
+updated_date: '2026-06-30 00:28'
 labels:
   - feature
   - release-1
@@ -28,8 +28,24 @@ <!-- SECTION:DESCRIPTION:END -->
 
 ## Acceptance Criteria
 <!-- AC:BEGIN -->
-- [ ] #1 Code files (by extension) use a minimal code scanner; markdown/text keep the markdown scanner
-- [ ] #2 Only strings, comments, and numbers/constants are colored (Alabaster-minimal); keywords/identifiers stay base, punctuation dimmed
-- [ ] #3 Uses the chroma lexer already bundled via glamour; spans concatenate to the raw line (display-only invariant)
-- [ ] #4 Multi-line tokens (block comments, multi-line strings) handled across lines
+- [x] #1 Code files (by extension) use a minimal code scanner; markdown/text keep the markdown scanner
+- [x] #2 Only strings, comments, and numbers/constants are colored (Alabaster-minimal); keywords/identifiers stay base, punctuation dimmed
+- [x] #3 Uses the chroma lexer already bundled via glamour; spans concatenate to the raw line (display-only invariant)
+- [x] #4 Multi-line tokens (block comments, multi-line strings) handled across lines
 <!-- AC:END -->
+
+## Implementation Plan
+
+<!-- SECTION:PLAN:BEGIN -->
+1. ScanCode(lines, filename, th) in internal/editor/code.go: chroma lexers.Match(filename) (fallback lexers.Fallback), Coalesce, Tokenise joined source; split token stream on newlines into per-line []Span; map String*->Code, Comment*->Comment, Number/Literal->Accent, Punctuation->Muted, rest->Text; preserve markup-visible invariant.
+2. Editor routing: codeFile field + SetLanguage(path) — md/markdown/mdx/txt/none keep markdown scanner, other extensions use code scanner; buildVisual picks scanner.
+3. app Load() calls editor.SetLanguage(path).
+4. Promote chroma to direct dep (go mod tidy).
+5. TDD: code_test.go (strings/comments/numbers colored, identifiers/keywords base, invariant, multi-line block comment) + routing test (.go colors strings, .md still colors headings).
+<!-- SECTION:PLAN:END -->
+
+## Implementation Notes
+
+<!-- SECTION:NOTES:BEGIN -->
+Done. internal/editor/code.go: ScanCode(lines, filename, th) via chroma lexers.Match (Fallback if unknown), Coalesce, Tokenise on joined source, split token stream on '\n' into per-line []Span. codeStyle maps String*->Code, Comment*->Comment, Number/Literal->Accent, Punctuation->Muted, rest base Text. Markup-visible invariant preserved (span text == raw line). Editor.codeFile + SetLanguage(path): md/markdown/mdx/txt/text/no-ext keep prose scanner, other exts use code scanner (base filename -> chroma). wrap.go buildVisual routes. app Load() calls SetLanguage. chroma/v2 promoted to direct dep. 8 TDD tests (strings/comments/numbers/identifiers/invariant/multiline-comment + buildVisual routing both ways + SetLanguage). Full suite + vet green.
+<!-- SECTION:NOTES:END -->
go.mod +1 −1
@@ -4,6 +4,7 @@ go 1.26.4
 
 require (
 	github.com/BurntSushi/toml v1.6.0
+	github.com/alecthomas/chroma/v2 v2.20.0
 	github.com/atotto/clipboard v0.1.4
 	github.com/charmbracelet/bubbles v1.0.0
 	github.com/charmbracelet/bubbletea v1.3.10
@@ -14,7 +15,6 @@ 	github.com/muesli/termenv v0.16.0
 )
 
 require (
-	github.com/alecthomas/chroma/v2 v2.20.0 // indirect
 	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
 	github.com/aymerick/douceur v0.2.0 // indirect
 	github.com/catppuccin/go v0.3.0 // indirect
internal/app/app.go +1 −0
@@ -132,6 +132,7 @@ 		return err
 	}
 	a.rememberCursor() // stash the outgoing file's position before switching
 	a.editor.SetContent(data)
+	a.editor.SetLanguage(path)
 	a.path = path
 	if pos, ok := a.cursorMem[path]; ok {
 		a.editor.SetCursor(pos) // restore where we left this file
internal/editor/code.go +80 −0
@@ -0,0 +1,80 @@
+package editor
+
+import (
+	"strings"
+
+	"glint/internal/theme"
+
+	"github.com/alecthomas/chroma/v2"
+	"github.com/alecthomas/chroma/v2/lexers"
+	"github.com/charmbracelet/lipgloss"
+)
+
+// ScanCode styles source code minimally (Alabaster philosophy): only strings,
+// comments, and numbers/literals get color; keywords, names, and operators stay
+// at base Text; punctuation is Muted. It returns one []Span per input line whose
+// concatenated text equals the line exactly (the markup-visible invariant), with
+// multi-line tokens (block comments, multi-line strings) split across lines.
+//
+// filename selects the chroma lexer; an unrecognized file falls back to a plain
+// lexer (everything base). On a tokenizer error it degrades to the prose scanner.
+func ScanCode(lines []string, filename string, th theme.Theme) [][]Span {
+	lexer := lexers.Match(filename)
+	if lexer == nil {
+		lexer = lexers.Fallback
+	}
+	lexer = chroma.Coalesce(lexer)
+
+	it, err := lexer.Tokenise(nil, strings.Join(lines, "\n"))
+	if err != nil {
+		return ScanLines(lines, th)
+	}
+
+	out := make([][]Span, len(lines))
+	li := 0
+	var cur []Span
+	emit := func(text string, style lipgloss.Style) {
+		if text != "" {
+			cur = append(cur, Span{Text: text, Style: style})
+		}
+	}
+	for _, tok := range it.Tokens() {
+		style := codeStyle(tok.Type, th)
+		val := tok.Value
+		for {
+			nl := strings.IndexByte(val, '\n')
+			if nl < 0 {
+				emit(val, style)
+				break
+			}
+			emit(val[:nl], style)
+			if li < len(out) {
+				out[li] = cur
+			}
+			cur = nil
+			li++
+			val = val[nl+1:]
+		}
+	}
+	if li < len(out) {
+		out[li] = cur
+	}
+	return out
+}
+
+// codeStyle maps a chroma token type to a theme color, coloring only strings,
+// comments, and numbers/literals; everything else stays base, punctuation muted.
+func codeStyle(tt chroma.TokenType, th theme.Theme) lipgloss.Style {
+	switch {
+	case tt.SubCategory() == chroma.String:
+		return lipgloss.NewStyle().Foreground(th.Code)
+	case tt.Category() == chroma.Comment:
+		return lipgloss.NewStyle().Foreground(th.Comment)
+	case tt.SubCategory() == chroma.Number || tt.Category() == chroma.Literal:
+		return lipgloss.NewStyle().Foreground(th.Accent)
+	case tt.Category() == chroma.Punctuation:
+		return lipgloss.NewStyle().Foreground(th.Muted)
+	default:
+		return lipgloss.NewStyle().Foreground(th.Text)
+	}
+}
internal/editor/code_test.go +142 −0
@@ -0,0 +1,142 @@
+package editor
+
+import (
+	"testing"
+
+	"glint/internal/theme"
+
+	"github.com/charmbracelet/lipgloss"
+)
+
+// styleOf returns the foreground color of the span covering the first occurrence
+// of sub within the line's spans, or "" if not found.
+func styleOf(spans []Span, sub string) lipgloss.Color {
+	for _, sp := range spans {
+		if sp.Text == sub {
+			return lipgloss.Color(sp.Style.GetForeground().(lipgloss.Color))
+		}
+	}
+	return lipgloss.Color("")
+}
+
+func TestCodeScannerColorsStrings(t *testing.T) {
+	th := theme.FlexokiDark()
+	out := ScanCode([]string{`x := "hello"`}, "main.go", th)
+	if got := styleOf(out[0], `"hello"`); got != th.Code {
+		t.Errorf("string color = %q, want Code %q (spans: %+v)", got, th.Code, out[0])
+	}
+}
+
+func TestCodeScannerColorsComments(t *testing.T) {
+	th := theme.FlexokiDark()
+	out := ScanCode([]string{"// a note"}, "main.go", th)
+	// The whole comment line should be Comment-colored.
+	for _, sp := range out[0] {
+		if sp.Style.GetForeground() != th.Comment {
+			t.Errorf("comment span %q color = %v, want Comment %v", sp.Text, sp.Style.GetForeground(), th.Comment)
+		}
+	}
+}
+
+func TestCodeScannerColorsNumbers(t *testing.T) {
+	th := theme.FlexokiDark()
+	out := ScanCode([]string{"x := 42"}, "main.go", th)
+	if got := styleOf(out[0], "42"); got != th.Accent {
+		t.Errorf("number color = %q, want Accent %q (spans: %+v)", got, th.Accent, out[0])
+	}
+}
+
+func TestCodeScannerIdentifiersStayBase(t *testing.T) {
+	th := theme.FlexokiDark()
+	out := ScanCode([]string{"foo := bar"}, "main.go", th)
+	if got := styleOf(out[0], "foo"); got != th.Text {
+		t.Errorf("identifier color = %q, want base Text %q", got, th.Text)
+	}
+}
+
+func TestCodeScannerInvariant(t *testing.T) {
+	th := theme.FlexokiDark()
+	lines := []string{
+		`package main`,
+		``,
+		`func main() {`,
+		`	x := "hi" // greet`,
+		`	_ = x + 1`,
+		`}`,
+	}
+	out := ScanCode(lines, "main.go", th)
+	if len(out) != len(lines) {
+		t.Fatalf("got %d lines of spans, want %d", len(out), len(lines))
+	}
+	for i, raw := range lines {
+		if got := spanText(out[i]); got != raw {
+			t.Errorf("line %d: span text %q != raw %q", i, got, raw)
+		}
+	}
+}
+
+func TestCodeScannerMultilineComment(t *testing.T) {
+	th := theme.FlexokiDark()
+	lines := []string{"/* first", "second */"}
+	out := ScanCode(lines, "main.go", th)
+	for i := range lines {
+		if len(out[i]) == 0 {
+			t.Fatalf("line %d empty", i)
+		}
+		for _, sp := range out[i] {
+			if sp.Style.GetForeground() != th.Comment {
+				t.Errorf("line %d span %q color = %v, want Comment", i, sp.Text, sp.Style.GetForeground())
+			}
+		}
+	}
+}
+
+func TestBuildVisualUsesCodeScannerForCode(t *testing.T) {
+	e := New()
+	e.SetContent([]byte(`x := "hi"`))
+	e.SetLanguage("main.go")
+	var found bool
+	for _, vr := range e.buildVisual() {
+		if c := styleOf(vr.spans, `"hi"`); c == e.theme.Code {
+			found = true
+		}
+	}
+	if !found {
+		t.Error("code file: string not colored via buildVisual")
+	}
+}
+
+func TestBuildVisualKeepsMarkdownForMd(t *testing.T) {
+	e := New()
+	e.SetContent([]byte("# Title"))
+	e.SetLanguage("notes.md")
+	var found bool
+	for _, vr := range e.buildVisual() {
+		if c := styleOf(vr.spans, "Title"); c == e.theme.Heading {
+			found = true
+		}
+	}
+	if !found {
+		t.Error("markdown file: heading not colored via buildVisual (wrong scanner?)")
+	}
+}
+
+func TestSetLanguageRoutesScanner(t *testing.T) {
+	e := New()
+	e.SetLanguage("main.go")
+	if e.codeFile == "" {
+		t.Error("SetLanguage(main.go) should enable the code scanner")
+	}
+	e.SetLanguage("notes.md")
+	if e.codeFile != "" {
+		t.Errorf("SetLanguage(notes.md) should keep markdown scanner, got codeFile=%q", e.codeFile)
+	}
+	e.SetLanguage("plain.txt")
+	if e.codeFile != "" {
+		t.Errorf("SetLanguage(plain.txt) should keep markdown scanner, got codeFile=%q", e.codeFile)
+	}
+	e.SetLanguage("README")
+	if e.codeFile != "" {
+		t.Errorf("SetLanguage(README) (no ext) should keep markdown scanner, got codeFile=%q", e.codeFile)
+	}
+}
internal/editor/editor.go +15 −0
@@ -1,6 +1,7 @@
 package editor
 
 import (
+	"path/filepath"
 	"strings"
 
 	"glint/internal/theme"
@@ -34,6 +35,20 @@
 	find       []match // in-document find matches, document order (TASK-007)
 	findActive int     // index of the active match in find, -1 = none
 	findQuery  string  // current find query
+
+	codeFile string // filename for the chroma lexer; "" = prose/markdown scanner (TASK-018)
+}
+
+// SetLanguage selects the scanner from the file's extension: markdown/text/no
+// extension keep the prose scanner; any other extension uses the code scanner,
+// with the base filename passed to chroma for lexer selection.
+func (e *Editor) SetLanguage(path string) {
+	switch strings.ToLower(filepath.Ext(path)) {
+	case "", ".md", ".markdown", ".mdx", ".txt", ".text":
+		e.codeFile = ""
+	default:
+		e.codeFile = filepath.Base(path)
+	}
 }
 
 // New returns an empty editor with one blank line and the default theme.
internal/editor/wrap.go +6 −1
@@ -79,7 +79,12 @@ // buildVisual wraps every logical line into visual rows, slicing each line's
 // styled spans to the segment ranges. Every span is given the theme background
 // so glyphs sit on the theme's paper rather than the terminal default.
 func (e *Editor) buildVisual() []vrow {
-	all := ScanLines(e.Lines, e.theme)
+	var all [][]Span
+	if e.codeFile != "" {
+		all = ScanCode(e.Lines, e.codeFile, e.theme)
+	} else {
+		all = ScanLines(e.Lines, e.theme)
+	}
 	var rows []vrow
 	for li := range e.Lines {
 		for _, s := range wrapLine(e.Lines[li], e.Width) {