drone/app/api/controller/pullreq/suggestions.go

202 lines
5.2 KiB
Go

// Copyright 2023 Harness, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package pullreq
import (
"crypto/sha256"
"fmt"
"strings"
)
type suggestion struct {
checkSum string
code string
}
// parseSuggestions parses the provided string for any markdown code blocks that are suggestions.
func parseSuggestions(s string) []suggestion {
const languageSuggestion = "suggestion"
out := []suggestion{}
for len(s) > 0 {
code, language, remaining, found := findNextMarkdownCodeBlock(s)
// always update s to the remainder
s = remaining
if !found {
break
}
if !strings.EqualFold(language, languageSuggestion) {
continue
}
out = append(out,
suggestion{
checkSum: hashCodeBlock(code),
code: code,
},
)
}
return out
}
func hashCodeBlock(s string) string {
return fmt.Sprintf("%x", sha256.Sum256([]byte(s)))
}
// findNextMarkdownCodeBlock finds a code block in markdown.
// NOTE: In the future we might want to use a proper markdown parser.
func findNextMarkdownCodeBlock(s string) (code string, language string, remaining string, found bool) {
// find fenced code block header
var startSequence string
s = foreachLine(s, func(line string) bool {
line, ok := trimMarkdownWhitespace(line)
if !ok {
return true
}
// try to find start sequence of a fenced code block (```+ or ~~~+)
startSequence, line = cutLongestPrefix(line, '~')
if len(startSequence) < 3 {
startSequence, line = cutLongestPrefix(line, '`')
if len(startSequence) < 3 {
// no code block prefix found in this line
return true
}
if strings.Contains(line, "`") {
// any single tic in the same line breaks a code block ``` opening
return true
}
}
language = strings.TrimSpace(line)
return false
})
if len(startSequence) == 0 {
return "", "", "", false
}
// parse codeBuilder block
codeBuilder := strings.Builder{}
linesAdded := 0
addLineToCode := func(line string) {
// To normalize we:
// - always use LF line ending
// - strip any line ending from last line
//
// e.g. "```suggestion\n```" is the same as "```suggestion\n" is the same as "```suggestion"
//
// This ensures similar result with and without end markers for fenced code blocks,
// and gives the user control on adding new lines at the end of the file.
if linesAdded > 0 {
codeBuilder.WriteByte('\n')
}
linesAdded++
codeBuilder.WriteString(line)
}
s = foreachLine(s, func(line string) bool {
// keep original line for appending it to code block if required
originalLine := line
line, ok := trimMarkdownWhitespace(line)
if !ok {
addLineToCode(originalLine)
return true
}
if !strings.HasPrefix(line, startSequence) {
addLineToCode(originalLine)
return true
}
_, line = cutLongestPrefix(line, rune(startSequence[0])) // any higher number of chars as starting sequence works
line = strings.TrimSpace(line) // spaces are fine
if len(line) > 0 {
// end of fenced code block can't contain anything else but spaces
addLineToCode(originalLine)
return true
}
return false
})
return codeBuilder.String(), language, s, true
}
// trimMarkdownWhitespace returns the provided line by removing any leading whitespaces.
// If the white space makes it an indented code block line, false is returned.
func trimMarkdownWhitespace(line string) (string, bool) {
// remove any leading spaces
prefix, updatedLine := cutLongestPrefix(line, ' ')
if len(prefix) >= 4 {
// line is considered a code line by indentation
return line, false
}
// check for leading tabs (doesn't matter how many)
if strings.HasPrefix(updatedLine, "\t") {
// line is considered a code line by indentation
return line, false
}
return updatedLine, true
}
// foreachLine iterates over the provided string and calls "process" method for each line.
// If process returns false, or the scan reaches the end of the lines, the scanning stops.
// The method returns the remaining text of s.
func foreachLine(s string, process func(line string) bool) string {
for len(s) > 0 {
line, remaining, _ := strings.Cut(s, "\n")
// always update s to the remaining string
s = remaining
// handle CLRF
if lineLen := len(line); lineLen > 0 && line[lineLen-1] == '\r' {
line = line[:lineLen-1]
}
if !process(line) {
return s
}
}
return s
}
// cutLongestPrefix returns the longest prefix of repeating 'c' together with the remainder of the string.
func cutLongestPrefix(s string, c rune) (string, string) {
if len(s) == 0 {
return "", ""
}
i := strings.IndexFunc(s, func(r rune) bool { return r != c })
if i < 0 {
// no character found that's different from the provided rune!
return s, ""
}
return s[:i], s[i:]
}