183 lines
5.3 KiB
Go
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' }
|