Skip to content

Commit e76db08

Browse files
committed
feat(tui): add line number support to code highlighting
- Add `ShowLineNumbers` field to `CodeHighlightInfo` struct - Implement line number rendering with padding and styling - Add support for `--numbered` flag in markdown code blocks - Update regex pattern to capture numbered flag from code blocks - Add tests for line number functionality
1 parent cba45ba commit e76db08

File tree

2 files changed

+133
-13
lines changed

2 files changed

+133
-13
lines changed

internal/tui/highlight.go

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package tui
22

33
import (
4+
"fmt"
45
"regexp"
56
"strconv"
67
"strings"
@@ -20,8 +21,9 @@ type LineRange struct {
2021
}
2122

2223
type CodeHighlightInfo struct {
23-
Language string
24-
Ranges []LineRange
24+
Language string
25+
Ranges []LineRange
26+
ShowLineNumbers bool
2527
}
2628

2729
func parseHighlightSyntax(syntax string) []LineRange {
@@ -88,17 +90,37 @@ func renderWithStyle(content string, info CodeHighlightInfo, lexer chroma.Lexer,
8890

8991
highlightStyle := lipgloss.NewStyle()
9092

93+
lineNumberWidth := 0
94+
if info.ShowLineNumbers {
95+
lineNumberWidth = len(strconv.Itoa(len(lines))) + 2
96+
}
97+
98+
lineNumberStyle := lipgloss.NewStyle().
99+
Foreground(lipgloss.Color("240")).
100+
PaddingRight(1)
101+
91102
var result strings.Builder
92103
for lineNum, line := range lines {
93-
if !shouldHighlightLine(lineNum+1, info.Ranges) {
104+
lineNumDisplay := lineNum + 1
105+
106+
// Add line number if requested
107+
if info.ShowLineNumbers {
108+
lineNumStr := strconv.Itoa(lineNumDisplay)
109+
paddedLineNum := fmt.Sprintf("%*s", lineNumberWidth-1, lineNumStr)
110+
result.WriteString(lineNumberStyle.Render(paddedLineNum))
111+
}
112+
113+
shouldHighlight := len(info.Ranges) == 0 || shouldHighlightLine(lineNumDisplay, info.Ranges)
114+
115+
if !shouldHighlight {
116+
// No syntax highlighting for this line
94117
result.WriteString(line)
95118
if lineNum < len(lines)-1 {
96119
result.WriteString("\n")
97120
}
98121
continue
99122
}
100123

101-
// Handle highlighted lines
102124
lineIterator, err := lexer.Tokenise(nil, line)
103125
if err != nil {
104126
result.WriteString(highlightStyle.Render(line))
@@ -137,7 +159,7 @@ func renderWithStyle(content string, info CodeHighlightInfo, lexer chroma.Lexer,
137159
}
138160

139161
func processMarkdownWithHighlighting(markdown string, themeName string) (string, error) {
140-
re := regexp.MustCompile("(?s)```([a-zA-Z0-9_+-]*)({[^}]*})?\n(.*?)\n```")
162+
re := regexp.MustCompile("(?s)```([a-zA-Z0-9_+-]*)({[^}]*})?(\\s+--numbered)?\\s*\n(.*?)\n```")
141163

142164
matches := re.FindAllStringSubmatch(markdown, -1)
143165
if len(matches) == 0 {
@@ -150,7 +172,7 @@ func processMarkdownWithHighlighting(markdown string, themeName string) (string,
150172
indices := re.FindAllStringIndex(markdown, -1)
151173

152174
for i, match := range matches {
153-
if len(match) < 4 {
175+
if len(match) < 5 {
154176
continue
155177
}
156178

@@ -177,18 +199,20 @@ func processMarkdownWithHighlighting(markdown string, themeName string) (string,
177199
if len(match) > 2 && match[2] != "" {
178200
highlightSyntax = match[2] // e.g., "{1-2}"
179201
}
180-
content := match[3] // The actual code content
202+
numberedFlag := strings.TrimSpace(match[3]) // " --numbered"
203+
content := match[4] // The actual code content
181204

182205
info := CodeHighlightInfo{
183-
Language: language,
184-
Ranges: parseHighlightSyntax(highlightSyntax),
206+
Language: language,
207+
Ranges: parseHighlightSyntax(highlightSyntax),
208+
ShowLineNumbers: strings.Contains(numberedFlag, "--numbered"),
185209
}
186210

187-
if len(info.Ranges) > 0 {
211+
if language != "" || info.ShowLineNumbers {
188212
customRendered := renderCustomCodeBlock(content, info, themeName)
189213
parts = append(parts, customRendered)
190214
} else {
191-
// No highlighting ranges, use Glamour directly for the entire code block
215+
// No language specified and no line numbers, use Glamour directly for the entire code block
192216
codeBlock := match[0]
193217
rendered, err := glamour.Render(codeBlock, themeName)
194218
if err != nil {

internal/tui/highlight_test.go

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,26 @@ func TestRenderCustomCodeBlock(t *testing.T) {
166166
},
167167
themeName: "dark",
168168
},
169+
{
170+
name: "code with line numbers",
171+
content: "console.log('hello');\nconsole.log('world');\nvar x = 1;",
172+
info: CodeHighlightInfo{
173+
Language: "javascript",
174+
Ranges: []LineRange{{Start: 1, End: 1}},
175+
ShowLineNumbers: true,
176+
},
177+
themeName: "dark",
178+
},
179+
{
180+
name: "code with only line numbers (no highlighting)",
181+
content: "def hello():\n print('hello')\n return True",
182+
info: CodeHighlightInfo{
183+
Language: "python",
184+
Ranges: nil,
185+
ShowLineNumbers: true,
186+
},
187+
themeName: "dark",
188+
},
169189
}
170190

171191
for _, tt := range tests {
@@ -182,6 +202,13 @@ func TestRenderCustomCodeBlock(t *testing.T) {
182202
t.Error("renderCustomCodeBlock returned only whitespace")
183203
}
184204

205+
// Check for line numbers if requested
206+
if tt.info.ShowLineNumbers {
207+
if !strings.Contains(result, "1") {
208+
t.Error("renderCustomCodeBlock with ShowLineNumbers should contain line number 1")
209+
}
210+
}
211+
185212
// For known languages, check that it contains some recognizable elements
186213
if tt.info.Language == "javascript" {
187214
if !strings.Contains(result, "console") && !strings.Contains(result, "log") {
@@ -231,6 +258,45 @@ func TestRenderWithStyle(t *testing.T) {
231258
}
232259
}
233260

261+
func TestRenderWithStyleLineNumbers(t *testing.T) {
262+
content := "console.log('test');\nvar x = 1;\nfunction hello() {\n return 'world';\n}"
263+
info := CodeHighlightInfo{
264+
Language: "javascript",
265+
Ranges: []LineRange{{Start: 1, End: 2}},
266+
ShowLineNumbers: true,
267+
}
268+
269+
lexer := lexers.Get("javascript")
270+
if lexer == nil {
271+
lexer = lexers.Fallback
272+
}
273+
lexer = chroma.Coalesce(lexer)
274+
275+
style := styles.Get("github")
276+
if style == nil {
277+
style = styles.Fallback
278+
}
279+
280+
result := renderWithStyle(content, info, lexer, style)
281+
282+
if result == "" {
283+
t.Error("renderWithStyle returned empty string")
284+
}
285+
286+
// Should contain line numbers
287+
if !strings.Contains(result, "1") {
288+
t.Error("renderWithStyle with ShowLineNumbers should contain line number 1")
289+
}
290+
if !strings.Contains(result, "2") {
291+
t.Error("renderWithStyle with ShowLineNumbers should contain line number 2")
292+
}
293+
294+
// Should contain some recognizable elements from the original content
295+
if !strings.Contains(result, "console") && !strings.Contains(result, "log") && !strings.Contains(result, "var") {
296+
t.Error("renderWithStyle result doesn't contain expected javascript elements")
297+
}
298+
}
299+
234300
func TestProcessMarkdownWithHighlighting(t *testing.T) {
235301
tests := []struct {
236302
name string
@@ -274,6 +340,24 @@ func TestProcessMarkdownWithHighlighting(t *testing.T) {
274340
theme: "dark",
275341
wantErr: false,
276342
},
343+
{
344+
name: "markdown with --numbered flag",
345+
markdown: "```javascript --numbered\nconsole.log('hello');\nconsole.log('world');\n```",
346+
theme: "dark",
347+
wantErr: false,
348+
},
349+
{
350+
name: "markdown with highlighting and --numbered",
351+
markdown: "```python{1-2} --numbered\ndef hello():\n print('hello')\n return True\n```",
352+
theme: "dark",
353+
wantErr: false,
354+
},
355+
{
356+
name: "markdown with --numbered and extra spaces",
357+
markdown: "```javascript --numbered \nconsole.log('test');\nvar x = 1;\n```",
358+
theme: "dark",
359+
wantErr: false,
360+
},
277361
}
278362

279363
for _, tt := range tests {
@@ -296,14 +380,22 @@ func TestProcessMarkdownWithHighlighting(t *testing.T) {
296380
t.Error("processMarkdownWithHighlighting result seems too short for highlighted content")
297381
}
298382
}
383+
384+
// For code blocks with --numbered, should contain line numbers
385+
if strings.Contains(tt.markdown, "--numbered") {
386+
if !strings.Contains(result, "1") {
387+
t.Error("processMarkdownWithHighlighting with --numbered should contain line number 1")
388+
}
389+
}
299390
})
300391
}
301392
}
302393

303394
func TestCodeHighlightInfo(t *testing.T) {
304395
info := CodeHighlightInfo{
305-
Language: "javascript",
306-
Ranges: []LineRange{{Start: 1, End: 3}, {Start: 5, End: 5}},
396+
Language: "javascript",
397+
Ranges: []LineRange{{Start: 1, End: 3}, {Start: 5, End: 5}},
398+
ShowLineNumbers: true,
307399
}
308400

309401
if info.Language != "javascript" {
@@ -318,6 +410,10 @@ func TestCodeHighlightInfo(t *testing.T) {
318410
t.Errorf("First range incorrect: got {%d, %d}, expected {1, 3}",
319411
info.Ranges[0].Start, info.Ranges[0].End)
320412
}
413+
414+
if !info.ShowLineNumbers {
415+
t.Error("Expected ShowLineNumbers to be true")
416+
}
321417
}
322418

323419
func TestLineRange(t *testing.T) {

0 commit comments

Comments
 (0)