fix: render command templates in a single substitution pass

render substituted $ARGUMENTS first and then re-scanned the whole result for positional $1..$9. When a user's arguments themselves contained a literal $1 (say, asking what $1 means in a shell script), that text got re-expanded with the first argument word, quietly corrupting the prompt. The substitution is now one left-to-right walk over the template, so inserted argument text is never re-examined for further placeholders. An unsupplied positional like $2 is still left untouched, as before.

paydii committed Jul 11, 2026 at 15:10 UTC 358ac78dffae036bffa3993ef26a366f5fc7997a
1 file changed +51 -13
src/commands.rs
+51 -13
@@ -81,21 +81,41 @@ pub fn resolve_command(name: &str) -> Option<CustomCommand> {
81 /// silently dropped.
82 pub fn render(body: &str, arguments: Option<&str>) -> String {
83 let arguments = arguments.unwrap_or("");
84 - let mut result = body.to_string();
85 - let mut substituted = false;
86 -
87 - if result.contains("$ARGUMENTS") {
88 - result = result.replace("$ARGUMENTS", arguments);
89 - substituted = true;
90 - }
91 -
84 let positional: Vec<&str> = arguments.split_whitespace().collect();
93 - for (i, arg) in positional.iter().enumerate() {
94 - let placeholder = format!("${}", i + 1);
95 - if result.contains(&placeholder) {
96 - result = result.replace(&placeholder, arg);
97 - substituted = true;
85 +
86 + // Substitute in a single left-to-right pass over the template so text we
87 + // insert is never re-scanned for further placeholders. A second pass (as
88 + // this once did) would re-expand a literal `$1` that appeared inside the
89 + // user's own `$ARGUMENTS`, corrupting prompts that mention shell
90 + // positional variables.
91 + let mut result = String::with_capacity(body.len());
92 + let mut substituted = false;
93 + let bytes = body.as_bytes();
94 + let mut i = 0;
95 + while i < body.len() {
96 + if bytes[i] == b'$' {
97 + if body[i..].starts_with("$ARGUMENTS") {
98 + result.push_str(arguments);
99 + substituted = true;
100 + i += "$ARGUMENTS".len();
101 + continue;
102 + }
103 + // Positional `$1`..`$9`, but only when that argument was supplied;
104 + // otherwise leave the placeholder untouched (as before).
105 + if let Some(&d) = bytes.get(i + 1)
106 + && d.is_ascii_digit()
107 + && d != b'0'
108 + && let Some(arg) = positional.get((d - b'1') as usize)
109 + {
110 + result.push_str(arg);
111 + substituted = true;
112 + i += 2;
113 + continue;
114 + }
115 }
116 + let ch = body[i..].chars().next().unwrap();
117 + result.push(ch);
118 + i += ch.len_utf8();
119 }
120
121 if !substituted && !arguments.is_empty() {
@@ -300,6 +320,24 @@ mod tests {
320 );
321 }
322
323 + #[test]
324 + fn render_does_not_re_expand_positional_vars_inside_arguments() {
325 + // A literal `$1` typed by the user must survive: the single pass
326 + // substitutes $ARGUMENTS once and never re-scans the inserted text.
327 + let body = "Explain: $ARGUMENTS";
328 + assert_eq!(
329 + render(body, Some("what does $1 mean in bash")),
330 + "Explain: what does $1 mean in bash"
331 + );
332 + }
333 +
334 + #[test]
335 + fn render_leaves_unsupplied_positional_placeholder_literal() {
336 + let body = "Compare $1 against $2.";
337 + // Only one argument supplied: $2 stays untouched, like the old pass.
338 + assert_eq!(render(body, Some("main")), "Compare main against $2.");
339 + }
340 +
341 #[test]
342 fn render_appends_arguments_when_no_placeholder_present() {
343 let body = "Run the linter.";