diff --git a/app/highlight/highlight.go b/app/highlight/highlight.go index b231f5e4..9b9e5024 100644 --- a/app/highlight/highlight.go +++ b/app/highlight/highlight.go @@ -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. @@ -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" } diff --git a/app/highlight/highlight_test.go b/app/highlight/highlight_test.go index 03cbc5f0..098b5b52 100644 --- a/app/highlight/highlight_test.go +++ b/app/highlight/highlight_test.go @@ -1,6 +1,7 @@ package highlight import ( + "fmt" "strings" "testing" @@ -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") +} diff --git a/docs/backlog/race-timeout-budget-too-tight.md b/docs/backlog/race-timeout-budget-too-tight.md index dee7198d..eef10386 100644 --- a/docs/backlog/race-timeout-budget-too-tight.md +++ b/docs/backlog/race-timeout-budget-too-tight.md @@ -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. diff --git a/go.mod b/go.mod index 8d69c89e..83a2e3a3 100644 --- a/go.mod +++ b/go.mod @@ -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 @@ -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