Skip to content
Open
100 changes: 100 additions & 0 deletions packages/opencode/src/config/substitute.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
const placeholderRegex = /\$(\d+)/g
const defaultPlaceholderRegex = /\$\{(\d+):([^}]*)\}/g
const rangeWithDefaultRegex = /\$\{(\d*)(\.\.\d*):([^}]*)\}/g

// AGENTS: When merging with feat/argument-range-syntax, maintain this processing order:
// AGENTS: 1. rangeWithDefaultRegex (most specific - requires .. and :)
// AGENTS: 2. defaultPlaceholderRegex (has : but no ..)
// AGENTS: 3. extendedPlaceholderRegex from argument-range-syntax (has .. but no :)
// AGENTS: 4. placeholderRegex ($N syntax)
// AGENTS: These regexes are mutually exclusive by syntax, so order doesn't affect correctness,
// AGENTS: but processing most-specific-first improves clarity and maintainability.
// AGENTS: Insert extendedPlaceholderRegex processing between defaultPlaceholderRegex and placeholderRegex.

function resolveChainedDefault(expr: string, defaultVal: string, args: string[]): string {
const parts = defaultVal.split(":")
for (const part of parts) {
if (part === "") return expr
const match = part.match(/^\$(\d+)$/)
if (match) {
const argIndex = Number(match[1]) - 1
if (argIndex < args.length) {
const arg = args[argIndex]
if (arg.trim() !== "") return arg
}
} else {
return part
}
}
return ""
}

