Skip to content
Open
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
58 changes: 58 additions & 0 deletions pkg/highlight/heredoc_bug_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package highlight

import (
"os"
"testing"
)

// TestHeredocPrematureEnd verifies that a single word like "DoesNotWork"
// inside a bash heredoc body does not prematurely end the heredoc region.
// See https://github.com/micro-editor/micro/issues/4114
func TestHeredocPrematureEnd(t *testing.T) {
data, err := os.ReadFile("../../runtime/syntax/sh.yaml")
if err != nil {
t.Fatalf("Failed to read syntax file: %v", err)
}
file, err := ParseFile(data)
if err != nil {
t.Fatalf("Failed to parse syntax file: %v", err)
}
def, err := ParseDef(file, nil)
if err != nil {
t.Fatalf("Failed to parse syntax def: %v", err)
}
h := NewHighlighter(def)

// heredoc with a single word line inside that should NOT end it
input := "cat <<HELLO\n# comment\nDoesNotWork\n\nHELLO\n"
matches := h.HighlightString(input)

if len(matches) < 5 {
t.Fatalf("expected at least 5 lines, got %d", len(matches))
}

constStringGroup := Groups["constant.string"]

// Line 2 (index 2, "DoesNotWork") must be entirely inside the heredoc.
// If the heredoc ended prematurely, group would change to 0.
for pos, group := range matches[2] {
if group == 0 && pos > 0 {
t.Fatalf("heredoc ended prematurely on line 'DoesNotWork' at position %d", pos)
}
if group != constStringGroup && group != 0 {
t.Fatalf("unexpected group %d on line 'DoesNotWork' at position %d", group, pos)
}
}

// Line 4 (index 4, "HELLO") should end the heredoc.
// The HELLO line starts in the heredoc and transitions to group 0 after the word.
foundEnd := false
for group := range matches[4] {
if group == 0 {
foundEnd = true
}
}
if !foundEnd {
t.Fatal("heredoc did not end on the proper delimiter line 'HELLO'")
}
}
47 changes: 47 additions & 0 deletions pkg/highlight/highlighter.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,40 @@ func findIndex(regex *regexp.Regexp, skip *regexp.Regexp, str []byte) []int {
return []int{runePos(match[0], str), runePos(match[1], str)}
}

// regionWithDelimiter returns a copy of the region with the delimiter set from
// the start match on the line. If the region's start regex has no capture groups,
// it returns the original region unchanged.
func regionWithDelimiter(r *region, line []byte) *region {
if r.start.NumSubexp() == 0 {
return r
}
sub := r.start.FindSubmatch(line)
if len(sub) <= 1 {
return r
}
rc := *r
rc.delimiter = string(sub[1])
return &rc
}

// bytePos converts a rune position to a byte position in the given slice.
func bytePos(runeIdx int, str []byte) int {
if runeIdx <= 0 {
return 0
}
count := 0
totalSize := 0
for totalSize < len(str) {
if count >= runeIdx {
return totalSize
}
_, _, size := DecodeCharacter(str[totalSize:])
totalSize += size
count++
}
return len(str)
}

func findAllIndex(regex *regexp.Regexp, str []byte) [][]int {
matches := regex.FindAllIndex(str, -1)
for i, m := range matches {
Expand All @@ -124,6 +158,17 @@ func (h *Highlighter) highlightRegion(highlights LineMatch, start int, canMatchE
firstLoc := []int{lineLen, 0}
searchNesting := true
endLoc := findIndex(curRegion.end, curRegion.skip, line)
if endLoc != nil && curRegion.delimiter != "" {
// When the region has a captured delimiter (e.g. heredoc),
// verify the matched text on the original line equals the delimiter.
// endLoc contains rune positions; convert to byte positions.
bStart := bytePos(endLoc[0], line)
bEnd := bytePos(endLoc[1], line)
matched := string(line[bStart:bEnd])
if matched != curRegion.delimiter {
endLoc = nil
}
}
if endLoc != nil {
if start == endLoc[0] {
searchNesting = false
Expand All @@ -146,6 +191,7 @@ func (h *Highlighter) highlightRegion(highlights LineMatch, start int, canMatchE
if !statesOnly {
highlights[start+firstLoc[0]] = firstRegion.limitGroup
}
firstRegion = regionWithDelimiter(firstRegion, line)
h.highlightEmptyRegion(highlights, start+firstLoc[1], canMatchEnd, lineNum, sliceStart(line, firstLoc[1]), statesOnly)
h.highlightRegion(highlights, start+firstLoc[1], canMatchEnd, lineNum, sliceStart(line, firstLoc[1]), firstRegion, statesOnly)
return highlights
Expand Down Expand Up @@ -228,6 +274,7 @@ func (h *Highlighter) highlightEmptyRegion(highlights LineMatch, start int, canM
if !statesOnly {
highlights[start+firstLoc[0]] = firstRegion.limitGroup
}
firstRegion = regionWithDelimiter(firstRegion, line)
h.highlightEmptyRegion(highlights, start, false, lineNum, sliceEnd(line, firstLoc[0]), statesOnly)
h.highlightRegion(highlights, start+firstLoc[1], canMatchEnd, lineNum, sliceStart(line, firstLoc[1]), firstRegion, statesOnly)
return highlights
Expand Down
1 change: 1 addition & 0 deletions pkg/highlight/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ type region struct {
end *regexp.Regexp
skip *regexp.Regexp
rules *rules
delimiter string // for heredoc-like regions: the captured delimiter from the start match
}

func init() {
Expand Down
4 changes: 2 additions & 2 deletions runtime/syntax/sh.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,8 @@ rules:
rules: []

- constant.string:
start: "<<[^\\s]+[-~.]*[A-Za-z0-9]+$"
end: "^[^\\s]+[A-Za-z0-9]+$"
start: "<<-?([A-Za-z_][A-Za-z0-9_]*)$"
end: "^([A-Za-z_][A-Za-z0-9_]*)$"
skip: "\\\\."
rules: []

Expand Down