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 raw []byte hooks []kimiHook layout kimiTomlLayout } func parseKimiDocument(contract kimiContract, raw []byte, exists bool) (kimiDocument, error) { if !exists { return kimiDocument{contract: contract, 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, 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" }