Commit 9581d34d47
Verified · cmc
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. | |
| 5 | package suggest | |
| 6 | ||
| 7 | import ( | |
| 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. | |
| 17 | func 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. | |
| 28 | func 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 | ||
| 37 | func 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. | |
| 41 | func 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. | |
| 69 | func 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 | ||
| 81 | func 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. | |
| 88 | func 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. | |
| 98 | func 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. | |
| 107 | func 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. | |
| 123 | func 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. | |
| 135 | func 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 @@ | ||
| 1 | package suggest | |
| 2 | ||
| 3 | import ( | |
| 4 | "reflect" | |
| 5 | "strings" | |
| 6 | "testing" | |
| 7 | ) | |
| 8 | ||
| 9 | func 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 | ||
| 41 | func 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 | ||
| 50 | func 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 | ||
| 58 | func 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 | ||
| 93 | func 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 | } | |