fix(backend): replace regex with one-pass algorithm for PowerShell param parsing

The previous regex-based approach used  which would
fail when encountering nested parentheses inside the param block, such as
 or .

This commit replaces the regex with a proper one-pass parser that:
- Tracks parenthesis depth to correctly handle nesting
- Respects string quotes (both single and double quotes)
- Handles PowerShell's backtick escape character
- Works correctly with complex default values and function calls

Changes:
- Removed RE_POWERSHELL_PARAM regex constant
- Added extract_powershell_param_block() function for extracting param contents
- Added extract_powershell_param_block_full() function for extracting full param block
- Updated parse_powershell_file() in windmill-parser-bash
- Updated pwsh_executor.rs to use the new extractor
- Added comprehensive test cases for nested parens and quoted strings

Fixes #7079

Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
claude[bot]
2025-11-07 10:20:05 +00:00
parent 5ef901152f
commit 95be9102ab
2 changed files with 222 additions and 5 deletions
+221 -3
View File
@@ -47,7 +47,6 @@ pub fn parse_powershell_sig(code: &str) -> anyhow::Result<MainArgSignature> {
lazy_static::lazy_static! {
static ref RE_BASH: Regex = Regex::new(r#"(?m)^(\w+)="\$(?:(\d+)|\{(\d+)\}|\{(\d+):-(.*)\})"(?:[\t ]*)?(?:#.*)?\r?$"#).unwrap();
pub static ref RE_POWERSHELL_PARAM: Regex = Regex::new(r#"(?m)param[\t ]*\(([^)]*)\)"#).unwrap();
static ref RE_POWERSHELL_ARGS: Regex = Regex::new(r#"(?:\[([\w\[\]]+)\])?\$(\w+)[\t ]*(?:=[\t ]*(?:(?:(?:"|')([^"\n\r\$]*)(?:"|'))|([\d.]+)))?\r?"#).unwrap();
}
@@ -85,6 +84,160 @@ fn parse_bash_file(code: &str) -> anyhow::Result<Option<Vec<Arg>>> {
Ok(Some(args))
}
/// Extract the full PowerShell param() block including the keyword and parentheses.
/// Returns the entire param block string, or None if not found.
/// This is useful for replacing or removing the param block from code.
pub fn extract_powershell_param_block_full(code: &str) -> Option<&str> {
// Find "param" keyword (case-insensitive)
let lower_code = code.to_lowercase();
let param_start = lower_code.find("param")?;
// Skip whitespace and tabs after "param"
let mut chars = code[param_start + 5..].char_indices();
let mut paren_offset = param_start + 5;
// Skip whitespace to find opening paren
while let Some((idx, ch)) = chars.next() {
if ch == '(' {
paren_offset += idx;
break;
} else if !ch.is_whitespace() && ch != '\t' {
// Found non-whitespace, non-paren character - not a valid param block
return None;
}
}
// Now parse from the opening parenthesis
let remaining = &code[paren_offset..];
let mut chars = remaining.char_indices();
// Skip the opening '('
if let Some((_, ch)) = chars.next() {
if ch != '(' {
return None;
}
} else {
return None;
}
let mut depth = 1;
let mut in_single_quote = false;
let mut in_double_quote = false;
let mut escape_next = false;
for (idx, ch) in chars {
if escape_next {
escape_next = false;
continue;
}
match ch {
'`' if in_single_quote || in_double_quote => {
// PowerShell escape character
escape_next = true;
}
'\'' if !in_double_quote => {
in_single_quote = !in_single_quote;
}
'"' if !in_single_quote => {
in_double_quote = !in_double_quote;
}
'(' if !in_single_quote && !in_double_quote => {
depth += 1;
}
')' if !in_single_quote && !in_double_quote => {
depth -= 1;
if depth == 0 {
// Found the matching closing parenthesis
// idx is the position of ')' relative to paren_offset
// We want to include the closing paren, so add 1
return Some(&code[param_start..paren_offset + idx + 1]);
}
}
_ => {}
}
}
None
}
/// Extract the contents of a PowerShell param() block, handling nested parentheses.
/// Returns the contents between the outermost parentheses of the param block, or None if not found.
pub fn extract_powershell_param_block(code: &str) -> Option<&str> {
// Find "param" keyword (case-insensitive)
let lower_code = code.to_lowercase();
let param_start = lower_code.find("param")?;
// Skip whitespace and tabs after "param"
let mut chars = code[param_start + 5..].char_indices();
let mut paren_offset = param_start + 5;
// Skip whitespace to find opening paren
while let Some((idx, ch)) = chars.next() {
if ch == '(' {
paren_offset += idx;
break;
} else if !ch.is_whitespace() && ch != '\t' {
// Found non-whitespace, non-paren character - not a valid param block
return None;
}
}
// Now parse from the opening parenthesis
let remaining = &code[paren_offset..];
let mut chars = remaining.char_indices();
// Skip the opening '('
if let Some((_, ch)) = chars.next() {
if ch != '(' {
return None;
}
} else {
return None;
}
let mut depth = 1;
let mut in_single_quote = false;
let mut in_double_quote = false;
let mut escape_next = false;
let content_start = paren_offset + 1; // Start after the opening '('
for (idx, ch) in chars {
if escape_next {
escape_next = false;
continue;
}
match ch {
'`' if in_single_quote || in_double_quote => {
// PowerShell escape character
escape_next = true;
}
'\'' if !in_double_quote => {
in_single_quote = !in_single_quote;
}
'"' if !in_single_quote => {
in_double_quote = !in_double_quote;
}
'(' if !in_single_quote && !in_double_quote => {
depth += 1;
}
')' if !in_single_quote && !in_double_quote => {
depth -= 1;
if depth == 0 {
// Found the matching closing parenthesis
// idx is the position of ')' relative to paren_offset
// We want content between parens, so exclude the closing paren
return Some(&code[content_start..paren_offset + idx]);
}
}
_ => {}
}
}
None
}
enum ParserState {
Normal,
InSingleQuote,
@@ -137,10 +290,9 @@ fn parse_powershell_single_typ(typ: &str) -> Typ {
}
fn parse_powershell_file(code: &str) -> anyhow::Result<Option<Vec<Arg>>> {
let param_wrapper = RE_POWERSHELL_PARAM.captures(code);
let param_wrapper = extract_powershell_param_block(code);
let mut args = vec![];
if let Some(param_wrapper) = param_wrapper {
let param_wrapper = param_wrapper.get(1).unwrap().as_str();
let params = split_pwsh_args(param_wrapper);
for param in params {
if let Some(cap) = RE_POWERSHELL_ARGS.captures(param) {
@@ -353,6 +505,72 @@ non_required="${5:-}"
Ok(())
}
#[test]
fn test_parse_powershell_sig_with_nested_parens() -> anyhow::Result<()> {
// Test with nested parentheses in default values
let code = r#"param($Msg, [string]$Func = (Get-Date), $Complex = (Get-Item("file.txt")), $Nested = ((1 + 2) * 3))"#;
let result = parse_powershell_sig(code)?;
// Should parse 4 parameters without errors
assert_eq!(result.args.len(), 4);
assert_eq!(result.args[0].name, "Msg");
assert_eq!(result.args[1].name, "Func");
assert_eq!(result.args[2].name, "Complex");
assert_eq!(result.args[3].name, "Nested");
Ok(())
}
#[test]
fn test_parse_powershell_sig_with_quoted_parens() -> anyhow::Result<()> {
// Test with parentheses inside quotes
let code = r#"param($Msg = "test (with parens)", $Msg2 = 'also (with parens)')"#;
let result = parse_powershell_sig(code)?;
// Should parse 2 parameters
assert_eq!(result.args.len(), 2);
assert_eq!(result.args[0].name, "Msg");
assert_eq!(result.args[0].default, Some(json!("test (with parens)")));
assert_eq!(result.args[1].name, "Msg2");
assert_eq!(result.args[1].default, Some(json!("also (with parens)")));
Ok(())
}
#[test]
fn test_extract_powershell_param_block() {
// Test basic param block
let code = "param($x, $y)";
assert_eq!(extract_powershell_param_block(code), Some("$x, $y"));
// Test with nested parentheses
let code = "param($x = Get-Func(1, 2), $y = (1 + 2))";
assert_eq!(extract_powershell_param_block(code), Some("$x = Get-Func(1, 2), $y = (1 + 2)"));
// Test with whitespace
let code = "param \t ($x, $y)";
assert_eq!(extract_powershell_param_block(code), Some("$x, $y"));
// Test with parentheses in quotes
let code = r#"param($x = "test (parens)")"#;
assert_eq!(extract_powershell_param_block(code), Some(r#"$x = "test (parens)""#));
}
#[test]
fn test_extract_powershell_param_block_full() {
// Test basic param block
let code = "param($x, $y)";
assert_eq!(extract_powershell_param_block_full(code), Some("param($x, $y)"));
// Test with nested parentheses
let code = "param($x = Get-Func(1, 2), $y)";
assert_eq!(extract_powershell_param_block_full(code), Some("param($x = Get-Func(1, 2), $y)"));
// Test with code after param block
let code = "param($x)\nWrite-Host $x";
assert_eq!(extract_powershell_param_block_full(code), Some("param($x)"));
}
#[test]
fn test_parse_bash_sig_with_crlf() -> anyhow::Result<()> {
// Test with CRLF line endings (Windows-style)
+1 -2
View File
@@ -426,9 +426,8 @@ $env:PSModulePath = \"{};$PSModulePathBackup\"",
}\n";
// make sure param() is first
let param_match = windmill_parser_bash::RE_POWERSHELL_PARAM.find(&content);
let param_match = windmill_parser_bash::extract_powershell_param_block_full(&content);
let content: String = if let Some(param_match) = param_match {
let param_match = param_match.as_str();
format!(
"{}\n{}\n{}\n{}\n{}",
param_match,