export function substituteArguments(
template: string,
args: string[],
): { result: string; hasPlaceholders: boolean } {
const placeholders = template.match(placeholderRegex) ?? []
const defaultPlaceholders = template.match(defaultPlaceholderRegex) ?? []
const rangeWithDefaultPlaceholders = template.match(rangeWithDefaultRegex) ?? []

let last = 0
for (const item of placeholders) {
const value = Number(item.slice(1))
if (value > last) last = value
}
for (const item of defaultPlaceholders) {
const match = item.match(/\$\{(\d+):/)
if (match) {
const value = Number(match[1])
if (value > last) last = value
}
}
for (const item of rangeWithDefaultPlaceholders) {
const match = item.match(/\$\{(\d*)\.\./)
if (match && match[1]) {
const value = Number(match[1])
if (value > last) last = value
}
}

const hasPlaceholders = placeholders.length > 0 || defaultPlaceholders.length > 0 || rangeWithDefaultPlaceholders.length > 0

let result = template.replaceAll(rangeWithDefaultRegex, (expr, start, dotsAndEnd, defaultVal) => {
const startIndex = start ? Number(start) : 1
const endIndex = dotsAndEnd && dotsAndEnd.length > 2 ? Number(dotsAndEnd.slice(2)) : undefined
const argStart = startIndex - 1
if (argStart < args.length) {
const slice = endIndex !== undefined
? args.slice(argStart, endIndex)
: args.slice(argStart)
const nonEmpty = slice.filter(arg => arg.trim() !== "")
if (nonEmpty.length > 0) return nonEmpty.join(" ")
}
return resolveChainedDefault(expr, defaultVal, args)
})

result = result.replaceAll(defaultPlaceholderRegex, (expr, position, defaultVal) => {
const pos = Number(position)
const argIndex = pos - 1
if (argIndex < args.length) {
const arg = args[argIndex]
if (arg.trim() !== "") {
if (pos === last) return args.slice(argIndex).join(" ")
return arg
}
}
return resolveChainedDefault(expr, defaultVal, args)
})

result = result.replaceAll(placeholderRegex, (_, index) => {
const position = Number(index)
const argIndex = position - 1
if (argIndex >= args.length) return ""
if (position === last) return args.slice(argIndex).join(" ")
return args[argIndex]
})

result = result.replaceAll("$ARGUMENTS", args.join(" "))

return { result, hasPlaceholders }
}
20 changes: 3 additions & 17 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { spawn } from "child_process"
import { Command } from "../command"
import { $, fileURLToPath, pathToFileURL } from "bun"
import { ConfigMarkdown } from "../config/markdown"
import { substituteArguments } from "../config/substitute"
import { SessionSummary } from "./summary"
import { NamedError } from "@opencode-ai/util/error"
import { fn } from "@/util/fn"
Expand Down Expand Up @@ -1733,7 +1734,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
const bashRegex = /!`([^`]+)`/g
// Match [Image N] as single token, quoted strings, or non-space sequences
const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi
const placeholderRegex = /\$(\d+)/g
const quoteTrimRegex = /^["']|["']$/g
/**
* Regular expression to match @ file references in text
Expand All @@ -1751,27 +1751,13 @@ NOTE: At any point in time through this workflow you should feel free to ask the

const templateCommand = await command.template

const placeholders = templateCommand.match(placeholderRegex) ?? []
let last = 0
for (const item of placeholders) {
const value = Number(item.slice(1))
if (value > last) last = value
}

// Let the final placeholder swallow any extra arguments so prompts read naturally
const withArgs = templateCommand.replaceAll(placeholderRegex, (_, index) => {
const position = Number(index)
const argIndex = position - 1
if (argIndex >= args.length) return ""
if (position === last) return args.slice(argIndex).join(" ")
return args[argIndex]
})
const { result: withArgs, hasPlaceholders } = substituteArguments(templateCommand, args)
const usesArgumentsPlaceholder = templateCommand.includes("$ARGUMENTS")
let template = withArgs.replaceAll("$ARGUMENTS", input.arguments)

// If command doesn't explicitly handle arguments (no $N or $ARGUMENTS placeholders)
// but user provided arguments, append them to the template
if (placeholders.length === 0 && !usesArgumentsPlaceholder && input.arguments.trim()) {
if (!hasPlaceholders && !usesArgumentsPlaceholder && input.arguments.trim()) {
template = template + "\n\n" + input.arguments
}

Expand Down
185 changes: 185 additions & 0 deletions packages/opencode/test/config/substitute.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import { test, expect } from "bun:test"
import { substituteArguments } from "../../src/config/substitute"

test("substituteArguments - no placeholders", () => {
const { result, hasPlaceholders } = substituteArguments("hello world", ["a", "b"])
expect(result).toBe("hello world")
expect(hasPlaceholders).toBe(false)
})

test("substituteArguments - single placeholder", () => {
const { result, hasPlaceholders } = substituteArguments("hello $1", ["world"])
expect(result).toBe("hello world")
expect(hasPlaceholders).toBe(true)
})

test("substituteArguments - multiple placeholders", () => {
const { result, hasPlaceholders } = substituteArguments("$1 and $2", ["first", "second"])
expect(result).toBe("first and second")
expect(hasPlaceholders).toBe(true)
})

test("substituteArguments - last placeholder swallows remaining", () => {
const { result } = substituteArguments("$1 $2", ["a", "b", "c", "d"])
expect(result).toBe("a b c d")
})

test("substituteArguments - missing argument returns empty", () => {
const { result } = substituteArguments("$1 and $3", ["only", "two"])
expect(result).toBe("only and ")
})

test("substituteArguments - $ARGUMENTS replaced", () => {
const { result } = substituteArguments("args: $ARGUMENTS", ["a", "b", "c"])
expect(result).toBe("args: a b c")
})

test("substituteArguments - ${1:default} with provided arg", () => {
const { result } = substituteArguments("${1:fallback}", ["provided"])
expect(result).toBe("provided")
})

test("substituteArguments - ${1:default} with empty arg", () => {
const { result } = substituteArguments("${1:fallback}", [""])
expect(result).toBe("fallback")
})

test("substituteArguments - ${1:default} with whitespace arg", () => {
const { result } = substituteArguments("${1:fallback}", [" "])
expect(result).toBe("fallback")
})

test("substituteArguments - ${1:default} with missing arg", () => {
const { result } = substituteArguments("${2:fallback}", ["only-one"])
expect(result).toBe("fallback")
})

test("substituteArguments - ${1:multi word default}", () => {
const { result } = substituteArguments("${1:foo bar baz}", [])
expect(result).toBe("foo bar baz")
})

test("substituteArguments - mix of $1 and ${2:default}", () => {
const { result } = substituteArguments("$1 and ${2:fallback}", ["first"])
expect(result).toBe("first and fallback")
})

test("substituteArguments - ${2:default} last swallows remaining", () => {
const { result } = substituteArguments("${1:first} ${2:second}", ["a", "b", "c"])
expect(result).toBe("a b c")
})

test("substituteArguments - ${N:default} hasPlaceholders is true", () => {
const { hasPlaceholders } = substituteArguments("${1:default}", [])
expect(hasPlaceholders).toBe(true)
})

test("substituteArguments - ${3:$2} with arg3 missing uses arg2", () => {
const { result } = substituteArguments("${3:$2}", ["a", "b"])
expect(result).toBe("b")
})

test("substituteArguments - ${3:$2} with arg3 present uses arg3", () => {
const { result } = substituteArguments("${3:$2}", ["a", "b", "c"])
expect(result).toBe("c")
})

test("substituteArguments - ${2:$1} fallback chain", () => {
const { result } = substituteArguments("${2:$1}", ["only-first"])
expect(result).toBe("only-first")
})

test("substituteArguments - ${1..:default} with args", () => {
const { result } = substituteArguments("${1..:fallback}", ["a", "b", "c"])
expect(result).toBe("a b c")
})

test("substituteArguments - ${1..:default} without args uses default", () => {
const { result } = substituteArguments("${1..:fallback}", [])
expect(result).toBe("fallback")
})

test("substituteArguments - ${2..3:default} with args", () => {
const { result } = substituteArguments("${2..3:fallback}", ["a", "b", "c", "d"])
expect(result).toBe("b c")
})

test("substituteArguments - ${2..3:default} without args uses default", () => {
const { result } = substituteArguments("${2..3:fallback}", ["a"])
expect(result).toBe("fallback")
})

test("substituteArguments - ${..:default} captures all from start", () => {
const { result } = substituteArguments("${..:fallback}", ["a", "b"])
expect(result).toBe("a b")
})

test("substituteArguments - ${..:default} without args uses default", () => {
const { result } = substituteArguments("${..:fallback}", [])
expect(result).toBe("fallback")
})

test("substituteArguments - ${..3:default} with args", () => {
const { result } = substituteArguments("${..3:fallback}", ["a", "b", "c", "d"])
expect(result).toBe("a b c")
})

test("substituteArguments - ${..3:default} without args uses default", () => {
const { result } = substituteArguments("${..3:fallback}", [])
expect(result).toBe("fallback")
})

test("substituteArguments - ${1..:multi word default}", () => {
const { result } = substituteArguments("${1..:foo bar baz}", [])
expect(result).toBe("foo bar baz")
})

test("substituteArguments - ${N..:default} hasPlaceholders is true", () => {
const { hasPlaceholders } = substituteArguments("${1..:default}", [])
expect(hasPlaceholders).toBe(true)
})

test("substituteArguments - ${2:$1:fallback} uses arg2", () => {
const { result } = substituteArguments("${2:$1:fallback}", ["a", "b"])
expect(result).toBe("b")
})

test("substituteArguments - ${2:$1:fallback} falls back to arg1", () => {
const { result } = substituteArguments("${2:$1:fallback}", ["a", ""])
expect(result).toBe("a")
})

test("substituteArguments - ${2:$1:fallback} falls back to literal", () => {
const { result } = substituteArguments("${2:$1:fallback}", ["", ""])
expect(result).toBe("fallback")
})

test("substituteArguments - ${3:$2:$1:final} chained fallback", () => {
const { result } = substituteArguments("${3:$2:$1:final}", ["first"])
expect(result).toBe("first")
})

test("substituteArguments - ${3:$2:$1:final} uses final", () => {
const { result } = substituteArguments("${3:$2:$1:final}", [])
expect(result).toBe("final")
})

test("substituteArguments - ${1::fallback} invalid returns unchanged", () => {
const { result } = substituteArguments("${1::fallback}", [])
expect(result).toBe("${1::fallback}")
})

test("substituteArguments - ${1:$2:} invalid returns with empty between colons", () => {
const { result } = substituteArguments("${1:$2:}", ["", ""])
expect(result).toBe("${1::}")
})

test("substituteArguments - ${1..:$2:fallback} range with chained fallback", () => {
const { result } = substituteArguments("${1..:$2:fallback}", ["", "b"])
expect(result).toBe("b")
})

test("substituteArguments - ${1..:$2:fallback} range uses final fallback", () => {
const { result } = substituteArguments("${1..:$2:fallback}", [])
expect(result).toBe("fallback")
})