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) {