Files
windmill/backend/parsers/windmill-parser-r/src/lib.rs
Ruben Fiszel 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

365 lines
12 KiB
Rust

#![cfg_attr(target_arch = "wasm32", feature(c_variadic))]
#[cfg(target_arch = "wasm32")]
pub mod wasm_libc;
use anyhow::anyhow;
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_r_sig_meta(code: &str) -> anyhow::Result<MainArgSignature> {
let mut parser = tree_sitter::Parser::new();
let language = tree_sitter_r::LANGUAGE;
parser
.set_language(&language.into())
.map_err(|e| anyhow!("Error setting R as language: {e}"))?;
let tree = parser
.parse(code, None)
.ok_or(anyhow!("Failed to parse code"))?;
let root_node = tree.root_node();
let args = find_main_signature(root_node, code)?;
let main_sig = MainArgSignature {
star_args: false,
star_kwargs: false,
args: args.unwrap_or_default(),
has_preprocessor: None,
auto_kind: None,
..Default::default()
};
Ok(main_sig)
}
pub fn parse_r_signature(code: &str) -> anyhow::Result<MainArgSignature> {
Ok(parse_r_sig_meta(code)?)
}
/// Extract package names from `library(...)` and `require(...)` calls in R code.
/// Returns a newline-separated list of package names.
pub fn parse_r_requirements(code: &str) -> anyhow::Result<String> {
let mut parser = tree_sitter::Parser::new();
let language = tree_sitter_r::LANGUAGE;
parser
.set_language(&language.into())
.map_err(|e| anyhow!("Error setting R as language: {e}"))?;
let tree = parser
.parse(code, None)
.ok_or(anyhow!("Failed to parse code"))?;
let root_node = tree.root_node();
let mut packages = vec![];
find_library_calls(root_node, code, &mut packages);
// Deduplicate and exclude base packages
packages.sort();
packages.dedup();
packages.retain(|p| !is_base_package(p));
Ok(packages.join("\n"))
}
fn find_library_calls(node: Node, code: &str, packages: &mut Vec<String>) {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() == "call" {
// call node: child 0 is the function name, child 1 is arguments
if let (Some(func_node), Some(args_node)) = (child.child(0), child.child(1)) {
let func_name = func_node.utf8_text(code.as_bytes()).unwrap_or("");
if func_name == "library" || func_name == "require" {
// AST: arguments → ( + argument → identifier/string + )
if args_node.kind() == "arguments" {
let mut args_cursor = args_node.walk();
for arg in args_node.children(&mut args_cursor) {
if arg.kind() == "argument" {
// The argument node wraps the actual value
if let Some(value_node) = arg.child(0) {
let pkg = value_node
.utf8_text(code.as_bytes())
.unwrap_or("")
.trim_matches('"')
.trim_matches('\'');
if !pkg.is_empty() {
packages.push(pkg.to_string());
}
}
break; // only first arg
}
}
}
}
}
}
// Recurse into children to find nested library() calls
find_library_calls(child, code, packages);
}
}
fn is_base_package(pkg: &str) -> bool {
matches!(
pkg,
"base"
| "compiler"
| "datasets"
| "grDevices"
| "graphics"
| "grid"
| "methods"
| "parallel"
| "splines"
| "stats"
| "stats4"
| "tcltk"
| "tools"
| "utils"
)
}
/// Find the main function signature in R code.
/// R function definitions look like: `main <- function(x, y = 10) { ... }`
/// In the tree-sitter-r AST, this is a `binary_operator` node with:
/// - child 0: identifier "main"
/// - child 1: "<-" or "="
/// - child 2: function_definition node
fn find_main_signature<'a>(root_node: Node<'a>, code: &str) -> anyhow::Result<Option<Vec<Arg>>> {
let mut cursor = root_node.walk();
for x in root_node.children(&mut cursor) {
if x.kind() == "binary_operator" {
let child_count = x.child_count();
if child_count < 3 {
continue;
}
// First child should be identifier "main"
let ident_node = x.child(0).unwrap();
if ident_node.kind() != "identifier" {
continue;
}
let ident = ident_node.utf8_text(code.as_bytes()).unwrap_or("");
if ident != "main" {
continue;
}
// Second child should be "<-" or "="
let op_node = x.child(1).unwrap();
let op = op_node.utf8_text(code.as_bytes()).unwrap_or("");
if op != "<-" && op != "=" {
continue;
}
// Third child should be the function_definition
let func_node = x.child(2).unwrap();
if func_node.kind() != "function_definition" {
continue;
}
return Ok(Some(parse_function_params(func_node, code)?));
}
}
Ok(None)
}
/// Parse parameters from a function_definition node.
/// function_definition has children: "function", parameters, body
/// Each parameter node has:
/// - 1 child (identifier) for positional args
/// - 3 children (identifier, "=", value) for default args
fn parse_function_params(func_node: Node, code: &str) -> anyhow::Result<Vec<Arg>> {
let mut args = vec![];
let mut func_cursor = func_node.walk();
for child in func_node.children(&mut func_cursor) {
if child.kind() == "parameters" {
let mut param_cursor = child.walk();
for param in child.children(&mut param_cursor) {
if param.kind() != "parameter" {
continue;
}
let param_child_count = param.child_count();
if param_child_count == 1 {
// Simple parameter: just identifier
let ident_node = param.child(0).unwrap();
let name = ident_node.utf8_text(code.as_bytes())?;
args.push(Arg { name: name.to_owned(), ..Default::default() });
} else if param_child_count >= 3 {
// Default parameter: identifier = value
let ident_node = param.child(0).unwrap();
let value_node = param.child(2).unwrap();
let name = ident_node.utf8_text(code.as_bytes())?;
let Range { start_byte, end_byte, .. } = value_node.range();
let raw = &code[start_byte..end_byte];
// Convert R literals to JSON
let unparsed = raw
.replace("NULL", "null")
.replace("TRUE", "true")
.replace("FALSE", "false");
match serde_json::from_str::<Value>(&unparsed) {
Ok(default) => {
args.push(Arg {
name: name.to_owned(),
typ: json_to_typ(&default, true),
default: Some(default),
has_default: true,
..Default::default()
});
}
Err(_) => {
args.push(Arg {
name: name.to_owned(),
has_default: true,
..Default::default()
});
}
}
}
}
}
}
Ok(args)
}
#[cfg(test)]
mod test {
use serde_json::json;
use windmill_parser::Typ;
use super::parse_r_sig_meta as parse;
#[test]
fn test_parse_r_no_main() {
let code = r#"
not_main <- function() {}
helper <- function(x) { x + 1 }
"#;
let sig = parse(code).unwrap();
assert_eq!(
sig,
windmill_parser::MainArgSignature { auto_kind: None, ..Default::default() }
);
}
#[test]
fn test_parse_r_no_args() {
let code = r#"
main <- function() {
return(42)
}
"#;
let sig = parse(code).unwrap();
assert_eq!(
sig,
windmill_parser::MainArgSignature { auto_kind: None, ..Default::default() }
);
}
#[test]
fn test_parse_r_positional_args() {
let code = r#"main <- function(a, b, c) { a + b + c }"#;
let sig = parse(code).unwrap();
assert_eq!(
sig,
windmill_parser::MainArgSignature {
args: vec![
windmill_parser::Arg { name: "a".into(), ..Default::default() },
windmill_parser::Arg { name: "b".into(), ..Default::default() },
windmill_parser::Arg { name: "c".into(), ..Default::default() },
],
auto_kind: None,
..Default::default()
}
);
}
#[test]
fn test_parse_r_default_args() {
let code = r#"main <- function(a = 10, b = "hey", c = FALSE) { }"#;
let sig = parse(code).unwrap();
assert_eq!(sig.args.len(), 3);
assert_eq!(sig.args[0].name, "a");
assert_eq!(sig.args[0].default, Some(json!(10)));
assert_eq!(sig.args[0].typ, Typ::Int);
assert_eq!(sig.args[1].name, "b");
assert_eq!(sig.args[1].default, Some(json!("hey")));
assert_eq!(sig.args[1].typ, Typ::Str(None));
assert_eq!(sig.args[2].name, "c");
assert_eq!(sig.args[2].default, Some(json!(false)));
assert_eq!(sig.args[2].typ, Typ::Bool);
}
#[test]
fn test_parse_r_equals_assignment() {
let code = r#"main = function(x, y = 5) { x + y }"#;
let sig = parse(code).unwrap();
assert_eq!(sig.args.len(), 2);
assert_eq!(sig.args[0].name, "x");
assert_eq!(sig.args[1].name, "y");
assert_eq!(sig.args[1].default, Some(json!(5)));
}
#[test]
fn test_parse_r_null_default() {
let code = r#"main <- function(x = NULL) { x }"#;
let sig = parse(code).unwrap();
assert_eq!(sig.args.len(), 1);
assert_eq!(sig.args[0].name, "x");
assert_eq!(sig.args[0].default, Some(json!(null)));
}
#[test]
fn test_parse_r_requirements() {
use super::parse_r_requirements;
let code = r#"
library(dplyr)
library(ggplot2)
require(tidyr)
library(stats)
main <- function(x) {
library(stringr)
x
}
"#;
let reqs = parse_r_requirements(code).unwrap();
let pkgs: Vec<&str> = reqs.lines().collect();
assert!(pkgs.contains(&"dplyr"));
assert!(pkgs.contains(&"ggplot2"));
assert!(pkgs.contains(&"tidyr"));
assert!(pkgs.contains(&"stringr"));
assert!(!pkgs.contains(&"stats")); // base package excluded
}
#[test]
fn test_parse_r_requirements_string_args() {
use super::parse_r_requirements;
let code = r#"
library("data.table")
require("jsonlite")
main <- function() { }
"#;
let reqs = parse_r_requirements(code).unwrap();
let pkgs: Vec<&str> = reqs.lines().collect();
assert!(pkgs.contains(&"data.table"));
assert!(pkgs.contains(&"jsonlite"));
}
#[test]
fn test_parse_r_requirements_no_deps() {
use super::parse_r_requirements;
let code = r#"main <- function(x) { x + 1 }"#;
let reqs = parse_r_requirements(code).unwrap();
assert!(reqs.is_empty());
}
}