Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion app/highlight/highlight.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,35 @@ import (
"fmt"
"log"
"strings"
"sync"

"github.com/alecthomas/chroma/v2"
"github.com/alecthomas/chroma/v2/lexers"
"github.com/alecthomas/chroma/v2/styles"
"github.com/dlclark/regexp2/v2"

"github.com/umputun/revdiff/app/diff"
)

// maxBacktrackingStack raises regexp2's default 100k-slot cap for long single-line tokens. The
// 40k-character Go string that exposed the regression needs 240,045 slots, leaving about four
// times headroom. Each slot is an int, so one million slots allow about 8 MB for runtrack on
// 64-bit targets. regexp2 caps only runtrack; runstack grows through doubleIntSlice with no limit
// check, so the two stacks together can use roughly twice the nominal runtrack budget.
//
// Chroma compiles lexer rules with a bare regexp2.Compile and sets a separate 250ms MatchTimeout on
// each rule. matchRules ignores either error and tries later rules, so affected text can lose its
// intended color or fall back to chroma.Error. Raising this cap cannot help once the timeout is the
// binding ceiling. This finite budget does not restore regexp2's former unbounded behavior, and
// larger tokens can still exceed either ceiling.
const maxBacktrackingStack = 1_000_000

// chroma defers rule compilation until a lexer is first used, and a Highlighter is the only way
// into that path, so raising the package default here reaches every lexer revdiff builds.
var raiseBacktrackingCap = sync.OnceFunc(func() {
regexp2.DefaultOptimizationOptions.MaxBacktrackingStackSize = maxBacktrackingStack
})

// chromaFallbackStyle is the name of the Chroma style that doubles as styles.Fallback.
// styles.Get returns Fallback for unknown names, but "swapoff" is a real built-in style
// whose registry entry IS the Fallback sentinel, so we must special-case it.
Expand All @@ -24,8 +45,11 @@ type Highlighter struct {
}

// New creates a Highlighter with the given Chroma style name and enabled state.
// if styleName is empty, defaults to "monokai". Logs a warning if the style name is unknown.
// If styleName is empty, defaults to "monokai". Logs a warning if the style name is unknown.
// It also changes regexp2.DefaultOptimizationOptions.MaxBacktrackingStackSize process-wide before
// Chroma compiles lexer rules.
func New(styleName string, enabled bool) *Highlighter {
raiseBacktrackingCap()
if styleName == "" {
styleName = "monokai"
}
Expand Down
22 changes: 22 additions & 0 deletions app/highlight/highlight_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package highlight

import (
"fmt"
"strings"
"testing"

Expand Down Expand Up @@ -206,3 +207,24 @@ func TestSetStyle_unknownStyle(t *testing.T) {
assert.False(t, ok)
assert.Equal(t, "monokai", h.StyleName(), "style should not change on failure")
}

func TestHighlighter_LongQuotedStringUsesStringColor(t *testing.T) {
// pins the regexp2 v2.7.1 regression that repainted a 40k-character Go quoted string as an error
h := New("monokai", true)

render := func(n int) string {
content := `x := "` + strings.Repeat("a", n) + `"`
got := h.HighlightLines("main.go", []diff.DiffLine{{NewNum: 1, Content: content, ChangeType: diff.ChangeContext}})
require.Len(t, got, 1)
return got[0]
}

fg := func(tt chroma.TokenType) string {
c := styles.Get("monokai").Get(tt).Colour //nolint:misspell // chroma API uses British spelling
return fmt.Sprintf("\033[38;2;%d;%d;%dm", c.Red(), c.Green(), c.Blue())
}

long := render(40000)
assert.Contains(t, long, fg(chroma.LiteralString), "long literal must keep the string color")
assert.NotContains(t, long, fg(chroma.Error), "long literal must not be painted as an error token")
}
6 changes: 6 additions & 0 deletions docs/backlog/race-timeout-budget-too-tight.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,11 @@ review worktree, passed in 192s standalone under that load, and was green at 68s
Nothing about the PR was involved. CI is green and so is an idle local run, which is why this is `later`
rather than `yes`.

Re-measured during the PR #327 review (2026-08-19), and the margin has shrunk: the `app` package now runs
81.5s and 87.5s on master and 79.9s on that PR's branch, against 68s before, with
`TestShellLaunchersPreserveAnnotationExitCode` alone at ~77s. Headroom against the 100s budget is down from
roughly 32s to roughly 13s, so an idle run is no longer comfortably clear of it. Still `later`, but the next
addition to the launcher matrix is what turns this into `yes`.

Fix is a choice, not a one-liner: raise the timeout, or split the launcher matrix into its own target with
its own budget so the ordinary race run stays fast and the slow matrix is allowed to be slow.
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ require (
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/x/ansi v0.11.8
github.com/dlclark/regexp2/v2 v2.7.1
github.com/jessevdk/go-flags v1.6.1
github.com/mattn/go-runewidth v0.0.28
github.com/muesli/termenv v0.16.0
Expand All @@ -22,7 +23,6 @@ require (
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/dlclark/regexp2/v2 v2.7.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
Expand Down