fix(openai): Cline 템플릿 툴 호출을 지원한다

This commit is contained in:
toki 2026-06-27 14:10:02 +09:00
parent 135259619e
commit 65172918ed
3 changed files with 763 additions and 14 deletions

View file

@ -167,7 +167,8 @@ Workspace-bound route는 workspace가 없거나 상대 경로이면 OpenAI-compa
`tools`가 있는 Chat Completions 요청에서 Edge는 backend-native tool call stream을 일반 구조화 이벤트로 중계하지 않는다.
단, backend가 assistant content에 Cline-style 텍스트 tool call 블록을 출력하고 그 function name이 요청의 `tools[].function.name`에 포함되어 있으면 Edge는 해당 블록을 OpenAI-compatible `message.tool_calls` 또는 stream `delta.tool_calls`로 변환하고 `finish_reason: "tool_calls"`를 반환한다.
지원하는 텍스트 블록의 최소 형태는 `<tool_call><function=<name>><parameter=<key>>JSON-or-text</parameter></function></tool_call>`이다.
지원하는 텍스트 블록의 최소 형태는 `<tool_call><function=<name>><parameter=<key>>JSON-or-text</parameter></function></tool_call>` 또는 `{{function_name(key=Python/JSON-like-literal)}}`이다.
템플릿 호출 형태의 인자는 single-quoted string, `True`/`False`/`None`, 배열, 객체를 JSON `arguments` 문자열로 정규화한다.
호환성을 위해 `tool_choice`가 생략되었거나 `auto`/강제 tool 선택 형태로 들어오면 내부 실행 입력에서는 `tool_choice: "none"`으로 낮춰 백엔드의 auto tool-calling 요구 조건에 의해 요청 전체가 실패하지 않게 한다.
`parallel_tool_calls``stream_options`는 클라이언트 호환성을 위해 수신하지만 현재 Edge 실행 의미에는 반영하지 않는다.

View file

