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.";