mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-24 08:01:38 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user