Files
2026-08-14 23:41:57 +08:00

183 lines
5.3 KiB
Go

// This file implements the prompt-template expansion engine (US-002, #332): it
// turns a template body plus tokenized invocation args into the final prompt
// text, supporting pi's positional/default/slice syntax.
//
// Supported placeholders (mirrors https://pi.dev/docs/latest/prompt-templates):
// - $1, $2, ... $N : Nth positional arg (1-indexed; out-of-range -> "")
// - $@, $ARGUMENTS : all args joined by a single space
// - ${1:-default} : arg 1 when present and non-empty, else `default`
// - ${@:-default}, ${ARGUMENTS:-default} : all args when non-empty, else default
// - ${@:N} : args from the Nth onward (1-indexed), joined
// - ${@:N:L} : L args starting at N, joined
//
// A single left-to-right pass expands both braced ${...} and bare $N/$@ forms.
// Because the pass consumes a ${...} as one unit, a bare $1 never matches inside
// ${1:-...}, and a default literal containing $ is not re-expanded (the
// substituted text is appended verbatim and the scan advances past it).
package runtime
import (
"strconv"
"strings"
)
// ExpandTemplate expands template against the tokenized args. A template with no
// placeholder preserves ParseUserCommand's behavior: with no args it is returned
// verbatim, with args they are appended after a blank line (joined by spaces).
func ExpandTemplate(template string, args []string) string {
if !hasPlaceholder(template) {
if len(args) == 0 {
return template
}
return template + "\n\n" + strings.Join(args, " ")
}
var b strings.Builder
i := 0
n := len(template)
for i < n {
c := template[i]
if c == '$' && i+1 < n {
next := template[i+1]
if next == '{' {
end := strings.IndexByte(template[i+2:], '}')
if end < 0 {
// No closing brace: emit the '$' literally and continue.
b.WriteByte(c)
i++
continue
}
inner := template[i+2 : i+2+end]
b.WriteString(expandBraced(inner, args))
i += 2 + end + 1
continue
}
if next == '@' {
b.WriteString(strings.Join(args, " "))
i += 2
continue
}
if isDigitByte(next) {
j := i + 1
for j < n && isDigitByte(template[j]) {
j++
}
idx, _ := strconv.Atoi(template[i+1 : j])
if idx >= 1 && idx <= len(args) {
b.WriteString(args[idx-1])
}
i = j
continue
}
if strings.HasPrefix(template[i+1:], "ARGUMENTS") {
b.WriteString(strings.Join(args, " "))
i += 1 + len("ARGUMENTS")
continue
}
}
b.WriteByte(c)
i++
}
return b.String()
}
// hasPlaceholder reports whether s contains any template placeholder: ${...},
// $@, $<digits>, or $ARGUMENTS. A bare '$' not followed by one of these is not a
// placeholder (emitted literally), so a template like "price $5" still counts.
func hasPlaceholder(s string) bool {
for i := 0; i < len(s); i++ {
if s[i] != '$' {
continue
}
if i+1 >= len(s) {
return false
}
next := s[i+1]
if next == '{' || next == '@' || isDigitByte(next) {
return true
}
if strings.HasPrefix(s[i+1:], "ARGUMENTS") {
return true
}
}
return false
}
// expandBraced expands the content inside ${...} (without the surrounding
// braces). It dispatches on the three forms: name:-default, @:N[:L], or plain.
func expandBraced(inner string, args []string) string {
// default form: name:-default
if idx := strings.Index(inner, ":-"); idx >= 0 {
return expandDefaulted(inner[:idx], inner[idx+2:], args)
}
// slice form: @:N or @:N:L
if idx := strings.Index(inner, ":"); idx >= 0 {
if name := inner[:idx]; name != "@" && name != "ARGUMENTS" {
return "" // slicing only applies to all-args
}
return expandSlice(inner[idx+1:], args)
}
// plain: N, @, or ARGUMENTS
return expandPlain(inner, args)
}
// expandPlain expands a bare braced name (no :- or :): a positional index, @,
// or ARGUMENTS. An unknown name (e.g. ${foo}) expands to "".
func expandPlain(name string, args []string) string {
if name == "@" || name == "ARGUMENTS" {
return strings.Join(args, " ")
}
if idx, err := strconv.Atoi(name); err == nil {
if idx >= 1 && idx <= len(args) {
return args[idx-1]
}
return ""
}
return ""
}
// expandDefaulted expands name:-default: the named positional (when in range and
// non-empty) or all-args (when the join is non-empty), otherwise the literal
// default. The default is not re-expanded.
func expandDefaulted(name, def string, args []string) string {
if name == "@" || name == "ARGUMENTS" {
if joined := strings.Join(args, " "); joined != "" {
return joined
}
return def
}
if idx, err := strconv.Atoi(name); err == nil {
if idx >= 1 && idx <= len(args) && args[idx-1] != "" {
return args[idx-1]
}
return def
}
return def
}
// expandSlice expands the N (or N:L) part of ${@:N} / ${@:N:L}: args from the Nth
// onward (1-indexed), optionally limited to L, joined by spaces. N<1 or beyond
// the arg list yields "".
func expandSlice(rest string, args []string) string {
parts := strings.SplitN(rest, ":", 2)
start, _ := strconv.Atoi(parts[0])
if start < 1 {
return ""
}
begin := start - 1
if begin >= len(args) {
return ""
}
end := len(args)
if len(parts) == 2 {
if l, err := strconv.Atoi(parts[1]); err == nil && l >= 0 {
end = begin + l
if end > len(args) {
end = len(args)
}
}
}
return strings.Join(args[begin:end], " ")
}
func isDigitByte(b byte) bool { return b >= '0' && b <= '9' }