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 | } | ||