Commit 9581d34d47

9581d34d47fe768a5896564d5bae73f0c5ce8799

parent: c9357581fe

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-29 04:47 UTC

suggest: parse suggestion blocks and apply them to a line range

Ref #288

Layout: unified · split

internal/suggest/suggest.go added +169
@@ -0,0 +1,169 @@
1// Package suggest reads the replacement lines a review comment proposes
2// in a fenced suggestion block, and applies them to a file's anchored
3// line range. The server's apply and the CLI's local apply share it, so
4// both produce the same bytes.
5package suggest
6
7import (
8 "bytes"
9 "errors"
10 "fmt"
11 "strings"
12)
13
14// Parse returns the lines of the one ```suggestion block in body. found
15// is false when there is none. An empty block proposes deleting the
16// range; a block holding one empty line proposes a blank line.
17func Parse(body string) (lines []string, found bool, err error) {
18 start, end, err := locate(body)
19 if err != nil || start < 0 {
20 return nil, false, err
21 }
22 all := strings.Split(normalize(body), "\n")
23 return append([]string{}, all[start+1:end]...), true, nil
24}
25
26// Strip returns body without its suggestion block, for rendering the
27// prose around a suggestion that is shown as a diff instead.
28func Strip(body string) string {
29 start, end, err := locate(body)
30 if err != nil || start < 0 {
31 return body
32 }
33 all := strings.Split(normalize(body), "\n")
34 return strings.TrimSpace(strings.Join(append(all[:start:start], all[end+1:]...), "\n"))
35}
36
37func normalize(body string) string { return strings.ReplaceAll(body, "\r\n", "\n") }
38
39// locate finds the suggestion block's opening and closing fence lines.
40// start is -1 when there is no block.
41func locate(body string) (start, end int, err error) {
42 all := strings.Split(normalize(body), "\n")
43 start = -1
44 for i := 0; i < len(all); i++ {
45 fence, ok := opening(all[i])
46 if !ok {
47 continue
48 }
49 if start >= 0 {
50 return -1, -1, errors.New("a comment carries one suggestion block")
51 }
52 j := i + 1
53 for ; j < len(all); j++ {
54 if closing(all[j], fence) {
55 break
56 }
57 }
58 if j == len(all) {
59 return -1, -1, errors.New("the suggestion block is not closed")
60 }
61 start, end = i, j
62 i = j
63 }
64 return start, end, nil
65}
66
67// opening reports whether line opens a suggestion block, and the length
68// of its backtick fence.
69func opening(line string) (int, bool) {
70 t := strings.TrimLeft(line, " ")
71 if len(line)-len(t) > 3 {
72 return 0, false
73 }
74 n := len(t) - len(strings.TrimLeft(t, "`"))
75 if n < 3 {
76 return 0, false
77 }
78 return n, strings.TrimSpace(t[n:]) == "suggestion"
79}
80
81func closing(line string, fence int) bool {
82 t := strings.TrimSpace(line)
83 return len(t) >= fence && strings.Trim(t, "`") == ""
84}
85
86// Text is the replacement as one string, every line ending in a newline:
87// "" deletes the range and "\n" is one blank line.
88func Text(lines []string) string {
89 var b strings.Builder
90 for _, l := range lines {
91 b.WriteString(l)
92 b.WriteByte('\n')
93 }
94 return b.String()
95}
96
97// FromText undoes Text.
98func FromText(s string) []string {
99 if s == "" {
100 return nil
101 }
102 return strings.Split(strings.TrimSuffix(s, "\n"), "\n")
103}
104
105// split cuts content into lines, each keeping its terminator. The last
106// line has none when the file does not end in a newline.
107func split(content []byte) [][]byte {
108 var out [][]byte
109 for len(content) > 0 {
110 i := bytes.IndexByte(content, '\n')
111 if i < 0 {
112 out = append(out, content)
113 break
114 }
115 out = append(out, content[:i+1])
116 content = content[i+1:]
117 }
118 return out
119}
120
121// Range returns lines start through end (1-based, inclusive) of content
122// with their terminators, and false when the file is shorter than that.
123func Range(content []byte, start, end int) ([]byte, bool) {
124 lines := split(content)
125 if start < 1 || end < start || end > len(lines) {
126 return nil, false
127 }
128 return bytes.Join(lines[start-1:end], nil), true
129}
130
131// Apply replaces lines start through end of content with repl. The
132// replacement takes the line ending the file uses there, CRLF or LF, and
133// the last replacement line keeps whatever ended the range, so a range
134// at the end of a file with no final newline still has none.
135func Apply(content []byte, start, end int, repl []string) ([]byte, error) {
136 lines := split(content)
137 if start < 1 || end < start {
138 return nil, fmt.Errorf("bad line range %d-%d", start, end)
139 }
140 if end > len(lines) {
141 return nil, fmt.Errorf("the file has %d lines; the suggestion ends at line %d", len(lines), end)
142 }
143 last := lines[end-1]
144 eol, lastEOL := []byte("\n"), []byte{}
145 switch {
146 case bytes.HasSuffix(last, []byte("\r\n")):
147 eol, lastEOL = []byte("\r\n"), []byte("\r\n")
148 case bytes.HasSuffix(last, []byte("\n")):
149 lastEOL = []byte("\n")
150 case end > 1 && bytes.HasSuffix(lines[end-2], []byte("\r\n")):
151 eol = []byte("\r\n")
152 }
153 var b bytes.Buffer
154 for _, l := range lines[:start-1] {
155 b.Write(l)
156 }
157 for i, r := range repl {
158 b.WriteString(r)
159 if i == len(repl)-1 {
160 b.Write(lastEOL)
161 } else {
162 b.Write(eol)
163 }
164 }
165 for _, l := range lines[end:] {
166 b.Write(l)
167 }
168 return b.Bytes(), nil
169}
internal/suggest/suggest_test.go added +100
@@ -0,0 +1,100 @@
1package suggest
2
3import (
4 "reflect"
5 "strings"
6 "testing"
7)
8
9func TestParse(t *testing.T) {
10 cases := []struct {
11 name string
12 body string
13 lines []string
14 found bool
15 err string
16 }{
17 {"none", "just prose\n```go\nx\n```\n", nil, false, ""},
18 {"one line", "try this\n```suggestion\nreturn nil\n```\n", []string{"return nil"}, true, ""},
19 {"several", "```suggestion\na\n\tb\n```", []string{"a", "\tb"}, true, ""},
20 {"deletion", "drop it\n```suggestion\n```\n", []string{}, true, ""},
21 {"blank line", "```suggestion\n\n```\n", []string{""}, true, ""},
22 {"crlf body", "x\r\n```suggestion\r\nnew\r\n```\r\n", []string{"new"}, true, ""},
23 {"longer fence", "````suggestion\n```\n````\n", []string{"```"}, true, ""},
24 {"unclosed", "```suggestion\nnew\n", nil, false, "not closed"},
25 {"two", "```suggestion\na\n```\n```suggestion\nb\n```\n", nil, false, "one suggestion"},
26 }
27 for _, c := range cases {
28 lines, found, err := Parse(c.body)
29 if c.err != "" {
30 if err == nil || !strings.Contains(err.Error(), c.err) {
31 t.Errorf("%s: err = %v, want %q", c.name, err, c.err)
32 }
33 continue
34 }
35 if err != nil || found != c.found || (c.found && !reflect.DeepEqual(lines, c.lines)) {
36 t.Errorf("%s: Parse = %q %v %v, want %q %v", c.name, lines, found, err, c.lines, c.found)
37 }
38 }
39}
40
41func TestStrip(t *testing.T) {
42 if got := Strip("use this\n```suggestion\nx\n```\nthanks"); got != "use this\nthanks" {
43 t.Errorf("Strip = %q", got)
44 }
45 if got := Strip("```suggestion\nx\n```"); got != "" {
46 t.Errorf("Strip of a bare block = %q", got)
47 }
48}
49
50func TestTextRoundTrip(t *testing.T) {
51 for _, lines := range [][]string{nil, {""}, {"a"}, {"a", "", "b"}} {
52 if got := FromText(Text(lines)); !reflect.DeepEqual(got, lines) && !(len(got) == 0 && len(lines) == 0) {
53 t.Errorf("FromText(Text(%q)) = %q", lines, got)
54 }
55 }
56}
57
58func TestApply(t *testing.T) {
59 cases := []struct {
60 name string
61 content string
62 start, end int
63 repl []string
64 want string
65 err string
66 }{
67 {"one line", "a\nb\nc\n", 2, 2, []string{"B"}, "a\nB\nc\n", ""},
68 {"range to more", "a\nb\nc\nd\n", 2, 3, []string{"x", "y", "z"}, "a\nx\ny\nz\nd\n", ""},
69 {"range to fewer", "a\nb\nc\nd\n", 1, 3, []string{"x"}, "x\nd\n", ""},
70 {"deletion", "a\nb\nc\n", 2, 2, nil, "a\nc\n", ""},
71 {"delete all", "a\nb\n", 1, 2, nil, "", ""},
72 {"eof no newline", "a\nb", 2, 2, []string{"B", "C"}, "a\nB\nC", ""},
73 {"eof with newline", "a\nb\n", 2, 2, []string{"B"}, "a\nB\n", ""},
74 {"crlf", "a\r\nb\r\nc\r\n", 2, 2, []string{"x", "y"}, "a\r\nx\r\ny\r\nc\r\n", ""},
75 {"crlf eof no newline", "a\r\nb", 2, 2, []string{"x", "y"}, "a\r\nx\r\ny", ""},
76 {"past eof", "a\nb\n", 2, 3, []string{"x"}, "", "has 2 lines"},
77 {"bad range", "a\n", 2, 1, nil, "", "bad line range"},
78 }
79 for _, c := range cases {
80 got, err := Apply([]byte(c.content), c.start, c.end, c.repl)
81 if c.err != "" {
82 if err == nil || !strings.Contains(err.Error(), c.err) {
83 t.Errorf("%s: err = %v, want %q", c.name, err, c.err)
84 }
85 continue
86 }
87 if err != nil || string(got) != c.want {
88 t.Errorf("%s: Apply = %q, %v; want %q", c.name, got, err, c.want)
89 }
90 }
91}
92
93func TestRange(t *testing.T) {
94 if got, ok := Range([]byte("a\r\nb\nc"), 2, 3); !ok || string(got) != "b\nc" {
95 t.Errorf("Range = %q %v", got, ok)
96 }
97 if _, ok := Range([]byte("a\n"), 1, 2); ok {
98 t.Error("Range past the end reported ok")
99 }
100}