@ -322,6 +322,101 @@ func TestChatCompletionsSynthesizesToolCallsFromClineTextBlock(t *testing.T) {
}
}
func TestChatCompletionsSynthesizesToolCallsFromClineTemplateCall(t *testing.T) {
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
fake.events <- &iop.RunEvent{Type: "delta", Delta: "사용자가 변경 내용을 푸시해달라고 요청하셨습니다. 먼저 현재 git 상태를 확인하여 변경된 파일과 커밋 가능한 내용을 파악하겠습니다.\n\n{{run_commands(commands=[{'command': 'git status', 'description': '현재 git 상태 확인', 'runInTerminal': True}]})}}"}
fake.events <- &iop.RunEvent{Type: "complete"}
srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil)
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
"model":"qwen3.6:35b",
"messages":[{"role":"user","content":"push changes"}],
"tools":[{"type":"function","function":{"name":"run_commands","parameters":{"type":"object"}}}],
"tool_choice":"auto"
}`))
w := httptest.NewRecorder()
srv.handleChatCompletions(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
var resp chatCompletionResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
choice := resp.Choices[0]
if choice.FinishReason != "tool_calls" {
t.Fatalf("finish_reason: got %q, want tool_calls", choice.FinishReason)
}
if strings.Contains(choice.Message.Content, "{{run_commands") {
t.Fatalf("content still contains raw template tool call: %q", choice.Message.Content)
}
if len(choice.Message.ToolCalls) != 1 {
t.Fatalf("tool_calls len: got %d", len(choice.Message.ToolCalls))
}
call, ok := choice.Message.ToolCalls[0].(map[string]any)
if !ok {
t.Fatalf("tool call shape: %+v", choice.Message.ToolCalls[0])
}
fn, ok := call["function"].(map[string]any)
if !ok {
t.Fatalf("function shape: %+v", call["function"])
}
if fn["name"] != "run_commands" {
t.Fatalf("function name: got %+v", fn["name"])
}
var args map[string][]map[string]any
if err := json.Unmarshal([]byte(fn["arguments"].(string)), &args); err != nil {
t.Fatalf("arguments JSON: %v", err)
}
commands := args["commands"]
if len(commands) != 1 {
t.Fatalf("commands len: got %d", len(commands))
}
if commands[0]["command"] != "git status" || commands[0]["description"] != "현재 git 상태 확인" || commands[0]["runInTerminal"] != true {
t.Fatalf("commands args: %+v", commands[0])
}
}
func TestSynthesizesTemplateToolCallWithClosingMarkerInString(t *testing.T) {
content := "확인합니다.\n\n{{run_commands(commands=[{'command': \"printf ')}}'\", 'description': 'marker test', 'runInTerminal': False}])}}"
tools := []any{map[string]any{
"type": "function",
"function": map[string]any{
"name": "run_commands",
},
}}
cleaned, toolCalls := synthesizeToolCallsFromText(content, tools, "run-test")
if cleaned != "확인합니다." {
t.Fatalf("content: got %q", cleaned)
}
if len(toolCalls) != 1 {
t.Fatalf("tool_calls len: got %d", len(toolCalls))
}
call, ok := toolCalls[0].(map[string]any)
if !ok {
t.Fatalf("tool call shape: %+v", toolCalls[0])
}
fn, ok := call["function"].(map[string]any)
if !ok {
t.Fatalf("function shape: %+v", call["function"])
}
var args map[string][]map[string]any
if err := json.Unmarshal([]byte(fn["arguments"].(string)), &args); err != nil {
t.Fatalf("arguments JSON: %v", err)
}
commands := args["commands"]
if len(commands) != 1 {
t.Fatalf("commands len: got %d", len(commands))
}
if commands[0]["command"] != "printf ')}}'" || commands[0]["runInTerminal"] != false {
t.Fatalf("commands args: %+v", commands[0])
}
}
func TestChatCompletionsLeavesUnknownTextToolCallAsContent(t *testing.T) {
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
fake.events <- &iop.RunEvent{Type: "delta", Delta: "<tool_call><function=delete_everything><parameter=confirm>true</parameter></function></tool_call>"}
@ -352,6 +447,36 @@ func TestChatCompletionsLeavesUnknownTextToolCallAsContent(t *testing.T) {
}
}
func TestChatCompletionsLeavesUnknownTemplateToolCallAsContent(t *testing.T) {
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
fake.events <- &iop.RunEvent{Type: "delta", Delta: "{{delete_everything(confirm=True)}}"}
fake.events <- &iop.RunEvent{Type: "complete"}
srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil)
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
"model":"qwen3.6:35b",
"messages":[{"role":"user","content":"status"}],
"tools":[{"type":"function","function":{"name":"run_commands"}}]
}`))
w := httptest.NewRecorder()
srv.handleChatCompletions(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
var resp chatCompletionResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.Choices[0].FinishReason == "tool_calls" || len(resp.Choices[0].Message.ToolCalls) != 0 {
t.Fatalf("unexpected tool call conversion: %+v", resp.Choices[0])
}
if !strings.Contains(resp.Choices[0].Message.Content, "{{delete_everything") {
t.Fatalf("raw unknown template tool call should remain content: %q", resp.Choices[0].Message.Content)
}
}
func TestChatCompletionsMapsMaxCompletionTokens(t *testing.T) {
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"}
@ -441,6 +566,44 @@ func TestChatCompletionsStreamSynthesizesToolCallsFromClineTextBlock(t *testing.
}
}
func TestChatCompletionsStreamSynthesizesToolCallsFromClineTemplateCall(t *testing.T) {
fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)}
fake.events <- &iop.RunEvent{Type: "delta", Delta: "먼저 현재 git 상태를 확인하겠습니다.\n\n"}
fake.events <- &iop.RunEvent{Type: "delta", Delta: "{{run_commands(commands=[{'command': 'git status', 'description': '현재 git 상태 확인', 'runInTerminal': True}]})}}"}
fake.events <- &iop.RunEvent{Type: "complete"}
srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil)
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
"model":"stream-model",
"stream":true,
"messages":[{"role":"user","content":"status"}],
"tools":[{"type":"function","function":{"name":"run_commands"}}]
}`))
w := httptest.NewRecorder()
srv.handleChatCompletions(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
body := w.Body.String()
if !strings.Contains(body, `"tool_calls"`) || !strings.Contains(body, `"finish_reason":"tool_calls"`) {
t.Fatalf("stream did not synthesize tool_calls:\n%s", body)
}
if strings.Contains(body, "{{run_commands") {
t.Fatalf("stream leaked raw template tool call:\n%s", body)
}
if !strings.Contains(body, `"index":0`) {
t.Fatalf("stream tool_call delta should include index:\n%s", body)
}
if !strings.Contains(body, `\"runInTerminal\":true`) {
t.Fatalf("stream tool_call arguments should include parsed boolean:\n%s", body)
}
if !strings.Contains(body, `"content":"먼저 현재 git 상태를 확인하겠습니다."`) {
t.Fatalf("stream should preserve text before tool call:\n%s", body)
}
}
func TestChatCompletionsStreamingReportsClosedRunStream(t *testing.T) {
fake := &fakeRunService{events: make(chan *iop.RunEvent)}
close(fake.events)

View file

@ -4,6 +4,8 @@ import (
"encoding/json"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
)
@ -173,18 +175,33 @@ func contentToString(v any) string {
}
var (
textToolCallBlockRE = regexp.MustCompile(`(?is)<\s*tool_call\s*>(.*?)<\s*/\s*tool_call\s*>`)
textToolFunctionRE = regexp.MustCompile(`(?is)<\s*function\s*=\s*([A-Za-z_][A-Za-z0-9_.:-]*)\s*>(.*?)<\s*/\s*function(?:\s*=\s*[A-Za-z_][A-Za-z0-9_.:-]*)?\s*>`)
textToolParameterRE = regexp.MustCompile(`(?is)<\s*parameter\s*=\s*([A-Za-z_][A-Za-z0-9_.:-]*)\s*>(.*?)<\s*/\s*parameter(?:\s*=\s*[A-Za-z_][A-Za-z0-9_.:-]*)?\s*>`)
textToolCallOpeningHint = "<tool_call"
textToolCallBlockRE = regexp.MustCompile(`(?is)<\s*tool_call\s*>(.*?)<\s*/\s*tool_call\s*>`)
textToolFunctionRE = regexp.MustCompile(`(?is)<\s*function\s*=\s*([A-Za-z_][A-Za-z0-9_.:-]*)\s*>(.*?)<\s*/\s*function(?:\s*=\s*[A-Za-z_][A-Za-z0-9_.:-]*)?\s*>`)
textToolParameterRE = regexp.MustCompile(`(?is)<\s*parameter\s*=\s*([A-Za-z_][A-Za-z0-9_.:-]*)\s*>(.*?)<\s*/\s*parameter(?:\s*=\s*[A-Za-z_][A-Za-z0-9_.:-]*)?\s*>`)
textToolCallOpeningHint = "<tool_call"
textMustacheToolCallOpenHint = "{{"
)
type textToolCallMatch struct {
start int
end int
name string
arguments string
}
type mustacheTextToolCallBlock struct {
start int
end int
name string
args string
}
func synthesizeToolCallsFromText(content string, tools []any, runID string) (string, []any) {
allowed := requestedToolNames(tools)
if len(allowed) == 0 || !strings.Contains(strings.ToLower(content), textToolCallOpeningHint) {
if len(allowed) == 0 {
return content, nil
}
matches := textToolCallBlockRE.FindAllStringSubmatchIndex(content, -1)
matches := collectTextToolCallMatches(content, allowed)
if len(matches) == 0 {
return content, nil
}
@ -193,19 +210,17 @@ func synthesizeToolCallsFromText(content string, tools []any, runID string) (str
cursor := 0
toolCalls := make([]any, 0, len(matches))
for _, match := range matches {
block := content[match[0]:match[1]]
name, arguments, ok := parseTextToolCallBlock(block, allowed)
if !ok {
if match.start < cursor {
continue
}
cleaned.WriteString(content[cursor:match[0]])
cursor = match[1]
cleaned.WriteString(content[cursor:match.start])
cursor = match.end
toolCalls = append(toolCalls, map[string]any{
"id": textToolCallID(runID, len(toolCalls)+1),
"type": "function",
"function": map[string]any{
"name": name,
"arguments": arguments,
"name": match.name,
"arguments": match.arguments,
},
})
}
@ -216,6 +231,158 @@ func synthesizeToolCallsFromText(content string, tools []any, runID string) (str
return strings.TrimSpace(cleaned.String()), toolCalls
}
func collectTextToolCallMatches(content string, allowed map[string]struct{}) []textToolCallMatch {
var matches []textToolCallMatch
matches = append(matches, collectXMLTextToolCallMatches(content, allowed)...)
matches = append(matches, collectMustacheTextToolCallMatches(content, allowed)...)
sort.SliceStable(matches, func(i, j int) bool {
if matches[i].start == matches[j].start {
return matches[i].end > matches[j].end
}
return matches[i].start < matches[j].start
})
return matches
}
func collectXMLTextToolCallMatches(content string, allowed map[string]struct{}) []textToolCallMatch {
if !strings.Contains(strings.ToLower(content), textToolCallOpeningHint) {
return nil
}
rawMatches := textToolCallBlockRE.FindAllStringSubmatchIndex(content, -1)
if len(rawMatches) == 0 {
return nil
}
out := make([]textToolCallMatch, 0, len(rawMatches))
for _, rawMatch := range rawMatches {
block := content[rawMatch[0]:rawMatch[1]]
name, arguments, ok := parseTextToolCallBlock(block, allowed)
if !ok {
continue
}
out = append(out, textToolCallMatch{
start: rawMatch[0],
end: rawMatch[1],
name: name,
arguments: arguments,
})
}
return out
}
func collectMustacheTextToolCallMatches(content string, allowed map[string]struct{}) []textToolCallMatch {
if !strings.Contains(content, textMustacheToolCallOpenHint) {
return nil
}
blocks := scanMustacheTextToolCallBlocks(content)
if len(blocks) == 0 {
return nil
}
out := make([]textToolCallMatch, 0, len(blocks))
for _, block := range blocks {
if _, ok := allowed[block.name]; !ok {
continue
}
args, ok := parseMustacheToolCallArguments(block.args)
if !ok {
continue
}
encoded, err := json.Marshal(args)
if err != nil {
continue
}
out = append(out, textToolCallMatch{
start: block.start,
end: block.end,
name: block.name,
arguments: string(encoded),
})
}
return out
}
func scanMustacheTextToolCallBlocks(content string) []mustacheTextToolCallBlock {
var blocks []mustacheTextToolCallBlock
for start := 0; start < len(content); {
open := strings.Index(content[start:], textMustacheToolCallOpenHint)
if open < 0 {
break
}
blockStart := start + open
nameStart := skipASCIISpaces(content, blockStart+len(textMustacheToolCallOpenHint))
nameEnd := nameStart
for nameEnd < len(content) && isTextToolIdentifierChar(content[nameEnd], nameEnd == nameStart) {
nameEnd++
}
name := content[nameStart:nameEnd]
if name == "" {
start = blockStart + len(textMustacheToolCallOpenHint)
continue
}
callOpen := skipASCIISpaces(content, nameEnd)
if callOpen >= len(content) || content[callOpen] != '(' {
start = blockStart + len(textMustacheToolCallOpenHint)
continue
}
argsStart := callOpen + 1
argsEnd, blockEnd, ok := findMustacheToolCallEnd(content, argsStart)
if !ok {
start = blockStart + len(textMustacheToolCallOpenHint)
continue
}
blocks = append(blocks, mustacheTextToolCallBlock{
start: blockStart,
end: blockEnd,
name: name,
args: content[argsStart:argsEnd],
})
start = blockEnd
}
return blocks
}
func findMustacheToolCallEnd(content string, argsStart int) (int, int, bool) {
depth := 0
var quote byte
escaped := false
for i := argsStart; i < len(content); i++ {
c := content[i]
if quote != 0 {
if escaped {
escaped = false
continue
}
if c == '\\' {
escaped = true
continue
}
if c == quote {
quote = 0
}
continue
}
switch c {
case '\'', '"':
quote = c
case '[', '{', '(':
depth++
case ']', '}':
if depth > 0 {
depth--
}
case ')':
if depth > 0 {
depth--
continue
}
closeStart := skipASCIISpaces(content, i+1)
if closeStart+1 < len(content) && content[closeStart] == '}' && content[closeStart+1] == '}' {
return i, closeStart + 2, true
}
}
}
return 0, 0, false
}
func textToolCallID(runID string, ordinal int) string {
normalized := normalizeToolCallIDPart(runID)
if normalized == "" {
@ -319,6 +486,424 @@ func parseTextToolParameterValue(raw string) any {
return trimmed
}
func parseMustacheToolCallArguments(raw string) (map[string]any, bool) {
if args, ok := parseMustacheToolCallArgumentsStrict(raw); ok {
return args, true
}
// Some Cline/provider outputs include one extra "}" before the closing
// template parens, e.g. `...True}]})}}`. Recover only after strict parsing.
trimmed := strings.TrimSpace(raw)
if strings.HasSuffix(trimmed, "}") {
return parseMustacheToolCallArgumentsStrict(strings.TrimSpace(trimmed[:len(trimmed)-1]))
}
return nil, false
}
func parseMustacheToolCallArgumentsStrict(raw string) (map[string]any, bool) {
trimmed := strings.TrimSpace(raw)
args := map[string]any{}
if trimmed == "" {
return args, true
}
for _, part := range splitTopLevel(trimmed, ',') {
part = strings.TrimSpace(part)
if part == "" {
continue
}
eq := indexTopLevel(part, '=')
if eq < 0 {
return nil, false
}
key := strings.TrimSpace(part[:eq])
if !isTextToolIdentifier(key) {
return nil, false
}
value, ok := parseTextToolLiteral(part[eq+1:])
if !ok {
return nil, false
}
args[key] = value
}
return args, true
}
func parseTextToolLiteral(raw string) (any, bool) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return "", true
}
var decoded any
if err := json.Unmarshal([]byte(trimmed), &decoded); err == nil {
return decoded, true
}
parser := pythonishLiteralParser{s: trimmed}
value, ok := parser.parseValue()
if !ok {
return nil, false
}
parser.skipSpaces()
if parser.pos != len(parser.s) {
return nil, false
}
return value, true
}
func splitTopLevel(s string, sep byte) []string {
parts := []string{}
start := 0
depth := 0
var quote byte
escaped := false
for i := 0; i < len(s); i++ {
c := s[i]
if quote != 0 {
if escaped {
escaped = false
continue
}
if c == '\\' {
escaped = true
continue
}
if c == quote {
quote = 0
}
continue
}
switch c {
case '\'', '"':
quote = c
case '[', '{', '(':
depth++
case ']', '}', ')':
if depth > 0 {
depth--
}
case sep:
if depth == 0 {
parts = append(parts, s[start:i])
start = i + 1
}
}
}
parts = append(parts, s[start:])
return parts
}
func indexTopLevel(s string, target byte) int {
depth := 0
var quote byte
escaped := false
for i := 0; i < len(s); i++ {
c := s[i]
if quote != 0 {
if escaped {
escaped = false
continue
}
if c == '\\' {
escaped = true
continue
}
if c == quote {
quote = 0
}
continue
}
switch c {
case '\'', '"':
quote = c
case '[', '{', '(':
depth++
case ']', '}', ')':
if depth > 0 {
depth--
}
default:
if c == target && depth == 0 {
return i
}
}
}
return -1
}
func skipASCIISpaces(s string, pos int) int {
for pos < len(s) {
switch s[pos] {
case ' ', '\n', '\r', '\t':
pos++
default:
return pos
}
}
return pos
}
type pythonishLiteralParser struct {
s string
pos int
}
func (p *pythonishLiteralParser) parseValue() (any, bool) {
p.skipSpaces()
if p.pos >= len(p.s) {
return nil, false
}
switch p.s[p.pos] {
case '\'', '"':
return p.parseString()
case '[':
return p.parseArray()
case '{':
return p.parseObject()
}
if p.consumeWord("True") || p.consumeWord("true") {
return true, true
}
if p.consumeWord("False") || p.consumeWord("false") {
return false, true
}
if p.consumeWord("None") || p.consumeWord("null") {
return nil, true
}
return p.parseNumberOrBare()
}
func (p *pythonishLiteralParser) parseString() (string, bool) {
if p.pos >= len(p.s) {
return "", false
}
quote := p.s[p.pos]
p.pos++
var b strings.Builder
for p.pos < len(p.s) {
c := p.s[p.pos]
p.pos++
if c == quote {
return b.String(), true
}
if c != '\\' {
b.WriteByte(c)
continue
}
if p.pos >= len(p.s) {
return "", false
}
esc := p.s[p.pos]
p.pos++
switch esc {
case '\\', '\'', '"':
b.WriteByte(esc)
case 'n':
b.WriteByte('\n')
case 'r':
b.WriteByte('\r')
case 't':
b.WriteByte('\t')
case 'b':
b.WriteByte('\b')
case 'f':
b.WriteByte('\f')
case 'u':
if p.pos+4 > len(p.s) {
return "", false
}
r, err := strconv.ParseInt(p.s[p.pos:p.pos+4], 16, 32)
if err != nil {
return "", false
}
b.WriteRune(rune(r))
p.pos += 4
default:
b.WriteByte(esc)
}
}
return "", false
}
func (p *pythonishLiteralParser) parseArray() ([]any, bool) {
p.pos++
items := []any{}
p.skipSpaces()
if p.consumeByte(']') {
return items, true
}
for {
value, ok := p.parseValue()
if !ok {
return nil, false
}
items = append(items, value)
p.skipSpaces()
if p.consumeByte(']') {
return items, true
}
if !p.consumeByte(',') {
return nil, false
}
p.skipSpaces()
if p.consumeByte(']') {
return items, true
}
}
}
func (p *pythonishLiteralParser) parseObject() (map[string]any, bool) {
p.pos++
obj := map[string]any{}
p.skipSpaces()
if p.consumeByte('}') {
return obj, true
}
for {
key, ok := p.parseObjectKey()
if !ok {
return nil, false
}
p.skipSpaces()
if !p.consumeByte(':') {
return nil, false
}
value, ok := p.parseValue()
if !ok {
return nil, false
}
obj[key] = value
p.skipSpaces()
if p.consumeByte('}') {
return obj, true
}
if !p.consumeByte(',') {
return nil, false
}
p.skipSpaces()
if p.consumeByte('}') {
return obj, true
}
}
}
func (p *pythonishLiteralParser) parseObjectKey() (string, bool) {
p.skipSpaces()
if p.pos >= len(p.s) {
return "", false
}
if p.s[p.pos] == '\'' || p.s[p.pos] == '"' {
return p.parseString()
}
start := p.pos
for p.pos < len(p.s) && isTextToolIdentifierChar(p.s[p.pos], p.pos == start) {
p.pos++
}
if start == p.pos {
return "", false
}
return p.s[start:p.pos], true
}
func (p *pythonishLiteralParser) parseNumberOrBare() (any, bool) {
start := p.pos
for p.pos < len(p.s) && !isLiteralDelimiter(p.s[p.pos]) {
p.pos++
}
if start == p.pos {
return nil, false
}
token := p.s[start:p.pos]
if value, ok := parseNumberToken(token); ok {
return value, true
}
if isTextToolIdentifier(token) {
return token, true
}
return nil, false
}
func (p *pythonishLiteralParser) skipSpaces() {
for p.pos < len(p.s) {
switch p.s[p.pos] {
case ' ', '\n', '\r', '\t':
p.pos++
default:
return
}
}
}
func (p *pythonishLiteralParser) consumeByte(b byte) bool {
if p.pos < len(p.s) && p.s[p.pos] == b {
p.pos++
return true
}
return false
}
func (p *pythonishLiteralParser) consumeWord(word string) bool {
if !strings.HasPrefix(p.s[p.pos:], word) {
return false
}
end := p.pos + len(word)
if end < len(p.s) && isBareLiteralChar(p.s[end]) {
return false
}
p.pos = end
return true
}
func parseNumberToken(token string) (any, bool) {
if token == "" {
return nil, false
}
if strings.ContainsAny(token, ".eE") {
value, err := strconv.ParseFloat(token, 64)
if err != nil {
return nil, false
}
return value, true
}
value, err := strconv.ParseInt(token, 10, 64)
if err != nil {
return nil, false
}
return value, true
}
func isTextToolIdentifier(s string) bool {
if s == "" {
return false
}
for i := 0; i < len(s); i++ {
if !isTextToolIdentifierChar(s[i], i == 0) {
return false
}
}
return true
}
func isTextToolIdentifierChar(c byte, first bool) bool {
if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c == '_' {
return true
}
if !first && (c >= '0' && c <= '9' || c == '.' || c == ':' || c == '-') {
return true
}
return false
}
func isBareLiteralChar(c byte) bool {
return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_'
}
func isLiteralDelimiter(c byte) bool {
switch c {
case ',', ']', '}', ' ', '\n', '\r', '\t':
return true
default:
return false
}
}
func previewString(s string, max int) string {
if max <= 0 || len(s) <= max {
return s