Files
windmill/backend/parsers/windmill-parser-ruby/src/lib.rs
T
Ruben FiszelandClaude Opus 4.6 0317d5891c feat: add powershell common parameters support (#8683)
* feat: add powershell common parameters support (-Verbose, -Debug, -ErrorAction, -WhatIf)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add powershell common params to script editor test panel

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: detect CmdletBinding from code instead of schema in script editor

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: ignore commented-out CmdletBinding in powershell detection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use preference variables for -Verbose/-Debug instead of CLI args

Verbose/Debug output goes to PowerShell stream 4/5 which isn't captured
by the 2>&1 redirect. Setting $VerbosePreference/$DebugPreference in the
wrapper scope propagates to child scripts and output flows through the
host to stderr, which Windmill captures as logs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use *>&1 to capture all powershell streams including verbose/debug

The previous 2>&1 only captured error stream. Verbose (stream 4) and
debug (stream 5) output was silently lost. Using *>&1 redirects all
streams to success stream so they flow through Tee-Object into logs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use targeted stream redirects (4>&1 5>&1 2>&1) instead of *>&1

*>&1 breaks $PSCmdlet.ShouldProcess() by redirecting internal streams.
Only redirect verbose (4), debug (5), and error (2) to success stream.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: revert to 2>&1 redirect — stream 4/5 redirects break powershell

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use 4>&1 5>&1 for verbose/debug capture, remove WhatIf support

Stream 4/5 redirects capture verbose/debug in the pipeline. WhatIf is
removed because $PSCmdlet.ShouldProcess() doesn't work when scripts
are invoked through Windmill's wrapper.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: redirect verbose/debug to files to keep result pipeline clean

Verbose (4) and debug (5) streams are redirected to separate log files
during script execution, then output via Write-Host after the script
completes. This keeps them out of the Tee-Object pipeline (used for
result extraction) while still showing them in the job logs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: output verbose/debug to stderr via Console.Error for log capture

Write-Host goes to stdout which gets mixed with result output and
truncated by OSS log threshold. Using [Console]::Error.WriteLine()
writes to stderr which Windmill captures separately as logs, with
VERBOSE:/DEBUG: prefixes for clarity.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: redirect script output to file only, send verbose/debug to stdout

The OSS log storage has a 9KB threshold. Previously, Tee-Object sent
the full JSON result to both stdout (logs) and the pipe file, eating
the log budget. Now script output goes only to the pipe file (> $pipe),
and only verbose/debug messages go to stdout for the log viewer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: preserve original Tee-Object behavior, append verbose/debug after

Keep the original wrapper behavior (Tee-Object to stdout + pipe file).
Only add 4>verbose.log 5>debug.log to capture those streams, and
output them at the end of logs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: inject preference vars into main.ps1 instead of CLI args

Passing -Verbose/-Debug as CLI args causes PowerShell module loading
to emit verbose noise. Instead, inject $VerbosePreference/$DebugPreference
inside main.ps1's try block so they only affect user code. Stream 4/5
are still redirected to files in the wrapper for log output.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: restore common param toggles from previous job args on Run Again

Extract _wm_ps_* keys from loaded args and initialize the toggle
states in PowerShellCommonParams. Also strip them from main args
so they don't appear as unknown schema form inputs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: show active common param badges when section is collapsed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: inject ErrorAction as preference variable instead of CLI arg

-ErrorAction as a CLI arg only affects the caller, not the script's
internal error handling. Setting $ErrorActionPreference inside main.ps1
correctly overrides the default 'Stop' behavior for the user's code.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: ensure full backward compatibility with existing powershell scripts

- Only filter common param names when [CmdletBinding()] is present
  (without it, $Verbose etc. are regular user-defined parameters)
- Only add 4>verbose.log 5>debug.log and log output lines when common
  params are actually enabled — original wrapper is unchanged otherwise

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: lighter styling for common params section

Replaced heavy Section component with a subtle inline chevron toggle
labeled "Common parameters". Smaller text, secondary color, indented
options. Badges still show when collapsed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: rename section to CmdletBinding parameters

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add ..Default::default() to windmill-parser-r (new parser from main)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: missing comma in graphql parser test + merge main

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add missing commas before ..Default::default() in parser tests

Merge from main brought test constructors with formatting issues
from the original automated script (missing comma between last field
and ..Default::default()).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: restore comment markers in nu parser test that script broke

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address PR review — ignore commented CmdletBinding, clear stale params

1. Parser: strip comment lines before detecting [CmdletBinding()] to
   avoid false positives from commented-out attributes
2. RunForm: always assign psCommonParams (not just when non-empty) so
   stale settings from a previous run don't leak into later runs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:03:22 +00:00

416 lines
15 KiB
Rust

#![cfg_attr(target_arch = "wasm32", feature(c_variadic))]
#[cfg(target_arch = "wasm32")]
pub mod wasm_libc;
use anyhow::anyhow;
use anyhow::bail;
use regex::Regex;
use serde_json::Value;
use tree_sitter::Node;
use tree_sitter::Range;
use windmill_parser::json_to_typ;
use windmill_parser::Arg;
use windmill_parser::MainArgSignature;
pub fn parse_ruby_sig_meta(code: &str) -> anyhow::Result<MainArgSignature> {
let mut parser = tree_sitter::Parser::new();
let language = tree_sitter_ruby::LANGUAGE;
parser
.set_language(&language.into())
.map_err(|e| anyhow!("Error setting Ruby as language: {e}"))?;
// Parse code
let tree = parser
.parse(code, None)
.ok_or(anyhow!("Failed to parse code"))?;
let root_node = tree.root_node();
root_node.clone().to_string();
// Traverse the AST to find the Main method signature
let args = find_main_signature(root_node, code)?;
let auto_kind = if args.is_none() {
Some("lib".to_string())
} else {
None
};
let main_sig = MainArgSignature {
star_args: false,
star_kwargs: false,
args: args.unwrap_or_default(),
has_preprocessor: None,
auto_kind,
..Default::default()
};
Ok(main_sig)
}
pub fn parse_ruby_signature(code: &str) -> anyhow::Result<MainArgSignature> {
Ok(parse_ruby_sig_meta(code)?)
}
pub fn parse_ruby_requirements(code: &str) -> anyhow::Result<String> {
let mut parser = tree_sitter::Parser::new();
let language = tree_sitter_ruby::LANGUAGE;
parser
.set_language(&language.into())
.map_err(|e| anyhow!("Error setting Ruby as language: {e}"))?;
// Parse code
let tree = parser
.parse(code, None)
.ok_or(anyhow!("Failed to parse code"))?;
let root_node = tree.root_node();
root_node.clone().to_string();
let mut cursor = root_node.walk();
'top_level: for x in root_node.children(&mut cursor) {
if x.kind() == "call" {
for (i, n) in x.children(&mut x.walk()).enumerate() {
if i == 0
&& n.kind() == "identifier"
&& !n
.utf8_text(code.as_bytes())
.map(|ident| ident == "gemfile")
.unwrap_or_default()
{
continue 'top_level;
} else if n.kind() == "do_block" {
let req = n.utf8_text(code.as_bytes())?.to_owned();
lazy_static::lazy_static! {
static ref WINDMILL_RE: Regex = Regex::new(r"(?m)^\s*require\s*'windmill/inline'").unwrap();
}
if WINDMILL_RE.find(&code).is_none() {
return Err(anyhow!(
"`require 'windmill/inline'` is not detected - please add `require 'windmill/inline'` in order to use inline gemfile.
Your Gemfile syntax will be compatible with bundler/inline."
)
.into());
}
return req
// gemfile do_block comes with 'do' and 'end'
// we want to omit these by taking slice
.get(2..(req.len() - 3))
.map(str::to_owned)
.ok_or(anyhow!("Invalid gemfile do block"));
}
}
}
}
Ok(String::new())
}
// Function to find the Main method's signature
fn find_main_signature<'a>(root_node: Node<'a>, code: &str) -> anyhow::Result<Option<Vec<Arg>>> {
let mut cursor = root_node.walk();
'top_level: for x in root_node.children(&mut cursor) {
if x.kind() == "method" {
let mut args = vec![];
for (i, m) in x.children(&mut x.walk()).skip(1).enumerate() {
if i == 0
&& m.kind() == "identifier"
&& !m
.utf8_text(code.as_bytes())
.map(|ident| ident == "main")
.unwrap_or_default()
{
continue 'top_level;
} else if m.kind() == "method_parameters" {
for a in m.children(&mut m.walk()) {
if a.kind() == "identifier" {
a.utf8_text(code.as_bytes()).inspect(|name| {
args.push(Arg { name: (*name).to_owned(), ..Default::default() })
})?;
}
if a.kind() == "optional_parameter" {
let mut walk = a.walk();
let mut it = a.children(&mut walk).into_iter();
match (it.next().and_then(|n| n.utf8_text(code.as_bytes()).ok()), {
// Skip `=`
it.next();
it.next().map(|n| n.range())
}) {
(Some(ident), Some(Range { start_byte, end_byte, .. })) => {
let unparsed =
&code[start_byte..end_byte].replace("nil", "null");
let default: Value = serde_json::from_str(unparsed).map_err(
|e|
anyhow!("Cannot convert default value to json\n\tvalue: {unparsed}\n\terror: {e}{}",
if e.to_string().contains("key must be a string") {
"\n\nNOTE: If you are trying to declare default hash, use following syntax:\n { \"<key>\": <value> }"
} else {
""
}
))?;
args.push(Arg {
name: ident.to_owned(),
typ: json_to_typ(&default, true),
default: Some(default),
has_default: true,
..Default::default()
});
}
_ => {
let Range { start_byte, end_byte, .. } = a.range();
bail!(
"Cannot parse optional parameter: {}",
&code.get(start_byte..end_byte).unwrap_or("CANNOT DISPLAY")
)
}
}
}
let kind = a.kind();
if matches!(
kind,
"keyword_parameter" | "splat_parameter" | "hash_splat_parameter"
) {
let Range { start_byte, end_byte, .. } = a.range();
bail!(
" - {}\n{}s are not supported",
&code.get(start_byte..end_byte).unwrap_or("CANNOT DISPLAY"),
kind.replace("_", " ")
);
}
}
}
}
return Ok(Some(args));
}
}
Ok(None)
}
#[cfg(test)]
mod test {
use serde_json::json;
use windmill_parser::{ObjectProperty, ObjectType, Typ};
use super::parse_ruby_sig_meta as parse;
use super::*;
#[test]
fn test_parse_ruby_no_main() {
let code = r#"
def not_main end
def private_fn end
"#;
let sig = parse(code).unwrap();
assert_eq!(
sig,
MainArgSignature { auto_kind: Some("lib".to_string()), ..Default::default() }
);
}
#[test]
fn test_parse_ruby_no_args() {
let code = r#"
def main
end
"#;
let sig = parse(code).unwrap();
assert_eq!(
sig,
MainArgSignature { auto_kind: None, ..Default::default() }
);
}
#[test]
fn test_parse_ruby_positional_args() {
let sig = {
let code = r#"def main(a, b, c) end"#;
parse(code).unwrap()
};
let sig2 = {
let code = r#"
def main a, b, c
end"#;
parse(code).unwrap()
};
assert_eq!(
sig,
MainArgSignature {
args: vec![
Arg { name: "a".into(), ..Default::default() },
Arg { name: "b".into(), ..Default::default() },
Arg { name: "c".into(), ..Default::default() }
],
auto_kind: None,
..Default::default()
}
);
assert_eq!(sig2, sig);
}
#[test]
fn test_parse_ruby_lists() {
let sig = {
let code = r#"def main(a = [ 1, 2, 3 ], b = [ 1, false, nil, "test"]) end"#;
parse(code).unwrap()
};
assert_eq!(
sig,
MainArgSignature {
args: vec![
Arg {
name: "a".into(),
has_default: true,
typ: Typ::List(Box::new(Typ::Int)),
default: Some(json!([1, 2, 3])),
..Default::default()
},
Arg {
name: "b".into(),
has_default: true,
typ: Typ::List(Box::new(Typ::Unknown)),
default: Some(json!([1, false, null, "test"])),
..Default::default()
},
],
auto_kind: None,
..Default::default()
}
);
}
#[test]
fn test_parse_ruby_hashes() {
let sig = {
let code = r#"def main(a = { "1": 4, "2": [ 1, 2, 3] }) end"#;
parse(code).unwrap()
};
assert_eq!(
sig,
MainArgSignature {
args: vec![Arg {
name: "a".into(),
has_default: true,
typ: Typ::Object(ObjectType::new(
None,
Some(vec![
ObjectProperty { key: "1".into(), typ: Box::new(Typ::Int) },
ObjectProperty {
key: "2".into(),
typ: Box::new(Typ::List(Box::new(Typ::Int)))
}
],)
)),
default: Some(json!({"1": 4, "2": [ 1, 2, 3 ]})),
..Default::default()
},],
auto_kind: None,
..Default::default()
}
);
}
#[test]
fn test_parse_ruby_default_args() {
let sig = {
let code =
r#"def main(a = 10, b = "hey", c = false, d = [ 1, 2, 3 ], e = { "a": 43 }) end"#;
parse(code).unwrap()
};
assert_eq!(
sig,
MainArgSignature {
args: vec![
Arg {
name: "a".into(),
has_default: true,
typ: Typ::Int,
default: Some(json!(10)),
..Default::default()
},
Arg {
name: "b".into(),
has_default: true,
typ: Typ::Str(None),
default: Some(json!("hey")),
..Default::default()
},
Arg {
name: "c".into(),
has_default: true,
typ: Typ::Bool,
default: Some(json!(false)),
..Default::default()
},
Arg {
name: "d".into(),
has_default: true,
typ: Typ::List(Box::new(Typ::Int)),
default: Some(json!([1, 2, 3])),
..Default::default()
},
Arg {
name: "e".into(),
has_default: true,
typ: Typ::Object(ObjectType::new(
None,
Some(vec![ObjectProperty {
key: "a".into(),
typ: Box::new(Typ::Int)
}])
)),
default: Some(json!({"a": 43})),
..Default::default()
},
],
auto_kind: None,
..Default::default()
}
);
}
#[test]
fn test_parse_ruby_unsupported() {
assert_eq!(
&parse("def main( a: ) end").unwrap_err().to_string(),
" - a:\nkeyword parameters are not supported"
);
assert_eq!(
&parse("def main( *a ) end").unwrap_err().to_string(),
" - *a\nsplat parameters are not supported"
);
assert_eq!(
&parse("def main( **a ) end").unwrap_err().to_string(),
" - **a\nhash splat parameters are not supported"
);
assert!(&parse("def main( a = Time.now ) end").is_err());
assert!(&parse("def main( a = :symbol ) end").is_err());
assert!(&parse("def main( a = { b: 2 } ) end").is_err());
assert!(&parse("def main( a = { b => 2 } ) end").is_err());
assert!(&parse(r#"def main( a = { "b" => 2 } ) end"#).is_err());
}
// #[test]
// fn test_parse_ruby_requirements() {
// assert_eq!(
// parse_ruby_requirements(
// "
// require 'dep1'
// require 'dep2'
// require 'dep2/submod'
// require 'u/username/script'
// require 'f/folder/script'
// "
// )
// .unwrap(),
// vec![
// "dep1".to_owned(),
// "dep2".into(),
// "dep2".into(),
// "u/username/script".into(),
// "f/folder/script".into(),
// ]
// );
// }
}