Files
windmill/backend/parsers/windmill-parser-bash/src/lib.rs
T
Ruben FiszelandClaude Opus 4.7 aedf369174 fix: pair PG arg type with actual Rust binding to keep query_typed_raw safe (#8999)
* fix: pair PG arg type with actual Rust binding to keep query_typed_raw safe

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(pg): wrap encoder errors with arg context, add fallback test

Followups on #8999 review:

- Wrap rust-postgres "error serializing parameter N" failures with the arg
  name, JSON value kind, and asserted Postgres type plus a hint about an
  explicit cast — so users see actionable context instead of an opaque
  WrongType.
- Drift-prevention meta-test: assert otyp_to_pg_type and convert_val agree
  on the Type for every recognised arg_t when the JSON value matches its
  natural Rust kind. Catches future drift if either side changes.
- Integration test for the prepare + query_raw fallback path: confirms
  unrecognised arg_t (custom enum) is routed through prepare and the
  server-resolved type appears in the failure surface — flips into a
  test failure if a regression accidentally routes unrecognised types
  through query_typed_raw.

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

* fix(pg): add otyp_inferred flag + regex-based placeholder renumbering

Two follow-ups from the review of #8999:

1. **Issue #1 (Number/Bool + explicit text decl in WHERE)**

   Add `Arg::otyp_inferred: bool` to the parser. The PG SQL parser sets
   it `true` only at the "no info → fall back to text" site (bare `$N`,
   no inline cast, no `-- $N (TYPE)` decl). All other arg sources keep
   it `false`.

   In `convert_val` this flag distinguishes:
   - explicit text-like target (`-- $1 (text)` or `$1::text`) — coerce
     `Bool`/`Number` → `Box<String>` so `WHERE text_col = $1` works
     (`text = text` operator). Pre-#8988 behaviour, restored.
   - parser-default text (bare `$N`) — bind the value's natural Rust
     type so the regression case (`Value::Bool` against a real `bool`
     column via `CAST AS bool`) keeps working.

   `Arg` is in `windmill-parser`; the new field has `#[serde(default)]`
   so persisted signatures stay backward-compatible.

2. **Issue #4 ($5/$50 substring rewrite collision)**

   Replace the per-index `String::replace` chain (which turned `$50`
   into `$10` when oidx=5 was processed first) with a single regex
   pass. `\d+` is greedy, so `$5` and `$50` match as distinct units;
   indices outside the mapping are left intact.

3. Tests:
   - parser: `test_parse_pgsql_otyp_inferred_flag` covers bare/inline-
     cast/decl/mixed shapes.
   - executor unit: `convert_val_bool_against_every_arg_t` and
     `convert_val_*_number_*` split each text-like target into explicit
     vs inferred expectations.
   - executor unit: `renumber_sparse_placeholders_no_collision`.
   - integration: `test_postgresql_arg_type_combinations` adds 4 cases
     covering decl(text)+Number/Bool in WHERE, bare $1+Bool, and
     sparse positional args ($5/$50).

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

* fix(pg+sdk): enum support, extended String arms, position-aware $N rewrite, SDK quality

Backend:

1. **`AnyTextValue` ToSql/FromSql wrapper**: vanilla `tokio_postgres`'s
   `ToSql for String` / `FromSql for String` reject `Kind::Enum` and
   `Kind::Domain` even though the wire format is plain UTF-8. The wrapper
   accepts those kinds in both directions. End result: explicit
   `$1::my_enum` / `CAST($1 AS my_enum)` casts now round-trip without the
   ugly `CAST($1::text AS my_enum)` workaround, AND `SELECT enum_col`
   results come back as JSON strings instead of erroring at the FromSql
   layer.

2. **#10 — Value::String → numeric/real/double/oid/bool**. Without these
   arms, a string-encoded value (`"3.14"`, `"true"`) for a non-text /
   non-temporal arg_t fell through to `Box<String> + TEXT`, which then
   failed at the server (no implicit cast text→numeric in expression
   context). Now strings are parsed into the matching native type with
   clear error messages on parse failure.

3. **Position-aware `$N` rewrite**: replaces the regex-based renumbering
   (which fixed the `$5/$50` substring collision but still walked through
   string literals and comments, mangling `'price: $5'` etc.) with a
   walk over `parse_pg_statement_arg_positions` — the same
   string/comment/dollar-quote-aware tokenizer used for index discovery.
   Adds `parse_pg_statement_arg_positions` to the parser's public API.

SDK:

4. **BigInt support**: `JSON.stringify(BigInt)` throws. The SDK now
   stringifies bigints before serialisation; the executor accepts
   numeric strings into BIGINT arg slots via the existing
   `Value::String → INT8` parsing arm. SDK-side `inferSqlType` is split
   so `BigInt` always resolves to `BIGINT` (was reaching
   `Number.isInteger(BigInt)` which returns false → wrong default).

5. **Homogeneous array auto-tag**: `${[1,2,3]}` against an `int[]` column
   now emits `$1::BIGINT[]` instead of `$1::JSON`. Detection covers
   primitive types only (number / bigint / string / boolean); mixed or
   nested arrays still fall back to JSON. Mixed int/float widens to
   `DOUBLE PRECISION[]`.

6. **`.query()` positional bug**: previously the `.query()` method
   abused the template-tag builder, which appended `$N::TYPE` after the
   user's literal SQL string instead of binding by position
   (`SELECT $1, $2` became `SELECT $1, $2$1::BIGINT`). Now `.query()`
   builds the executor-shaped content directly: a `-- $N argN (TYPE)`
   declaration block followed by the user's SQL verbatim.

Tests:

- Parser: `test_parse_pg_statement_arg_positions_skips_strings_and_comments`
  asserts string literals, comments, and dollar-quoted blocks don't
  produce positions (so renumbering doesn't mangle them).
- Executor unit: `renumber_sparse_placeholders_no_collision_no_string_mangling`
  uses the new position-aware path and includes string-literal + comment
  + `$$…$$` cases. Existing convert_val tests grow to cover new
  String→numeric/real/double/oid/bool arms.
- Integration: `test_postgresql_arg_type_combinations` adds 13 cases
  (enum round-trip both directions, string→numeric/real/double/bool/oid,
  string-literal `$N` non-mangling). The prepare-fallback test now
  asserts SUCCESS (not failure) for enum encoding via AnyTextValue.
- SDK: new `typescript-client/tests/sqlUtils.test.ts` (42 tests)
  exhaustively covering inferSqlType primitives + arrays,
  parseTypeAnnotation, datatable() template tag (with all the new
  shapes — BigInt, homogeneous arrays, RawSql, schema preamble),
  datatable().query() positional, and ducklake() shape.

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

* fix(pg): replace DISCARD ALL with curated reset (preserves typeinfo cache)

Found while exhaustively probing custom-type DX: every cached-connection
reuse was running `DISCARD ALL`, whose included `DEALLOCATE ALL`
deallocates *all* prepared statements server-side — including the typeinfo
statements that tokio_postgres caches per-Client to resolve custom enum /
domain Oids. tokio_postgres still held `Statement` objects whose names
the server had forgotten, so the next custom-type query failed with
intermittent "prepared statement \"sN\" does not exist" errors. The
failure was easy to reproduce: any sequence that forced typeinfo lookup
for two different custom-type kinds on the same cached connection (e.g.
enum followed by domain) would hit it.

Replace `DISCARD ALL` with a curated reset that explicitly targets the
state we actually care about, *without* touching prepared statements:

  RESET ALL                     — GUC parameters (search_path, application
                                  _name, statement_timeout, …)
  RESET SESSION AUTHORIZATION   — undoes both `SET SESSION AUTHORIZATION`
                                  and `SET ROLE` (RESET ALL does NOT —
                                  these aren't GUC parameters, so without
                                  this an elevated role from a previous
                                  job would silently leak)
  UNLISTEN *                    — drops LISTEN registrations
  CLOSE ALL                     — closes open cursors

Trade-off: temp tables, advisory locks (session-scoped), and user-created
PREPARE statements may persist across cached-connection reuse — rare in
datatable / PG-script workloads. tokio_postgres's typeinfo cache survives
intact, so custom enum / domain queries are fast on subsequent reuse.

Tests:
- `test_postgresql_custom_types_on_cached_connection` — runs 10×
  alternating enum + domain queries on a cached connection. Pre-fix this
  failed with `prepared statement "sN" does not exist` after the first
  reuse; post-fix passes.
- `test_postgresql_set_role_does_not_leak_across_cached_connection` —
  switches `SET ROLE` and `SET SESSION AUTHORIZATION` to a non-postgres
  role, then runs a follow-up job and asserts current_user/session_user
  are restored. Specifically catches the case where someone might switch
  back to `RESET ALL` alone (which doesn't cover SET ROLE / SESSION
  AUTHORIZATION) and silently introduce a permission-leak vector.
- All existing session-isolation tests
  (`test_postgresql_cached_connection_resets_session`,
   `test_postgresql_single_worker_session_isolation`,
   `test_postgresql_100_jobs_cached`) continue to pass.

Found via end-to-end probing of datatable / PG-script DX, not previously
covered: the existing isolation tests only did `SET ROLE postgres`, the
connecting user, so the leak was invisible.

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

* fix(pg): address PR #8999 review (cubic + claude)

cubic (P1, real bug):
- `convert_vec_val` for `timetz` array asserted `Type::TIMETZ_ARRAY`, but
  chrono `NaiveTime` only encodes for TIME (same caveat as the scalar
  arm). Switch to `Type::TIME_ARRAY`; rely on PG's implicit `time→timetz`
  assignment cast at the column site. Add an explicit unit test.

claude (#1, silent failure → explicit error):
- `Bool` + explicit `(char)` / `(character)` decl previously silently
  bound BOOL, hoping the server would cast at the use site — but PG has
  no implicit `bool→char` and the resulting error
  ("operator does not exist: bool = char") was opaque. Now error at
  bind time with an actionable hint to use `bool` decl or pass the
  value as a "t"/"f" string.

claude (#2, asymmetry doc):
- Object/Array still coerce to text on `matches!(typ, Typ::Str(_))`
  (covers both explicit AND inferred-default text), unlike Bool/Number
  which key on `explicit_text_target`. The asymmetry is intentional
  (no implicit `jsonb → text` cast in expression context vs PG having
  implicit `bool/int → text` casts) — added a body comment so future
  maintainers don't try to "align" them.

claude (#3, perf):
- `parse_pg_statement_arg_indices` and `parse_pg_statement_arg_positions`
  walked the SQL tokenizer twice. Fold into a single pass that derives
  the index set from the position list.

claude (#4, fmt drift):
- `cargo fmt` over the parser crates I touched with perl scripts in the
  earlier commit (windmill-parser-{sql,bash,ts,go,php,java,csharp,nu,py,
  rust,graphql,yaml,r}). Net cosmetic.

claude (#5, parseTypeAnnotation):
- One-line caveat in the SDK's `parseTypeAnnotation` that the returned
  string is presence-only (e.g. `${x}::DOUBLE PRECISION` returns
  `"DOUBLE"`, `CAST(${x} AS int)` returns `"int)"` — neither matches a
  real PG type, but the only consumer just checks `!== undefined`).

While here — discovered + fixed independently while exhaustively probing
DX:

- **Replace `DISCARD ALL` with curated reset** (`RESET ALL; RESET
  SESSION AUTHORIZATION; UNLISTEN *; CLOSE ALL;`). DISCARD's
  `DEALLOCATE ALL` killed tokio_postgres' typeinfo cache, producing
  intermittent `prepared statement "sN" does not exist` errors on
  custom-type queries after cached-conn reuse. New regression tests:
  `test_postgresql_custom_types_on_cached_connection` and
  `test_postgresql_set_role_does_not_leak_across_cached_connection`
  (the latter catches the case where someone might switch back to
  `RESET ALL` alone and silently introduce a permission-leak vector —
  RESET ALL doesn't cover SET ROLE / SET SESSION AUTHORIZATION).

- **ISO-8601 timestamp results** (`pg_cell_to_json_value`). Pre-fix
  `TIMESTAMP` was rendered with a space separator ("2024-01-15 10:30:00")
  and `TIMESTAMPTZ` with " UTC" suffix ("2024-01-15 10:30:00 UTC") —
  neither parseable by `date-fns parseISO`, JavaScript `new Date()` is
  lenient enough to handle them but several frontend `App*Input.svelte`
  components use parseISO and fail silently. Switched to ISO-8601 with
  `T` separator and `+00:00` offset; arg-parsing path still accepts the
  legacy " UTC" suffix for back-compat.

Test coverage:
- 17/17 unit (`pg_executor::tests`)
- 9/9 integration (`backend/tests/worker.rs`, `test_postgresql_*`)
- 27/27 parser (`windmill-parser-sql`)
- 42/42 SDK (`typescript-client/tests/sqlUtils.test.ts`)

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

* fix(pg): bounded one-shot warning on numeric precision loss + ISO-8601 + NaN handling

Found while probing PG-script DX with millions of numeric cells:

1. **Numeric precision-loss warning**: `numeric` results are still serialised
   as JSON Number (back-compat — switching to JSON String would silently
   break user code doing arithmetic on results), but we now detect
   `Decimal -> f64 -> Decimal` round-trip failure and emit a single
   job-log warning recommending a `::text` cast in the SQL. Bounded by
   `NUMERIC_PRECISION_CHECK_BUDGET = 256` cells per query (one atomic
   load + one fetch_sub on the hot path; first lossy value
   short-circuits to a single load thereafter). Worst-case overhead on
   a 1M-cell numeric-heavy query: ~25µs of checks + 5ns × N atomic
   loads (vs. ~100ms unbounded).

2. **ISO-8601 timestamps**: `pg_cell_to_json_value` previously returned
   `"2024-01-15 10:30:00"` (TIMESTAMP) and `"2024-01-15 10:30:00 UTC"`
   (TIMESTAMPTZ) — neither parseable by date-fns `parseISO`, which is
   what the apps `App*Input.svelte` components use, so timestamp values
   silently failed to round-trip into date pickers. Switch to ISO-8601
   (`T` separator + `+00:00` offset) on the result side; arg-parser
   continues to accept the legacy `" UTC"`-suffixed format for
   back-compat.

3. **Float NaN / Infinity results**: `Number::from_f64` returns None for
   NaN / ±Inf, which `pg_cell_to_json_value` was raising as
   "invalid json-float" — failing the *entire* query if any cell held
   one of these special values. Now serialise them as JSON strings
   ("NaN", "Infinity", "-Infinity") and let the rest of the row come
   through. Arg-side: `s.parse::<f64>()` already accepts the same
   strings.

Tests:
- `decimal_fits_f64_losslessly_predicate` — covers fits / doesn't-fit
  cases for the precision-loss predicate.
- `precision_check_budget_caps_per_query_overhead` — locks in the
  budget cap and the loss-flag short-circuit.
- All 9 PG integration tests + 17 unit tests pass.

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

* fix(pg): add pg_advisory_unlock_all to reset; warn on missing args; honor decl defaults

While probing PG-script DX further found three more frictions:

1. **Advisory lock leak** (cubic P2): switching from `DISCARD ALL` to
   `RESET ALL; RESET SESSION AUTHORIZATION; UNLISTEN *; CLOSE ALL;`
   meant session-scoped advisory locks (`pg_advisory_lock`) leaked
   across cached-connection reuse. Add `SELECT pg_advisory_unlock_all()`
   to the chain — `DISCARD ALL` covered this implicitly via
   `DISCARD PLANS / DEALLOCATE / pg_advisory_unlock_all` and we lost it
   in the switch.

2. **Missing-arg silent NULL**: an arg declared in the SQL (e.g.
   `-- $1 amount (numeric)`) but not provided in the args object was
   bound as NULL with no error / warning. Misspelling the key in the
   args object silently produced a row of NULLs — a notorious DX
   debugging trap. Now: collect the names of declared-but-missing
   args during dispatch and emit a single one-shot warning to the job
   logs at end-of-query naming each one. Bound NULL is preserved for
   back-compat.

3. **Declaration defaults ignored**: `-- $1 a (int) = 5` carries
   `arg.default = Some(Number(5))`, but the dispatch fell straight to
   NULL when the arg was missing. Now: respect the default —
   user-supplied value > declaration default > NULL. Also fixes the
   warning logic above (only warn for args that *don't* have a default).

Tests: existing 19 unit + 9 integration pass.

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

* fix(pg): multi-word PG types with [] suffix lost the array-ness; array arms accept stringified values

Two more frictions found while probing SDK end-to-end against a real
datatable resource:

1. **Multi-word array types lose the [] suffix in the parser**.
   `transform_types_with_spaces` recognises aliases for "double
   precision", "character varying", "timestamp with time zone", etc.
   but its return type was `&'a str` — only the bare alias, never with
   a trailing `[]`. The `RE_CODE_PGSQL` regex's `\w+` captures stop at
   the first space, so the regex's own `(?:\[\])?` array-suffix branch
   sees only `"double"` (not `"double precision[]"`); the `[]` was
   silently lost. Result: `$1::double precision[]` (which the SDK now
   emits for homogeneous float arrays via the new auto-tag) routed
   through `Value::Array → Type::JSONB` and the server failed with
   "cannot cast type jsonb to double precision[]".

   Fix: switch `transform_types_with_spaces` to return `Cow<'a, str>`
   and re-check the trailing bytes after a multi-word match. If they
   start with `[]`, return `format!("{alias}[]")` — Owned. Single-word
   types and the no-match path keep returning Borrowed slices, so no
   allocation in the hot path.

2. **Array arms in `convert_vec_val` rejected stringified values for
   numeric / int* / bool / oid / real / double**. The scalar `convert_val`
   already parses strings into the matching native type for these arg_ts,
   but the array variant only accepted JSON-native counterparts. Sending
   `["1.5", "2.5", "3.5"]` against `$1::numeric[]` (e.g. via `unnest` for
   bulk loading, or `JSON.stringify(BigInt[])` round-trip) failed with
   "Mixed types in array". Now the array arms mirror the scalar ones —
   `as_<native>().or_else(|| as_str().and_then(parse))` — so both shapes
   round-trip cleanly.

Tests: 19 unit + 9 integration pass; existing parser tests cover the
multi-word array forms (the regex-cap behaviour didn't break for
single-word types, and Cow plumbing is transparent to all callers).

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

* fix(parsers): add otyp_inferred field to Arg literals in tests + 3 missed src files

CI failures: the perl-driven sweep that added `otyp_inferred: false` to
every `Arg { ... }` literal when I introduced the field in the parser
schema covered `src/lib.rs` files but missed:

  - parsers/windmill-parser-bash/src/lib.rs       (mass-edited but a
    later format pass un-applied a few sites)
  - parsers/windmill-parser-go/src/lib.rs         (same)
  - parsers/windmill-parser-graphql/src/lib.rs    (same)
  - parsers/windmill-parser-nu/tests/tests.rs     (test file — not
    swept the first time)
  - parsers/windmill-parser-ts/tests/tests.rs     (test file — same)

Also tightened the regex to handle `oidx: None` without the trailing
comma (some test files had the field as the last initialiser line).

`cargo build --features <CI feature combo> --workspace --all-targets`
is clean.

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

* fix(sdk): Date → TIMESTAMPTZ; NaN / ±Infinity → string

Two more frictions found while running the actual SDK end-to-end against
a live datatable resource:

1. **JS `Date`** fell into the typeof "object" branch and was tagged
   `::JSON`. It worked accidentally for `${date}::timestamptz` via PG's
   `json → text → timestamptz` implicit cast chain, but `${date}` against
   a `timestamptz` column without a user-supplied cast bound the value
   as a JSON string and the comparison `timestamptz = json` failed. Now:
   `inferSqlType` recognises `Date` and tags `::TIMESTAMPTZ`;
   `serializeArgValue` emits `Date.toISOString()` so the executor's
   `Value::String → TIMESTAMPTZ` arm parses it cleanly.

2. **JS `NaN` / `±Infinity`** silently became NULL. `JSON.stringify(NaN)`
   returns `"null"` per the JS spec, so the value reached the executor as
   JSON null — the SDK's `::DOUBLE PRECISION` tag then bound a NULL
   double. Fix: detect non-finite numbers in `serializeArgValue` and
   stringify them as `"NaN" / "Infinity" / "-Infinity"`. The executor's
   `Value::String → FLOAT8` arm (`f64::from_str`) accepts these literals
   directly, and the result-side already renders the values as JSON
   strings (matching round-trip).

SDK unit tests grow from 42 → 44 passing.

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

* test(pg): integration coverage for multi-word arrays + stringified array elements

Locks in the two array fixes from the previous commit
(`fix(pg): multi-word PG types with [] suffix lost the array-ness`)
with end-to-end cases in `test_postgresql_arg_type_combinations`:

- `double precision[]`, `character varying[]`, `timestamp without time
  zone[]` — verifies the parser keeps the `[]` suffix after multi-word
  alias resolution.
- `numeric[]` / `int[]` / `bool[]` from stringified primitives — verifies
  the array arms of `convert_vec_val` apply the same string-coercion
  the scalar arms do.

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

* style: fix indentation drift on otyp_inferred lines

cargo fmt cleanup of leftover indentation where the perl-driven sweep
that introduced the otyp_inferred field landed at the wrong column.
No behaviour change.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-01 17:08:59 +00:00

1605 lines
57 KiB
Rust

#![allow(non_snake_case)] // TODO: switch to parse_* function naming
use anyhow::anyhow;
#[cfg(not(target_arch = "wasm32"))]
use regex::Regex;
#[cfg(target_arch = "wasm32")]
use regex_lite::Regex;
use serde_json::json;
use std::{collections::HashMap, str::FromStr};
use windmill_parser::{Arg, MainArgSignature, ObjectType, Typ};
pub fn parse_bash_sig(code: &str) -> anyhow::Result<MainArgSignature> {
let parsed = parse_bash_file(&code)?;
if let Some(x) = parsed {
let args = x;
Ok(MainArgSignature {
star_args: false,
star_kwargs: false,
args,
auto_kind: None,
has_preprocessor: None,
..Default::default()
})
} else {
Err(anyhow!("Error parsing bash script".to_string()))
}
}
/// PowerShell common parameter names that are automatically added by [CmdletBinding()].
/// These should be filtered from the parsed signature since they are not user-defined.
const POWERSHELL_COMMON_PARAMS: &[&str] = &[
"verbose",
"debug",
"erroraction",
"errorvariable",
"informationaction",
"informationvariable",
"outvariable",
"outbuffer",
"pipelinevariable",
"warningaction",
"warningvariable",
"whatif",
"confirm",
"progressaction",
];
/// Detects whether the script uses [CmdletBinding()] and whether it declares SupportsShouldProcess.
fn detect_cmdlet_binding(code: &str) -> (bool, bool) {
let attr_region = match extract_powershell_param_block_with_attributes(code, true) {
Some((region, _)) => region,
None => return (false, false),
};
// Strip comment lines to avoid false positives from commented-out [CmdletBinding()]
let uncommented: String = attr_region
.lines()
.filter(|line| !line.trim_start().starts_with('#'))
.collect::<Vec<_>>()
.join("\n");
let lower = uncommented.to_lowercase();
let has_cmd_binding = lower.contains("[cmdletbinding");
let supports_should_process = has_cmd_binding
&& lower.contains("supportsshouldprocess")
&& !lower.contains("supportsshouldprocess=$false")
&& !lower.contains("supportsshouldprocess = $false");
(has_cmd_binding, supports_should_process)
}
pub fn parse_powershell_sig(code: &str) -> anyhow::Result<MainArgSignature> {
let parsed = parse_powershell_file(&code)?;
if let Some(args) = parsed {
let (has_cmd_binding, supports_should_process) = detect_cmdlet_binding(code);
// Filter out common parameters only when CmdletBinding is present
// (without CmdletBinding, $Verbose etc. are regular user-defined parameters)
let args = if has_cmd_binding {
args.into_iter()
.filter(|arg| !POWERSHELL_COMMON_PARAMS.contains(&arg.name.to_lowercase().as_str()))
.collect()
} else {
args
};
Ok(MainArgSignature {
star_args: false,
star_kwargs: false,
args,
auto_kind: None,
has_preprocessor: None,
has_cmd_binding: if has_cmd_binding { Some(true) } else { None },
supports_should_process: if supports_should_process {
Some(true)
} else {
None
},
..Default::default()
})
} else {
Err(anyhow!("Error parsing powershell script".to_string()))
}
}
lazy_static::lazy_static! {
static ref RE_BASH: Regex = Regex::new(r#"(?m)^(\w+)="\$(?:(\d+)|\{(\d+)\}|\{(\d+):-(.*)\})"(?:[\t ]*)?(?:#.*)?\r?$"#).unwrap();
}
fn parse_bash_file(code: &str) -> anyhow::Result<Option<Vec<Arg>>> {
let mut hm: HashMap<i32, (String, Option<String>)> = HashMap::new();
for cap in RE_BASH.captures_iter(code) {
hm.insert(
cap.get(2)
.or(cap.get(3))
.or(cap.get(4))
.and_then(|x| x.as_str().parse::<i32>().ok())
.ok_or_else(|| anyhow!("Impossible to parse arg digit"))?,
(
cap[1].to_string(),
cap.get(5).map(|x| x.as_str().to_string()),
),
);
}
let mut args = vec![];
for i in 1..20 {
if hm.contains_key(&i) {
let (name, default) = hm.get(&i).unwrap();
args.push(Arg {
name: name.clone(),
typ: Typ::Str(None),
default: default.clone().map(|x| json!(x)),
otyp: None,
has_default: default.is_some(),
oidx: None,
otyp_inferred: false,
});
} else {
break;
}
}
Ok(Some(args))
}
/// Extract a PowerShell param() block with its preceding attributes, handling nested parentheses.
///
/// # Arguments
/// * `code` - The PowerShell code to extract from
/// * `include_attributes` - If true, includes attributes like [CmdletBinding()] before param
///
/// # Returns
/// A tuple of (param_block_with_attributes, remaining_code) or None if not found.
/// - `param_block_with_attributes`: The param block including attributes/comments if include_attributes is true
/// - `remaining_code`: The rest of the code after the param block
///
/// This function uses the existing extract_powershell_param_block validation, which already
/// ensures that only comments, whitespace, and attributes appear before param. So we can
/// simply return everything from the beginning to the end of the param block.
pub fn extract_powershell_param_block_with_attributes(
code: &str,
include_attributes: bool,
) -> Option<(&str, &str)> {
// First, use the existing function to validate and find the param block
let param_block = extract_powershell_param_block(code, true)?;
// Find where the param block ends in the original code
let param_end_pos = code.find(param_block)? + param_block.len();
// If include_attributes is true, we start from the beginning (position 0)
// since we know everything before param is valid (comments/whitespace/attributes)
// Otherwise, start from where param begins
if include_attributes {
Some((&code[..param_end_pos], &code[param_end_pos..]))
} else {
Some((param_block, &code[param_end_pos..]))
}
}
/// Extract a PowerShell param() block, handling nested parentheses.
///
/// # Arguments
/// * `code` - The PowerShell code to extract from
/// * `include_keyword` - If true, returns the full block including "param(...)"
/// If false, returns only the contents between the parentheses
///
/// # Returns
/// The extracted param block or contents, or None if not found.
pub fn extract_powershell_param_block(code: &str, include_keyword: bool) -> Option<&str> {
// Scan through the code looking for "param" while validating everything before it
let mut chars = code.chars().enumerate().peekable();
let mut in_block_comment = false;
let mut in_attribute_bracket = false;
let mut bracket_depth = 0;
let mut in_single_quote = false;
let mut in_double_quote = false;
let mut param_start = None;
while let Some((idx, ch)) = chars.next() {
if in_block_comment {
// Check for end of block comment: #>
if ch == '#' && chars.peek().map(|(_, c)| c) == Some(&'>') {
chars.next(); // consume '>'
in_block_comment = false;
}
} else if in_attribute_bracket {
// Handle quotes inside attributes to avoid counting brackets/parens inside strings
if in_single_quote {
if ch == '\'' {
in_single_quote = false;
}
} else if in_double_quote {
if ch == '"' {
in_double_quote = false;
}
} else {
// Track bracket depth to handle nested brackets/parens in attributes
match ch {
'\'' => in_single_quote = true,
'"' => in_double_quote = true,
'[' => bracket_depth += 1,
']' => {
bracket_depth -= 1;
if bracket_depth == 0 {
in_attribute_bracket = false;
}
}
_ => {}
}
}
} else {
match ch {
// Start of block comment: <#
'<' if chars.peek().map(|(_, c)| c) == Some(&'#') => {
chars.next(); // consume '#'
in_block_comment = true;
}
// Single-line comment: consume until end of line
'#' => {
for (_, next_ch) in chars.by_ref() {
if next_ch == '\n' || next_ch == '\r' {
break;
}
}
}
// Start of attribute bracket (e.g., [CmdletBinding()])
'[' => {
in_attribute_bracket = true;
bracket_depth = 1;
}
// Check if we've found "param" (case-insensitive)
'p' | 'P' => {
// Check if this is the start of "param" keyword
let remaining = &code[idx..];
if remaining.len() >= 5 {
let next_four = &remaining[1..5];
if next_four.eq_ignore_ascii_case("aram") {
// Check word boundaries
let before_ok = idx == 0 || {
let before = code.as_bytes()[idx - 1];
!before.is_ascii_alphanumeric() && before != b'_'
};
let after_ok = idx + 5 >= code.len() || {
let after = code.as_bytes()[idx + 5];
!after.is_ascii_alphanumeric() && after != b'_'
};
if before_ok && after_ok {
param_start = Some(idx);
break;
}
}
}
}
// Whitespace is allowed
c if c.is_whitespace() => {}
// Any other character means there's code before param
_ => return None,
}
}
}
// If we're still in a block comment or unclosed attribute bracket, it's invalid
if in_block_comment || in_attribute_bracket {
return None;
}
let param_start = param_start?;
// 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_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
if include_keyword {
// Return full block including "param" keyword and closing paren
return Some(&code[param_start..paren_offset + idx + 1]);
} else {
// Return only contents between parentheses
return Some(&code[content_start..paren_offset + idx]);
}
}
}
_ => {}
}
}
None
}
fn parse_powershell_single_typ(typ: &str) -> Typ {
match typ.to_lowercase().as_str() {
"string" => Typ::Str(None),
"int" | "long" => Typ::Int,
"decimal" | "double" | "single" => Typ::Float,
"datetime" => Typ::Datetime,
"bool" | "switch" => Typ::Bool,
"pscustomobject" => Typ::Object(ObjectType::new(None, None)),
_ => Typ::Str(None),
}
}
/// Parse ValidateSet attribute to extract enum values
/// Example: ValidateSet('Red', 'Green', 'Blue') -> Some(vec!["Red", "Green", "Blue"])
fn parse_validate_set(bracket_content: &str) -> Option<Vec<String>> {
// Find the opening parenthesis
let start = bracket_content.find('(')?;
let end = bracket_content.rfind(')')?;
if start >= end {
return None;
}
let values_str = &bracket_content[start + 1..end];
let mut values = Vec::new();
let mut current_value = String::new();
let mut in_single_quote = false;
let mut in_double_quote = false;
let mut escape_next = false;
for ch in values_str.chars() {
if escape_next {
current_value.push(ch);
escape_next = false;
continue;
}
match ch {
'`' if in_double_quote => {
escape_next = true;
}
'\'' if !in_double_quote => {
if in_single_quote {
// End of single-quoted string
values.push(current_value.clone());
current_value.clear();
in_single_quote = false;
} else {
// Start of single-quoted string
in_single_quote = true;
}
}
'"' if !in_single_quote => {
if in_double_quote {
// End of double-quoted string
values.push(current_value.clone());
current_value.clear();
in_double_quote = false;
} else {
// Start of double-quoted string
in_double_quote = true;
}
}
',' if !in_single_quote && !in_double_quote => {
// Skip commas outside quotes
continue;
}
c if in_single_quote || in_double_quote => {
current_value.push(c);
}
c if !c.is_whitespace() => {
// Handle unquoted values (though PowerShell typically requires quotes)
current_value.push(c);
}
_ => {}
}
}
// Handle any remaining unquoted value
if !current_value.is_empty() {
values.push(current_value.trim().to_string());
}
if values.is_empty() {
None
} else {
Some(values)
}
}
/// Single-pass PowerShell parameter parser.
/// Parses the content of a param() block and extracts all parameter information.
///
/// This function processes PowerShell parameter declarations in a single pass, handling:
/// - Parameter attributes: [Parameter(Mandatory)], [Parameter(Mandatory=$true)], [ValidateSet(...)], etc.
/// - Type annotations: [string], [int[]], [PSCustomObject], etc.
/// - Variable names: $Name, $Value, etc.
/// - Default values: = 'text', = 25, = $env:VAR, etc.
/// - Mandatory detection: Parameters with Mandatory attribute are marked as required
fn parse_powershell_parameters(content: &str) -> anyhow::Result<Vec<Arg>> {
#[derive(Debug, PartialEq)]
enum State {
Normal,
InSingleQuote,
InDoubleQuote,
InBracket,
}
let mut args = Vec::new();
let mut chars = content.char_indices().peekable();
let mut state = State::Normal;
let mut bracket_depth: i32 = 0;
let mut paren_depth: i32 = 0;
// Current parameter being built
let mut type_annotation: Option<String> = None;
let mut var_name: Option<String> = None;
let mut default_value: Option<String> = None;
let mut is_mandatory = false;
let mut validate_set: Option<Vec<String>> = None;
// Track position for extracting text
let mut last_bracket_start = None;
let mut found_dollar = false;
while let Some((idx, ch)) = chars.next() {
match state {
State::InSingleQuote => {
if ch == '\'' {
state = State::Normal;
}
}
State::InDoubleQuote => {
if ch == '"' {
// Check for escape character
if idx > 0 && content.chars().nth(idx - 1) != Some('`') {
state = State::Normal;
}
}
}
State::InBracket => {
match ch {
'[' => bracket_depth += 1,
']' => {
bracket_depth -= 1;
if bracket_depth == 0 {
// Extract the bracket content
if let Some(start) = last_bracket_start {
let bracket_content = &content[start + 1..idx];
// Check if this is a Parameter attribute with Mandatory (case-insensitive)
let lower = bracket_content.to_lowercase();
if lower.starts_with("parameter(")
|| lower.starts_with("parameter ")
{
// Check for Mandatory (case-insensitive)
if lower.contains("mandatory") {
// Check if it's explicitly set to false
if !lower.contains("mandatory=$false")
&& !lower.contains("mandatory = $false")
{
is_mandatory = true;
}
}
}
// Check if this is a ValidateSet attribute
if lower.starts_with("validateset(") {
// Extract values from ValidateSet('val1', 'val2', ...)
if let Some(values) = parse_validate_set(bracket_content) {
validate_set = Some(values);
}
}
// Check if this looks like a type (simple word, possibly with [])
let is_type = !bracket_content.contains('(')
&& !bracket_content.contains('=')
&& (bracket_content
.chars()
.next()
.unwrap_or(' ')
.is_alphabetic()
|| bracket_content.starts_with('['));
if is_type && !found_dollar {
type_annotation = Some(bracket_content.to_string());
}
}
state = State::Normal;
last_bracket_start = None;
}
}
'(' => paren_depth += 1,
')' => paren_depth = paren_depth.saturating_sub(1),
_ => {}
}
}
State::Normal => {
match ch {
'\'' => state = State::InSingleQuote,
'"' => state = State::InDoubleQuote,
'[' => {
state = State::InBracket;
bracket_depth = 1;
last_bracket_start = Some(idx);
}
'$' => {
found_dollar = true;
// Extract variable name
let name_start = idx + 1;
let mut name_end = name_start;
while let Some(&(_, next_ch)) = chars.peek() {
if next_ch.is_alphanumeric() || next_ch == '_' {
name_end += 1;
chars.next();
} else {
break;
}
}
var_name = Some(content[name_start..name_end].to_string());
}
'=' if found_dollar => {
// Extract default value
// Skip whitespace after =
while let Some(&(_, next_ch)) = chars.peek() {
if next_ch.is_whitespace() {
chars.next();
} else {
break;
}
}
let default_start = chars.peek().map(|(i, _)| *i).unwrap_or(content.len());
let mut default_end = default_start;
let mut in_string = false;
let mut string_char = ' ';
while let Some((i, ch)) = chars.peek().copied() {
if in_string {
if ch == string_char
&& content.chars().nth(i.saturating_sub(1)) != Some('`')
{
in_string = false;
default_end = i + 1;
chars.next();
} else {
default_end = i + 1;
chars.next();
}
} else if ch == '\'' || ch == '"' {
in_string = true;
string_char = ch;
default_end = i + 1;
chars.next();
} else if ch == ',' {
break;
} else if ch.is_whitespace()
&& chars.clone().skip(1).next().map(|(_, c)| c) == Some(',')
{
break;
} else {
default_end = i + 1;
chars.next();
}
}
default_value =
Some(content[default_start..default_end].trim().to_string());
}
',' => {
// End of parameter, finalize it
if let Some(name) = var_name.take() {
args.push(finalize_parameter(
name,
type_annotation.take(),
default_value.take(),
is_mandatory,
validate_set.take(),
)?);
}
// Reset for next parameter
type_annotation = None;
var_name = None;
default_value = None;
is_mandatory = false;
validate_set = None;
found_dollar = false;
}
_ => {}
}
}
}
}
// Finalize last parameter
if let Some(name) = var_name {
args.push(finalize_parameter(
name,
type_annotation,
default_value,
is_mandatory,
validate_set,
)?);
}
Ok(args)
}
fn finalize_parameter(
name: String,
type_annotation: Option<String>,
default_value: Option<String>,
is_mandatory: bool,
validate_set: Option<Vec<String>>,
) -> anyhow::Result<Arg> {
// Store the original PowerShell type for use in the executor
let otyp = type_annotation.clone();
// If ValidateSet is present, use it to create an enum type
let mut parsed_typ = if let Some(ref enum_values) = validate_set {
Some(Typ::Str(Some(enum_values.clone())))
} else if let Some(typ) = type_annotation {
if typ.ends_with("[]") {
Some(Typ::List(Box::new(parse_powershell_single_typ(
typ.strip_suffix("[]").unwrap(),
))))
} else {
Some(parse_powershell_single_typ(&typ))
}
} else {
None
};
let default = if let Some(default_str) = default_value {
// Try to parse as string (quoted)
if (default_str.starts_with('"') && default_str.ends_with('"'))
|| (default_str.starts_with('\'') && default_str.ends_with('\''))
{
Some(json!(default_str[1..default_str.len() - 1].to_string()))
} else {
// Try to parse as number
if parsed_typ.is_none() {
if default_str.parse::<i64>().is_ok() {
parsed_typ = Some(Typ::Int);
} else if default_str.parse::<f64>().is_ok() {
parsed_typ = Some(Typ::Float);
}
}
serde_json::Number::from_str(&default_str)
.ok()
.map(serde_json::Value::Number)
}
} else {
None
};
// has_default semantics:
// - true: parameter is optional (has a default value OR is not mandatory)
// - false: parameter is required (marked as Mandatory AND no default value)
// Simplified: A parameter is optional unless it's mandatory without a default
let has_default = default.is_some() || !is_mandatory;
Ok(Arg {
name,
typ: parsed_typ.unwrap_or(Typ::Str(None)),
default: default.clone(),
otyp,
has_default,
oidx: None,
otyp_inferred: false,
})
}
fn parse_powershell_file(code: &str) -> anyhow::Result<Option<Vec<Arg>>> {
let param_wrapper = extract_powershell_param_block(code, false);
if let Some(param_wrapper) = param_wrapper {
Ok(Some(parse_powershell_parameters(param_wrapper)?))
} else {
Ok(Some(vec![]))
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
#[test]
fn test_parse_bash_sig() -> anyhow::Result<()> {
let code = r#"
token="$1"
image="$2"
digest="${3:-latest with spaces}"
text="$4" # with comment
non_required="${5:-}"
"#;
//println!("{}", serde_json::to_string()?);
assert_eq!(
parse_bash_sig(code)?,
MainArgSignature {
star_args: false,
star_kwargs: false,
args: vec![
Arg {
otyp: None,
name: "token".to_string(),
typ: Typ::Str(None),
default: None,
has_default: false,
oidx: None,
otyp_inferred: false,
},
Arg {
otyp: None,
name: "image".to_string(),
typ: Typ::Str(None),
default: None,
has_default: false,
oidx: None,
otyp_inferred: false,
},
Arg {
otyp: None,
name: "digest".to_string(),
typ: Typ::Str(None),
default: Some(json!("latest with spaces")),
has_default: true,
oidx: None,
otyp_inferred: false,
},
Arg {
otyp: None,
name: "text".to_string(),
typ: Typ::Str(None),
default: None,
has_default: false,
oidx: None,
otyp_inferred: false,
},
Arg {
otyp: None,
name: "non_required".to_string(),
typ: Typ::Str(None),
default: Some(json!("")),
has_default: true,
oidx: None,
otyp_inferred: false,
}
],
auto_kind: None,
has_preprocessor: None,
..Default::default()
}
);
Ok(())
}
#[test]
fn test_parse_powershell_sig() -> anyhow::Result<()> {
let code = r#"param($Msg, [string]$Msg2, $Dflt = "default value, with comma", [int]$Nb = 3 , $Nb2 = 5.0, $Nb3 = 5, $Wahoo = $env:WAHOO, [PSCustomObject]$Obj, [string[]]$Arr, [Parameter(Mandatory)][ValidateSet('Green', 'Blue', 'Red')][string]$Message)"#;
assert_eq!(
parse_powershell_sig(code)?,
MainArgSignature {
star_args: false,
star_kwargs: false,
args: vec![
Arg {
otyp: None, // No type annotation
name: "Msg".to_string(),
typ: Typ::Str(None),
default: None,
has_default: true, // Optional (not mandatory)
oidx: None,
otyp_inferred: false,
},
Arg {
otyp: Some("string".to_string()), // [string]
name: "Msg2".to_string(),
typ: Typ::Str(None),
default: None,
has_default: true, // Optional (not mandatory)
oidx: None,
otyp_inferred: false,
},
Arg {
otyp: None, // No type annotation
name: "Dflt".to_string(),
typ: Typ::Str(None),
default: Some(json!("default value, with comma")),
has_default: true,
oidx: None,
otyp_inferred: false,
},
Arg {
otyp: Some("int".to_string()), // [int]
name: "Nb".to_string(),
typ: Typ::Int,
default: Some(json!(3)),
has_default: true,
oidx: None,
otyp_inferred: false,
},
Arg {
otyp: None, // Type inferred from default value
name: "Nb2".to_string(),
typ: Typ::Float,
default: Some(json!(5.0)),
has_default: true,
oidx: None,
otyp_inferred: false,
},
Arg {
otyp: None, // Type inferred from default value
name: "Nb3".to_string(),
typ: Typ::Int,
default: Some(json!(5)),
has_default: true,
oidx: None,
otyp_inferred: false,
},
Arg {
otyp: None, // No type annotation
name: "Wahoo".to_string(),
typ: Typ::Str(None),
default: None,
has_default: true, // Optional (not mandatory)
oidx: None,
otyp_inferred: false,
},
Arg {
otyp: Some("PSCustomObject".to_string()), // [PSCustomObject]
name: "Obj".to_string(),
typ: Typ::Object(ObjectType::new(None, None)),
default: None,
has_default: true, // Optional (not mandatory)
oidx: None,
otyp_inferred: false,
},
Arg {
otyp: Some("string[]".to_string()), // [string[]]
name: "Arr".to_string(),
typ: Typ::List(Box::new(Typ::Str(None))),
default: None,
has_default: true, // Optional (not mandatory)
oidx: None,
otyp_inferred: false,
},
Arg {
otyp: Some("string".to_string()), // [string] (last type bracket with Mandatory and ValidateSet)
name: "Message".to_string(),
typ: Typ::Str(Some(vec![
"Green".to_string(),
"Blue".to_string(),
"Red".to_string()
])), // ValidateSet enum
default: None,
has_default: false, // Required (Mandatory attribute)
oidx: None,
otyp_inferred: false,
}
],
auto_kind: None,
has_preprocessor: None,
..Default::default()
}
);
Ok(())
}
#[test]
fn test_extract_powershell_param_block() {
// Basic cases
assert_eq!(
extract_powershell_param_block("param($Name, $Age)", true),
Some("param($Name, $Age)")
);
assert_eq!(
extract_powershell_param_block("param($Name, $Age)", false),
Some("$Name, $Age")
);
// Case insensitive and whitespace
assert_eq!(
extract_powershell_param_block("PARAM ($Value)", false),
Some("$Value")
);
// Nested parentheses
assert_eq!(
extract_powershell_param_block("param([ValidateScript({$_ -gt 0})]$Count)", false),
Some("[ValidateScript({$_ -gt 0})]$Count")
);
// Strings with parentheses
assert_eq!(
extract_powershell_param_block("param([string]$Path = 'C:\\file(1).txt')", false),
Some("[string]$Path = 'C:\\file(1).txt'")
);
assert_eq!(
extract_powershell_param_block(r#"param($Msg = "Hello (world)")"#, false),
Some(r#"$Msg = "Hello (world)""#)
);
assert_eq!(
extract_powershell_param_block(
r#"param([Parameter(Mandatory)][ValidateSet('Green', 'Blue', 'Red')][string]$Message)"#,
false
),
Some(r#"[Parameter(Mandatory)][ValidateSet('Green', 'Blue', 'Red')][string]$Message"#)
);
// Escaped quotes
assert_eq!(
extract_powershell_param_block("param($Text = 'don''t')", false),
Some("$Text = 'don''t'")
);
assert_eq!(
extract_powershell_param_block(r#"param($Text = "He said `"Hi`"")"#, false),
Some(r#"$Text = "He said `"Hi`"""#)
);
// Multiline
let multiline = "param(\n [string]$Name,\n [int]$Age\n)";
assert!(extract_powershell_param_block(multiline, false).is_some());
// Invalid cases
assert_eq!(extract_powershell_param_block("$x = 5", false), None);
assert_eq!(extract_powershell_param_block("param", false), None);
assert_eq!(extract_powershell_param_block("param($x", false), None);
// Valid: param at beginning with single-line comments before
assert_eq!(
extract_powershell_param_block("# This is a comment\nparam($Name)", false),
Some("$Name")
);
assert_eq!(
extract_powershell_param_block("# Comment 1\n# Comment 2\n\nparam($Name)", false),
Some("$Name")
);
// Valid: param at beginning with block comment before
assert_eq!(
extract_powershell_param_block("<# Block comment #>\nparam($Name)", false),
Some("$Name")
);
assert_eq!(
extract_powershell_param_block(
"<#\n Multi-line\n block comment\n#>\nparam($Name)",
false
),
Some("$Name")
);
// Valid: mixed comments and whitespace
assert_eq!(
extract_powershell_param_block(
"# Line comment\n<# Block comment #>\n\nparam($Name)",
false
),
Some("$Name")
);
// Invalid: code before param
assert_eq!(
extract_powershell_param_block("$x = 5\nparam($Name)", false),
None
);
assert_eq!(
extract_powershell_param_block("Write-Host 'test'\nparam($Name)", false),
None
);
// Invalid: unclosed block comment
assert_eq!(
extract_powershell_param_block("<# Unclosed comment\nparam($Name)", false),
None
);
// Invalid: unclosed block comment
assert_eq!(
extract_powershell_param_block("function test-x{ param($Name)\n}", false),
None
);
// Valid: [CmdletBinding()] before param
assert_eq!(
extract_powershell_param_block("[CmdletBinding()]\nparam($Name)", false),
Some("$Name")
);
assert_eq!(
extract_powershell_param_block("[CmdletBinding()]\nparam($Name, $Age)", true),
Some("param($Name, $Age)")
);
// Valid: [CmdletBinding()] with options before param
assert_eq!(
extract_powershell_param_block(
"[CmdletBinding(SupportsShouldProcess=$true)]\nparam($Path)",
false
),
Some("$Path")
);
// Valid: Multiple attributes before param
assert_eq!(
extract_powershell_param_block(
"[CmdletBinding()]\n[OutputType([string])]\nparam($Value)",
false
),
Some("$Value")
);
// Valid: CmdletBinding with comments
assert_eq!(
extract_powershell_param_block("# My function\n[CmdletBinding()]\nparam($Name)", false),
Some("$Name")
);
// Valid: CmdletBinding with whitespace variations
assert_eq!(
extract_powershell_param_block("[CmdletBinding()] \n param($Name)", false),
Some("$Name")
);
// Invalid: Unclosed attribute bracket
assert_eq!(
extract_powershell_param_block("[CmdletBinding(\nparam($Name)", false),
None
);
// Valid: CmdletBinding with DefaultParameterSetName
assert_eq!(
extract_powershell_param_block(
"[CmdletBinding(DefaultParameterSetName='ByName')]\nparam($Name, $Id)",
false
),
Some("$Name, $Id")
);
// Valid: CmdletBinding with complex parameters
assert_eq!(
extract_powershell_param_block(
"[CmdletBinding(DefaultParameterSetName='ByName', SupportsShouldProcess=$true)]\nparam($Path)",
false
),
Some("$Path")
);
// Valid: Multiple attributes with parameters
assert_eq!(
extract_powershell_param_block(
"[CmdletBinding(DefaultParameterSetName='Set1')]\n[OutputType([string])]\nparam($Value)",
false
),
Some("$Value")
);
}
#[test]
fn test_parse_powershell_sig_with_parameter_attributes() -> anyhow::Result<()> {
// Test with [Parameter(Mandatory=$true)] attribute
let code = r#"[CmdletBinding()]
param(
[Parameter(Mandatory=$true)]
[string]$Name,
[Parameter(Mandatory=$false)]
[int]$Age = 25
)"#;
let result = parse_powershell_sig(code)?;
assert_eq!(result.args.len(), 2);
assert_eq!(result.args[0].name, "Name");
assert_eq!(result.args[0].typ, Typ::Str(None));
assert_eq!(result.args[0].has_default, false);
assert_eq!(result.args[1].name, "Age");
assert_eq!(result.args[1].typ, Typ::Int);
assert_eq!(result.args[1].has_default, true);
assert_eq!(result.args[1].default, Some(json!(25)));
// Test with complex attributes including ValidateSet
let code2 = r#"param(
[Parameter(Mandatory=$true, Position=0)]
[ValidateSet('Red', 'Green', 'Blue')]
[string]$Color,
[Parameter(ValueFromPipeline=$true)]
[string[]]$Items
)"#;
let result2 = parse_powershell_sig(code2)?;
assert_eq!(result2.args.len(), 2);
assert_eq!(result2.args[0].name, "Color");
assert_eq!(
result2.args[0].typ,
Typ::Str(Some(vec![
"Red".to_string(),
"Green".to_string(),
"Blue".to_string()
]))
);
assert_eq!(result2.args[1].name, "Items");
assert_eq!(result2.args[1].typ, Typ::List(Box::new(Typ::Str(None))));
Ok(())
}
#[test]
fn test_powershell_single_pass_parser() -> anyhow::Result<()> {
// Test the single-pass parser with a complex real-world example
let code = r#"[CmdletBinding()]
param(
[Parameter(Mandatory=$true, Position=0, HelpMessage="Enter the server name")]
[ValidateNotNullOrEmpty()]
[string]$ServerName,
[Parameter(Mandatory=$false)]
[ValidateRange(1, 65535)]
[int]$Port = 8080,
[Parameter(ValueFromPipeline=$true)]
[string[]]$LogFiles,
[ValidateSet('Debug', 'Info', 'Warning', 'Error')]
[string]$LogLevel = 'Info',
[PSCustomObject]$Config
)"#;
let result = parse_powershell_sig(code)?;
assert_eq!(result.args.len(), 5);
// ServerName: mandatory string with no default
assert_eq!(result.args[0].name, "ServerName");
assert_eq!(result.args[0].typ, Typ::Str(None));
assert_eq!(result.args[0].has_default, false);
// Port: optional int with default
assert_eq!(result.args[1].name, "Port");
assert_eq!(result.args[1].typ, Typ::Int);
assert_eq!(result.args[1].default, Some(json!(8080)));
assert_eq!(result.args[1].has_default, true);
// LogFiles: string array (no mandatory, so optional)
assert_eq!(result.args[2].name, "LogFiles");
assert_eq!(result.args[2].typ, Typ::List(Box::new(Typ::Str(None))));
assert_eq!(result.args[2].has_default, true); // Optional (not mandatory)
// LogLevel: string with default and ValidateSet (creates enum type)
assert_eq!(result.args[3].name, "LogLevel");
assert_eq!(
result.args[3].typ,
Typ::Str(Some(vec![
"Debug".to_string(),
"Info".to_string(),
"Warning".to_string(),
"Error".to_string()
]))
);
assert_eq!(result.args[3].default, Some(json!("Info")));
assert_eq!(result.args[3].has_default, true);
// Config: PSCustomObject (no mandatory, so optional)
assert_eq!(result.args[4].name, "Config");
assert_eq!(result.args[4].typ, Typ::Object(ObjectType::new(None, None)));
assert_eq!(result.args[4].has_default, true); // Optional (not mandatory)
Ok(())
}
#[test]
fn test_powershell_mandatory_attribute() -> anyhow::Result<()> {
// Test various forms of the Mandatory attribute
let code = r#"param(
[Parameter(Mandatory)]
[string]$RequiredNoEquals,
[Parameter(Mandatory=$true)]
[string]$RequiredWithTrue,
[Parameter(Mandatory = $true)]
[string]$RequiredWithSpaces,
[Parameter(Mandatory=$false)]
[string]$NotRequired,
[Parameter(Position=0)]
[string]$NoMandatory,
[string]$PlainRequired = "default",
[Parameter(Mandatory=$true)]
[int]$RequiredInt
)"#;
let result = parse_powershell_sig(code)?;
assert_eq!(result.args.len(), 7);
// RequiredNoEquals: mandatory without =$true
assert_eq!(result.args[0].name, "RequiredNoEquals");
assert_eq!(result.args[0].has_default, false); // Required (mandatory, no default)
// RequiredWithTrue: mandatory with =$true
assert_eq!(result.args[1].name, "RequiredWithTrue");
assert_eq!(result.args[1].has_default, false); // Required
// RequiredWithSpaces: mandatory with spaces
assert_eq!(result.args[2].name, "RequiredWithSpaces");
assert_eq!(result.args[2].has_default, false); // Required
// NotRequired: explicitly Mandatory=$false
assert_eq!(result.args[3].name, "NotRequired");
assert_eq!(result.args[3].has_default, true); // Optional (not mandatory)
// NoMandatory: no Mandatory attribute
assert_eq!(result.args[4].name, "NoMandatory");
assert_eq!(result.args[4].has_default, true); // Optional (not mandatory)
// PlainRequired: has default value (always optional)
assert_eq!(result.args[5].name, "PlainRequired");
assert_eq!(result.args[5].has_default, true); // Optional (has default)
assert_eq!(result.args[5].default, Some(json!("default")));
// RequiredInt: mandatory int
assert_eq!(result.args[6].name, "RequiredInt");
assert_eq!(result.args[6].typ, Typ::Int);
assert_eq!(result.args[6].has_default, false); // Required
Ok(())
}
#[test]
fn test_extract_powershell_param_block_with_attributes() {
// Test without attributes
let code = "param($Name, $Age)";
let result = extract_powershell_param_block_with_attributes(code, true);
assert_eq!(result, Some(("param($Name, $Age)", "")));
// Test with simple CmdletBinding
let code2 = "[CmdletBinding()]\nparam($Name)";
let result2 = extract_powershell_param_block_with_attributes(code2, true);
assert_eq!(result2, Some(("[CmdletBinding()]\nparam($Name)", "")));
// Test with CmdletBinding with parameters
let code3 = "[CmdletBinding(DefaultParameterSetName='ByName')]\nparam($Name, $Id)";
let result3 = extract_powershell_param_block_with_attributes(code3, true);
assert_eq!(
result3,
Some((
"[CmdletBinding(DefaultParameterSetName='ByName')]\nparam($Name, $Id)",
""
))
);
// Test with multiple attributes
let code4 = "[CmdletBinding()]\n[OutputType([string])]\nparam($Value)";
let result4 = extract_powershell_param_block_with_attributes(code4, true);
assert_eq!(
result4,
Some((
"[CmdletBinding()]\n[OutputType([string])]\nparam($Value)",
""
))
);
// Test with comment before attributes
let code5 = "# My function\n[CmdletBinding()]\nparam($Name)";
let result5 = extract_powershell_param_block_with_attributes(code5, true);
assert_eq!(
result5,
Some(("# My function\n[CmdletBinding()]\nparam($Name)", ""))
);
// Test with include_attributes = false (should only get param block, not attributes)
let code6 = "[CmdletBinding()]\nparam($Name)";
let result6 = extract_powershell_param_block_with_attributes(code6, false);
assert_eq!(result6, Some(("param($Name)", "")));
// Test with code after param
let code7 = "[CmdletBinding()]\nparam($Name)\nWrite-Host 'Hello'";
let result7 = extract_powershell_param_block_with_attributes(code7, true);
assert_eq!(
result7,
Some(("[CmdletBinding()]\nparam($Name)", "\nWrite-Host 'Hello'"))
);
// Test with code after param (without attributes)
let code8 = "[CmdletBinding()]\nparam($Name)\nWrite-Host 'Hello'";
let result8 = extract_powershell_param_block_with_attributes(code8, false);
assert_eq!(result8, Some(("param($Name)", "\nWrite-Host 'Hello'")));
}
#[test]
fn test_powershell_sig_with_cmdletbinding_paramsetname() -> anyhow::Result<()> {
// Test with [CmdletBinding(DefaultParameterSetName='ByName')]
let code = r#"[CmdletBinding(DefaultParameterSetName='ByName')]
param(
[Parameter(Mandatory=$true, ParameterSetName='ByName')]
[string]$Name,
[Parameter(Mandatory=$true, ParameterSetName='ById')]
[int]$Id,
[string]$Description = "default description"
)"#;
let result = parse_powershell_sig(code)?;
assert_eq!(result.args.len(), 3);
// Name: mandatory string
assert_eq!(result.args[0].name, "Name");
assert_eq!(result.args[0].typ, Typ::Str(None));
assert_eq!(result.args[0].has_default, false);
// Id: mandatory int
assert_eq!(result.args[1].name, "Id");
assert_eq!(result.args[1].typ, Typ::Int);
assert_eq!(result.args[1].has_default, false);
// Description: optional with default
assert_eq!(result.args[2].name, "Description");
assert_eq!(result.args[2].typ, Typ::Str(None));
assert_eq!(result.args[2].default, Some(json!("default description")));
assert_eq!(result.args[2].has_default, true);
Ok(())
}
#[test]
fn test_powershell_case_insensitive_parameter() -> anyhow::Result<()> {
// Test that [parameter(...)] is case-insensitive
let code = r#"param(
[parameter(Mandatory)]
[string]$LowerCase,
[PARAMETER(MANDATORY=$TRUE)]
[string]$UpperCase,
[Parameter(mandatory=$true)]
[string]$MixedCase
)"#;
let result = parse_powershell_sig(code)?;
assert_eq!(result.args.len(), 3);
// All should be detected as mandatory
assert_eq!(result.args[0].name, "LowerCase");
assert_eq!(result.args[0].has_default, false);
assert_eq!(result.args[1].name, "UpperCase");
assert_eq!(result.args[1].has_default, false);
assert_eq!(result.args[2].name, "MixedCase");
assert_eq!(result.args[2].has_default, false);
Ok(())
}
#[test]
fn test_powershell_validateset_enum() -> anyhow::Result<()> {
// Test with ValidateSet creating an enum type
let code = r#"param(
[ValidateSet('Red', 'Green', 'Blue')]
[string]$Color,
[Parameter(Mandatory=$true)]
[ValidateSet("Small", "Medium", "Large")]
[string]$Size
)"#;
let result = parse_powershell_sig(code)?;
assert_eq!(result.args.len(), 2);
// Color: optional with ValidateSet (enum)
assert_eq!(result.args[0].name, "Color");
assert_eq!(
result.args[0].typ,
Typ::Str(Some(vec![
"Red".to_string(),
"Green".to_string(),
"Blue".to_string()
]))
);
assert_eq!(result.args[0].has_default, true); // Optional (not mandatory)
// Size: mandatory with ValidateSet (enum)
assert_eq!(result.args[1].name, "Size");
assert_eq!(
result.args[1].typ,
Typ::Str(Some(vec![
"Small".to_string(),
"Medium".to_string(),
"Large".to_string()
]))
);
assert_eq!(result.args[1].has_default, false); // Required (mandatory)
Ok(())
}
#[test]
fn test_parse_bash_sig_with_crlf() -> anyhow::Result<()> {
// Test with CRLF line endings (Windows-style)
let code = "\r\ntoken=\"$1\"\r\nimage=\"$2\"\r\ndigest=\"${3:-latest with spaces}\"\r\ntext=\"$4\" # with comment\r\nnon_required=\"${5:-}\"\r\n\r\n\r\n";
assert_eq!(
parse_bash_sig(code)?,
MainArgSignature {
star_args: false,
star_kwargs: false,
args: vec![
Arg {
otyp: None,
name: "token".to_string(),
typ: Typ::Str(None),
default: None,
has_default: false,
oidx: None,
otyp_inferred: false,
},
Arg {
otyp: None,
name: "image".to_string(),
typ: Typ::Str(None),
default: None,
has_default: false,
oidx: None,
otyp_inferred: false,
},
Arg {
otyp: None,
name: "digest".to_string(),
typ: Typ::Str(None),
default: Some(json!("latest with spaces")),
has_default: true,
oidx: None,
otyp_inferred: false,
},
Arg {
otyp: None,
name: "text".to_string(),
typ: Typ::Str(None),
default: None,
has_default: false,
oidx: None,
otyp_inferred: false,
},
Arg {
otyp: None,
name: "non_required".to_string(),
typ: Typ::Str(None),
default: Some(json!("")),
has_default: true,
oidx: None,
otyp_inferred: false,
}
],
auto_kind: None,
has_preprocessor: None,
..Default::default()
}
);
Ok(())
}
#[test]
fn test_detect_cmdlet_binding() {
// Basic CmdletBinding
let (has_cb, has_ssp) = detect_cmdlet_binding("[CmdletBinding()]\nparam($Name)");
assert!(has_cb);
assert!(!has_ssp);
// CmdletBinding with SupportsShouldProcess
let (has_cb, has_ssp) =
detect_cmdlet_binding("[CmdletBinding(SupportsShouldProcess=$true)]\nparam($Path)");
assert!(has_cb);
assert!(has_ssp);
// CmdletBinding with SupportsShouldProcess=false
let (has_cb, has_ssp) =
detect_cmdlet_binding("[CmdletBinding(SupportsShouldProcess=$false)]\nparam($Path)");
assert!(has_cb);
assert!(!has_ssp);
// No CmdletBinding
let (has_cb, has_ssp) = detect_cmdlet_binding("param($Name)");
assert!(!has_cb);
assert!(!has_ssp);
// Case insensitive
let (has_cb, has_ssp) =
detect_cmdlet_binding("[cmdletbinding(supportsshouldprocess=$true)]\nparam($X)");
assert!(has_cb);
assert!(has_ssp);
// Commented out CmdletBinding should NOT be detected
let (has_cb, has_ssp) =
detect_cmdlet_binding("# [CmdletBinding(SupportsShouldProcess=$true)]\nparam($Path)");
assert!(!has_cb);
assert!(!has_ssp);
}
#[test]
fn test_powershell_common_param_filtering() -> anyhow::Result<()> {
// Common parameters declared in param() should be filtered out
let code = r#"[CmdletBinding()]
param(
[string]$Name,
[switch]$Verbose,
[string]$ErrorAction,
[int]$Age
)"#;
let sig = parse_powershell_sig(code)?;
assert_eq!(sig.args.len(), 2);
assert_eq!(sig.args[0].name, "Name");
assert_eq!(sig.args[1].name, "Age");
assert_eq!(sig.has_cmd_binding, Some(true));
assert_eq!(sig.supports_should_process, None);
Ok(())
}
#[test]
fn test_powershell_sig_cmdlet_binding_metadata() -> anyhow::Result<()> {
// Script without CmdletBinding
let code = "param([string]$Name)";
let sig = parse_powershell_sig(code)?;
assert_eq!(sig.has_cmd_binding, None);
assert_eq!(sig.supports_should_process, None);
// Script with CmdletBinding + SupportsShouldProcess
let code = "[CmdletBinding(SupportsShouldProcess=$true)]\nparam([string]$Path)";
let sig = parse_powershell_sig(code)?;
assert_eq!(sig.has_cmd_binding, Some(true));
assert_eq!(sig.supports_should_process, Some(true));
assert_eq!(sig.args.len(), 1);
assert_eq!(sig.args[0].name, "Path");
Ok(())
}
}