Files

477 lines
12 KiB
Go

package plugins
import (
"bytes"
"fmt"
"sort"
"strconv"
"strings"
"github.com/pelletier/go-toml/v2"
"github.com/pelletier/go-toml/v2/unstable"
)
type kimiByteRange struct {
start int
end int
}
type kimiTomlLayout struct {
hookTables []kimiByteRange
inlineValue *kimiByteRange
inlineElements []kimiByteRange
}
type kimiDocument struct {
contract kimiContract
exists bool
raw []byte
hooks []kimiHook
layout kimiTomlLayout
}
func parseKimiDocument(contract kimiContract, raw []byte, exists bool) (kimiDocument, error) {
if !exists {
return kimiDocument{
contract: contract, exists: false, raw: []byte{}, hooks: []kimiHook{},
}, nil
}
root := map[string]any{}
if err := toml.Unmarshal(raw, &root); err != nil {
return kimiDocument{}, fmt.Errorf(
"parse %s at %s: %w",
contract.label,
contract.configPath,
err,
)
}
hooks, err := kimiHooks(root, contract)
if err != nil {
return kimiDocument{}, err
}
layout, err := scanKimiTomlLayout(raw)
if err != nil {
return kimiDocument{}, fmt.Errorf(
"inspect %s layout at %s: %w",
contract.label,
contract.configPath,
err,
)
}
if len(hooks) > 0 && len(layout.hookTables) == 0 && layout.inlineValue == nil {
return kimiDocument{}, fmt.Errorf("%s hooks layout could not be located", contract.label)
}
if len(layout.hookTables) > 0 && len(layout.hookTables) != len(hooks) {
return kimiDocument{}, fmt.Errorf(
"%s hook layout count %d does not match decoded hook count %d",
contract.label,
len(layout.hookTables),
len(hooks),
)
}
if layout.inlineValue != nil && len(layout.inlineElements) != len(hooks) {
return kimiDocument{}, fmt.Errorf(
"%s inline hook layout count %d does not match decoded hook count %d",
contract.label,
len(layout.inlineElements),
len(hooks),
)
}
return kimiDocument{
contract: contract,
exists: true,
raw: append([]byte(nil), raw...),
hooks: hooks,
layout: layout,
}, nil
}
func scanKimiTomlLayout(raw []byte) (kimiTomlLayout, error) {
parser := unstable.Parser{KeepComments: true}
parser.Reset(raw)
var tableStarts, hookStarts []int
var inlineValue *kimiByteRange
var inlineElements []kimiByteRange
contextIsRoot := true
for parser.NextExpression() {
expression := parser.Expression()
switch expression.Kind {
case unstable.Table, unstable.ArrayTable:
keys := kimiTomlKeys(expression)
keyRange, err := kimiFirstKeyRange(expression)
if err != nil {
return kimiTomlLayout{}, err
}
start := kimiLineStart(raw, int(keyRange.Offset))
tableStarts = append(tableStarts, start)
contextIsRoot = false
if expression.Kind == unstable.ArrayTable && len(keys) == 1 && keys[0] == "hooks" {
hookStarts = append(hookStarts, start)
}
case unstable.KeyValue:
keys := kimiTomlKeys(expression)
if !contextIsRoot || len(keys) != 1 || keys[0] != "hooks" {
continue
}
value := expression.Value()
if value.Kind != unstable.Array {
continue
}
rangeValue, err := kimiArrayValueRange(raw, expression)
if err != nil {
return kimiTomlLayout{}, err
}
inlineValue = &rangeValue
inlineElements, err = kimiInlineElementRanges(raw, value)
if err != nil {
return kimiTomlLayout{}, err
}
}
}
if err := parser.Error(); err != nil {
return kimiTomlLayout{}, err
}
if len(hookStarts) > 0 && inlineValue != nil {
return kimiTomlLayout{}, fmt.Errorf("hooks use conflicting TOML layouts")
}
sort.Ints(tableStarts)
tables := make([]kimiByteRange, 0, len(hookStarts))
for _, start := range hookStarts {
end := len(raw)
for _, candidate := range tableStarts {
if candidate > start {
end = candidate
break
}
}
tables = append(tables, kimiByteRange{start: start, end: end})
}
return kimiTomlLayout{
hookTables: tables,
inlineValue: inlineValue,
inlineElements: inlineElements,
}, nil
}
func kimiArrayValueRange(raw []byte, expression *unstable.Node) (kimiByteRange, error) {
keyRange, err := kimiFirstKeyRange(expression)
if err != nil {
return kimiByteRange{}, err
}
start := int(keyRange.Offset + keyRange.Length)
end := int(expression.Raw.Offset + expression.Raw.Length)
if start < 0 || start > end || end > len(raw) {
return kimiByteRange{}, fmt.Errorf("hooks array range is invalid")
}
equals := bytes.IndexByte(raw[start:end], '=')
if equals < 0 {
return kimiByteRange{}, fmt.Errorf("hooks array assignment has no equals sign")
}
start += equals + 1
for start < end && (raw[start] == ' ' || raw[start] == '\t') {
start++
}
if start >= end || raw[start] != '[' {
return kimiByteRange{}, fmt.Errorf("hooks array value has no opening bracket")
}
return kimiByteRange{start: start, end: end}, nil
}
func kimiInlineElementRanges(raw []byte, array *unstable.Node) ([]kimiByteRange, error) {
ranges := []kimiByteRange{}
children := array.Children()
for children.Next() {
child := children.Node()
if child.Kind != unstable.InlineTable {
continue
}
start := int(child.Raw.Offset)
end, err := kimiInlineTableEnd(raw, start)
if err != nil {
return nil, err
}
ranges = append(ranges, kimiByteRange{start: start, end: end})
}
return ranges, nil
}
func kimiInlineTableEnd(raw []byte, start int) (int, error) {
if start < 0 || start >= len(raw) || raw[start] != '{' {
return 0, fmt.Errorf("inline hook table has no opening brace")
}
depth := 0
for index := start; index < len(raw); {
switch raw[index] {
case '"', '\'':
end, err := kimiTomlStringEnd(raw, index)
if err != nil {
return 0, err
}
index = end
case '#':
if newline := bytes.IndexByte(raw[index:], '\n'); newline >= 0 {
index += newline + 1
} else {
return 0, fmt.Errorf("inline hook table comment reaches end of document")
}
case '{':
depth++
index++
case '}':
depth--
index++
if depth == 0 {
return index, nil
}
default:
index++
}
}
return 0, fmt.Errorf("inline hook table has no closing brace")
}
func kimiTomlStringEnd(raw []byte, start int) (int, error) {
delimiter := raw[start]
multiline := start+2 < len(raw) && raw[start+1] == delimiter && raw[start+2] == delimiter
index := start + 1
if multiline {
index = start + 3
}
for index < len(raw) {
if delimiter == '"' && raw[index] == '\\' {
index += 2
continue
}
if raw[index] != delimiter {
index++
continue
}
if !multiline {
return index + 1, nil
}
runEnd := index
for runEnd < len(raw) && raw[runEnd] == delimiter {
runEnd++
}
if runEnd-index >= 3 {
return runEnd, nil
}
index = runEnd
}
return 0, fmt.Errorf("inline hook table contains an unterminated string")
}
func kimiTomlKeys(node *unstable.Node) []string {
keys := []string{}
iterator := node.Key()
for iterator.Next() {
keys = append(keys, string(iterator.Node().Data))
}
return keys
}
func kimiFirstKeyRange(node *unstable.Node) (unstable.Range, error) {
iterator := node.Key()
if !iterator.Next() {
return unstable.Range{}, fmt.Errorf("TOML expression has no key")
}
return iterator.Node().Raw, nil
}
func kimiLineStart(raw []byte, offset int) int {
if offset <= 0 {
return 0
}
lineBreak := bytes.LastIndexByte(raw[:offset], '\n')
if lineBreak < 0 {
return 0
}
return lineBreak + 1
}
func renderKimiHooks(document kimiDocument, keepIndices []int, additions []kimiHook) ([]byte, error) {
keep := make(map[int]struct{}, len(keepIndices))
for _, index := range keepIndices {
if index < 0 || index >= len(document.hooks) {
return nil, fmt.Errorf("%s hook index %d is out of range", document.contract.label, index)
}
keep[index] = struct{}{}
}
if document.layout.inlineValue != nil {
return renderKimiInlineDocument(document, keep, additions)
}
base := document.raw
if len(document.layout.hookTables) > 0 {
var result bytes.Buffer
cursor := 0
for index, block := range document.layout.hookTables {
if _, exists := keep[index]; exists {
continue
}
result.Write(base[cursor:block.start])
cursor = block.end
}
result.Write(base[cursor:])
base = result.Bytes()
}
if len(additions) == 0 {
return append([]byte(nil), base...), nil
}
return appendKimiHookTables(base, additions, kimiNewline(document.raw)), nil
}
func renderKimiInlineDocument(
document kimiDocument,
keep map[int]struct{},
additions []kimiHook,
) ([]byte, error) {
valueRange := *document.layout.inlineValue
value := document.raw[valueRange.start:valueRange.end]
if len(value) < 2 || value[0] != '[' || value[len(value)-1] != ']' {
return nil, fmt.Errorf("%s inline hooks value is malformed", document.contract.label)
}
local := make([]kimiByteRange, len(document.layout.inlineElements))
for index, element := range document.layout.inlineElements {
local[index] = kimiByteRange{
start: element.start - valueRange.start,
end: element.end - valueRange.start,
}
}
var rendered bytes.Buffer
rendered.WriteByte('[')
contentStart := 1
pending := []byte{}
if len(local) > 0 {
pending = append(pending, value[contentStart:local[0].start]...)
} else {
pending = append(pending, value[contentStart:len(value)-1]...)
}
emitted := 0
for index, element := range local {
if _, exists := keep[index]; exists {
if emitted > 0 {
rendered.WriteByte(',')
}
rendered.Write(kimiGapWithoutCommas(pending))
rendered.Write(value[element.start:element.end])
pending = pending[:0]
emitted++
}
next := len(value) - 1
if index+1 < len(local) {
next = local[index+1].start
}
pending = append(pending, value[element.end:next]...)
}
for _, hook := range additions {
if emitted > 0 {
rendered.WriteByte(',')
}
if len(pending) > 0 {
rendered.Write(kimiGapWithoutCommas(pending))
pending = pending[:0]
} else if emitted > 0 {
rendered.WriteByte(' ')
}
rendered.WriteString(renderKimiInlineTable(hook))
emitted++
}
rendered.Write(kimiGapWithoutCommas(pending))
rendered.WriteByte(']')
result := make([]byte, 0, len(document.raw)-len(value)+rendered.Len())
result = append(result, document.raw[:valueRange.start]...)
result = append(result, rendered.Bytes()...)
result = append(result, document.raw[valueRange.end:]...)
return result, nil
}
func kimiGapWithoutCommas(gap []byte) []byte {
result := make([]byte, 0, len(gap))
inComment := false
for _, character := range gap {
if inComment {
result = append(result, character)
if character == '\n' {
inComment = false
}
continue
}
if character == '#' {
inComment = true
result = append(result, character)
continue
}
if character != ',' {
result = append(result, character)
}
}
return result
}
func appendKimiHookTables(base []byte, hooks []kimiHook, newline string) []byte {
result := append([]byte(nil), base...)
if len(result) > 0 {
if !bytes.HasSuffix(result, []byte(newline)) {
result = append(result, newline...)
}
if !bytes.HasSuffix(result, []byte(newline+newline)) {
result = append(result, newline...)
}
}
for index, hook := range hooks {
if index > 0 {
result = append(result, newline...)
}
result = append(result, renderKimiHookTable(hook, newline)...)
}
return result
}
func renderKimiHookTable(hook kimiHook, newline string) []byte {
var result strings.Builder
result.WriteString("[[hooks]]")
result.WriteString(newline)
result.WriteString("event = ")
result.WriteString(strconv.Quote(hook.event))
result.WriteString(newline)
if hook.matcher != nil {
result.WriteString("matcher = ")
result.WriteString(strconv.Quote(*hook.matcher))
result.WriteString(newline)
}
result.WriteString("command = ")
result.WriteString(strconv.Quote(hook.command))
result.WriteString(newline)
if hook.timeout != nil {
result.WriteString("timeout = ")
result.WriteString(strconv.FormatInt(*hook.timeout, 10))
result.WriteString(newline)
}
return []byte(result.String())
}
func renderKimiInlineTable(hook kimiHook) string {
fields := []string{"event = " + strconv.Quote(hook.event)}
if hook.matcher != nil {
fields = append(fields, "matcher = "+strconv.Quote(*hook.matcher))
}
fields = append(fields, "command = "+strconv.Quote(hook.command))
if hook.timeout != nil {
fields = append(fields, "timeout = "+strconv.FormatInt(*hook.timeout, 10))
}
return "{ " + strings.Join(fields, ", ") + " }"
}
func kimiNewline(raw []byte) string {
if bytes.Contains(raw, []byte("\r\n")) {
return "\r\n"
}
return "\n"
}