// 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: ${...}, // $@, $, 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' }