From 24dda0b3098b5bc1d93367e92d08cec7d813771e Mon Sep 17 00:00:00 2001 From: PerishCode Date: Mon, 15 Jun 2026 14:13:04 +0800 Subject: [PATCH 01/20] seal: replace legacy transpiler with parser skeleton --- .runseal/wrappers/cloudflare.seal | 222 ------ .runseal/wrappers/guard.seal | 195 ----- .runseal/wrappers/init.seal | 140 ---- .runseal/wrappers/pr.seal | 257 ------- .runseal/wrappers/release.seal | 139 ---- AGENTS.md | 13 +- README.md | 66 +- app/src/bin/runseal.rs | 18 +- app/src/core/internal_help.rs | 55 +- app/src/core/mod.rs | 2 +- app/src/core/runtime.rs | 9 +- app/src/core/seal/ast.rs | 262 +++++++ app/src/core/seal/diag.rs | 16 + app/src/core/seal/ground.rs | 232 ++++++ app/src/core/seal/lexer.rs | 368 +++++++++ app/src/core/seal/mod.rs | 10 + app/src/core/seal/parser/expr.rs | 288 +++++++ app/src/core/seal/parser/mod.rs | 305 ++++++++ app/src/core/seal/parser/process.rs | 138 ++++ app/src/core/seal/parser/statement.rs | 230 ++++++ app/src/core/seal/span.rs | 31 + app/src/core/seal/token.rs | 128 ++++ app/src/core/transpile/ast.rs | 174 ----- app/src/core/transpile/emit/mod.rs | 490 ------------ app/src/core/transpile/emit/powershell.rs | 396 ---------- .../core/transpile/emit/powershell_argv.rs | 113 --- .../core/transpile/emit/powershell_support.rs | 120 --- app/src/core/transpile/emit/support.rs | 142 ---- app/src/core/transpile/frontend/mod.rs | 4 - app/src/core/transpile/frontend/powershell.rs | 262 ------- .../transpile/frontend/powershell_value.rs | 315 -------- app/src/core/transpile/frontend/predicate.rs | 66 -- app/src/core/transpile/guards.rs | 64 -- app/src/core/transpile/lower.rs | 86 --- app/src/core/transpile/mod.rs | 127 ---- app/src/core/transpile/parse.rs | 496 ------------ app/src/core/transpile/parse_argv.rs | 214 ------ app/src/core/transpile/parse_command.rs | 100 --- app/src/core/transpile/parse_lex.rs | 196 ----- app/src/core/transpile/runner/args.rs | 163 ---- app/src/core/transpile/runner/mod.rs | 357 --------- app/src/core/transpile/runner/support.rs | 146 ---- app/src/core/transpile/value.rs | 173 ----- app/tests/first_run.rs | 1 - app/tests/fixtures/estate/admin.seal | 191 ----- app/tests/fixtures/estate/kube.seal | 13 - app/tests/fixtures/estate/pr.seal | 161 ---- app/tests/fixtures/estate/ssh.seal | 73 -- app/tests/internal.rs | 12 +- app/tests/internal_wrappers.rs | 202 +---- app/tests/operator.rs | 10 - app/tests/operator/cloudflare.rs | 426 ----------- app/tests/operator/estate.rs | 486 ------------ app/tests/operator/estate/pr.rs | 327 -------- app/tests/operator/guard.rs | 220 ------ app/tests/operator/init.rs | 196 ----- app/tests/operator/repo.rs | 464 ------------ app/tests/seal.rs | 190 +++++ app/tests/transpile.rs | 479 ------------ app/tests/transpile_cases.rs | 18 - app/tests/transpile_cases/argv.rs | 257 ------- .../transpile_cases/command_predicate.rs | 77 -- app/tests/transpile_cases/env.rs | 65 -- app/tests/transpile_cases/expansion.rs | 183 ----- app/tests/transpile_cases/regex.rs | 103 --- app/tests/transpile_cases/release.rs | 172 ----- app/tests/transpile_cases/retry.rs | 115 --- app/tests/transpile_cases/wrappers.rs | 284 ------- app/tests/transpile_support/syntax.rs | 88 --- app/tests/transpile_support/tool.rs | 21 - docs/examples/README.md | 10 +- docs/examples/seal/README.md | 30 + docs/examples/seal/calls.md | 148 ++++ docs/examples/seal/case.md | 246 +++--- docs/examples/seal/control-flow.md | 92 +++ docs/examples/seal/env-scope.md | 94 +++ docs/examples/seal/failure-model.md | 572 ++++++++++++++ docs/examples/seal/grammar.md | 714 ++++++++++++++++++ docs/examples/seal/io-pipeline.md | 99 +++ docs/examples/seal/perish-scenarios.md | 352 +++++++++ docs/examples/seal/semantics.md | 346 +++++++++ docs/examples/seal/stream-model.md | 306 ++++++++ docs/examples/seal/values.md | 189 +++++ docs/spec/README.md | 6 + docs/spec/seal-language.md | 550 ++++++++++++++ 85 files changed, 5850 insertions(+), 10066 deletions(-) delete mode 100644 .runseal/wrappers/cloudflare.seal delete mode 100644 .runseal/wrappers/guard.seal delete mode 100644 .runseal/wrappers/init.seal delete mode 100644 .runseal/wrappers/pr.seal delete mode 100644 .runseal/wrappers/release.seal create mode 100644 app/src/core/seal/ast.rs create mode 100644 app/src/core/seal/diag.rs create mode 100644 app/src/core/seal/ground.rs create mode 100644 app/src/core/seal/lexer.rs create mode 100644 app/src/core/seal/mod.rs create mode 100644 app/src/core/seal/parser/expr.rs create mode 100644 app/src/core/seal/parser/mod.rs create mode 100644 app/src/core/seal/parser/process.rs create mode 100644 app/src/core/seal/parser/statement.rs create mode 100644 app/src/core/seal/span.rs create mode 100644 app/src/core/seal/token.rs delete mode 100644 app/src/core/transpile/ast.rs delete mode 100644 app/src/core/transpile/emit/mod.rs delete mode 100644 app/src/core/transpile/emit/powershell.rs delete mode 100644 app/src/core/transpile/emit/powershell_argv.rs delete mode 100644 app/src/core/transpile/emit/powershell_support.rs delete mode 100644 app/src/core/transpile/emit/support.rs delete mode 100644 app/src/core/transpile/frontend/mod.rs delete mode 100644 app/src/core/transpile/frontend/powershell.rs delete mode 100644 app/src/core/transpile/frontend/powershell_value.rs delete mode 100644 app/src/core/transpile/frontend/predicate.rs delete mode 100644 app/src/core/transpile/guards.rs delete mode 100644 app/src/core/transpile/lower.rs delete mode 100644 app/src/core/transpile/mod.rs delete mode 100644 app/src/core/transpile/parse.rs delete mode 100644 app/src/core/transpile/parse_argv.rs delete mode 100644 app/src/core/transpile/parse_command.rs delete mode 100644 app/src/core/transpile/parse_lex.rs delete mode 100644 app/src/core/transpile/runner/args.rs delete mode 100644 app/src/core/transpile/runner/mod.rs delete mode 100644 app/src/core/transpile/runner/support.rs delete mode 100644 app/src/core/transpile/value.rs delete mode 100644 app/tests/fixtures/estate/admin.seal delete mode 100644 app/tests/fixtures/estate/kube.seal delete mode 100644 app/tests/fixtures/estate/pr.seal delete mode 100644 app/tests/fixtures/estate/ssh.seal delete mode 100644 app/tests/operator.rs delete mode 100644 app/tests/operator/cloudflare.rs delete mode 100644 app/tests/operator/estate.rs delete mode 100644 app/tests/operator/estate/pr.rs delete mode 100644 app/tests/operator/guard.rs delete mode 100644 app/tests/operator/init.rs delete mode 100644 app/tests/operator/repo.rs create mode 100644 app/tests/seal.rs delete mode 100644 app/tests/transpile.rs delete mode 100644 app/tests/transpile_cases.rs delete mode 100644 app/tests/transpile_cases/argv.rs delete mode 100644 app/tests/transpile_cases/command_predicate.rs delete mode 100644 app/tests/transpile_cases/env.rs delete mode 100644 app/tests/transpile_cases/expansion.rs delete mode 100644 app/tests/transpile_cases/regex.rs delete mode 100644 app/tests/transpile_cases/release.rs delete mode 100644 app/tests/transpile_cases/retry.rs delete mode 100644 app/tests/transpile_cases/wrappers.rs delete mode 100644 app/tests/transpile_support/syntax.rs delete mode 100644 app/tests/transpile_support/tool.rs create mode 100644 docs/examples/seal/README.md create mode 100644 docs/examples/seal/calls.md create mode 100644 docs/examples/seal/control-flow.md create mode 100644 docs/examples/seal/env-scope.md create mode 100644 docs/examples/seal/failure-model.md create mode 100644 docs/examples/seal/grammar.md create mode 100644 docs/examples/seal/io-pipeline.md create mode 100644 docs/examples/seal/perish-scenarios.md create mode 100644 docs/examples/seal/semantics.md create mode 100644 docs/examples/seal/stream-model.md create mode 100644 docs/examples/seal/values.md create mode 100644 docs/spec/README.md create mode 100644 docs/spec/seal-language.md diff --git a/.runseal/wrappers/cloudflare.seal b/.runseal/wrappers/cloudflare.seal deleted file mode 100644 index fa24817..0000000 --- a/.runseal/wrappers/cloudflare.seal +++ /dev/null @@ -1,222 +0,0 @@ -print() { - printf '%s\n' "$1" -} - -error() { - printf '%s\n' "$1" >&2 -} - -fail() { - error "$1" - exit 1 -} - -usage() { - print "Usage: runseal :cloudflare [args]" - print "" - print "Commands:" - print " init create repo-local .local/secrets/cloudflare.env template" - print " check validate repo-local credentials and probe core account APIs" - print " manage-plan print the desired manage redirect rule shape" - print " manage-inspect inspect current dynamic redirect ruleset for manage rules" - print " manage-ensure-redirect create/update exact-path manage redirects (use --dry-run first)" - print " api use: runseal @tool cloudflare api request ..." - print "" - print "Credentials:" - print " .local/secrets/cloudflare.env" -} - -reject_extra_arg() { - if [ -n "$1" ]; then - fail "$2" - fi -} - -load_manage_redirect_rules() { - zone_name=$(runseal @tool cloudflare config get zone_name) - request_host=$(runseal @tool cloudflare config get manage_host) - redirect_host=$(runseal @tool cloudflare config get manage_origin_host) - prefix=$(runseal @tool cloudflare config get manage_redirect_prefix) - if [ -z "$prefix" ]; then - target_sh="https://$redirect_host/manage.sh" - target_ps1="https://$redirect_host/manage.ps1" - else - target_sh="https://$redirect_host/$prefix/manage.sh" - target_ps1="https://$redirect_host/$prefix/manage.ps1" - fi - rule_sh=$(runseal @tool cloudflare redirect-rule exact --ref runseal_manage_sh_redirect --description "Redirect runseal manage.sh to releases bucket asset" --host "$request_host" --path /manage.sh --target-url "$target_sh") - rule_ps1=$(runseal @tool cloudflare redirect-rule exact --ref runseal_manage_ps1_redirect --description "Redirect runseal manage.ps1 to releases bucket asset" --host "$request_host" --path /manage.ps1 --target-url "$target_ps1") -} - -print_manage_redirect_plan() { - pretty_sh=$(runseal @tool json pretty value "$rule_sh") - pretty_ps1=$(runseal @tool json pretty value "$rule_ps1") - print "manage redirect plan" - print "zone: $zone_name" - if [ "$1" = with-zone-id ]; then - print "zone id: $zone_id" - fi - print "request host: $request_host" - print "redirect host: $redirect_host" - print "phase: http_request_dynamic_redirect" - print "rules:" - print "$pretty_sh" - print "$pretty_ps1" -} - -if [ -z "$1" ]; then - usage - exit 0 -fi - -if [ "$1" = help ]; then - usage - exit 0 -fi - -if [ "$1" = --help ]; then - usage - exit 0 -fi - -case "$1" in - init) - reject_extra_arg "$2" "cloudflare: init does not accept arguments" - local_dir="${RUNSEAL_REPO_LOCAL_DIR:-.local}" - secrets_dir="${RUNSEAL_REPO_SECRETS_DIR:-.local/secrets}" - tmp_dir="${RUNSEAL_REPO_TMP_DIR:-.local/tmp}" - token_file="$secrets_dir/cloudflare.env" - runseal @tool fs mkdir "$local_dir" 700 - runseal @tool fs mkdir "$secrets_dir" 700 - runseal @tool fs mkdir "$tmp_dir" 700 - if [ -f "$token_file" ]; then - print "exists $token_file" - else - runseal @tool fs write-base64 "$token_file" IyBSZXBvLWxvY2FsIENsb3VkZmxhcmUgY3JlZGVudGlhbHMgZm9yIHJ1bnNlYWwgc3VwcG9ydCBjb21tYW5kcy4KIyBGaWxsIHRoZXNlIHZhbHVlcyBtYW51YWxseS4gVGhpcyBmaWxlIHN0YXlzIGxvY2FsIGFuZCBnaXRpZ25vcmVkLgpDTE9VREZMQVJFX0FDQ09VTlRfSUQ9CkNMT1VERkxBUkVfQVBJX1RPS0VOPQpDTE9VREZMQVJFX1pPTkVfTkFNRT1wZXJpc2gudWsKQ0xPVURGTEFSRV9NQU5BR0VfSE9TVD1ydW5zZWFsLnBlcmlzaC51awpDTE9VREZMQVJFX01BTkFHRV9PUklHSU5fSE9TVD1yZWxlYXNlcy5ydW5zZWFsLnBlcmlzaC51awpDTE9VREZMQVJFX01BTkFHRV9SRURJUkVDVF9QUkVGSVg9Cg== - runseal @tool fs chmod "$token_file" 600 - print "created $token_file" - fi - ;; - check) - reject_extra_arg "$2" "cloudflare: check does not accept arguments" - account_id=$(runseal @tool cloudflare config get account_id) - zone_name=$(runseal @tool cloudflare config get zone_name) - zone=$(runseal @tool cloudflare zone get --name "$zone_name") - zone_id=$(runseal @tool json get "$zone" .id) - rulesets=$(runseal @tool cloudflare zone ruleset list --zone-id "$zone_id") - ruleset_count=$(runseal @tool json len "$rulesets") - zones_payload=$(runseal @tool cloudflare api request GET /zones --query "account.id=$account_id" --query per_page=50) - zones=$(runseal @tool json get "$zones_payload" .result) - zones_pretty=$(runseal @tool json pretty value "$zones") - account=$(runseal @tool cloudflare account get --account-id "$account_id") - account_name=$(runseal @tool json get "$account" .name) - buckets=$(runseal @tool cloudflare account r2 bucket list --account-id "$account_id") - buckets_pretty=$(runseal @tool json pretty value "$buckets") - print "cloudflare check: ok" - print "account id: $account_id" - print "account name: $account_name" - print "manage zone: $zone_name ($zone_id)" - print "zone rulesets: $ruleset_count" - print "zones:" - print "$zones_pretty" - print "r2 buckets:" - print "$buckets_pretty" - ;; - manage-plan) - reject_extra_arg "$2" "cloudflare: manage-plan does not accept arguments" - load_manage_redirect_rules - print_manage_redirect_plan - ;; - manage-inspect) - reject_extra_arg "$2" "cloudflare: manage-inspect does not accept arguments" - zone_name=$(runseal @tool cloudflare config get zone_name) - zone=$(runseal @tool cloudflare zone get --name "$zone_name") - zone_id=$(runseal @tool json get "$zone" .id) - rulesets=$(runseal @tool cloudflare zone ruleset list --zone-id "$zone_id") - ruleset=$(runseal @tool json find "$rulesets" phase http_request_dynamic_redirect) - if [ -z "$ruleset" ]; then - print "manage inspect: no http_request_dynamic_redirect zone ruleset found" - exit 0 - fi - ruleset_id=$(runseal @tool json get "$ruleset" .id) - full_ruleset=$(runseal @tool cloudflare zone ruleset get --zone-id "$zone_id" --ruleset-id "$ruleset_id") - ruleset_name=$(runseal @tool json get "$full_ruleset" .name) - rules=$(runseal @tool json get "$full_ruleset" .rules) - matched=$(runseal @tool json filter "$rules" ref runseal_manage_sh_redirect runseal_manage_ps1_redirect) - matched_count=$(runseal @tool json len "$matched") - print "zone id: $zone_id" - print "ruleset id: $ruleset_id" - print "ruleset name: $ruleset_name" - if [ "$matched_count" = 0 ]; then - print "manage inspect: no manage redirect rules found" - exit 0 - fi - pretty=$(runseal @tool json pretty value "$matched") - print "manage rules:" - print "$pretty" - ;; - manage-ensure-redirect) - dry_run=false - if [ "$2" = --dry-run ]; then - dry_run=true - if [ -n "$3" ]; then - fail "cloudflare: unknown manage-ensure-redirect argument: $3" - fi - else - if [ -n "$2" ]; then - fail "cloudflare: unknown manage-ensure-redirect argument: $2" - fi - fi - load_manage_redirect_rules - zone=$(runseal @tool cloudflare zone get --name "$zone_name") - zone_id=$(runseal @tool json get "$zone" .id) - if [ "$dry_run" = true ]; then - print_manage_redirect_plan with-zone-id - exit 0 - fi - rulesets=$(runseal @tool cloudflare zone ruleset list --zone-id "$zone_id") - ruleset=$(runseal @tool json find "$rulesets" phase http_request_dynamic_redirect) - if [ -z "$ruleset" ]; then - ruleset=$(runseal @tool cloudflare zone ruleset create --zone-id "$zone_id" --phase http_request_dynamic_redirect --name "Single Redirects ruleset") - else - ruleset_id=$(runseal @tool json get "$ruleset" .id) - ruleset=$(runseal @tool cloudflare zone ruleset get --zone-id "$zone_id" --ruleset-id "$ruleset_id") - fi - ruleset_id=$(runseal @tool json get "$ruleset" .id) - rules=$(runseal @tool json get "$ruleset" .rules) - current_sh=$(runseal @tool json find "$rules" ref runseal_manage_sh_redirect) - current_ps1=$(runseal @tool json find "$rules" ref runseal_manage_ps1_redirect) - if [ -z "$current_sh" ]; then - runseal @tool cloudflare zone ruleset rule add --zone-id "$zone_id" --ruleset-id "$ruleset_id" --json "$rule_sh" - changed_sh="created runseal_manage_sh_redirect" - else - rule_id=$(runseal @tool json get "$current_sh" .id) - runseal @tool cloudflare zone ruleset rule update --zone-id "$zone_id" --ruleset-id "$ruleset_id" --rule-id "$rule_id" --json "$rule_sh" - changed_sh="updated runseal_manage_sh_redirect" - fi - if [ -z "$current_ps1" ]; then - runseal @tool cloudflare zone ruleset rule add --zone-id "$zone_id" --ruleset-id "$ruleset_id" --json "$rule_ps1" - changed_ps1="created runseal_manage_ps1_redirect" - else - rule_id=$(runseal @tool json get "$current_ps1" .id) - runseal @tool cloudflare zone ruleset rule update --zone-id "$zone_id" --ruleset-id "$ruleset_id" --rule-id "$rule_id" --json "$rule_ps1" - changed_ps1="updated runseal_manage_ps1_redirect" - fi - print "manage ensure redirect: ok" - print " - $changed_sh" - print " - $changed_ps1" - ;; - api) - if [ -z "$2" ]; then - fail "cloudflare: api requires a method" - fi - if [ -z "$3" ]; then - fail "cloudflare: api requires a path" - fi - shift - runseal @tool cloudflare api request "$@" - ;; - *) - fail "cloudflare: unknown command: $1" - ;; -esac diff --git a/.runseal/wrappers/guard.seal b/.runseal/wrappers/guard.seal deleted file mode 100644 index cbda19f..0000000 --- a/.runseal/wrappers/guard.seal +++ /dev/null @@ -1,195 +0,0 @@ -print() { - printf '%s\n' "$1" -} - -error() { - printf '%s\n' "$1" >&2 -} - -fail() { - error "$1" - exit 1 -} - -usage() { - print "Usage: runseal :guard [version-check|version-hash]" - print "" - print "Run repository guard checks or one explicit version-policy helper." - print "" - print "Commands:" - print " version-check validate version policy against stable metadata" - print " version-hash print the current guard.version.hash value" -} - -mode=full -if [ "$#" -gt 0 ]; then - case "$1" in - version-check) - mode=version-check - shift - ;; - version-hash) - mode=version-hash - shift - ;; - -h|--help|help) - usage - exit 0 - ;; - *) - fail "guard: unknown command: $1" - ;; - esac -fi - -if [ "$#" -gt 0 ]; then - fail "guard: unexpected arguments" -fi - -if [ "$mode" = version-hash ]; then - runseal @tool hash tree app/tests - exit 0 -fi - -public_url="${RUNSEAL_RELEASES_PUBLIC_URL:-}" -if [ -z "$public_url" ]; then - public_url="https://releases.runseal.perish.uk" -fi - -metadata_url="${RUNSEAL_STABLE_METADATA_URL:-}" -if [ -z "$metadata_url" ]; then - metadata_url="$public_url/stable/latest/metadata.json" -fi - -tmp_dir="${RUNSEAL_REPO_TMP_DIR:-.local/tmp}" -if [ -n "${RUNNER_TEMP:-}" ]; then - tmp_dir="${RUNNER_TEMP}/runseal-guard" -fi -runseal @tool fs mkdir "$tmp_dir" 700 -metadata_file="$tmp_dir/runseal-guard-stable-metadata.json" -cargo_metadata=$(cargo metadata --no-deps --format-version 1) -current_version=$(runseal @tool json get "$cargo_metadata" '.packages[0].version') -current_hash=$(runseal @tool hash tree app/tests) -status=$(curl -sS -o "$metadata_file" -w "%{http_code}" "$metadata_url?version=$current_version") -if [ "$status" = 404 ]; then - print "guard version policy: no stable metadata; skipping" - if [ "$mode" = version-check ]; then - exit 0 - fi -else - if [ "$status" = 200 ]; then - else - fail "guard version policy: failed to fetch stable metadata: HTTP $status" - fi - - metadata=$(cat "$metadata_file") - has_prior_hash=$(runseal @tool json has "$metadata" .guard.version.hash) - if [ "$has_prior_hash" = true ]; then - prior_hash=$(runseal @tool json get "$metadata" .guard.version.hash) - else - prior_hash= - fi - if [ -z "$prior_hash" ]; then - print "guard version policy: stable metadata has no guard.version.hash; skipping" - if [ "$mode" = version-check ]; then - exit 0 - fi - else - has_stable_version=$(runseal @tool json has "$metadata" .stableVersion) - if [ "$has_stable_version" = true ]; then - prior_version=$(runseal @tool json get "$metadata" .stableVersion) - else - prior_version= - fi - if [ -z "$prior_version" ]; then - has_release_version=$(runseal @tool json has "$metadata" .releaseVersion) - if [ "$has_release_version" = true ]; then - prior_version=$(runseal @tool json get "$metadata" .releaseVersion) - fi - fi - if [ -z "$prior_version" ]; then - fail "guard version policy: stable metadata is missing stableVersion/releaseVersion" - fi - - current_order=$(runseal @tool version compare "$current_version" "$prior_version") - prior_major=$(runseal @tool version part "$prior_version" major) - prior_minor=$(runseal @tool version part "$prior_version" minor) - current_major=$(runseal @tool version part "$current_version" major) - current_minor=$(runseal @tool version part "$current_version" minor) - same_minor_lineage=false - - if [ "$current_order" = lt ]; then - fail "guard version policy: version regressed below prior stable $prior_version" - fi - if [ "$current_order" = eq ]; then - fail "guard version policy: version matches prior stable $prior_version" - fi - if [ "$current_major" = "$prior_major" ]; then - if [ "$current_minor" = "$prior_minor" ]; then - same_minor_lineage=true - fi - fi - - if [ "$current_hash" = "$prior_hash" ]; then - if [ "$same_minor_lineage" = false ]; then - fail "guard version policy: unchanged guard.version.hash requires a patch-only bump above $prior_version" - fi - print "guard version policy: hash unchanged -> patch bump ok ($prior_version -> $current_version)" - else - if [ "$same_minor_lineage" = true ]; then - fail "guard version policy: changed guard.version.hash requires a minor-or-higher bump above $prior_version" - fi - print "guard version policy: hash changed -> minor-or-higher bump ok ($prior_version -> $current_version)" - fi - - if [ "$mode" = version-check ]; then - exit 0 - fi - fi -fi - -print "==> cargo fmt" -cargo fmt --all --check - -print "==> cargo clippy" -cargo clippy --locked --workspace --all-targets -- -D warnings - -print "==> cargo test" -cargo test --locked --workspace - -print "==> seal wrappers" -cargo run --quiet --locked -p runseal -- @transpile --input-lang=seal --output-lang=sealir .runseal/wrappers/cloudflare.seal -cargo run --quiet --locked -p runseal -- @transpile --input-lang=seal --output-lang=sealir .runseal/wrappers/guard.seal -cargo run --quiet --locked -p runseal -- @transpile --input-lang=seal --output-lang=sealir .runseal/wrappers/init.seal -cargo run --quiet --locked -p runseal -- @transpile --input-lang=seal --output-lang=sealir .runseal/wrappers/pr.seal -cargo run --quiet --locked -p runseal -- @transpile --input-lang=seal --output-lang=sealir .runseal/wrappers/release.seal - -print "==> flavor self-check" -flavor check --root . --config flavor.toml - -print "==> shell syntax" -sh -n manage.sh -sh -n .github/scripts/release/assets/checksums.sh -sh -n .github/scripts/release/assets/package.sh -sh -n .github/scripts/release/assets/verify.sh -sh -n .github/scripts/release/github/cleanup-artifacts.sh -bash -n .github/scripts/release/r2/check.sh -bash -n .github/scripts/release/r2/publish.sh -bash -n .github/scripts/release/r2/summary.sh -bash -n .github/scripts/release/r2/verify.sh -sh -n .github/scripts/release/smoke/smoke.sh - -print "==> python syntax" -python3 -m py_compile .github/scripts/release/metadata/beta.py -python3 -m py_compile .github/scripts/release/metadata/stable.py - -has_pwsh=$(runseal @tool process exists pwsh) -if [ "$has_pwsh" = true ]; then - print "==> PowerShell syntax" - pwsh -NoProfile -NonInteractive -Command "[scriptblock]::Create((Get-Content -Raw 'manage.ps1')) | Out-Null" - pwsh -NoProfile -NonInteractive -Command "[scriptblock]::Create((Get-Content -Raw '.github/scripts/release/assets/package.ps1')) | Out-Null" - pwsh -NoProfile -NonInteractive -Command "[scriptblock]::Create((Get-Content -Raw '.github/scripts/release/smoke/smoke.ps1')) | Out-Null" -else - print "==> PowerShell syntax" - print "skip: pwsh not found" -fi diff --git a/.runseal/wrappers/init.seal b/.runseal/wrappers/init.seal deleted file mode 100644 index 904da5e..0000000 --- a/.runseal/wrappers/init.seal +++ /dev/null @@ -1,140 +0,0 @@ -__seal_argc=$# -__seal_help=false -force=false -while [ "$#" -gt 0 ]; do - case "$1" in - --force) - force=true - shift - ;; - --) - shift - break - ;; - -h|--help|help) - __seal_help=true - shift - ;; - *) fail "unknown option: $1" ;; - esac -done - -print() { - printf '%s\n' "$1" -} - -error() { - printf '%s\n' "$1" >&2 -} - -fail() { - error "$1" - exit 1 -} - -usage() { - print "Usage: runseal :init [--force]" - print "" - print "Validate the repository and install generated git hooks." - print "" - print "Options:" - print " --force back up custom hooks before replacing them" -} - -require_tool() { - exists=$(runseal @tool process exists "$1") - if [ "$exists" = true ]; then - else - fail "init: missing required tool: $1" - fi -} - -require_path() { - path="$root/$1" - if [ -f "$path" ]; then - else - fail "init: missing required path: $1" - fi -} - -prepare_hook() { - if [ -f "$1" ]; then - generated=$(runseal @tool fs contains-any "$1" "runseal init hook" "runseal bootstrap hook") - if [ "$generated" = true ]; then - else - if [ "$force" = true ]; then - backup=$(runseal @tool fs backup-numbered "$1") - print "backed up existing hook to $backup" - else - fail "init: $1 already exists and was not generated by runseal init; rerun with --force to back it up and replace it" - fi - fi - fi -} - -if [ "$__seal_help" = true ]; then - usage - exit 0 -fi - -print "==> resolving repository" -root=$(git rev-parse --show-toplevel) -git_dir=$(git rev-parse --absolute-git-dir) -hooks_dir="$git_dir/hooks" -pre_commit="$hooks_dir/pre-commit" -commit_msg="$hooks_dir/commit-msg" -print "repository: $root" - -print "==> checking required tools" -require_tool git -require_tool python3 -require_tool cargo -require_tool runseal -require_tool flavor -require_tool sh -require_tool bash -require_tool cp -require_tool sed -require_tool grep -print "ok: git, python3, cargo, runseal, flavor, sh, bash, cp, sed, grep" - -print "==> checking repository entrypoints" -require_path Cargo.toml -require_path Cargo.lock -require_path flavor.toml -require_path manage.sh -require_path manage.ps1 -require_path runseal.toml -require_path .runseal/hooks/pre-commit -require_path .runseal/hooks/commit-msg -require_path .runseal/wrappers/cloudflare.seal -require_path .runseal/wrappers/guard.seal -require_path .runseal/wrappers/init.seal -require_path .runseal/wrappers/pr.seal -require_path .runseal/wrappers/release.seal -require_path .github/workflows/guard.yml -require_path .github/workflows/release-beta.yml -require_path .github/workflows/release-stable.yml -require_path .github/scripts/release/assets/package.sh -require_path .github/scripts/release/assets/package.ps1 -require_path .github/scripts/release/r2/publish.sh -require_path .github/scripts/release/smoke/smoke.sh -require_path .github/scripts/release/smoke/smoke.ps1 -print "ok: repository entrypoints" - -print "==> installing git hooks" -runseal @tool fs mkdir "$hooks_dir" 700 - -prepare_hook "$pre_commit" - -cp .runseal/hooks/pre-commit "$pre_commit" -runseal @tool fs chmod "$pre_commit" 755 -print "installed $pre_commit" - -prepare_hook "$commit_msg" - -cp .runseal/hooks/commit-msg "$commit_msg" -runseal @tool fs chmod "$commit_msg" 755 -print "installed $commit_msg" - -print "development environment ready" diff --git a/.runseal/wrappers/pr.seal b/.runseal/wrappers/pr.seal deleted file mode 100644 index 1b83fa4..0000000 --- a/.runseal/wrappers/pr.seal +++ /dev/null @@ -1,257 +0,0 @@ -print() { - printf '%s\n' "$1" -} - -error() { - printf '%s\n' "$1" >&2 -} - -fail() { - error "$1" - exit 1 -} - -usage() { - print "Usage: runseal :pr [options]" - print "" - print "Create or update, watch, and squash-merge the GitHub PR for the current branch." - print "" - print "Options:" - print " --base PR base branch (default: main)" - print " --title title when creating a new PR" - print " --body-file <path> body file when creating a new PR" - print " --draft create the PR as draft and require --no-merge" - print " --no-watch do not watch PR checks" - print " --no-merge do not squash-merge after checks" - print " --no-push do not push the current branch first" - print " --dry-run print planned actions without changing remote state" -} - -print_dry_run() { - print "branch: $branch" - print "base: $base" - if [ "$no_push" = true ]; then - print "push: False" - else - print "push: True" - fi - print "pr: create if missing, otherwise reuse existing" - if [ "$draft" = true ]; then - print "draft: True" - print "ready: False" - else - print "draft: False" - print "ready: True" - fi - if [ "$no_watch" = true ]; then - print "watch: False" - else - print "watch: True" - fi - if [ "$no_merge" = true ]; then - print "squash_merge: False" - else - print "squash_merge: True" - fi -} - -create_pr() { - if [ "$draft" = true ]; then - if [ -n "$title" ]; then - if [ -n "$body_file" ]; then - gh pr create --draft --base "$base" --head "$branch" --title "$title" --body-file "$body_file" - else - gh pr create --draft --base "$base" --head "$branch" --title "$title" --fill - fi - else - if [ -n "$body_file" ]; then - gh pr create --draft --base "$base" --head "$branch" --fill --body-file "$body_file" - else - gh pr create --draft --base "$base" --head "$branch" --fill - fi - fi - else - if [ -n "$title" ]; then - if [ -n "$body_file" ]; then - gh pr create --base "$base" --head "$branch" --title "$title" --body-file "$body_file" - else - gh pr create --base "$base" --head "$branch" --title "$title" --fill - fi - else - if [ -n "$body_file" ]; then - gh pr create --base "$base" --head "$branch" --fill --body-file "$body_file" - else - gh pr create --base "$base" --head "$branch" --fill - fi - fi - fi -} - -watch_checks() { - checks_seen=false - attempt=0 - while [ "$attempt" -lt 12 ]; do - checks_seen=$(runseal @tool github pr checks probe "$number") - if [ "$checks_seen" = true ]; then - checks_seen=true - break - fi - sleep 5 - attempt=$(runseal @tool int add "$attempt" 1) - done - if [ "$checks_seen" = false ]; then - print "no checks reported on PR #$number; skipping watch" - else - gh pr checks "$number" --watch --interval 10 - fi -} - -__seal_argc=$# -__seal_help=false -base=main -title= -body_file= -draft=false -no_watch=false -no_merge=false -no_push=false -dry_run=false -while [ "$#" -gt 0 ]; do - case "$1" in - --base) - if [ "$#" -lt 2 ]; then fail "missing value for --base"; fi - base=$2 - shift 2 - ;; - --base=*) - base=${1#--base=} - shift - ;; - --title) - if [ "$#" -lt 2 ]; then fail "missing value for --title"; fi - title=$2 - shift 2 - ;; - --title=*) - title=${1#--title=} - shift - ;; - --body-file) - if [ "$#" -lt 2 ]; then fail "missing value for --body-file"; fi - body_file=$2 - shift 2 - ;; - --body-file=*) - body_file=${1#--body-file=} - shift - ;; - --draft) - draft=true - shift - ;; - --no-watch) - no_watch=true - shift - ;; - --no-merge) - no_merge=true - shift - ;; - --no-push) - no_push=true - shift - ;; - --dry-run) - dry_run=true - shift - ;; - --) - shift - break - ;; - -h|--help|help) - __seal_help=true - shift - ;; - *) fail "unknown option: $1" ;; - esac -done - -if [ "$__seal_help" = true ]; then - usage - exit 0 -fi - -git --version -gh --version -gh auth status - -branch=$(git branch --show-current) -if [ -z "$branch" ]; then - fail "pr: not on a branch" -fi - -if [ "$branch" = "$base" ]; then - fail "pr: refusing to open a PR from base branch: $branch" -fi - -if [ "$branch" = main ]; then - fail "pr: refusing to open a PR from base branch: $branch" -fi - -if [ "$branch" = master ]; then - fail "pr: refusing to open a PR from base branch: $branch" -fi - -if [ "$draft" = true ]; then - if [ "$no_merge" = false ]; then - fail "pr: --draft requires --no-merge" - fi -fi - -if [ "$dry_run" = true ]; then - print_dry_run - exit 0 -fi - -if [ "$no_push" = false ]; then - git push -u origin "$branch" -fi - -created=false -pr_raw=$(gh pr list --head "$branch" --json number,title,state,url,isDraft) - -if [ "$(runseal @tool json empty "$pr_raw")" = true ]; then - create_pr - created=true - pr_raw=$(gh pr list --head "$branch" --json number,title,state,url,isDraft) - if [ "$(runseal @tool json empty "$pr_raw")" = true ]; then - fail "pr: created PR for $branch, but could not find it afterward" - fi -fi - -number=$(runseal @tool json get "$pr_raw" '.[0].number') -url=$(runseal @tool json get "$pr_raw" '.[0].url') -is_draft=$(runseal @tool json get "$pr_raw" '.[0].isDraft') - -if [ "$created" = true ]; then - print "created PR #$number: $url" -else - print "found PR #$number: $url" -fi - -if [ "$is_draft" = true ]; then - if [ "$draft" = false ]; then - gh pr ready "$number" - print "marked PR #$number ready" - fi -fi - -if [ "$no_watch" = false ]; then - watch_checks -fi - -if [ "$no_merge" = false ]; then - gh pr merge "$number" --squash --delete-branch - print "squash-merged PR #$number" -fi diff --git a/.runseal/wrappers/release.seal b/.runseal/wrappers/release.seal deleted file mode 100644 index 07d1616..0000000 --- a/.runseal/wrappers/release.seal +++ /dev/null @@ -1,139 +0,0 @@ -print() { - printf '%s\n' "$1" -} - -error() { - printf '%s\n' "$1" >&2 -} - -fail() { - error "$1" - exit 1 -} - -usage() { - print "Usage: runseal :release --channel=stable|beta [options]" - print "" - print "Trigger a release workflow." - print "" - print "Options:" - print " --channel <name> release channel: stable or beta" - print " --ref <ref> git ref passed to the workflow (default: main)" - print " --version <version> optional workflow version_override" - print " --watch watch the triggered workflow run" - print " --dry-run print planned action without triggering a workflow" -} - -__seal_argc=$# -__seal_help=false -channel= -ref=main -version= -watch=false -dry_run=false -while [ "$#" -gt 0 ]; do - case "$1" in - --channel) - if [ "$#" -lt 2 ]; then fail "missing value for --channel"; fi - channel=$2 - shift 2 - ;; - --channel=*) - channel=${1#--channel=} - shift - ;; - --ref) - if [ "$#" -lt 2 ]; then fail "missing value for --ref"; fi - ref=$2 - shift 2 - ;; - --ref=*) - ref=${1#--ref=} - shift - ;; - --version) - if [ "$#" -lt 2 ]; then fail "missing value for --version"; fi - version=$2 - shift 2 - ;; - --version=*) - version=${1#--version=} - shift - ;; - --watch) - watch=true - shift - ;; - --dry-run) - dry_run=true - shift - ;; - --) - shift - break - ;; - -h|--help|help) - __seal_help=true - shift - ;; - *) fail "unknown option: $1" ;; - esac -done - -if [ "$__seal_argc" = 0 ]; then - usage - exit 0 -fi - -if [ "$__seal_help" = true ]; then - usage - exit 0 -fi - -if [ -z "$channel" ]; then - fail "release: --channel is required" -fi - -case "$channel" in - stable) workflow=release-stable.yml ;; - beta) workflow=release-beta.yml ;; - *) - error "invalid choice: $channel" - exit 2 - ;; -esac - -command="gh workflow run $workflow --ref $ref -f ref=$ref -f version_override=$version" - -if [ "$dry_run" = true ]; then - print "$command" -else - gh --version - gh auth status - ref_sha=$(git rev-parse "$ref") - trigger_output=$(gh workflow run "$workflow" --ref "$ref" -f "ref=$ref" -f "version_override=$version") - if [ -n "$trigger_output" ]; then - print "$trigger_output" - fi - print "triggered $workflow for ref $ref" - if [ "$watch" = true ]; then - run_id=$(runseal @tool regex capture "$trigger_output" '/actions/runs/([0-9]+)' 1) - if [ -z "$run_id" ]; then - attempt=0 - raw='[]' - while [ "$attempt" -lt 6 ]; do - raw=$(gh run list --workflow "$workflow" --branch "$ref" --commit "$ref_sha" --event workflow_dispatch --limit 1 --json databaseId) - if [ "$(runseal @tool json empty "$raw")" = false ]; then - run_id=$(runseal @tool json get "$raw" '.[0].databaseId') - break - fi - sleep 2 - attempt=$(runseal @tool int add "$attempt" 1) - done - fi - if [ -z "$run_id" ]; then - fail "release: could not find a recent run for $workflow on $ref" - fi - gh run watch "$run_id" --interval 10 - fi -fi diff --git a/AGENTS.md b/AGENTS.md index b911150..5c443b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,12 +83,12 @@ the repository-owned canonical files directly. - `app/src/core/config.rs`: app configuration and profile discovery. - `app/src/core/profile.rs`: profile format loading and normalization. - `app/src/core/runtime.rs`: command execution lifecycle. -- `app/src/core/transpile/runner.rs`: direct Seal wrapper runtime. - `app/src/core/injections/`: `env` and `symlink` implementations. - `app/src/core/tool/`: built-in atomic `@tool` surface. - `app/tests/`: integration tests and focused behavioral coverage. -- `.runseal/wrappers/`: repo-local `:wrapper` entrypoints. Prefer `.seal` - wrappers; platform scripts exist only while a wrapper has not migrated. +- `.runseal/wrappers/`: repo-local `:wrapper` entrypoints. `.seal` names are + reserved for the new Seal interpreter/runtime; use platform scripts for + current operational flows until that runtime lands. - `runseal.toml`: repo-local operator profile. - `manage.sh` and `manage.ps1`: public install and uninstall managers. @@ -250,9 +250,10 @@ artifact first. ### Should `.seal` wrappers be treated as first-class runtime entrypoints? -Yes. Treat `.runseal/wrappers/*.seal` as first-class wrappers executed directly -by runseal. `@transpile` is a debug/export tool, not the normal wrapper -execution path. +Yes, but the old bash-shaped direct runner has been removed. Treat +`.runseal/wrappers/*.seal` as reserved first-class wrapper names for the new +Seal interpreter/runtime. Until that runtime lands, runseal should resolve +`.seal` wrappers and fail clearly instead of executing legacy semantics. ### What should never be committed? diff --git a/README.md b/README.md index ddf3d86..3557944 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,6 @@ Runseal inspection commands are read-only and do not run profile injections: runseal @profile runseal @resources runseal @resolve resource:// resource://ssh/config -runseal @transpile --input-lang=seal --output-lang=bash ./operator.seal runseal @tool json get '{"releaseVersion":"v0.6.0"}' '.releaseVersion' runseal @wrappers runseal @which :ssh @@ -77,13 +76,13 @@ Use `runseal profile` without `@` to run an external command named `profile`. ## Examples -Repository-owned examples for canonical `.seal` and `@tool` shapes live under +Repository-owned examples for Seal target syntax and `@tool` shapes live under [docs/examples](./docs/examples/README.md). -Start here when a wrapper shape feels "obvious in shell" but still needs the -exact runseal form: +Start here when discussing the next Seal source syntax shape: -- [Seal `case` / argv parser shapes](./docs/examples/seal/case.md) +- [Seal language specification](./docs/spec/seal-language.md) +- [Seal target syntax examples](./docs/examples/seal/README.md) - [GitHub tool examples](./docs/examples/tools/github.md) ## Fit @@ -255,56 +254,22 @@ The child working directory is not changed. A resolved wrapper receives: - `RUNSEAL_WRAPPER_NAME` - `RUNSEAL_WRAPPER_FILE` -Seal wrappers use the `.seal` suffix and are interpreted directly by runseal. -On Unix, shell wrappers use the `.sh` suffix and must be executable. +.seal wrapper names are reserved for the new Seal interpreter/runtime. Execution +is unavailable until that runtime lands. On Unix, shell wrappers use the `.sh` +suffix and must be executable. Extensionless files in `.runseal/wrappers` are not wrapper entrypoints; migrate legacy wrappers to `<name>.seal` or `<name>.sh`. On Windows, runseal also checks `.exe`, `.cmd`, and `.bat` when the wrapper name has no extension. ### Seal wrappers -`.seal` files are bash-runnable wrapper glue. They are meant for -cross-platform repository operations where the bash/PowerShell shared shape is -clear. The boundary is syntax shape, not script size: - -- ordinary command execution, assignment, functions, `if`, `while`, `case`, - `shift`, `"$@"`, command success predicates such as - `if git checkout "$branch"; then`, and command-scoped env overlays such as - `KUBECONFIG="$kubeconfig" kubectl "$@"` -- bash `[ ... ]` tests for ordinary predicates -- explicit `runseal @tool ...` calls for atomic glue where bash and PowerShell - do not share a clean expression - -Use `.seal` as the profile integration layer: it should pass caller-specific -paths, env names, and defaults explicitly. Keep reusable domain atoms in -`@tool`, such as SSH config inspection, stdin script execution, path-list -joining, branch slugging, Gitee PR API calls, and encrypted local archive -round trips. For example, a wrapper can expose `:ssh <host> --run <script>` -while `runseal @tool ssh script run` owns the stdin, argv forwarding, and host -config details. - -For example, a repo-local `:kube` wrapper can stay pure `.seal` by pushing file -enumeration and path joining into atomic tools: +The old bash-shaped `.seal` transpiler has been removed. New `.seal` files will +target the modern Seal language and interpreter described in +[docs/spec/seal-language.md](./docs/spec/seal-language.md). -```bash -kube_dir=${PERISH_TOP_KUBE_DIR:?kube: missing PERISH_TOP_KUBE_DIR} -configs=$(runseal @tool fs list "$kube_dir" --glob "*.yaml" --files --require-nonempty) -kubeconfig=$(runseal @tool string join "$configs" --separator path) -KUBECONFIG="$kubeconfig" kubectl "$@" -``` - -Prefer visible repo or local artifacts under `.runseal/` or `.local/` for -multi-line config and payload text. The wrapper should usually validate -preconditions, choose files, assemble arguments, and invoke the operational -flow, rather than build inline heredoc-style configuration. - -Runseal interprets `.seal` wrappers directly when called as `runseal :name`. -Use `runseal @transpile --input-lang=seal --output-lang=bash <file>` or -`--output-lang=powershell` to inspect generated targets. - -Seal is not intended to become a general scripting language. If a workflow -wants richer parsing, data structures, or platform-specific behavior, move that -part to Python, Ruby, JavaScript, etc. and call it from the wrapper. +Until that interpreter/runtime lands, `runseal :name` resolves `.seal` wrappers +but refuses to execute them with a clear error. Use `.sh`, `.cmd`, `.bat`, or +external commands for current operational flows. ## Internal Commands @@ -315,7 +280,6 @@ command instead of a literal program name: runseal @profile runseal @resources runseal @resolve resource:// resource://ssh/config -runseal @transpile --input-lang=seal --output-lang=sealir ./operator.seal runseal @wrappers runseal @which :ssh ``` @@ -328,10 +292,6 @@ read-only; `@tool` is the explicit atomic tool runtime. - `@resources` prints the resolved resource root. - `@resolve resource://...` prints resolved absolute resource paths, one per argument. -- `@transpile --input-lang=<lang> --output-lang=<lang> <source>` transpiles - explicit glue languages and prints the generated output. Cold start supports - `bash`, `seal`, `powershell`, and `sealir` inputs and outputs for the - currently recognized intersection. - `@tool <namespace> <command> ...` runs an atomic runseal tool command. Cold start supports JSON, string, regex, integer, process, filesystem, archive, SSH config, GitHub, Gitee, and Cloudflare helpers. Run `runseal @tool --help` diff --git a/app/src/bin/runseal.rs b/app/src/bin/runseal.rs index 589a96b..6ee6f45 100644 --- a/app/src/bin/runseal.rs +++ b/app/src/bin/runseal.rs @@ -7,7 +7,6 @@ use runseal::core::app::AppState; use runseal::core::config::{CliInput, RawEnv, RuntimeConfig}; use runseal::core::internal_help; use runseal::core::tool; -use runseal::core::transpile; use runseal::run; #[derive(Debug, Parser)] @@ -25,22 +24,19 @@ Runseal commands: @profile print resolved runtime paths @resources print the resolved resource root @resolve <uri>... resolve resource:// paths - @transpile transpile explicit input/output glue languages @tool run an atomic runseal tool command @wrappers list visible wrappers @which :<name> print a wrapper path Seal wrappers: - .seal files are bash-runnable wrapper glue interpreted directly by runseal. - Use ordinary commands for shared behavior and runseal @tool for atomic glue - where bash and PowerShell do not share a clean expression. - Run runseal @transpile --help for Seal code-generation support. + .seal files are reserved for the new Seal interpreter/runtime. Legacy + transpilation has been removed. Profile discovery walks from the current directory upward for runseal.toml|yaml|yml|json, then falls back to $RUNSEAL_PROFILE_HOME/default.toml|yaml|yml|json. -Run runseal @profile --help, @resolve --help, @transpile --help, @tool --help, -@wrappers --help, or @which --help for details. +Run runseal @profile --help, @resolve --help, @tool --help, @wrappers --help, +or @which --help for details. Repository: https://github.com/PerishCode/runseal" )] @@ -90,12 +86,6 @@ fn build_runtime_config(cli: Cli) -> Result<RuntimeConfig> { fn run_early_internal(command: &[String]) -> Result<bool> { match command.first().map(String::as_str) { - Some("@transpile") => { - let options = transpile::parse_args(&command[1..])?; - let output = transpile::transpile_file(&options)?; - print!("{output}"); - Ok(true) - } Some("@tool") => { tool::run(&command[1..])?; Ok(true) diff --git a/app/src/core/internal_help.rs b/app/src/core/internal_help.rs index 3154772..fd3b08e 100644 --- a/app/src/core/internal_help.rs +++ b/app/src/core/internal_help.rs @@ -13,7 +13,6 @@ fn text(name: &str) -> Result<&'static str> { "resolve" => Ok(RESOLVE), "resources" => Ok(RESOURCES), "tool" => Ok(crate::core::tool::help()), - "transpile" => Ok(TRANSPILE), "wrappers" => Ok(WRAPPERS), "which" => Ok(WHICH), _ => bail!("unknown internal command: @{name}"), @@ -70,46 +69,6 @@ Invalid resource paths include empty segments, '.', '..', backslashes, and ':' i path segments. Resolved paths are printed even when the target file does not exist. "; -const TRANSPILE: &str = "\ -Usage: runseal @transpile --input-lang=<lang> --output-lang=<lang> <source> - -Transpile one explicit glue language into another and print the result to stdout. - -Languages: - seal bash-runnable Seal wrapper glue - sealir JSON SealIR semantic form - bash bash output target - powershell PowerShell output target - -Seal source is intentionally a constrained bash subset. Prefer ordinary bash -syntax for control flow, argv parsing, tests, shift, and command execution. Use -runseal @tool as explicit glue for atomic behavior that does not have a clean -bash/PowerShell intersection. If a workflow wants a richer language, move that -part to Python, Ruby, JavaScript, etc. instead of expanding Seal. - -Cold-start supported paths: - bash -> sealir - bash -> seal - bash -> powershell - seal -> sealir - seal -> bash - seal -> powershell - powershell -> sealir - powershell -> seal - powershell -> bash - sealir -> seal - sealir -> bash - sealir -> powershell - -Examples: - runseal @transpile --input-lang=seal --output-lang=bash manage.seal - runseal @transpile --input-lang=seal --output-lang=powershell manage.seal - runseal @transpile --input-lang=seal --output-lang=sealir manage.seal - -@transpile is explicit code generation only. It does not infer languages, write -files, execute generated code, or run profile injections. -"; - const WRAPPERS: &str = "\ Usage: runseal @wrappers @@ -122,19 +81,15 @@ Lookup order: 4. $RUNSEAL_HOME/wrappers/<name>.sh Profile-local wrappers shadow home wrappers with the same name. On Unix, wrapper -shell files use the .sh suffix and must be executable. Seal wrappers use the -.seal suffix and are interpreted directly by runseal. Extensionless files in a +shell files use the .sh suffix and must be executable. Seal wrapper names reserve +the .seal suffix for the new interpreter/runtime. Extensionless files in a wrappers directory are not wrapper entrypoints; migrate legacy wrappers to <name>.seal or <name>.sh. On Windows, runseal also checks .exe, .cmd, and .bat when the wrapper name has no extension. -.seal wrappers are bash-runnable wrapper glue. They are intended for -cross-platform repository operations where bash and PowerShell share a clear -shape: shell-shaped control flow, command success predicates, command-scoped env -overlays, and explicit runseal @tool calls for atomic glue. - -The boundary is syntax shape, not script size. Keep reusable domain atoms in -@tool and pass profile-specific paths or env names from the wrapper. +.seal wrapper execution is unavailable until the new Seal interpreter/runtime +lands. Use .sh/.cmd/.bat wrappers or external commands for current operational +flows. @wrappers is read-only and does not run profile injections. "; diff --git a/app/src/core/mod.rs b/app/src/core/mod.rs index 8f83d76..b269d4d 100644 --- a/app/src/core/mod.rs +++ b/app/src/core/mod.rs @@ -5,5 +5,5 @@ pub mod injections; pub mod internal_help; pub mod profile; pub mod runtime; +pub mod seal; pub mod tool; -pub mod transpile; diff --git a/app/src/core/runtime.rs b/app/src/core/runtime.rs index 33f1d0a..b63543e 100644 --- a/app/src/core/runtime.rs +++ b/app/src/core/runtime.rs @@ -7,7 +7,7 @@ use super::config::RuntimeConfig; use super::env_key::is_valid_env_key; use super::internal_help; use super::profile::InjectionProfile; -use super::{injections, profile, transpile}; +use super::{injections, profile}; mod wrapper_paths; @@ -283,8 +283,11 @@ fn run_command( if let Some(wrapper) = &resolved.wrapper && wrapper_paths::is_seal(&wrapper.file) { - let env = run_env(config, resolved, exports)?; - return transpile::run_seal_file(&wrapper.file, &resolved.argv[1..], &env); + bail!( + ".seal wrapper execution is unavailable while the new Seal interpreter/runtime is being implemented: :{} ({})", + wrapper.name, + wrapper.file.display() + ); } let mut child = child_command(resolved); diff --git a/app/src/core/seal/ast.rs b/app/src/core/seal/ast.rs new file mode 100644 index 0000000..7662c4b --- /dev/null +++ b/app/src/core/seal/ast.rs @@ -0,0 +1,262 @@ +use super::span::Span; + +pub type CommentId = usize; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Comment { + pub span: Span, + pub text: String, + pub kind: CommentKind, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CommentKind { + Line, + Block, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct SourceFile { + pub items: Vec<RawItem>, + pub comments: Vec<Comment>, + pub span: Span, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RawItem { + pub kind: RawItemKind, + pub span: Span, + pub leading_comments: Vec<CommentId>, + pub trailing_comments: Vec<CommentId>, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum RawItemKind { + Method(RawMethod), + Statement(RawStatement), + Comment(CommentId), + Error, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RawMethod { + pub name: String, + pub params: Vec<RawParam>, + pub body: RawBlock, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RawParam { + pub name: String, + pub default: Option<RawExpr>, + pub span: Span, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RawBlock { + pub items: Vec<RawItem>, + pub span: Span, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RawStatement { + pub kind: RawStatementKind, + pub span: Span, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum RawStatementKind { + Let { + name: String, + binding: LetBinding, + value: RawExpr, + }, + Assign { + target: RawExpr, + value: RawExpr, + }, + If { + branches: Vec<RawIfBranch>, + else_branch: Option<RawBlock>, + }, + For { + binding: String, + iterable: RawExpr, + body: RawBlock, + }, + While { + condition: RawExpr, + body: RawBlock, + }, + WithEnv { + bindings: Vec<RawEnvBinding>, + body: RawBlock, + }, + Expr(RawExpr), + Effect(RawExpr), + Break, + Continue, + Error, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LetBinding { + Value, + Stream, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RawIfBranch { + pub condition: RawExpr, + pub body: RawBlock, + pub span: Span, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RawEnvBinding { + pub name: String, + pub value: RawExpr, + pub span: Span, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RawExpr { + pub kind: RawExprKind, + pub span: Span, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum RawExprKind { + Ident(String), + Literal(RawLiteral), + AtName(Vec<String>), + Env(String), + Channel(String), + Array(Vec<RawExpr>), + Map(Vec<RawMapEntry>), + Call { + callee: Box<RawExpr>, + args: Vec<RawArg>, + }, + ReceiverCall { + receiver: Box<RawExpr>, + method: String, + args: Vec<RawArg>, + }, + Unary { + op: UnaryOp, + expr: Box<RawExpr>, + }, + Binary { + op: BinaryOp, + left: Box<RawExpr>, + right: Box<RawExpr>, + }, + Process(RawProcess), + StreamFlow { + op: StreamOp, + left: Box<RawExpr>, + right: Box<RawExpr>, + }, + Group(Box<RawExpr>), + Error, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum RawLiteral { + String(String), + TextBlock(String), + Int(String), + Bool(bool), + Null, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RawMapEntry { + pub key: String, + pub value: RawExpr, + pub span: Span, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RawArg { + pub label: Option<String>, + pub value: RawExpr, + pub span: Span, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RawProcess { + pub program: Option<RawProcessArg>, + pub args: Vec<RawProcessArg>, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RawProcessArg { + pub kind: RawProcessArgKind, + pub span: Span, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum RawProcessArgKind { + Word(Vec<RawProcessPart>), + String(String), + TextBlock(String), + Spread(Box<RawExpr>), + Error, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum RawProcessPart { + Text(String), + Interpolation(RawExpr), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UnaryOp { + Not, + Neg, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BinaryOp { + Multiply, + Divide, + Remainder, + Add, + Subtract, + Less, + LessEq, + Greater, + GreaterEq, + In, + Eq, + NotEq, + And, + Or, + NullCoalesce, +} + +impl BinaryOp { + pub fn is_comparison(self) -> bool { + matches!( + self, + Self::Less | Self::LessEq | Self::Greater | Self::GreaterEq | Self::In + ) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StreamOp { + To, + From, +} + +impl RawExpr { + pub fn error(span: Span) -> Self { + Self { + kind: RawExprKind::Error, + span, + } + } +} diff --git a/app/src/core/seal/diag.rs b/app/src/core/seal/diag.rs new file mode 100644 index 0000000..62f6662 --- /dev/null +++ b/app/src/core/seal/diag.rs @@ -0,0 +1,16 @@ +use super::span::Span; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Diagnostic { + pub span: Span, + pub message: String, +} + +impl Diagnostic { + pub fn new(span: Span, message: impl Into<String>) -> Self { + Self { + span, + message: message.into(), + } + } +} diff --git a/app/src/core/seal/ground.rs b/app/src/core/seal/ground.rs new file mode 100644 index 0000000..d2f2007 --- /dev/null +++ b/app/src/core/seal/ground.rs @@ -0,0 +1,232 @@ +use super::{ + ast::{RawExpr, RawExprKind, RawItemKind, RawStatementKind, SourceFile}, + diag::Diagnostic, + span::Span, +}; + +#[derive(Debug, Clone, PartialEq)] +pub struct GroundOutput { + pub file: GroundFile, + pub diagnostics: Vec<Diagnostic>, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct GroundFile { + pub nodes: Vec<GroundNode>, + pub span: Span, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum GroundNode { + Method { name: String, span: Span }, + Let { name: String, span: Span }, + Expr { span: Span }, + Effect { span: Span }, + Error { span: Span }, +} + +pub fn ground(file: &SourceFile) -> GroundOutput { + let mut diagnostics = Vec::new(); + let mut nodes = Vec::new(); + + for item in &file.items { + match &item.kind { + RawItemKind::Comment(_) => {} + RawItemKind::Method(method) => { + nodes.push(GroundNode::Method { + name: method.name.clone(), + span: item.span, + }); + } + RawItemKind::Statement(statement) => { + nodes.push(ground_statement(statement, &mut diagnostics)); + } + RawItemKind::Error => nodes.push(GroundNode::Error { span: item.span }), + } + } + + GroundOutput { + file: GroundFile { + nodes, + span: file.span, + }, + diagnostics, + } +} + +fn ground_statement( + statement: &super::ast::RawStatement, + diagnostics: &mut Vec<Diagnostic>, +) -> GroundNode { + match &statement.kind { + RawStatementKind::Let { name, value, .. } => { + reject_comparison_chain(value, diagnostics); + GroundNode::Let { + name: name.clone(), + span: statement.span, + } + } + RawStatementKind::Assign { target, value } => { + reject_comparison_chain(target, diagnostics); + reject_comparison_chain(value, diagnostics); + GroundNode::Expr { + span: statement.span, + } + } + RawStatementKind::If { .. } + | RawStatementKind::For { .. } + | RawStatementKind::While { .. } + | RawStatementKind::WithEnv { .. } => { + reject_statement_comparison_chains(statement, diagnostics); + GroundNode::Expr { + span: statement.span, + } + } + RawStatementKind::Effect(expr) => { + reject_comparison_chain(expr, diagnostics); + GroundNode::Effect { + span: statement.span, + } + } + RawStatementKind::Expr(expr) => { + reject_comparison_chain(expr, diagnostics); + GroundNode::Expr { + span: statement.span, + } + } + RawStatementKind::Break | RawStatementKind::Continue => GroundNode::Expr { + span: statement.span, + }, + RawStatementKind::Error => GroundNode::Error { + span: statement.span, + }, + } +} + +fn reject_statement_comparison_chains( + statement: &super::ast::RawStatement, + diagnostics: &mut Vec<Diagnostic>, +) { + match &statement.kind { + RawStatementKind::Let { value, .. } => reject_comparison_chain(value, diagnostics), + RawStatementKind::Assign { target, value } => { + reject_comparison_chain(target, diagnostics); + reject_comparison_chain(value, diagnostics); + } + RawStatementKind::Expr(expr) | RawStatementKind::Effect(expr) => { + reject_comparison_chain(expr, diagnostics); + } + RawStatementKind::If { + branches, + else_branch, + } => { + for branch in branches { + reject_comparison_chain(&branch.condition, diagnostics); + for item in &branch.body.items { + if let RawItemKind::Statement(nested) = &item.kind { + reject_statement_comparison_chains(nested, diagnostics); + } + } + } + if let Some(block) = else_branch { + for item in &block.items { + if let RawItemKind::Statement(nested) = &item.kind { + reject_statement_comparison_chains(nested, diagnostics); + } + } + } + } + RawStatementKind::For { iterable, body, .. } => { + reject_comparison_chain(iterable, diagnostics); + for item in &body.items { + if let RawItemKind::Statement(nested) = &item.kind { + reject_statement_comparison_chains(nested, diagnostics); + } + } + } + RawStatementKind::While { condition, body } => { + reject_comparison_chain(condition, diagnostics); + for item in &body.items { + if let RawItemKind::Statement(nested) = &item.kind { + reject_statement_comparison_chains(nested, diagnostics); + } + } + } + RawStatementKind::WithEnv { bindings, body } => { + for binding in bindings { + reject_comparison_chain(&binding.value, diagnostics); + } + for item in &body.items { + if let RawItemKind::Statement(nested) = &item.kind { + reject_statement_comparison_chains(nested, diagnostics); + } + } + } + RawStatementKind::Break | RawStatementKind::Continue | RawStatementKind::Error => {} + } +} + +fn reject_comparison_chain(expr: &RawExpr, diagnostics: &mut Vec<Diagnostic>) { + match &expr.kind { + RawExprKind::Binary { op, left, right } => { + if op.is_comparison() + && (matches!(&left.kind, RawExprKind::Binary { op: left_op, .. } if left_op.is_comparison()) + || matches!(&right.kind, RawExprKind::Binary { op: right_op, .. } if right_op.is_comparison())) + { + diagnostics.push(Diagnostic::new( + expr.span, + "comparison operators cannot be chained", + )); + } + reject_comparison_chain(left, diagnostics); + reject_comparison_chain(right, diagnostics); + } + RawExprKind::Unary { expr, .. } | RawExprKind::Group(expr) => { + reject_comparison_chain(expr, diagnostics); + } + RawExprKind::Call { callee, args } => { + reject_comparison_chain(callee, diagnostics); + for arg in args { + reject_comparison_chain(&arg.value, diagnostics); + } + } + RawExprKind::ReceiverCall { receiver, args, .. } => { + reject_comparison_chain(receiver, diagnostics); + for arg in args { + reject_comparison_chain(&arg.value, diagnostics); + } + } + RawExprKind::Array(items) => { + for item in items { + reject_comparison_chain(item, diagnostics); + } + } + RawExprKind::Map(entries) => { + for entry in entries { + reject_comparison_chain(&entry.value, diagnostics); + } + } + RawExprKind::StreamFlow { left, right, .. } => { + reject_comparison_chain(left, diagnostics); + reject_comparison_chain(right, diagnostics); + } + RawExprKind::Process(process) => { + for arg in process.program.iter().chain(process.args.iter()) { + match &arg.kind { + super::ast::RawProcessArgKind::Spread(expr) => { + reject_comparison_chain(expr, diagnostics); + } + super::ast::RawProcessArgKind::Word(parts) => { + for part in parts { + if let super::ast::RawProcessPart::Interpolation(expr) = part { + reject_comparison_chain(expr, diagnostics); + } + } + } + _ => {} + } + } + } + _ => {} + } +} diff --git a/app/src/core/seal/lexer.rs b/app/src/core/seal/lexer.rs new file mode 100644 index 0000000..a10702a --- /dev/null +++ b/app/src/core/seal/lexer.rs @@ -0,0 +1,368 @@ +use super::{ + diag::Diagnostic, + span::Span, + token::{Keyword, Token, TokenKind, Trivia, TriviaKind}, +}; + +#[derive(Debug, Clone)] +pub struct LexOutput { + pub tokens: Vec<Token>, + pub diagnostics: Vec<Diagnostic>, +} + +pub fn lex(source: &str) -> LexOutput { + Lexer::new(source).lex() +} + +struct Lexer<'a> { + source: &'a str, + pos: usize, + tokens: Vec<Token>, + diagnostics: Vec<Diagnostic>, + trivia_boundary: bool, +} + +impl<'a> Lexer<'a> { + fn new(source: &'a str) -> Self { + Self { + source, + pos: 0, + tokens: Vec::new(), + diagnostics: Vec::new(), + trivia_boundary: true, + } + } + + fn lex(mut self) -> LexOutput { + while !self.is_eof() { + let leading = self.collect_leading_trivia(); + let start = self.pos; + let Some(ch) = self.peek_char() else { + break; + }; + + if ch == '\n' { + self.bump_char(); + self.push(TokenKind::Newline, start, self.pos, leading); + self.trivia_boundary = true; + continue; + } + if ch == '\r' { + self.bump_char(); + if self.peek_char() == Some('\n') { + self.bump_char(); + } + self.push(TokenKind::Newline, start, self.pos, leading); + self.trivia_boundary = true; + continue; + } + + if is_ident_start(ch) { + self.lex_ident_or_keyword(start, leading); + self.trivia_boundary = false; + continue; + } + if ch.is_ascii_digit() { + self.lex_number(start, leading); + self.trivia_boundary = false; + continue; + } + + let kind = match ch { + '"' => { + self.lex_string(start, leading); + self.trivia_boundary = false; + continue; + } + '`' => { + self.lex_text_block(start, leading); + self.trivia_boundary = false; + continue; + } + '@' => single(&mut self, TokenKind::At), + '$' => single(&mut self, TokenKind::Dollar), + '#' => single(&mut self, TokenKind::Hash), + '|' => { + if self.starts_with("||") { + double(&mut self, TokenKind::OrOr) + } else { + single(&mut self, TokenKind::Pipe) + } + } + '>' => { + if self.starts_with(">>") { + double(&mut self, TokenKind::ShiftRight) + } else if self.starts_with(">=") { + double(&mut self, TokenKind::GreaterEq) + } else { + single(&mut self, TokenKind::Greater) + } + } + '<' => { + if self.starts_with("<<") { + double(&mut self, TokenKind::ShiftLeft) + } else if self.starts_with("<=") { + double(&mut self, TokenKind::LessEq) + } else { + single(&mut self, TokenKind::Less) + } + } + '&' if self.starts_with("&&") => double(&mut self, TokenKind::AndAnd), + '=' => { + if self.starts_with("=>") { + double(&mut self, TokenKind::FatArrow) + } else if self.starts_with("==") { + double(&mut self, TokenKind::EqEq) + } else { + single(&mut self, TokenKind::Eq) + } + } + ':' => { + if self.starts_with(":=") { + double(&mut self, TokenKind::ColonEq) + } else { + single(&mut self, TokenKind::Colon) + } + } + '!' => { + if self.starts_with("!=") { + double(&mut self, TokenKind::BangEq) + } else { + single(&mut self, TokenKind::Bang) + } + } + '?' => { + if self.starts_with("??") { + double(&mut self, TokenKind::QuestionQuestion) + } else { + single(&mut self, TokenKind::Question) + } + } + '+' => single(&mut self, TokenKind::Plus), + '-' => single(&mut self, TokenKind::Minus), + '*' => single(&mut self, TokenKind::Star), + '/' => single(&mut self, TokenKind::Slash), + '%' => single(&mut self, TokenKind::Percent), + '.' => single(&mut self, TokenKind::Dot), + ',' => single(&mut self, TokenKind::Comma), + ';' => single(&mut self, TokenKind::Semicolon), + '(' => single(&mut self, TokenKind::LParen), + ')' => single(&mut self, TokenKind::RParen), + '{' => single(&mut self, TokenKind::LBrace), + '}' => single(&mut self, TokenKind::RBrace), + '[' => single(&mut self, TokenKind::LBracket), + ']' => single(&mut self, TokenKind::RBracket), + '_' => single(&mut self, TokenKind::Underscore), + _ => { + self.bump_char(); + self.diagnostics.push(Diagnostic::new( + Span::new(start, self.pos), + format!("unexpected character {ch:?}"), + )); + TokenKind::Unknown + } + }; + + self.push(kind, start, self.pos, leading); + self.trivia_boundary = false; + } + + let leading = self.collect_leading_trivia(); + self.tokens.push(Token::new( + TokenKind::Eof, + "", + Span::empty(self.pos), + leading, + )); + + LexOutput { + tokens: self.tokens, + diagnostics: self.diagnostics, + } + } + + fn collect_leading_trivia(&mut self) -> Vec<Trivia> { + let mut trivia = Vec::new(); + loop { + let start = self.pos; + let Some(ch) = self.peek_char() else { + break; + }; + if matches!(ch, ' ' | '\t') { + while matches!(self.peek_char(), Some(' ' | '\t')) { + self.bump_char(); + } + trivia.push(Trivia::new( + TriviaKind::Whitespace, + &self.source[start..self.pos], + Span::new(start, self.pos), + )); + self.trivia_boundary = true; + continue; + } + if self.trivia_boundary && self.starts_with("//") { + self.pos += 2; + while let Some(next) = self.peek_char() { + if matches!(next, '\n' | '\r') { + break; + } + self.bump_char(); + } + trivia.push(Trivia::new( + TriviaKind::LineComment, + &self.source[start..self.pos], + Span::new(start, self.pos), + )); + self.trivia_boundary = true; + continue; + } + if self.trivia_boundary && self.starts_with("/*") { + self.pos += 2; + while !self.is_eof() && !self.starts_with("*/") { + self.bump_char(); + } + if self.starts_with("*/") { + self.pos += 2; + } else { + self.diagnostics.push(Diagnostic::new( + Span::new(start, self.pos), + "unterminated block comment", + )); + } + trivia.push(Trivia::new( + TriviaKind::BlockComment, + &self.source[start..self.pos], + Span::new(start, self.pos), + )); + self.trivia_boundary = true; + continue; + } + break; + } + trivia + } + + fn lex_ident_or_keyword(&mut self, start: usize, leading: Vec<Trivia>) { + self.bump_char(); + while matches!(self.peek_char(), Some(ch) if is_ident_continue(ch)) { + self.bump_char(); + } + let text = &self.source[start..self.pos]; + let kind = match text { + "method" => TokenKind::Keyword(Keyword::Method), + "let" => TokenKind::Keyword(Keyword::Let), + "if" => TokenKind::Keyword(Keyword::If), + "else" => TokenKind::Keyword(Keyword::Else), + "match" => TokenKind::Keyword(Keyword::Match), + "for" => TokenKind::Keyword(Keyword::For), + "in" => TokenKind::Keyword(Keyword::In), + "while" => TokenKind::Keyword(Keyword::While), + "break" => TokenKind::Keyword(Keyword::Break), + "continue" => TokenKind::Keyword(Keyword::Continue), + "with" => TokenKind::Keyword(Keyword::With), + "env" => TokenKind::Keyword(Keyword::Env), + "true" => TokenKind::Keyword(Keyword::True), + "false" => TokenKind::Keyword(Keyword::False), + "null" => TokenKind::Keyword(Keyword::Null), + _ => TokenKind::Ident, + }; + self.push(kind, start, self.pos, leading); + } + + fn lex_number(&mut self, start: usize, leading: Vec<Trivia>) { + self.bump_char(); + while matches!(self.peek_char(), Some(ch) if ch.is_ascii_digit()) { + self.bump_char(); + } + self.push(TokenKind::Number, start, self.pos, leading); + } + + fn lex_string(&mut self, start: usize, leading: Vec<Trivia>) { + self.bump_char(); + let mut escaped = false; + while let Some(ch) = self.peek_char() { + self.bump_char(); + if escaped { + escaped = false; + continue; + } + if ch == '\\' { + escaped = true; + continue; + } + if ch == '"' { + self.push(TokenKind::String, start, self.pos, leading); + return; + } + if matches!(ch, '\n' | '\r') { + break; + } + } + self.diagnostics.push(Diagnostic::new( + Span::new(start, self.pos), + "unterminated string literal", + )); + self.push(TokenKind::String, start, self.pos, leading); + } + + fn lex_text_block(&mut self, start: usize, leading: Vec<Trivia>) { + self.bump_char(); + while let Some(ch) = self.peek_char() { + self.bump_char(); + if ch == '`' { + self.push(TokenKind::TextBlock, start, self.pos, leading); + return; + } + } + self.diagnostics.push(Diagnostic::new( + Span::new(start, self.pos), + "unterminated text block", + )); + self.push(TokenKind::TextBlock, start, self.pos, leading); + } + + fn push(&mut self, kind: TokenKind, start: usize, end: usize, leading: Vec<Trivia>) { + self.tokens.push(Token::new( + kind, + &self.source[start..end], + Span::new(start, end), + leading, + )); + } + + fn starts_with(&self, needle: &str) -> bool { + self.source[self.pos..].starts_with(needle) + } + + fn peek_char(&self) -> Option<char> { + self.source[self.pos..].chars().next() + } + + fn bump_char(&mut self) -> Option<char> { + let ch = self.peek_char()?; + self.pos += ch.len_utf8(); + Some(ch) + } + + fn is_eof(&self) -> bool { + self.pos >= self.source.len() + } +} + +fn single(lexer: &mut Lexer<'_>, kind: TokenKind) -> TokenKind { + lexer.bump_char(); + kind +} + +fn double(lexer: &mut Lexer<'_>, kind: TokenKind) -> TokenKind { + lexer.pos += 2; + kind +} + +fn is_ident_start(ch: char) -> bool { + ch.is_ascii_alphabetic() +} + +fn is_ident_continue(ch: char) -> bool { + ch.is_ascii_alphanumeric() || ch == '_' +} diff --git a/app/src/core/seal/mod.rs b/app/src/core/seal/mod.rs new file mode 100644 index 0000000..3ae1345 --- /dev/null +++ b/app/src/core/seal/mod.rs @@ -0,0 +1,10 @@ +pub mod ast; +pub mod diag; +pub mod ground; +pub mod lexer; +pub mod parser; +pub mod span; +pub mod token; + +pub use lexer::lex; +pub use parser::parse; diff --git a/app/src/core/seal/parser/expr.rs b/app/src/core/seal/parser/expr.rs new file mode 100644 index 0000000..2430d50 --- /dev/null +++ b/app/src/core/seal/parser/expr.rs @@ -0,0 +1,288 @@ +use super::*; + +impl Parser { + pub(super) fn parse_expr(&mut self) -> RawExpr { + self.parse_expr_bp(0) + } + + fn parse_expr_bp(&mut self, min_bp: u8) -> RawExpr { + let mut left = self.parse_prefix_expr(); + while let Some((op, left_bp, right_bp)) = self.current_binary_op() { + if left_bp < min_bp { + break; + } + self.bump(); + let right = self.parse_expr_bp(right_bp); + let span = left.span.join(right.span); + left = RawExpr { + span, + kind: RawExprKind::Binary { + op, + left: Box::new(left), + right: Box::new(right), + }, + }; + } + left + } + + fn parse_prefix_expr(&mut self) -> RawExpr { + if self.at(TokenKind::Bang) || self.at(TokenKind::Minus) { + let token = self.bump(); + let op = if token.kind == TokenKind::Bang { + UnaryOp::Not + } else { + UnaryOp::Neg + }; + let expr = self.parse_expr_bp(11); + return RawExpr { + span: token.span.join(expr.span), + kind: RawExprKind::Unary { + op, + expr: Box::new(expr), + }, + }; + } + self.parse_postfix_expr() + } + + pub(super) fn parse_postfix_expr(&mut self) -> RawExpr { + let mut expr = self.parse_primary_expr(); + loop { + if self.at(TokenKind::LParen) { + let args = self.parse_call_args(); + let span = expr + .span + .join(args.last().map_or(self.previous().span, |arg| arg.span)); + expr = RawExpr { + span, + kind: RawExprKind::Call { + callee: Box::new(expr), + args, + }, + }; + continue; + } + if self.at(TokenKind::Dot) && self.peek_kind(1) == Some(&TokenKind::Ident) { + self.bump(); + let method = self.bump(); + if self.at(TokenKind::LParen) { + let args = self.parse_call_args(); + let span = expr + .span + .join(args.last().map_or(method.span, |arg| arg.span)); + expr = RawExpr { + span, + kind: RawExprKind::ReceiverCall { + receiver: Box::new(expr), + method: method.text, + args, + }, + }; + } else { + let span = expr.span.join(method.span); + expr = RawExpr { + span, + kind: RawExprKind::ReceiverCall { + receiver: Box::new(expr), + method: method.text, + args: Vec::new(), + }, + }; + } + continue; + } + break; + } + expr + } + + fn parse_primary_expr(&mut self) -> RawExpr { + let token = self.current().clone(); + match token.kind { + TokenKind::Ident => { + self.bump(); + RawExpr { + span: token.span, + kind: RawExprKind::Ident(token.text), + } + } + TokenKind::Number => { + self.bump(); + RawExpr { + span: token.span, + kind: RawExprKind::Literal(RawLiteral::Int(token.text)), + } + } + TokenKind::String => { + self.bump(); + RawExpr { + span: token.span, + kind: RawExprKind::Literal(RawLiteral::String(token.text)), + } + } + TokenKind::TextBlock => { + self.bump(); + RawExpr { + span: token.span, + kind: RawExprKind::Literal(RawLiteral::TextBlock(token.text)), + } + } + TokenKind::Keyword(Keyword::True) | TokenKind::Keyword(Keyword::False) => { + self.bump(); + RawExpr { + span: token.span, + kind: RawExprKind::Literal(RawLiteral::Bool( + token.kind == TokenKind::Keyword(Keyword::True), + )), + } + } + TokenKind::Keyword(Keyword::Null) => { + self.bump(); + RawExpr { + span: token.span, + kind: RawExprKind::Literal(RawLiteral::Null), + } + } + TokenKind::At => self.parse_at_name(), + TokenKind::Dollar => self.parse_prefixed_name(TokenKind::Dollar), + TokenKind::Hash => self.parse_prefixed_name(TokenKind::Hash), + TokenKind::Pipe => self.parse_process(), + TokenKind::LParen => self.parse_group(), + TokenKind::LBracket => self.parse_array(), + TokenKind::LBrace => self.parse_map(), + _ => { + self.diagnostics.push(Diagnostic::new( + token.span, + format!("expected expression, found {:?}", token.kind), + )); + if !self.at_statement_boundary() { + self.bump(); + } + RawExpr::error(token.span) + } + } + } + + fn parse_at_name(&mut self) -> RawExpr { + let start = self.expect(TokenKind::At, "expected '@'").span; + let mut parts = Vec::new(); + parts.push(self.expect_ident("expected identifier after '@'")); + while self.eat(TokenKind::Dot).is_some() { + parts.push(self.expect_ident("expected identifier after '.'")); + } + let end = self.previous().span; + RawExpr { + span: start.join(end), + kind: RawExprKind::AtName(parts), + } + } + + fn parse_prefixed_name(&mut self, prefix: TokenKind) -> RawExpr { + let start = self.expect(prefix.clone(), "expected prefix").span; + let name = self.expect_ident("expected identifier after prefix"); + let span = start.join(self.previous().span); + let kind = if prefix == TokenKind::Dollar { + RawExprKind::Env(name) + } else { + RawExprKind::Channel(name) + }; + RawExpr { span, kind } + } + + fn parse_group(&mut self) -> RawExpr { + let open = self.expect(TokenKind::LParen, "expected '('").span; + self.consume_soft_separators(); + let expr = self.parse_expr(); + self.consume_soft_separators(); + let close = self.expect(TokenKind::RParen, "expected ')' after expression"); + RawExpr { + span: open.join(close.span), + kind: RawExprKind::Group(Box::new(expr)), + } + } + + fn parse_array(&mut self) -> RawExpr { + let open = self.expect(TokenKind::LBracket, "expected '['").span; + let mut items = Vec::new(); + self.consume_soft_separators(); + while !self.at(TokenKind::RBracket) && !self.at(TokenKind::Eof) { + items.push(self.parse_expr()); + self.consume_soft_separators(); + if self.eat(TokenKind::Comma).is_none() { + break; + } + self.consume_soft_separators(); + } + let close = self.expect(TokenKind::RBracket, "expected ']' after array"); + RawExpr { + span: open.join(close.span), + kind: RawExprKind::Array(items), + } + } + + fn parse_map(&mut self) -> RawExpr { + let open = self.expect(TokenKind::LBrace, "expected '{'").span; + let mut entries = Vec::new(); + self.consume_soft_separators(); + while !self.at(TokenKind::RBrace) && !self.at(TokenKind::Eof) { + let key_token = self.current().clone(); + let key = match key_token.kind { + TokenKind::Ident | TokenKind::String => { + self.bump(); + key_token.text + } + _ => { + self.diagnostics + .push(Diagnostic::new(key_token.span, "expected map key")); + self.recover_until_expr_boundary(); + break; + } + }; + self.expect(TokenKind::Colon, "expected ':' after map key"); + let value = self.parse_expr(); + let span = key_token.span.join(value.span); + entries.push(RawMapEntry { key, value, span }); + self.consume_soft_separators(); + if self.eat(TokenKind::Comma).is_none() { + break; + } + self.consume_soft_separators(); + } + let close = self.expect(TokenKind::RBrace, "expected '}' after map"); + RawExpr { + span: open.join(close.span), + kind: RawExprKind::Map(entries), + } + } + + fn parse_call_args(&mut self) -> Vec<RawArg> { + self.expect(TokenKind::LParen, "expected '(' before arguments"); + let mut args = Vec::new(); + self.consume_soft_separators(); + while !self.at(TokenKind::RParen) && !self.at(TokenKind::Eof) { + let start = self.current().span; + let label = if self.at(TokenKind::Ident) && self.peek_kind(1) == Some(&TokenKind::Colon) + { + let label = self.bump().text; + self.bump(); + Some(label) + } else { + None + }; + let value = self.parse_expr(); + args.push(RawArg { + label, + span: start.join(value.span), + value, + }); + self.consume_soft_separators(); + if self.eat(TokenKind::Comma).is_none() { + break; + } + self.consume_soft_separators(); + } + self.expect(TokenKind::RParen, "expected ')' after arguments"); + args + } +} diff --git a/app/src/core/seal/parser/mod.rs b/app/src/core/seal/parser/mod.rs new file mode 100644 index 0000000..6d08cd1 --- /dev/null +++ b/app/src/core/seal/parser/mod.rs @@ -0,0 +1,305 @@ +use super::{ + ast::*, + diag::Diagnostic, + lexer, + span::Span, + token::{Keyword, Token, TokenKind, TriviaKind}, +}; + +mod expr; +mod process; +mod statement; + +#[derive(Debug, Clone)] +pub struct ParseOutput { + pub file: SourceFile, + pub diagnostics: Vec<Diagnostic>, +} + +pub fn parse(source: &str) -> ParseOutput { + let lexed = lexer::lex(source); + let mut parser = Parser::new(lexed.tokens, lexed.diagnostics); + parser.parse_source_file() +} + +struct Parser { + tokens: Vec<Token>, + cursor: usize, + diagnostics: Vec<Diagnostic>, + comments: Vec<Comment>, +} + +impl Parser { + fn new(tokens: Vec<Token>, diagnostics: Vec<Diagnostic>) -> Self { + Self { + tokens, + cursor: 0, + diagnostics, + comments: Vec::new(), + } + } + + fn parse_source_file(&mut self) -> ParseOutput { + let start = self.current().span.start; + let mut items = Vec::new(); + self.consume_separators_as_items(&mut items); + while !self.at(TokenKind::Eof) { + let mut item = self.parse_item_or_recover(); + item.trailing_comments + .extend(self.consume_trailing_comments()); + items.push(item); + self.consume_separators_as_items(&mut items); + } + let end = self.current().span.end; + ParseOutput { + file: SourceFile { + items, + comments: std::mem::take(&mut self.comments), + span: Span::new(start, end), + }, + diagnostics: std::mem::take(&mut self.diagnostics), + } + } + + fn parse_item_or_recover(&mut self) -> RawItem { + let leading_comments = self.take_leading_comments(); + if self.at_keyword(Keyword::Method) { + return self.parse_method(leading_comments); + } + let statement = self.parse_statement(); + RawItem { + span: statement.span, + kind: RawItemKind::Statement(statement), + leading_comments, + trailing_comments: Vec::new(), + } + } + + fn parse_method(&mut self, leading_comments: Vec<CommentId>) -> RawItem { + let start = self + .expect(TokenKind::Keyword(Keyword::Method), "expected method") + .span; + let name = self.expect_ident("expected method name"); + self.expect(TokenKind::LParen, "expected '(' after method name"); + let mut params = Vec::new(); + while !self.at(TokenKind::RParen) && !self.at(TokenKind::Eof) { + let param_start = self.current().span; + let name = self.expect_ident("expected parameter name"); + let default = if self.eat(TokenKind::Eq).is_some() { + Some(self.parse_expr()) + } else { + None + }; + let end = default + .as_ref() + .map_or(param_start.end, |expr| expr.span.end); + params.push(RawParam { + name, + default, + span: Span::new(param_start.start, end), + }); + if self.eat(TokenKind::Comma).is_none() { + break; + } + } + self.expect(TokenKind::RParen, "expected ')' after method parameters"); + let body = self.parse_block(); + let span = start.join(body.span); + RawItem { + span, + kind: RawItemKind::Method(RawMethod { name, params, body }), + leading_comments, + trailing_comments: Vec::new(), + } + } + + fn parse_block(&mut self) -> RawBlock { + let open = self.expect(TokenKind::LBrace, "expected '{' before block"); + let mut items = Vec::new(); + self.consume_separators_as_items(&mut items); + while !self.at(TokenKind::RBrace) && !self.at(TokenKind::Eof) { + let mut item = self.parse_item_or_recover(); + item.trailing_comments + .extend(self.consume_trailing_comments()); + items.push(item); + self.consume_separators_as_items(&mut items); + } + let close = self.expect(TokenKind::RBrace, "expected '}' after block"); + RawBlock { + items, + span: open.span.join(close.span), + } + } + fn current_binary_op(&self) -> Option<(BinaryOp, u8, u8)> { + let op = match self.current().kind { + TokenKind::Star => (BinaryOp::Multiply, 10, 11), + TokenKind::Slash => (BinaryOp::Divide, 10, 11), + TokenKind::Percent => (BinaryOp::Remainder, 10, 11), + TokenKind::Plus => (BinaryOp::Add, 9, 10), + TokenKind::Minus => (BinaryOp::Subtract, 9, 10), + TokenKind::Less => (BinaryOp::Less, 8, 9), + TokenKind::LessEq => (BinaryOp::LessEq, 8, 9), + TokenKind::Greater => (BinaryOp::Greater, 8, 9), + TokenKind::GreaterEq => (BinaryOp::GreaterEq, 8, 9), + TokenKind::Keyword(Keyword::In) => (BinaryOp::In, 8, 9), + TokenKind::EqEq => (BinaryOp::Eq, 7, 8), + TokenKind::BangEq => (BinaryOp::NotEq, 7, 8), + TokenKind::AndAnd => (BinaryOp::And, 6, 7), + TokenKind::OrOr => (BinaryOp::Or, 5, 6), + TokenKind::QuestionQuestion => (BinaryOp::NullCoalesce, 4, 5), + _ => return None, + }; + Some(op) + } + + fn consume_separators_as_items(&mut self, items: &mut Vec<RawItem>) { + while self.at(TokenKind::Newline) || self.at(TokenKind::Semicolon) { + let token = self.bump(); + for comment in self.comments_from_token(&token) { + items.push(RawItem { + span: self.comments[comment].span, + kind: RawItemKind::Comment(comment), + leading_comments: Vec::new(), + trailing_comments: Vec::new(), + }); + } + } + } + + fn consume_trailing_comments(&mut self) -> Vec<CommentId> { + let mut comments = Vec::new(); + while self.at(TokenKind::Newline) || self.at(TokenKind::Semicolon) { + let token = self.bump(); + comments.extend(self.comments_from_token(&token)); + if token.kind == TokenKind::Newline { + break; + } + } + comments + } + + fn consume_soft_separators(&mut self) { + while self.at(TokenKind::Newline) || self.at(TokenKind::Semicolon) { + self.bump(); + } + } + + fn take_leading_comments(&mut self) -> Vec<CommentId> { + let token = self.current().clone(); + self.comments_from_token(&token) + } + + fn comments_from_token(&mut self, token: &Token) -> Vec<CommentId> { + let mut ids = Vec::new(); + for trivia in token.leading_comments() { + let kind = match trivia.kind { + TriviaKind::LineComment => CommentKind::Line, + TriviaKind::BlockComment => CommentKind::Block, + TriviaKind::Whitespace => continue, + }; + let id = self.comments.len(); + self.comments.push(Comment { + span: trivia.span, + text: trivia.text.clone(), + kind, + }); + ids.push(id); + } + ids + } + + fn recover_until_expr_boundary(&mut self) { + while !self.at(TokenKind::Eof) + && !self.at(TokenKind::Newline) + && !self.at(TokenKind::Semicolon) + && !self.at(TokenKind::Comma) + && !self.at(TokenKind::RParen) + && !self.at(TokenKind::RBracket) + && !self.at(TokenKind::RBrace) + { + self.bump(); + } + } + + fn at_statement_boundary(&self) -> bool { + matches!( + self.current().kind, + TokenKind::Eof | TokenKind::Newline | TokenKind::Semicolon | TokenKind::RBrace + ) + } + + fn at_process_boundary(&self) -> bool { + matches!( + self.current().kind, + TokenKind::Eof + | TokenKind::Newline + | TokenKind::Semicolon + | TokenKind::RBrace + | TokenKind::ShiftRight + | TokenKind::ShiftLeft + ) + } + + fn expect_ident(&mut self, message: &str) -> String { + if self.at(TokenKind::Ident) { + self.bump().text + } else { + let token = self.current().clone(); + self.diagnostics.push(Diagnostic::new(token.span, message)); + String::new() + } + } + + fn expect(&mut self, kind: TokenKind, message: &str) -> Token { + if self.current().kind == kind { + self.bump() + } else { + let token = self.current().clone(); + self.diagnostics.push(Diagnostic::new(token.span, message)); + Token { + kind, + text: String::new(), + span: Span::empty(token.span.start), + leading: Vec::new(), + } + } + } + + fn eat(&mut self, kind: TokenKind) -> Option<Token> { + if self.at(kind) { + Some(self.bump()) + } else { + None + } + } + + fn at(&self, kind: TokenKind) -> bool { + self.current().kind == kind + } + + fn at_keyword(&self, keyword: Keyword) -> bool { + self.current().kind == TokenKind::Keyword(keyword) + } + + fn current(&self) -> &Token { + &self.tokens[self.cursor] + } + + fn previous(&self) -> &Token { + &self.tokens[self.cursor.saturating_sub(1)] + } + + fn peek_kind(&self, offset: usize) -> Option<&TokenKind> { + self.tokens + .get(self.cursor + offset) + .map(|token| &token.kind) + } + + fn bump(&mut self) -> Token { + let token = self.current().clone(); + if token.kind != TokenKind::Eof { + self.cursor += 1; + } + token + } +} diff --git a/app/src/core/seal/parser/process.rs b/app/src/core/seal/parser/process.rs new file mode 100644 index 0000000..0c1c178 --- /dev/null +++ b/app/src/core/seal/parser/process.rs @@ -0,0 +1,138 @@ +use super::*; + +impl Parser { + pub(super) fn parse_process(&mut self) -> RawExpr { + let start = self + .expect(TokenKind::Pipe, "expected process marker '|'") + .span; + if !self.at_process_boundary() && !self.current().has_leading_whitespace() { + self.diagnostics.push(Diagnostic::new( + self.current().span, + "expected whitespace after process marker '|'", + )); + } + let mut args = Vec::new(); + while !self.at_process_boundary() { + if self.current().has_leading_whitespace() && !args.is_empty() { + // Whitespace separates argv atoms; the next loop iteration parses + // the next argument from the current token. + } + args.push(self.parse_process_arg()); + } + let end = args.last().map_or(start, |arg| arg.span); + if args.is_empty() { + self.diagnostics.push(Diagnostic::new( + start, + "expected command word after process marker '|'", + )); + } + let mut iter = args.into_iter(); + let program = iter.next(); + RawExpr { + span: start.join(end), + kind: RawExprKind::Process(RawProcess { + program, + args: iter.collect(), + }), + } + } + + pub(super) fn parse_process_arg(&mut self) -> RawProcessArg { + let token = self.current().clone(); + match token.kind { + TokenKind::Star => self.parse_process_spread(), + TokenKind::String => { + self.bump(); + RawProcessArg { + span: token.span, + kind: RawProcessArgKind::String(token.text), + } + } + TokenKind::TextBlock => { + self.bump(); + RawProcessArg { + span: token.span, + kind: RawProcessArgKind::TextBlock(token.text), + } + } + _ => self.parse_process_word(), + } + } + + pub(super) fn parse_process_spread(&mut self) -> RawProcessArg { + let star = self.expect(TokenKind::Star, "expected '*'"); + let expr = if self.at(TokenKind::LBrace) { + self.bump(); + let expr = self.parse_expr(); + self.expect(TokenKind::RBrace, "expected '}' after process spread"); + expr + } else { + self.parse_postfix_expr() + }; + RawProcessArg { + span: star.span.join(expr.span), + kind: RawProcessArgKind::Spread(Box::new(expr)), + } + } + + pub(super) fn parse_process_word(&mut self) -> RawProcessArg { + let start = self.current().span; + let mut span = start; + let mut parts = Vec::new(); + let mut text = String::new(); + let mut first = true; + + while !self.at_process_boundary() { + if !first && self.current().has_leading_whitespace() { + break; + } + let token = self.current().clone(); + match token.kind { + TokenKind::String + | TokenKind::TextBlock + | TokenKind::LParen + | TokenKind::RParen + | TokenKind::LBracket + | TokenKind::RBracket => break, + TokenKind::LBrace => { + if !text.is_empty() { + parts.push(RawProcessPart::Text(std::mem::take(&mut text))); + } + self.bump(); + let expr = self.parse_expr(); + self.expect(TokenKind::RBrace, "expected '}' after argv interpolation"); + span = span.join(expr.span); + parts.push(RawProcessPart::Interpolation(expr)); + } + _ => { + text.push_str(&token.text); + span = span.join(token.span); + self.bump(); + } + } + first = false; + } + + if !text.is_empty() { + parts.push(RawProcessPart::Text(text)); + } + if parts.is_empty() { + self.diagnostics.push(Diagnostic::new( + self.current().span, + "expected process argv word", + )); + if !self.at_process_boundary() { + let bad = self.bump(); + return RawProcessArg { + span: bad.span, + kind: RawProcessArgKind::Error, + }; + } + } + + RawProcessArg { + span, + kind: RawProcessArgKind::Word(parts), + } + } +} diff --git a/app/src/core/seal/parser/statement.rs b/app/src/core/seal/parser/statement.rs new file mode 100644 index 0000000..db3aeca --- /dev/null +++ b/app/src/core/seal/parser/statement.rs @@ -0,0 +1,230 @@ +use super::*; + +impl Parser { + pub(super) fn parse_statement(&mut self) -> RawStatement { + if self.at_keyword(Keyword::Let) { + return self.parse_let_statement(); + } + if self.at_keyword(Keyword::If) { + return self.parse_if_statement(); + } + if self.at_keyword(Keyword::For) { + return self.parse_for_statement(); + } + if self.at_keyword(Keyword::While) { + return self.parse_while_statement(); + } + if self.at_keyword(Keyword::With) { + return self.parse_with_env_statement(); + } + if self.at_keyword(Keyword::Break) { + let token = self.bump(); + return RawStatement { + span: token.span, + kind: RawStatementKind::Break, + }; + } + if self.at_keyword(Keyword::Continue) { + let token = self.bump(); + return RawStatement { + span: token.span, + kind: RawStatementKind::Continue, + }; + } + + let expr = self.parse_stream_expr(); + if self.at(TokenKind::Eq) { + let eq = self.bump(); + let value = self.parse_stream_expr(); + let span = expr.span.join(value.span).join(eq.span); + return RawStatement { + span, + kind: RawStatementKind::Assign { + target: expr, + value, + }, + }; + } + let kind = if matches!( + expr.kind, + RawExprKind::Process(_) | RawExprKind::StreamFlow { .. } + ) { + RawStatementKind::Effect(expr) + } else { + RawStatementKind::Expr(expr) + }; + let span = match &kind { + RawStatementKind::Effect(expr) | RawStatementKind::Expr(expr) => expr.span, + _ => Span::empty(self.current().span.start), + }; + RawStatement { kind, span } + } + + fn parse_let_statement(&mut self) -> RawStatement { + let start = self + .expect(TokenKind::Keyword(Keyword::Let), "expected let") + .span; + let name = self.expect_ident("expected binding name"); + let binding = if self.eat(TokenKind::ColonEq).is_some() { + LetBinding::Stream + } else { + self.expect(TokenKind::Eq, "expected '=' or ':=' after binding name"); + LetBinding::Value + }; + let value = self.parse_stream_expr(); + let span = start.join(value.span); + RawStatement { + span, + kind: RawStatementKind::Let { + name, + binding, + value, + }, + } + } + + fn parse_if_statement(&mut self) -> RawStatement { + let start = self + .expect(TokenKind::Keyword(Keyword::If), "expected if") + .span; + let condition = self.parse_expr(); + let body = self.parse_block(); + let mut span = start.join(body.span); + let mut branches = vec![RawIfBranch { + span, + condition, + body, + }]; + let mut else_branch = None; + + while self.at_keyword(Keyword::Else) { + self.bump(); + if self.at_keyword(Keyword::If) { + self.bump(); + let condition = self.parse_expr(); + let body = self.parse_block(); + span = span.join(body.span); + branches.push(RawIfBranch { + span: condition.span.join(body.span), + condition, + body, + }); + } else { + let body = self.parse_block(); + span = span.join(body.span); + else_branch = Some(body); + break; + } + } + + RawStatement { + span, + kind: RawStatementKind::If { + branches, + else_branch, + }, + } + } + + fn parse_for_statement(&mut self) -> RawStatement { + let start = self + .expect(TokenKind::Keyword(Keyword::For), "expected for") + .span; + let binding = self.expect_ident("expected for binding name"); + self.expect( + TokenKind::Keyword(Keyword::In), + "expected 'in' after for binding", + ); + let iterable = self.parse_expr(); + let body = self.parse_block(); + RawStatement { + span: start.join(body.span), + kind: RawStatementKind::For { + binding, + iterable, + body, + }, + } + } + + fn parse_while_statement(&mut self) -> RawStatement { + let start = self + .expect(TokenKind::Keyword(Keyword::While), "expected while") + .span; + let condition = self.parse_expr(); + let body = self.parse_block(); + RawStatement { + span: start.join(body.span), + kind: RawStatementKind::While { condition, body }, + } + } + + fn parse_with_env_statement(&mut self) -> RawStatement { + let start = self + .expect(TokenKind::Keyword(Keyword::With), "expected with") + .span; + self.expect( + TokenKind::Keyword(Keyword::Env), + "expected 'env' after with", + ); + let bindings = self.parse_env_bindings(); + let body = self.parse_block(); + RawStatement { + span: start.join(body.span), + kind: RawStatementKind::WithEnv { bindings, body }, + } + } + + fn parse_env_bindings(&mut self) -> Vec<RawEnvBinding> { + self.expect(TokenKind::LBrace, "expected '{' before env bindings"); + let mut bindings = Vec::new(); + self.consume_soft_separators(); + while !self.at(TokenKind::RBrace) && !self.at(TokenKind::Eof) { + let start = self.current().span; + let name = self.expect_ident("expected env binding name"); + self.expect(TokenKind::Eq, "expected '=' after env binding name"); + let value = self.parse_expr(); + bindings.push(RawEnvBinding { + name, + span: start.join(value.span), + value, + }); + self.consume_soft_separators(); + self.eat(TokenKind::Comma); + self.consume_soft_separators(); + } + self.expect(TokenKind::RBrace, "expected '}' after env bindings"); + bindings + } + + fn parse_stream_expr(&mut self) -> RawExpr { + let mut left = self.parse_effect_atom(); + while self.at(TokenKind::ShiftRight) || self.at(TokenKind::ShiftLeft) { + let token = self.bump(); + let op = if token.kind == TokenKind::ShiftRight { + StreamOp::To + } else { + StreamOp::From + }; + self.consume_soft_separators(); + let right = self.parse_effect_atom(); + let span = left.span.join(right.span); + left = RawExpr { + span, + kind: RawExprKind::StreamFlow { + op, + left: Box::new(left), + right: Box::new(right), + }, + }; + } + left + } + + fn parse_effect_atom(&mut self) -> RawExpr { + if self.at(TokenKind::Pipe) { + return self.parse_process(); + } + self.parse_expr() + } +} diff --git a/app/src/core/seal/span.rs b/app/src/core/seal/span.rs new file mode 100644 index 0000000..1a55e56 --- /dev/null +++ b/app/src/core/seal/span.rs @@ -0,0 +1,31 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Span { + pub start: usize, + pub end: usize, +} + +impl Span { + pub fn new(start: usize, end: usize) -> Self { + Self { start, end } + } + + pub fn empty(offset: usize) -> Self { + Self { + start: offset, + end: offset, + } + } + + pub fn join(self, other: Span) -> Self { + if self.start == self.end { + return other; + } + if other.start == other.end { + return self; + } + Self { + start: self.start.min(other.start), + end: self.end.max(other.end), + } + } +} diff --git a/app/src/core/seal/token.rs b/app/src/core/seal/token.rs new file mode 100644 index 0000000..9793ebc --- /dev/null +++ b/app/src/core/seal/token.rs @@ -0,0 +1,128 @@ +use super::span::Span; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TriviaKind { + Whitespace, + LineComment, + BlockComment, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Trivia { + pub kind: TriviaKind, + pub text: String, + pub span: Span, +} + +impl Trivia { + pub fn new(kind: TriviaKind, text: impl Into<String>, span: Span) -> Self { + Self { + kind, + text: text.into(), + span, + } + } + + pub fn is_comment(&self) -> bool { + matches!( + self.kind, + TriviaKind::LineComment | TriviaKind::BlockComment + ) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Keyword { + Method, + Let, + If, + Else, + Match, + For, + In, + While, + Break, + Continue, + With, + Env, + True, + False, + Null, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TokenKind { + Eof, + Newline, + Ident, + Number, + String, + TextBlock, + Keyword(Keyword), + At, + Dollar, + Hash, + Pipe, + ShiftRight, + ShiftLeft, + OrOr, + AndAnd, + FatArrow, + ColonEq, + EqEq, + BangEq, + LessEq, + GreaterEq, + QuestionQuestion, + Plus, + Minus, + Star, + Slash, + Percent, + Bang, + Eq, + Less, + Greater, + Question, + Dot, + Comma, + Colon, + Semicolon, + LParen, + RParen, + LBrace, + RBrace, + LBracket, + RBracket, + Underscore, + Unknown, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Token { + pub kind: TokenKind, + pub text: String, + pub span: Span, + pub leading: Vec<Trivia>, +} + +impl Token { + pub fn new(kind: TokenKind, text: impl Into<String>, span: Span, leading: Vec<Trivia>) -> Self { + Self { + kind, + text: text.into(), + span, + leading, + } + } + + pub fn has_leading_whitespace(&self) -> bool { + self.leading + .iter() + .any(|trivia| matches!(trivia.kind, TriviaKind::Whitespace)) + } + + pub fn leading_comments(&self) -> impl Iterator<Item = &Trivia> { + self.leading.iter().filter(|trivia| trivia.is_comment()) + } +} diff --git a/app/src/core/transpile/ast.rs b/app/src/core/transpile/ast.rs deleted file mode 100644 index d883232..0000000 --- a/app/src/core/transpile/ast.rs +++ /dev/null @@ -1,174 +0,0 @@ -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct Program { - pub version: u32, - pub items: Vec<Item>, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum Item { - Function { name: String, body: Vec<Statement> }, - Statement { statement: Statement }, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum Statement { - Assign { - name: String, - value: Value, - }, - ExecWrite { - stream: OutputStream, - path: Value, - append: bool, - argv: Vec<Value>, - }, - ExecChecked { - argv: Vec<Value>, - }, - EnvExecChecked { - env: Vec<EnvAssign>, - argv: Vec<Value>, - }, - Shift { - count: usize, - }, - ArgvParse { - specs: Vec<ArgvSpec>, - positional: Option<ArgvPositional>, - }, - CaptureChecked { - name: String, - argv: Vec<Value>, - }, - CaptureFunction { - name: String, - function: String, - argv: Vec<Value>, - }, - If { - predicate: Predicate, - then_body: Vec<Statement>, - else_body: Vec<Statement>, - }, - While { - predicate: Predicate, - body: Vec<Statement>, - }, - Case { - value: Value, - arms: Vec<CaseArm>, - }, - CallFunction { - name: String, - argv: Vec<Value>, - }, - Print { - value: Value, - }, - Error { - value: Value, - }, - Fail { - value: Value, - }, - Exit { - code: i32, - }, - Break, - Sleep { - seconds: u64, - }, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct CaseArm { - pub patterns: Vec<String>, - pub body: Vec<Statement>, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct ArgvSpec { - pub name: String, - pub kind: ArgvKind, - pub default: Option<String>, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct ArgvPositional { - pub name: String, - pub default: String, - pub extra_error: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct EnvAssign { - pub name: String, - pub value: Value, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum ArgvKind { - String, - Flag, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum Value { - Literal { - text: String, - }, - Argc, - Args, - Expand { - source: ValueSource, - op: ExpansionOp, - }, - Concat { - parts: Vec<Value>, - }, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum ValueSource { - Var { name: String }, - Env { name: String }, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum ExpansionOp { - Plain, - DefaultIfUnsetOrEmpty { fallback: String }, - RequireNonEmpty { message: String }, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum Predicate { - Command { argv: Vec<Value> }, - Empty { value: Value }, - NotEmpty { value: Value }, - Eq { left: Value, right: Value }, - Neq { left: Value, right: Value }, - IntLt { left: Value, right: Value }, - IntLte { left: Value, right: Value }, - IntGt { left: Value, right: Value }, - IntGte { left: Value, right: Value }, - JsonEmpty { value: Value }, - JsonNotEmpty { value: Value }, - FileExists { path: Value }, - DirExists { path: Value }, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum OutputStream { - Stdout, - Stderr, -} -use serde::{Deserialize, Serialize}; diff --git a/app/src/core/transpile/emit/mod.rs b/app/src/core/transpile/emit/mod.rs deleted file mode 100644 index a47950b..0000000 --- a/app/src/core/transpile/emit/mod.rs +++ /dev/null @@ -1,490 +0,0 @@ -use super::ast::{ArgvKind, ArgvPositional, ArgvSpec, Item, OutputStream, Program, Statement}; -use super::guards::{bash_required_tools, emit_bash_guards}; - -mod powershell; -mod powershell_support; -mod support; - -pub(crate) use powershell::emit_powershell; -use support::{ - bash_predicate, bash_value, generated_header, join_values, option_name, seal_value, sh_quote, -}; - -pub(crate) fn emit_seal(program: &Program) -> String { - let mut out = String::new(); - for item in &program.items { - match item { - Item::Function { name, body } => { - out.push_str(name); - out.push_str("() {\n"); - emit_seal_statements(&mut out, body, 1); - out.push_str("}\n"); - } - Item::Statement { statement } => emit_seal_statement(&mut out, statement, 0), - } - } - out -} - -fn emit_seal_statements(out: &mut String, statements: &[Statement], indent: usize) { - for statement in statements { - emit_seal_statement(out, statement, indent); - } -} - -fn emit_seal_statement(out: &mut String, statement: &Statement, indent: usize) { - let pad = " ".repeat(indent); - match statement { - Statement::Assign { name, value } => { - out.push_str(&format!("{pad}{name}={}\n", seal_value(value))); - } - Statement::ExecChecked { argv } => { - out.push_str(&pad); - out.push_str(&join_values(argv, seal_value)); - out.push('\n'); - } - Statement::ExecWrite { - stream, - path, - append, - argv, - } => { - out.push_str(&pad); - out.push_str(&join_values(argv, seal_value)); - out.push(' '); - out.push_str(match (stream, append) { - (OutputStream::Stdout, false) => ">", - (OutputStream::Stdout, true) => ">>", - (OutputStream::Stderr, false) => "2>", - (OutputStream::Stderr, true) => "2>>", - }); - out.push(' '); - out.push_str(&seal_value(path)); - out.push('\n'); - } - Statement::EnvExecChecked { env, argv } => { - out.push_str(&pad); - for item in env { - out.push_str(&item.name); - out.push('='); - out.push_str(&seal_value(&item.value)); - out.push(' '); - } - out.push_str(&join_values(argv, seal_value)); - out.push('\n'); - } - Statement::Shift { count } => { - out.push_str(&pad); - out.push_str("shift"); - if *count != 1 { - out.push(' '); - out.push_str(&count.to_string()); - } - out.push('\n'); - } - Statement::ArgvParse { specs, positional } => { - emit_seal_argv_parse(out, specs, positional.as_ref(), indent); - } - Statement::CaptureChecked { name, argv } => { - out.push_str(&pad); - out.push_str(name); - out.push_str("=$("); - out.push_str(&join_values(argv, seal_value)); - out.push_str(")\n"); - } - Statement::CaptureFunction { - name, - function, - argv, - } => { - out.push_str(&pad); - out.push_str(name); - out.push_str("=$("); - out.push_str(function); - if !argv.is_empty() { - out.push(' '); - out.push_str(&join_values(argv, seal_value)); - } - out.push_str(")\n"); - } - Statement::If { - predicate, - then_body, - else_body, - } => { - out.push_str(&format!("{pad}if {}; then\n", bash_predicate(predicate))); - emit_seal_statements(out, then_body, indent + 1); - if !else_body.is_empty() { - out.push_str(&format!("{pad}else\n")); - emit_seal_statements(out, else_body, indent + 1); - } - out.push_str(&format!("{pad}fi\n")); - } - Statement::While { predicate, body } => { - out.push_str(&format!("{pad}while {}; do\n", bash_predicate(predicate))); - emit_seal_statements(out, body, indent + 1); - out.push_str(&format!("{pad}done\n")); - } - Statement::Case { value, arms } => { - out.push_str(&format!("{pad}case {} in\n", seal_value(value))); - for arm in arms { - out.push_str(&format!("{pad} {})\n", arm.patterns.join("|"))); - emit_seal_statements(out, &arm.body, indent + 2); - out.push_str(&format!("{pad} ;;\n")); - } - out.push_str(&format!("{pad}esac\n")); - } - Statement::CallFunction { name, argv } => { - out.push_str(&pad); - out.push_str(name); - if !argv.is_empty() { - out.push(' '); - out.push_str(&join_values(argv, seal_value)); - } - out.push('\n'); - } - Statement::Print { value } => out.push_str(&format!("{pad}print {}\n", seal_value(value))), - Statement::Error { value } => out.push_str(&format!("{pad}error {}\n", seal_value(value))), - Statement::Fail { value } => out.push_str(&format!("{pad}fail {}\n", seal_value(value))), - Statement::Exit { code } => out.push_str(&format!("{pad}exit {code}\n")), - Statement::Break => out.push_str(&format!("{pad}break\n")), - Statement::Sleep { seconds } => out.push_str(&format!("{pad}sleep {seconds}\n")), - } -} - -fn emit_seal_argv_parse( - out: &mut String, - specs: &[ArgvSpec], - positional: Option<&ArgvPositional>, - indent: usize, -) { - let pad = " ".repeat(indent); - out.push_str(&format!("{pad}__seal_argc=$#\n")); - out.push_str(&format!("{pad}__seal_help=false\n")); - for spec in specs { - let value = match spec.kind { - ArgvKind::String => spec.default.as_deref().unwrap_or(""), - ArgvKind::Flag => "false", - }; - out.push_str(&format!("{pad}{}={value}\n", spec.name)); - } - if let Some(positional) = positional { - out.push_str(&format!( - "{pad}{}={}\n", - positional.name, positional.default - )); - } - out.push_str(&format!("{pad}while [ \"$#\" -gt 0 ]; do\n")); - out.push_str(&format!("{pad} case \"$1\" in\n")); - for spec in specs { - match spec.kind { - ArgvKind::String => emit_seal_string_option(out, spec, indent), - ArgvKind::Flag => emit_seal_flag_option(out, spec, indent), - } - } - out.push_str(&format!("{pad} --)\n")); - out.push_str(&format!("{pad} shift\n")); - out.push_str(&format!("{pad} break\n")); - out.push_str(&format!("{pad} ;;\n")); - out.push_str(&format!("{pad} -h|--help|help)\n")); - out.push_str(&format!("{pad} __seal_help=true\n")); - out.push_str(&format!("{pad} shift\n")); - out.push_str(&format!("{pad} ;;\n")); - if let Some(positional) = positional { - out.push_str(&format!("{pad} *)\n")); - out.push_str(&format!( - "{pad} if [ -z \"${}\" ]; then\n", - positional.name - )); - out.push_str(&format!("{pad} {}=$1\n", positional.name)); - out.push_str(&format!("{pad} shift\n")); - out.push_str(&format!("{pad} else\n")); - out.push_str(&format!( - "{pad} fail \"{}\"\n", - positional.extra_error - )); - out.push_str(&format!("{pad} fi\n")); - out.push_str(&format!("{pad} ;;\n")); - } else { - out.push_str(&format!("{pad} *) fail \"unknown option: $1\" ;;\n")); - } - out.push_str(&format!("{pad} esac\n")); - out.push_str(&format!("{pad}done\n")); -} - -fn emit_seal_string_option(out: &mut String, spec: &ArgvSpec, indent: usize) { - let pad = " ".repeat(indent); - let option = option_name(&spec.name); - out.push_str(&format!("{pad} {option})\n")); - out.push_str(&format!( - "{pad} if [ \"$#\" -lt 2 ]; then fail 'missing value for {option}'; fi\n" - )); - out.push_str(&format!("{pad} {}=$2\n", spec.name)); - out.push_str(&format!("{pad} shift 2\n")); - out.push_str(&format!("{pad} ;;\n")); - out.push_str(&format!("{pad} {option}=*)\n")); - out.push_str(&format!("{pad} {}=${{1#{option}=}}\n", spec.name)); - out.push_str(&format!("{pad} shift\n")); - out.push_str(&format!("{pad} ;;\n")); -} - -fn emit_seal_flag_option(out: &mut String, spec: &ArgvSpec, indent: usize) { - let pad = " ".repeat(indent); - let option = option_name(&spec.name); - out.push_str(&format!("{pad} {option})\n")); - out.push_str(&format!("{pad} {}=true\n", spec.name)); - out.push_str(&format!("{pad} shift\n")); - out.push_str(&format!("{pad} ;;\n")); -} - -pub(crate) fn emit_bash(program: &Program, source_name: Option<&str>) -> String { - let mut out = generated_header("bash", source_name); - out.push_str("set -euo pipefail\n\n"); - out.push_str("seal_fail() {\n printf '%s\\n' \"$1\" >&2\n exit 1\n}\n\n"); - emit_bash_guards(&mut out, &bash_required_tools(program)); - emit_bash_items(&mut out, program); - out -} - -fn emit_bash_items(out: &mut String, program: &Program) { - for item in &program.items { - match item { - Item::Function { name, body } => { - out.push_str(name); - out.push_str("() {\n"); - emit_bash_body(out, body, 1); - out.push_str("}\n\n"); - } - Item::Statement { statement } => emit_bash_statement(out, statement, 0), - } - } -} - -fn emit_bash_body(out: &mut String, statements: &[Statement], indent: usize) { - if statements.is_empty() { - let pad = " ".repeat(indent); - out.push_str(&format!("{pad}:\n")); - } else { - emit_bash_statements(out, statements, indent); - } -} - -fn emit_bash_statements(out: &mut String, statements: &[Statement], indent: usize) { - for statement in statements { - emit_bash_statement(out, statement, indent); - } -} - -fn emit_bash_statement(out: &mut String, statement: &Statement, indent: usize) { - let pad = " ".repeat(indent); - match statement { - Statement::Assign { name, value } => { - out.push_str(&format!("{pad}{name}={}\n", bash_value(value))); - } - Statement::ExecWrite { - stream, - path, - append, - argv, - } => { - out.push_str(&pad); - out.push_str(&join_values(argv, bash_value)); - out.push(' '); - out.push_str(match (stream, append) { - (OutputStream::Stdout, false) => ">", - (OutputStream::Stdout, true) => ">>", - (OutputStream::Stderr, false) => "2>", - (OutputStream::Stderr, true) => "2>>", - }); - out.push(' '); - out.push_str(&bash_value(path)); - out.push('\n'); - } - Statement::ExecChecked { argv } => { - out.push_str(&pad); - out.push_str(&join_values(argv, bash_value)); - out.push('\n'); - } - Statement::EnvExecChecked { env, argv } => { - out.push_str(&pad); - for item in env { - out.push_str(&item.name); - out.push('='); - out.push_str(&bash_value(&item.value)); - out.push(' '); - } - out.push_str(&join_values(argv, bash_value)); - out.push('\n'); - } - Statement::Shift { count } => { - out.push_str(&pad); - out.push_str("shift"); - if *count != 1 { - out.push(' '); - out.push_str(&count.to_string()); - } - out.push('\n'); - } - Statement::ArgvParse { specs, positional } => { - emit_bash_argv_parse(out, specs, positional.as_ref(), indent) - } - Statement::CaptureChecked { name, argv } => { - out.push_str(&pad); - out.push_str(name); - out.push_str("=$("); - out.push_str(&join_values(argv, bash_value)); - out.push_str(")\n"); - } - Statement::CaptureFunction { - name, - function, - argv, - } => { - out.push_str(&pad); - out.push_str(name); - out.push_str("=$("); - out.push_str(function); - if !argv.is_empty() { - out.push(' '); - out.push_str(&join_values(argv, bash_value)); - } - out.push_str(")\n"); - } - Statement::If { - predicate, - then_body, - else_body, - } => { - out.push_str(&format!("{pad}if {}; then\n", bash_predicate(predicate))); - emit_bash_body(out, then_body, indent + 1); - if !else_body.is_empty() { - out.push_str(&format!("{pad}else\n")); - emit_bash_body(out, else_body, indent + 1); - } - out.push_str(&format!("{pad}fi\n")); - } - Statement::While { predicate, body } => { - out.push_str(&format!("{pad}while {}; do\n", bash_predicate(predicate))); - emit_bash_body(out, body, indent + 1); - out.push_str(&format!("{pad}done\n")); - } - Statement::Case { value, arms } => { - out.push_str(&format!("{pad}case {} in\n", bash_value(value))); - for arm in arms { - out.push_str(&format!("{pad} {})\n", arm.patterns.join("|"))); - emit_bash_body(out, &arm.body, indent + 2); - out.push_str(&format!("{pad} ;;\n")); - } - out.push_str(&format!("{pad}esac\n")); - } - Statement::CallFunction { name, argv } => { - out.push_str(&pad); - out.push_str(name); - if !argv.is_empty() { - out.push(' '); - out.push_str(&join_values(argv, bash_value)); - } - out.push('\n'); - } - Statement::Print { value } => { - out.push_str(&format!("{pad}printf '%s\\n' {}\n", bash_value(value))); - } - Statement::Error { value } => { - out.push_str(&format!("{pad}printf '%s\\n' {} >&2\n", bash_value(value))); - } - Statement::Fail { value } => { - out.push_str(&format!("{pad}seal_fail {}\n", bash_value(value))); - } - Statement::Exit { code } => out.push_str(&format!("{pad}exit {code}\n")), - Statement::Break => out.push_str(&format!("{pad}break\n")), - Statement::Sleep { seconds } => out.push_str(&format!("{pad}sleep {seconds}\n")), - } -} - -fn emit_bash_argv_parse( - out: &mut String, - specs: &[ArgvSpec], - positional: Option<&ArgvPositional>, - indent: usize, -) { - let pad = " ".repeat(indent); - out.push_str(&format!("{pad}__seal_argc=$#\n")); - out.push_str(&format!("{pad}__seal_help=false\n")); - for spec in specs { - let value = match spec.kind { - ArgvKind::String => sh_quote(spec.default.as_deref().unwrap_or("")), - ArgvKind::Flag => "false".to_string(), - }; - out.push_str(&format!("{pad}{}={value}\n", spec.name)); - } - if let Some(positional) = positional { - out.push_str(&format!( - "{pad}{}={}\n", - positional.name, - sh_quote(&positional.default) - )); - } - out.push_str(&format!("{pad}while [ \"$#\" -gt 0 ]; do\n")); - out.push_str(&format!("{pad} case \"$1\" in\n")); - for spec in specs { - match spec.kind { - ArgvKind::String => emit_bash_string_option(out, spec, indent), - ArgvKind::Flag => emit_bash_flag_option(out, spec, indent), - } - } - out.push_str(&format!("{pad} --)\n")); - out.push_str(&format!("{pad} shift\n")); - out.push_str(&format!("{pad} break\n")); - out.push_str(&format!("{pad} ;;\n")); - out.push_str(&format!("{pad} -h|--help|help)\n")); - out.push_str(&format!("{pad} __seal_help=true\n")); - out.push_str(&format!("{pad} shift\n")); - out.push_str(&format!("{pad} ;;\n")); - if let Some(positional) = positional { - out.push_str(&format!("{pad} *)\n")); - out.push_str(&format!( - "{pad} if [ -z \"${}\" ]; then\n", - positional.name - )); - out.push_str(&format!("{pad} {}=$1\n", positional.name)); - out.push_str(&format!("{pad} shift\n")); - out.push_str(&format!("{pad} else\n")); - out.push_str(&format!( - "{pad} seal_fail \"{}\"\n", - positional.extra_error - )); - out.push_str(&format!("{pad} fi\n")); - out.push_str(&format!("{pad} ;;\n")); - } else { - out.push_str(&format!( - "{pad} *) seal_fail \"unknown option: $1\" ;;\n" - )); - } - out.push_str(&format!("{pad} esac\n")); - out.push_str(&format!("{pad}done\n")); -} - -fn emit_bash_string_option(out: &mut String, spec: &ArgvSpec, indent: usize) { - let pad = " ".repeat(indent); - let option = option_name(&spec.name); - out.push_str(&format!("{pad} {option})\n")); - out.push_str(&format!( - "{pad} if [ \"$#\" -lt 2 ]; then seal_fail 'missing value for {option}'; fi\n" - )); - out.push_str(&format!("{pad} {}=$2\n", spec.name)); - out.push_str(&format!("{pad} shift 2\n")); - out.push_str(&format!("{pad} ;;\n")); - out.push_str(&format!("{pad} {option}=*)\n")); - out.push_str(&format!("{pad} {}=${{1#{option}=}}\n", spec.name)); - out.push_str(&format!("{pad} shift\n")); - out.push_str(&format!("{pad} ;;\n")); -} - -fn emit_bash_flag_option(out: &mut String, spec: &ArgvSpec, indent: usize) { - let pad = " ".repeat(indent); - let option = option_name(&spec.name); - out.push_str(&format!("{pad} {option})\n")); - out.push_str(&format!("{pad} {}=true\n", spec.name)); - out.push_str(&format!("{pad} shift\n")); - out.push_str(&format!("{pad} ;;\n")); -} diff --git a/app/src/core/transpile/emit/powershell.rs b/app/src/core/transpile/emit/powershell.rs deleted file mode 100644 index 2947eb8..0000000 --- a/app/src/core/transpile/emit/powershell.rs +++ /dev/null @@ -1,396 +0,0 @@ -use super::powershell_support::{emit_positional_bindings, max_positional_statements}; -use super::support::generated_header; -use crate::core::transpile::ast::{ - EnvAssign, ExpansionOp, Item, OutputStream, Predicate, Program, Statement, Value, ValueSource, -}; - -#[path = "powershell_argv.rs"] -mod powershell_argv; -use self::powershell_argv::emit_argv_parse; - -pub(crate) fn emit_powershell(program: &Program, source_name: Option<&str>) -> String { - let mut out = generated_header("powershell", source_name); - out.push_str("$ErrorActionPreference = 'Stop'\n\n"); - let top_level = program - .items - .iter() - .filter_map(|item| match item { - Item::Statement { statement } => Some(statement), - Item::Function { .. } => None, - }) - .collect::<Vec<_>>(); - if emit_positional_bindings( - &mut out, - 0, - max_positional_statements(top_level.iter().copied()), - ) { - out.push('\n'); - } - emit_items(&mut out, program); - out -} - -fn emit_items(out: &mut String, program: &Program) { - let top_level_max = max_positional_statements(program.items.iter().filter_map(|item| { - if let Item::Statement { statement } = item { - Some(statement) - } else { - None - } - })); - for item in &program.items { - match item { - Item::Function { name, body } => { - out.push_str(&format!("function {name} {{\n")); - emit_positional_bindings(out, 1, max_positional_statements(body.iter())); - emit_statements(out, body, 1); - out.push_str("}\n\n"); - } - Item::Statement { statement } => emit_statement(out, statement, 0, top_level_max), - } - } -} - -fn emit_statements(out: &mut String, statements: &[Statement], indent: usize) { - let positional_max = max_positional_statements(statements.iter()); - for statement in statements { - emit_statement(out, statement, indent, positional_max); - } -} - -fn emit_statement(out: &mut String, statement: &Statement, indent: usize, positional_max: usize) { - let pad = " ".repeat(indent); - match statement { - Statement::Assign { name, value } => { - out.push_str(&format!("{pad}${name} = {}\n", powershell_value(value))); - } - Statement::ExecWrite { - stream, - path, - append, - argv, - } => { - out.push_str(&pad); - out.push_str("& "); - out.push_str(&join_values(argv, powershell_value)); - out.push(' '); - out.push_str(match (stream, append) { - (OutputStream::Stdout, false) => ">", - (OutputStream::Stdout, true) => ">>", - (OutputStream::Stderr, false) => "2>", - (OutputStream::Stderr, true) => "2>>", - }); - out.push(' '); - out.push_str(&powershell_value(path)); - out.push('\n'); - } - Statement::ExecChecked { argv } => { - out.push_str(&pad); - out.push_str("& "); - out.push_str(&join_values(argv, powershell_value)); - out.push('\n'); - } - Statement::EnvExecChecked { env, argv } => emit_env_exec(out, &pad, env, argv), - Statement::Shift { count } => { - if *count == 0 { - out.push_str(&format!("{pad}$args = @($args)\n")); - } else { - out.push_str(&format!( - "{pad}$args = if ($args.Count -gt {count}) {{ @($args[{count}..($args.Count - 1)]) }} else {{ @() }}\n" - )); - } - emit_positional_bindings(out, indent, positional_max); - } - Statement::ArgvParse { specs, positional } => { - emit_argv_parse(out, specs, positional.as_ref(), indent) - } - Statement::CaptureChecked { name, argv } => { - out.push_str(&pad); - out.push_str(&format!("${name} = & ")); - out.push_str(&join_values(argv, powershell_value)); - out.push('\n'); - } - Statement::CaptureFunction { - name, - function, - argv, - } => { - out.push_str(&pad); - out.push_str(&format!("${name} = & {function}")); - if !argv.is_empty() { - out.push(' '); - out.push_str(&join_values(argv, powershell_value)); - } - out.push('\n'); - } - Statement::If { - predicate, - then_body, - else_body, - } => emit_if(out, &pad, predicate, then_body, else_body, indent), - Statement::While { predicate, body } => { - emit_while(out, &pad, predicate, body, indent); - } - Statement::Case { value, arms } => { - out.push_str(&format!("{pad}switch ({}) {{\n", powershell_value(value))); - for arm in arms { - for pattern in &arm.patterns { - let pattern = if pattern == "*" { - "Default".to_string() - } else { - powershell_quote(pattern) - }; - out.push_str(&format!("{pad} {pattern} {{\n")); - emit_statements(out, &arm.body, indent + 2); - out.push_str(&format!("{pad} break\n")); - out.push_str(&format!("{pad} }}\n")); - } - } - out.push_str(&format!("{pad}}}\n")); - } - Statement::CallFunction { name, argv } => { - out.push_str(&pad); - out.push_str(name); - if !argv.is_empty() { - out.push(' '); - out.push_str(&join_values(argv, powershell_value)); - } - out.push('\n'); - } - Statement::Print { value } => { - out.push_str(&format!("{pad}Write-Output {}\n", powershell_value(value))); - } - Statement::Error { value } => { - out.push_str(&format!( - "{pad}[Console]::Error.WriteLine({})\n", - powershell_value(value) - )); - } - Statement::Fail { value } => { - out.push_str(&format!("{pad}throw {}\n", powershell_value(value))); - } - Statement::Exit { code } => out.push_str(&format!("{pad}exit {code}\n")), - Statement::Break => out.push_str(&format!("{pad}break\n")), - Statement::Sleep { seconds } => { - out.push_str(&format!("{pad}Start-Sleep -Seconds {seconds}\n")); - } - } -} - -fn emit_if( - out: &mut String, - pad: &str, - predicate: &Predicate, - then_body: &[Statement], - else_body: &[Statement], - indent: usize, -) { - if let Predicate::Command { argv } = predicate { - out.push_str(&format!("{pad}& ")); - out.push_str(&join_values(argv, powershell_value)); - out.push('\n'); - out.push_str(&format!("{pad}if ($LASTEXITCODE -eq 0) {{\n")); - emit_statements(out, then_body, indent + 1); - if else_body.is_empty() { - out.push_str(&format!("{pad}}}\n")); - } else { - out.push_str(&format!("{pad}}} else {{\n")); - emit_statements(out, else_body, indent + 1); - out.push_str(&format!("{pad}}}\n")); - } - return; - } - out.push_str(&format!("{pad}if ({}) {{\n", predicate_text(predicate))); - emit_statements(out, then_body, indent + 1); - if else_body.is_empty() { - out.push_str(&format!("{pad}}}\n")); - } else { - out.push_str(&format!("{pad}}} else {{\n")); - emit_statements(out, else_body, indent + 1); - out.push_str(&format!("{pad}}}\n")); - } -} - -fn emit_while( - out: &mut String, - pad: &str, - predicate: &Predicate, - body: &[Statement], - indent: usize, -) { - if let Predicate::Command { argv } = predicate { - out.push_str(&format!("{pad}while ($true) {{\n")); - let inner = " ".repeat(indent + 1); - out.push_str(&format!("{inner}& ")); - out.push_str(&join_values(argv, powershell_value)); - out.push('\n'); - out.push_str(&format!( - "{inner}if ($LASTEXITCODE -ne 0) {{\n{inner} break\n{inner}}}\n" - )); - emit_statements(out, body, indent + 1); - out.push_str(&format!("{pad}}}\n")); - return; - } - out.push_str(&format!("{pad}while ({}) {{\n", predicate_text(predicate))); - emit_statements(out, body, indent + 1); - out.push_str(&format!("{pad}}}\n")); -} - -fn predicate_text(predicate: &Predicate) -> String { - match predicate { - Predicate::Command { argv } => { - format!( - "(& {}; $LASTEXITCODE) -eq 0", - join_values(argv, powershell_value) - ) - } - Predicate::Empty { value } => { - format!("[string]::IsNullOrEmpty({})", powershell_value(value)) - } - Predicate::NotEmpty { value } => { - format!("![string]::IsNullOrEmpty({})", powershell_value(value)) - } - Predicate::Eq { left, right } => { - format!("{} -eq {}", powershell_value(left), powershell_value(right)) - } - Predicate::Neq { left, right } => { - format!("{} -ne {}", powershell_value(left), powershell_value(right)) - } - Predicate::IntLt { left, right } => int_compare(left, "-lt", right), - Predicate::IntLte { left, right } => int_compare(left, "-le", right), - Predicate::IntGt { left, right } => int_compare(left, "-gt", right), - Predicate::IntGte { left, right } => int_compare(left, "-ge", right), - Predicate::JsonEmpty { value } => { - format!( - "(& 'runseal' '@tool' 'json' 'empty' {}) -eq 'true'", - powershell_value(value) - ) - } - Predicate::JsonNotEmpty { value } => { - format!( - "(& 'runseal' '@tool' 'json' 'empty' {}) -eq 'false'", - powershell_value(value) - ) - } - Predicate::FileExists { path } => { - format!( - "Test-Path -LiteralPath {} -PathType Leaf", - powershell_value(path) - ) - } - Predicate::DirExists { path } => { - format!( - "Test-Path -LiteralPath {} -PathType Container", - powershell_value(path) - ) - } - } -} - -fn int_compare(left: &Value, operator: &str, right: &Value) -> String { - format!( - "[int]{} {operator} {}", - powershell_value(left), - powershell_value(right) - ) -} - -fn powershell_value(value: &Value) -> String { - match value { - Value::Literal { text } => powershell_quote(text), - Value::Argc => "$args.Count".to_string(), - Value::Args => "@args".to_string(), - Value::Expand { source, op } => powershell_expand(source, op), - Value::Concat { parts } => { - if parts.is_empty() { - return "''".to_string(); - } - let value = parts - .iter() - .map(powershell_value) - .collect::<Vec<_>>() - .join(" + "); - format!("({value})") - } - } -} - -fn powershell_expand(source: &ValueSource, op: &ExpansionOp) -> String { - match op { - ExpansionOp::Plain => powershell_source_value(source), - ExpansionOp::DefaultIfUnsetOrEmpty { fallback } => { - powershell_guarded_expand(source, fallback, false) - } - ExpansionOp::RequireNonEmpty { message } => { - powershell_guarded_expand(source, message, true) - } - } -} - -fn powershell_source_value(source: &ValueSource) -> String { - match source { - ValueSource::Var { name } => format!("${name}"), - ValueSource::Env { name } => format!("$env:{name}"), - } -} - -fn powershell_guarded_expand(source: &ValueSource, text: &str, require: bool) -> String { - let fallback_or_message = powershell_quote(text); - let action = if require { - format!("throw {fallback_or_message}") - } else { - fallback_or_message - }; - match source { - ValueSource::Env { name } => format!( - "$(if ([string]::IsNullOrEmpty($env:{name})) {{ {action} }} else {{ $env:{name} }})" - ), - ValueSource::Var { name } if name.bytes().all(|byte| byte.is_ascii_digit()) => { - let index = name.parse::<usize>().unwrap_or_default(); - format!( - "$(if (($args.Count -lt {index}) -or [string]::IsNullOrEmpty(${name})) {{ {action} }} else {{ ${name} }})" - ) - } - ValueSource::Var { name } => { - format!("$(if ([string]::IsNullOrEmpty(${name})) {{ {action} }} else {{ ${name} }})") - } - } -} - -fn emit_env_exec(out: &mut String, pad: &str, env: &[EnvAssign], argv: &[Value]) { - out.push_str(&format!("{pad}& {{\n")); - for item in env { - out.push_str(&format!( - "{pad} $__seal_old_env_{} = $env:{}\n", - item.name, item.name - )); - } - out.push_str(&format!("{pad} try {{\n")); - for item in env { - out.push_str(&format!( - "{pad} $env:{} = {}\n", - item.name, - powershell_value(&item.value) - )); - } - out.push_str(&format!("{pad} & ")); - out.push_str(&join_values(argv, powershell_value)); - out.push('\n'); - out.push_str(&format!("{pad} }} finally {{\n")); - for item in env { - out.push_str(&format!( - "{pad} $env:{} = $__seal_old_env_{}\n", - item.name, item.name - )); - } - out.push_str(&format!("{pad} }}\n")); - out.push_str(&format!("{pad}}}\n")); -} - -fn join_values(values: &[Value], format: fn(&Value) -> String) -> String { - values.iter().map(format).collect::<Vec<_>>().join(" ") -} - -fn powershell_quote(value: &str) -> String { - format!("'{}'", value.replace('\'', "''")) -} diff --git a/app/src/core/transpile/emit/powershell_argv.rs b/app/src/core/transpile/emit/powershell_argv.rs deleted file mode 100644 index 0108e2b..0000000 --- a/app/src/core/transpile/emit/powershell_argv.rs +++ /dev/null @@ -1,113 +0,0 @@ -use crate::core::transpile::ast::{ArgvKind, ArgvPositional, ArgvSpec}; -use crate::core::transpile::emit::support::option_name; - -use super::powershell_quote; - -pub(super) fn emit_argv_parse( - out: &mut String, - specs: &[ArgvSpec], - positional: Option<&ArgvPositional>, - indent: usize, -) { - let pad = " ".repeat(indent); - out.push_str(&format!("{pad}$__seal_argc = $args.Count\n")); - out.push_str(&format!("{pad}$__seal_help = 'false'\n")); - for spec in specs { - let value = match spec.kind { - ArgvKind::String => powershell_quote(spec.default.as_deref().unwrap_or("")), - ArgvKind::Flag => "'false'".to_string(), - }; - out.push_str(&format!("{pad}${} = {value}\n", spec.name)); - } - if let Some(positional) = positional { - out.push_str(&format!( - "{pad}${} = {}\n", - positional.name, - powershell_quote(&positional.default) - )); - } - out.push_str(&format!("{pad}$__seal_index = 0\n")); - out.push_str(&format!("{pad}while ($__seal_index -lt $args.Count) {{\n")); - out.push_str(&format!("{pad} $__seal_arg = $args[$__seal_index]\n")); - out.push_str(&format!("{pad} switch -Regex ($__seal_arg) {{\n")); - for spec in specs { - match spec.kind { - ArgvKind::String => emit_string_option(out, spec, indent), - ArgvKind::Flag => emit_flag_option(out, spec, indent), - } - } - out.push_str(&format!("{pad} '^--$' {{\n")); - out.push_str(&format!("{pad} $__seal_index = $args.Count\n")); - out.push_str(&format!("{pad} break\n")); - out.push_str(&format!("{pad} }}\n")); - out.push_str(&format!("{pad} '^(-h|--help|help)$' {{\n")); - out.push_str(&format!("{pad} $__seal_help = 'true'\n")); - out.push_str(&format!("{pad} $__seal_index += 1\n")); - out.push_str(&format!("{pad} break\n")); - out.push_str(&format!("{pad} }}\n")); - if let Some(positional) = positional { - out.push_str(&format!("{pad} default {{\n")); - out.push_str(&format!( - "{pad} if ([string]::IsNullOrEmpty(${})) {{\n", - positional.name - )); - out.push_str(&format!( - "{pad} ${} = $__seal_arg\n", - positional.name - )); - out.push_str(&format!("{pad} $__seal_index += 1\n")); - out.push_str(&format!("{pad} break\n")); - out.push_str(&format!("{pad} }} else {{\n")); - out.push_str(&format!( - "{pad} throw \"{}\"\n", - positional.extra_error.replace("$1", "$__seal_arg") - )); - out.push_str(&format!("{pad} }}\n")); - out.push_str(&format!("{pad} }}\n")); - } else { - out.push_str(&format!( - "{pad} default {{ throw \"unknown option: $__seal_arg\" }}\n" - )); - } - out.push_str(&format!("{pad} }}\n")); - out.push_str(&format!("{pad}}}\n")); -} - -fn emit_string_option(out: &mut String, spec: &ArgvSpec, indent: usize) { - let pad = " ".repeat(indent); - let option = option_name(&spec.name); - out.push_str(&format!("{pad} '^{}$' {{\n", regex_quote(&option))); - out.push_str(&format!( - "{pad} if ($__seal_index + 1 -ge $args.Count) {{ throw 'missing value for {option}' }}\n" - )); - out.push_str(&format!( - "{pad} ${} = $args[$__seal_index + 1]\n", - spec.name - )); - out.push_str(&format!("{pad} $__seal_index += 2\n")); - out.push_str(&format!("{pad} break\n")); - out.push_str(&format!("{pad} }}\n")); - out.push_str(&format!("{pad} '^{}=' {{\n", regex_quote(&option))); - out.push_str(&format!( - "{pad} ${} = $__seal_arg.Substring({})\n", - spec.name, - option.len() + 1 - )); - out.push_str(&format!("{pad} $__seal_index += 1\n")); - out.push_str(&format!("{pad} break\n")); - out.push_str(&format!("{pad} }}\n")); -} - -fn emit_flag_option(out: &mut String, spec: &ArgvSpec, indent: usize) { - let pad = " ".repeat(indent); - let option = option_name(&spec.name); - out.push_str(&format!("{pad} '^{}$' {{\n", regex_quote(&option))); - out.push_str(&format!("{pad} ${} = 'true'\n", spec.name)); - out.push_str(&format!("{pad} $__seal_index += 1\n")); - out.push_str(&format!("{pad} break\n")); - out.push_str(&format!("{pad} }}\n")); -} - -fn regex_quote(value: &str) -> String { - value.replace('-', "\\-") -} diff --git a/app/src/core/transpile/emit/powershell_support.rs b/app/src/core/transpile/emit/powershell_support.rs deleted file mode 100644 index 30a39bb..0000000 --- a/app/src/core/transpile/emit/powershell_support.rs +++ /dev/null @@ -1,120 +0,0 @@ -use crate::core::transpile::ast::{ExpansionOp, Predicate, Statement, Value, ValueSource}; - -pub(super) fn emit_positional_bindings(out: &mut String, indent: usize, max: usize) -> bool { - if max == 0 { - return false; - } - let pad = " ".repeat(indent); - out.push_str(&format!("{pad}$0 = $args.Count\n")); - for index in 1..=max { - let offset = index - 1; - out.push_str(&format!( - "{pad}${index} = if ($args.Count -ge {index}) {{ $args[{offset}] }} else {{ '' }}\n" - )); - } - true -} - -pub(super) fn max_positional_statements<'a>( - statements: impl IntoIterator<Item = &'a Statement>, -) -> usize { - statements - .into_iter() - .map(max_positional_statement) - .max() - .unwrap_or_default() -} - -fn max_positional_statement(statement: &Statement) -> usize { - match statement { - Statement::Assign { value, .. } => max_positional_value(value), - Statement::ExecWrite { argv, path, .. } => argv - .iter() - .map(max_positional_value) - .max() - .unwrap_or_default() - .max(max_positional_value(path)), - Statement::ExecChecked { argv } - | Statement::EnvExecChecked { argv, .. } - | Statement::CaptureChecked { argv, .. } - | Statement::CaptureFunction { argv, .. } - | Statement::CallFunction { argv, .. } => argv - .iter() - .map(max_positional_value) - .max() - .unwrap_or_default(), - Statement::If { - predicate, - then_body, - else_body, - } => max_positional_predicate(predicate) - .max(max_positional_statements(then_body.iter())) - .max(max_positional_statements(else_body.iter())), - Statement::While { predicate, body } => { - max_positional_predicate(predicate).max(max_positional_statements(body.iter())) - } - Statement::Case { value, arms } => arms - .iter() - .map(|arm| max_positional_statements(arm.body.iter())) - .max() - .unwrap_or_default() - .max(max_positional_value(value)), - Statement::Print { value } | Statement::Error { value } | Statement::Fail { value } => { - max_positional_value(value) - } - Statement::ArgvParse { .. } - | Statement::Shift { .. } - | Statement::Exit { .. } - | Statement::Break - | Statement::Sleep { .. } => 0, - } -} - -fn max_positional_predicate(predicate: &Predicate) -> usize { - match predicate { - Predicate::Command { argv } => argv - .iter() - .map(max_positional_value) - .max() - .unwrap_or_default(), - Predicate::Empty { value } - | Predicate::NotEmpty { value } - | Predicate::JsonEmpty { value } - | Predicate::JsonNotEmpty { value } => max_positional_value(value), - Predicate::Eq { left, right } - | Predicate::Neq { left, right } - | Predicate::IntLt { left, right } - | Predicate::IntLte { left, right } - | Predicate::IntGt { left, right } - | Predicate::IntGte { left, right } => { - max_positional_value(left).max(max_positional_value(right)) - } - Predicate::FileExists { path } | Predicate::DirExists { path } => { - max_positional_value(path) - } - } -} - -fn max_positional_value(value: &Value) -> usize { - match value { - Value::Expand { source, op } => max_positional_expand(source, op), - Value::Concat { parts } => parts - .iter() - .map(max_positional_value) - .max() - .unwrap_or_default(), - Value::Literal { .. } | Value::Argc | Value::Args => 0, - } -} - -fn max_positional_expand(source: &ValueSource, op: &ExpansionOp) -> usize { - let source_max = match source { - ValueSource::Var { name } => name.parse::<usize>().unwrap_or_default(), - ValueSource::Env { .. } => 0, - }; - match op { - ExpansionOp::Plain - | ExpansionOp::DefaultIfUnsetOrEmpty { .. } - | ExpansionOp::RequireNonEmpty { .. } => source_max, - } -} diff --git a/app/src/core/transpile/emit/support.rs b/app/src/core/transpile/emit/support.rs deleted file mode 100644 index 458f91c..0000000 --- a/app/src/core/transpile/emit/support.rs +++ /dev/null @@ -1,142 +0,0 @@ -use crate::core::transpile::ast::{ExpansionOp, Predicate, Value, ValueSource}; - -pub(super) fn option_name(name: &str) -> String { - format!("--{}", name.replace('_', "-")) -} - -pub(super) fn join_values(values: &[Value], format: fn(&Value) -> String) -> String { - values.iter().map(format).collect::<Vec<_>>().join(" ") -} - -pub(super) fn generated_header(target: &str, source_name: Option<&str>) -> String { - let source = source_name.unwrap_or("<memory>"); - format!("# Generated by runseal @transpile from {source} for {target}.\n") -} - -pub(super) fn bash_predicate(predicate: &Predicate) -> String { - match predicate { - Predicate::Command { argv } => join_values(argv, bash_value), - Predicate::Empty { value } => format!("[ -z {} ]", bash_value(value)), - Predicate::NotEmpty { value } => format!("[ -n {} ]", bash_value(value)), - Predicate::Eq { left, right } => { - format!("[ {} = {} ]", bash_value(left), bash_value(right)) - } - Predicate::Neq { left, right } => { - format!("[ {} != {} ]", bash_value(left), bash_value(right)) - } - Predicate::IntLt { left, right } => { - format!("[ {} -lt {} ]", bash_int_value(left), bash_int_value(right)) - } - Predicate::IntLte { left, right } => { - format!("[ {} -le {} ]", bash_int_value(left), bash_int_value(right)) - } - Predicate::IntGt { left, right } => { - format!("[ {} -gt {} ]", bash_int_value(left), bash_int_value(right)) - } - Predicate::IntGte { left, right } => { - format!("[ {} -ge {} ]", bash_int_value(left), bash_int_value(right)) - } - Predicate::JsonEmpty { value } => { - format!( - "[ \"$(runseal @tool json empty {})\" = true ]", - bash_value(value) - ) - } - Predicate::JsonNotEmpty { value } => { - format!( - "[ \"$(runseal @tool json empty {})\" = false ]", - bash_value(value) - ) - } - Predicate::FileExists { path } => format!("[ -f {} ]", bash_value(path)), - Predicate::DirExists { path } => format!("[ -d {} ]", bash_value(path)), - } -} - -pub(super) fn seal_value(value: &Value) -> String { - match value { - Value::Literal { text } => sh_quote(text), - Value::Argc => "$#".to_string(), - Value::Args => "\"$@\"".to_string(), - Value::Expand { source, op } => seal_expand(source, op), - Value::Concat { parts } => { - let inner = parts - .iter() - .map(|part| match part { - Value::Literal { text } => text.clone(), - Value::Argc => "$#".to_string(), - Value::Args => "$@".to_string(), - _ => seal_value(part), - }) - .collect::<String>(); - double_quote(&inner) - } - } -} - -pub(super) fn bash_value(value: &Value) -> String { - match value { - Value::Literal { text } => sh_quote(text), - Value::Argc => "\"$#\"".to_string(), - Value::Args => "\"$@\"".to_string(), - Value::Expand { source, op } => format!("\"{}\"", seal_expand(source, op)), - Value::Concat { parts } => double_quote( - &parts - .iter() - .map(|part| match part { - Value::Literal { text } => text.clone(), - Value::Argc => "$#".to_string(), - Value::Args => "$@".to_string(), - Value::Expand { source, op } => seal_expand(source, op), - Value::Concat { .. } => bash_value(part), - }) - .collect::<String>(), - ), - } -} - -pub(super) fn bash_int_value(value: &Value) -> String { - match value { - Value::Argc => "$#".to_string(), - Value::Args => "\"$@\"".to_string(), - Value::Expand { source, op } => seal_expand(source, op), - Value::Literal { text } => sh_quote(text), - Value::Concat { .. } => bash_value(value), - } -} - -fn seal_expand(source: &ValueSource, op: &ExpansionOp) -> String { - let name = match source { - ValueSource::Var { name } | ValueSource::Env { name } => name, - }; - match op { - ExpansionOp::Plain => match source { - ValueSource::Var { .. } => format!("${name}"), - ValueSource::Env { .. } => format!("${{{name}}}"), - }, - ExpansionOp::DefaultIfUnsetOrEmpty { fallback } => { - format!("${{{name}:-{fallback}}}") - } - ExpansionOp::RequireNonEmpty { message } => { - format!("${{{name}:?{message}}}") - } - } -} - -pub(super) fn sh_quote(value: &str) -> String { - if value.is_empty() { - return "''".to_string(); - } - if value.bytes().all(|byte| { - byte.is_ascii_alphanumeric() - || matches!(byte, b'_' | b'.' | b'/' | b'-' | b':') - || byte == b'@' - }) { - return value.to_string(); - } - format!("'{}'", value.replace('\'', "'\"'\"'")) -} - -fn double_quote(value: &str) -> String { - format!("\"{}\"", value.replace('"', "\\\"")) -} diff --git a/app/src/core/transpile/frontend/mod.rs b/app/src/core/transpile/frontend/mod.rs deleted file mode 100644 index d0dcde0..0000000 --- a/app/src/core/transpile/frontend/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -mod powershell; -mod predicate; - -pub(crate) use powershell::parse_powershell; diff --git a/app/src/core/transpile/frontend/powershell.rs b/app/src/core/transpile/frontend/powershell.rs deleted file mode 100644 index 4f67d91..0000000 --- a/app/src/core/transpile/frontend/powershell.rs +++ /dev/null @@ -1,262 +0,0 @@ -use anyhow::{Result, bail}; - -#[path = "powershell_value.rs"] -mod powershell_value; - -pub(crate) use self::powershell_value::parse_value; -use self::powershell_value::{ - assignment, is_generated_positional_binding, parse_argv, parse_pattern, strip_comment, - validate_name, -}; -use super::predicate::parse_powershell_predicate; -use crate::core::transpile::ast::{CaseArm, Item, Program, Statement, Value}; -use crate::core::transpile::lower::lower_functions; -use crate::core::transpile::parse_lex::is_valid_name; - -#[derive(Debug, Clone)] -struct Line { - number: usize, - text: String, -} - -struct Parser { - lines: Vec<Line>, - index: usize, -} - -pub(crate) fn parse_powershell(source: &str) -> Result<Program> { - Parser::new(source).parse_program() -} - -impl Parser { - fn new(source: &str) -> Self { - let lines = source - .lines() - .enumerate() - .filter_map(|(index, line)| { - let text = strip_comment(line).trim().to_string(); - if text.is_empty() - || text.starts_with("# Generated by ") - || text == "$ErrorActionPreference = 'Stop'" - || is_generated_positional_binding(&text) - { - return None; - } - Some(Line { - number: index + 1, - text, - }) - }) - .collect(); - Self { lines, index: 0 } - } - - fn parse_program(mut self) -> Result<Program> { - let mut items = Vec::new(); - while self.peek().is_some() { - if self - .peek_text() - .is_some_and(|text| text.starts_with("function ")) - { - items.push(self.parse_function()?); - } else { - items.push(Item::Statement { - statement: self.parse_statement()?, - }); - } - } - Ok(lower_functions(Program { version: 1, items })) - } - - fn parse_function(&mut self) -> Result<Item> { - let line = self.next().expect("function line should exist"); - let name = line - .text - .strip_prefix("function ") - .and_then(|text| text.strip_suffix(" {")) - .ok_or_else(|| anyhow::anyhow!("{}: expected function header", line.number))?; - validate_name(name, line.number)?; - let body = self.parse_block(&["}"])?; - self.expect("}")?; - Ok(Item::Function { - name: name.to_string(), - body, - }) - } - - fn parse_block(&mut self, terminators: &[&str]) -> Result<Vec<Statement>> { - let mut body = Vec::new(); - while let Some(line) = self.peek() { - if terminators - .iter() - .any(|terminator| line.text == *terminator) - { - break; - } - body.push(self.parse_statement()?); - } - Ok(body) - } - - fn parse_statement(&mut self) -> Result<Statement> { - let Some(line) = self.peek().cloned() else { - bail!("unexpected end of input"); - }; - if line.text.starts_with("if ") { - return self.parse_if(); - } - if line.text.starts_with("while ") { - return self.parse_while(); - } - if line.text.starts_with("switch ") { - return self.parse_switch(); - } - self.next(); - parse_simple_statement(&line) - } - - fn parse_if(&mut self) -> Result<Statement> { - let line = self.next().expect("if line should exist"); - let inner = line - .text - .strip_prefix("if (") - .and_then(|text| text.strip_suffix(") {")) - .ok_or_else(|| anyhow::anyhow!("{}: unsupported if predicate", line.number))?; - let then_body = self.parse_block(&["}", "} else {"])?; - let else_body = if self.peek_text() == Some("} else {") { - self.next(); - let body = self.parse_block(&["}"])?; - self.expect("}")?; - body - } else { - self.expect("}")?; - Vec::new() - }; - Ok(Statement::If { - predicate: parse_powershell_predicate(inner, line.number)?, - then_body, - else_body, - }) - } - fn parse_while(&mut self) -> Result<Statement> { - let line = self.next().expect("while line should exist"); - let inner = line - .text - .strip_prefix("while (") - .and_then(|text| text.strip_suffix(") {")) - .ok_or_else(|| anyhow::anyhow!("{}: expected while condition", line.number))?; - let predicate = parse_powershell_predicate(inner, line.number)?; - let body = self.parse_block(&["}"])?; - self.expect("}")?; - Ok(Statement::While { predicate, body }) - } - - fn parse_switch(&mut self) -> Result<Statement> { - let line = self.next().expect("switch line should exist"); - let value = line - .text - .strip_prefix("switch (") - .and_then(|text| text.strip_suffix(") {")) - .ok_or_else(|| anyhow::anyhow!("{}: expected switch header", line.number))?; - let mut arms = Vec::new(); - while let Some(line) = self.peek().cloned() { - if line.text == "}" { - self.next(); - break; - } - let pattern = line - .text - .strip_suffix(" {") - .ok_or_else(|| anyhow::anyhow!("{}: expected switch arm", line.number))?; - self.next(); - let body = self.parse_block(&["}"])?; - self.expect("}")?; - arms.push(CaseArm { - patterns: vec![parse_pattern(pattern)], - body, - }); - } - Ok(Statement::Case { - value: parse_value(value, line.number)?, - arms, - }) - } - - fn expect(&mut self, expected: &str) -> Result<()> { - let Some(line) = self.next() else { - bail!("expected `{expected}`, found end of input"); - }; - if line.text != expected { - bail!( - "{}: expected `{expected}`, got `{}`", - line.number, - line.text - ); - } - Ok(()) - } - - fn peek(&self) -> Option<&Line> { - self.lines.get(self.index) - } - - fn peek_text(&self) -> Option<&str> { - self.peek().map(|line| line.text.as_str()) - } - - fn next(&mut self) -> Option<Line> { - let line = self.lines.get(self.index).cloned(); - self.index += usize::from(line.is_some()); - line - } -} - -fn parse_simple_statement(line: &Line) -> Result<Statement> { - if let Some((name, value)) = assignment(&line.text) { - if let Some(argv) = value.strip_prefix("& ") { - let argv = parse_argv(argv, line.number)?; - return Ok(Statement::CaptureChecked { name, argv }); - } - return Ok(Statement::Assign { - name, - value: parse_value(value, line.number)?, - }); - } - if let Some(value) = line.text.strip_prefix("Write-Output ") { - return Ok(Statement::Print { - value: parse_value(value, line.number)?, - }); - } - if let Some(value) = line.text.strip_prefix("throw ") { - return Ok(Statement::Fail { - value: parse_value(value, line.number)?, - }); - } - if line.text == "break" { - return Ok(Statement::Break); - } - if let Some(seconds) = line.text.strip_prefix("Start-Sleep -Seconds ") { - return Ok(Statement::Sleep { - seconds: seconds - .parse() - .map_err(|_| anyhow::anyhow!("{}: invalid sleep seconds", line.number))?, - }); - } - if let Some(argv) = line.text.strip_prefix("& ") { - return Ok(Statement::ExecChecked { - argv: parse_argv(argv, line.number)?, - }); - } - if is_valid_name(&line.text) { - return Ok(Statement::ExecChecked { - argv: vec![Value::Literal { - text: line.text.clone(), - }], - }); - } - bail!( - "{}: unsupported PowerShell statement: {}", - line.number, - line.text - ) -} diff --git a/app/src/core/transpile/frontend/powershell_value.rs b/app/src/core/transpile/frontend/powershell_value.rs deleted file mode 100644 index 976ae60..0000000 --- a/app/src/core/transpile/frontend/powershell_value.rs +++ /dev/null @@ -1,315 +0,0 @@ -use anyhow::{Result, bail}; - -use crate::core::transpile::ast::{ExpansionOp, Value, ValueSource}; - -pub(super) fn parse_argv(text: &str, line: usize) -> Result<Vec<Value>> { - split_exprs(text, line)? - .iter() - .map(|arg| parse_argv_value(arg, line)) - .collect::<Result<Vec<_>>>() -} - -pub(crate) fn parse_value(text: &str, line: usize) -> Result<Value> { - let text = text.trim(); - if let Some(value) = text - .strip_prefix('\'') - .and_then(|value| value.strip_suffix('\'')) - { - return Ok(Value::Literal { - text: value.replace("''", "'"), - }); - } - if let Some((source, op)) = guarded_expand(text, line)? { - return Ok(Value::Expand { source, op }); - } - if let Some(name) = text.strip_prefix("$env:") { - validate_name(name, line)?; - return Ok(Value::Expand { - source: ValueSource::Env { - name: name.to_string(), - }, - op: ExpansionOp::Plain, - }); - } - if let Some(name) = text.strip_prefix('$') { - if is_valid_positional(name) { - return Ok(Value::Expand { - source: ValueSource::Var { - name: name.to_string(), - }, - op: ExpansionOp::Plain, - }); - } - validate_name(name, line)?; - return Ok(Value::Expand { - source: ValueSource::Var { - name: name.to_string(), - }, - op: ExpansionOp::Plain, - }); - } - if text.bytes().all(|byte| byte.is_ascii_digit()) { - return Ok(Value::Literal { - text: text.to_string(), - }); - } - if let Some(inner) = text - .strip_prefix('(') - .and_then(|value| value.strip_suffix(')')) - && inner.contains(" + ") - { - return Ok(Value::Concat { - parts: split_concat(inner, line)? - .iter() - .map(|part| parse_value(part, line)) - .collect::<Result<Vec<_>>>()?, - }); - } - bail!("{line}: unsupported PowerShell value: {text}") -} - -pub(super) fn assignment(text: &str) -> Option<(String, &str)> { - let (name, value) = text.split_once(" = ")?; - let name = name.strip_prefix('$')?; - is_valid_var_name(name).then_some((name.to_string(), value)) -} - -pub(super) fn parse_pattern(pattern: &str) -> String { - if pattern == "Default" { - return "*".to_string(); - } - pattern - .strip_prefix('\'') - .and_then(|value| value.strip_suffix('\'')) - .unwrap_or(pattern) - .to_string() -} - -pub(super) fn strip_comment(line: &str) -> String { - let mut output = String::new(); - let mut quote = false; - for ch in line.chars() { - match ch { - '\'' => { - quote = !quote; - output.push(ch); - } - '#' if !quote => break, - _ => output.push(ch), - } - } - output -} - -pub(super) fn is_generated_positional_binding(text: &str) -> bool { - if text == "$0 = $args.Count" { - return true; - } - let Some(rest) = text.strip_prefix('$') else { - return false; - }; - let Some((index, rest)) = rest.split_once(" = if ($args.Count -ge ") else { - return false; - }; - let Some((index2, rest)) = rest.split_once(") { $args[") else { - return false; - }; - let Some((offset, _rest)) = rest.split_once("] } else { '' }") else { - return false; - }; - if index != index2 { - return false; - } - let Ok(index) = index.parse::<usize>() else { - return false; - }; - let Ok(offset) = offset.parse::<usize>() else { - return false; - }; - index.checked_sub(1) == Some(offset) -} - -pub(super) fn validate_name(name: &str, line: usize) -> Result<()> { - if !is_valid_name(name) { - bail!("{line}: invalid PowerShell name: {name}"); - } - Ok(()) -} - -fn parse_argv_value(text: &str, line: usize) -> Result<Value> { - parse_value(text, line).or_else(|_| { - if is_valid_name(text) { - Ok(Value::Literal { - text: text.to_string(), - }) - } else { - bail!("{line}: unsupported PowerShell argv value: {text}") - } - }) -} - -fn guarded_expand(text: &str, line: usize) -> Result<Option<(ValueSource, ExpansionOp)>> { - let Some(inner) = text - .strip_prefix("$(if (") - .and_then(|value| value.strip_suffix(" })")) - else { - return Ok(None); - }; - if let Some(parsed) = env_guarded_expand(inner, line)? { - return Ok(Some(parsed)); - } - if let Some(parsed) = positional_guarded_expand(inner, line)? { - return Ok(Some(parsed)); - } - if let Some(parsed) = var_guarded_expand(inner, line)? { - return Ok(Some(parsed)); - } - Ok(None) -} - -fn env_guarded_expand(text: &str, line: usize) -> Result<Option<(ValueSource, ExpansionOp)>> { - let Some(rest) = text.strip_prefix("[string]::IsNullOrEmpty($env:") else { - return Ok(None); - }; - let (name, rest) = rest - .split_once(")) { ") - .ok_or_else(|| anyhow::anyhow!("{line}: unsupported PowerShell env expansion"))?; - validate_name(name, line)?; - let source = ValueSource::Env { - name: name.to_string(), - }; - let plain = format!("$env:{name}"); - parse_guarded_action(source, &plain, rest, line).map(Some) -} - -fn positional_guarded_expand( - text: &str, - line: usize, -) -> Result<Option<(ValueSource, ExpansionOp)>> { - let Some(rest) = text.strip_prefix("($args.Count -lt ") else { - return Ok(None); - }; - let (index, rest) = rest - .split_once(") -or [string]::IsNullOrEmpty($") - .ok_or_else(|| anyhow::anyhow!("{line}: unsupported PowerShell positional expansion"))?; - let (name, rest) = rest - .split_once(")) { ") - .ok_or_else(|| anyhow::anyhow!("{line}: unsupported PowerShell positional expansion"))?; - if index != name { - bail!("{line}: unsupported PowerShell positional expansion"); - } - let source = ValueSource::Var { - name: name.to_string(), - }; - let plain = format!("${name}"); - parse_guarded_action(source, &plain, rest, line).map(Some) -} - -fn var_guarded_expand(text: &str, line: usize) -> Result<Option<(ValueSource, ExpansionOp)>> { - let Some(rest) = text.strip_prefix("[string]::IsNullOrEmpty($") else { - return Ok(None); - }; - let (name, rest) = rest - .split_once(")) { ") - .ok_or_else(|| anyhow::anyhow!("{line}: unsupported PowerShell variable expansion"))?; - validate_name(name, line)?; - let source = ValueSource::Var { - name: name.to_string(), - }; - let plain = format!("${name}"); - parse_guarded_action(source, &plain, rest, line).map(Some) -} - -fn parse_guarded_action( - source: ValueSource, - plain: &str, - rest: &str, - line: usize, -) -> Result<(ValueSource, ExpansionOp)> { - let (action, expected_plain) = rest - .split_once(" } else { ") - .ok_or_else(|| anyhow::anyhow!("{line}: unsupported PowerShell guarded expansion"))?; - if expected_plain != plain { - bail!("{line}: unsupported PowerShell guarded expansion"); - } - if let Some(message) = action.strip_prefix("throw ") { - let message = parse_single_quoted(message, line)?; - return Ok((source, ExpansionOp::RequireNonEmpty { message })); - } - let fallback = parse_single_quoted(action, line)?; - Ok((source, ExpansionOp::DefaultIfUnsetOrEmpty { fallback })) -} - -fn parse_single_quoted(text: &str, line: usize) -> Result<String> { - let Some(value) = text - .strip_prefix('\'') - .and_then(|value| value.strip_suffix('\'')) - else { - bail!("{line}: unsupported PowerShell quoted literal: {text}"); - }; - Ok(value.replace("''", "'")) -} - -fn split_exprs(text: &str, line: usize) -> Result<Vec<String>> { - split_top_level(text, line, ' ') -} - -fn split_concat(text: &str, line: usize) -> Result<Vec<String>> { - split_top_level(text, line, '+').map(|items| { - items - .into_iter() - .map(|item| item.trim().to_string()) - .collect() - }) -} - -fn split_top_level(text: &str, line: usize, delimiter: char) -> Result<Vec<String>> { - let mut items = Vec::new(); - let mut current = String::new(); - let mut quote = false; - let mut depth = 0usize; - for ch in text.chars() { - match ch { - '\'' => { - quote = !quote; - current.push(ch); - } - '(' if !quote => { - depth += 1; - current.push(ch); - } - ')' if !quote => { - depth = depth.saturating_sub(1); - current.push(ch); - } - ch if ch == delimiter && !quote && depth == 0 => { - if !current.trim().is_empty() { - items.push(current.trim().to_string()); - current.clear(); - } - } - _ => current.push(ch), - } - } - if quote { - bail!("{line}: unterminated PowerShell string"); - } - if !current.trim().is_empty() { - items.push(current.trim().to_string()); - } - Ok(items) -} - -fn is_valid_var_name(name: &str) -> bool { - is_valid_name(name) || is_valid_positional(name) -} - -fn is_valid_name(name: &str) -> bool { - let mut bytes = name.bytes(); - matches!(bytes.next(), Some(byte) if byte.is_ascii_alphabetic() || byte == b'_') - && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') -} - -fn is_valid_positional(name: &str) -> bool { - !name.is_empty() && name.bytes().all(|byte| byte.is_ascii_digit()) -} diff --git a/app/src/core/transpile/frontend/predicate.rs b/app/src/core/transpile/frontend/predicate.rs deleted file mode 100644 index 7de662b..0000000 --- a/app/src/core/transpile/frontend/predicate.rs +++ /dev/null @@ -1,66 +0,0 @@ -use anyhow::{Result, bail}; - -use super::powershell::parse_value; -use crate::core::transpile::ast::Predicate; - -pub(crate) fn parse_powershell_predicate(text: &str, line: usize) -> Result<Predicate> { - if let Some(value) = text - .strip_prefix("[string]::IsNullOrEmpty(") - .and_then(|value| value.strip_suffix(')')) - { - return Ok(Predicate::Empty { - value: parse_value(value, line)?, - }); - } - if let Some(value) = text - .strip_prefix("![string]::IsNullOrEmpty(") - .and_then(|value| value.strip_suffix(')')) - { - return Ok(Predicate::NotEmpty { - value: parse_value(value, line)?, - }); - } - if let Some(value) = json_count_value(text, "-eq 0") { - return Ok(Predicate::JsonEmpty { - value: parse_value(value, line)?, - }); - } - if let Some(value) = json_count_value(text, "-gt 0") { - return Ok(Predicate::JsonNotEmpty { - value: parse_value(value, line)?, - }); - } - if let Some((operator, left, right)) = powershell_compare(text) { - return int_predicate(operator, left, right, line); - } - bail!("{line}: unsupported PowerShell predicate: {text}") -} - -fn int_predicate(operator: &str, left: &str, right: &str, line: usize) -> Result<Predicate> { - let left = left.strip_prefix("[int]").unwrap_or(left); - let left = parse_value(left, line)?; - let right = parse_value(right, line)?; - match operator { - "-lt" => Ok(Predicate::IntLt { left, right }), - "-le" => Ok(Predicate::IntLte { left, right }), - "-gt" => Ok(Predicate::IntGt { left, right }), - "-ge" => Ok(Predicate::IntGte { left, right }), - _ => bail!("{line}: unsupported PowerShell comparison operator: {operator}"), - } -} - -fn powershell_compare(text: &str) -> Option<(&str, &str, &str)> { - for operator in [" -lt ", " -le ", " -gt ", " -ge "] { - if let Some((left, right)) = text.split_once(operator) { - return Some((operator.trim(), left, right)); - } - } - None -} - -fn json_count_value<'a>(text: &'a str, comparison: &str) -> Option<&'a str> { - let inner = text.strip_prefix("((")?; - let suffix = format!(").Count {comparison})"); - let inner = inner.strip_suffix(&suffix)?; - inner.strip_suffix(" | ConvertFrom-Json") -} diff --git a/app/src/core/transpile/guards.rs b/app/src/core/transpile/guards.rs deleted file mode 100644 index d1a807c..0000000 --- a/app/src/core/transpile/guards.rs +++ /dev/null @@ -1,64 +0,0 @@ -use std::collections::BTreeSet; - -use super::ast::{Item, Program, Statement}; - -pub(crate) fn bash_required_tools(program: &Program) -> BTreeSet<&'static str> { - let mut tools = BTreeSet::new(); - for item in &program.items { - match item { - Item::Function { body, .. } => collect_bash_tools(body, &mut tools), - Item::Statement { statement } => collect_bash_tool(statement, &mut tools), - } - } - tools -} - -pub(crate) fn emit_bash_guards(out: &mut String, tools: &BTreeSet<&'static str>) { - for tool in tools { - out.push_str(&format!( - "if ! command -v {tool} >/dev/null 2>&1; then\n seal_fail 'missing dependency: {tool}'\nfi\n\n" - )); - } -} - -fn collect_bash_tools(statements: &[Statement], tools: &mut BTreeSet<&'static str>) { - for statement in statements { - collect_bash_tool(statement, tools); - } -} - -fn collect_bash_tool(statement: &Statement, tools: &mut BTreeSet<&'static str>) { - match statement { - Statement::If { - then_body, - else_body, - .. - } => { - collect_bash_tools(then_body, tools); - collect_bash_tools(else_body, tools); - } - Statement::While { body, .. } => { - collect_bash_tools(body, tools); - } - Statement::Case { arms, .. } => { - for arm in arms { - collect_bash_tools(&arm.body, tools); - } - } - Statement::Assign { .. } - | Statement::ArgvParse { .. } - | Statement::ExecWrite { .. } - | Statement::ExecChecked { .. } - | Statement::EnvExecChecked { .. } - | Statement::Shift { .. } - | Statement::CaptureChecked { .. } - | Statement::CaptureFunction { .. } - | Statement::CallFunction { .. } - | Statement::Print { .. } - | Statement::Error { .. } - | Statement::Fail { .. } - | Statement::Exit { .. } - | Statement::Break - | Statement::Sleep { .. } => {} - } -} diff --git a/app/src/core/transpile/lower.rs b/app/src/core/transpile/lower.rs deleted file mode 100644 index b8d515a..0000000 --- a/app/src/core/transpile/lower.rs +++ /dev/null @@ -1,86 +0,0 @@ -use std::collections::BTreeSet; - -use super::ast::{Item, Program, Statement, Value}; - -pub(crate) fn lower_functions(mut program: Program) -> Program { - let functions = program - .items - .iter() - .filter_map(|item| match item { - Item::Function { name, .. } => Some(name.clone()), - Item::Statement { .. } => None, - }) - .collect::<BTreeSet<_>>(); - for item in &mut program.items { - match item { - Item::Function { body, .. } => lower_statements(body, &functions), - Item::Statement { statement } => lower_statement(statement, &functions), - } - } - program -} - -fn lower_statements(statements: &mut [Statement], functions: &BTreeSet<String>) { - for statement in statements { - lower_statement(statement, functions); - } -} - -fn lower_statement(statement: &mut Statement, functions: &BTreeSet<String>) { - match statement { - Statement::ExecChecked { argv } => { - let Some(Value::Literal { text }) = argv.first() else { - return; - }; - if functions.contains(text) { - let name = text.clone(); - let argv = argv[1..].to_vec(); - *statement = Statement::CallFunction { name, argv }; - } - } - Statement::CaptureChecked { name, argv } => { - let Some(Value::Literal { text }) = argv.first() else { - return; - }; - if functions.contains(text) { - let destination = name.clone(); - let function = text.clone(); - let argv = argv[1..].to_vec(); - *statement = Statement::CaptureFunction { - name: destination, - function, - argv, - }; - } - } - Statement::If { - then_body, - else_body, - .. - } => { - lower_statements(then_body, functions); - lower_statements(else_body, functions); - } - Statement::While { body, .. } => { - lower_statements(body, functions); - } - Statement::Case { arms, .. } => { - for arm in arms { - lower_statements(&mut arm.body, functions); - } - } - Statement::Assign { .. } - | Statement::ArgvParse { .. } - | Statement::ExecWrite { .. } - | Statement::EnvExecChecked { .. } - | Statement::Shift { .. } - | Statement::CaptureFunction { .. } - | Statement::CallFunction { .. } - | Statement::Print { .. } - | Statement::Error { .. } - | Statement::Fail { .. } - | Statement::Exit { .. } - | Statement::Break - | Statement::Sleep { .. } => {} - } -} diff --git a/app/src/core/transpile/mod.rs b/app/src/core/transpile/mod.rs deleted file mode 100644 index 374e0e6..0000000 --- a/app/src/core/transpile/mod.rs +++ /dev/null @@ -1,127 +0,0 @@ -use std::fs; - -use anyhow::{Result, bail}; - -mod ast; -mod emit; -mod frontend; -mod guards; -mod lower; -mod parse; -mod parse_argv; -mod parse_command; -mod parse_lex; -mod runner; -mod value; - -use emit::{emit_bash, emit_powershell, emit_seal}; -use frontend::parse_powershell; -use parse::parse_seal; -pub(crate) use runner::run_seal_file; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Lang { - Seal, - Bash, - PowerShell, - SealIr, -} - -impl Lang { - fn parse(value: &str, flag: &str) -> Result<Self> { - match value { - "seal" => Ok(Self::Seal), - "bash" => Ok(Self::Bash), - "powershell" => Ok(Self::PowerShell), - "sealir" => Ok(Self::SealIr), - _ => bail!("invalid {flag}: {value}; expected seal, bash, powershell, or sealir"), - } - } -} - -#[derive(Debug, Clone)] -pub struct Options { - pub input_lang: Lang, - pub output_lang: Lang, - pub source: String, -} - -pub fn parse_args(args: &[String]) -> Result<Options> { - let mut input_lang = None; - let mut output_lang = None; - let mut source = None; - let mut index = 0; - while index < args.len() { - let arg = &args[index]; - if let Some(value) = arg.strip_prefix("--input-lang=") { - input_lang = Some(Lang::parse(value, "--input-lang")?); - index += 1; - continue; - } - if arg == "--input-lang" { - index += 1; - let Some(value) = args.get(index) else { - bail!("--input-lang requires a value"); - }; - input_lang = Some(Lang::parse(value, "--input-lang")?); - index += 1; - continue; - } - if let Some(value) = arg.strip_prefix("--output-lang=") { - output_lang = Some(Lang::parse(value, "--output-lang")?); - index += 1; - continue; - } - if arg == "--output-lang" { - index += 1; - let Some(value) = args.get(index) else { - bail!("--output-lang requires a value"); - }; - output_lang = Some(Lang::parse(value, "--output-lang")?); - index += 1; - continue; - } - if arg.starts_with('-') { - bail!("unknown @transpile option: {arg}"); - } - if source.replace(arg.clone()).is_some() { - bail!("@transpile requires exactly one source file"); - } - index += 1; - } - Ok(Options { - input_lang: input_lang.ok_or_else(|| anyhow::anyhow!("--input-lang is required"))?, - output_lang: output_lang.ok_or_else(|| anyhow::anyhow!("--output-lang is required"))?, - source: source.ok_or_else(|| anyhow::anyhow!("@transpile requires one source file"))?, - }) -} - -pub fn transpile_file(options: &Options) -> Result<String> { - let source = fs::read_to_string(&options.source) - .map_err(|err| anyhow::anyhow!("failed to read {}: {err}", options.source))?; - transpile_source( - options.input_lang, - options.output_lang, - &source, - Some(&options.source), - ) -} - -pub fn transpile_source( - input_lang: Lang, - output_lang: Lang, - source: &str, - source_name: Option<&str>, -) -> Result<String> { - let program = match input_lang { - Lang::Seal | Lang::Bash => parse_seal(source)?, - Lang::PowerShell => parse_powershell(source)?, - Lang::SealIr => serde_json::from_str(source)?, - }; - match output_lang { - Lang::SealIr => Ok(serde_json::to_string_pretty(&program)? + "\n"), - Lang::Seal => Ok(emit_seal(&program)), - Lang::Bash => Ok(emit_bash(&program, source_name)), - Lang::PowerShell => Ok(emit_powershell(&program, source_name)), - } -} diff --git a/app/src/core/transpile/parse.rs b/app/src/core/transpile/parse.rs deleted file mode 100644 index adc8945..0000000 --- a/app/src/core/transpile/parse.rs +++ /dev/null @@ -1,496 +0,0 @@ -use anyhow::{Result, bail}; - -use super::ast::{CaseArm, EnvAssign, Item, Predicate, Program, Statement, Value}; -use super::lower::lower_functions; -use super::parse_argv::parse_argv_block; -use super::parse_command::{parse_exec_write, validate_external_tokens, validate_shell_command}; -use super::parse_lex::{ - assignment, is_safe_command_name, is_valid_name, split_test_words, split_words, strip_comment, -}; -use super::value::parse_value_text; - -#[derive(Debug, Clone)] -pub(super) struct SourceLine { - pub(super) number: usize, - pub(super) text: String, -} - -pub(super) struct Parser { - lines: Vec<SourceLine>, - index: usize, -} -impl Parser { - fn new(source: &str) -> Self { - let lines = source - .lines() - .enumerate() - .filter_map(|(index, line)| { - let text = strip_comment(line).trim().to_string(); - if text.is_empty() { - return None; - } - Some(SourceLine { - number: index + 1, - text, - }) - }) - .collect(); - Self { lines, index: 0 } - } - - fn parse_program(mut self) -> Result<Program> { - let mut items = Vec::new(); - while self.peek().is_some() { - if self - .peek_text() - .is_some_and(|text| function_header(text).is_some()) - { - items.push(self.parse_function()?); - } else { - items.push(Item::Statement { - statement: self.parse_statement()?, - }); - } - } - let program = Program { version: 1, items }; - Ok(lower_functions(program)) - } - fn parse_function(&mut self) -> Result<Item> { - let line = self.next().expect("function header should exist"); - let name = function_header(&line.text) - .ok_or_else(|| anyhow::anyhow!("{}: expected function header", line.number))? - .to_string(); - let body = self.parse_block(&["}"])?; - self.expect_exact("}")?; - Ok(Item::Function { name, body }) - } - fn parse_block(&mut self, terminators: &[&str]) -> Result<Vec<Statement>> { - let mut body = Vec::new(); - while let Some(line) = self.peek() { - if terminators - .iter() - .any(|terminator| line.text == *terminator) - { - break; - } - body.push(self.parse_statement()?); - } - Ok(body) - } - fn parse_statement(&mut self) -> Result<Statement> { - let Some(line) = self.peek().cloned() else { - bail!("unexpected end of input"); - }; - if line.text == "__seal_argc=$#" { - return parse_argv_block(self); - } - if line.text.starts_with("if ") { - return self.parse_if(); - } - if line.text.starts_with("while ") { - return self.parse_while(); - } - if line.text.starts_with("case ") { - return self.parse_case(); - } - self.next(); - parse_simple_statement(&line) - } - fn parse_if(&mut self) -> Result<Statement> { - let line = self.next().expect("if line should exist"); - let inner = line - .text - .strip_prefix("if ") - .and_then(|text| text.strip_suffix("; then")) - .ok_or_else(|| anyhow::anyhow!("{}: expected `if <predicate>; then`", line.number))?; - let predicate = parse_predicate(inner, line.number)?; - let then_body = self.parse_block(&["else", "fi"])?; - let else_body = if self.peek_text() == Some("else") { - self.next(); - self.parse_block(&["fi"])? - } else { - Vec::new() - }; - self.expect_exact("fi")?; - Ok(Statement::If { - predicate, - then_body, - else_body, - }) - } - fn parse_while(&mut self) -> Result<Statement> { - let line = self.next().expect("while line should exist"); - let inner = line - .text - .strip_prefix("while ") - .and_then(|text| text.strip_suffix("; do")) - .ok_or_else(|| anyhow::anyhow!("{}: expected `while <predicate>; do`", line.number))?; - let predicate = parse_predicate(inner, line.number)?; - let body = self.parse_block(&["done"])?; - self.expect_exact("done")?; - Ok(Statement::While { predicate, body }) - } - fn parse_case(&mut self) -> Result<Statement> { - let line = self.next().expect("case line should exist"); - let value_text = line - .text - .strip_prefix("case ") - .and_then(|text| text.strip_suffix(" in")) - .ok_or_else(|| anyhow::anyhow!("{}: expected `case <value> in`", line.number))?; - let value = parse_value_text(value_text, line.number)?; - let mut arms = Vec::new(); - loop { - let Some(line) = self.peek().cloned() else { - bail!("{}: missing esac for case", line.number); - }; - if line.text == "esac" { - self.next(); - break; - } - let text = line.text.clone(); - let Some((patterns, remainder)) = text.split_once(')') else { - bail!("{}: expected case arm pattern", line.number); - }; - self.next(); - let patterns = patterns - .split('|') - .map(str::trim) - .map(str::to_string) - .collect::<Vec<_>>(); - if patterns.iter().any(|pattern| pattern.is_empty()) { - bail!("{}: empty case pattern", line.number); - } - let mut body = Vec::new(); - let remainder = remainder.trim(); - if !remainder.is_empty() { - let statement = remainder - .strip_suffix(";;") - .ok_or_else(|| { - anyhow::anyhow!("{}: inline case arms must end with `;;`", line.number) - })? - .trim(); - if !statement.is_empty() { - body.push(parse_simple_statement(&SourceLine { - number: line.number, - text: statement.to_string(), - })?); - } - } else { - while let Some(next) = self.peek().cloned() { - if next.text == ";;" { - self.next(); - break; - } - if next.text == "esac" { - bail!("{}: missing `;;` before esac", next.number); - } - body.push(self.parse_statement()?); - } - } - arms.push(CaseArm { patterns, body }); - } - Ok(Statement::Case { value, arms }) - } - pub(super) fn expect_exact(&mut self, expected: &str) -> Result<()> { - let Some(line) = self.next() else { - bail!("expected `{expected}`, found end of input"); - }; - if line.text != expected { - bail!( - "{}: expected `{expected}`, got `{}`", - line.number, - line.text - ); - } - Ok(()) - } - pub(super) fn peek(&self) -> Option<&SourceLine> { - self.lines.get(self.index) - } - pub(super) fn peek_text(&self) -> Option<&str> { - self.peek().map(|line| line.text.as_str()) - } - pub(super) fn next(&mut self) -> Option<SourceLine> { - let line = self.lines.get(self.index).cloned(); - self.index += usize::from(line.is_some()); - line - } -} - -fn function_header(text: &str) -> Option<&str> { - let name = text.strip_suffix("() {")?; - is_valid_name(name).then_some(name) -} - -fn parse_simple_statement(line: &SourceLine) -> Result<Statement> { - if let Some((name, value)) = assignment(&line.text) - && let Some(argv) = capture_argv(value, line.number)? - { - return Ok(Statement::CaptureChecked { - name: name.to_string(), - argv, - }); - } - let tokens = split_words(&line.text, line.number)?; - if let Some(statement) = parse_exec_write(&tokens, line.number)? { - return Ok(statement); - } - if let Some(statement) = parse_env_exec(&tokens, line.number)? { - return Ok(statement); - } - if let Some((name, value)) = assignment(&line.text) { - return Ok(Statement::Assign { - name: name.to_string(), - value: parse_value_text(value, line.number)?, - }); - } - let Some((command, args)) = tokens.split_first() else { - bail!("{}: expected statement", line.number); - }; - validate_shell_command(command, args, line.number)?; - match command.as_str() { - "printf" => parse_printf(args, line.number), - "eval" => bail!("{}: unsupported statement: eval", line.number), - "seal" => bail!("{}: unsupported legacy seal helper statement", line.number), - "shift" => { - let count = match args { - [] => 1, - [count] => count - .parse::<usize>() - .map_err(|_| anyhow::anyhow!("{}: invalid shift count", line.number))?, - _ => bail!("{}: shift accepts at most one argument", line.number), - }; - Ok(Statement::Shift { count }) - } - "print" => Ok(Statement::Print { - value: one_value(args, line.number, "print")?, - }), - "error" => Ok(Statement::Error { - value: one_value(args, line.number, "error")?, - }), - "fail" => Ok(Statement::Fail { - value: one_value(args, line.number, "fail")?, - }), - "break" => { - if !args.is_empty() { - bail!("{}: break does not accept arguments", line.number); - } - Ok(Statement::Break) - } - "exit" => { - if args.len() != 1 { - bail!("{}: exit requires one code argument", line.number); - } - Ok(Statement::Exit { - code: args[0] - .parse() - .map_err(|_| anyhow::anyhow!("{}: invalid exit code", line.number))?, - }) - } - "sleep" => { - if args.len() != 1 { - bail!("{}: sleep requires one seconds argument", line.number); - } - Ok(Statement::Sleep { - seconds: args[0] - .parse() - .map_err(|_| anyhow::anyhow!("{}: invalid sleep seconds", line.number))?, - }) - } - _ if is_safe_command_name(command) => { - validate_external_tokens(&tokens, line.number)?; - Ok(Statement::ExecChecked { - argv: parse_values(&tokens, line.number)?, - }) - } - _ => bail!("{}: unsupported statement: {}", line.number, line.text), - } -} - -fn parse_env_exec(tokens: &[String], line: usize) -> Result<Option<Statement>> { - let mut env = Vec::new(); - let mut index = 0; - while let Some(token) = tokens.get(index) { - let Some((name, value)) = assignment(token) else { - break; - }; - env.push(EnvAssign { - name: name.to_string(), - value: parse_value_text(value, line)?, - }); - index += 1; - } - if env.is_empty() || index == tokens.len() { - return Ok(None); - } - let argv_tokens = &tokens[index..]; - validate_external_tokens(argv_tokens, line)?; - let Some(command) = argv_tokens.first() else { - return Ok(None); - }; - validate_shell_command(command, &argv_tokens[1..], line)?; - if !is_safe_command_name(command) { - bail!("{line}: unsupported statement: {}", tokens.join(" ")); - } - Ok(Some(Statement::EnvExecChecked { - env, - argv: parse_values(argv_tokens, line)?, - })) -} - -fn parse_printf(args: &[String], line: usize) -> Result<Statement> { - match args { - [format, value] if format == "'%s\\n'" => Ok(Statement::Print { - value: parse_value_text(value, line)?, - }), - [format, value, redirect] if format == "'%s\\n'" && redirect == ">&2" => { - Ok(Statement::Error { - value: parse_value_text(value, line)?, - }) - } - [format, value, redirect, target] - if format == "'%s\\n'" && redirect == ">" && target == "&2" => - { - Ok(Statement::Error { - value: parse_value_text(value, line)?, - }) - } - _ => bail!("{line}: unsupported printf form"), - } -} - -pub(super) fn option_to_name(option: &str, line: usize) -> Result<String> { - let Some(option) = option.strip_prefix("--") else { - bail!("{line}: expected long option: {option}"); - }; - let name = option.replace('-', "_"); - if !is_valid_name(&name) { - bail!("{line}: invalid option name: {option}"); - } - Ok(name) -} - -fn capture_argv(value: &str, line: usize) -> Result<Option<Vec<Value>>> { - let Some(inner) = value - .strip_prefix("$(") - .and_then(|value| value.strip_suffix(')')) - else { - return Ok(None); - }; - let tokens = split_words(inner, line)?; - if tokens.is_empty() { - bail!("{line}: capture command cannot be empty"); - } - validate_external_tokens(&tokens, line)?; - Ok(Some(parse_values(&tokens, line)?)) -} - -fn one_value(args: &[String], line: usize, command: &str) -> Result<Value> { - if args.len() != 1 { - bail!("{line}: {command} requires exactly one argument"); - } - parse_value_text(&args[0], line) -} - -fn parse_predicate(text: &str, line: usize) -> Result<Predicate> { - if let Some(inner) = text - .strip_prefix("[ ") - .and_then(|text| text.strip_suffix(" ]")) - { - return parse_test_predicate(inner, line); - } - - let tokens = split_words(text, line)?; - let Some(command) = tokens.first() else { - bail!("{line}: command predicate cannot be empty"); - }; - validate_shell_command(command, &tokens[1..], line)?; - if !is_safe_command_name(command) { - bail!("{line}: unsupported predicate: {text}"); - } - validate_external_tokens(&tokens, line)?; - Ok(Predicate::Command { - argv: parse_values(&tokens, line)?, - }) -} - -pub(super) fn parse_values(tokens: &[String], line: usize) -> Result<Vec<Value>> { - tokens - .iter() - .map(|arg| parse_value_text(arg, line)) - .collect() -} - -fn parse_test_predicate(text: &str, line: usize) -> Result<Predicate> { - let tokens = split_test_words(text, line)?; - match tokens.as_slice() { - [flag, value] if flag == "-z" => Ok(Predicate::Empty { - value: parse_value_text(value, line)?, - }), - [flag, value] if flag == "-n" => Ok(Predicate::NotEmpty { - value: parse_value_text(value, line)?, - }), - [flag, path] if flag == "-f" => Ok(Predicate::FileExists { - path: parse_value_text(path, line)?, - }), - [flag, path] if flag == "-d" => Ok(Predicate::DirExists { - path: parse_value_text(path, line)?, - }), - [left, op, right] if op == "=" => { - if let Some(value) = json_empty_value(left, line)? { - return match right.as_str() { - "true" => Ok(Predicate::JsonEmpty { value }), - "false" => Ok(Predicate::JsonNotEmpty { value }), - _ => bail!("{line}: unsupported json empty comparison: {text}"), - }; - } - Ok(Predicate::Eq { - left: parse_value_text(left, line)?, - right: parse_value_text(right, line)?, - }) - } - [left, op, right] if op == "!=" => Ok(Predicate::Neq { - left: parse_value_text(left, line)?, - right: parse_value_text(right, line)?, - }), - [left, op, right] if op == "-lt" => Ok(Predicate::IntLt { - left: parse_value_text(left, line)?, - right: parse_value_text(right, line)?, - }), - [left, op, right] if op == "-le" => Ok(Predicate::IntLte { - left: parse_value_text(left, line)?, - right: parse_value_text(right, line)?, - }), - [left, op, right] if op == "-gt" => Ok(Predicate::IntGt { - left: parse_value_text(left, line)?, - right: parse_value_text(right, line)?, - }), - [left, op, right] if op == "-ge" => Ok(Predicate::IntGte { - left: parse_value_text(left, line)?, - right: parse_value_text(right, line)?, - }), - _ => bail!("{line}: unsupported test predicate: {text}"), - } -} - -fn json_empty_value(text: &str, line: usize) -> Result<Option<Value>> { - let Some(inner) = text - .strip_prefix("\"$(") - .and_then(|text| text.strip_suffix(")\"")) - else { - return Ok(None); - }; - let tokens = split_words(inner, line)?; - match tokens.as_slice() { - [runseal, tool, json, empty, value] - if runseal == "runseal" && tool == "@tool" && json == "json" && empty == "empty" => - { - Ok(Some(parse_value_text(value, line)?)) - } - _ => bail!("{line}: unsupported command substitution predicate: {text}"), - } -} - -pub(crate) fn parse_seal(source: &str) -> Result<Program> { - Parser::new(source).parse_program() -} diff --git a/app/src/core/transpile/parse_argv.rs b/app/src/core/transpile/parse_argv.rs deleted file mode 100644 index dd823a4..0000000 --- a/app/src/core/transpile/parse_argv.rs +++ /dev/null @@ -1,214 +0,0 @@ -use std::collections::BTreeMap; - -use anyhow::{Result, bail}; - -use super::ast::{ArgvKind, ArgvPositional, ArgvSpec, Statement}; -use super::parse::{Parser, option_to_name}; -use super::parse_lex::assignment; - -pub(super) fn parse_argv_block(parser: &mut Parser) -> Result<Statement> { - parser.expect_exact("__seal_argc=$#")?; - parser.expect_exact("__seal_help=false")?; - - let mut order = Vec::new(); - let mut defaults = BTreeMap::new(); - while let Some(line) = parser.peek().cloned() { - if line.text == "while [ \"$#\" -gt 0 ]; do" { - break; - } - let Some((name, value)) = assignment(&line.text) else { - bail!("{}: expected argv variable default", line.number); - }; - parser.next(); - order.push(name.to_string()); - defaults.insert(name.to_string(), value.to_string()); - } - - parser.expect_exact("while [ \"$#\" -gt 0 ]; do")?; - parser.expect_exact("case \"$1\" in")?; - - let mut kinds = BTreeMap::new(); - let mut positional = None; - loop { - let Some(line) = parser.peek().cloned() else { - bail!("missing esac for argv parser"); - }; - match line.text.as_str() { - "esac" => { - parser.next(); - break; - } - "--)" => parse_double_dash(parser)?, - "-h|--help|help)" => parse_help(parser)?, - "*) fail \"unknown option: $1\" ;;" | "*) seal_fail \"unknown option: $1\" ;;" => { - parser.next(); - } - "*)" => { - positional = Some(parse_positional_arm(parser, &defaults)?); - } - text if text.starts_with("--") && text.ends_with("=*)") => { - parse_eq_arm(parser, &mut kinds, text, line.number)?; - } - text if text.starts_with("--") && text.ends_with(')') => { - parse_option_arm(parser, &mut kinds, text, line.number)?; - } - _ => bail!( - "{}: unsupported argv parser arm: {}", - line.number, - line.text - ), - } - } - parser.expect_exact("done")?; - Ok(Statement::ArgvParse { - specs: argv_specs(order, defaults, kinds, positional.as_ref())?, - positional, - }) -} - -fn parse_eq_arm( - parser: &mut Parser, - kinds: &mut BTreeMap<String, ArgvKind>, - text: &str, - line: usize, -) -> Result<()> { - let option = text.trim_end_matches("=*)"); - let name = option_to_name(option, line)?; - parser.next(); - parser.expect_exact(&format!("{name}=${{1#{option}=}}"))?; - parser.expect_exact("shift")?; - parser.expect_exact(";;")?; - kinds.insert(name, ArgvKind::String); - Ok(()) -} - -fn parse_option_arm( - parser: &mut Parser, - kinds: &mut BTreeMap<String, ArgvKind>, - text: &str, - line: usize, -) -> Result<()> { - let option = text.trim_end_matches(')'); - let name = option_to_name(option, line)?; - parser.next(); - if parse_missing_value_guard(parser)? { - parser.expect_exact(&format!("{name}=$2"))?; - parser.expect_exact("shift 2")?; - parser.expect_exact(";;")?; - kinds.insert(name, ArgvKind::String); - } else { - parser.expect_exact(&format!("{name}=true"))?; - parser.expect_exact("shift")?; - parser.expect_exact(";;")?; - kinds.insert(name, ArgvKind::Flag); - } - Ok(()) -} - -fn parse_missing_value_guard(parser: &mut Parser) -> Result<bool> { - let Some(line) = parser.peek().cloned() else { - bail!("missing argv option arm body"); - }; - if line.text.starts_with("if [ \"$#\" -lt 2 ]; then ") { - parser.next(); - return Ok(true); - } - if line.text != "if [ \"$#\" -lt 2 ]; then" { - return Ok(false); - } - - parser.next(); - while let Some(next) = parser.peek().cloned() { - parser.next(); - if next.text == "fi" { - return Ok(true); - } - } - bail!("missing fi for argv missing-value guard"); -} - -fn parse_double_dash(parser: &mut Parser) -> Result<()> { - parser.expect_exact("--)")?; - parser.expect_exact("shift")?; - parser.expect_exact("break")?; - parser.expect_exact(";;") -} - -fn parse_help(parser: &mut Parser) -> Result<()> { - parser.expect_exact("-h|--help|help)")?; - parser.expect_exact("__seal_help=true")?; - parser.expect_exact("shift")?; - parser.expect_exact(";;") -} - -fn parse_positional_arm( - parser: &mut Parser, - defaults: &BTreeMap<String, String>, -) -> Result<ArgvPositional> { - parser.expect_exact("*)")?; - let line = parser - .next() - .ok_or_else(|| anyhow::anyhow!("missing positional argv arm body"))?; - let Some(name) = line - .text - .strip_prefix("if [ -z \"$") - .and_then(|text| text.strip_suffix("\" ]; then")) - else { - bail!("{}: unsupported argv positional arm", line.number); - }; - parser.expect_exact(&format!("{name}=$1"))?; - parser.expect_exact("shift")?; - parser.expect_exact("else")?; - let fail_line = parser - .next() - .ok_or_else(|| anyhow::anyhow!("missing argv positional else body"))?; - let extra_error = fail_line - .text - .strip_prefix("fail \"") - .and_then(|text| text.strip_suffix('"')) - .or_else(|| { - fail_line - .text - .strip_prefix("seal_fail \"") - .and_then(|text| text.strip_suffix('"')) - }) - .ok_or_else(|| { - anyhow::anyhow!("{}: unsupported argv positional else arm", fail_line.number) - })?; - parser.expect_exact("fi")?; - parser.expect_exact(";;")?; - Ok(ArgvPositional { - name: name.to_string(), - default: defaults.get(name).cloned().unwrap_or_default(), - extra_error: extra_error.to_string(), - }) -} - -fn argv_specs( - order: Vec<String>, - mut defaults: BTreeMap<String, String>, - mut kinds: BTreeMap<String, ArgvKind>, - positional: Option<&ArgvPositional>, -) -> Result<Vec<ArgvSpec>> { - let mut specs = Vec::new(); - for name in order { - if positional.is_some_and(|positional| positional.name == name) { - defaults.remove(&name); - continue; - } - let Some(kind) = kinds.remove(&name) else { - bail!("missing argv parser arm for {name}"); - }; - let default = defaults.remove(&name).unwrap_or_default(); - let default = match kind { - ArgvKind::String => Some(default), - ArgvKind::Flag => None, - }; - specs.push(ArgvSpec { - name, - kind, - default, - }); - } - Ok(specs) -} diff --git a/app/src/core/transpile/parse_command.rs b/app/src/core/transpile/parse_command.rs deleted file mode 100644 index 7e1d628..0000000 --- a/app/src/core/transpile/parse_command.rs +++ /dev/null @@ -1,100 +0,0 @@ -use anyhow::{Result, bail}; - -use super::ast::{OutputStream, Statement}; -use super::parse::parse_values; -use super::parse_lex::is_safe_command_name; -use super::value::parse_value_text; - -pub(super) fn parse_exec_write(tokens: &[String], line: usize) -> Result<Option<Statement>> { - let redirects = tokens - .iter() - .enumerate() - .filter(|(_, token)| matches!(token.as_str(), ">" | ">>" | "2>" | "2>>" | "|")) - .collect::<Vec<_>>(); - if redirects.is_empty() { - return Ok(None); - } - if redirects.iter().any(|(_, token)| token.as_str() == "|") { - bail!("{line}: unsupported shell metacharacter: |"); - } - if redirects.len() != 1 { - bail!("{line}: unsupported redirect combination"); - } - let (index, token) = redirects[0]; - if index == 0 || index + 2 != tokens.len() { - bail!("{line}: redirect requires one command and one file target"); - } - let argv_tokens = &tokens[..index]; - validate_external_tokens(argv_tokens, line)?; - let Some((command, args)) = argv_tokens.split_first() else { - bail!("{line}: redirect requires one command"); - }; - if command == "printf" && token.as_str() == ">" && tokens[index + 1] == "&2" { - return Ok(None); - } - validate_shell_command(command, args, line)?; - if !is_safe_command_name(command) { - bail!("{line}: unsupported statement: {}", tokens.join(" ")); - } - if matches!(tokens[index + 1].as_str(), "&1" | "&2") { - bail!("{line}: unsupported redirect combination"); - } - let path = parse_value_text(&tokens[index + 1], line)?; - let (stream, append) = match token.as_str() { - ">" => (OutputStream::Stdout, false), - ">>" => (OutputStream::Stdout, true), - "2>" => (OutputStream::Stderr, false), - "2>>" => (OutputStream::Stderr, true), - _ => unreachable!(), - }; - Ok(Some(Statement::ExecWrite { - stream, - path, - append, - argv: parse_values(argv_tokens, line)?, - })) -} - -pub(super) fn validate_external_tokens(tokens: &[String], line: usize) -> Result<()> { - for token in tokens { - if matches!(token.as_str(), ">" | ">>" | "2>" | "2>>" | "|") { - bail!("{line}: unsupported shell metacharacter: {token}"); - } - if token.starts_with('"') || token.starts_with('\'') { - continue; - } - if token - .chars() - .any(|ch| matches!(ch, '|' | '>' | '<' | '&' | ';' | '`')) - { - bail!("{line}: unsupported shell metacharacter in token: {token}"); - } - } - Ok(()) -} - -pub(super) fn validate_shell_command(command: &str, args: &[String], line: usize) -> Result<()> { - if SHELL_ONLY_COMMANDS.contains(&command) { - bail!( - "{line}: shell-specific construct is not supported in .seal: {command}; use .sh/.ps1 or file an issue for first-class support" - ); - } - let shell_launch = matches!( - (command, args.first().map(String::as_str)), - ("sh", Some("-c")) - | ("bash", Some("-c")) - | ("pwsh", Some("-Command" | "-command")) - | ("powershell", Some("-Command" | "-command")) - ); - if shell_launch { - bail!( - "{line}: shell-specific construct is not supported in .seal: {command} {}; use .sh/.ps1 or file an issue for first-class support", - args.first().expect("checked first arg exists") - ); - } - Ok(()) -} - -const SHELL_ONLY_COMMANDS: &[&str] = &[ - ".", "alias", "exec", "export", "local", "readonly", "source", "trap", "unalias", "unset", -]; diff --git a/app/src/core/transpile/parse_lex.rs b/app/src/core/transpile/parse_lex.rs deleted file mode 100644 index 988b091..0000000 --- a/app/src/core/transpile/parse_lex.rs +++ /dev/null @@ -1,196 +0,0 @@ -use anyhow::{Result, bail}; - -pub(super) fn assignment(text: &str) -> Option<(&str, &str)> { - let (name, value) = text.split_once('=')?; - is_valid_name(name).then_some((name, value)) -} - -pub(super) fn split_words(text: &str, line: usize) -> Result<Vec<String>> { - let mut words = Vec::new(); - let mut current = String::new(); - let mut quote = None; - let mut parameter_depth = 0_usize; - let mut chars = text.chars().peekable(); - while let Some(ch) = chars.next() { - if quote.is_none() && ch == '$' && chars.peek() == Some(&'{') { - current.push(ch); - current.push(chars.next().expect("peeked char should exist")); - parameter_depth += 1; - continue; - } - match quote { - Some(q) if ch == q => { - current.push(ch); - quote = None; - } - Some(_) => current.push(ch), - None if ch == '\'' || ch == '"' => { - current.push(ch); - quote = Some(ch); - } - None if ch == '}' && parameter_depth > 0 => { - current.push(ch); - parameter_depth -= 1; - } - None if ch == '2' && matches!(chars.peek(), Some('>')) => { - if !current.is_empty() { - words.push(std::mem::take(&mut current)); - } - chars.next(); - if matches!(chars.peek(), Some('>')) { - chars.next(); - words.push("2>>".to_string()); - } else { - words.push("2>".to_string()); - } - } - None if ch == '>' => { - if !current.is_empty() { - words.push(std::mem::take(&mut current)); - } - if matches!(chars.peek(), Some('>')) { - chars.next(); - words.push(">>".to_string()); - } else { - words.push(">".to_string()); - } - } - None if ch == '|' => { - if !current.is_empty() { - words.push(std::mem::take(&mut current)); - } - words.push("|".to_string()); - } - None if ch.is_whitespace() && parameter_depth == 0 => { - if !current.is_empty() { - words.push(std::mem::take(&mut current)); - } - } - None => current.push(ch), - } - } - if let Some(q) = quote { - bail!("{line}: unterminated {q} quote"); - } - if parameter_depth != 0 { - bail!("{line}: unterminated parameter expansion"); - } - if !current.is_empty() { - words.push(current); - } - Ok(words) -} - -pub(super) fn split_test_words(text: &str, line: usize) -> Result<Vec<String>> { - let mut words = Vec::new(); - let mut current = String::new(); - let mut quote = None; - let mut command_depth = 0_usize; - let mut parameter_depth = 0_usize; - let mut chars = text.chars().peekable(); - while let Some(ch) = chars.next() { - if quote.is_none() && ch == '$' && chars.peek() == Some(&'{') { - current.push(ch); - current.push(chars.next().expect("peeked char should exist")); - parameter_depth += 1; - continue; - } - if ch == '$' && chars.peek() == Some(&'(') { - current.push(ch); - current.push(chars.next().expect("peeked char should exist")); - command_depth += 1; - continue; - } - - match quote { - Some(q) if ch == q && command_depth == 0 => { - current.push(ch); - quote = None; - } - Some(_) => { - if ch == ')' && command_depth > 0 { - command_depth -= 1; - } - current.push(ch); - } - None if ch == '\'' || ch == '"' => { - current.push(ch); - quote = Some(ch); - } - None if ch == '}' && parameter_depth > 0 => { - current.push(ch); - parameter_depth -= 1; - } - None if ch.is_whitespace() && parameter_depth == 0 => { - if !current.is_empty() { - words.push(std::mem::take(&mut current)); - } - } - None => current.push(ch), - } - } - if let Some(q) = quote { - bail!("{line}: unterminated {q} quote"); - } - if command_depth != 0 { - bail!("{line}: unterminated command substitution"); - } - if parameter_depth != 0 { - bail!("{line}: unterminated parameter expansion"); - } - if !current.is_empty() { - words.push(current); - } - Ok(words) -} - -pub(super) fn strip_comment(line: &str) -> String { - let mut output = String::new(); - let mut quote = None; - let mut parameter_depth = 0_usize; - let mut chars = line.chars().peekable(); - while let Some(ch) = chars.next() { - if quote.is_none() && ch == '$' && chars.peek() == Some(&'{') { - output.push(ch); - output.push(chars.next().expect("peeked char should exist")); - parameter_depth += 1; - continue; - } - if quote.is_none() && ch == '$' && chars.peek() == Some(&'#') { - output.push(ch); - output.push(chars.next().expect("peeked char should exist")); - continue; - } - match quote { - Some(q) if ch == q => { - output.push(ch); - quote = None; - } - Some(_) => output.push(ch), - None if ch == '\'' || ch == '"' => { - output.push(ch); - quote = Some(ch); - } - None if ch == '}' && parameter_depth > 0 => { - parameter_depth -= 1; - output.push(ch); - } - None if ch == '#' && parameter_depth == 0 => break, - None => output.push(ch), - } - } - output -} - -pub(super) fn is_valid_name(name: &str) -> bool { - let mut bytes = name.bytes(); - matches!(bytes.next(), Some(byte) if byte.is_ascii_alphabetic() || byte == b'_') - && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') -} - -pub(super) fn is_safe_command_name(name: &str) -> bool { - !name.is_empty() - && name - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'/' | b'-')) -} diff --git a/app/src/core/transpile/runner/args.rs b/app/src/core/transpile/runner/args.rs deleted file mode 100644 index 5a1d3ab..0000000 --- a/app/src/core/transpile/runner/args.rs +++ /dev/null @@ -1,163 +0,0 @@ -use anyhow::{Result, bail}; - -use super::*; - -impl<'a> Runner<'a> { - pub(super) fn parse_argv( - &mut self, - specs: &[ArgvSpec], - positional: Option<&ArgvPositional>, - ) -> Result<()> { - let argv = self - .vars - .get("__seal_argv") - .map(|value| split_words(value)) - .unwrap_or_default(); - self.vars - .insert("__seal_argc".to_string(), argv.len().to_string()); - self.vars - .insert("__seal_help".to_string(), "false".to_string()); - for spec in specs { - let value = match spec.kind { - ArgvKind::String => spec.default.clone().unwrap_or_default(), - ArgvKind::Flag => "false".to_string(), - }; - self.vars.insert(spec.name.clone(), value); - } - if let Some(positional) = positional { - self.vars - .insert(positional.name.clone(), positional.default.clone()); - } - let mut index = 0; - while index < argv.len() { - let arg = &argv[index]; - if arg == "--" { - break; - } - if matches!(arg.as_str(), "-h" | "--help" | "help") { - self.vars - .insert("__seal_help".to_string(), "true".to_string()); - index += 1; - continue; - } - let Some(spec) = find_spec(specs, arg) else { - if let Some(positional) = positional { - let current = self.vars.get(&positional.name).cloned().unwrap_or_default(); - if current.is_empty() { - self.vars.insert(positional.name.clone(), arg.clone()); - index += 1; - continue; - } - eprintln!("{}", positional.extra_error.replace("$1", arg)); - bail!("argv parse failed"); - } - eprintln!("unknown option: {arg}"); - bail!("argv parse failed"); - }; - match spec.kind { - ArgvKind::Flag => { - self.vars.insert(spec.name.clone(), "true".to_string()); - index += 1; - } - ArgvKind::String => { - let option = option_name(&spec.name); - if let Some(value) = arg.strip_prefix(&(option.clone() + "=")) { - self.vars.insert(spec.name.clone(), value.to_string()); - index += 1; - } else { - let Some(value) = argv.get(index + 1) else { - eprintln!("missing value for {option}"); - bail!("argv parse failed"); - }; - self.vars.insert(spec.name.clone(), value.clone()); - index += 2; - } - } - } - } - Ok(()) - } - - pub(super) fn set_function_args(&mut self, argv: &[Value]) -> Result<ArgSnapshot> { - let values = self.expanded_values(argv)?; - let old_len = self.argc(); - let old = (0..=old_len) - .map(|index| self.vars.remove(&index.to_string())) - .collect::<Vec<_>>(); - let snapshot = ArgSnapshot { - argv: self.vars.remove("__seal_argv"), - values: old, - }; - self.set_positional_args(&values); - Ok(snapshot) - } - - pub(super) fn restore_function_args(&mut self, old: ArgSnapshot) { - let current_len = self.argc(); - for index in 0..=current_len { - self.vars.remove(&index.to_string()); - } - for (index, value) in old.values.into_iter().enumerate() { - match value { - Some(value) => { - self.vars.insert(index.to_string(), value); - } - None => { - self.vars.remove(&index.to_string()); - } - } - } - match old.argv { - Some(value) => { - self.vars.insert("__seal_argv".to_string(), value); - } - None => { - self.vars.remove("__seal_argv"); - } - } - } - - pub(super) fn expanded_values(&self, values: &[Value]) -> Result<Vec<String>> { - let mut expanded = Vec::new(); - for value in values { - match value { - Value::Args => expanded.extend(self.current_args()), - Value::Argc => expanded.push(self.argc().to_string()), - _ => expanded.push(self.try_value(value)?), - } - } - Ok(expanded) - } - - pub(super) fn argc(&self) -> usize { - self.vars - .get("0") - .and_then(|value| value.parse::<usize>().ok()) - .unwrap_or_default() - } - - pub(super) fn current_args(&self) -> Vec<String> { - (1..=self.argc()) - .filter_map(|index| self.vars.get(&index.to_string()).cloned()) - .collect() - } - - pub(super) fn shift_args(&mut self, count: usize) { - let args = self.current_args(); - let remaining = args.into_iter().skip(count).collect::<Vec<_>>(); - self.set_positional_args(&remaining); - } - - fn set_positional_args(&mut self, values: &[String]) { - let old_len = self.argc(); - for index in 0..=old_len { - self.vars.remove(&index.to_string()); - } - for (index, value) in values.iter().enumerate() { - self.vars.insert((index + 1).to_string(), value.clone()); - } - self.vars.insert("0".to_string(), values.len().to_string()); - self.vars - .insert("__seal_argv".to_string(), shell_words(values)); - } -} diff --git a/app/src/core/transpile/runner/mod.rs b/app/src/core/transpile/runner/mod.rs deleted file mode 100644 index 53f5341..0000000 --- a/app/src/core/transpile/runner/mod.rs +++ /dev/null @@ -1,357 +0,0 @@ -use std::{collections::BTreeMap, path::Path, process::Command, time::Duration}; - -use anyhow::{Context, Result, bail}; - -use crate::core::tool; - -use self::support::{ - ArgSnapshot, CaptureMode, CommandOutput, SourceState, case_matches, find_spec, - map_source_state, option_name, shell_words, split_words, write_stderr, write_stderr_line, - write_stdout, write_stdout_line, write_stream_file, -}; -use super::ast::{ - ArgvKind, ArgvPositional, ArgvSpec, ExpansionOp, Item, Predicate, Program, Statement, Value, - ValueSource, -}; -use super::parse::parse_seal; - -mod args; -mod support; - -pub(crate) fn run_seal_file( - path: &Path, - argv: &[String], - env_overlay: &[(String, String)], -) -> Result<i32> { - let source = std::fs::read_to_string(path) - .with_context(|| format!("failed to read {}", path.display()))?; - let program = parse_seal(&source)?; - let mut runner = Runner::new(&program, argv, env_overlay); - runner.run_program() -} - -struct Runner<'a> { - program: &'a Program, - vars: BTreeMap<String, String>, - env: BTreeMap<String, String>, - stdout_stack: Vec<String>, -} - -enum Flow { - Continue, - Break, - Exit(i32), -} - -impl<'a> Runner<'a> { - fn new(program: &'a Program, argv: &[String], env_overlay: &[(String, String)]) -> Self { - let mut env = std::env::vars().collect::<BTreeMap<_, _>>(); - env.extend(env_overlay.iter().cloned()); - let mut vars = BTreeMap::new(); - vars.insert("__seal_argv".to_string(), shell_words(argv)); - vars.insert("0".to_string(), argv.len().to_string()); - for (index, value) in argv.iter().enumerate() { - vars.insert((index + 1).to_string(), value.clone()); - } - Self { - program, - vars, - env, - stdout_stack: Vec::new(), - } - } - - fn run_program(&mut self) -> Result<i32> { - let statements = self - .program - .items - .iter() - .filter_map(|item| match item { - Item::Statement { statement } => Some(statement), - Item::Function { .. } => None, - }) - .collect::<Vec<_>>(); - match self.run_statements(&statements)? { - Flow::Continue | Flow::Break => Ok(0), - Flow::Exit(code) => Ok(code), - } - } - - fn run_statements(&mut self, statements: &[&Statement]) -> Result<Flow> { - for statement in statements { - match self.run_statement(statement)? { - Flow::Continue => {} - flow => return Ok(flow), - } - } - Ok(Flow::Continue) - } - - fn run_body(&mut self, statements: &[Statement]) -> Result<Flow> { - let refs = statements.iter().collect::<Vec<_>>(); - self.run_statements(&refs) - } - - fn run_statement(&mut self, statement: &Statement) -> Result<Flow> { - match statement { - Statement::Assign { name, value } => { - let value = self.try_value(value)?; - self.vars.insert(name.clone(), value); - } - Statement::ArgvParse { specs, positional } => { - self.parse_argv(specs, positional.as_ref())? - } - Statement::Shift { count } => self.shift_args(*count), - Statement::ExecChecked { argv } => { - let code = self.run_external(argv, CaptureMode::None)?.code; - if code != 0 { - return Ok(Flow::Exit(code)); - } - } - Statement::ExecWrite { - stream, - path, - append, - argv, - } => { - let output = self.run_external(argv, CaptureMode::All)?; - let path = self.try_value(path)?; - write_stream_file(stream, Path::new(&path), *append, &output)?; - if output.code != 0 { - return Ok(Flow::Exit(output.code)); - } - } - Statement::EnvExecChecked { env, argv } => { - let overlay = env - .iter() - .map(|item| Ok((item.name.clone(), self.try_value(&item.value)?))) - .collect::<Result<Vec<_>>>()?; - let code = self - .run_external_with_env(argv, CaptureMode::None, &overlay)? - .code; - if code != 0 { - return Ok(Flow::Exit(code)); - } - } - Statement::CaptureChecked { name, argv } => { - let output = self.run_external(argv, CaptureMode::Stdout)?; - if output.code != 0 { - return Ok(Flow::Exit(output.code)); - } - self.vars - .insert(name.clone(), output.stdout.trim().to_string()); - } - Statement::CaptureFunction { - name, - function, - argv, - } => { - let old_args = self.set_function_args(argv)?; - self.stdout_stack.push(String::new()); - let flow = self.run_function(function)?; - let captured = self - .stdout_stack - .pop() - .expect("stdout capture stack should be balanced"); - self.restore_function_args(old_args); - if !matches!(flow, Flow::Continue) { - return Ok(flow); - } - self.vars.insert(name.clone(), captured.trim().to_string()); - } - Statement::If { - predicate, - then_body, - else_body, - } => { - let flow = if self.predicate(predicate)? { - self.run_body(then_body)? - } else { - self.run_body(else_body)? - }; - if !matches!(flow, Flow::Continue) { - return Ok(flow); - } - } - Statement::While { predicate, body } => { - while self.predicate(predicate)? { - match self.run_body(body)? { - Flow::Continue => {} - Flow::Break => break, - flow => return Ok(flow), - } - } - } - Statement::Case { value, arms } => { - let value = self.try_value(value)?; - for arm in arms { - if arm - .patterns - .iter() - .any(|pattern| case_matches(pattern, &value)) - { - let flow = self.run_body(&arm.body)?; - if !matches!(flow, Flow::Continue) { - return Ok(flow); - } - break; - } - } - } - Statement::CallFunction { name, argv } => { - let old_args = self.set_function_args(argv)?; - let flow = self.run_function(name)?; - self.restore_function_args(old_args); - if !matches!(flow, Flow::Continue) { - return Ok(flow); - } - } - Statement::Print { value } => { - let text = self.try_value(value)?; - write_stdout_line(&mut self.stdout_stack, &text)? - } - Statement::Error { value } => write_stderr_line(&self.try_value(value)?)?, - Statement::Fail { value } => { - write_stderr_line(&self.try_value(value)?)?; - return Ok(Flow::Exit(1)); - } - Statement::Exit { code } => return Ok(Flow::Exit(*code)), - Statement::Break => return Ok(Flow::Break), - Statement::Sleep { seconds } => std::thread::sleep(Duration::from_secs(*seconds)), - } - Ok(Flow::Continue) - } - - fn run_function(&mut self, name: &str) -> Result<Flow> { - let Some(body) = self.program.items.iter().find_map(|item| match item { - Item::Function { - name: function_name, - body, - } if function_name == name => Some(body), - _ => None, - }) else { - bail!("unknown function: {name}"); - }; - self.run_body(body) - } - - fn try_value(&self, value: &Value) -> Result<String> { - Ok(match value { - Value::Literal { text } => text.clone(), - Value::Argc => self.argc().to_string(), - Value::Args => shell_words(&self.current_args()), - Value::Expand { source, op } => self.expand_value(source, op)?, - Value::Concat { parts } => { - let mut combined = String::new(); - for part in parts { - combined.push_str(&self.try_value(part)?); - } - combined - } - }) - } - - fn predicate(&mut self, predicate: &Predicate) -> Result<bool> { - Ok(match predicate { - Predicate::Command { argv } => self.run_external(argv, CaptureMode::None)?.code == 0, - Predicate::Empty { value } => self.try_value(value)?.is_empty(), - Predicate::NotEmpty { value } => !self.try_value(value)?.is_empty(), - Predicate::Eq { left, right } => self.try_value(left)? == self.try_value(right)?, - Predicate::Neq { left, right } => self.try_value(left)? != self.try_value(right)?, - Predicate::IntLt { left, right } => self.int_value(left)? < self.int_value(right)?, - Predicate::IntLte { left, right } => self.int_value(left)? <= self.int_value(right)?, - Predicate::IntGt { left, right } => self.int_value(left)? > self.int_value(right)?, - Predicate::IntGte { left, right } => self.int_value(left)? >= self.int_value(right)?, - Predicate::JsonEmpty { value } => { - self.tool_path(&["json", "empty"], std::slice::from_ref(value))? == "true" - } - Predicate::JsonNotEmpty { value } => { - self.tool_path(&["json", "empty"], std::slice::from_ref(value))? == "false" - } - Predicate::FileExists { path } => Path::new(&self.try_value(path)?).is_file(), - Predicate::DirExists { path } => Path::new(&self.try_value(path)?).is_dir(), - }) - } - - fn int_value(&self, value: &Value) -> Result<i64> { - let value = self.try_value(value)?; - value - .parse::<i64>() - .with_context(|| format!("invalid integer: {value}")) - } - - fn expand_value(&self, source: &ValueSource, op: &ExpansionOp) -> Result<String> { - let state = self.source_state(source); - match op { - ExpansionOp::Plain => Ok(match state { - SourceState::Unset => String::new(), - SourceState::Empty => String::new(), - SourceState::Present(value) => value, - }), - ExpansionOp::DefaultIfUnsetOrEmpty { fallback } => Ok(match state { - SourceState::Present(value) => value, - SourceState::Unset | SourceState::Empty => fallback.clone(), - }), - ExpansionOp::RequireNonEmpty { message } => match state { - SourceState::Present(value) => Ok(value), - SourceState::Unset | SourceState::Empty => bail!("{message}"), - }, - } - } - - fn source_state(&self, source: &ValueSource) -> SourceState { - match source { - ValueSource::Env { name } => map_source_state(self.env.get(name).cloned()), - ValueSource::Var { name } => map_source_state(self.vars.get(name).cloned()), - } - } - - fn tool_path(&self, path: &[&str], argv: &[Value]) -> Result<String> { - let mut args = path.iter().map(|part| part.to_string()).collect::<Vec<_>>(); - args.extend(self.expanded_values(argv)?); - Ok(tool::eval(&args)?.unwrap_or_default()) - } - - fn run_external(&mut self, argv: &[Value], capture: CaptureMode) -> Result<CommandOutput> { - self.run_external_with_env(argv, capture, &[]) - } - - fn run_external_with_env( - &mut self, - argv: &[Value], - capture: CaptureMode, - env_overlay: &[(String, String)], - ) -> Result<CommandOutput> { - let argv = self.expanded_values(argv)?; - let Some((program, args)) = argv.split_first() else { - bail!("external command cannot be empty"); - }; - let mut command = Command::new(program); - command.args(args).envs(&self.env); - command.envs(env_overlay.iter().map(|(key, value)| (key, value))); - if matches!(capture, CaptureMode::None) && self.stdout_stack.is_empty() { - let status = command - .status() - .with_context(|| format!("failed to execute command: {program}"))?; - return Ok(CommandOutput { - code: status.code().unwrap_or(1), - stdout: String::new(), - stderr: String::new(), - }); - } - let output = command - .output() - .with_context(|| format!("failed to execute command: {program}"))?; - let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); - let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); - if matches!(capture, CaptureMode::None) { - write_stdout(&mut self.stdout_stack, &stdout)?; - write_stderr(&stderr)?; - } - Ok(CommandOutput { - code: output.status.code().unwrap_or(1), - stdout, - stderr, - }) - } -} diff --git a/app/src/core/transpile/runner/support.rs b/app/src/core/transpile/runner/support.rs deleted file mode 100644 index 6bfa3e6..0000000 --- a/app/src/core/transpile/runner/support.rs +++ /dev/null @@ -1,146 +0,0 @@ -use std::path::Path; - -use anyhow::{Context, Result}; - -use super::super::ast::{ArgvSpec, OutputStream}; - -pub(super) enum CaptureMode { - None, - Stdout, - All, -} - -pub(super) struct CommandOutput { - pub(super) code: i32, - pub(super) stdout: String, - pub(super) stderr: String, -} - -pub(super) struct ArgSnapshot { - pub(super) argv: Option<String>, - pub(super) values: Vec<Option<String>>, -} - -pub(super) enum SourceState { - Unset, - Empty, - Present(String), -} - -pub(super) fn write_stream_file( - stream: &OutputStream, - path: &Path, - append: bool, - output: &CommandOutput, -) -> Result<()> { - use std::io::Write; - - if let Some(parent) = path.parent() - && !parent.as_os_str().is_empty() - { - std::fs::create_dir_all(parent) - .with_context(|| format!("failed to create parent directory: {}", parent.display()))?; - } - let captured = match stream { - OutputStream::Stdout => output.stdout.as_bytes(), - OutputStream::Stderr => output.stderr.as_bytes(), - }; - if append { - let mut file = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(path) - .with_context(|| format!("failed to append file: {}", path.display()))?; - file.write_all(captured) - .with_context(|| format!("failed to append file: {}", path.display()))?; - } else { - std::fs::write(path, captured) - .with_context(|| format!("failed to write file: {}", path.display()))?; - } - match stream { - OutputStream::Stdout => { - std::io::stderr() - .write_all(output.stderr.as_bytes()) - .context("failed to write stderr")?; - } - OutputStream::Stderr => { - std::io::stdout() - .write_all(output.stdout.as_bytes()) - .context("failed to write stdout")?; - } - } - Ok(()) -} - -pub(super) fn find_spec<'a>(specs: &'a [ArgvSpec], arg: &str) -> Option<&'a ArgvSpec> { - specs.iter().find(|spec| { - let option = option_name(&spec.name); - arg == option || arg.starts_with(&(option + "=")) - }) -} - -pub(super) fn option_name(name: &str) -> String { - format!("--{}", name.replace('_', "-")) -} - -pub(super) fn case_matches(pattern: &str, value: &str) -> bool { - pattern == "*" || pattern == value -} - -pub(super) fn shell_words(argv: &[String]) -> String { - argv.join("\u{1f}") -} - -pub(super) fn split_words(value: &str) -> Vec<String> { - if value.is_empty() { - Vec::new() - } else { - value.split('\u{1f}').map(str::to_string).collect() - } -} - -pub(super) fn map_source_state(value: Option<String>) -> SourceState { - match value { - None => SourceState::Unset, - Some(value) if value.is_empty() => SourceState::Empty, - Some(value) => SourceState::Present(value), - } -} - -pub(super) fn write_stdout(stdout_stack: &mut [String], text: &str) -> Result<()> { - use std::io::Write; - - if text.is_empty() { - return Ok(()); - } - if let Some(buffer) = stdout_stack.last_mut() { - buffer.push_str(text); - return Ok(()); - } - std::io::stdout() - .write_all(text.as_bytes()) - .context("failed to write stdout") -} - -pub(super) fn write_stdout_line(stdout_stack: &mut [String], text: &str) -> Result<()> { - let mut line = text.to_string(); - line.push('\n'); - write_stdout(stdout_stack, &line) -} - -pub(super) fn write_stderr(text: &str) -> Result<()> { - use std::io::Write; - - if text.is_empty() { - return Ok(()); - } - std::io::stderr() - .write_all(text.as_bytes()) - .context("failed to write stderr") -} - -pub(super) fn write_stderr_line(text: &str) -> Result<()> { - let mut line = text.to_string(); - line.push('\n'); - write_stderr(&line) -} diff --git a/app/src/core/transpile/value.rs b/app/src/core/transpile/value.rs deleted file mode 100644 index e7d5e1b..0000000 --- a/app/src/core/transpile/value.rs +++ /dev/null @@ -1,173 +0,0 @@ -use anyhow::{Result, bail}; - -use super::ast::{ExpansionOp, Value, ValueSource}; - -pub(crate) fn parse_value_text(text: &str, line: usize) -> Result<Value> { - if text == "$@" || text == "\"$@\"" { - return Ok(Value::Args); - } - if text == "$#" || text == "\"$#\"" { - return Ok(Value::Argc); - } - if let Some(value) = text - .strip_prefix('\'') - .and_then(|value| value.strip_suffix('\'')) - { - return Ok(Value::Literal { - text: value.to_string(), - }); - } - if let Some(value) = text - .strip_prefix('"') - .and_then(|value| value.strip_suffix('"')) - { - return parse_template(value, line); - } - if let Some(name) = text.strip_prefix('$') { - if is_positional_name(name) { - return Ok(Value::Expand { - source: ValueSource::Var { - name: name.to_string(), - }, - op: ExpansionOp::Plain, - }); - } - if let Some(name) = name - .strip_prefix('{') - .and_then(|name| name.strip_suffix('}')) - { - return parse_braced_expansion(name, line); - } - validate_name(name, line)?; - return Ok(Value::Expand { - source: ValueSource::Var { - name: name.to_string(), - }, - op: ExpansionOp::Plain, - }); - } - if text.contains('$') { - return parse_template(text, line); - } - Ok(Value::Literal { - text: text.to_string(), - }) -} - -fn parse_template(text: &str, line: usize) -> Result<Value> { - let mut parts = Vec::new(); - let mut literal = String::new(); - let mut chars = text.chars().peekable(); - while let Some(ch) = chars.next() { - if ch != '$' { - literal.push(ch); - continue; - } - if !literal.is_empty() { - parts.push(Value::Literal { - text: std::mem::take(&mut literal), - }); - } - if chars.peek() == Some(&'{') { - chars.next(); - let mut inner = String::new(); - for next in chars.by_ref() { - if next == '}' { - break; - } - inner.push(next); - } - parts.push(parse_braced_expansion(&inner, line)?); - continue; - } - let mut name = String::new(); - if let Some(next) = chars.peek().copied() - && next == '@' - { - bail!("{line}: $@ is only supported as a standalone argument"); - } - if let Some(next) = chars.peek().copied() - && next.is_ascii_digit() - { - name.push(next); - chars.next(); - parts.push(Value::Expand { - source: ValueSource::Var { name }, - op: ExpansionOp::Plain, - }); - continue; - } - while let Some(next) = chars.peek().copied() { - if next.is_ascii_alphanumeric() || next == '_' { - name.push(next); - chars.next(); - } else { - break; - } - } - validate_name(&name, line)?; - parts.push(Value::Expand { - source: ValueSource::Var { name }, - op: ExpansionOp::Plain, - }); - } - if !literal.is_empty() { - parts.push(Value::Literal { text: literal }); - } - match parts.as_slice() { - [single] => Ok(single.clone()), - _ => Ok(Value::Concat { parts }), - } -} - -fn is_positional_name(name: &str) -> bool { - !name.is_empty() && name.bytes().all(|byte| byte.is_ascii_digit()) -} - -fn parse_braced_expansion(text: &str, line: usize) -> Result<Value> { - if let Some((name, message)) = text.split_once(":?") { - let source = parse_braced_source(name, line)?; - return Ok(Value::Expand { - source, - op: ExpansionOp::RequireNonEmpty { - message: message.to_string(), - }, - }); - } - if let Some((name, fallback)) = text.split_once(":-") { - let source = parse_braced_source(name, line)?; - return Ok(Value::Expand { - source, - op: ExpansionOp::DefaultIfUnsetOrEmpty { - fallback: fallback.to_string(), - }, - }); - } - let source = parse_braced_source(text, line)?; - Ok(Value::Expand { - source, - op: ExpansionOp::Plain, - }) -} - -fn parse_braced_source(name: &str, line: usize) -> Result<ValueSource> { - if is_positional_name(name) { - return Ok(ValueSource::Var { - name: name.to_string(), - }); - } - validate_name(name, line)?; - Ok(ValueSource::Env { - name: name.to_string(), - }) -} - -fn validate_name(name: &str, line: usize) -> Result<()> { - let mut bytes = name.bytes(); - let valid = matches!(bytes.next(), Some(byte) if byte.is_ascii_alphabetic() || byte == b'_') - && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_'); - if !valid { - bail!("{line}: invalid variable name: {name}"); - } - Ok(()) -} diff --git a/app/tests/first_run.rs b/app/tests/first_run.rs index 4f44b63..cd11650 100644 --- a/app/tests/first_run.rs +++ b/app/tests/first_run.rs @@ -26,7 +26,6 @@ fn internal_help_without_profile() { (vec!["@profile", "--help"], "Usage: runseal @profile"), (vec!["@resources", "--help"], "Usage: runseal @resources"), (vec!["@resolve", "--help"], "Usage: runseal @resolve"), - (vec!["@transpile", "--help"], "Usage: runseal @transpile"), (vec!["@wrappers", "--help"], "Usage: runseal @wrappers"), (vec!["@which", "--help"], "Usage: runseal @which :<wrapper>"), ] { diff --git a/app/tests/fixtures/estate/admin.seal b/app/tests/fixtures/estate/admin.seal deleted file mode 100644 index 7a352aa..0000000 --- a/app/tests/fixtures/estate/admin.seal +++ /dev/null @@ -1,191 +0,0 @@ -print() { - printf '%s\n' "$1" -} - -error() { - printf '%s\n' "$1" >&2 -} - -fail() { - error "$1" - exit 1 -} - -usage() { - print "Usage: runseal :admin <command> [args]" - print "" - print "Commands:" - print " init" - print " check" - print " bootstrap-kube" - print " export <archive.enc>" - print " import [--force] <archive.enc>" -} - -local_dir=${PERISH_TOP_LOCAL_DIR} -ssh_dir=${PERISH_TOP_SSH_DIR} -ssh_config=${PERISH_TOP_SSH_CONFIG} -kube_dir=${PERISH_TOP_KUBE_DIR} -secrets_dir=${PERISH_TOP_SECRETS_DIR} -tmp_dir=${PERISH_TOP_TMP_DIR} - -ensure_dir() { - runseal @tool fs mkdir "$1" 700 -} - -ensure_file() { - runseal @tool fs touch "$1" 600 -} - -check_dir() { - if [ -d "$1" ]; then - current=$(runseal @tool fs mode "$1") - if [ "$current" = 700 ]; then - else - print "bad mode: $1 is $current, expected 700" - ok=false - fi - else - print "missing: $1" - ok=false - fi -} - -check_file_mode() { - if [ -f "$1" ]; then - current=$(runseal @tool fs mode "$1") - if [ "$current" = "$2" ]; then - else - print "bad mode: $1 is $current, expected $2" - ok=false - fi - else - print "missing: $1" - ok=false - fi -} - -check_host() { - allowed=$(runseal @tool ssh config host "$1" --config "$config") - if [ "$allowed" = true ]; then - else - print "missing ssh Host: $1" - ok=false - fi -} - -if [ -z "$1" ]; then - usage - exit 0 -fi - -command=$1 -shift - -case "$command" in - init) - if [ -n "$1" ]; then - fail "admin init: unknown argument: $1" - fi - ensure_dir "$local_dir" - ensure_dir "$ssh_dir" - ensure_dir "$kube_dir" - ensure_dir "$secrets_dir" - ensure_dir "$tmp_dir" - config="$ssh_config" - if [ -f "$config" ]; then - print "exists $config" - else - runseal @tool fs write-base64 "$config" "__SSH_CONFIG_BASE64__" - runseal @tool fs chmod "$config" 600 - print "created $config" - fi - ensure_file "$ssh_dir/id_perish_top_root" - ensure_file "$ssh_dir/known_hosts" - print "admin init: ok" - ;; - check) - if [ -n "$1" ]; then - fail "admin check: unknown argument: $1" - fi - ok=true - config="$ssh_config" - check_dir "$local_dir" - check_dir "$ssh_dir" - check_dir "$kube_dir" - check_dir "$secrets_dir" - check_file_mode "$config" 600 - check_host 10m.hk.zxi - check_host 5m.hk.zxi - check_host la.us.lisa - check_host ny.us.lisa - identities=$(runseal @tool ssh config identities --config "$config") - identity=$(runseal @tool json get "$identities" '.[0]') - check_file_mode "$identity" 600 - kube_files=$(runseal @tool fs list "$kube_dir" --glob "*.yaml" --files --require-nonempty) - kube_file=$(runseal @tool json get "$kube_files" '.[0]') - check_file_mode "$kube_file" 600 - if [ "$ok" = true ]; then - print "admin check: ok" - else - print "admin check: failed" - exit 1 - fi - ;; - bootstrap-kube) - if [ -n "$1" ]; then - fail "admin bootstrap-kube: unknown argument: $1" - fi - ensure_dir "$kube_dir" - host=10m.hk.zxi - context=hk-zxi - server=https://k8s.perish.top:6443 - ops_admin=infra/k8s/access/ops-admin.yaml - setup=nodes/10m-hk-zxi/k3s/bootstrap/35-emit-kubeconfig.sh - kubeconfig="$kube_dir/$context.yaml" - runseal @tool ssh script run --config "$ssh_config" --host "$host" --file "$ops_admin" - raw=$(runseal @tool ssh script capture --config "$ssh_config" --host "$host" --file "$setup" -- "$server" "$context") - runseal @tool fs write "$kubeconfig" "$raw" 600 - print "admin bootstrap-kube: wrote $kubeconfig" - ;; - export) - if [ -z "$1" ]; then - fail "admin export: archive path is required" - fi - archive=$1 - shift - if [ -n "$1" ]; then - fail "admin export: unknown argument: $1" - fi - runseal @tool archive local export --source "$local_dir" --archive "$archive" --password-env PERISH_TOP_LOCAL_PASSWORD - print "admin export: wrote encrypted archive $archive" - ;; - import) - force=false - if [ "$1" = --force ]; then - force=true - shift - fi - if [ -z "$1" ]; then - fail "admin import: archive path is required" - fi - archive=$1 - shift - if [ -n "$1" ]; then - fail "admin import: unknown argument: $1" - fi - if [ "$force" = true ]; then - runseal @tool archive local import --source "$local_dir" --archive "$archive" --password-env PERISH_TOP_LOCAL_PASSWORD --force - else - runseal @tool archive local import --source "$local_dir" --archive "$archive" --password-env PERISH_TOP_LOCAL_PASSWORD - fi - print "admin import: restored .local" - ;; - -h|--help|help) - usage - ;; - *) - usage - exit 2 - ;; -esac diff --git a/app/tests/fixtures/estate/kube.seal b/app/tests/fixtures/estate/kube.seal deleted file mode 100644 index a326e3d..0000000 --- a/app/tests/fixtures/estate/kube.seal +++ /dev/null @@ -1,13 +0,0 @@ -case "${1:-}" in - -h|--help|help) - print "Usage: runseal :kube [kubectl args...]" - print "" - print "Assemble KUBECONFIG from .local/kube/*.yaml and run kubectl with it." - exit 0 - ;; -esac - -kube_dir=${PERISH_TOP_KUBE_DIR:?kube: missing PERISH_TOP_KUBE_DIR} -configs=$(runseal @tool fs list "$kube_dir" --glob "*.yaml" --files --require-nonempty) -kubeconfig=$(runseal @tool string join "$configs" --separator path) -KUBECONFIG="$kubeconfig" kubectl "$@" diff --git a/app/tests/fixtures/estate/pr.seal b/app/tests/fixtures/estate/pr.seal deleted file mode 100644 index 3a7565b..0000000 --- a/app/tests/fixtures/estate/pr.seal +++ /dev/null @@ -1,161 +0,0 @@ -print() { - printf '%s\n' "$1" -} - -error() { - printf '%s\n' "$1" >&2 -} - -fail() { - error "$1" - exit 1 -} - -usage() { - print "Usage: runseal :pr [options] <message>" -} - -base=main -branch= -body= -no_merge=false -dry_run=false -resume=false -message= - -while [ "$#" -gt 0 ]; do - case "$1" in - --base) - if [ "$#" -lt 2 ]; then - fail "missing value for --base" - fi - base=$2 - shift 2 - ;; - --branch) - if [ "$#" -lt 2 ]; then - fail "missing value for --branch" - fi - branch=$2 - shift 2 - ;; - --body) - if [ "$#" -lt 2 ]; then - fail "missing value for --body" - fi - body=$2 - shift 2 - ;; - --no-merge) - no_merge=true - shift - ;; - --dry-run) - dry_run=true - shift - ;; - --resume) - resume=true - shift - ;; - -h|--help|help) - usage - exit 0 - ;; - *) - if [ -z "$message" ]; then - message=$1 - shift - else - fail "pr: unexpected argument: $1" - fi - ;; - esac -done - -if [ -z "$message" ]; then - usage - exit 2 -fi - -if [ -z "$body" ]; then - body="$message" -fi - -current=$(git branch --show-current) - -if [ -z "$branch" ]; then - if [ "$resume" = true ]; then - branch="$current" - else - slug=$(runseal @tool string slug "$message" --max-len 48 --fallback change) - branch="auto/$slug" - fi -fi - -token_file="${PERISH_TOP_SECRETS_DIR}/gitee.env" -origin=$(git remote get-url origin) -repo_json=$(runseal @tool gitee repo parse-origin "$origin") -owner=$(runseal @tool json get "$repo_json" .owner) -repo=$(runseal @tool json get "$repo_json" .repo) - -if [ "$dry_run" = true ]; then - print "branch: $branch" - print "base: $base" - print "owner: $owner" - print "repo: $repo" - print "resume: $resume" - exit 0 -fi - -if [ "$resume" = true ]; then - if [ "$branch" = "$base" ]; then - fail "pr: --resume needs a topic branch, not the base branch" - fi - if [ "$current" = "$branch" ]; then - else - if git checkout "$branch"; then - else - git fetch origin "$branch" - git checkout -B "$branch" "origin/$branch" - fi - fi - dirty=$(git status --short) - if [ -n "$dirty" ]; then - fail "pr: --resume requires a clean topic branch" - fi -else - if [ "$current" = "$base" ]; then - else - fail "pr: must start from $base, current branch is $current" - fi - dirty=$(git status --short) - if [ -z "$dirty" ]; then - fail "pr: no local changes to land" - fi - git checkout -b "$branch" - git add -A - git commit -m "$message" -fi - -git push -u origin "$branch" - -pr=$(runseal @tool gitee pr find --owner "$owner" --repo "$repo" --token-file "$token_file" --head "$branch" --base "$base") -if [ "$(runseal @tool json empty "$pr")" = true ]; then - pr=$(runseal @tool gitee pr create --owner "$owner" --repo "$repo" --token-file "$token_file" --base "$base" --head "$branch" --title "$message" --body "$body") -fi -number=$(runseal @tool json get "$pr" .number) -url=$(runseal @tool json get "$pr" .html_url) - -if [ "$no_merge" = false ]; then - runseal @tool gitee pr pass-gates --owner "$owner" --repo "$repo" --token-file "$token_file" --number "$number" - runseal @tool gitee pr merge --owner "$owner" --repo "$repo" --token-file "$token_file" --number "$number" --method squash - git checkout "$base" - git pull --ff-only origin "$base" - git push origin --delete "$branch" - git branch -D "$branch" -else - git checkout "$base" -fi - -print "$url" diff --git a/app/tests/fixtures/estate/ssh.seal b/app/tests/fixtures/estate/ssh.seal deleted file mode 100644 index c9c6ca4..0000000 --- a/app/tests/fixtures/estate/ssh.seal +++ /dev/null @@ -1,73 +0,0 @@ -print() { - printf '%s\n' "$1" -} - -error() { - printf '%s\n' "$1" >&2 -} - -fail() { - error "$1" - exit 1 -} - -usage() { - print "Usage: runseal :ssh <host> [--run <script> [-- <args>...] | -- <remote-command>...]" -} - -ssh_config=${PERISH_TOP_SSH_CONFIG} -run_script=false -script= - -if [ -z "$1" ]; then - usage - exit 0 -fi - -case "$1" in - -h|--help|help) - usage - exit 0 - ;; -esac - -host=$1 -shift - -if [ -n "$1" ]; then - if [ "$1" = --run ]; then - run_script=true - shift - if [ -z "$1" ]; then - usage - exit 2 - fi - script=$1 - shift - if [ -n "$1" ]; then - if [ "$1" = -- ]; then - shift - else - fail "ssh: script arguments must be separated with --" - fi - fi - else - if [ "$1" = -- ]; then - shift - else - fail "ssh: remote command must be separated with --" - fi - fi -fi - -if [ "$run_script" = true ]; then - runseal @tool ssh script run --config "$ssh_config" --host "$host" --file "$script" -- "$@" -else - allowed=$(runseal @tool ssh config host "$host" --config "$ssh_config") - if [ "$allowed" = true ]; then - else - fail "ssh: host is not declared in $ssh_config: $host" - fi - - ssh -F "$ssh_config" "$host" "$@" -fi diff --git a/app/tests/internal.rs b/app/tests/internal.rs index c48eb37..d8f03b3 100644 --- a/app/tests/internal.rs +++ b/app/tests/internal.rs @@ -136,11 +136,10 @@ fn help_explains_model() { assert!(stdout.contains("runseal <cmd>")); assert!(stdout.contains("runseal :<name>")); assert!(stdout.contains("runseal @<name>")); - assert!(stdout.contains(".seal files are bash-runnable")); - assert!(stdout.contains("runseal @tool for atomic glue")); + assert!(stdout.contains(".seal files are reserved")); assert!(stdout.contains("@profile")); assert!(stdout.contains("@resolve")); - assert!(stdout.contains("@transpile")); + assert!(stdout.contains("@tool")); assert!(stdout.contains("current directory upward")); assert!(stdout.contains("https://github.com/PerishCode/runseal")); } @@ -154,11 +153,12 @@ fn internal_help_topics() { (vec!["@profile", "help"], "Profile discovery"), (vec!["@resources", "--help"], "Usage: runseal @resources"), (vec!["@resolve", "--help"], "Usage: runseal @resolve"), - (vec!["@transpile", "--help"], "Usage: runseal @transpile"), - (vec!["@transpile", "-h"], "constrained bash subset"), (vec!["@tool", "--help"], "Usage: runseal @tool"), (vec!["@wrappers", "--help"], "Lookup order"), - (vec!["@wrappers", "-h"], ".seal wrappers are bash-runnable"), + ( + vec!["@wrappers", "-h"], + ".seal wrapper execution is unavailable", + ), (vec!["@which", "--help"], "Usage: runseal @which :<wrapper>"), ] { let output = run_in(&fx, &args); diff --git a/app/tests/internal_wrappers.rs b/app/tests/internal_wrappers.rs index cf9c948..23bd986 100644 --- a/app/tests/internal_wrappers.rs +++ b/app/tests/internal_wrappers.rs @@ -169,213 +169,33 @@ fn extensionless_is_ignored() { } #[test] -fn seal_wrapper_runs_directly() { +fn seal_unavailable() { let fx = fixture(); make_seal_wrapper( &fx.project_wrappers.join("seal-tool.seal"), - r#" -__seal_argc=$# -__seal_help=false -name=world -loud=false -while [ "$#" -gt 0 ]; do - case "$1" in - --name) - if [ "$#" -lt 2 ]; then fail "missing value for --name"; fi - name=$2 - shift 2 - ;; - --name=*) - name=${1#--name=} - shift - ;; - --loud) - loud=true - shift - ;; - --) - shift - break - ;; - -h|--help|help) - __seal_help=true - shift - ;; - *) fail "unknown option: $1" ;; - esac -done -if [ "$__seal_argc" = 0 ]; then - print "hello $name" -else - if [ "$loud" = true ]; then - print "HELLO $name from ${RUNSEAL_WRAPPER_NAME}" - else - print "hello $name" - fi -fi -"#, + "| echo sealed\n", ); - let output = run_in(&fx, &[":seal-tool", "--name", "seal", "--loud"]); + let output = run_in(&fx, &[":seal-tool"]); - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert_eq!(stdout, "HELLO seal from seal-tool\n"); -} - -#[test] -fn seal_captures_local_output() { - let fx = fixture(); - make_seal_wrapper( - &fx.project_wrappers.join("capture-local.seal"), - r#" -helper() { - print "hello $1" -} - -value=$(helper seal) -print "$value" -"#, - ); - - let output = run_in(&fx, &[":capture-local"]); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert_eq!(stdout, "hello seal\n"); -} - -#[test] -fn seal_argv_positional() { - let fx = fixture(); - make_seal_wrapper( - &fx.project_wrappers.join("argv-positional.seal"), - r#" -print() { - printf '%s\n' "$1" -} - -fail() { - print "$1" - exit 1 -} - -__seal_argc=$# -__seal_help=false -body= -message= -while [ "$#" -gt 0 ]; do - case "$1" in - --body) - if [ "$#" -lt 2 ]; then fail "missing value for --body"; fi - body=$2 - shift 2 - ;; - --body=*) - body=${1#--body=} - shift - ;; - --) - shift - break - ;; - -h|--help|help) - __seal_help=true - shift - ;; - *) - if [ -z "$message" ]; then - message=$1 - shift - else - fail "unexpected argument: $1" - fi - ;; - esac -done - -print "$body|$message" -"#, - ); - - let output = run_in(&fx, &[":argv-positional", "--body=demo", "hello"]); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert_eq!(stdout, "demo|hello\n"); -} - -#[test] -fn seal_argv_multiline_guard() { - let fx = fixture(); - make_seal_wrapper( - &fx.project_wrappers.join("argv-multiline-guard.seal"), - r#" -print() { - printf '%s\n' "$1" -} - -fail() { - print "$1" - exit 1 -} - -__seal_argc=$# -__seal_help=false -body= -while [ "$#" -gt 0 ]; do - case "$1" in - --body) - if [ "$#" -lt 2 ]; then - fail "missing value for --body" - fi - body=$2 - shift 2 - ;; - *) fail "unknown option: $1" ;; - esac -done - -print "$body" -"#, - ); - - let output = run_in(&fx, &[":argv-multiline-guard", "--body", "demo"]); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert_eq!(stdout, "demo\n"); + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).expect("stderr should be UTF-8"); + assert!(stderr.contains(".seal wrapper execution is unavailable")); + assert!(stderr.contains(":seal-tool")); } #[test] -fn seal_wrapper_shadows() { +fn seal_shadows_shell() { let fx = fixture(); make_wrapper(&wrapper_file(&fx.project_wrappers, "tool"), "shell"); - make_seal_wrapper(&fx.project_wrappers.join("tool.seal"), "print seal\n"); + make_seal_wrapper(&fx.project_wrappers.join("tool.seal"), "| echo sealed\n"); let output = run_in(&fx, &[":tool"]); - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert_eq!(stdout, "seal\n"); -} - -#[test] -fn seal_env_overlay_fails() { - let fx = fixture(); - make_seal_wrapper( - &fx.project_wrappers.join("env-tool.seal"), - r#" -RUNSEAL_MARKER=sealed sh -c 'printf %s "$RUNSEAL_MARKER"' -"#, - ); - - let output = run_in(&fx, &[":env-tool"]); - assert!(!output.status.success()); let stderr = String::from_utf8(output.stderr).expect("stderr should be UTF-8"); - assert!(stderr.contains("shell-specific construct is not supported in .seal")); - assert!(stderr.contains("sh -c")); + assert!(stderr.contains(".seal wrapper execution is unavailable")); + assert!(stderr.contains(":tool")); } #[test] diff --git a/app/tests/operator.rs b/app/tests/operator.rs deleted file mode 100644 index c46ca46..0000000 --- a/app/tests/operator.rs +++ /dev/null @@ -1,10 +0,0 @@ -#[path = "operator/cloudflare.rs"] -mod cloudflare; -#[path = "operator/estate.rs"] -mod estate; -#[path = "operator/guard.rs"] -mod guard; -#[path = "operator/init.rs"] -mod init; -#[path = "operator/repo.rs"] -mod repo; diff --git a/app/tests/operator/cloudflare.rs b/app/tests/operator/cloudflare.rs deleted file mode 100644 index 4ca8a2b..0000000 --- a/app/tests/operator/cloudflare.rs +++ /dev/null @@ -1,426 +0,0 @@ -#![cfg(unix)] - -use std::{ - ffi::OsString, - io::{Read, Write}, - net::TcpListener, - path::{Path, PathBuf}, - process::Command, - thread, -}; - -use tempfile::TempDir; - -struct Fixture { - _temp: TempDir, - project: PathBuf, -} - -fn fixture() -> Fixture { - let temp = TempDir::new().expect("temp dir should be created"); - let project = temp.path().join("project"); - std::fs::create_dir_all(project.join(".runseal/wrappers")) - .expect("wrapper dir should be created"); - std::fs::write( - project.join("runseal.toml"), - r#" -[resources] -root = ".local" - -[[injections]] -type = "env" - -[injections.vars] -RUNSEAL_REPO_LOCAL_DIR = "resource://" -RUNSEAL_REPO_SECRETS_DIR = "resource://secrets" -RUNSEAL_REPO_TMP_DIR = "resource://tmp" -"#, - ) - .expect("profile should be written"); - std::fs::write( - project.join(".runseal/wrappers/cloudflare.seal"), - std::fs::read_to_string(repo_root().join(".runseal/wrappers/cloudflare.seal")) - .expect("repo cloudflare seal should be readable"), - ) - .expect("cloudflare seal should be copied"); - Fixture { - _temp: temp, - project, - } -} - -fn repo_root() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .expect("app dir should have repo parent") - .to_path_buf() -} - -fn run_cloudflare(fx: &Fixture, args: &[&str]) -> std::process::Output { - run_cloudflare_with_env(fx, args, &[]) -} - -fn run_cloudflare_with_env( - fx: &Fixture, - args: &[&str], - envs: &[(&str, String)], -) -> std::process::Output { - let mut command = Command::new(env!("CARGO_BIN_EXE_runseal")); - command - .current_dir(&fx.project) - .env("PATH", prepend_path()) - .arg("-p") - .arg(fx.project.join("runseal.toml")) - .arg(":cloudflare") - .args(args); - for (key, value) in envs { - command.env(key, value); - } - command.output().expect("cloudflare wrapper should run") -} - -fn prepend_path() -> OsString { - let mut paths = Vec::new(); - if let Some(runseal_dir) = Path::new(env!("CARGO_BIN_EXE_runseal")).parent() { - paths.push(runseal_dir.to_path_buf()); - } - if let Some(existing) = std::env::var_os("PATH") { - paths.extend(std::env::split_paths(&existing)); - } - std::env::join_paths(paths).expect("PATH should be joinable") -} - -fn run_cloudflare_tool(args: &[&str], envs: &[(&str, String)]) -> std::process::Output { - let mut command = Command::new(env!("CARGO_BIN_EXE_runseal")); - command.args(args); - for (key, value) in envs { - command.env(key, value); - } - command.output().expect("cloudflare tool should run") -} - -fn write_credentials(fx: &Fixture) { - let secrets = fx.project.join(".local/secrets"); - std::fs::create_dir_all(&secrets).expect("secrets dir should be created"); - std::fs::write( - secrets.join("cloudflare.env"), - "\ -CLOUDFLARE_ACCOUNT_ID=account-123 -CLOUDFLARE_API_TOKEN=token-456 -CLOUDFLARE_ZONE_NAME=perish.uk -CLOUDFLARE_MANAGE_HOST=runseal.perish.uk -CLOUDFLARE_MANAGE_ORIGIN_HOST=releases.runseal.perish.uk -CLOUDFLARE_MANAGE_REDIRECT_PREFIX= -", - ) - .expect("credentials should be written"); -} - -fn tool_credentials() -> (TempDir, PathBuf) { - let temp = TempDir::new().expect("temp dir should be created"); - let secrets = temp.path().join("secrets"); - std::fs::create_dir_all(&secrets).expect("secrets dir should be created"); - std::fs::write( - secrets.join("cloudflare.env"), - "\ -CLOUDFLARE_ACCOUNT_ID=account-123 -CLOUDFLARE_API_TOKEN=token-456 -", - ) - .expect("credentials should be written"); - (temp, secrets) -} - -fn mock_cloudflare<F>(assert_request: F, body: &'static str) -> (String, thread::JoinHandle<()>) -where - F: FnOnce(&str) + Send + 'static, -{ - let server = TcpListener::bind("127.0.0.1:0").expect("mock server should bind"); - let address = server - .local_addr() - .expect("mock server address should exist"); - let handle = thread::spawn(move || { - let (mut stream, _) = server.accept().expect("mock request should arrive"); - let mut request = [0_u8; 4096]; - let read = stream - .read(&mut request) - .expect("request should be readable"); - let request = String::from_utf8_lossy(&request[..read]); - assert_request(&request); - write!( - stream, - "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}", - body.len(), - body - ) - .expect("response should be written"); - }); - (format!("http://{address}"), handle) -} - -fn stdout(output: &std::process::Output) -> String { - String::from_utf8(output.stdout.clone()).expect("stdout should be UTF-8") -} - -fn stderr(output: &std::process::Output) -> String { - String::from_utf8(output.stderr.clone()).expect("stderr should be UTF-8") -} - -#[test] -fn cloudflare_init_writes_template() { - let fx = fixture(); - - let output = run_cloudflare(&fx, &["init"]); - - assert!(output.status.success(), "stderr: {}", stderr(&output)); - assert!(stdout(&output).contains("created")); - let token_file = fx.project.join(".local/secrets/cloudflare.env"); - let text = std::fs::read_to_string(token_file).expect("token template should exist"); - assert!(text.contains("CLOUDFLARE_ACCOUNT_ID=")); - assert!(text.contains("CLOUDFLARE_ZONE_NAME=perish.uk")); -} - -#[test] -fn manage_plan_uses_tool() { - let fx = fixture(); - write_credentials(&fx); - - let output = run_cloudflare(&fx, &["manage-plan"]); - - assert!(output.status.success(), "stderr: {}", stderr(&output)); - let stdout = stdout(&output); - assert!(stdout.contains("manage redirect plan")); - assert!(stdout.contains("runseal_manage_sh_redirect")); - assert!(stdout.contains("https://releases.runseal.perish.uk/manage.sh")); - assert!(stdout.contains("runseal_manage_ps1_redirect")); -} - -#[test] -fn zone_get_uses_tool() { - let (_temp, secrets) = tool_credentials(); - let (api_base, handle) = mock_cloudflare( - move |request| { - assert!(request.starts_with("GET /zones?name=perish.uk ")); - assert!(request.contains("authorization: Bearer token-456")); - }, - r#"{"success":true,"result":[{"id":"zone-123","name":"perish.uk","status":"active"}]}"#, - ); - - let output = run_cloudflare_tool( - &["@tool", "cloudflare", "zone", "get", "--name", "perish.uk"], - &[ - ( - "RUNSEAL_REPO_SECRETS_DIR", - secrets.to_string_lossy().into_owned(), - ), - ("RUNSEAL_CLOUDFLARE_API_BASE", api_base), - ], - ); - - handle.join().expect("mock server should finish"); - assert!(output.status.success(), "stderr: {}", stderr(&output)); - assert_eq!( - stdout(&output), - r#"{"id":"zone-123","name":"perish.uk","status":"active"}"#.to_string() + "\n" - ); -} - -#[test] -fn dns_record_list() { - let (_temp, secrets) = tool_credentials(); - let (api_base, handle) = mock_cloudflare( - move |request| { - assert!(request.starts_with("GET /zones/zone-123/dns_records?name=sidecar.perish.uk ")); - assert!(request.contains("authorization: Bearer token-456")); - }, - r#"{"success":true,"result":[{"id":"record-123","name":"sidecar.perish.uk"}]}"#, - ); - - let output = run_cloudflare_tool( - &[ - "@tool", - "cloudflare", - "zone", - "dns-record", - "list", - "--zone-id", - "zone-123", - "--name", - "sidecar.perish.uk", - ], - &[ - ( - "RUNSEAL_REPO_SECRETS_DIR", - secrets.to_string_lossy().into_owned(), - ), - ("RUNSEAL_CLOUDFLARE_API_BASE", api_base), - ], - ); - - handle.join().expect("mock server should finish"); - assert!(output.status.success(), "stderr: {}", stderr(&output)); - assert_eq!( - stdout(&output), - r#"[{"id":"record-123","name":"sidecar.perish.uk"}]"#.to_string() + "\n" - ); -} - -#[test] -fn dns_record_create() { - let (_temp, secrets) = tool_credentials(); - let record = r#"{"type":"CNAME","name":"sidecar.perish.uk","content":"releases.sidecar.perish.uk","ttl":1,"proxied":true}"#; - let (api_base, handle) = mock_cloudflare( - move |request| { - assert!(request.starts_with("POST /zones/zone-123/dns_records ")); - assert!(request.contains("authorization: Bearer token-456")); - assert_json_body(request, record); - }, - r#"{"success":true,"result":{"id":"record-123","type":"CNAME"}}"#, - ); - - let output = run_cloudflare_tool( - &[ - "@tool", - "cloudflare", - "zone", - "dns-record", - "create", - "--zone-id", - "zone-123", - "--json", - record, - ], - &[ - ( - "RUNSEAL_REPO_SECRETS_DIR", - secrets.to_string_lossy().into_owned(), - ), - ("RUNSEAL_CLOUDFLARE_API_BASE", api_base), - ], - ); - - handle.join().expect("mock server should finish"); - assert!(output.status.success(), "stderr: {}", stderr(&output)); - assert_eq!( - stdout(&output), - r#"{"id":"record-123","type":"CNAME"}"#.to_string() + "\n" - ); -} - -#[test] -fn dns_record_update() { - let (_temp, secrets) = tool_credentials(); - let record = r#"{"type":"CNAME","name":"sidecar.perish.uk","content":"releases.sidecar.perish.uk","ttl":1,"proxied":true}"#; - let (api_base, handle) = mock_cloudflare( - move |request| { - assert!(request.starts_with("PATCH /zones/zone-123/dns_records/record-123 ")); - assert!(request.contains("authorization: Bearer token-456")); - assert_json_body(request, record); - }, - r#"{"success":true,"result":{"id":"record-123","modified":true}}"#, - ); - - let output = run_cloudflare_tool( - &[ - "@tool", - "cloudflare", - "zone", - "dns-record", - "update", - "--zone-id", - "zone-123", - "--record-id", - "record-123", - "--json", - record, - ], - &[ - ( - "RUNSEAL_REPO_SECRETS_DIR", - secrets.to_string_lossy().into_owned(), - ), - ("RUNSEAL_CLOUDFLARE_API_BASE", api_base), - ], - ); - - handle.join().expect("mock server should finish"); - assert!(output.status.success(), "stderr: {}", stderr(&output)); - assert_eq!( - stdout(&output), - r#"{"id":"record-123","modified":true}"#.to_string() + "\n" - ); -} - -#[test] -fn dns_record_bad_json() { - let (_temp, secrets) = tool_credentials(); - - let output = run_cloudflare_tool( - &[ - "@tool", - "cloudflare", - "zone", - "dns-record", - "create", - "--zone-id", - "zone-123", - "--json", - "{", - ], - &[( - "RUNSEAL_REPO_SECRETS_DIR", - secrets.to_string_lossy().into_owned(), - )], - ); - - assert!(!output.status.success()); - assert!(stderr(&output).contains("invalid DNS record JSON")); -} - -fn assert_json_body(request: &str, expected: &str) { - let body = request - .split_once("\r\n\r\n") - .map(|(_, body)| body) - .expect("request should include a body"); - let actual: serde_json::Value = serde_json::from_str(body).expect("body should be JSON"); - let expected: serde_json::Value = - serde_json::from_str(expected).expect("expected body should be JSON"); - assert_eq!(actual, expected); -} - -#[test] -fn api_passthrough_uses_tool() { - let fx = fixture(); - write_credentials(&fx); - let server = TcpListener::bind("127.0.0.1:0").expect("mock server should bind"); - let address = server - .local_addr() - .expect("mock server address should exist"); - let handle = thread::spawn(move || { - let (mut stream, _) = server.accept().expect("mock request should arrive"); - let mut request = [0_u8; 2048]; - let read = stream - .read(&mut request) - .expect("request should be readable"); - let request = String::from_utf8_lossy(&request[..read]); - assert!(request.starts_with("GET /zones?name=perish.uk ")); - let body = r#"{"success":true,"result":[{"id":"zone-123"}]}"#; - write!( - stream, - "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}", - body.len(), - body - ) - .expect("response should be written"); - }); - - let output = run_cloudflare_with_env( - &fx, - &["api", "GET", "/zones", "--query", "name=perish.uk"], - &[("RUNSEAL_CLOUDFLARE_API_BASE", format!("http://{address}"))], - ); - - handle.join().expect("mock server should finish"); - assert!(output.status.success(), "stderr: {}", stderr(&output)); - assert!(stdout(&output).contains(r#""id":"zone-123""#)); -} diff --git a/app/tests/operator/estate.rs b/app/tests/operator/estate.rs deleted file mode 100644 index d607e67..0000000 --- a/app/tests/operator/estate.rs +++ /dev/null @@ -1,486 +0,0 @@ -#![cfg(unix)] - -#[path = "estate/pr.rs"] -mod pr; - -use std::{ - ffi::OsString, - path::{Path, PathBuf}, - process::Command, -}; - -use tempfile::TempDir; - -struct Fixture { - _temp: TempDir, - project: PathBuf, - bin: PathBuf, - log: PathBuf, -} - -fn fixture() -> Fixture { - let temp = TempDir::new().expect("temp dir should be created"); - let project = temp.path().join("project"); - let bin = temp.path().join("bin"); - let log = temp.path().join("commands.log"); - std::fs::create_dir_all(project.join(".runseal/wrappers")) - .expect("wrapper dir should be created"); - std::fs::create_dir_all(&bin).expect("bin dir should be created"); - write_profile(&project); - write_wrappers(&project); - write_git_stub(&bin.join("git")); - write_ssh_stub(&bin.join("ssh")); - write_kubectl_stub(&bin.join("kubectl")); - Fixture { - _temp: temp, - project, - bin, - log, - } -} - -fn write_profile(project: &Path) { - std::fs::write( - project.join("runseal.toml"), - r#" -[resources] -root = ".local" - -[[injections]] -type = "env" - -[injections.vars] -PERISH_TOP_LOCAL_DIR = "resource://" -PERISH_TOP_SSH_DIR = "resource://ssh" -PERISH_TOP_SSH_CONFIG = "resource://ssh/config" -PERISH_TOP_KUBE_DIR = "resource://kube" -PERISH_TOP_SECRETS_DIR = "resource://secrets" -PERISH_TOP_TMP_DIR = "resource://tmp" -"#, - ) - .expect("profile should be written"); -} - -fn write_wrappers(project: &Path) { - let wrappers = project.join(".runseal/wrappers"); - std::fs::write( - wrappers.join("admin.seal"), - ADMIN_SEAL.replace("__SSH_CONFIG_BASE64__", SSH_CONFIG_BASE64), - ) - .expect("admin seal should be written"); - std::fs::write(wrappers.join("ssh.seal"), SSH_SEAL).expect("ssh seal should be written"); - std::fs::write(wrappers.join("kube.seal"), KUBE_SEAL).expect("kube seal should be written"); - std::fs::write(wrappers.join("pr.seal"), PR_SEAL).expect("pr seal should be written"); -} - -fn write_git_stub(path: &Path) { - write_executable( - path, - r#"#!/usr/bin/env sh -set -eu -case "$1" in - checkout) - if [ "${RUNSEAL_TEST_CHECKOUT_FAIL:-}" = "${2:-}" ]; then - printf 'git' >> "$RUNSEAL_TEST_LOG" - for arg in "$@"; do - printf '|%s' "$arg" >> "$RUNSEAL_TEST_LOG" - done - printf '\n' >> "$RUNSEAL_TEST_LOG" - exit 1 - fi - ;; - branch) - if [ "${2:-}" = "--show-current" ]; then - printf '%s\n' "${RUNSEAL_TEST_BRANCH:-main}" - exit 0 - fi - ;; - remote) - if [ "${2:-}" = "get-url" ] && [ "${3:-}" = "origin" ]; then - printf 'git@gitee.com:perishme/perish.top.git\n' - exit 0 - fi - ;; - status) - if [ "${2:-}" = "--short" ]; then - printf '%s\n' "${RUNSEAL_TEST_STATUS- M docs.md}" - exit 0 - fi - ;; -esac -printf 'git' >> "$RUNSEAL_TEST_LOG" -for arg in "$@"; do - printf '|%s' "$arg" >> "$RUNSEAL_TEST_LOG" -done -printf '\n' >> "$RUNSEAL_TEST_LOG" -"#, - ); -} - -fn write_ssh_stub(path: &Path) { - write_executable( - path, - r#"#!/usr/bin/env sh -set -eu -printf 'ssh' >> "$RUNSEAL_TEST_LOG" -for arg in "$@"; do - printf '|%s' "$arg" >> "$RUNSEAL_TEST_LOG" -done -printf '\n' >> "$RUNSEAL_TEST_LOG" -cat >/dev/null -printf 'captured-kube' -"#, - ); -} - -fn write_kubectl_stub(path: &Path) { - write_executable( - path, - r#"#!/usr/bin/env sh -set -eu -printf 'kubectl|KUBECONFIG=%s' "${KUBECONFIG:-}" >> "$RUNSEAL_TEST_LOG" -for arg in "$@"; do - printf '|%s' "$arg" >> "$RUNSEAL_TEST_LOG" -done -printf '\n' >> "$RUNSEAL_TEST_LOG" -"#, - ); -} - -fn write_executable(path: &Path, content: &str) { - use std::os::unix::fs::PermissionsExt; - - std::fs::write(path, content).expect("stub should be written"); - let mut permissions = std::fs::metadata(path) - .expect("stub metadata should be readable") - .permissions(); - permissions.set_mode(0o755); - std::fs::set_permissions(path, permissions).expect("stub should be executable"); -} - -fn run_wrapper(fx: &Fixture, name: &str, args: &[&str]) -> std::process::Output { - run_wrapper_env(fx, name, args, &[]) -} - -fn run_wrapper_env( - fx: &Fixture, - name: &str, - args: &[&str], - envs: &[(&str, String)], -) -> std::process::Output { - let mut command = Command::new(env!("CARGO_BIN_EXE_runseal")); - command - .current_dir(&fx.project) - .env("PATH", prepend_path(&fx.bin)) - .env("RUNSEAL_TEST_LOG", &fx.log) - .arg("-p") - .arg(fx.project.join("runseal.toml")) - .arg(format!(":{name}")) - .args(args); - for (key, value) in envs { - command.env(key, value); - } - command.output().expect("runseal wrapper should run") -} - -fn prepend_path(first: &Path) -> OsString { - let mut paths = vec![first.to_path_buf()]; - if let Some(runseal_dir) = Path::new(env!("CARGO_BIN_EXE_runseal")).parent() { - paths.push(runseal_dir.to_path_buf()); - } - if let Some(existing) = std::env::var_os("PATH") { - paths.extend(std::env::split_paths(&existing)); - } - std::env::join_paths(paths).expect("PATH should be joinable") -} - -fn log(fx: &Fixture) -> String { - std::fs::read_to_string(&fx.log).unwrap_or_default() -} - -#[test] -fn admin_init_check() { - let fx = fixture(); - - let init = run_wrapper(&fx, "admin", &["init"]); - assert!( - init.status.success(), - "stderr: {}", - String::from_utf8_lossy(&init.stderr) - ); - - for path in [".local/ssh", ".local/kube", ".local/secrets", ".local/tmp"] { - assert!(fx.project.join(path).is_dir(), "{path} should exist"); - } - assert!(fx.project.join(".local/ssh/config").is_file()); - assert!(fx.project.join(".local/ssh/id_perish_top_root").is_file()); - assert!(fx.project.join(".local/ssh/known_hosts").is_file()); - - std::fs::write(fx.project.join(".local/ssh/id_perish_top_root"), "key") - .expect("root key should be filled"); - let kubeconfig = fx.project.join(".local/kube/hk-zxi.yaml"); - std::fs::write(&kubeconfig, "kube").expect("kubeconfig should be written"); - set_mode(&kubeconfig, 0o600); - - let check = run_wrapper(&fx, "admin", &["check"]); - assert!( - check.status.success(), - "stdout: {}\nstderr: {}", - String::from_utf8_lossy(&check.stdout), - String::from_utf8_lossy(&check.stderr) - ); - assert!(String::from_utf8_lossy(&check.stdout).contains("admin check: ok")); -} - -#[test] -fn ssh_remote_args() { - let fx = fixture(); - let init = run_wrapper(&fx, "admin", &["init"]); - assert!(init.status.success()); - - let ok = run_wrapper(&fx, "ssh", &["10m.hk.zxi", "--", "uptime", "-p"]); - assert!( - ok.status.success(), - "stderr: {}", - String::from_utf8_lossy(&ok.stderr) - ); - assert_eq!( - log(&fx), - format!( - "ssh|-F|{}|10m.hk.zxi|uptime|-p\n", - fx.project.join(".local/ssh/config").display() - ) - ); - - let denied = run_wrapper(&fx, "ssh", &["unknown.example"]); - assert!(!denied.status.success()); - assert!(String::from_utf8_lossy(&denied.stderr).contains("host is not declared")); -} - -#[test] -fn ssh_help() { - let fx = fixture(); - - let output = run_wrapper(&fx, "ssh", &["--help"]); - - assert!(output.status.success()); - assert_eq!( - String::from_utf8(output.stdout).expect("stdout should be UTF-8"), - "Usage: runseal :ssh <host> [--run <script> [-- <args>...] | -- <remote-command>...]\n" - ); - assert!(output.stderr.is_empty()); - assert!(log(&fx).is_empty()); -} - -#[test] -fn ssh_run_mode() { - let fx = fixture(); - let init = run_wrapper(&fx, "admin", &["init"]); - assert!(init.status.success()); - let script = fx.project.join("probe.sh"); - std::fs::write(&script, "echo probe").expect("script should be written"); - - let output = run_wrapper( - &fx, - "ssh", - &[ - "10m.hk.zxi", - "--run", - script.to_str().unwrap(), - "--", - "one", - "two", - ], - ); - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - assert_eq!( - log(&fx), - format!( - "ssh|-F|{}|10m.hk.zxi|bash|-s|--|one|two\n", - fx.project.join(".local/ssh/config").display() - ) - ); -} - -#[test] -fn admin_bootstrap_kube() { - let fx = fixture(); - let init = run_wrapper(&fx, "admin", &["init"]); - assert!(init.status.success()); - let ops_admin = fx.project.join("infra/k8s/access/ops-admin.yaml"); - let setup = fx - .project - .join("nodes/10m-hk-zxi/k3s/bootstrap/35-emit-kubeconfig.sh"); - std::fs::create_dir_all(ops_admin.parent().expect("ops parent should exist")) - .expect("ops parent should be created"); - std::fs::create_dir_all(setup.parent().expect("setup parent should exist")) - .expect("setup parent should be created"); - std::fs::write(&ops_admin, "ops").expect("ops file should be written"); - std::fs::write(&setup, "setup").expect("setup file should be written"); - - let output = run_wrapper(&fx, "admin", &["bootstrap-kube"]); - assert!( - output.status.success(), - "stdout: {}\nstderr: {}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - let kubeconfig = fx.project.join(".local/kube/hk-zxi.yaml"); - assert_eq!( - std::fs::read_to_string(&kubeconfig).expect("kubeconfig should be readable"), - "captured-kube" - ); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - assert_eq!( - std::fs::metadata(&kubeconfig) - .expect("metadata should be readable") - .permissions() - .mode() - & 0o777, - 0o600 - ); - } - assert_eq!( - log(&fx), - format!( - "ssh|-F|{}|10m.hk.zxi|bash|-s|--\nssh|-F|{}|10m.hk.zxi|bash|-s|--|https://k8s.perish.top:6443|hk-zxi\n", - fx.project.join(".local/ssh/config").display(), - fx.project.join(".local/ssh/config").display() - ) - ); -} - -#[test] -fn admin_archive() { - let fx = fixture(); - let init = run_wrapper(&fx, "admin", &["init"]); - assert!(init.status.success()); - std::fs::write( - fx.project.join(".local/secrets/gitee.env"), - "GITEE_TOKEN=test-token\n", - ) - .expect("secret should be written"); - let archive = fx.project.join("local.enc"); - - let export = run_wrapper_env( - &fx, - "admin", - &["export", archive.to_str().unwrap()], - &[("PERISH_TOP_LOCAL_PASSWORD", "secret".to_string())], - ); - assert!( - export.status.success(), - "stdout: {}\nstderr: {}", - String::from_utf8_lossy(&export.stdout), - String::from_utf8_lossy(&export.stderr) - ); - assert!(archive.is_file()); - - std::fs::remove_dir_all(fx.project.join(".local")).expect(".local should be removable"); - let import = run_wrapper_env( - &fx, - "admin", - &["import", "--force", archive.to_str().unwrap()], - &[("PERISH_TOP_LOCAL_PASSWORD", "secret".to_string())], - ); - assert!( - import.status.success(), - "stdout: {}\nstderr: {}", - String::from_utf8_lossy(&import.stdout), - String::from_utf8_lossy(&import.stderr) - ); - assert_eq!( - std::fs::read_to_string(fx.project.join(".local/secrets/gitee.env")) - .expect("secret should be restored"), - "GITEE_TOKEN=test-token\n" - ); - assert!(String::from_utf8_lossy(&export.stdout).contains("admin export: wrote")); - assert!(String::from_utf8_lossy(&import.stdout).contains("admin import: restored .local")); -} - -#[test] -fn kube_help() { - let fx = fixture(); - - let output = run_wrapper(&fx, "kube", &["--help"]); - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - assert_eq!(log(&fx), ""); - assert_eq!( - String::from_utf8(output.stdout).expect("stdout should be UTF-8"), - "Usage: runseal :kube [kubectl args...]\n\nAssemble KUBECONFIG from .local/kube/*.yaml and run kubectl with it.\n" - ); -} - -#[test] -fn kube_env() { - let fx = fixture(); - std::fs::create_dir_all(fx.project.join(".local/kube")).expect("kube dir should be created"); - std::fs::write(fx.project.join(".local/kube/b.yaml"), "b").expect("kubeconfig should exist"); - std::fs::write(fx.project.join(".local/kube/a.yaml"), "a").expect("kubeconfig should exist"); - - let output = run_wrapper(&fx, "kube", &["auth", "whoami"]); - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - assert_eq!( - log(&fx), - format!( - "kubectl|KUBECONFIG={}:{}|auth|whoami\n", - fx.project - .join(".local/kube/a.yaml") - .canonicalize() - .expect("a path should canonicalize") - .display(), - fx.project - .join(".local/kube/b.yaml") - .canonicalize() - .expect("b path should canonicalize") - .display() - ) - ); -} - -#[test] -fn kube_requires_matching_files() { - let fx = fixture(); - std::fs::create_dir_all(fx.project.join(".local/kube")).expect("kube dir should be created"); - - let output = run_wrapper(&fx, "kube", &["auth", "whoami"]); - assert!( - !output.status.success(), - "wrapper should fail without kube files" - ); - assert_eq!(log(&fx), ""); -} - -fn set_mode(path: &Path, mode: u32) { - use std::os::unix::fs::PermissionsExt; - - let mut permissions = std::fs::metadata(path) - .expect("metadata should be readable") - .permissions(); - permissions.set_mode(mode); - std::fs::set_permissions(path, permissions).expect("mode should be set"); -} - -const SSH_CONFIG_BASE64: &str = "SG9zdCAxMG0uaGsuenhpCiAgSG9zdE5hbWUgNDMuMjUxLjIyNS4xMTMKICBVc2VyIHJvb3QKICBJZGVudGl0eUZpbGUgaWRfcGVyaXNoX3RvcF9yb290CgpIb3N0IDVtLmhrLnp4aQogIEhvc3ROYW1lIDQzLjI1MS4yMjUuODUKICBVc2VyIHJvb3QKICBJZGVudGl0eUZpbGUgaWRfcGVyaXNoX3RvcF9yb290CgpIb3N0IGxhLnVzLmxpc2EKICBIb3N0TmFtZSAxNTQuMjkuMTU4LjEzNAogIFBvcnQgMjc2OTEKICBVc2VyIHJvb3QKICBJZGVudGl0eUZpbGUgaWRfcGVyaXNoX3RvcF9yb290CgpIb3N0IG55LnVzLmxpc2EKICBIb3N0TmFtZSAzOC43Ny4xMzMuMTExCiAgUG9ydCAyMTM2OQogIFVzZXIgcm9vdAogIElkZW50aXR5RmlsZSBpZF9wZXJpc2hfdG9wX3Jvb3QK"; - -const ADMIN_SEAL: &str = include_str!("../fixtures/estate/admin.seal"); - -const SSH_SEAL: &str = include_str!("../fixtures/estate/ssh.seal"); - -const KUBE_SEAL: &str = include_str!("../fixtures/estate/kube.seal"); - -const PR_SEAL: &str = include_str!("../fixtures/estate/pr.seal"); diff --git a/app/tests/operator/estate/pr.rs b/app/tests/operator/estate/pr.rs deleted file mode 100644 index 61b1929..0000000 --- a/app/tests/operator/estate/pr.rs +++ /dev/null @@ -1,327 +0,0 @@ -use std::{ - io::{Read, Write}, - net::TcpListener, - thread, -}; - -use super::{fixture, log, run_wrapper, run_wrapper_env}; - -#[test] -fn api_flow() { - let fx = fixture(); - let secrets = fx.project.join(".local/secrets"); - std::fs::create_dir_all(&secrets).expect("secrets dir should exist"); - std::fs::write(secrets.join("gitee.env"), "GITEE_TOKEN=test-token\n") - .expect("token file should be written"); - let (api_base, handle) = mock_gitee_sequence("feat/seal", true, false); - - let output = run_wrapper_env( - &fx, - "pr", - &["--branch", "feat/seal", "--body", "Body", "Land change"], - &[("RUNSEAL_GITEE_API_BASE", api_base)], - ); - - assert!( - output.status.success(), - "stdout: {}\nstderr: {}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - handle.join().expect("mock server should finish"); - assert!(String::from_utf8_lossy(&output.stdout).contains("https://gitee.test/pr/42")); - assert_eq!( - log(&fx), - "git|checkout|-b|feat/seal\ngit|add|-A\ngit|commit|-m|Land change\ngit|push|-u|origin|feat/seal\ngit|checkout|main\ngit|pull|--ff-only|origin|main\ngit|push|origin|--delete|feat/seal\ngit|branch|-D|feat/seal\n" - ); -} - -#[test] -fn default_branch() { - let fx = fixture(); - let secrets = fx.project.join(".local/secrets"); - std::fs::create_dir_all(&secrets).expect("secrets dir should exist"); - std::fs::write(secrets.join("gitee.env"), "GITEE_TOKEN=test-token\n") - .expect("token file should be written"); - let (api_base, handle) = mock_gitee_sequence("auto/land-change", true, false); - - let output = run_wrapper_env( - &fx, - "pr", - &["Land Change"], - &[("RUNSEAL_GITEE_API_BASE", api_base)], - ); - - assert!( - output.status.success(), - "stdout: {}\nstderr: {}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - handle.join().expect("mock server should finish"); - assert_eq!( - log(&fx), - "git|checkout|-b|auto/land-change\ngit|add|-A\ngit|commit|-m|Land Change\ngit|push|-u|origin|auto/land-change\ngit|checkout|main\ngit|pull|--ff-only|origin|main\ngit|push|origin|--delete|auto/land-change\ngit|branch|-D|auto/land-change\n" - ); -} - -#[test] -fn dry_run() { - let fx = fixture(); - - let output = run_wrapper( - &fx, - "pr", - &["--branch", "feat/seal", "--dry-run", "Land change"], - ); - - assert!( - output.status.success(), - "stdout: {}\nstderr: {}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("branch: feat/seal")); - assert!(stdout.contains("owner: perishme")); - assert!(stdout.contains("repo: perish.top")); - assert_eq!(log(&fx), ""); -} - -#[test] -fn no_merge() { - let fx = fixture(); - let secrets = fx.project.join(".local/secrets"); - std::fs::create_dir_all(&secrets).expect("secrets dir should exist"); - std::fs::write(secrets.join("gitee.env"), "GITEE_TOKEN=test-token\n") - .expect("token file should be written"); - let (api_base, handle) = mock_gitee_sequence("feat/no-merge", false, false); - - let output = run_wrapper_env( - &fx, - "pr", - &["--branch", "feat/no-merge", "--no-merge", "No merge"], - &[("RUNSEAL_GITEE_API_BASE", api_base)], - ); - - assert!( - output.status.success(), - "stdout: {}\nstderr: {}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - handle.join().expect("mock server should finish"); - assert_eq!( - log(&fx), - "git|checkout|-b|feat/no-merge\ngit|add|-A\ngit|commit|-m|No merge\ngit|push|-u|origin|feat/no-merge\ngit|checkout|main\n" - ); -} - -#[test] -fn reuse_existing_pr() { - let fx = fixture(); - let secrets = fx.project.join(".local/secrets"); - std::fs::create_dir_all(&secrets).expect("secrets dir should exist"); - std::fs::write(secrets.join("gitee.env"), "GITEE_TOKEN=test-token\n") - .expect("token file should be written"); - let (api_base, handle) = mock_gitee_sequence("feat/reuse", false, true); - - let output = run_wrapper_env( - &fx, - "pr", - &["--branch", "feat/reuse", "--no-merge", "Reuse existing"], - &[("RUNSEAL_GITEE_API_BASE", api_base)], - ); - - assert!( - output.status.success(), - "stdout: {}\nstderr: {}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - handle.join().expect("mock server should finish"); - assert!(String::from_utf8_lossy(&output.stdout).contains("https://gitee.test/pr/42")); - assert_eq!( - log(&fx), - "git|checkout|-b|feat/reuse\ngit|add|-A\ngit|commit|-m|Reuse existing\ngit|push|-u|origin|feat/reuse\ngit|checkout|main\n" - ); -} - -#[test] -fn resume_local() { - let fx = fixture(); - let secrets = fx.project.join(".local/secrets"); - std::fs::create_dir_all(&secrets).expect("secrets dir should exist"); - std::fs::write(secrets.join("gitee.env"), "GITEE_TOKEN=test-token\n") - .expect("token file should be written"); - let (api_base, handle) = mock_gitee_sequence("feat/resume", true, true); - - let output = run_wrapper_env( - &fx, - "pr", - &["--resume", "Resume change"], - &[ - ("RUNSEAL_GITEE_API_BASE", api_base), - ("RUNSEAL_TEST_BRANCH", "feat/resume".to_string()), - ("RUNSEAL_TEST_STATUS", "".to_string()), - ], - ); - - assert!( - output.status.success(), - "stdout: {}\nstderr: {}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - handle.join().expect("mock server should finish"); - assert_eq!( - log(&fx), - "git|push|-u|origin|feat/resume\ngit|checkout|main\ngit|pull|--ff-only|origin|main\ngit|push|origin|--delete|feat/resume\ngit|branch|-D|feat/resume\n" - ); -} - -#[test] -fn resume_remote() { - let fx = fixture(); - let secrets = fx.project.join(".local/secrets"); - std::fs::create_dir_all(&secrets).expect("secrets dir should exist"); - std::fs::write(secrets.join("gitee.env"), "GITEE_TOKEN=test-token\n") - .expect("token file should be written"); - let (api_base, handle) = mock_gitee_sequence("feat/remote", true, true); - - let output = run_wrapper_env( - &fx, - "pr", - &["--resume", "--branch", "feat/remote", "Resume remote"], - &[ - ("RUNSEAL_GITEE_API_BASE", api_base), - ("RUNSEAL_TEST_BRANCH", "main".to_string()), - ("RUNSEAL_TEST_CHECKOUT_FAIL", "feat/remote".to_string()), - ("RUNSEAL_TEST_STATUS", "".to_string()), - ], - ); - - assert!( - output.status.success(), - "stdout: {}\nstderr: {}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - handle.join().expect("mock server should finish"); - assert_eq!( - log(&fx), - "git|checkout|feat/remote\ngit|fetch|origin|feat/remote\ngit|checkout|-B|feat/remote|origin/feat/remote\ngit|push|-u|origin|feat/remote\ngit|checkout|main\ngit|pull|--ff-only|origin|main\ngit|push|origin|--delete|feat/remote\ngit|branch|-D|feat/remote\n" - ); -} - -#[test] -fn clean_start() { - let fx = fixture(); - - let output = run_wrapper_env( - &fx, - "pr", - &["--branch", "feat/empty", "Empty change"], - &[("RUNSEAL_TEST_STATUS", "".to_string())], - ); - - assert!(!output.status.success()); - assert!(String::from_utf8_lossy(&output.stderr).contains("pr: no local changes to land")); - assert_eq!(log(&fx), ""); -} - -#[test] -fn resume_clean() { - let fx = fixture(); - - let output = run_wrapper_env( - &fx, - "pr", - &["--resume", "Resume dirty"], - &[ - ("RUNSEAL_TEST_BRANCH", "feat/resume".to_string()), - ("RUNSEAL_TEST_STATUS", " M docs.md".to_string()), - ], - ); - - assert!(!output.status.success()); - assert!( - String::from_utf8_lossy(&output.stderr) - .contains("pr: --resume requires a clean topic branch") - ); - assert_eq!(log(&fx), ""); -} - -fn mock_gitee_sequence( - expected_head: &'static str, - merge: bool, - existing_pr: bool, -) -> (String, thread::JoinHandle<()>) { - let server = TcpListener::bind("127.0.0.1:0").expect("mock server should bind"); - let address = server - .local_addr() - .expect("mock server address should exist"); - let handle = thread::spawn(move || { - let mut expected = vec![("GET", "/repos/perishme/perish.top/pulls")]; - if !existing_pr { - expected.push(("POST", "/repos/perishme/perish.top/pulls")); - } - if merge { - expected.push(("POST", "/repos/perishme/perish.top/pulls/42/review")); - expected.push(("POST", "/repos/perishme/perish.top/pulls/42/test")); - expected.push(("PUT", "/repos/perishme/perish.top/pulls/42/merge")); - } - for (index, expected) in expected.into_iter().enumerate() { - let (mut stream, _) = server.accept().expect("mock request should arrive"); - let mut request = [0_u8; 4096]; - let read = stream - .read(&mut request) - .expect("request should be readable"); - let request = String::from_utf8_lossy(&request[..read]); - if expected.0 == "GET" { - assert!( - request.starts_with(&format!("{} {}?", expected.0, expected.1)), - "unexpected request: {request}" - ); - } else { - assert!( - request.starts_with(&format!("{} {} ", expected.0, expected.1)), - "unexpected request: {request}" - ); - } - assert!(request.contains("authorization: token test-token")); - let body = if index == 0 { - assert!(request.contains(&format!("head={}", expected_head.replace('/', "%2F")))); - assert!(request.contains("state=open")); - if request.contains("base=main") { - } else { - panic!("expected base=main filter in request: {request}"); - } - if existing_pr { - Box::leak( - format!( - r#"[{{"number":42,"head":{{"ref":"{expected_head}"}},"base":{{"ref":"main"}},"html_url":"https://gitee.test/pr/42"}}]"# - ) - .into_boxed_str(), - ) - } else { - r#"[]"# - } - } else if !existing_pr && index == 1 { - assert!(request.contains("test-token")); - assert!(request.contains(expected_head)); - r#"{"number":42,"html_url":"https://gitee.test/pr/42"}"# - } else { - r#"{}"# - }; - write!( - stream, - "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}", - body.len(), - body - ) - .expect("response should be written"); - } - }); - (format!("http://{address}"), handle) -} diff --git a/app/tests/operator/guard.rs b/app/tests/operator/guard.rs deleted file mode 100644 index 9f1bd52..0000000 --- a/app/tests/operator/guard.rs +++ /dev/null @@ -1,220 +0,0 @@ -#![cfg(unix)] - -use std::{ - ffi::OsString, - path::{Path, PathBuf}, - process::Command, -}; - -use tempfile::TempDir; - -struct Fixture { - _temp: TempDir, - project: PathBuf, - bin: PathBuf, -} - -fn fixture() -> Fixture { - let temp = TempDir::new().expect("temp dir should be created"); - let project = temp.path().join("project"); - let bin = temp.path().join("bin"); - std::fs::create_dir_all(project.join(".runseal/wrappers")) - .expect("wrapper dir should be created"); - std::fs::create_dir_all(project.join("app/tests")).expect("app tests dir should be created"); - std::fs::create_dir_all(&bin).expect("bin dir should be created"); - - std::fs::write(project.join("runseal.toml"), "injections = []\n") - .expect("profile should be written"); - std::fs::write( - project.join(".runseal/wrappers/guard.seal"), - std::fs::read_to_string(repo_root().join(".runseal/wrappers/guard.seal")) - .expect("repo guard seal should be readable"), - ) - .expect("guard seal should be copied"); - std::fs::write(project.join("app/tests/sample.txt"), "sample\n") - .expect("sample test file should be written"); - - write_executable( - &bin.join("git"), - r#"#!/usr/bin/env sh -set -eu -if [ "${1:-}" = "rev-parse" ] && [ "${2:-}" = "--show-toplevel" ]; then - printf '%s\n' "${RUNSEAL_TEST_ROOT:?}" - exit 0 -fi -exit 0 -"#, - ); - write_executable( - &bin.join("cargo"), - r#"#!/usr/bin/env sh -set -eu -if [ "${1:-}" = "metadata" ]; then - if [ -n "${RUNSEAL_TEST_CARGO_METADATA:-}" ]; then - printf '%s\n' "$RUNSEAL_TEST_CARGO_METADATA" - else - printf '%s\n' '{"packages":[{"version":"0.6.1"}]}' - fi - exit 0 -fi -exit 0 -"#, - ); - write_executable( - &bin.join("curl"), - r#"#!/usr/bin/env sh -set -eu -out="" -while [ "$#" -gt 0 ]; do - case "$1" in - -o) - out="$2" - shift 2 - ;; - -w) - shift 2 - ;; - -s|-S) - shift - ;; - *) - shift - ;; - esac -done -if [ -n "${RUNSEAL_TEST_CURL_BODY:-}" ] && [ -n "$out" ]; then - printf '%s' "$RUNSEAL_TEST_CURL_BODY" > "$out" -fi -printf '%s' "${RUNSEAL_TEST_CURL_STATUS:-404}" -"#, - ); - write_executable( - &bin.join("cat"), - r#"#!/usr/bin/env sh -set -eu -/bin/cat "$@" -"#, - ); - - Fixture { - _temp: temp, - project, - bin, - } -} - -fn repo_root() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .expect("app dir should have repo parent") - .to_path_buf() -} - -fn write_executable(path: &Path, content: &str) { - use std::os::unix::fs::PermissionsExt; - - std::fs::write(path, content).expect("stub should be written"); - let mut permissions = std::fs::metadata(path) - .expect("stub metadata should be readable") - .permissions(); - permissions.set_mode(0o755); - std::fs::set_permissions(path, permissions).expect("stub should be executable"); -} - -fn prepend_path(first: &Path) -> OsString { - let mut paths = vec![first.to_path_buf()]; - if let Some(runseal_dir) = Path::new(env!("CARGO_BIN_EXE_runseal")).parent() { - paths.push(runseal_dir.to_path_buf()); - } - if let Some(existing) = std::env::var_os("PATH") { - paths.extend(std::env::split_paths(&existing)); - } - std::env::join_paths(paths).expect("PATH should be joinable") -} - -fn run_guard(fx: &Fixture, args: &[&str], envs: &[(&str, &str)]) -> std::process::Output { - let mut command = Command::new(env!("CARGO_BIN_EXE_runseal")); - command - .current_dir(&fx.project) - .env("PATH", prepend_path(&fx.bin)) - .env("RUNSEAL_TEST_ROOT", &fx.project) - .arg("-p") - .arg(fx.project.join("runseal.toml")) - .arg(":guard") - .args(args); - for (key, value) in envs { - command.env(key, value); - } - command.output().expect("guard should run") -} - -#[test] -fn version_hash() { - let fx = fixture(); - - let wrapper = run_guard(&fx, &["version-hash"], &[]); - assert!( - wrapper.status.success(), - "stderr: {}", - String::from_utf8_lossy(&wrapper.stderr) - ); - - let tool = Command::new(env!("CARGO_BIN_EXE_runseal")) - .current_dir(&fx.project) - .env("PATH", prepend_path(&fx.bin)) - .env("RUNSEAL_HOME", fx.project.join(".home")) - .args(["@tool", "hash", "tree", "app/tests"]) - .output() - .expect("hash tool should run"); - assert!(tool.status.success()); - - assert_eq!(wrapper.stdout, tool.stdout); -} - -#[test] -fn skip_no_stable() { - let fx = fixture(); - - let output = run_guard( - &fx, - &["version-check"], - &[("RUNSEAL_TEST_CURL_STATUS", "404")], - ); - - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - assert!( - String::from_utf8_lossy(&output.stdout) - .contains("guard version policy: no stable metadata; skipping") - ); -} - -#[test] -fn reject_hash_change_patch() { - let fx = fixture(); - - let output = run_guard( - &fx, - &["version-check"], - &[ - ( - "RUNSEAL_TEST_CARGO_METADATA", - r#"{"packages":[{"version":"0.6.1"}]}"#, - ), - ("RUNSEAL_TEST_CURL_STATUS", "200"), - ( - "RUNSEAL_TEST_CURL_BODY", - r#"{"stableVersion":"0.6.0","guard":{"version":{"hash":"different"}}}"#, - ), - ], - ); - - assert!(!output.status.success()); - assert!( - String::from_utf8_lossy(&output.stderr) - .contains("changed guard.version.hash requires a minor-or-higher bump") - ); -} diff --git a/app/tests/operator/init.rs b/app/tests/operator/init.rs deleted file mode 100644 index 790f42b..0000000 --- a/app/tests/operator/init.rs +++ /dev/null @@ -1,196 +0,0 @@ -#![cfg(unix)] - -use std::{ - ffi::OsString, - path::{Path, PathBuf}, - process::Command, -}; - -use tempfile::TempDir; - -struct Fixture { - _temp: TempDir, - project: PathBuf, - bin: PathBuf, -} - -fn fixture() -> Fixture { - let temp = TempDir::new().expect("temp dir should be created"); - let project = temp.path().join("project"); - let bin = temp.path().join("bin"); - std::fs::create_dir_all(&project).expect("project should be created"); - std::fs::create_dir_all(&bin).expect("bin should be created"); - Command::new("git") - .arg("init") - .arg(&project) - .output() - .expect("git init should run"); - write_required_files(&project); - write_stub(&bin.join("python3")); - write_stub(&bin.join("cargo")); - write_stub(&bin.join("flavor")); - write_stub(&bin.join("sh")); - write_stub(&bin.join("bash")); - write_stub(&bin.join("sed")); - write_stub(&bin.join("grep")); - Fixture { - _temp: temp, - project, - bin, - } -} - -fn write_required_files(project: &Path) { - for path in [ - "Cargo.toml", - "Cargo.lock", - "flavor.toml", - "manage.sh", - "manage.ps1", - "runseal.toml", - ".runseal/hooks/pre-commit", - ".runseal/hooks/commit-msg", - ".runseal/wrappers/cloudflare.seal", - ".runseal/wrappers/guard.seal", - ".runseal/wrappers/init.seal", - ".runseal/wrappers/pr.seal", - ".runseal/wrappers/release.seal", - ".github/workflows/guard.yml", - ".github/workflows/release-beta.yml", - ".github/workflows/release-stable.yml", - ".github/scripts/release/assets/package.sh", - ".github/scripts/release/assets/package.ps1", - ".github/scripts/release/r2/publish.sh", - ".github/scripts/release/smoke/smoke.sh", - ".github/scripts/release/smoke/smoke.ps1", - ] { - let file = project.join(path); - std::fs::create_dir_all(file.parent().expect("file should have a parent")) - .expect("parent should be created"); - std::fs::write(&file, "").expect("required file should be written"); - } - std::fs::write( - project.join(".runseal/wrappers/init.seal"), - std::fs::read_to_string(repo_root().join(".runseal/wrappers/init.seal")) - .expect("repo init seal should be readable"), - ) - .expect("init seal should be copied"); - std::fs::write( - project.join(".runseal/wrappers/guard.seal"), - std::fs::read_to_string(repo_root().join(".runseal/wrappers/guard.seal")) - .expect("repo guard seal should be readable"), - ) - .expect("guard seal should be copied"); - std::fs::write( - project.join(".runseal/hooks/pre-commit"), - std::fs::read_to_string(repo_root().join(".runseal/hooks/pre-commit")) - .expect("repo pre-commit hook should be readable"), - ) - .expect("pre-commit hook should be copied"); - std::fs::write( - project.join(".runseal/hooks/commit-msg"), - std::fs::read_to_string(repo_root().join(".runseal/hooks/commit-msg")) - .expect("repo commit-msg hook should be readable"), - ) - .expect("commit-msg hook should be copied"); - std::fs::write(project.join("runseal.toml"), "injections = []\n") - .expect("profile should be written"); -} - -fn write_stub(path: &Path) { - use std::os::unix::fs::PermissionsExt; - - std::fs::write(path, "#!/usr/bin/env sh\nexit 0\n").expect("stub should be written"); - let mut permissions = std::fs::metadata(path) - .expect("stub metadata should be readable") - .permissions(); - permissions.set_mode(0o755); - std::fs::set_permissions(path, permissions).expect("stub should be executable"); -} - -fn repo_root() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .expect("app dir should have repo parent") - .to_path_buf() -} - -fn run_init(fx: &Fixture, args: &[&str]) -> std::process::Output { - Command::new(env!("CARGO_BIN_EXE_runseal")) - .current_dir(&fx.project) - .env("PATH", prepend_path(&fx.bin)) - .arg("-p") - .arg(fx.project.join("runseal.toml")) - .arg(":init") - .args(args) - .output() - .expect("runseal init should run") -} - -fn prepend_path(first: &Path) -> OsString { - let mut paths = vec![first.to_path_buf()]; - if let Some(runseal_dir) = Path::new(env!("CARGO_BIN_EXE_runseal")).parent() { - paths.push(runseal_dir.to_path_buf()); - } - if let Some(existing) = std::env::var_os("PATH") { - paths.extend(std::env::split_paths(&existing)); - } - std::env::join_paths(paths).expect("PATH should be joinable") -} - -#[test] -fn init_installs_generated_hooks() { - let fx = fixture(); - - let output = run_init(&fx, &[]); - - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - let pre_commit = fx.project.join(".git/hooks/pre-commit"); - let commit_msg = fx.project.join(".git/hooks/commit-msg"); - let pre_commit_text = std::fs::read_to_string(&pre_commit).expect("pre-commit should exist"); - let commit_msg_text = std::fs::read_to_string(&commit_msg).expect("commit-msg should exist"); - assert!(pre_commit_text.contains("runseal init hook")); - assert!(pre_commit_text.contains("runseal :guard")); - assert!(commit_msg_text.contains("runseal init hook")); -} - -#[test] -fn init_help_is_readonly() { - let fx = fixture(); - - let output = run_init(&fx, &["--help"]); - - assert!(output.status.success()); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("Usage: runseal :init")); - assert!(!fx.project.join(".git/hooks/pre-commit").exists()); - assert!(!fx.project.join(".git/hooks/commit-msg").exists()); -} - -#[test] -fn force_backs_up_hook() { - let fx = fixture(); - let pre_commit = fx.project.join(".git/hooks/pre-commit"); - std::fs::write(&pre_commit, "#!/usr/bin/env sh\necho custom\n") - .expect("custom hook should be written"); - - let rejected = run_init(&fx, &[]); - - assert!(!rejected.status.success()); - assert!(String::from_utf8_lossy(&rejected.stderr).contains("rerun with --force")); - - let forced = run_init(&fx, &["--force"]); - - assert!( - forced.status.success(), - "stderr: {}", - String::from_utf8_lossy(&forced.stderr) - ); - assert!(fx.project.join(".git/hooks/pre-commit.bak").is_file()); - let pre_commit_text = std::fs::read_to_string(&pre_commit).expect("pre-commit should exist"); - assert!(pre_commit_text.contains("runseal init hook")); -} diff --git a/app/tests/operator/repo.rs b/app/tests/operator/repo.rs deleted file mode 100644 index de07754..0000000 --- a/app/tests/operator/repo.rs +++ /dev/null @@ -1,464 +0,0 @@ -#![cfg(unix)] - -use std::{ - ffi::OsString, - path::{Path, PathBuf}, - process::Command, -}; - -use tempfile::TempDir; - -struct Fixture { - _temp: TempDir, - project: PathBuf, - bin: PathBuf, - state: PathBuf, -} - -fn fixture() -> Option<Fixture> { - let temp = TempDir::new().expect("temp dir should be created"); - let project = temp.path().join("project"); - let bin = temp.path().join("bin"); - let state = temp.path().join("state"); - std::fs::create_dir_all(&project).expect("project should be created"); - std::fs::create_dir_all(&bin).expect("stub bin dir should be created"); - std::fs::create_dir_all(&state).expect("stub state dir should be created"); - write_stub( - &bin.join("git"), - r#"#!/usr/bin/env sh -set -eu -case "${1:-}" in - --version) - ;; - branch) - [ "${2:-}" = "--show-current" ] || exit 9 - printf '%s\n' "${RUNSEAL_TEST_BRANCH:-feat/seal}" - ;; - remote) - [ "${2:-}" = "get-url" ] || exit 9 - [ "${3:-}" = "origin" ] || exit 9 - printf '%s\n' "${RUNSEAL_TEST_REMOTE_ORIGIN:-git@github.com:PerishCode/runseal.git}" - ;; - rev-parse) - printf '%s\n' "${RUNSEAL_TEST_REF_SHA:-abc123}" - ;; - *) - printf 'git %s\n' "$*" >> "${RUNSEAL_TEST_LOG:?}" - ;; -esac -"#, - ); - write_stub( - &bin.join("gh"), - r#"#!/usr/bin/env sh -set -eu - -log() { - printf 'gh %s\n' "$*" >> "${RUNSEAL_TEST_LOG:?}" -} - -case "${1:-}" in - --version) - ;; - auth) - [ "${2:-}" = status ] || exit 9 - ;; - workflow) - log "$@" - [ "${2:-}" = run ] || exit 9 - printf '%s\n' "${RUNSEAL_TEST_WORKFLOW_OUTPUT:-}" - ;; - run) - log "$@" - case "${2:-}" in - list) - printf '%s\n' "${RUNSEAL_TEST_RUN_LIST:-[]}" - ;; - watch) - ;; - *) - exit 9 - ;; - esac - ;; - pr) - log "$@" - case "${2:-}" in - list) - count_file="${RUNSEAL_TEST_STATE:?}/pr_list_count" - count=0 - if [ -f "$count_file" ]; then - count=$(cat "$count_file") - fi - next=$((count + 1)) - printf '%s\n' "$next" > "$count_file" - if [ "$count" -eq 0 ] && [ "${RUNSEAL_TEST_PR_LIST_FIRST+x}" ]; then - printf '%s\n' "$RUNSEAL_TEST_PR_LIST_FIRST" - elif [ "$count" -gt 0 ] && [ "${RUNSEAL_TEST_PR_LIST_NEXT+x}" ]; then - printf '%s\n' "$RUNSEAL_TEST_PR_LIST_NEXT" - elif [ "${RUNSEAL_TEST_PR_LIST+x}" ]; then - printf '%s\n' "$RUNSEAL_TEST_PR_LIST" - else - printf '%s\n' '[{"number":42,"title":"Seal","state":"OPEN","url":"https://example.test/pull/42","isDraft":false}]' - fi - ;; - create|ready|checks|merge) - ;; - *) - exit 9 - ;; - esac - ;; - *) - log "$@" - ;; -esac -"#, - ); - Some(Fixture { - _temp: temp, - project, - bin, - state, - }) -} - -fn repo_root() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .expect("app dir should have repo parent") - .to_path_buf() -} - -fn write_stub(path: &Path, content: &str) { - use std::os::unix::fs::PermissionsExt; - - std::fs::write(path, content).expect("stub should be written"); - let mut permissions = std::fs::metadata(path) - .expect("stub metadata should be readable") - .permissions(); - permissions.set_mode(0o755); - std::fs::set_permissions(path, permissions).expect("stub should be executable"); -} - -fn run_active_wrapper(fx: &Fixture, name: &str, args: &[&str]) -> std::process::Output { - run_wrapper_env(fx, name, args, &[]) -} - -fn run_wrapper_env( - fx: &Fixture, - name: &str, - args: &[&str], - envs: &[(&str, &str)], -) -> std::process::Output { - let log = fx.project.join("commands.log"); - let path = prepend_path(&fx.bin); - Command::new(env!("CARGO_BIN_EXE_runseal")) - .current_dir(&fx.project) - .env("PATH", path) - .env("RUNSEAL_TEST_LOG", &log) - .env("RUNSEAL_TEST_STATE", &fx.state) - .arg("-p") - .arg(repo_root().join("runseal.toml")) - .arg(format!(":{name}")) - .args(args) - .envs(envs.iter().copied()) - .output() - .expect("active operator wrapper should run") -} - -fn prepend_path(first: &Path) -> OsString { - let mut paths = vec![first.to_path_buf()]; - if let Some(runseal_dir) = Path::new(env!("CARGO_BIN_EXE_runseal")).parent() { - paths.push(runseal_dir.to_path_buf()); - } - if let Some(existing) = std::env::var_os("PATH") { - paths.extend(std::env::split_paths(&existing)); - } - std::env::join_paths(paths).expect("PATH should be joinable") -} - -fn stdout(output: &std::process::Output) -> String { - String::from_utf8(output.stdout.clone()).expect("stdout should be UTF-8") -} - -fn stderr(output: &std::process::Output) -> String { - String::from_utf8(output.stderr.clone()).expect("stderr should be UTF-8") -} - -fn command_log(fx: &Fixture) -> String { - std::fs::read_to_string(fx.project.join("commands.log")).unwrap_or_default() -} - -#[test] -fn pr_help_option() { - let Some(fx) = fixture() else { - return; - }; - - let output = run_active_wrapper(&fx, "pr", &["--help"]); - - assert!(output.status.success()); - let stdout = stdout(&output); - assert!(stdout.contains("Usage: runseal :pr [options]")); - assert!(stdout.contains("--dry-run")); -} - -#[test] -fn pr_dry_run_matches() { - let Some(fx) = fixture() else { - return; - }; - - let output = run_active_wrapper(&fx, "pr", &["--dry-run"]); - - assert!(output.status.success()); - assert_eq!( - stdout(&output), - "\ -branch: feat/seal -base: main -push: True -pr: create if missing, otherwise reuse existing -draft: False -ready: True -watch: True -squash_merge: True -" - ); -} - -#[test] -fn pr_rejects_draft_merge() { - let Some(fx) = fixture() else { - return; - }; - - let output = run_active_wrapper(&fx, "pr", &["--draft", "--dry-run"]); - - assert!(!output.status.success()); - assert!(stderr(&output).contains("pr: --draft requires --no-merge")); -} - -#[test] -fn pr_rejects_base_branch() { - let Some(fx) = fixture() else { - return; - }; - - let output = run_wrapper_env( - &fx, - "pr", - &["--dry-run"], - &[("RUNSEAL_TEST_BRANCH", "main")], - ); - - assert!(!output.status.success()); - assert!(stderr(&output).contains("pr: refusing to open a PR from base branch: main")); -} - -#[test] -fn pr_reuses_draft() { - let Some(fx) = fixture() else { - return; - }; - - let output = run_wrapper_env( - &fx, - "pr", - &["--no-push", "--no-watch", "--no-merge"], - &[( - "RUNSEAL_TEST_PR_LIST", - r#"[{"number":42,"title":"Seal","state":"OPEN","url":"https://example.test/pull/42","isDraft":true}]"#, - )], - ); - - assert!(output.status.success(), "stderr: {}", stderr(&output)); - assert_eq!( - stdout(&output), - "\ -found PR #42: https://example.test/pull/42 -marked PR #42 ready -" - ); - assert_eq!( - command_log(&fx), - "\ -gh pr list --head feat/seal --json number,title,state,url,isDraft -gh pr ready 42 -" - ); -} - -#[test] -fn pr_creates_and_merges() { - let Some(fx) = fixture() else { - return; - }; - - let output = run_wrapper_env( - &fx, - "pr", - &[ - "--title", - "Seal migration", - "--body-file", - "body.md", - "--base", - "develop", - ], - &[ - ("RUNSEAL_TEST_PR_LIST_FIRST", "[]"), - ( - "RUNSEAL_TEST_PR_LIST_NEXT", - r#"[{"number":77,"title":"Seal migration","state":"OPEN","url":"https://example.test/pull/77","isDraft":false}]"#, - ), - ], - ); - - assert!(output.status.success(), "stderr: {}", stderr(&output)); - assert_eq!( - stdout(&output), - "\ -created PR #77: https://example.test/pull/77 -squash-merged PR #77 -" - ); - assert_eq!( - command_log(&fx), - "\ -git push -u origin feat/seal -gh pr list --head feat/seal --json number,title,state,url,isDraft -gh pr create --base develop --head feat/seal --title Seal migration --body-file body.md -gh pr list --head feat/seal --json number,title,state,url,isDraft -gh pr checks 77 --watch --interval 10 -gh pr merge 77 --squash --delete-branch -" - ); -} - -#[test] -fn release_help_without_args() { - let Some(fx) = fixture() else { - return; - }; - - let output = run_active_wrapper(&fx, "release", &[]); - - assert!(output.status.success()); - let stdout = stdout(&output); - assert!(stdout.contains("Usage: runseal :release --channel=stable|beta [options]")); - assert!(stdout.contains("--watch")); -} - -#[test] -fn release_dry_run_matches() { - let Some(fx) = fixture() else { - return; - }; - - let output = run_active_wrapper( - &fx, - "release", - &[ - "--channel", - "beta", - "--ref", - "feature/ref", - "--version", - "v1.2.3-beta.4", - "--dry-run", - ], - ); - - assert!(output.status.success()); - assert_eq!( - stdout(&output), - "gh workflow run release-beta.yml --ref feature/ref -f ref=feature/ref -f version_override=v1.2.3-beta.4\n" - ); -} - -#[test] -fn release_requires_channel() { - let Some(fx) = fixture() else { - return; - }; - - let output = run_active_wrapper(&fx, "release", &["--dry-run"]); - - assert!(!output.status.success()); - assert!(stderr(&output).contains("release: --channel is required")); -} - -#[test] -fn release_rejects_invalid_channel() { - let Some(fx) = fixture() else { - return; - }; - - let output = run_active_wrapper(&fx, "release", &["--channel", "nightly", "--dry-run"]); - - assert_eq!(output.status.code(), Some(2)); - assert!(stderr(&output).contains("invalid choice")); -} - -#[test] -fn release_watches_trigger_url() { - let Some(fx) = fixture() else { - return; - }; - - let output = run_wrapper_env( - &fx, - "release", - &["--channel", "stable", "--watch"], - &[( - "RUNSEAL_TEST_WORKFLOW_OUTPUT", - "https://github.com/acme/runseal/actions/runs/12345", - )], - ); - - assert!(output.status.success(), "stderr: {}", stderr(&output)); - assert_eq!( - stdout(&output), - "\ -https://github.com/acme/runseal/actions/runs/12345 -triggered release-stable.yml for ref main -" - ); - assert_eq!( - command_log(&fx), - "\ -gh workflow run release-stable.yml --ref main -f ref=main -f version_override= -gh run watch 12345 --interval 10 -" - ); -} - -#[test] -fn release_uses_latest_run() { - let Some(fx) = fixture() else { - return; - }; - - let output = run_wrapper_env( - &fx, - "release", - &["--channel", "beta", "--ref", "feature/ref", "--watch"], - &[("RUNSEAL_TEST_RUN_LIST", r#"[{"databaseId":67890}]"#)], - ); - - assert!(output.status.success(), "stderr: {}", stderr(&output)); - assert_eq!( - stdout(&output), - "triggered release-beta.yml for ref feature/ref\n" - ); - assert_eq!( - command_log(&fx), - "\ -gh workflow run release-beta.yml --ref feature/ref -f ref=feature/ref -f version_override= -gh run list --workflow release-beta.yml --branch feature/ref --commit abc123 --event workflow_dispatch --limit 1 --json databaseId -gh run watch 67890 --interval 10 -" - ); -} diff --git a/app/tests/seal.rs b/app/tests/seal.rs new file mode 100644 index 0000000..82b225b --- /dev/null +++ b/app/tests/seal.rs @@ -0,0 +1,190 @@ +use runseal::core::seal::{ + ast::{ + LetBinding, RawExprKind, RawItemKind, RawProcessArgKind, RawProcessPart, RawStatement, + RawStatementKind, + }, + ground, lex, parse, + token::{Keyword, TokenKind}, +}; + +#[test] +fn lexer_comment_newline() { + let output = lex("// hi\nlet x = 1"); + + assert!(output.diagnostics.is_empty()); + assert_eq!(output.tokens[0].kind, TokenKind::Newline); + assert_eq!(output.tokens[0].leading_comments().count(), 1); + assert_eq!(output.tokens[1].kind, TokenKind::Keyword(Keyword::Let)); +} + +#[test] +fn lexer_url_comment() { + let output = lex("| curl https://example.com // comment\n"); + let slash_count = output + .tokens + .iter() + .filter(|token| token.kind == TokenKind::Slash) + .count(); + + assert_eq!(slash_count, 2); + assert!( + output + .tokens + .iter() + .any(|token| token.leading_comments().count() == 1) + ); +} + +#[test] +fn raw_comments() { + let output = parse("// file\nlet x = 1 // x\nlet y = 2"); + + assert!(output.diagnostics.is_empty()); + assert_eq!(output.file.comments.len(), 2); + assert!(matches!(output.file.items[0].kind, RawItemKind::Comment(0))); + assert_eq!(output.file.items[1].trailing_comments, vec![1]); +} + +#[test] +fn process_argv() { + let output = parse("| gh pr view {number} --json number,url *extra\n"); + + assert!(output.diagnostics.is_empty()); + let RawItemKind::Statement(statement) = &output.file.items[0].kind else { + panic!("expected statement"); + }; + let RawStatementKind::Effect(expr) = &statement.kind else { + panic!("expected effect"); + }; + let RawExprKind::Process(process) = &expr.kind else { + panic!("expected process"); + }; + assert!(matches!( + process.program.as_ref().map(|arg| &arg.kind), + Some(RawProcessArgKind::Word(parts)) if parts == &vec![RawProcessPart::Text("gh".to_string())] + )); + assert_eq!(process.args.len(), 6); + assert!(matches!( + process.args[2].kind, + RawProcessArgKind::Word(ref parts) + if parts.iter().any(|part| matches!(part, RawProcessPart::Interpolation(_))) + )); + assert!(matches!(process.args[5].kind, RawProcessArgKind::Spread(_))); +} + +#[test] +fn process_whitespace() { + let output = parse("|gh\n"); + + assert_eq!(output.diagnostics.len(), 1); + assert_eq!( + output.diagnostics[0].message, + "expected whitespace after process marker '|'" + ); +} + +#[test] +fn with_env_scope() { + let output = parse( + r#" +with env { + RUST_LOG = "debug" + RUNSEAL_CHANNEL = channel +} { + | cargo test +} +"#, + ); + + assert!(output.diagnostics.is_empty()); + let RawItemKind::Statement(statement) = &output.file.items[0].kind else { + panic!("expected statement"); + }; + let RawStatementKind::WithEnv { bindings, body } = &statement.kind else { + panic!("expected with env"); + }; + assert_eq!(bindings.len(), 2); + assert_eq!(bindings[0].name, "RUST_LOG"); + assert_eq!(body.items.len(), 1); +} + +#[test] +fn control_flow() { + let output = parse( + r#" +if branch == "main" { + attempt = 1 +} else if branch == "beta" { + attempt = 2 +} else { + attempt = 3 +} + +while attempt < 6 { + attempt = attempt + 1 +} + +for tool in tools { + | which {tool} +} +"#, + ); + + assert!(output.diagnostics.is_empty()); + assert_eq!(output.file.items.len(), 3); + assert!(matches!( + output.file.items[0].kind, + RawItemKind::Statement(RawStatement { + kind: RawStatementKind::If { .. }, + .. + }) + )); + assert!(matches!( + output.file.items[1].kind, + RawItemKind::Statement(RawStatement { + kind: RawStatementKind::While { .. }, + .. + }) + )); + assert!(matches!( + output.file.items[2].kind, + RawItemKind::Statement(RawStatement { + kind: RawStatementKind::For { .. }, + .. + }) + )); +} + +#[test] +fn parse_recovery() { + let output = parse("let x =\nlet y = 1\n"); + + assert!(!output.diagnostics.is_empty()); + assert_eq!(output.file.items.len(), 2); + let RawItemKind::Statement(statement) = &output.file.items[1].kind else { + panic!("expected second statement"); + }; + assert!(matches!( + statement.kind, + RawStatementKind::Let { + ref name, + binding: LetBinding::Value, + .. + } if name == "y" + )); +} + +#[test] +fn ground_comparison_chain() { + let output = parse("// file\nlet x = a < b < c\n"); + assert!(output.diagnostics.is_empty()); + + let grounded = ground::ground(&output.file); + + assert_eq!(grounded.file.nodes.len(), 1); + assert_eq!(grounded.diagnostics.len(), 1); + assert_eq!( + grounded.diagnostics[0].message, + "comparison operators cannot be chained" + ); +} diff --git a/app/tests/transpile.rs b/app/tests/transpile.rs deleted file mode 100644 index ecfdbb6..0000000 --- a/app/tests/transpile.rs +++ /dev/null @@ -1,479 +0,0 @@ -use std::process::Command; -use tempfile::TempDir; - -#[path = "transpile_support/syntax.rs"] -mod syntax; - -fn bin() -> Command { - Command::new(env!("CARGO_BIN_EXE_runseal")) -} - -struct Fixture { - _temp: TempDir, - dir: std::path::PathBuf, - source: std::path::PathBuf, -} - -fn fixture(source: &str) -> Fixture { - let temp = TempDir::new().expect("temp dir should be created"); - let dir = temp.path().join("project-without-profile"); - std::fs::create_dir_all(&dir).expect("project dir should be created"); - let source_path = dir.join("operator.seal"); - std::fs::write(&source_path, source).expect("source should be written"); - Fixture { - _temp: temp, - dir, - source: source_path, - } -} - -fn run_transpile(fx: &Fixture, input_lang: &str, output_lang: &str) -> std::process::Output { - bin() - .current_dir(&fx.dir) - .arg("@transpile") - .arg("--input-lang") - .arg(input_lang) - .arg("--output-lang") - .arg(output_lang) - .arg(&fx.source) - .output() - .expect("runseal should run") -} - -fn sample_source() -> &'static str { - r#" -channel=${RUNSEAL_CHANNEL:-stable} - -release_run() { - if [ -z "$channel" ]; then - fail "channel missing" - fi - gh workflow run release.yml --ref main -f "channel=$channel" -} - -case "$channel" in - stable) print "stable release" ;; - beta) release_run ;; - *) fail "unknown channel: $channel" ;; -esac -"# -} - -fn powershell_source() -> &'static str { - r#" -$channel = $(if ([string]::IsNullOrEmpty($env:RUNSEAL_CHANNEL)) { 'stable' } else { $env:RUNSEAL_CHANNEL }) -function release_run { - if ([string]::IsNullOrEmpty($channel)) { - throw 'channel missing' - } - & 'gh' 'workflow' 'run' 'release.yml' '--ref' 'main' '-f' ('channel=' + $channel) -} - -switch ($channel) { - 'stable' { - Write-Output 'stable release' - break - } - 'beta' { - release_run - break - } - Default { - throw ('unknown channel: ' + $channel) - break - } -} -"# -} - -fn capture_source() -> &'static str { - r#" -raw=$(gh run list --json databaseId) -print "$raw" -"# -} - -fn powershell_capture_source() -> &'static str { - r#" -$raw = & 'gh' 'run' 'list' '--json' 'databaseId' -Write-Output $raw -"# -} - -fn trim_source() -> &'static str { - r#" -raw=" value " -trimmed=$(runseal @tool string trim "$raw") -print "$trimmed" -"# -} - -fn powershell_trim_source() -> &'static str { - r#" -$raw = ' value ' -$trimmed = & 'runseal' '@tool' 'string' 'trim' $raw -Write-Output $trimmed -"# -} - -fn json_get_source() -> &'static str { - r#" -raw='[{"databaseId":123}]' -run_id=$(runseal @tool json get "$raw" '.[0].databaseId') -print "$run_id" -"# -} - -fn redirect_source() -> &'static str { - r#" -gh run list --json databaseId > build/openapi.json -gh run view 123 2>> build/errors.log -"# -} - -fn powershell_json_get_source() -> &'static str { - r#" -$raw = '[{"databaseId":123}]' -$run_id = & 'runseal' '@tool' 'json' 'get' $raw '.[0].databaseId' -Write-Output $run_id -"# -} - -#[test] -fn help_without_profile() { - let fx = fixture(""); - - let output = bin() - .current_dir(&fx.dir) - .arg("@transpile") - .arg("--help") - .output() - .expect("runseal should run"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert!(stdout.contains("Usage: runseal @transpile")); - assert!(stdout.contains("--input-lang")); -} - -#[test] -fn sealir_without_profile() { - let fx = fixture(sample_source()); - - let output = run_transpile(&fx, "seal", "sealir"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - let payload: serde_json::Value = serde_json::from_str(&stdout).expect("stdout should be JSON"); - assert_eq!(payload["version"], 1); - assert!(stdout.contains("default_if_unset_or_empty")); - assert!(stdout.contains("exec_checked")); -} - -#[test] -fn redirect_ir_and_targets() { - let fx = fixture(redirect_source()); - - let sealir = run_transpile(&fx, "seal", "sealir"); - assert!(sealir.status.success()); - let sealir = String::from_utf8(sealir.stdout).expect("stdout should be UTF-8"); - assert!(sealir.contains("exec_write")); - assert!(sealir.contains("stdout")); - assert!(sealir.contains("stderr")); - - let bash = run_transpile(&fx, "seal", "bash"); - let powershell = run_transpile(&fx, "seal", "powershell"); - assert!(bash.status.success()); - assert!(powershell.status.success()); - let bash = String::from_utf8(bash.stdout).expect("stdout should be UTF-8"); - let powershell = String::from_utf8(powershell.stdout).expect("stdout should be UTF-8"); - assert!(bash.contains("gh run list --json databaseId > build/openapi.json")); - assert!(bash.contains("gh run view 123 2>> build/errors.log")); - assert!( - powershell.contains("& 'gh' 'run' 'list' '--json' 'databaseId' > 'build/openapi.json'") - ); - assert!(powershell.contains("& 'gh' 'run' 'view' '123' 2>> 'build/errors.log'")); - syntax::assert_bash(&bash); - syntax::assert_pwsh(&powershell); -} - -#[test] -fn bash_frontend_sealir() { - let fx = fixture(sample_source()); - - let output = run_transpile(&fx, "bash", "sealir"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert!(stdout.contains("default_if_unset_or_empty")); - assert!(stdout.contains("exec_checked")); -} - -#[test] -fn powershell_frontend_sealir() { - let fx = fixture(powershell_source()); - - let output = run_transpile(&fx, "powershell", "sealir"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert!(stdout.contains("default_if_unset_or_empty")); - assert!(stdout.contains("call_function")); - assert!(stdout.contains("exec_checked")); -} - -#[test] -fn powershell_to_bash() { - let fx = fixture(powershell_source()); - - let output = run_transpile(&fx, "powershell", "bash"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert!(stdout.contains("release_run() {")); - assert!(stdout.contains("gh workflow run release.yml --ref main -f \"channel=$channel\"")); - syntax::assert_bash(&stdout); -} - -#[test] -fn bash_capture_ir() { - let fx = fixture(capture_source()); - - let output = run_transpile(&fx, "bash", "sealir"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert!(stdout.contains("capture_checked")); - assert!(stdout.contains("databaseId")); -} - -#[test] -fn powershell_capture_ir() { - let fx = fixture(powershell_capture_source()); - - let output = run_transpile(&fx, "powershell", "sealir"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert!(stdout.contains("capture_checked")); - assert!(stdout.contains("databaseId")); -} - -#[test] -fn capture_to_targets() { - let fx = fixture(capture_source()); - - let bash = run_transpile(&fx, "bash", "bash"); - let powershell = run_transpile(&fx, "bash", "powershell"); - - assert!(bash.status.success()); - assert!(powershell.status.success()); - let bash = String::from_utf8(bash.stdout).expect("stdout should be UTF-8"); - let powershell = String::from_utf8(powershell.stdout).expect("stdout should be UTF-8"); - assert!(bash.contains("raw=$(gh run list --json databaseId)")); - assert!(powershell.contains("$raw = & 'gh' 'run' 'list' '--json' 'databaseId'")); - syntax::assert_bash(&bash); - syntax::assert_pwsh(&powershell); -} - -#[test] -fn string_trim_tool_roundtrip() { - for input_lang in ["seal", "bash"] { - let fx = fixture(trim_source()); - let output = run_transpile(&fx, input_lang, "sealir"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert!(stdout.contains("capture_checked")); - assert!(stdout.contains("string")); - } - - let fx = fixture(powershell_trim_source()); - let output = run_transpile(&fx, "powershell", "sealir"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert!(stdout.contains("capture_checked")); - assert!(stdout.contains("string")); -} - -#[test] -fn string_trim_emits_tool() { - let fx = fixture(trim_source()); - - let bash = run_transpile(&fx, "seal", "bash"); - let powershell = run_transpile(&fx, "seal", "powershell"); - - assert!(bash.status.success()); - assert!(powershell.status.success()); - let bash = String::from_utf8(bash.stdout).expect("stdout should be UTF-8"); - let powershell = String::from_utf8(powershell.stdout).expect("stdout should be UTF-8"); - assert!(bash.contains("trimmed=$(runseal @tool string trim \"$raw\")")); - assert!(powershell.contains("$trimmed = & 'runseal' '@tool' 'string' 'trim' $raw")); - syntax::assert_bash(&bash); - syntax::assert_pwsh(&powershell); -} - -#[test] -fn json_get_tool_roundtrip() { - for input_lang in ["seal", "bash"] { - let fx = fixture(json_get_source()); - let output = run_transpile(&fx, input_lang, "sealir"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert!(stdout.contains("capture_checked")); - assert!(stdout.contains("json")); - assert!(stdout.contains("databaseId")); - } - - let fx = fixture(powershell_json_get_source()); - let output = run_transpile(&fx, "powershell", "sealir"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert!(stdout.contains("capture_checked")); - assert!(stdout.contains("json")); - assert!(stdout.contains("databaseId")); -} - -#[test] -fn json_get_emits_tool() { - let fx = fixture(json_get_source()); - - let bash = run_transpile(&fx, "seal", "bash"); - let powershell = run_transpile(&fx, "seal", "powershell"); - - assert!(bash.status.success()); - assert!(powershell.status.success()); - let bash = String::from_utf8(bash.stdout).expect("stdout should be UTF-8"); - let powershell = String::from_utf8(powershell.stdout).expect("stdout should be UTF-8"); - assert!(bash.contains("run_id=$(runseal @tool json get \"$raw\" '.[0].databaseId')")); - assert!( - powershell.contains("$run_id = & 'runseal' '@tool' 'json' 'get' $raw '.[0].databaseId'") - ); - syntax::assert_bash(&bash); - syntax::assert_pwsh(&powershell); -} - -#[test] -fn bash_syntax_valid() { - let fx = fixture(sample_source()); - - let output = run_transpile(&fx, "seal", "bash"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert!(stdout.contains("set -euo pipefail")); - assert!(stdout.contains("gh workflow run release.yml --ref main -f \"channel=$channel\"")); - assert!(stdout.contains("case \"$channel\" in")); - syntax::assert_bash(&stdout); -} - -#[test] -fn powershell_readable() { - let fx = fixture(sample_source()); - - let output = run_transpile(&fx, "seal", "powershell"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert!(stdout.contains("$ErrorActionPreference = 'Stop'")); - assert!(stdout.contains("function release_run")); - assert!(stdout.contains("& 'gh' 'workflow' 'run' 'release.yml' '--ref' 'main' '-f'")); - assert!(stdout.contains("('channel=' + $channel)")); - assert!(stdout.contains("switch ($channel)")); - syntax::assert_pwsh(&stdout); -} - -#[test] -fn empty_string_powershell() { - let fx = fixture("print \"\"\n"); - - let output = run_transpile(&fx, "seal", "powershell"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert!(stdout.contains("Write-Output ''")); - syntax::assert_pwsh(&stdout); -} - -#[test] -fn sealir_to_seal() { - let fx = fixture(sample_source()); - let sealir = run_transpile(&fx, "seal", "sealir"); - assert!(sealir.status.success()); - let sealir_text = String::from_utf8(sealir.stdout).expect("stdout should be UTF-8"); - let sealir_path = fx.dir.join("operator.sealir.json"); - std::fs::write(&sealir_path, sealir_text).expect("sealir should be written"); - - let output = bin() - .current_dir(&fx.dir) - .arg("@transpile") - .arg("--input-lang=sealir") - .arg("--output-lang=seal") - .arg(&sealir_path) - .output() - .expect("runseal should run"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert!(stdout.contains("release_run() {")); - assert!(stdout.contains("case $channel in")); -} - -#[test] -fn unsupported_input_fails() { - let fx = fixture("print ok\n"); - - let output = run_transpile(&fx, "python", "powershell"); - - assert!(!output.status.success()); - let stderr = String::from_utf8(output.stderr).expect("stderr should be UTF-8"); - assert!(stderr.contains("invalid --input-lang")); -} - -#[test] -fn underscore_exec() { - let fx = fixture("tool_name --version\n"); - - let output = run_transpile(&fx, "seal", "sealir"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert!(stdout.contains("exec_checked")); - assert!(!stdout.contains("call_function")); -} - -#[test] -fn hyphen_exec() { - let fx = fixture("git-lfs version\n"); - - let output = run_transpile(&fx, "seal", "bash"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert!(stdout.contains("git-lfs version")); -} - -#[test] -fn metacharacters_fail() { - for source in [ - "printf ok | cat\n", - "eval something\n", - "echo ok 2>&1\n", - "exec echo ok\n", - "sh -c 'echo ok'\n", - ] { - let fx = fixture(source); - - let output = run_transpile(&fx, "seal", "sealir"); - - assert!(!output.status.success(), "{source:?} should fail"); - let stderr = String::from_utf8(output.stderr).expect("stderr should be UTF-8"); - assert!( - stderr.contains("unsupported") || stderr.contains("shell-specific construct"), - "expected unsupported error, got {stderr:?}" - ); - } -} diff --git a/app/tests/transpile_cases.rs b/app/tests/transpile_cases.rs deleted file mode 100644 index 3dc76cf..0000000 --- a/app/tests/transpile_cases.rs +++ /dev/null @@ -1,18 +0,0 @@ -#[path = "transpile_cases/argv.rs"] -mod argv; -#[path = "transpile_cases/command_predicate.rs"] -mod command_predicate; -#[path = "transpile_cases/env.rs"] -mod env; -#[path = "transpile_cases/expansion.rs"] -mod expansion; -#[path = "transpile_cases/regex.rs"] -mod regex; -#[path = "transpile_cases/release.rs"] -mod release; -#[path = "transpile_cases/retry.rs"] -mod retry; -#[path = "transpile_support/syntax.rs"] -mod syntax; -#[path = "transpile_cases/wrappers.rs"] -mod wrappers; diff --git a/app/tests/transpile_cases/argv.rs b/app/tests/transpile_cases/argv.rs deleted file mode 100644 index a65e243..0000000 --- a/app/tests/transpile_cases/argv.rs +++ /dev/null @@ -1,257 +0,0 @@ -use std::process::Command; - -use tempfile::TempDir; - -struct Fixture { - _temp: TempDir, - dir: std::path::PathBuf, - source: std::path::PathBuf, -} - -fn fixture(source: &str) -> Fixture { - let temp = TempDir::new().expect("temp dir should be created"); - let dir = temp.path().join("project-without-profile"); - std::fs::create_dir_all(&dir).expect("project dir should be created"); - let source_path = dir.join("operator.seal"); - std::fs::write(&source_path, source).expect("source should be written"); - Fixture { - _temp: temp, - dir, - source: source_path, - } -} - -fn run_transpile(fx: &Fixture, input_lang: &str, output_lang: &str) -> std::process::Output { - Command::new(env!("CARGO_BIN_EXE_runseal")) - .current_dir(&fx.dir) - .arg("@transpile") - .arg("--input-lang") - .arg(input_lang) - .arg("--output-lang") - .arg(output_lang) - .arg(&fx.source) - .output() - .expect("runseal should run") -} - -fn argv_source() -> &'static str { - r#" -__seal_argc=$# -__seal_help=false -channel=stable -ref=main -body_file= -dry_run=false -no_merge=false -while [ "$#" -gt 0 ]; do - case "$1" in - --channel) - if [ "$#" -lt 2 ]; then fail "missing value for --channel"; fi - channel=$2 - shift 2 - ;; - --channel=*) - channel=${1#--channel=} - shift - ;; - --ref) - if [ "$#" -lt 2 ]; then fail "missing value for --ref"; fi - ref=$2 - shift 2 - ;; - --ref=*) - ref=${1#--ref=} - shift - ;; - --body-file) - if [ "$#" -lt 2 ]; then fail "missing value for --body-file"; fi - body_file=$2 - shift 2 - ;; - --body-file=*) - body_file=${1#--body-file=} - shift - ;; - --dry-run) - dry_run=true - shift - ;; - --no-merge) - no_merge=true - shift - ;; - --) - shift - break - ;; - -h|--help|help) - __seal_help=true - shift - ;; - *) fail "unknown option: $1" ;; - esac -done -if [ -z "$channel" ]; then - fail "channel missing" -fi -print "$body_file" -"# -} - -fn argv_positional_source() -> &'static str { - r#" -__seal_argc=$# -__seal_help=false -body= -message= -while [ "$#" -gt 0 ]; do - case "$1" in - --body) - if [ "$#" -lt 2 ]; then fail "missing value for --body"; fi - body=$2 - shift 2 - ;; - --body=*) - body=${1#--body=} - shift - ;; - --) - shift - break - ;; - -h|--help|help) - __seal_help=true - shift - ;; - *) - if [ -z "$message" ]; then - message=$1 - shift - else - fail "unexpected argument: $1" - fi - ;; - esac -done -print "$body" -print "$message" -"# -} - -fn argv_multiline_guard_source() -> &'static str { - r#" -__seal_argc=$# -__seal_help=false -body= -while [ "$#" -gt 0 ]; do - case "$1" in - --body) - if [ "$#" -lt 2 ]; then - fail "missing value for --body" - fi - body=$2 - shift 2 - ;; - *) fail "unknown option: $1" ;; - esac -done -print "$body" -"# -} - -#[test] -fn argv_parse_roundtrip() { - for input_lang in ["seal", "bash"] { - let fx = fixture(argv_source()); - let output = run_transpile(&fx, input_lang, "sealir"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert!(stdout.contains("argv_parse")); - assert!(stdout.contains("body_file")); - assert!(stdout.contains("dry_run")); - } -} - -#[test] -fn argv_parse_emits_targets() { - let fx = fixture(argv_source()); - - let bash = run_transpile(&fx, "seal", "bash"); - let powershell = run_transpile(&fx, "seal", "powershell"); - - assert!(bash.status.success()); - assert!(powershell.status.success()); - let bash = String::from_utf8(bash.stdout).expect("stdout should be UTF-8"); - let powershell = String::from_utf8(powershell.stdout).expect("stdout should be UTF-8"); - assert!(bash.contains("body_file=${1#--body-file=}")); - assert!(bash.contains("dry_run=true")); - assert!(powershell.contains("$body_file = $__seal_arg.Substring(12)")); - assert!(powershell.contains("$dry_run = 'false'")); - assert!(powershell.contains("$dry_run = 'true'")); - assert!(!powershell.contains("$dry_run = $false")); - assert!(!powershell.contains("$dry_run = $true")); - super::syntax::assert_bash(&bash); - super::syntax::assert_pwsh(&powershell); -} - -#[test] -fn argv_positional_roundtrip() { - let fx = fixture(argv_positional_source()); - let output = run_transpile(&fx, "seal", "sealir"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert!(stdout.contains("\"positional\"")); - assert!(stdout.contains("\"name\": \"message\"")); - assert!(stdout.contains("\"extra_error\": \"unexpected argument: $1\"")); -} - -#[test] -fn argv_positional_targets() { - let fx = fixture(argv_positional_source()); - - let bash = run_transpile(&fx, "seal", "bash"); - let powershell = run_transpile(&fx, "seal", "powershell"); - - assert!(bash.status.success()); - assert!(powershell.status.success()); - let bash = String::from_utf8(bash.stdout).expect("stdout should be UTF-8"); - let powershell = String::from_utf8(powershell.stdout).expect("stdout should be UTF-8"); - assert!(bash.contains("body=${1#--body=}")); - assert!(bash.contains("if [ -z \"$message\" ]; then")); - assert!(powershell.contains("$body = $__seal_arg.Substring(7)")); - assert!(powershell.contains("if ([string]::IsNullOrEmpty($message)) {")); - assert!(powershell.contains("$message = $__seal_arg")); - super::syntax::assert_bash(&bash); - super::syntax::assert_pwsh(&powershell); -} - -#[test] -fn argv_multiline_guard_roundtrip() { - let fx = fixture(argv_multiline_guard_source()); - let output = run_transpile(&fx, "seal", "sealir"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert!(stdout.contains("argv_parse")); - assert!(stdout.contains("\"name\": \"body\"")); -} - -#[test] -fn argv_multiline_guard_targets() { - let fx = fixture(argv_multiline_guard_source()); - - let bash = run_transpile(&fx, "seal", "bash"); - let powershell = run_transpile(&fx, "seal", "powershell"); - - assert!(bash.status.success()); - assert!(powershell.status.success()); - let bash = String::from_utf8(bash.stdout).expect("stdout should be UTF-8"); - let powershell = String::from_utf8(powershell.stdout).expect("stdout should be UTF-8"); - assert!(bash.contains("body=$2")); - assert!(bash.contains("shift 2")); - assert!(powershell.contains("$body = $args[$__seal_index + 1]")); - super::syntax::assert_bash(&bash); - super::syntax::assert_pwsh(&powershell); -} diff --git a/app/tests/transpile_cases/command_predicate.rs b/app/tests/transpile_cases/command_predicate.rs deleted file mode 100644 index d219316..0000000 --- a/app/tests/transpile_cases/command_predicate.rs +++ /dev/null @@ -1,77 +0,0 @@ -use std::path::PathBuf; -use std::process::Command; - -use tempfile::TempDir; - -use super::syntax; - -fn bin() -> Command { - Command::new(env!("CARGO_BIN_EXE_runseal")) -} - -struct Fixture { - _temp: TempDir, - dir: PathBuf, - source: PathBuf, -} - -fn fixture(source: &str) -> Fixture { - let temp = TempDir::new().expect("temp dir should be created"); - let dir = temp.path().join("project-without-profile"); - std::fs::create_dir_all(&dir).expect("project dir should be created"); - let source_path = dir.join("operator.seal"); - std::fs::write(&source_path, source).expect("source should be written"); - Fixture { - _temp: temp, - dir, - source: source_path, - } -} - -fn run_transpile(fx: &Fixture, output_lang: &str) -> std::process::Output { - bin() - .current_dir(&fx.dir) - .arg("@transpile") - .arg("--input-lang=seal") - .arg("--output-lang") - .arg(output_lang) - .arg(&fx.source) - .output() - .expect("runseal should run") -} - -#[test] -fn command_predicate_emits_targets() { - let fx = fixture( - r#" -branch=feat/remote -if git checkout "$branch"; then - print ok -else - git fetch origin "$branch" - git checkout -B "$branch" "origin/$branch" -fi -"#, - ); - - let bash = run_transpile(&fx, "bash"); - let powershell = run_transpile(&fx, "powershell"); - - assert!( - bash.status.success(), - "stderr: {}", - String::from_utf8_lossy(&bash.stderr) - ); - assert!( - powershell.status.success(), - "stderr: {}", - String::from_utf8_lossy(&powershell.stderr) - ); - let bash = String::from_utf8(bash.stdout).expect("stdout should be UTF-8"); - let powershell = String::from_utf8(powershell.stdout).expect("stdout should be UTF-8"); - assert!(bash.contains("if git checkout \"$branch\"; then")); - assert!(powershell.contains("& 'git' 'checkout' $branch")); - assert!(powershell.contains("if ($LASTEXITCODE -eq 0)")); - syntax::assert_bash(&bash); - syntax::assert_pwsh(&powershell); -} diff --git a/app/tests/transpile_cases/env.rs b/app/tests/transpile_cases/env.rs deleted file mode 100644 index 391ef86..0000000 --- a/app/tests/transpile_cases/env.rs +++ /dev/null @@ -1,65 +0,0 @@ -use std::path::PathBuf; -use std::process::Command; - -use tempfile::TempDir; - -use super::syntax; - -fn bin() -> Command { - Command::new(env!("CARGO_BIN_EXE_runseal")) -} - -struct Fixture { - _temp: TempDir, - dir: PathBuf, - source: PathBuf, -} - -fn fixture(source: &str) -> Fixture { - let temp = TempDir::new().expect("temp dir should be created"); - let dir = temp.path().join("project-without-profile"); - std::fs::create_dir_all(&dir).expect("project dir should be created"); - let source_path = dir.join("operator.seal"); - std::fs::write(&source_path, source).expect("source should be written"); - Fixture { - _temp: temp, - dir, - source: source_path, - } -} - -fn run_transpile(fx: &Fixture, output_lang: &str) -> std::process::Output { - bin() - .current_dir(&fx.dir) - .arg("@transpile") - .arg("--input-lang=seal") - .arg("--output-lang") - .arg(output_lang) - .arg(&fx.source) - .output() - .expect("runseal should run") -} - -#[test] -fn env_overlay_emits_targets() { - let fx = fixture( - r#" -kubeconfig=/tmp/a.yaml -KUBECONFIG="$kubeconfig" kubectl "$@" -"#, - ); - - let bash = run_transpile(&fx, "bash"); - let powershell = run_transpile(&fx, "powershell"); - - assert!(bash.status.success()); - assert!(powershell.status.success()); - let bash = String::from_utf8(bash.stdout).expect("stdout should be UTF-8"); - let powershell = String::from_utf8(powershell.stdout).expect("stdout should be UTF-8"); - assert!(bash.contains("KUBECONFIG=\"$kubeconfig\" kubectl \"$@\"")); - assert!(powershell.contains("$__seal_old_env_KUBECONFIG = $env:KUBECONFIG")); - assert!(powershell.contains("$env:KUBECONFIG = $kubeconfig")); - assert!(powershell.contains("& 'kubectl' @args")); - syntax::assert_bash(&bash); - syntax::assert_pwsh(&powershell); -} diff --git a/app/tests/transpile_cases/expansion.rs b/app/tests/transpile_cases/expansion.rs deleted file mode 100644 index 14dd915..0000000 --- a/app/tests/transpile_cases/expansion.rs +++ /dev/null @@ -1,183 +0,0 @@ -use std::process::Command; - -use tempfile::TempDir; - -use super::syntax; - -fn bin() -> Command { - Command::new(env!("CARGO_BIN_EXE_runseal")) -} - -struct Fixture { - _temp: TempDir, - dir: std::path::PathBuf, - source: std::path::PathBuf, -} - -fn fixture(source: &str) -> Fixture { - let temp = TempDir::new().expect("temp dir should be created"); - let dir = temp.path().join("project-without-profile"); - std::fs::create_dir_all(&dir).expect("project dir should be created"); - let source_path = dir.join("operator.seal"); - std::fs::write(&source_path, source).expect("source should be written"); - Fixture { - _temp: temp, - dir, - source: source_path, - } -} - -fn run_transpile(fx: &Fixture, input_lang: &str, output_lang: &str) -> std::process::Output { - bin() - .current_dir(&fx.dir) - .arg("@transpile") - .arg("--input-lang") - .arg(input_lang) - .arg("--output-lang") - .arg(output_lang) - .arg(&fx.source) - .output() - .expect("runseal should run") -} - -fn expansion_source() -> &'static str { - r#" -channel=${RUNSEAL_CHANNEL:-stable} -required=${RUNSEAL_TOKEN:?missing token} -target=${1:-origin} -branch=${2:?missing branch} -print "$channel $required $target $branch" -"# -} - -#[test] -fn forms_roundtrip() { - let fx = fixture(expansion_source()); - - let sealir = run_transpile(&fx, "seal", "sealir"); - assert!(sealir.status.success()); - let sealir = String::from_utf8(sealir.stdout).expect("stdout should be UTF-8"); - assert!(sealir.contains("require_non_empty")); - assert!(sealir.contains("default_if_unset_or_empty")); - - let bash = run_transpile(&fx, "seal", "bash"); - assert!(bash.status.success()); - let bash = String::from_utf8(bash.stdout).expect("stdout should be UTF-8"); - assert!(bash.contains("channel=\"${RUNSEAL_CHANNEL:-stable}\"")); - assert!(bash.contains("required=\"${RUNSEAL_TOKEN:?missing token}\"")); - assert!(bash.contains("target=\"${1:-origin}\"")); - assert!(bash.contains("branch=\"${2:?missing branch}\"")); - syntax::assert_bash(&bash); - - let powershell = run_transpile(&fx, "seal", "powershell"); - assert!(powershell.status.success()); - let powershell = String::from_utf8(powershell.stdout).expect("stdout should be UTF-8"); - assert!(powershell.contains( - "$channel = $(if ([string]::IsNullOrEmpty($env:RUNSEAL_CHANNEL)) { 'stable' } else { $env:RUNSEAL_CHANNEL })" - )); - assert!(powershell.contains( - "$required = $(if ([string]::IsNullOrEmpty($env:RUNSEAL_TOKEN)) { throw 'missing token' } else { $env:RUNSEAL_TOKEN })" - )); - assert!(powershell.contains( - "$target = $(if (($args.Count -lt 1) -or [string]::IsNullOrEmpty($1)) { 'origin' } else { $1 })" - )); - assert!(powershell.contains( - "$branch = $(if (($args.Count -lt 2) -or [string]::IsNullOrEmpty($2)) { throw 'missing branch' } else { $2 })" - )); - syntax::assert_pwsh(&powershell); - - let powershell_fx = fixture(&powershell); - let roundtrip = run_transpile(&powershell_fx, "powershell", "sealir"); - assert!(roundtrip.status.success()); - let roundtrip = String::from_utf8(roundtrip.stdout).expect("stdout should be UTF-8"); - assert!(roundtrip.contains("require_non_empty")); - assert!(roundtrip.contains("default_if_unset_or_empty")); -} - -fn run_wrapper(source: &str, args: &[&str], env: &[(&str, &str)]) -> std::process::Output { - let temp = TempDir::new().expect("temp dir should be created"); - let project = temp.path().join("project"); - let wrappers = project.join(".runseal").join("wrappers"); - std::fs::create_dir_all(&wrappers).expect("wrappers should be created"); - std::fs::write(project.join("runseal.toml"), "injections = []\n") - .expect("profile should be written"); - std::fs::write(wrappers.join("expansion.seal"), source).expect("wrapper should be written"); - let mut command = bin(); - command.current_dir(&project).arg(":expansion").args(args); - for (key, value) in env { - command.env(key, value); - } - command.output().expect("runseal should run") -} - -#[test] -fn env_default_runtime() { - let source = "print \"${RUNSEAL_CHANNEL:-stable}\"\n"; - let missing = run_wrapper(source, &[], &[]); - assert!(missing.status.success()); - assert_eq!( - String::from_utf8(missing.stdout).expect("stdout should be UTF-8"), - "stable\n" - ); - let empty = run_wrapper(source, &[], &[("RUNSEAL_CHANNEL", "")]); - assert!(empty.status.success()); - assert_eq!( - String::from_utf8(empty.stdout).expect("stdout should be UTF-8"), - "stable\n" - ); -} - -#[test] -fn positional_default_runtime() { - let source = "print \"${1:-origin}\"\n"; - let missing = run_wrapper(source, &[], &[]); - assert!(missing.status.success()); - assert_eq!( - String::from_utf8(missing.stdout).expect("stdout should be UTF-8"), - "origin\n" - ); - let empty = run_wrapper(source, &[""], &[]); - assert!(empty.status.success()); - assert_eq!( - String::from_utf8(empty.stdout).expect("stdout should be UTF-8"), - "origin\n" - ); -} - -#[test] -fn env_require_runtime() { - let source = "print \"${RUNSEAL_TOKEN:?missing token}\"\n"; - let missing = run_wrapper(source, &[], &[]); - assert!(!missing.status.success()); - assert!( - String::from_utf8(missing.stderr) - .expect("stderr should be UTF-8") - .contains("missing token") - ); - let empty = run_wrapper(source, &[], &[("RUNSEAL_TOKEN", "")]); - assert!(!empty.status.success()); - assert!( - String::from_utf8(empty.stderr) - .expect("stderr should be UTF-8") - .contains("missing token") - ); -} - -#[test] -fn positional_require_runtime() { - let source = "print \"${1:?missing branch}\"\n"; - let missing = run_wrapper(source, &[], &[]); - assert!(!missing.status.success()); - assert!( - String::from_utf8(missing.stderr) - .expect("stderr should be UTF-8") - .contains("missing branch") - ); - let empty = run_wrapper(source, &[""], &[]); - assert!(!empty.status.success()); - assert!( - String::from_utf8(empty.stderr) - .expect("stderr should be UTF-8") - .contains("missing branch") - ); -} diff --git a/app/tests/transpile_cases/regex.rs b/app/tests/transpile_cases/regex.rs deleted file mode 100644 index 0257a22..0000000 --- a/app/tests/transpile_cases/regex.rs +++ /dev/null @@ -1,103 +0,0 @@ -use std::process::Command; - -use tempfile::TempDir; - -struct Fixture { - _temp: TempDir, - dir: std::path::PathBuf, - source: std::path::PathBuf, -} - -fn fixture(source: &str) -> Fixture { - let temp = TempDir::new().expect("temp dir should be created"); - let dir = temp.path().join("project-without-profile"); - std::fs::create_dir_all(&dir).expect("project dir should be created"); - let source_path = dir.join("operator.seal"); - std::fs::write(&source_path, source).expect("source should be written"); - Fixture { - _temp: temp, - dir, - source: source_path, - } -} - -fn run_transpile(fx: &Fixture, input_lang: &str, output_lang: &str) -> std::process::Output { - Command::new(env!("CARGO_BIN_EXE_runseal")) - .current_dir(&fx.dir) - .arg("@transpile") - .arg("--input-lang") - .arg(input_lang) - .arg("--output-lang") - .arg(output_lang) - .arg(&fx.source) - .output() - .expect("runseal should run") -} - -fn regex_source() -> &'static str { - r#" -trigger_output='https://github.com/PerishCode/runseal/actions/runs/12345' -run_id=$(runseal @tool regex capture "$trigger_output" '/actions/runs/([0-9]+)' 1) -if [ -z "$run_id" ]; then - run_id=$(latest_run_id "$workflow" "$ref") -fi -print "$run_id" -"# -} - -fn powershell_regex_source() -> &'static str { - r#" -$trigger_output = 'https://github.com/PerishCode/runseal/actions/runs/12345' -$run_id = & 'runseal' '@tool' 'regex' 'capture' $trigger_output '/actions/runs/([0-9]+)' '1' -if ([string]::IsNullOrEmpty($run_id)) { - $run_id = & 'latest_run_id' $workflow $ref -} -Write-Output $run_id -"# -} - -#[test] -fn regex_capture_roundtrip() { - for input_lang in ["seal", "bash"] { - let fx = fixture(regex_source()); - let output = run_transpile(&fx, input_lang, "sealir"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert!(stdout.contains("capture_checked")); - assert!(stdout.contains("regex")); - assert!(stdout.contains("/actions/runs/([0-9]+)")); - } - - let fx = fixture(powershell_regex_source()); - let output = run_transpile(&fx, "powershell", "sealir"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert!(stdout.contains("capture_checked")); - assert!(stdout.contains("regex")); - assert!(stdout.contains("\"1\"")); -} - -#[test] -fn regex_capture_emits_targets() { - let fx = fixture(regex_source()); - - let bash = run_transpile(&fx, "seal", "bash"); - let powershell = run_transpile(&fx, "seal", "powershell"); - - assert!(bash.status.success()); - assert!(powershell.status.success()); - let bash = String::from_utf8(bash.stdout).expect("stdout should be UTF-8"); - let powershell = String::from_utf8(powershell.stdout).expect("stdout should be UTF-8"); - assert!(bash.contains( - "run_id=$(runseal @tool regex capture \"$trigger_output\" '/actions/runs/([0-9]+)' 1)" - )); - assert!( - powershell.contains( - "$run_id = & 'runseal' '@tool' 'regex' 'capture' $trigger_output '/actions/runs/([0-9]+)' '1'" - ) - ); - super::syntax::assert_bash(&bash); - super::syntax::assert_pwsh(&powershell); -} diff --git a/app/tests/transpile_cases/release.rs b/app/tests/transpile_cases/release.rs deleted file mode 100644 index f089665..0000000 --- a/app/tests/transpile_cases/release.rs +++ /dev/null @@ -1,172 +0,0 @@ -use std::process::Command; - -use tempfile::TempDir; - -struct Fixture { - _temp: TempDir, - dir: std::path::PathBuf, - source: std::path::PathBuf, -} - -fn fixture(source: &str) -> Fixture { - let temp = TempDir::new().expect("temp dir should be created"); - let dir = temp.path().join("project-without-profile"); - std::fs::create_dir_all(&dir).expect("project dir should be created"); - let source_path = dir.join("operator.seal"); - std::fs::write(&source_path, source).expect("source should be written"); - Fixture { - _temp: temp, - dir, - source: source_path, - } -} - -fn run_transpile(fx: &Fixture, input_lang: &str, output_lang: &str) -> std::process::Output { - Command::new(env!("CARGO_BIN_EXE_runseal")) - .current_dir(&fx.dir) - .arg("@transpile") - .arg("--input-lang") - .arg(input_lang) - .arg("--output-lang") - .arg(output_lang) - .arg(&fx.source) - .output() - .expect("runseal should run") -} - -fn release_source() -> &'static str { - r#" -__seal_argc=$# -__seal_help=false -channel=stable -ref=main -version= -watch=false -dry_run=false -while [ "$#" -gt 0 ]; do - case "$1" in - --channel) - if [ "$#" -lt 2 ]; then fail "missing value for --channel"; fi - channel=$2 - shift 2 - ;; - --channel=*) - channel=${1#--channel=} - shift - ;; - --ref) - if [ "$#" -lt 2 ]; then fail "missing value for --ref"; fi - ref=$2 - shift 2 - ;; - --ref=*) - ref=${1#--ref=} - shift - ;; - --version) - if [ "$#" -lt 2 ]; then fail "missing value for --version"; fi - version=$2 - shift 2 - ;; - --version=*) - version=${1#--version=} - shift - ;; - --watch) - watch=true - shift - ;; - --dry-run) - dry_run=true - shift - ;; - --) - shift - break - ;; - -h|--help|help) - __seal_help=true - shift - ;; - *) fail "unknown option: $1" ;; - esac -done - -if [ -z "$channel" ]; then - fail "--channel is required" -fi - -case "$channel" in - stable) workflow=release-stable.yml ;; - beta) workflow=release-beta.yml ;; - *) fail "unknown channel: $channel" ;; -esac - -if [ "$dry_run" = true ]; then - print "dry run" -else - gh --version - gh auth status - trigger_output=$(gh workflow run "$workflow" --ref "$ref" -f "ref=$ref" -f "version_override=$version") - if [ -n "$trigger_output" ]; then - print "$trigger_output" - fi - print "triggered $workflow for ref $ref" - if [ "$watch" = true ]; then - run_id=$(runseal @tool regex capture "$trigger_output" '/actions/runs/([0-9]+)' 1) - if [ -z "$run_id" ]; then - attempt=0 - raw='[]' - while [ "$attempt" -lt 6 ]; do - raw=$(gh run list --workflow "$workflow" --branch "$ref" --event workflow_dispatch --limit 1 --json databaseId) - if [ "$(runseal @tool json empty "$raw")" = false ]; then - run_id=$(runseal @tool json get "$raw" '.[0].databaseId') - break - fi - sleep 2 - attempt=$(runseal @tool int add "$attempt" 1) - done - fi - gh run watch "$run_id" --interval 10 - fi -fi -"# -} - -#[test] -fn release_fixture_roundtrip() { - let fx = fixture(release_source()); - let sealir = run_transpile(&fx, "seal", "sealir"); - - assert!(sealir.status.success()); - let sealir = String::from_utf8(sealir.stdout).expect("stdout should be UTF-8"); - assert!(sealir.contains("argv_parse")); - assert!(sealir.contains("capture_checked")); - assert!(sealir.contains("regex")); - assert!(sealir.contains("json_not_empty")); - assert!(sealir.contains("runseal")); - assert!(sealir.contains("release-stable.yml")); -} - -#[test] -fn release_fixture_emits_targets() { - let fx = fixture(release_source()); - - let bash = run_transpile(&fx, "seal", "bash"); - let powershell = run_transpile(&fx, "seal", "powershell"); - - assert!(bash.status.success()); - assert!(powershell.status.success()); - let bash = String::from_utf8(bash.stdout).expect("stdout should be UTF-8"); - let powershell = String::from_utf8(powershell.stdout).expect("stdout should be UTF-8"); - assert!(bash.contains("trigger_output=$(gh workflow run \"$workflow\"")); - assert!(bash.contains( - "run_id=$(runseal @tool regex capture \"$trigger_output\" '/actions/runs/([0-9]+)' 1)" - )); - assert!(bash.contains("attempt=$(runseal @tool int add \"$attempt\" 1)")); - assert!(powershell.contains("$trigger_output = & 'gh' 'workflow' 'run' $workflow")); - assert!(powershell.contains("& 'runseal' '@tool' 'regex' 'capture' $trigger_output")); - assert!(powershell.contains("& 'runseal' '@tool' 'json' 'empty' $raw")); - super::syntax::assert_bash(&bash); - super::syntax::assert_pwsh(&powershell); -} diff --git a/app/tests/transpile_cases/retry.rs b/app/tests/transpile_cases/retry.rs deleted file mode 100644 index b2f0a67..0000000 --- a/app/tests/transpile_cases/retry.rs +++ /dev/null @@ -1,115 +0,0 @@ -use std::process::Command; - -use tempfile::TempDir; - -struct Fixture { - _temp: TempDir, - dir: std::path::PathBuf, - source: std::path::PathBuf, -} - -fn fixture(source: &str) -> Fixture { - let temp = TempDir::new().expect("temp dir should be created"); - let dir = temp.path().join("project-without-profile"); - std::fs::create_dir_all(&dir).expect("project dir should be created"); - let source_path = dir.join("operator.seal"); - std::fs::write(&source_path, source).expect("source should be written"); - Fixture { - _temp: temp, - dir, - source: source_path, - } -} - -fn run_transpile(fx: &Fixture, input_lang: &str, output_lang: &str) -> std::process::Output { - Command::new(env!("CARGO_BIN_EXE_runseal")) - .current_dir(&fx.dir) - .arg("@transpile") - .arg("--input-lang") - .arg(input_lang) - .arg("--output-lang") - .arg(output_lang) - .arg(&fx.source) - .output() - .expect("runseal should run") -} - -fn retry_source() -> &'static str { - r#" -attempt=0 -raw='[]' -while [ "$attempt" -lt 6 ]; do - raw=$(gh run list --json databaseId) - if [ "$(runseal @tool json empty "$raw")" = false ]; then - run_id=$(runseal @tool json get "$raw" '.[0].databaseId') - break - fi - sleep 2 - attempt=$(runseal @tool int add "$attempt" 1) -done -print "$run_id" -"# -} - -fn powershell_retry_source() -> &'static str { - r#" -$attempt = '0' -$raw = '[]' -while ([int]$attempt -lt '6') { - $raw = & 'gh' 'run' 'list' '--json' 'databaseId' - if ((($raw | ConvertFrom-Json).Count -gt 0)) { - $run_id = & 'runseal' '@tool' 'json' 'get' $raw '.[0].databaseId' - break - } - Start-Sleep -Seconds 2 - $attempt = & 'runseal' '@tool' 'int' 'add' $attempt '1' -} -Write-Output $run_id -"# -} - -#[test] -fn retry_loop_roundtrip() { - for input_lang in ["seal", "bash"] { - let fx = fixture(retry_source()); - let output = run_transpile(&fx, input_lang, "sealir"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert!(stdout.contains("\"type\": \"while\"")); - assert!(stdout.contains("json_not_empty")); - assert!(stdout.contains("capture_checked")); - assert!(stdout.contains("runseal")); - assert!(stdout.contains("\"type\": \"break\"")); - } - - let fx = fixture(powershell_retry_source()); - let output = run_transpile(&fx, "powershell", "sealir"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert!(stdout.contains("\"type\": \"while\"")); - assert!(stdout.contains("json_not_empty")); - assert!(stdout.contains("capture_checked")); -} - -#[test] -fn retry_loop_emits_targets() { - let fx = fixture(retry_source()); - - let bash = run_transpile(&fx, "seal", "bash"); - let powershell = run_transpile(&fx, "seal", "powershell"); - - assert!(bash.status.success()); - assert!(powershell.status.success()); - let bash = String::from_utf8(bash.stdout).expect("stdout should be UTF-8"); - let powershell = String::from_utf8(powershell.stdout).expect("stdout should be UTF-8"); - assert!(bash.contains("while [ $attempt -lt 6 ]; do")); - assert!(bash.contains("attempt=$(runseal @tool int add \"$attempt\" 1)")); - assert!(bash.contains("break")); - assert!(powershell.contains("while ([int]$attempt -lt '6') {")); - assert!(powershell.contains("& 'runseal' '@tool' 'json' 'empty' $raw")); - assert!(powershell.contains("$attempt = & 'runseal' '@tool' 'int' 'add' $attempt '1'")); - super::syntax::assert_bash(&bash); - super::syntax::assert_pwsh(&powershell); -} diff --git a/app/tests/transpile_cases/wrappers.rs b/app/tests/transpile_cases/wrappers.rs deleted file mode 100644 index 8993d25..0000000 --- a/app/tests/transpile_cases/wrappers.rs +++ /dev/null @@ -1,284 +0,0 @@ -use std::path::PathBuf; -use std::process::Command; - -use tempfile::TempDir; - -use super::syntax; - -const WRAPPERS: [&str; 4] = [ - ".runseal/wrappers/cloudflare.seal", - ".runseal/wrappers/init.seal", - ".runseal/wrappers/pr.seal", - ".runseal/wrappers/release.seal", -]; - -fn bin() -> Command { - Command::new(env!("CARGO_BIN_EXE_runseal")) -} - -struct Fixture { - _temp: TempDir, - dir: PathBuf, - source: PathBuf, -} - -fn fixture(source: &str) -> Fixture { - let temp = TempDir::new().expect("temp dir should be created"); - let dir = temp.path().join("project-without-profile"); - std::fs::create_dir_all(&dir).expect("project dir should be created"); - let source_path = dir.join("operator.seal"); - std::fs::write(&source_path, source).expect("source should be written"); - Fixture { - _temp: temp, - dir, - source: source_path, - } -} - -fn run_transpile(fx: &Fixture, output_lang: &str) -> std::process::Output { - bin() - .current_dir(&fx.dir) - .arg("@transpile") - .arg("--input-lang=seal") - .arg("--output-lang") - .arg(output_lang) - .arg(&fx.source) - .output() - .expect("runseal should run") -} - -fn repo_root() -> PathBuf { - std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .expect("app dir should have repo parent") - .to_path_buf() -} - -#[test] -fn repo_wrapper_syntax() { - let root = repo_root(); - for wrapper in WRAPPERS { - let source = root.join(wrapper); - - let bash = bin() - .current_dir(&root) - .arg("@transpile") - .arg("--input-lang=seal") - .arg("--output-lang=bash") - .arg(&source) - .output() - .expect("runseal should run"); - assert!( - bash.status.success(), - "{wrapper} bash stderr: {}", - String::from_utf8_lossy(&bash.stderr) - ); - let bash = String::from_utf8(bash.stdout).expect("bash output should be UTF-8"); - syntax::assert_bash(&bash); - - let powershell = bin() - .current_dir(&root) - .arg("@transpile") - .arg("--input-lang=seal") - .arg("--output-lang=powershell") - .arg(&source) - .output() - .expect("runseal should run"); - assert!( - powershell.status.success(), - "{wrapper} powershell stderr: {}", - String::from_utf8_lossy(&powershell.stderr) - ); - let powershell = - String::from_utf8(powershell.stdout).expect("powershell output should be UTF-8"); - syntax::assert_pwsh(&powershell); - } -} - -#[test] -fn wrappers_use_tool_cli() { - for wrapper in WRAPPERS { - let source = wrapper_source(wrapper); - for namespace in [ - "cloudflare", - "fs", - "github", - "int", - "json", - "process", - "regex", - "string", - ] { - assert!( - !source.contains(&format!("seal {namespace}")), - "{wrapper} should use `runseal @tool {namespace}`, not `seal {namespace}`" - ); - } - } -} - -#[test] -fn wrappers_use_tests() { - for wrapper in WRAPPERS { - let source = wrapper_source(wrapper); - for predicate in [ - "if empty ", - "if not_empty ", - "if eq ", - "if neq ", - "if file_exists ", - "if dir_exists ", - "if json_empty ", - "if json_not_empty ", - "while lt ", - ] { - assert!( - !source.contains(predicate), - "{wrapper} should use bash test predicates, not `{predicate}`" - ); - } - } -} - -#[test] -fn wrappers_use_shift() { - for wrapper in WRAPPERS { - let source = wrapper_source(wrapper); - assert!( - !source.contains("seal passthrough"), - "{wrapper} should use bash shift plus `\"$@\"`, not `seal passthrough`" - ); - } -} - -#[test] -fn wrappers_use_argv_blocks() { - for wrapper in WRAPPERS { - let source = wrapper_source(wrapper); - assert!( - !source.contains("seal argv parse"), - "{wrapper} should use a bash while/case argv parser block, not `seal argv parse`" - ); - } -} - -#[test] -fn wrappers_use_check_tool() { - for wrapper in WRAPPERS { - let source = wrapper_source(wrapper); - assert!( - !source.contains("seal capture optional"), - "{wrapper} should use focused `runseal @tool` glue, not `seal capture optional`" - ); - } -} - -#[test] -fn shift_args_targets() { - let fx = fixture( - r#" -shift -runseal @tool cloudflare api request "$@" -"#, - ); - - let bash = run_transpile(&fx, "bash"); - let powershell = run_transpile(&fx, "powershell"); - - assert!(bash.status.success()); - assert!(powershell.status.success()); - let bash = String::from_utf8(bash.stdout).expect("stdout should be UTF-8"); - let powershell = String::from_utf8(powershell.stdout).expect("stdout should be UTF-8"); - assert!(bash.contains("shift")); - assert!(bash.contains("runseal @tool cloudflare api request \"$@\"")); - assert!(powershell.contains("$args = if ($args.Count -gt 1)")); - assert!(powershell.contains("& 'runseal' '@tool' 'cloudflare' 'api' 'request' @args")); - syntax::assert_bash(&bash); - syntax::assert_pwsh(&powershell); -} - -#[test] -fn powershell_binds_positionals() { - let fx = fixture( - r#" -echo_first() { - print "$1" -} - -echo_first "$2" -"#, - ); - - let powershell = run_transpile(&fx, "powershell"); - - assert!(powershell.status.success()); - let powershell = String::from_utf8(powershell.stdout).expect("stdout should be UTF-8"); - assert!(powershell.contains("$1 = if ($args.Count -ge 1) { $args[0] } else { '' }")); - assert!(powershell.contains("$2 = if ($args.Count -ge 2) { $args[1] } else { '' }")); - assert!(powershell.contains("function echo_first {\n $0 = $args.Count\n $1 = if")); - assert!(!powershell.contains("$3 = if ($args.Count -ge 3)")); - syntax::assert_pwsh(&powershell); -} - -#[test] -fn capture_locals() { - let fx = fixture( - r#" -helper() { - print "hello $1" -} - -value=$(helper world) -print "$value" -"#, - ); - - let sealir = run_transpile(&fx, "sealir"); - assert!(sealir.status.success()); - let sealir = String::from_utf8(sealir.stdout).expect("stdout should be UTF-8"); - assert!(sealir.contains("capture_function")); - assert!(sealir.contains("\"function\": \"helper\"")); - - let bash = run_transpile(&fx, "bash"); - let powershell = run_transpile(&fx, "powershell"); - assert!(bash.status.success()); - assert!(powershell.status.success()); - let bash = String::from_utf8(bash.stdout).expect("stdout should be UTF-8"); - let powershell = String::from_utf8(powershell.stdout).expect("stdout should be UTF-8"); - assert!(bash.contains("value=$(helper world)")); - assert!(powershell.contains("$value = & helper 'world'")); - syntax::assert_bash(&bash); - syntax::assert_pwsh(&powershell); -} - -#[test] -fn capture_locals_pwsh() { - let fx = fixture( - r#" -function helper { - Write-Output ('hello ' + $1) -} - -$value = & helper 'world' -Write-Output $value -"#, - ); - - let output = bin() - .current_dir(&fx.dir) - .arg("@transpile") - .arg("--input-lang=powershell") - .arg("--output-lang=sealir") - .arg(&fx.source) - .output() - .expect("runseal should run"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert!(stdout.contains("capture_function")); - assert!(stdout.contains("\"function\": \"helper\"")); -} - -fn wrapper_source(wrapper: &str) -> String { - std::fs::read_to_string(repo_root().join(wrapper)).expect("wrapper should be readable") -} diff --git a/app/tests/transpile_support/syntax.rs b/app/tests/transpile_support/syntax.rs deleted file mode 100644 index 22bf792..0000000 --- a/app/tests/transpile_support/syntax.rs +++ /dev/null @@ -1,88 +0,0 @@ -use std::{ - io::Write, - process::{Command, Stdio}, -}; - -use tempfile::TempDir; - -#[path = "tool.rs"] -mod tool; - -pub fn assert_bash(source: &str) { - if !tool::exists("bash") || !bash_accepts_stdin() { - return; - } - let mut child = Command::new("bash") - .arg("-n") - .arg("-s") - .stdin(Stdio::piped()) - .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .spawn() - .expect("bash should run"); - child - .stdin - .as_mut() - .expect("bash stdin should be piped") - .write_all(source.as_bytes()) - .expect("bash source should be written"); - let output = child.wait_with_output().expect("bash should finish"); - assert!( - output.status.success(), - "bash syntax should pass: stdout={} stderr={}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); -} - -fn bash_accepts_stdin() -> bool { - let output = Command::new("bash") - .arg("-n") - .arg("-s") - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .output(); - output.is_ok_and(|output| output.status.success()) -} - -pub fn assert_pwsh(source: &str) { - if !tool::exists("pwsh") { - return; - } - let temp = TempDir::new().expect("temp dir should be created"); - let source_path = temp.path().join("source.ps1"); - let checker_path = temp.path().join("check.ps1"); - std::fs::write(&source_path, source).expect("PowerShell source should be written"); - std::fs::write( - &checker_path, - r#" -param([string]$Path) -$tokens = $null -$errors = $null -[System.Management.Automation.Language.Parser]::ParseInput( - (Get-Content -Raw -LiteralPath $Path), - [ref]$tokens, - [ref]$errors -) | Out-Null -if ($errors.Count -gt 0) { - $errors | ForEach-Object { Write-Error $_.Message } - exit 1 -} -"#, - ) - .expect("PowerShell checker should be written"); - let output = Command::new("pwsh") - .arg("-NoProfile") - .arg("-NonInteractive") - .arg("-File") - .arg(&checker_path) - .arg(&source_path) - .output() - .expect("pwsh should run"); - assert!( - output.status.success(), - "PowerShell syntax should pass: {}", - String::from_utf8_lossy(&output.stderr) - ); -} diff --git a/app/tests/transpile_support/tool.rs b/app/tests/transpile_support/tool.rs deleted file mode 100644 index 1b797bb..0000000 --- a/app/tests/transpile_support/tool.rs +++ /dev/null @@ -1,21 +0,0 @@ -use std::path::Path; - -pub fn exists(name: &str) -> bool { - let path = std::env::var_os("PATH").unwrap_or_default(); - std::env::split_paths(&path).any(|dir| executable_exists(&dir.join(name))) -} - -#[cfg(unix)] -fn executable_exists(path: &Path) -> bool { - use std::os::unix::fs::PermissionsExt; - - path.is_file() - && path - .metadata() - .is_ok_and(|metadata| metadata.permissions().mode() & 0o111 != 0) -} - -#[cfg(windows)] -fn executable_exists(path: &Path) -> bool { - path.is_file() -} diff --git a/docs/examples/README.md b/docs/examples/README.md index c2eb7ff..532a49d 100644 --- a/docs/examples/README.md +++ b/docs/examples/README.md @@ -1,10 +1,10 @@ # Examples -These examples capture canonical shapes that are valid but easy to get wrong -from shell intuition alone. +These examples capture repository-owned syntax and tool usage shapes. -- [Seal `case` / argv parser shapes](./seal/case.md) +- [Specs](../spec/README.md) +- [Seal target syntax examples](./seal/README.md) - [GitHub tool examples](./tools/github.md) -Use these as the repository-owned reference when a live wrapper or operator -flow feels "almost shell" but still needs the exact runseal shape. +The Seal examples are target source shapes for the isolated language direction. +They intentionally prioritize clear operational syntax over bash compatibility. diff --git a/docs/examples/seal/README.md b/docs/examples/seal/README.md new file mode 100644 index 0000000..4b38c3e --- /dev/null +++ b/docs/examples/seal/README.md @@ -0,0 +1,30 @@ +# Seal Target Syntax Examples + +These examples describe the intended isolated Seal source syntax. They are +design targets for discussion, not a claim that the current runtime already +parses every shape. + +Seal models finite operational flow: + +- Seal method calls for repo-owned operations. +- External process nodes for ordinary developer infrastructure. +- `@` tool calls for structured runseal capabilities. +- Explicit variables, environment access, control flow, IO, and stream flow. + +The author-facing syntax should not preserve bash surface forms for familiarity. +Cross-platform behavior belongs in the runseal runtime, not in shell-shaped +source code. + +## Files + +- [Grammar draft](./grammar.md) +- [Terminology and lowering model](./semantics.md) +- [Perish workflow sketches](./perish-scenarios.md) +- [Calls](./calls.md) +- [Control flow](./control-flow.md) +- [Environment and scope](./env-scope.md) +- [IO and pipelines](./io-pipeline.md) +- [Values and collections](./values.md) +- [Full stream model sketch](./stream-model.md) +- [Full failure model sketch](./failure-model.md) +- [Argv parsing](./case.md) diff --git a/docs/examples/seal/calls.md b/docs/examples/seal/calls.md new file mode 100644 index 0000000..bdbc3a1 --- /dev/null +++ b/docs/examples/seal/calls.md @@ -0,0 +1,148 @@ +# Seal Calls + +Seal has three source call forms. They should be visually distinct because they +come from different providers, but they all lower toward function-valued +callables that instantiate operation frames. + +## Method calls + +Methods are Seal-owned operations. Calls always use parentheses, even when no +arguments are passed. This keeps method calls distinct from external binaries. + +```seal +method release(channel, watch = false) { + validate_release_channel(channel) + build(channel) + publish(channel, watch) +} + +method validate_release_channel(channel) { + if channel != "stable" && channel != "beta" { + fail("invalid release channel: {channel}", code: 2) + } +} + +release("beta", watch: true) +``` + +## External Process Calls + +External programs use a leading `|` marker. The marker reads as an external +process node, not as a pipeline operator. Stream flow uses `>>` and `<<`. + +```seal +| git status +| cargo test --locked --workspace +| kubectl apply -f "deploy.yaml" +| ssh deploy "systemctl restart runseal" +``` + +Variables in argv position use `{expr}` interpolation. Bare command words remain +literal argv tokens. + +```seal +let ref = "main" +let workflow = "release-beta.yml" + +| gh workflow run {workflow} --ref {ref} -f "ref={ref}" +``` + +The explicit metaprogramming/debug form is `@call.process(...)`. + +```seal +| gh pr view {number} --json number,url + +@call.process("gh", ["pr", "view", number, "--json", "number,url"]) +``` + +## Tool calls + +Runseal tools are first-class `@` calls, not disguised external binaries. The +current CLI path `runseal @tool github issue comment create ...` maps to a +structured Seal call. + +```seal +@github.issue.comment.create( + repo: "PerishCode/runseal", + number: 49, + body_file: "body.md", + body_max: 0, + prefix_enable: true, +) +``` + +The `@` namespace is function-call shaped only. It has two valid categories: +regular methods whose behavior can be modeled by composing runtime primitives, +and optimized built-ins that the runtime may implement directly. It should not +grow semantic glue such as `@frame.*`; frame structure belongs to `#` streams. + +Tool and built-in calls can be used as statements when the effect matters, or as +expressions when their return value is needed. + +```seal +let exists = @process.exists("git") + +if !exists { + fail("missing required tool: git") +} + +@fs.mkdir(".git/hooks", mode: 700) +``` + +## Function-valued callables + +Methods, external process calls, and `@` built-ins all become callable values at +the runtime model layer. A direct call is the surface form of raw execution: +evaluate the callable with its actual arguments, create the operation frame, and +apply the default completion policy. + +```seal +method current_branch() { + | git branch --show-current +} + +let branch_reader = current_branch +let branch = @type.string(branch_reader()) +``` + +The equivalent metaprogramming/debug form is deliberately more explicit. + +```seal +@call.forward(branch_reader, []) +``` + +`@call.forward(branch_reader, [])` is semantically equivalent to +`branch_reader()`. `@call.process("git", ["branch", "--show-current"])` is +semantically equivalent to `| git branch --show-current`. The second argument in +both explicit forms is an ordinary argument bundle array, not a frame variable. +Named arguments can be carried as ordinary map values when needed; the +cold-start model does not need separate named-argument forwarding syntax. + +The cold-start surface does not need heavy function syntax, but the model should +allow function values because guards, cleanup callbacks, and callable frame +expansion all depend on the same idea. + +## Complete shape + +```seal +method main() { + let channel = $RUNSEAL_CHANNEL ?? "beta" + let workflow = match channel { + "stable" => "release-stable.yml" + "beta" => "release-beta.yml" + _ => fail("invalid release channel: {channel}", code: 2) + } + + | git --version + | gh auth status + + @github.workflow.run( + workflow: workflow, + ref: "main", + fields: { + ref: "main", + version_override: "", + }, + ) +} +``` diff --git a/docs/examples/seal/case.md b/docs/examples/seal/case.md index 9fe7a0d..18882d3 100644 --- a/docs/examples/seal/case.md +++ b/docs/examples/seal/case.md @@ -1,170 +1,116 @@ -# Seal `case` / Argv Shapes +# Seal Argv Parsing -This file documents the canonical `.seal` shapes for `case "$1" in` argv -parsing. These are intentionally narrower than general shell scripting. +This file replaces the old shell-shaped `case "$1" in` examples with the target +Seal source shape for wrapper argument parsing. -## Supported option arm: `--name=value` +The first design target is still a normal operational loop: explicit defaults, +explicit flags, and clear failures. A future declarative argv parser can be +added later if the repeated shape becomes valuable enough. -```sh ---body=*) - body=${1#--body=} - shift - ;; -``` - -This is the canonical inline-value string option arm. - -## Supported option arm: `--name <value>` - -Single-line guarded form: - -```sh ---body) - if [ "$#" -lt 2 ]; then fail "missing value for --body"; fi - body=$2 - shift 2 - ;; -``` - -Multi-line guarded form: +## Cursor-based parsing -```sh ---body) - if [ "$#" -lt 2 ]; then - fail "missing value for --body" - fi - body=$2 - shift 2 - ;; -``` +```seal +method main(argv) { + let channel = "" + let ref = "main" + let version = "" + let watch = false + let dry_run = false -Both are canonical string-option arms. + let args = argv.cursor() -The guard predicate is intentionally exact: + while args.has_next() { + let arg = args.next() -```sh -if [ "$#" -lt 2 ]; then -``` + match arg { + "--channel" => channel = args.value_or_fail("--channel") + when arg.starts_with("--channel=") => channel = arg.strip_prefix("--channel=") -This is not a general parser for arbitrary pre-check logic. + "--ref" => ref = args.value_or_fail("--ref") + when arg.starts_with("--ref=") => ref = arg.strip_prefix("--ref=") -## Supported flag arm + "--version" => version = args.value_or_fail("--version") + when arg.starts_with("--version=") => version = arg.strip_prefix("--version=") -```sh ---dry-run) - dry_run=true - shift - ;; -``` + "--watch" => watch = true + "--dry-run" => dry_run = true -## Supported help arm + "-h" | "--help" | "help" => { + usage() + exit(0) + } -```sh --h|--help|help) - __seal_help=true - shift - ;; -``` + _ => fail("unknown option: {arg}") + } + } -## Supported positional fallback arm - -This is the one supported positional sink shape: - -```sh -*) - if [ -z "$message" ]; then - message=$1 - shift - else - fail "unexpected argument: $1" - fi - ;; -``` + if channel == "" { + fail("release: --channel is required") + } -Semantics: - -- The first unmatched argument fills one positional target. -- The next unmatched argument fails with a stable operator-facing message. -- This is not the same as "take one arg and break out of parsing." - -## Not supported - -These shapes are intentionally outside the current canonical surface: - -```sh -*) - message=$1 - shift - break - ;; -``` - -```sh -*) - shift - ;; -``` - -```sh ---body) - body=$2 - shift 2 - ;; + release(channel, ref: ref, version: version, watch: watch, dry_run: dry_run) +} ``` -The last form looks natural in shell, but runseal currently requires the -canonical missing-value guard for separated-value option arms. - -## Complete minimal example - -```sh -print() { - printf '%s\n' "$1" +## Positional fallback + +The old canonical shell shape allowed one positional sink and rejected the +second unmatched argument. In Seal, that should be visible as ordinary state. + +```seal +method main(argv) { + let body = "" + let message = "" + let dry_run = false + let args = argv.cursor() + + while args.has_next() { + let arg = args.next() + + match arg { + "--body" => body = args.value_or_fail("--body") + when arg.starts_with("--body=") => body = arg.strip_prefix("--body=") + "--dry-run" => dry_run = true + "-h" | "--help" | "help" => { + usage() + exit(0) + } + _ => { + if message == "" { + message = arg + } else { + fail("unexpected argument: {arg}") + } + } + } + } + + create_comment(body: body, message: message, dry_run: dry_run) } +``` -fail() { - print "$1" - exit 1 +## Possible future parser shape + +If this pattern repeats enough, Seal can grow a first-class argv declaration. +That should be a deliberate syntax feature, not a hidden shell parser. + +```seal +method main(argv) { + let opts = argv.parse { + option channel required "--channel" + option ref = "main" "--ref" + option version = null "--version" + flag watch "--watch" + flag dry_run "--dry-run" + help "-h" "--help" "help" + } + + release( + opts.channel, + ref: opts.ref, + version: opts.version, + watch: opts.watch, + dry_run: opts.dry_run, + ) } - -__seal_argc=$# -__seal_help=false -body= -message= - -while [ "$#" -gt 0 ]; do - case "$1" in - --body) - if [ "$#" -lt 2 ]; then - fail "missing value for --body" - fi - body=$2 - shift 2 - ;; - --body=*) - body=${1#--body=} - shift - ;; - -h|--help|help) - __seal_help=true - shift - ;; - *) - if [ -z "$message" ]; then - message=$1 - shift - else - fail "unexpected argument: $1" - fi - ;; - esac -done - -if [ "$__seal_help" = true ]; then - print help - exit 0 -fi - -print "$body" -print "$message" ``` diff --git a/docs/examples/seal/control-flow.md b/docs/examples/seal/control-flow.md new file mode 100644 index 0000000..08cfd10 --- /dev/null +++ b/docs/examples/seal/control-flow.md @@ -0,0 +1,92 @@ +# Seal Control Flow + +Control blocks use braces. Seal should not inherit `then`/`fi`, `do`/`done`, or +`case`/`esac` surface syntax. + +## If / else if / else + +```seal +method ensure_release_branch(base) { + let branch = @type.string { + | git branch --show-current + } + + if branch == "" { + fail("not on a branch") + } else if branch == base { + fail("refusing to release from base branch: {branch}") + } else if branch == "main" || branch == "master" { + fail("refusing to release from base branch: {branch}") + } +} +``` + +## Match + +Use `match` for value selection. It replaces shell `case` for ordinary control +flow. + +```seal +let workflow = match channel { + "stable" => "release-stable.yml" + "beta" => "release-beta.yml" + _ => fail("invalid choice: {channel}", code: 2) +} +``` + +Match arms may also run blocks when the branch is effectful. + +```seal +match target { + "macos" => { + | brew --version + | ./manage.sh install --channel {channel} + } + "linux" => { + | systemctl --version + | ./manage.sh install --channel {channel} + } + _ => fail("unsupported target: {target}") +} +``` + +## For + +`for` iterates over lists. The loop variable is scoped to the block. + +```seal +let required = ["git", "gh", "cargo", "runseal", "flavor"] + +for tool in required { + if !@process.exists(tool) { + fail("missing required tool: {tool}") + } +} +``` + +## While + +`while` is useful for bounded polling and retry loops. Operational wrappers +should keep loops finite and visible. + +```seal +let attempt = 0 +let run_id = "" + +while attempt < 6 && run_id == "" { + let runs = @type.array { + | gh run list --workflow {workflow} --branch {ref} --limit 1 --json databaseId + } + + if runs != [] { + run_id = runs[0].databaseId + } else { + @time.sleep(2) + attempt = attempt + 1 + } +} + +if run_id == "" { + fail("could not find a recent workflow run for {workflow}") +} +``` diff --git a/docs/examples/seal/env-scope.md b/docs/examples/seal/env-scope.md new file mode 100644 index 0000000..005a31d --- /dev/null +++ b/docs/examples/seal/env-scope.md @@ -0,0 +1,94 @@ +# Seal Environment And Scope + +Environment access is a primitive process-boundary concept. Seal variables and +process environment are related, but not the same namespace. + +## Reading environment + +```seal +let token = $GITHUB_TOKEN +let channel = $RUNSEAL_CHANNEL ?? "beta" +let profile = $RUNSEAL_PROFILE_PATH +``` + +Environment reads return `string | null`. Use `??` when the wrapper owns a +default, and `require(...)` when the wrapper needs a hard precondition. + +```seal +let ref = $RUNSEAL_REF ?? "main" +let token = require($GITHUB_TOKEN, "missing GITHUB_TOKEN") +``` + +## Temporary environment + +Temporary environment is scoped with `with env { ... } { ... }`. The first block +is pure environment binding: no commands, no control flow, no side effects. + +```seal +with env { + RUST_LOG = "debug" + RUNSEAL_CHANNEL = channel +} { + | cargo test --locked --workspace +} +``` + +The injected values apply only to process nodes and tool calls inside the block. + +```seal +method publish(channel) { + with env { + RUNSEAL_CHANNEL = channel + } { + @cloudflare.api.request("GET", "/zones", query: ["per_page=50"]) + | gh workflow run "release-beta.yml" --ref "main" + } +} +``` + +Config paths and values use the same environment projection model. + +```seal +let configs = @fs.list($PERISH_TOP_KUBE_DIR, glob: "*.yaml", files: true) +let kubeconfig = @string.join(configs, separator: "path") + +with env { + KUBECONFIG = kubeconfig +} { + | kubectl config current-context + | kubectl apply -f "deploy.yaml" +} +``` + +## Variables and block scope + +`let` declares a variable in the current block. Assignment updates the nearest +visible variable. + +```seal +method prepare() { + let root = @type.string { + | git rev-parse --show-toplevel + } + + if root == "" { + fail("not inside a git repository") + } + + { + let hooks_dir = "{root}/.git/hooks" + @fs.mkdir(hooks_dir, mode: 700) + } + + // hooks_dir is not visible here. +} +``` + +Cold-start Seal does not use type annotations. Runtime values still have +concrete types, and mismatched operations fail fast. + +```seal +let dry_run = false +let attempts = 6 +let body_file = "body.md" +``` diff --git a/docs/examples/seal/failure-model.md b/docs/examples/seal/failure-model.md new file mode 100644 index 0000000..29c4d78 --- /dev/null +++ b/docs/examples/seal/failure-model.md @@ -0,0 +1,572 @@ +# Seal Full Failure Model Sketch + +This file intentionally sketches a maximal failure model before surface syntax +is compressed. It is not a final author-facing style. + +The current direction is to model failure as part of operation frame lifecycle, +not as a separate `try/catch/finally` language family. The same frame model must +cover methods, external binaries, and optimized `@` built-ins. + +The goal is to separate concepts that shell and common exception syntax often +mix together: + +- operation frame lifecycle +- frame control events +- effect completion +- command exit code +- stream conversion failure +- Seal semantic exception +- operator cancellation +- cleanup / scope finalization + +## Frame streams + +`#<word>` consistently denotes a current-frame stream or channel. + +```text +frame { + #stdin: stream + #stdout: stream + #stderr: stream + #frame: stream +} +``` + +`#frame` is the current operation frame's control/event stream. The executor +consumes this stream and owns the lifecycle policy for events written to it. + +```seal +{ + type: "exit", + code: 0, +} >> #frame +``` + +A structured fault event can also be written to `#frame`. + +```seal +{ + type: "fault", + fault: { + kind: "shape", + message: "expected release.version to be a string", + data: {}, + }, +} >> #frame +``` + +This keeps lifecycle control inside the `#` stream family. Do not introduce +`@frame.*`; the `@` namespace remains function-call shaped. + +## Function calls and call-domain helpers + +Methods, binaries, and optimized built-ins are all callable values at the model +layer. Direct calls are the daily surface. `@call.forward(...)` is the equivalent +metaprogramming/debug form. + +```seal +method current_branch() { + | git branch --show-current +} + +let reader = current_branch +let branch = @type.string(reader()) +``` + +The explicit call-forwarding form accepts a callable and an ordinary array of +actual arguments. + +```seal +@call.forward(reader, []) +``` + +`@call.forward(reader, [])` is semantically equivalent to `reader()`. The second +argument is an ordinary argument bundle array, not a built-in frame variable. +Named arguments can be represented with map values instead of a separate +forwarding syntax. + +External process syntax has the same explicit/debug relationship: + +```seal +| gh pr view {number} + +@call.process("gh", ["pr", "view", number]) +``` + +The callable's operation frame owns `#stdin`, `#stdout`, `#stderr`, and +`#frame`. Ordinary source syntax can hide most of this, but the full model +should not. + +## Failure domains + +```text +completed ok + the frame finished successfully + +completed failed + the frame finished, but the operation failed as a domain/process result + +faulted + the frame could not continue under Seal semantics, or a structured exception + event was written to #frame + +cancelled + the frame was interrupted by the operator or a parent runtime cancellation + +cleanup + functions registered with the frame run because the scope is leaving, + regardless of completed/failed/faulted/cancelled +``` + +These domains should not collapse into `try/catch/finally`. + +## Frame event protocol v0 + +Executor-recognized writes to `#frame` use map events. + +```seal +{ + type: "exit", + code: 0, +} >> #frame + +{ + type: "failed", + code: 2, + message: "invalid release channel", + data: {}, +} >> #frame + +{ + type: "fault", + fault: { + kind: "shape", + message: "expected release.version to be a string", + data: {}, + }, +} >> #frame + +{ + type: "cancelled", + source: "operator", + signal: "interrupt", +} >> #frame + +{ + type: "cleanup", + run: () => { + @file.remove(tmp) + }, +} >> #frame +``` + +The executor owns the lifecycle effect of these events. User code writes events; +it does not call an `@frame.*` control API. + +## Completion value v0 + +`@call.completion(...)` returns a structured completion value. The value is +ordinary data and can be matched directly. + +```seal +{ + status: "ok", + exit: { + code: 0, + signal: null, + }, + faults: [], + cancelled: null, +} +``` + +```seal +{ + status: "failed", + exit: { + code: 1, + signal: null, + }, + faults: [], + cancelled: null, +} +``` + +```seal +{ + status: "faulted", + exit: null, + faults: [ + { + kind: "shape", + message: "expected release.version to be a string", + data: {}, + }, + ], + cancelled: null, +} +``` + +```seal +{ + status: "cancelled", + exit: null, + faults: [], + cancelled: { + source: "operator", + signal: "interrupt", + }, +} +``` + +This shape intentionally keeps stdout and stderr out of the completion value. +Call IO is routed through the callback parameters. Completion records how the +operation ended. + +## Completion is after-frame state + +Completion is not a live stream inside the child frame body. The callback passed +to `@call.completion(...)` can route the callable's live streams through +parameters, but the returned completion value only exists after that frame +leaves. + +```seal +let outer_err = #stderr + +let completion = @call.completion( + @call.process("gh", ["pr", "view", number, "--json", "number,url,isDraft"]), + (stdin, stdout, stderr, frame) => { + stderr >> outer_err + }, +) + +match completion { + { status: "ok" } => { + use_existing_pr() + } + { status: "failed", exit: exit } => { + create_pr() + } + { status: "faulted", faults: faults } => { + @exception.raise({ + kind: "child-fault", + cause: faults, + }) + } + { status: "cancelled" } => { + @exception.raise({ + kind: "cancelled", + message: "operator cancelled child frame", + }) + } +} +``` + +This is the full-model shape for catch-like behavior: enter a child operation +frame, route its streams, then observe its completion from the parent layer. + +## Completion chaining sugar + +Promise-like chaining is allowed as built-in sugar over the same completion +value. It is not a separate exception system. + +```seal +@call.completion( + @call.process("gh", ["pr", "view", number, "--json", "number,url,isDraft"]), + (stdin, stdout, stderr, frame) => { + stderr >> outer_err + }, +) + .ok((completion) => { + use_existing_pr() + }) + .failed((exit, completion) => { + create_pr() + }) + .faulted((faults, completion) => { + @exception.raise({ + kind: "child-fault", + cause: faults, + }) + }) + .cancelled((cancelled, completion) => { + @exception.raise({ + kind: "cancelled", + cause: cancelled, + }) + }) + .always((completion) => { + @file.remove(tmp) + }) +``` + +Chaining lowers to `match completion { ... }`. `.failed(...)` handles +completed-but-failed operations; `.faulted(...)` handles Seal/runtime faults. +The two should not collapse into one catch-all branch. + +## Quick-fail default + +Ordinary effect execution quick-fails when the child frame does not complete +successfully. + +```seal +| gh auth status +| git push -u origin {branch} +``` + +Expanded model: + +```text +create child operation frame +inherit or route child stdio +wait for child frame completion +if completion is completed ok: + continue +if completion is completed failed: + write diagnostic/control event to current #frame +if completion is faulted: + write structured fault event to current #frame +if completion is cancelled: + write cancellation event to current #frame +``` + +When a workflow expects failure as data, it should observe completion instead of +letting the default quick-fail policy run. + +## Operational fail as a helper + +Many ordinary control-flow examples use `fail(...)` for operator-facing workflow +failure. + +```seal +fail("invalid release channel: {channel}", code: 2) +``` + +This should be treated as a normal helper function, not as a special `@frame` +namespace and not as a Seal semantic exception. Conceptually it writes a failed +completion event to the helper frame's current `#frame` and then exits the +helper frame. The caller then sees the helper's failed completion through the +normal frame propagation policy. + +```seal +{ + type: "failed", + code: 2, + message: "invalid release channel: {channel}", + data: {}, +} >> #frame + +{ + type: "exit", + code: 2, +} >> #frame + +return null // unreachable +``` + +This is distinct from `@exception.raise(...)`, which produces a fault event. + +## Stream conversion failure + +Optimized `@type.*` helpers combine effect completion, stdout EOF, conversion, +and binding when their argument is a process node or stream graph. + +```seal +let pr = @type.map { + | gh pr view {number} --json number,url,isDraft +} +``` + +Expanded model: + +```text +create child operation frame with empty stdin +capture child #stdout +inherit or route child #stderr +wait for stdout EOF +wait for child frame completion +if completion is not completed ok: + write diagnostic/control event to current #frame +convert stdout bytes with @type.map +if conversion fails: + write structured fault event to current #frame +bind pr +``` + +Conversion failure is not the same thing as command exit failure. Both can +become current-frame control events, but they come from different model layers. +Use `:=` only when stdout should be bound as a readonly stream view. + +## Exception raise as a function call + +`@exception.raise(...)` can remain valid because it is function-call shaped. It +is not an `@frame.*` control namespace. Its behavior can be modeled as a regular +method or optimized built-in that writes to the current `#frame` stream and then +quick-returns through its own frame. + +```seal +@exception.raise({ + kind: "shape", + message: "expected release.version to be a string", +}) +``` + +Conceptual expansion: + +```seal +{ + type: "fault", + fault: { + kind: "shape", + message: "expected release.version to be a string", + data: {}, + }, +} >> #frame + +{ + type: "exit", + code: 1, +} >> #frame + +return null // unreachable +``` + +The exact event shape is not final. The important boundary is that raising an +exception is a function call whose implementation composes frame streams; frame +itself is not exposed as an `@frame` function namespace. + +## Guarding a stream + +Catch-like behavior can also be modeled as guarding a stream and writing +recognized fault events to a target frame stream. + +```seal +let outer_frame = #frame + +@call.completion( + @call.process("gh", ["pr", "view", number, "--json", "number,url,isDraft"]), + (stdin, stdout, stderr, frame) => { + @exception.guard(outer_frame, stderr) + }, +) +``` + +`outer_frame` is not a frame object. It is the outer `#frame` stream captured as +a value. A guard can be modeled as an ordinary function that receives a target +frame stream and a source stream. + +```seal +method guard(target_frame, source) { + while true { + let event = @stream.read(source) + + if @exception.matches(event) { + { + type: "fault", + fault: { + kind: "guarded-stream", + message: "guarded stream emitted an exception event", + data: { + cause: event, + }, + }, + } >> target_frame + + { + type: "exit", + code: 1, + } >> #frame + } + } +} +``` + +This sketch intentionally ignores EOF and scheduling details. The point is that +guarding is stream composition plus frame control events, not a special +`try/catch` primitive. + +## Cleanup as frame data + +Cleanup belongs to frame lifecycle, not to exception handling. Once function is +a runtime value, cleanup can be modeled as a function value registered with the +current frame. + +```seal +let tmp = @file.temp() + +{ + type: "cleanup", + run: () => { + @file.remove(tmp) + }, +} >> #frame +``` + +This is a deliberately verbose model shape. Surface syntax can later shrink +cleanup registration, but the executor-level behavior remains frame lifecycle: +cleanup runs when the frame leaves, regardless of success, failure, fault, or +cancellation. + +## Full ugly shape + +This deliberately verbose sketch shows all pieces at once. + +```seal +let tmp = @file.temp() +let outer_err = #stderr +let outer_frame = #frame + +{ + type: "cleanup", + run: () => { + @file.remove(tmp) + }, +} >> #frame + +let completion = @call.completion( + @call.process("gh", ["pr", "view", number, "--json", "number,url,isDraft"]), + (stdin, stdout, stderr, frame) => { + stdout >> @file.write(tmp) + stderr >> outer_err + + @exception.guard(outer_frame, stderr) + }, +) + +match completion { + { status: "ok" } => { + let pr = @type.map { + | cat {tmp} + } + + if pr.isDraft { + @exception.raise({ + kind: "draft-pr", + message: "PR is still draft", + }) + } + } + + { status: "failed", exit: exit } => { + create_pr() + } + + { status: "faulted", faults: faults } => { + @exception.raise({ + kind: "child-fault", + cause: faults, + }) + } + + { status: "cancelled" } => { + @exception.raise({ + kind: "cancelled", + message: "operator cancelled child frame", + }) + } +} +``` + +This is not the desired surface. It is a pressure test for the model. Later +syntax should only hide defaults and common routing patterns; it should not +change the frame/stream lifecycle underneath. diff --git a/docs/examples/seal/grammar.md b/docs/examples/seal/grammar.md new file mode 100644 index 0000000..4a9ab8c --- /dev/null +++ b/docs/examples/seal/grammar.md @@ -0,0 +1,714 @@ +# Seal Grammar Draft + +This is a first-pass grammar draft for the isolated Seal source syntax. It is +meant to drive parser design, not to freeze every surface detail. + +The grammar is intentionally centered on the current syntax spine: + +```text +Seal call foo(a, b) +tool/builtin call @github.pr.view(...) +process node | gh pr view ... +stream flow a >> b +mirror flow b << a +``` + +## Notation + +This file uses informal EBNF: + +```text +x? optional x +x* zero or more x +x+ one or more x +x | y x or y +"x" literal token +``` + +`SEP` means a statement separator. Newline and semicolon are equivalent +separators, except where a surrounding construct owns the newline. + +```text +SEP = NEWLINE | ";" +``` + +## Comments + +Line comments use `//`. Block comments use `/* ... */`. + +```seal +// line comment + +/* + block comment +*/ +``` + +First-pass block comments do not need to nest. + +Comments are trivia. For process argv, `//` should be recognized as a comment +only at a trivia boundary, such as the start of a line or after whitespace, so a +bare URL-like argv token is not split accidentally. + +```seal +| curl https://example.com // ok: URL is one argv token +| curl https://example.com // ok: comment starts after whitespace +``` + +## Lexical Structure + +```text +identifier + = ASCII_ALPHA (ASCII_ALNUM | "_")* + +at_name + = "@" identifier ("." identifier)* + +env_name + = "$" identifier + +frame_channel + = "#" identifier + +string + = double_quoted_string | text_block +``` + +String literals are the only lexical escape hatch. Double-quoted strings cover +ordinary inline text and support `{expr}` interpolation plus string escape +sequences. Backtick text blocks cover multiline strings. Seal does not provide +backtick identifiers, raw identifiers, shell-style backslash escaping in process +argv, or alternate quote forms for escaping syntax. + +```seal +"ref={ref}" +"literal {ref}" +` +multi-line text +` +``` + +Reserved words: + +```text +method let if else match for in while break continue with env +true false null +``` + +Reserved words and syntax symbols keep their grammar meaning outside strings. +If source needs one as literal data, put it in a string literal. + +```seal +| some-tool ";" +| some-tool ">>" +let word = "method" +``` + +Tokenization should prefer the longest valid token. In particular: + +```text +">>" before ">" +"<<" before "<" +"||" before "|" +"=>" before "=" +":=" before ":" +"??" before "?" +``` + +`| WHITESPACE` is recognized as a process-node marker only where the parser +expects an effect atom or effect statement. In expression grammar, `||` remains +boolean OR. In match pattern grammar, `|` remains a pattern alternative +separator. + +## Program + +```text +program + = separator* item* EOF + +item + = method_decl + | statement + +separator + = SEP+ +``` + +Separators are ignored inside strings and inside parenthesized, bracketed, or +braced expression forms unless that construct explicitly parses statements. + +## Blocks + +```text +block + = "{" separator* statement_list? separator* "}" + +statement_list + = statement (separator+ statement)* separator* +``` + +Blocks own their internal separators. A block used as a map literal is parsed in +expression context; a block used as a method body, control-flow body, or effect +body is parsed in statement context. + +## Methods + +```text +method_decl + = "method" identifier "(" parameter_list? ")" block + +parameter_list + = parameter ("," parameter)* ","? + +parameter + = identifier ("=" expression)? +``` + +```seal +method release(channel, ref = "main") { + | gh workflow run {channel} --ref {ref} +} +``` + +## Callable Tail And Completion + +Callable bodies are operation frames. They do not need a hidden return-value +slot. Output and completion are modeled through the current frame streams: + +```text +value output -> value >> #stdout +normal end -> { type: "ok" } >> #frame +``` + +At the end of a callable body, fallthrough emits a normal ok completion event. +If the callable body has a tail value expression and does not explicitly use the +current frame's `#stdout`, that tail value is implicitly written to `#stdout`. + +```seal +method workflow_for(channel) { + match channel { + "stable" => "release-stable.yml" + "beta" => "release-beta.yml" + _ => fail("invalid channel: {channel}") + } +} +``` + +Conceptual lowering: + +```seal +method workflow_for(channel) { + match channel { + "stable" => "release-stable.yml" + "beta" => "release-beta.yml" + _ => fail("invalid channel: {channel}") + } >> #stdout + + { type: "ok" } >> #frame +} +``` + +If the callable body explicitly references the current frame's `#stdout`, this +implicit tail-output sugar is disabled for that callable. Nested callables, +lambdas, and handlers have their own `#stdout` and do not affect the outer +callable's tail-output decision. + +```seal +method report() { + "starting" >> #stdout + make_summary() // not implicitly written to #stdout +} +``` + +Early normal completion uses an explicit call-domain helper: + +```seal +@call.exit(value, event?) +``` + +Conceptual lowering: + +```seal +value >> #stdout +(event ?? { type: "ok" }) >> #frame +// stop current callable frame +``` + +`@call.exit()` is equivalent to `@call.exit(null)`. `@call.exit(value)` is the +canonical form for early return-like behavior. A future concise return sugar can +lower to this helper, but the explicit `#stdout`/`#frame` path must remain +adjacent and usable. + +## Statements + +```text +statement + = let_statement + | assign_statement + | if_statement + | match_statement + | for_statement + | while_statement + | with_env_statement + | break_statement + | continue_statement + | effect_statement + | expression_statement +``` + +```text +let_statement + = "let" identifier "=" expression + | "let" identifier ":=" effect_block_or_expr + +assign_statement + = lvalue "=" expression + +break_statement + = "break" + +continue_statement + = "continue" + +effect_statement + = effect_expression + +expression_statement + = expression +``` + +```text +lvalue + = identifier lvalue_suffix* + +lvalue_suffix + = "." identifier + | "[" expression "]" +``` + +`let x := ...` binds stdout as a readonly stream view. `let x = ...` binds a +Seal value. + +```seal +let text = "hello" + +let logs := { + | kubectl logs deploy/app --follow +} +``` + +## Control Flow + +```text +if_statement + = "if" expression block ("else" "if" expression block)* ("else" block)? + +while_statement + = "while" expression block + +for_statement + = "for" identifier "in" expression block +``` + +```seal +if branch in ["main", "master"] { + fail("protected branch: {branch}") +} else if branch == "" { + fail("not on a branch") +} +``` + +`match` is both an expression and a statement form. Arms can return expressions +or run blocks. + +```text +match_expression + = "match" expression "{" match_arm* "}" + +match_statement + = match_expression + +match_arm + = pattern_list "=>" (expression | block) separator* + +pattern_list + = pattern ("|" pattern)* + +pattern + = "_" + | literal + | identifier + | map_pattern + | array_pattern + +map_pattern + = "{" (map_pattern_entry ("," map_pattern_entry)* ","?)? "}" + +map_pattern_entry + = identifier ":" pattern + +array_pattern + = "[" (pattern ("," pattern)* ","?)? "]" +``` + +```seal +let workflow = match channel { + "stable" => "release-stable.yml" + "beta" => "release-beta.yml" + _ => fail("invalid channel: {channel}") +} +``` + +Pattern alternatives use `|` only inside match-arm pattern context. That does +not conflict with process nodes because process nodes are recognized only at +statement or effect-expression starts followed by whitespace. + +## Environment Scope + +```text +with_env_statement + = "with" "env" env_block block + +env_block + = "{" separator* env_binding* separator* "}" + +env_binding + = identifier "=" expression separator* +``` + +The first block is pure environment projection. It does not accept commands, +tool calls, or control flow. + +```seal +with env { + RUST_LOG = "debug" + RUNSEAL_CHANNEL = channel +} { + | cargo test --locked --workspace +} +``` + +## Expressions + +The expression grammar is ordinary and deliberately separate from process argv +mode. + +```text +expression + = match_expression + | lambda_expression + | null_coalesce + +lambda_expression + = "(" parameter_list? ")" "=>" block + +null_coalesce + = boolean_or ("??" boolean_or)* + +boolean_or + = boolean_and ("||" boolean_and)* + +boolean_and + = equality ("&&" equality)* + +equality + = comparison (("==" | "!=") comparison)* + +comparison + = additive (("<" | "<=" | ">" | ">=" | "in") additive)* + +additive + = multiplicative (("+" | "-") multiplicative)* + +multiplicative + = unary (("*" | "/" | "%") unary)* + +unary + = ("!" | "-") unary + | postfix + +postfix + = primary postfix_suffix* + +postfix_suffix + = call_suffix + | block_argument + | "." identifier + | "[" expression "]" + +call_suffix + = "(" argument_list? ")" + +argument_list + = argument ("," argument)* ","? + +argument + = expression + | identifier ":" expression + +block_argument + = effect_block +``` + +Labeled call arguments are source sugar. They should not become a separate +runtime forwarding system; lowering can turn them into ordinary structured +values for tool and helper APIs. + +Primary expressions: + +```text +primary + = literal + | identifier + | at_name + | env_name + | frame_channel + | array_literal + | map_literal + | "(" expression ")" + +literal + = string | integer | "true" | "false" | "null" + +array_literal + = "[" (expression ("," expression)* ","?)? "]" + +map_literal + = "{" (map_entry ("," map_entry)* ","?)? "}" + +map_entry + = identifier ":" expression + | string ":" expression +``` + +Examples: + +```seal +let port = @type.int($PORT ?? "8080") +let run_id = require(runs[0].databaseId, "missing run id") + +@github.issue.comment.create( + repo: "PerishCode/runseal", + number: 49, + body_file: "body.md", +) +``` + +## Receiver Calls + +Postfix member calls can be receiver-style calls. This is source sugar over a +canonical self-call operation. + +```seal +let trimmed = text.trim() +let upper = name.upper() +``` + +Candidate lowering: + +```seal +@call.self(text, @string.trim, []) +@call.self(name, @string.upper, []) +``` + +Primitive/runtime values may be boxed and unboxed by the runtime to call +built-in receiver methods. This keeps convenient method syntax adjacent to an +explicit metaprogramming form, similar to the relationship between `foo(a)` and +`@call.forward(foo, [a])`. + +Field access and receiver calls remain distinct at the syntax edge: + +```seal +release.version // field access +release.version() // receiver-style call +``` + +## Effect Expressions + +Effect expressions are where process nodes and stream flow live. + +```text +effect_block_or_expr + = effect_block + | effect_expression + +effect_block + = "{" separator* effect_expression separator* "}" + +effect_expression + = stream_expression + +stream_expression + = effect_atom (stream_operator effect_atom)* + +stream_operator + = ">>" | "<<" + +effect_atom + = process_node + | expression +``` + +`>>` connects the left atom's stdout to the right atom's stdin. `<<` is the +mirror spelling. + +```seal +| gh api repos/PerishCode/runseal/actions/runs >> @json.pretty.stdin() + +@file.write("out.json") << { + | gh pr view {number} --json number,url +} +``` + +Long stream chains can lower to an array-shaped pipeline helper. + +```seal +| git branch --format "%(refname:short)" >> +| grep "^feat/" >> +| head -n 1 +``` + +Conceptual lowering: + +```seal +@stream.pipeline([ + @call.process("git", ["branch", "--format", "%(refname:short)"]), + @call.process("grep", ["^feat/"]), + @call.process("head", ["-n", "1"]), +]) +``` + +## Process Nodes + +A process node starts with `|` followed by whitespace. A lone `|` is not an +infix pipeline operator. + +```text +process_node + = "|" WHITESPACE process_program process_arg* + +process_program + = process_word + | process_interpolation + +process_arg + = process_word + | process_string + | process_interpolation + | process_spread + +process_string + = string + +process_interpolation + = "{" expression "}" + +process_spread + = "*" identifier + | "*{" expression "}" +``` + +`process_word` is a bare argv token. It ends at whitespace, `SEP`, `>>`, `<<`, +or the closing delimiter of the surrounding effect block. It does not perform +shell expansion or backslash escaping. If an argv value needs whitespace, +reserved syntax, or a token boundary character, write it as a double-quoted +string. + +```seal +| gh pr view {number} --json number,url +| gh *args +| {program} *{args} +| some-tool ";" +``` + +Lowering: + +```seal +| gh pr view {number} + +@call.process("gh", ["pr", "view", number]) +``` + +`*args` spreads an array into process argv. `{expr}` inserts one argv value. +String conversion should be explicit and strict at runtime; non-stringable +values fail fast unless spread syntax is used intentionally. + +## Separators And Continuation + +Newline and semicolon are equivalent statement separators. + +```seal +| gh --version +| gh auth status + +| gh --version; | gh auth status +``` + +A separator is suppressed when the parser is inside `()`, `[]`, `{}` expression +forms, method/control blocks, environment blocks, or when a stream operator is +waiting for its right-hand side. + +```seal +let branch = @type.string { + | git branch --format "%(refname:short)" >> + | grep "^feat/" >> + | head -n 1 +} +``` + +A process node ends at the next statement separator, stream operator, or closing +delimiter owned by the containing construct. + +```seal +| gh pr view {number}; print("done") +| gh api repos/... >> @json.pretty.stdin() +``` + +If `;` or whitespace-sensitive text should be passed as argv, quote it. + +```seal +| some-tool ";" +``` + +## Parser Modes + +The first parser should keep two clear modes: + +- **Expression mode** parses Seal values, calls, arrays, maps, lambdas, and + control expressions. +- **Process argv mode** starts after `| <whitespace>` and parses raw argv tokens + plus `{expr}` interpolation and `*` spread. + +This avoids the old ambiguous shape where a bare command had to be parsed inside +ordinary function-call parentheses. + +```seal +let pr = @type.map { + | gh pr view {number} --json number,url +} +``` + +The block gives the effect boundary. The `|` marker gives the process-node +boundary. The ordinary expression parser never has to guess where `gh ...` +ends. + +## Open Items + +This draft intentionally leaves a few details open: + +- Whether `/* ... */` block comments nest. +- Whether backtick text blocks support interpolation, how they handle indentation + trimming, and whether the final newline is preserved. +- The exact character set accepted by `process_word`. +- Whether labeled call arguments are allowed everywhere or restricted to + `@` tool/helper calls. +- Whether comparison chaining such as `a < b < c` is rejected syntactically or + by runtime shape checks. +- Whether a later syntax should add multi-statement effect blocks. First-pass + `effect_block` is exactly one stream graph. +- The exact v0 shape of the normal ok frame event used by `@call.exit(...)` and + fallthrough completion. +- Whether `@call.exit(...)` needs a concise source sugar, or whether the + canonical helper is enough for cold-start Seal. +- The final lowering shape for long `>>` chains: nested `@stream.flow(...)` or + array-shaped `@stream.pipeline(...)`. diff --git a/docs/examples/seal/io-pipeline.md b/docs/examples/seal/io-pipeline.md new file mode 100644 index 0000000..68603e4 --- /dev/null +++ b/docs/examples/seal/io-pipeline.md @@ -0,0 +1,99 @@ +# Seal IO And Pipelines + +Seal keeps process IO visible without turning the source language back into +general shell scripting. + +## Stream routing + +```seal +@call.stdio( + @call.process("cargo", ["test", "--locked", "--workspace"]), + (stdin, stdout, stderr) => { + stdout >> @file.write("target/runseal-test.log") + stderr >> @file.write("target/runseal-test.err") + }, +) +``` + +Stdio routing uses scope-local streams, not shell file descriptor syntax. + +```seal +@call.stdio( + @call.process("gh", ["pr", "checks", number, "--watch", "--interval", "10"]), + (stdin, stdout, stderr) => { + stdout >> @file.write("target/pr-checks.log") + stderr >> @file.write("target/pr-checks.err") + }, +) +``` + +## Pipelines + +Pipelines connect effect scopes: the left stage `#stdout` feeds the right stage +`#stdin`. They do not produce Seal values by themselves. + +```seal +| git branch --format "%(refname:short)" >> | grep "^feat/" >> | head -n 1 +``` + +Tool calls can participate when the tool has a stream mode. + +```seal +| gh api repos/PerishCode/runseal/actions/runs >> @json.pretty.stdin() +``` + +## Capturing stdout + +There are three stdout entrypoints. + +Use optimized `@type.*` helpers when process stdout becomes a Seal value. When +the argument is a process node or stream graph, the helper runs it, absorbs +stdout, waits for completion, quick-fails on non-ok completion, converts stdout, +and returns the value. + +```seal +let branch = @type.string { + | git branch --show-current +} + +let runs = @type.array { + | gh run list --workflow {workflow} --branch {branch} --limit 1 --json databaseId +} + +let run_id = require(runs[0].databaseId, "missing run id") +``` + +Use `:=` when process stdout should be bound as a readonly stream view. This +is copy/view sugar, not a writable clone. + +```seal +let raw := { + | gh run list --workflow {workflow} --branch {branch} --limit 1 --json databaseId +} +let text = @type.string(raw) +``` + +Use `@stream.dupe(...)` when the workflow needs a new writable stream with the +process stdout fully materialized into it. + +```seal +let mutable = @stream.dupe { + | gh run list --workflow {workflow} --branch {branch} --limit 1 --json databaseId +} +``` + +Stderr remains independent. When needed, declare stderr routing on the process +call with `@call.stdio(...)`. In the full model, convert the explicit `stdout` +parameter yourself. + +```seal +let pr = null + +@call.stdio( + @call.process("gh", ["pr", "view", number, "--json", "number,url,isDraft"]), + (stdin, stdout, stderr) => { + pr = @type.map(stdout) + stderr >> @file.write("target/gh-pr-view.err") + }, +) +``` diff --git a/docs/examples/seal/perish-scenarios.md b/docs/examples/seal/perish-scenarios.md new file mode 100644 index 0000000..c784738 --- /dev/null +++ b/docs/examples/seal/perish-scenarios.md @@ -0,0 +1,352 @@ +# Seal Perish Workflow Sketches + +This file stress-tests the current Seal terminology against real wrapper shapes +from this repository. It is intentionally written as source-feel exploration, +not as an implementation contract. + +Read this after [Terminology and lowering model](./semantics.md). + +## Release Workflow + +```seal +method release(channel, ref = "main", version = "", watch = false, dry_run = false) { + let workflow = match channel { + "stable" => "release-stable.yml" + "beta" => "release-beta.yml" + _ => fail("release: invalid channel: {channel}", code: 2) + } + + let command = [ + "workflow", "run", workflow, + "--ref", ref, + "-f", "ref={ref}", + "-f", "version_override={version}", + ] + + if dry_run { + print(@text.join(["gh", *command], separator: " ")) + return + } + + | gh --version + | gh auth status + + let ref_sha = @type.string { + | git rev-parse {ref} + } + + let trigger_text = @type.string { + | gh *command + } + if trigger_text != "" { + print(trigger_text) + } + + print("triggered {workflow} for ref {ref}") + + if watch { + let run_id = @regex.capture(trigger_text, "/actions/runs/([0-9]+)", 1) ?? "" + + if run_id == "" { + let attempt = 0 + + while attempt < 6 && run_id == "" { + let runs = @type.array( + @call.process("gh", [ + "run", "list", + "--workflow", workflow, + "--branch", ref, + "--commit", ref_sha, + "--event", "workflow_dispatch", + "--limit", "1", + "--json", "databaseId", + ]) + ) + + if runs != [] { + run_id = require(runs[0].databaseId, "release: run is missing databaseId") + } else { + @time.sleep(2) + attempt = attempt + 1 + } + } + } + + if run_id == "" { + fail("release: could not find a recent run for {workflow} on {ref}") + } + + | gh run watch {run_id} --interval 10 + } +} +``` + +Immediate pressure: + +- `| gh *command` keeps dynamic argv ergonomic while still marking the external + process node. +- `@type.string { | gh *command }` gives command capture an explicit block + boundary instead of relying on command expressions inside ordinary function + arguments. +- Multi-line process calls can fall back to `@call.process(program, args)` when + line-oriented argv syntax gets too dense. + +## PR Workflow + +```seal +method current_pr(branch, base, title = "", body_file = "", draft = false) { + let raw = @type.array { + | gh pr list --head {branch} --json number,title,state,url,isDraft + } + + if raw != [] { + return raw[0] + } + + let args = ["pr", "create", "--base", base, "--head", branch] + + if draft { + args = @array.push(args, "--draft") + } + + if title != "" { + args = @array.push(args, "--title", title) + } + + if body_file != "" { + args = @array.push(args, "--body-file", body_file) + } else { + args = @array.push(args, "--fill") + } + + @call.process("gh", args) + + let after = @type.array { + | gh pr list --head {branch} --json number,title,state,url,isDraft + } + if after == [] { + fail("pr: created PR for {branch}, but could not find it afterward") + } + + return after[0] +} + +method pr(base = "main", draft = false, no_watch = false, no_merge = false, no_push = false, dry_run = false) { + | git --version + | gh --version + | gh auth status + + let branch = @type.string { + | git branch --show-current + } + + if branch == "" { + fail("pr: not on a branch") + } + + if branch in [base, "main", "master"] { + fail("pr: refusing to open a PR from base branch: {branch}") + } + + if draft && !no_merge { + fail("pr: --draft requires --no-merge") + } + + if dry_run { + print_plan(branch, base, draft, no_watch, no_merge, no_push) + return + } + + if !no_push { + | git push -u origin {branch} + } + + let pr = current_pr(branch, base, { draft: draft }) + let number = require(pr.number, "pr: missing PR number") + let url = require(pr.url, "pr: missing PR url") + let is_draft = require(pr.isDraft, "pr: missing PR draft state") + + print("PR #{number}: {url}") + + if is_draft && !draft { + | gh pr ready {number} + } + + if !no_watch { + let probe = @call.completion( + @github.pr.checks.probe(number), + (stdin, stdout, stderr, frame) => {}, + ) + + probe + .ok((completion) => { + | gh pr checks {number} --watch --interval 10 + }) + .failed((exit, completion) => { + print("no checks reported on PR #{number}; skipping watch") + }) + } + + if !no_merge { + | gh pr merge {number} --squash --delete-branch + } +} +``` + +Immediate pressure: + +- Named args are intentionally omitted. Structured options should use a map + argument, as in `current_pr(branch, base, { draft: draft })`. +- Building argv arrays is verbose. We likely need ergonomic array helpers or a + small argv builder, but that can remain an `@` helper. +- `@call.completion(@github.pr.checks.probe(number), ...)` feels correct but + verbose when no IO routing is needed. A helper for completion-only probing may + be worth considering later. + +## Guard Version Policy + +```seal +method guard_version_policy(mode = "full") { + let public_url = $RUNSEAL_RELEASES_PUBLIC_URL ?? "https://releases.runseal.perish.uk" + let metadata_url = $RUNSEAL_STABLE_METADATA_URL ?? "{public_url}/stable/latest/metadata.json" + + let tmp_dir = $RUNNER_TEMP ?? ($RUNSEAL_REPO_TMP_DIR ?? ".local/tmp") + let metadata_file = "{tmp_dir}/runseal-guard-stable-metadata.json" + + @fs.mkdir(tmp_dir, mode: 700) + + let cargo_metadata = @type.map { + | cargo metadata --no-deps --format-version 1 + } + let current_version = require(cargo_metadata.packages[0].version, "guard: missing current version") + let current_hash = @type.string(@hash.tree("app/tests")) + + let completion = @call.completion( + @call.process("curl", [ + "-sS", + "-o", metadata_file, + "-w", "%{http_code}", + "{metadata_url}?version={current_version}", + ]), + (stdin, stdout, stderr, frame) => {}, + ) + + let status = @type.string(completion.stdout) +} +``` + +This sketch intentionally fails: `completion` does not contain stdout by design. +That exposes a useful rule: + +- If the workflow needs both stdout and completion, it must capture stdout in + the handler: + +```seal +let status = "" + +let completion = @call.completion( + @call.process("curl", [ + "-sS", + "-o", metadata_file, + "-w", "%{http_code}", + "{metadata_url}?version={current_version}", + ]), + (stdin, stdout, stderr, frame) => { + status = @type.string(stdout) + }, +) +``` + +This is semantically clean, but visually heavy. A common "completion plus stdout +value" helper may be worth considering later. + +## Cloudflare Redirect Rules + +```seal +method load_manage_redirect_rules() { + let zone_name = @cloudflare.config.get("zone_name") + let request_host = @cloudflare.config.get("manage_host") + let redirect_host = @cloudflare.config.get("manage_origin_host") + let prefix = @cloudflare.config.get("manage_redirect_prefix") + + let target_sh = if prefix == "" { + "https://{redirect_host}/manage.sh" + } else { + "https://{redirect_host}/{prefix}/manage.sh" + } + + let target_ps1 = if prefix == "" { + "https://{redirect_host}/manage.ps1" + } else { + "https://{redirect_host}/{prefix}/manage.ps1" + } + + let rule_sh = @cloudflare.redirect_rule.exact({ + ref: "runseal_manage_sh_redirect", + description: "Redirect runseal manage.sh to releases bucket asset", + host: request_host, + path: "/manage.sh", + target_url: target_sh, + }) + + let rule_ps1 = @cloudflare.redirect_rule.exact({ + ref: "runseal_manage_ps1_redirect", + description: "Redirect runseal manage.ps1 to releases bucket asset", + host: request_host, + path: "/manage.ps1", + target_url: target_ps1, + }) + + return { + zone_name: zone_name, + request_host: request_host, + redirect_host: redirect_host, + rule_sh: rule_sh, + rule_ps1: rule_ps1, + } +} +``` + +Immediate pressure: + +- Map-heavy `@` calls feel much better than argv-heavy tool calls for structured + operations. This supports keeping complex cross-platform operations in atomic + tools instead of Seal syntax. +- Inline `if` expressions need to be explicitly allowed or rejected. The control + flow examples currently show `match` for values, not `if` expressions. + +## Sharp Edges Found + +1. **Command boundaries are now explicit.** + `@type.map { | gh pr view ... }` preserves command ergonomics without + embedding a bare command in ordinary function-call parentheses. The parser + sees `| <whitespace>` as an external process node and the block as the capture + boundary. + +2. **Array spread belongs in process argv position.** + `@call.forward(foo, args)` consumes an argument bundle directly. Real wrapper + code still benefits from `| gh *args` when building dynamic process argv. + +3. **Named method args are removed.** + Use ordinary map values for structured options. This removes a low-value + second argument system. + +4. **Completion plus stdout is verbose, but helper-shaped.** + `@call.completion(...)` correctly keeps IO out of completion, but common + cases can add an `@` helper that returns `{ completion, stdout }` without + weakening the model. This is not a core syntax flaw. + +5. **Handlers need a scheduling contract.** + They are routing/effect setup scopes, not ordinary synchronous callbacks. The + runtime must prevent deadlocks when callbacks read stdout/stderr while the + target call is running. + +6. **`@type.*(call)` and `:= call` must define when completion is checked.** + The likely rule is: value conversion waits for EOF and completion; + readonly stream binding defers EOF/completion until consumption or scope + finalization. + +7. **Map event protocols need constructors.** + Raw map writes to `#frame` are good for the full model, but helpers that + construct event maps would prevent typo-heavy source while still avoiding + `@frame.*` control APIs. This also belongs in the `@` helper layer. diff --git a/docs/examples/seal/semantics.md b/docs/examples/seal/semantics.md new file mode 100644 index 0000000..cdd58b7 --- /dev/null +++ b/docs/examples/seal/semantics.md @@ -0,0 +1,346 @@ +# Seal Terminology And Lowering Model + +This file defines the words used by the target syntax examples. It is a +normalization layer for discussion, not a parser or runtime specification. + +The goal is to keep syntax design grounded in one model: + +```text +callable -> call expression -> operation frame -> completion value +``` + +## Callable + +A callable is any value that can be called. + +Examples: + +- Seal method +- external binary adapter +- optimized `@` built-in +- lambda / function value + +Use `callable` for this layer. Avoid using `command`, `process`, or `function` +when the point is simply "something that can be called". + +```seal +method current_branch() { + | git branch --show-current +} + +let reader = current_branch +let branch = @type.string(reader()) +``` + +## Call Expression + +A call expression is a call intent: callable plus argument bundle. + +Daily syntax: + +```seal +foo(a, b, c) +``` + +Explicit metaprogramming/debug form: + +```seal +@call.forward(foo, [a, b, c]) +``` + +These are semantically equivalent. `@call.forward(...)` does not add completion +handling, stream routing, retry, recovery, or any special failure policy. + +The second argument is an ordinary argument bundle array. Named arguments are +not part of the call model; use map values when structured options are needed. + +```seal +@call.forward(deploy, ["prod", { dry_run: true }]) +``` + +External process calls use a leading `|` source marker. The marker lowers to +`@call.process(...)`. + +```seal +| gh pr view {number} --json number,url + +@call.process("gh", ["pr", "view", number, "--json", "number,url"]) +``` + +Array spread remains available in process argv position. + +```seal +let args = ["workflow", "run", workflow, "--ref", ref] +| gh *args +``` + +`@call.forward(foo, args)` consumes the whole argument bundle directly, so it +does not need spread syntax. `@call.process(program, args)` also consumes the +whole process argv bundle directly. + +## Interpolation + +Seal keeps interpolation narrow. Process argv interpolation uses `{expr}` only. + +```seal +| gh workflow run {workflow} --ref {ref} -f "ref={ref}" +``` + +This keeps call expression parsing bounded: + +- outside `{...}`, command words are argv tokens +- inside `{...}`, Seal parses an expression +- `$NAME` remains environment access, not local variable interpolation +- no additional shell-style expansion is implied + +## Operation Frame + +An operation frame is the runtime instance created when a call expression is +executed. + +The frame owns streams and lifecycle state: + +```text +operation frame { + stdin + stdout + stderr + frame + completion + cleanup +} +``` + +Inside ordinary code, `#<word>` refers to the current operation frame's stream or +channel. + +```seal +#stdin +#stdout +#stderr +#frame +``` + +`#frame` is the current frame's control/event stream. It is not an object handle, +capability object, or `@frame.*` namespace. + +## Stdio Scope + +`@call.stdio(...)` executes a call expression and exposes the target call's +standard IO streams to a handler. + +```seal +@call.stdio( + @call.process("cargo", ["test", "--locked", "--workspace"]), + (stdin, stdout, stderr) => { + stdout >> @file.write("target/test.log") + stderr >> @file.write("target/test.err") + }, +) +``` + +The handler receives the target call's streams as ordinary parameters. The +handler's own `#stdin`, `#stdout`, `#stderr`, and `#frame` still refer to the +handler frame. + +`@call.stdio(...)` uses the default completion policy: non-ok completion +propagates according to normal wrapper rules. + +## Completion Scope + +`@call.completion(...)` executes a call expression and returns its completion as +ordinary data. + +```seal +let completion = @call.completion( + @call.process("gh", ["pr", "view", number, "--json", "number,url,isDraft"]), + (stdin, stdout, stderr, frame) => { + stderr >> #stderr + }, +) +``` + +The handler receives the target call's `stdin`, `stdout`, `stderr`, and `frame` +streams as ordinary parameters. Its own `#frame` still belongs to the handler +frame. + +Use this when failed/faulted/cancelled completion is part of the workflow's +decision logic. + +```seal +match completion { + { status: "ok" } => { + use_existing_pr() + } + { status: "failed", exit: exit } => { + create_pr() + } + { status: "faulted", faults: faults } => { + @exception.raise({ + kind: "child-fault", + cause: faults, + }) + } +} +``` + +## Completion Value + +A completion value records how an operation frame ended. It is ordinary Seal +data, not a stream. + +First-pass shape: + +```seal +{ + status: "ok" | "failed" | "faulted" | "cancelled", + exit: null | { + code: int, + signal: string | null, + }, + faults: [], + cancelled: null | { + source: string, + signal: string | null, + }, +} +``` + +Completion values intentionally do not contain stdout or stderr. Route IO +through `@call.stdio(...)` or `@call.completion(...)` callback parameters. + +## Handler + +A handler is the callback passed to `@call.stdio(...)` or +`@call.completion(...)`. + +It should be read as a routing/effect setup scope, not as a normal business +callback. Runtime scheduling must allow the target call and stream handling to +make progress without deadlocking. + +```seal +(stdin, stdout, stderr) => { + stdout >> @file.write("target/out.log") + stderr >> @file.write("target/err.log") +} +``` + +The stream parameters are capability-like values. They should not be compared, +serialized, or implicitly converted to strings. + +## Stdout Entrypoints + +Seal has three stdout entrypoints. + +Value conversion: + +```seal +let value = @type.map(call) +``` + +Daily source can pass a process node or stream graph as a block argument. This +keeps effect boundaries explicit without forcing every example into +`@call.process(...)`. + +```seal +let value = @type.map { + | gh pr view {number} --json number,url +} +``` + +This lowers to the same value-conversion path as `@type.map(call)`. + +When the argument is a call expression, process node, or stream graph, optimized +`@type.*` helpers run it, absorb stdout, wait for completion, quick-fail on +non-ok completion, convert stdout, and return the value. + +Readonly stream view: + +```seal +let raw := call +``` + +This binds the call's stdout as a readonly stream view. It is not a writable +clone. + +Writable stream materialization: + +```seal +let mutable = @stream.dupe(call) +``` + +This fully reads stdout and writes the bytes into a new writable stream. + +## Stream Views + +Stream copies are readonly views over underlying stream data. Think slice-like +read handles, not writable clones. + +If a workflow needs rewritten content, create a new stream and write the +transformed content into that stream explicitly. + +## Call Lowering Summary + +```text +foo(a, b) + -> call expression + +@call.forward(foo, [a, b]) + -> same call expression, explicit form + +| gh pr view {number} + -> external process call expression + +@call.process("gh", ["pr", "view", number]) + -> same external process call expression, explicit form + +a >> b + -> stream flow from a.#stdout to b.#stdin + +b << a + -> stream flow from a.#stdout to b.#stdin, mirror spelling + +@call.stdio(call, handler) + -> call expression + stdio routing + default completion propagation + +@call.completion(call, handler) + -> call expression + stdio/frame routing + completion value + +@type.map(call) + -> call expression + stdout absorption + quick-fail + conversion + +let x := call + -> call expression + stdout readonly view + +@stream.dupe(call) + -> call expression + stdout materialization into a new writable stream +``` + +## Chaining Sugar + +Completion chaining is built-in sugar over the same completion value. + +```seal +@call.completion(@call.forward(foo, args), (stdin, stdout, stderr, frame) => { + stderr >> #stderr +}) + .ok((completion) => { + use_result() + }) + .failed((exit, completion) => { + handle_failed_exit(exit) + }) + .faulted((faults, completion) => { + handle_faults(faults) + }) + .cancelled((cancelled, completion) => { + handle_cancel(cancelled) + }) + .always((completion) => { + cleanup() + }) +``` + +This lowers to `match completion { ... }`. `.failed(...)` handles +completed-but-failed operations. `.faulted(...)` handles Seal/runtime faults. +They should not collapse into one catch-all branch. diff --git a/docs/examples/seal/stream-model.md b/docs/examples/seal/stream-model.md new file mode 100644 index 0000000..f9934d1 --- /dev/null +++ b/docs/examples/seal/stream-model.md @@ -0,0 +1,306 @@ +# Seal Full Stream Model Sketch + +This file intentionally sketches a maximal stream model before surface syntax is +compressed. It is not a final author-facing style. + +The core model is: + +- every effectful scope owns predefined `#stdin`, `#stdout`, `#stderr`, and + `#frame` streams +- method bodies, external process nodes, tool calls, stream graphs, and stream-scope blocks + are all effectful scopes +- methods, external process invocations, and optimized built-ins are all callable + function values that instantiate operation frames +- stream is a replayable FIFO byte buffer in the runtime/IR layer +- stream copies are readonly views over underlying stream data, not writable + clones +- `#<word>` consistently denotes a current-frame stream or channel +- stream modeling is expected to be common in IR and sparse in source +- source syntax should expose intent; IR should preserve stream mechanics + +## Scope-local stdio + +Every effectful scope has its own stream context: + +```text +scope { + #stdin: stream + #stdout: stream + #stderr: stream + #frame: stream +} +``` + +A nested callable inherits the current scope streams unless it declares a +different policy. + +```seal +method test() { + | cargo test --locked --workspace +} +``` + +Expanded model: + +```text +test.#stdin <- caller.#stdin +test.#stdout -> caller.#stdout +test.#stderr -> caller.#stderr +test.#frame -> test executor + +cargo.#stdin <- test.#stdin +cargo.#stdout -> test.#stdout +cargo.#stderr -> test.#stderr +cargo.#frame -> cargo executor +``` + +`#frame` is the current operation frame's control/event stream. The executor +owns the policy for events written to that stream, such as an exit event or a +structured fault event. This keeps frame lifecycle mechanics in the same `#` +stream family as stdio instead of introducing an `@frame.*` tool namespace. + +## Full stdio scope + +The maximal source sketch uses `@call.stdio(call, handler)` to expose a call's +standard IO streams explicitly. `#<word>` remains only the current frame's +stream/channel form; the target call's streams are passed to the handler as +ordinary parameters. + +```seal +let outer_out = #stdout +let outer_err = #stderr + +@call.stdio( + @call.process("gh", ["api", "repos/PerishCode/runseal/issues"]), + (stdin, stdout, stderr) => { + @text.stream(payload) >> stdin + + stdout >> @file.write("target/issue.json") + stdout >> outer_out + stderr >> @file.write("target/issue.err") + stderr >> outer_err + }, +) +``` + +Interpretation: + +- `@call.stdio(...)` creates the callable's operation frame with default + completion propagation +- `| gh ...` lowers to `@call.process("gh", [...])`, whose second argument is + an ordinary array of actual arguments +- the handler receives the target call's `stdin`, `stdout`, and `stderr` streams + as ordinary parameters +- `@text.stream(payload) >> stdin` provides the callable's stdin stream +- omitting writes to `stdin` provides an empty stdin stream +- inside the handler, `#stdin`, `#stdout`, `#stderr`, and `#frame` still refer to + the handler lambda's own frame, not the target call frame +- stream values are replayable, so multiple consumers can read independent + readonly views of `stdout` or `stderr` + +Forwarding to the caller requires capturing the outer streams before entering +the stdio routing scope. + +## Outer stream capture + +This example isolates the forwarding pattern: + +```seal +let outer_out = #stdout +let outer_err = #stderr + +@call.stdio( + @call.process("gh", ["api", "repos/PerishCode/runseal/issues"]), + (stdin, stdout, stderr) => { + @text.stream(payload) >> stdin + + stdout >> @file.write("target/issue.json") + stdout >> outer_out + stderr >> @file.write("target/issue.err") + stderr >> outer_err + }, +) +``` + +This is intentionally explicit. Final syntax can shrink the common inherit case +without changing the model. + +## Empty stdin for ordinary commands + +Traditional stdout/stderr splitting becomes a stream scope with empty stdin. + +```seal +@call.stdio( + @call.process("cargo", ["test", "--locked", "--workspace"]), + (stdin, stdout, stderr) => { + stdout >> @file.write("target/test.log") + stderr >> @file.write("target/test.err") + }, +) +``` + +Omitting writes to `stdin` means the callable receives no stdin input. + +## Capturing stdout with quick-fail sugar + +The full stream model can convert stdout through an explicit type shell: + +```seal +@call.stdio( + @call.process("gh", [ + "run", "list", + "--workflow", workflow, + "--branch", ref, + "--limit", "1", + "--json", "databaseId", + ]), + (stdin, stdout, stderr) => { + let runs = @type.array(stdout) + stderr >> @file.write("target/gh-run-list.err") + }, +) +``` + +`@type.array(stdout)` converts the stdout stream into a Seal array. It decodes +JSON bytes and requires the top-level decoded value to be an array. + +The daily value syntax is an optimized `@type.*` call. When its argument is a +process node or stream graph, the built-in runs it, absorbs stdout, waits for +completion, quick-fails on non-ok completion, converts stdout, and returns the +Seal value. + +```seal +let runs = @type.array { + | gh run list --workflow {workflow} --branch {ref} --limit 1 --json databaseId +} +``` + +When stderr routing is needed, use the explicit `@call.stdio(...)` form above +until a smaller routing sugar is chosen. + +The value sugar means: + +- create a callable scope for the right side +- provide empty stdin unless a fuller call form says otherwise +- capture the callable stdout stream +- wait for stdout EOF +- wait for the callable frame completion after the stream scope leaves +- fail if the callable completion is not successful +- fail if conversion fails +- bind the converted value + +Use `:=` when process stdout should be bound as a readonly stream view +instead of converted immediately. + +```seal +let raw := { + | gh run list --workflow {workflow} --branch {ref} --limit 1 --json databaseId +} +``` + +Use `@stream.dupe(...)` when the workflow needs a new writable stream with the +process stdout fully materialized into it. + +```seal +let mutable = @stream.dupe { + | gh run list --workflow {workflow} --branch {ref} --limit 1 --json databaseId +} +``` + +## Method stdio contract + +Seal methods can also be captured because a method call is an effectful scope. + +```seal +method current_branch() { + | git branch --show-current +} + +let branch = @type.string(current_branch()) +``` + +Expanded model: + +```text +current_branch.#stdout is fed by git.#stdout +@type.string consumes a replayable readonly view of current_branch.#stdout +branch is bound after EOF and successful conversion +``` + +## Forwarding streams through a method + +```seal +method pretty_json() { + let outer_in = #stdin + let outer_out = #stdout + let outer_err = #stderr + + @call.stdio( + @call.forward(@json.pretty, []), + (stdin, stdout, stderr) => { + outer_in >> stdin + stdout >> outer_out + stderr >> outer_err + }, + ) +} +``` + +This sketch is intentionally mechanical. It means the nested tool is connected +to the method's stream context. A final surface form should be smaller. + +## Pipeline plus capture + +```seal +let branch = @type.string { + | git branch --format "%(refname:short)" >> + | grep "^feat/" >> + | head -n 1 +} +``` + +The body of the type block is the whole pipeline. The pipeline is a stream +graph, not a value expression chain. Each `|` command is an external process +node. `>>` connects the left node's stdout to the right node's stdin. Each stage +has local `#stdin`, `#stdout`, and `#stderr`; each stage also has its own +`#frame` control/event stream. + +## Replayable stream consumption + +```seal +@call.stdio( + @call.process("gh", [ + "run", "list", + "--workflow", workflow, + "--branch", ref, + "--limit", "1", + "--json", "databaseId", + ]), + (stdin, stdout, stderr) => { + stdout >> @file.write("target/runs.json") + + let runs = @type.array(stdout) + let raw = @type.string(stdout) + }, +) +``` + +Each consumer reads its own readonly view of the same FIFO byte stream. Runtime +may buffer in memory, spool to disk, and release segments when all consumers are +done. These views are slice-like read handles, not writable clones. A workflow +that needs rewritten content should create a new stream and write the transformed +content into that stream explicitly. + +## Infinite streams + +```seal +let logs := { + | kubectl logs deploy/app --follow +} +``` + +This binds a readonly stream view and does not wait for EOF by itself. +Converting it with `@type.string(logs)` would wait for EOF. Long-running streams +are an operator concern: interrupting the wrapper should propagate cancellation +to child calls and clean up stream resources. The cold-start syntax does not try +to make infinite streams safe. diff --git a/docs/examples/seal/values.md b/docs/examples/seal/values.md new file mode 100644 index 0000000..03514df --- /dev/null +++ b/docs/examples/seal/values.md @@ -0,0 +1,189 @@ +# Seal Values And Collections + +Cold-start Seal does not use author-facing type annotations. Values still have +runtime types, and invalid operations fail fast. + +## Runtime values + +Seal starts with these value kinds: + +- string +- int +- boolean +- byte +- bytes +- array +- map +- null +- stream +- function + +`null` is the only missing or undefined value. It is not an empty string, false, +zero, an empty array, or an empty map. + +`stream` is a process IO resource, not a business value. It is useful only while +moving data or when explicitly converted through a built-in such as +`@type.string`, `@type.bytes`, `@type.array`, or `@type.map`. + +`byte` is a scalar byte value. `bytes` is a finite byte sequence value. `stream` +is the FIFO delivery form. + +`function` is a callable value. Methods, binary-backed invocations, and +optimized `@` built-ins all lower toward function values that instantiate +operation frames. Cold-start Seal does not need author-facing function type +annotations, but the runtime model should treat functions as values so frame +guards, cleanup callbacks, and callable forwarding can share one mechanism. + +```seal +let version = null +let tools = ["git", "gh", "cargo"] +let archive = @type.bytes { + | tar -czf - "./dist" +} +let release = { + channel: "beta", + ref: "main", + watch: true, + labels: ["release", "beta"], +} +``` + +Function values can be named and called. This is a model sketch, not a claim +that the first parser must expose every function expression form. Direct calls +are the surface form of raw execution. + +```seal +method current_branch() { + | git branch --show-current +} + +let reader = current_branch +let branch = @type.string(reader()) +``` + +The equivalent metaprogramming/debug form can be written with an explicit +argument array when the model needs to show the underlying call shape. + +```seal +@call.forward(reader, []) +``` + +The second argument is an ordinary argument bundle array. It is not a special +current frame variable. Named arguments can be represented by map values rather +than a separate forwarding form. + +Collections can nest without generic type syntax. + +```seal +let matrix = [ + { + repo: "runseal", + checks: ["fmt", "test", "flavor"], + }, + { + repo: "flavor", + checks: ["test"], + }, +] + +for item in matrix { + for check in item.checks { + run_check(item.repo, check) + } +} +``` + +## Strict equality + +`==` and `!=` use strict equality. Seal does not do implicit type conversion. + +```seal +1 == 1 // true +"1" == "1" // true +1 == "1" // false +true == "true" // false +null == null // true +``` + +Streams cannot be compared or implicitly converted to other values. + +`??` only handles `null`. + +```seal +let channel = $RUNSEAL_CHANNEL ?? "beta" +let version = release.version ?? null +``` + +These values are not null and are not replaced by `??`. + +```seal +let empty_text = "" +let disabled = false +let zero = 0 +let empty_array = [] +let empty_map = {} +``` + +## Explicit conversion + +Environment variables are strings. Convert explicitly when the workflow needs a +different type. + +```seal +let port = @type.int($PORT ?? "8080") +let dry_run = @type.boolean($DRY_RUN ?? "false") +let attempt_limit = @type.int($ATTEMPT_LIMIT ?? "6") +``` + +`if` requires a boolean. It does not use truthy or falsy conversion. + +```seal +let dry_run = @type.boolean($DRY_RUN ?? "false") + +if dry_run { + print_plan() +} +``` + +Boolean operators only accept boolean values, but expressions can be composed +directly when conversion is explicit. + +```seal +if @type.boolean($DRY_RUN ?? "false") || dry_run { + print_plan() +} +``` + +## Null guards + +Environment reads return `string | null`. Map key access returns the value or +`null` when the key is missing. + +```seal +let token = $GITHUB_TOKEN +let version = release.version +``` + +Use `require(value, message)` when `null` should stop the workflow. It returns +the original value when the value is not null. + +```seal +let token = require($GITHUB_TOKEN, "missing GITHUB_TOKEN") +let channel = require(release.channel, "missing release channel") +``` + +## Membership + +`in` supports array membership and map key checks. + +```seal +if branch in ["main", "master"] { + fail("refusing protected branch: {branch}") +} + +if "labels" in release { + for label in release.labels { + @github.issue.label.add(label) + } +} +``` diff --git a/docs/spec/README.md b/docs/spec/README.md new file mode 100644 index 0000000..17f56ed --- /dev/null +++ b/docs/spec/README.md @@ -0,0 +1,6 @@ +# Specs + +These files describe repository-owned language and runtime contracts. + +- [Seal language specification](./seal-language.md) + diff --git a/docs/spec/seal-language.md b/docs/spec/seal-language.md new file mode 100644 index 0000000..5b131ee --- /dev/null +++ b/docs/spec/seal-language.md @@ -0,0 +1,550 @@ +# Seal Language Specification + +Status: v0 draft. + +This document is the normative draft for the isolated Seal source language. The +files under `docs/examples/seal/` remain design examples and explanatory +sketches; this file is the contract a first parser and interpreter should target +for v0 source syntax. + +Seal is an operations language. It exposes the process model as the callable +model: a callable creates an operation frame with `#stdin`, `#stdout`, `#stderr`, +`#frame`, cleanup, and completion. Syntax may be concise, but it must not erase +the stdout/completion model. + +## Design Rules + +Seal v0 follows these rules: + +- Method calls, tool/builtin calls, and external process nodes all lower to call + expressions that create operation frames. +- External process nodes use `| <program> ...`; bare command statements are not + Seal syntax. +- Stream flow uses `>>` and `<<`; `|` is not an infix pipeline operator. +- Strings are the only lexical escape hatch. +- `@` exposes canonical operations behind source sugar, similar in spirit to a + reflection surface. +- Return-like behavior is modeled as stdout output plus frame completion, not as + a hidden return-value slot. + +## Source Forms + +Seal has four primary callable/source forms: + +```seal +deploy("prod") // Seal callable +@github.pr.view(...) // tool or builtin callable +| gh pr view ... // external process node +text.trim() // receiver-style call +``` + +Canonical lowering examples: + +```seal +foo(a, b) +@call.forward(foo, [a, b]) + +| gh pr view {number} +@call.process("gh", ["pr", "view", number]) + +text.trim() +@call.self(text, @string.trim, []) +``` + +Stream flow: + +```seal +| gh api repos/PerishCode/runseal/actions/runs >> @json.pretty.stdin() +@file.write("out.json") << { | gh pr view {number} --json number,url } +``` + +## Lexical Rules + +Identifiers: + +```text +identifier = ASCII_ALPHA (ASCII_ALNUM | "_")* +``` + +Names: + +```text +at_name = "@" identifier ("." identifier)* +env_name = "$" identifier +frame_channel = "#" identifier +``` + +Reserved words: + +```text +method let if else match for in while break continue with env +true false null +``` + +Strings: + +- Double-quoted strings are ordinary inline strings. +- Backtick text blocks are multiline strings. +- Strings are the only lexical escape hatch. +- Seal v0 does not have backtick identifiers, raw identifiers, shell-style argv + backslash escaping, or alternate quote forms. + +```seal +"ref={ref}" +` +multi-line text +` +``` + +Double-quoted strings support `{expr}` interpolation and ordinary string escape +sequences. Backtick text blocks are raw: they do not interpolate, do not process +escape sequences, do not trim indentation, and preserve every character between +the opening and closing backtick, including final newlines when present. A raw +backtick cannot appear inside a backtick text block in v0; use a double-quoted +string or concatenate values when a literal backtick is needed. + +Comments: + +```seal +// line comment + +/* + block comment +*/ +``` + +Block comments do not nest in v0. + +Tokenization uses longest-match behavior. In particular: + +```text +">>" before ">" +"<<" before "<" +"||" before "|" +"=>" before "=" +":=" before ":" +"??" before "?" +``` + +`| WHITESPACE` is a process-node marker only where the parser expects an effect +atom or effect statement. + +## Separators + +Newline and semicolon are equivalent statement separators. + +```seal +| gh --version +| gh auth status + +| gh --version; | gh auth status +``` + +Separators are suppressed inside `()`, `[]`, `{}` expression forms, statement +blocks, environment blocks, and while a stream operator is waiting for its +right-hand side. + +## Program Structure + +```text +program = item* EOF + +item + = method_decl + | statement +``` + +Methods: + +```seal +method release(channel, ref = "main") { + | gh workflow run {channel} --ref {ref} +} +``` + +Parameters may have default expressions: + +```text +method_decl = "method" identifier "(" parameter_list? ")" block +parameter = identifier ("=" expression)? +``` + +## Statements + +Seal v0 statements: + +```text +statement + = let_statement + | assign_statement + | if_statement + | match_statement + | for_statement + | while_statement + | with_env_statement + | break_statement + | continue_statement + | effect_statement + | expression_statement +``` + +Variable binding: + +```seal +let value = expression + +let raw := { + | kubectl logs deploy/app --follow +} +``` + +`let x = ...` binds a Seal value. `let x := ...` binds stdout as a readonly +stream view. + +Environment scope: + +```seal +with env { + RUST_LOG = "debug" + RUNSEAL_CHANNEL = channel +} { + | cargo test --locked --workspace +} +``` + +The first `with env` block is a dedicated environment binding block. It accepts +only `NAME = expression` entries. + +Control flow: + +```seal +if branch in ["main", "master"] { + fail("protected branch: {branch}") +} else if branch == "" { + fail("not on a branch") +} + +for tool in tools { + check_tool(tool) +} + +while attempt < 6 { + attempt = attempt + 1 +} +``` + +`match` is both an expression and statement form: + +```seal +let workflow = match channel { + "stable" => "release-stable.yml" + "beta" => "release-beta.yml" + _ => fail("invalid channel: {channel}") +} +``` + +Pattern alternatives use `|` only inside match patterns. + +## Expressions + +Expression mode is separate from process argv mode. + +Operator precedence, high to low: + +```text +postfix: call, block argument, field, index +unary: ! - +multiply: * / % +add: + - +compare: < <= > >= in +equality: == != +boolean and: && +boolean or: || +null coalesce: ?? +``` + +Primary expressions include literals, identifiers, `@` names, `$` env reads, +`#` frame channels, arrays, maps, and grouped expressions. + +Arrays and maps: + +```seal +let tools = ["git", "gh", "cargo"] +let release = { + channel: "beta", + ref: "main", +} +``` + +Labeled call arguments are source sugar for structured helper/tool calls: + +```seal +@fs.mkdir(tmp_dir, mode: 700) +``` + +They must not become a separate runtime named-argument forwarding system. +The parser accepts labeled arguments in call syntax. Semantic lowering then +allows them only when the callee is statically known to accept labels, such as a +method with named parameters or an `@` helper/tool. Dynamic callable values and +explicit `@call.forward(...)` argument bundles are positional-only. + +Comparison operators are non-associative. Chaining is a syntax error: + +```seal +a < b < c // invalid +a < b && b < c // valid +``` + +## Process Nodes + +A process node starts with `|` followed by whitespace: + +```seal +| gh pr view {number} --json number,url +| gh *args +| {program} *{args} +``` + +Lowering: + +```seal +| gh pr view {number} +@call.process("gh", ["pr", "view", number]) +``` + +Process argv mode supports: + +- bare argv words +- double-quoted strings and backtick text blocks +- `{expr}` interpolation for one argv value +- `*args` and `*{expr}` array spread + +Bare argv words do not perform shell expansion or backslash escaping. A bare +word ends at whitespace, statement separator, `>>`, `<<`, or the closing +delimiter of the containing effect block. It also ends before `{`, `}`, `(`, +`)`, `[`, `]`, `"`, or `` ` ``. A `//` comment starts only at a trivia boundary, +such as the start of a line or after whitespace, so `https://example.com` remains +one argv word. If an argv value needs syntax characters, delimiters, comments, +or whitespace, use a string literal. + +```seal +| some-tool ";" +| some-tool ">>" +``` + +## Stream Flow + +`>>` connects the left effect atom's stdout to the right effect atom's stdin. +`<<` is the mirror spelling. + +```seal +| git branch --format "%(refname:short)" >> +| grep "^feat/" >> +| head -n 1 +``` + +`a >> b` lowers to `@stream.flow(a, b)`. Longer left-to-right chains lower to +`@stream.pipeline([...])` in source order. `a << b` lowers as `@stream.flow(b, +a)`. Source semantics are the same in all forms: each stage is an operation +frame, and edges connect stdout to stdin. + +Effect blocks contain exactly one stream graph in v0: + +```seal +let branch = @type.string { + | git branch --show-current +} +``` + +## Callable Output And Completion + +A callable body is an operation frame. It has no hidden return-value slot. +Output and completion are modeled through current-frame streams: + +```text +value output -> value >> #stdout +normal end -> { type: "ok" } >> #frame +``` + +At callable fallthrough, the runtime emits normal ok completion. If a callable +body has a tail value expression and does not explicitly reference current-frame +`#stdout`, that tail value is implicitly written to `#stdout`. + +```seal +method workflow_for(channel) { + match channel { + "stable" => "release-stable.yml" + "beta" => "release-beta.yml" + _ => fail("invalid channel: {channel}") + } +} +``` + +Conceptual lowering: + +```seal +method workflow_for(channel) { + match channel { + "stable" => "release-stable.yml" + "beta" => "release-beta.yml" + _ => fail("invalid channel: {channel}") + } >> #stdout + + { type: "ok" } >> #frame +} +``` + +Explicit current-frame `#stdout` use disables implicit tail output for that +callable only. Nested callables, lambdas, and handlers have independent +`#stdout`. + +Early normal completion uses: + +```seal +@call.exit(value, event?) +``` + +Conceptual lowering: + +```seal +value >> #stdout +(event ?? { type: "ok" }) >> #frame +// stop current callable frame +``` + +`@call.exit()` is equivalent to `@call.exit(null)`. + +Seal v0 does not define a shorter return keyword or operator. Any future concise +return-like syntax must lower to `@call.exit(...)` and must preserve the +explicit `#stdout`/`#frame` escape path. + +## Frame Channels + +The predefined current-frame channels are: + +```seal +#stdin +#stdout +#stderr +#frame +``` + +`#frame` is a control/event stream, not a capability object. Frame lifecycle +events are ordinary structured values written to `#frame`. + +Frame event shapes recognized by the v0 runtime are: + +```seal +{ type: "ok" } + +{ + type: "failed", + exit: { + code: 1, + signal: null, + }, + message: null, + data: {}, +} + +{ + type: "fault", + fault: { + kind: "shape", + message: "expected string", + data: {}, + }, +} + +{ + type: "cancelled", + source: "operator", + signal: "interrupt", +} + +{ + type: "cleanup", + run: () => { + @file.remove(tmp) + }, +} +``` + +These are control/event stream values, not `@frame.*` API calls. Additional +fields are allowed and ignored by runtimes that do not recognize them; missing +required fields are faults. + +## Runtime Values + +Seal v0 values: + +- string +- int +- boolean +- byte +- bytes +- array +- map +- null +- stream +- function + +`null` is the only missing value. `??` only handles `null`. Equality is strict; +there is no implicit type conversion. + +Streams are process IO resources. Convert them explicitly with helpers such as: + +```seal +@type.string(...) +@type.bytes(...) +@type.array(...) +@type.map(...) +``` + +`@type.array(...)` and `@type.map(...)` decode JSON bytes and require the +decoded top-level shape to match. + +## Receiver Calls + +Receiver calls are native sugar: + +```seal +let trimmed = text.trim() +``` + +Candidate lowering: + +```seal +@call.self(text, @string.trim, []) +``` + +The runtime may box and unbox primitive/runtime values to call built-in receiver +methods. Field access and receiver calls remain syntactically distinct: + +```seal +release.version +release.version() +``` + +## Canonical Operation Namespace + +The `@` namespace exposes canonical operations behind source sugar: + +```seal +foo(a, b) -> @call.forward(foo, [a, b]) +| gh ... -> @call.process("gh", [...]) +text.trim() -> @call.self(text, @string.trim, []) +a >> b -> @stream.flow(a, b) +``` + +This namespace is not a dumping ground for arbitrary language magic. It exists +to make the core operation model visible, testable, and available for +metaprogramming. + +## Lexer And Parser Closure + +The v0 lexer and parser should be able to proceed from this spec without +unresolved source-syntax decisions. Remaining work is runtime design: stream +scheduling, completion-timing details for `@type.*` and `:=`, +buffering/spooling policy, and the concrete implementation of built-in helper +namespaces. From d224d98cfaa5cc99b7bdb35ea17521e077210322 Mon Sep 17 00:00:00 2001 From: PerishCode <perishcode@gmail.com> Date: Mon, 15 Jun 2026 14:18:10 +0800 Subject: [PATCH 02/20] seal: parse match and block calls --- app/src/core/seal/ast.rs | 30 +++++++++++ app/src/core/seal/ground.rs | 19 +++++++ app/src/core/seal/parser/expr.rs | 76 +++++++++++++++++++++++++++ app/src/core/seal/parser/mod.rs | 2 + app/src/core/seal/parser/statement.rs | 10 ++-- app/tests/seal.rs | 49 +++++++++++++++++ 6 files changed, 181 insertions(+), 5 deletions(-) diff --git a/app/src/core/seal/ast.rs b/app/src/core/seal/ast.rs index 7662c4b..395c132 100644 --- a/app/src/core/seal/ast.rs +++ b/app/src/core/seal/ast.rs @@ -138,6 +138,10 @@ pub enum RawExprKind { callee: Box<RawExpr>, args: Vec<RawArg>, }, + BlockCall { + callee: Box<RawExpr>, + block: RawBlock, + }, ReceiverCall { receiver: Box<RawExpr>, method: String, @@ -152,6 +156,7 @@ pub enum RawExprKind { left: Box<RawExpr>, right: Box<RawExpr>, }, + Match(RawMatch), Process(RawProcess), StreamFlow { op: StreamOp, @@ -185,6 +190,31 @@ pub struct RawArg { pub span: Span, } +#[derive(Debug, Clone, PartialEq)] +pub struct RawMatch { + pub scrutinee: Box<RawExpr>, + pub arms: Vec<RawMatchArm>, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RawMatchArm { + pub patterns: Vec<RawPattern>, + pub value: RawExpr, + pub span: Span, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RawPattern { + pub kind: RawPatternKind, + pub span: Span, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum RawPatternKind { + Wildcard, + Expr(RawExpr), +} + #[derive(Debug, Clone, PartialEq)] pub struct RawProcess { pub program: Option<RawProcessArg>, diff --git a/app/src/core/seal/ground.rs b/app/src/core/seal/ground.rs index d2f2007..680a2d1 100644 --- a/app/src/core/seal/ground.rs +++ b/app/src/core/seal/ground.rs @@ -181,6 +181,17 @@ fn reject_comparison_chain(expr: &RawExpr, diagnostics: &mut Vec<Diagnostic>) { reject_comparison_chain(left, diagnostics); reject_comparison_chain(right, diagnostics); } + RawExprKind::Match(match_expr) => { + reject_comparison_chain(&match_expr.scrutinee, diagnostics); + for arm in &match_expr.arms { + for pattern in &arm.patterns { + if let super::ast::RawPatternKind::Expr(expr) = &pattern.kind { + reject_comparison_chain(expr, diagnostics); + } + } + reject_comparison_chain(&arm.value, diagnostics); + } + } RawExprKind::Unary { expr, .. } | RawExprKind::Group(expr) => { reject_comparison_chain(expr, diagnostics); } @@ -190,6 +201,14 @@ fn reject_comparison_chain(expr: &RawExpr, diagnostics: &mut Vec<Diagnostic>) { reject_comparison_chain(&arg.value, diagnostics); } } + RawExprKind::BlockCall { callee, block } => { + reject_comparison_chain(callee, diagnostics); + for item in &block.items { + if let RawItemKind::Statement(statement) = &item.kind { + reject_statement_comparison_chains(statement, diagnostics); + } + } + } RawExprKind::ReceiverCall { receiver, args, .. } => { reject_comparison_chain(receiver, diagnostics); for arg in args { diff --git a/app/src/core/seal/parser/expr.rs b/app/src/core/seal/parser/expr.rs index 2430d50..1b19aa7 100644 --- a/app/src/core/seal/parser/expr.rs +++ b/app/src/core/seal/parser/expr.rs @@ -5,6 +5,14 @@ impl Parser { self.parse_expr_bp(0) } + pub(super) fn parse_expr_no_block(&mut self) -> RawExpr { + let previous = self.allow_block_call; + self.allow_block_call = false; + let expr = self.parse_expr(); + self.allow_block_call = previous; + expr + } + fn parse_expr_bp(&mut self, min_bp: u8) -> RawExpr { let mut left = self.parse_prefix_expr(); while let Some((op, left_bp, right_bp)) = self.current_binary_op() { @@ -63,6 +71,18 @@ impl Parser { }; continue; } + if self.allow_block_call && self.at(TokenKind::LBrace) { + let block = self.parse_block(); + let span = expr.span.join(block.span); + expr = RawExpr { + span, + kind: RawExprKind::BlockCall { + callee: Box::new(expr), + block, + }, + }; + continue; + } if self.at(TokenKind::Dot) && self.peek_kind(1) == Some(&TokenKind::Ident) { self.bump(); let method = self.bump(); @@ -144,6 +164,7 @@ impl Parser { kind: RawExprKind::Literal(RawLiteral::Null), } } + TokenKind::Keyword(Keyword::Match) => self.parse_match_expr(), TokenKind::At => self.parse_at_name(), TokenKind::Dollar => self.parse_prefixed_name(TokenKind::Dollar), TokenKind::Hash => self.parse_prefixed_name(TokenKind::Hash), @@ -285,4 +306,59 @@ impl Parser { self.expect(TokenKind::RParen, "expected ')' after arguments"); args } + + fn parse_match_expr(&mut self) -> RawExpr { + let start = self + .expect(TokenKind::Keyword(Keyword::Match), "expected match") + .span; + let scrutinee = self.parse_expr_no_block(); + self.expect(TokenKind::LBrace, "expected '{' before match arms"); + let mut arms = Vec::new(); + self.consume_soft_separators(); + while !self.at(TokenKind::RBrace) && !self.at(TokenKind::Eof) { + arms.push(self.parse_match_arm()); + self.consume_soft_separators(); + self.eat(TokenKind::Comma); + self.consume_soft_separators(); + } + let close = self.expect(TokenKind::RBrace, "expected '}' after match"); + RawExpr { + span: start.join(close.span), + kind: RawExprKind::Match(RawMatch { + scrutinee: Box::new(scrutinee), + arms, + }), + } + } + + fn parse_match_arm(&mut self) -> RawMatchArm { + let start = self.current().span; + let mut patterns = vec![self.parse_pattern()]; + while self.at(TokenKind::Pipe) { + self.bump(); + patterns.push(self.parse_pattern()); + } + self.expect(TokenKind::FatArrow, "expected '=>' after match pattern"); + let value = self.parse_stream_expr(); + RawMatchArm { + span: start.join(value.span), + patterns, + value, + } + } + + fn parse_pattern(&mut self) -> RawPattern { + if self.at(TokenKind::Underscore) { + let token = self.bump(); + return RawPattern { + span: token.span, + kind: RawPatternKind::Wildcard, + }; + } + let expr = self.parse_expr_no_block(); + RawPattern { + span: expr.span, + kind: RawPatternKind::Expr(expr), + } + } } diff --git a/app/src/core/seal/parser/mod.rs b/app/src/core/seal/parser/mod.rs index 6d08cd1..a1dedb7 100644 --- a/app/src/core/seal/parser/mod.rs +++ b/app/src/core/seal/parser/mod.rs @@ -27,6 +27,7 @@ struct Parser { cursor: usize, diagnostics: Vec<Diagnostic>, comments: Vec<Comment>, + allow_block_call: bool, } impl Parser { @@ -36,6 +37,7 @@ impl Parser { cursor: 0, diagnostics, comments: Vec::new(), + allow_block_call: true, } } diff --git a/app/src/core/seal/parser/statement.rs b/app/src/core/seal/parser/statement.rs index db3aeca..8d26674 100644 --- a/app/src/core/seal/parser/statement.rs +++ b/app/src/core/seal/parser/statement.rs @@ -87,7 +87,7 @@ impl Parser { let start = self .expect(TokenKind::Keyword(Keyword::If), "expected if") .span; - let condition = self.parse_expr(); + let condition = self.parse_expr_no_block(); let body = self.parse_block(); let mut span = start.join(body.span); let mut branches = vec![RawIfBranch { @@ -101,7 +101,7 @@ impl Parser { self.bump(); if self.at_keyword(Keyword::If) { self.bump(); - let condition = self.parse_expr(); + let condition = self.parse_expr_no_block(); let body = self.parse_block(); span = span.join(body.span); branches.push(RawIfBranch { @@ -135,7 +135,7 @@ impl Parser { TokenKind::Keyword(Keyword::In), "expected 'in' after for binding", ); - let iterable = self.parse_expr(); + let iterable = self.parse_expr_no_block(); let body = self.parse_block(); RawStatement { span: start.join(body.span), @@ -151,7 +151,7 @@ impl Parser { let start = self .expect(TokenKind::Keyword(Keyword::While), "expected while") .span; - let condition = self.parse_expr(); + let condition = self.parse_expr_no_block(); let body = self.parse_block(); RawStatement { span: start.join(body.span), @@ -197,7 +197,7 @@ impl Parser { bindings } - fn parse_stream_expr(&mut self) -> RawExpr { + pub(super) fn parse_stream_expr(&mut self) -> RawExpr { let mut left = self.parse_effect_atom(); while self.at(TokenKind::ShiftRight) || self.at(TokenKind::ShiftLeft) { let token = self.bump(); diff --git a/app/tests/seal.rs b/app/tests/seal.rs index 82b225b..836281c 100644 --- a/app/tests/seal.rs +++ b/app/tests/seal.rs @@ -155,6 +155,55 @@ for tool in tools { )); } +#[test] +fn match_expr() { + let output = parse( + r#" +let workflow = match channel { + "stable" | "prod" => "release-stable.yml" + "beta" => "release-beta.yml" + _ => fail("invalid channel") +} +"#, + ); + + assert!(output.diagnostics.is_empty()); + let RawItemKind::Statement(statement) = &output.file.items[0].kind else { + panic!("expected statement"); + }; + let RawStatementKind::Let { value, .. } = &statement.kind else { + panic!("expected let"); + }; + let RawExprKind::Match(match_expr) = &value.kind else { + panic!("expected match expression"); + }; + assert_eq!(match_expr.arms.len(), 3); + assert_eq!(match_expr.arms[0].patterns.len(), 2); +} + +#[test] +fn block_call() { + let output = parse( + r#" +let branch = @type.string { + | git branch --show-current +} +"#, + ); + + assert!(output.diagnostics.is_empty()); + let RawItemKind::Statement(statement) = &output.file.items[0].kind else { + panic!("expected statement"); + }; + let RawStatementKind::Let { value, .. } = &statement.kind else { + panic!("expected let"); + }; + let RawExprKind::BlockCall { block, .. } = &value.kind else { + panic!("expected block call"); + }; + assert_eq!(block.items.len(), 1); +} + #[test] fn parse_recovery() { let output = parse("let x =\nlet y = 1\n"); From ee7cb50129ff0c441bc6b4d5af4acbec67736028 Mon Sep 17 00:00:00 2001 From: PerishCode <perishcode@gmail.com> Date: Mon, 15 Jun 2026 14:20:18 +0800 Subject: [PATCH 03/20] seal: validate effect block shape --- app/src/core/seal/ground.rs | 24 ++++++++++++++++++++++++ app/tests/seal.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/app/src/core/seal/ground.rs b/app/src/core/seal/ground.rs index 680a2d1..13092c7 100644 --- a/app/src/core/seal/ground.rs +++ b/app/src/core/seal/ground.rs @@ -203,6 +203,7 @@ fn reject_comparison_chain(expr: &RawExpr, diagnostics: &mut Vec<Diagnostic>) { } RawExprKind::BlockCall { callee, block } => { reject_comparison_chain(callee, diagnostics); + validate_effect_block(block, diagnostics); for item in &block.items { if let RawItemKind::Statement(statement) = &item.kind { reject_statement_comparison_chains(statement, diagnostics); @@ -249,3 +250,26 @@ fn reject_comparison_chain(expr: &RawExpr, diagnostics: &mut Vec<Diagnostic>) { _ => {} } } + +fn validate_effect_block(block: &super::ast::RawBlock, diagnostics: &mut Vec<Diagnostic>) { + let statements = block + .items + .iter() + .filter_map(|item| match &item.kind { + RawItemKind::Statement(statement) => Some(statement), + RawItemKind::Comment(_) => None, + RawItemKind::Method(_) | RawItemKind::Error => None, + }) + .collect::<Vec<_>>(); + + let valid = matches!( + statements.as_slice(), + [statement] if matches!(statement.kind, RawStatementKind::Effect(_)) + ); + if !valid { + diagnostics.push(Diagnostic::new( + block.span, + "effect block must contain exactly one stream graph", + )); + } +} diff --git a/app/tests/seal.rs b/app/tests/seal.rs index 836281c..008a8ad 100644 --- a/app/tests/seal.rs +++ b/app/tests/seal.rs @@ -237,3 +237,31 @@ fn ground_comparison_chain() { "comparison operators cannot be chained" ); } + +#[test] +fn ground_effect_block() { + let valid = parse( + r#" +let branch = @type.string { + | git branch --show-current +} +"#, + ); + assert!(valid.diagnostics.is_empty()); + assert!(ground::ground(&valid.file).diagnostics.is_empty()); + + let invalid = parse( + r#" +let branch = @type.string { + let x = 1 +} +"#, + ); + assert!(invalid.diagnostics.is_empty()); + let grounded = ground::ground(&invalid.file); + assert_eq!(grounded.diagnostics.len(), 1); + assert_eq!( + grounded.diagnostics[0].message, + "effect block must contain exactly one stream graph" + ); +} From b02bd672ec48dc95a30269d9104bf48a7bec43f4 Mon Sep 17 00:00:00 2001 From: PerishCode <perishcode@gmail.com> Date: Mon, 15 Jun 2026 14:27:41 +0800 Subject: [PATCH 04/20] seal: mark method tail output --- app/src/core/seal/ground.rs | 161 ++++++++++++++++++++++++++++++++++-- app/tests/seal.rs | 46 ++++++++++- 2 files changed, 201 insertions(+), 6 deletions(-) diff --git a/app/src/core/seal/ground.rs b/app/src/core/seal/ground.rs index 13092c7..0321571 100644 --- a/app/src/core/seal/ground.rs +++ b/app/src/core/seal/ground.rs @@ -18,11 +18,31 @@ pub struct GroundFile { #[derive(Debug, Clone, PartialEq)] pub enum GroundNode { - Method { name: String, span: Span }, - Let { name: String, span: Span }, - Expr { span: Span }, - Effect { span: Span }, - Error { span: Span }, + Method { + name: String, + tail: TailOutput, + span: Span, + }, + Let { + name: String, + span: Span, + }, + Expr { + span: Span, + }, + Effect { + span: Span, + }, + Error { + span: Span, + }, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum TailOutput { + Implicit { span: Span }, + DisabledByStdout { span: Span }, + None, } pub fn ground(file: &SourceFile) -> GroundOutput { @@ -33,8 +53,10 @@ pub fn ground(file: &SourceFile) -> GroundOutput { match &item.kind { RawItemKind::Comment(_) => {} RawItemKind::Method(method) => { + let tail = method_tail_output(&method.body, &mut diagnostics); nodes.push(GroundNode::Method { name: method.name.clone(), + tail, span: item.span, }); } @@ -54,6 +76,30 @@ pub fn ground(file: &SourceFile) -> GroundOutput { } } +fn method_tail_output( + body: &super::ast::RawBlock, + diagnostics: &mut Vec<Diagnostic>, +) -> TailOutput { + if let Some(span) = find_current_stdout_block(body) { + return TailOutput::DisabledByStdout { span }; + } + + for item in body.items.iter().rev() { + match &item.kind { + RawItemKind::Comment(_) => continue, + RawItemKind::Statement(statement) => { + reject_statement_comparison_chains(statement, diagnostics); + return match &statement.kind { + RawStatementKind::Expr(expr) => TailOutput::Implicit { span: expr.span }, + _ => TailOutput::None, + }; + } + RawItemKind::Method(_) | RawItemKind::Error => return TailOutput::None, + } + } + TailOutput::None +} + fn ground_statement( statement: &super::ast::RawStatement, diagnostics: &mut Vec<Diagnostic>, @@ -103,6 +149,111 @@ fn ground_statement( } } +fn find_current_stdout_block(block: &super::ast::RawBlock) -> Option<Span> { + block.items.iter().find_map(|item| match &item.kind { + RawItemKind::Statement(statement) => find_current_stdout_statement(statement), + RawItemKind::Comment(_) | RawItemKind::Method(_) | RawItemKind::Error => None, + }) +} + +fn find_current_stdout_statement(statement: &super::ast::RawStatement) -> Option<Span> { + match &statement.kind { + RawStatementKind::Let { value, .. } => find_current_stdout_expr(value), + RawStatementKind::Assign { target, value } => { + find_current_stdout_expr(target).or_else(|| find_current_stdout_expr(value)) + } + RawStatementKind::If { + branches, + else_branch, + } => { + for branch in branches { + if let Some(span) = find_current_stdout_expr(&branch.condition) + .or_else(|| find_current_stdout_block(&branch.body)) + { + return Some(span); + } + } + else_branch.as_ref().and_then(find_current_stdout_block) + } + RawStatementKind::For { iterable, body, .. } => { + find_current_stdout_expr(iterable).or_else(|| find_current_stdout_block(body)) + } + RawStatementKind::While { condition, body } => { + find_current_stdout_expr(condition).or_else(|| find_current_stdout_block(body)) + } + RawStatementKind::WithEnv { bindings, body } => bindings + .iter() + .find_map(|binding| find_current_stdout_expr(&binding.value)) + .or_else(|| find_current_stdout_block(body)), + RawStatementKind::Expr(expr) | RawStatementKind::Effect(expr) => { + find_current_stdout_expr(expr) + } + RawStatementKind::Break | RawStatementKind::Continue | RawStatementKind::Error => None, + } +} + +fn find_current_stdout_expr(expr: &RawExpr) -> Option<Span> { + match &expr.kind { + RawExprKind::Channel(name) if name == "stdout" => Some(expr.span), + RawExprKind::Binary { left, right, .. } | RawExprKind::StreamFlow { left, right, .. } => { + find_current_stdout_expr(left).or_else(|| find_current_stdout_expr(right)) + } + RawExprKind::Unary { expr, .. } | RawExprKind::Group(expr) => { + find_current_stdout_expr(expr) + } + RawExprKind::Call { callee, args } => find_current_stdout_expr(callee).or_else(|| { + args.iter() + .find_map(|arg| find_current_stdout_expr(&arg.value)) + }), + RawExprKind::BlockCall { callee, block } => { + find_current_stdout_expr(callee).or_else(|| find_current_stdout_block(block)) + } + RawExprKind::ReceiverCall { receiver, args, .. } => find_current_stdout_expr(receiver) + .or_else(|| { + args.iter() + .find_map(|arg| find_current_stdout_expr(&arg.value)) + }), + RawExprKind::Array(items) => items.iter().find_map(find_current_stdout_expr), + RawExprKind::Map(entries) => entries + .iter() + .find_map(|entry| find_current_stdout_expr(&entry.value)), + RawExprKind::Match(match_expr) => { + find_current_stdout_expr(&match_expr.scrutinee).or_else(|| { + match_expr.arms.iter().find_map(|arm| { + arm.patterns + .iter() + .find_map(|pattern| match &pattern.kind { + super::ast::RawPatternKind::Expr(expr) => { + find_current_stdout_expr(expr) + } + super::ast::RawPatternKind::Wildcard => None, + }) + .or_else(|| find_current_stdout_expr(&arm.value)) + }) + }) + } + RawExprKind::Process(process) => process + .program + .iter() + .chain(process.args.iter()) + .find_map(find_stdout_arg), + _ => None, + } +} + +fn find_stdout_arg(arg: &super::ast::RawProcessArg) -> Option<Span> { + match &arg.kind { + super::ast::RawProcessArgKind::Spread(expr) => find_current_stdout_expr(expr), + super::ast::RawProcessArgKind::Word(parts) => parts.iter().find_map(|part| match part { + super::ast::RawProcessPart::Interpolation(expr) => find_current_stdout_expr(expr), + super::ast::RawProcessPart::Text(_) => None, + }), + super::ast::RawProcessArgKind::String(_) + | super::ast::RawProcessArgKind::TextBlock(_) + | super::ast::RawProcessArgKind::Error => None, + } +} + fn reject_statement_comparison_chains( statement: &super::ast::RawStatement, diagnostics: &mut Vec<Diagnostic>, diff --git a/app/tests/seal.rs b/app/tests/seal.rs index 008a8ad..717e852 100644 --- a/app/tests/seal.rs +++ b/app/tests/seal.rs @@ -3,7 +3,8 @@ use runseal::core::seal::{ LetBinding, RawExprKind, RawItemKind, RawProcessArgKind, RawProcessPart, RawStatement, RawStatementKind, }, - ground, lex, parse, + ground::{self, GroundNode, TailOutput}, + lex, parse, token::{Keyword, TokenKind}, }; @@ -265,3 +266,46 @@ let branch = @type.string { "effect block must contain exactly one stream graph" ); } + +#[test] +fn ground_method_tail() { + let implicit = parse( + r#" +method workflow_for(channel) { + match channel { + "stable" => "release-stable.yml" + _ => fail("invalid channel") + } +} +"#, + ); + assert!(implicit.diagnostics.is_empty()); + let grounded = ground::ground(&implicit.file); + assert!(grounded.diagnostics.is_empty()); + assert!(matches!( + grounded.file.nodes[0], + GroundNode::Method { + tail: TailOutput::Implicit { .. }, + .. + } + )); + + let explicit = parse( + r###" +method status() { + "starting" >> #stdout + make_summary() +} +"###, + ); + assert!(explicit.diagnostics.is_empty()); + let grounded = ground::ground(&explicit.file); + assert!(grounded.diagnostics.is_empty()); + assert!(matches!( + grounded.file.nodes[0], + GroundNode::Method { + tail: TailOutput::DisabledByStdout { .. }, + .. + } + )); +} From 1d805657af616ff830c24f8075616179bff01761 Mon Sep 17 00:00:00 2001 From: PerishCode <perishcode@gmail.com> Date: Mon, 15 Jun 2026 14:49:43 +0800 Subject: [PATCH 05/20] seal: parse handlers and match arm blocks --- app/src/core/seal/ast.rs | 24 ++++- app/src/core/seal/ground.rs | 40 +++++++- app/src/core/seal/parser/expr.rs | 122 ++++++++++++++++++++++++- app/tests/seal.rs | 152 ++++++++++++++++++++++++++++++- 4 files changed, 330 insertions(+), 8 deletions(-) diff --git a/app/src/core/seal/ast.rs b/app/src/core/seal/ast.rs index 395c132..6a42996 100644 --- a/app/src/core/seal/ast.rs +++ b/app/src/core/seal/ast.rs @@ -147,6 +147,7 @@ pub enum RawExprKind { method: String, args: Vec<RawArg>, }, + Lambda(RawLambda), Unary { op: UnaryOp, expr: Box<RawExpr>, @@ -190,6 +191,12 @@ pub struct RawArg { pub span: Span, } +#[derive(Debug, Clone, PartialEq)] +pub struct RawLambda { + pub params: Vec<RawParam>, + pub body: RawBlock, +} + #[derive(Debug, Clone, PartialEq)] pub struct RawMatch { pub scrutinee: Box<RawExpr>, @@ -199,10 +206,25 @@ pub struct RawMatch { #[derive(Debug, Clone, PartialEq)] pub struct RawMatchArm { pub patterns: Vec<RawPattern>, - pub value: RawExpr, + pub body: RawMatchArmBody, pub span: Span, } +#[derive(Debug, Clone, PartialEq)] +pub enum RawMatchArmBody { + Expr(RawExpr), + Block(RawBlock), +} + +impl RawMatchArmBody { + pub fn span(&self) -> Span { + match self { + Self::Expr(expr) => expr.span, + Self::Block(block) => block.span, + } + } +} + #[derive(Debug, Clone, PartialEq)] pub struct RawPattern { pub kind: RawPatternKind, diff --git a/app/src/core/seal/ground.rs b/app/src/core/seal/ground.rs index 0321571..646b412 100644 --- a/app/src/core/seal/ground.rs +++ b/app/src/core/seal/ground.rs @@ -208,6 +208,7 @@ fn find_current_stdout_expr(expr: &RawExpr) -> Option<Span> { RawExprKind::BlockCall { callee, block } => { find_current_stdout_expr(callee).or_else(|| find_current_stdout_block(block)) } + RawExprKind::Lambda(_) => None, RawExprKind::ReceiverCall { receiver, args, .. } => find_current_stdout_expr(receiver) .or_else(|| { args.iter() @@ -228,7 +229,7 @@ fn find_current_stdout_expr(expr: &RawExpr) -> Option<Span> { } super::ast::RawPatternKind::Wildcard => None, }) - .or_else(|| find_current_stdout_expr(&arm.value)) + .or_else(|| find_stdout_arm_body(&arm.body)) }) }) } @@ -241,6 +242,13 @@ fn find_current_stdout_expr(expr: &RawExpr) -> Option<Span> { } } +fn find_stdout_arm_body(body: &super::ast::RawMatchArmBody) -> Option<Span> { + match body { + super::ast::RawMatchArmBody::Expr(expr) => find_current_stdout_expr(expr), + super::ast::RawMatchArmBody::Block(block) => find_current_stdout_block(block), + } +} + fn find_stdout_arg(arg: &super::ast::RawProcessArg) -> Option<Span> { match &arg.kind { super::ast::RawProcessArgKind::Spread(expr) => find_current_stdout_expr(expr), @@ -317,6 +325,22 @@ fn reject_statement_comparison_chains( } } +fn reject_arm_body_comparisons( + body: &super::ast::RawMatchArmBody, + diagnostics: &mut Vec<Diagnostic>, +) { + match body { + super::ast::RawMatchArmBody::Expr(expr) => reject_comparison_chain(expr, diagnostics), + super::ast::RawMatchArmBody::Block(block) => { + for item in &block.items { + if let RawItemKind::Statement(statement) = &item.kind { + reject_statement_comparison_chains(statement, diagnostics); + } + } + } + } +} + fn reject_comparison_chain(expr: &RawExpr, diagnostics: &mut Vec<Diagnostic>) { match &expr.kind { RawExprKind::Binary { op, left, right } => { @@ -340,7 +364,7 @@ fn reject_comparison_chain(expr: &RawExpr, diagnostics: &mut Vec<Diagnostic>) { reject_comparison_chain(expr, diagnostics); } } - reject_comparison_chain(&arm.value, diagnostics); + reject_arm_body_comparisons(&arm.body, diagnostics); } } RawExprKind::Unary { expr, .. } | RawExprKind::Group(expr) => { @@ -361,6 +385,18 @@ fn reject_comparison_chain(expr: &RawExpr, diagnostics: &mut Vec<Diagnostic>) { } } } + RawExprKind::Lambda(lambda) => { + for param in &lambda.params { + if let Some(default) = ¶m.default { + reject_comparison_chain(default, diagnostics); + } + } + for item in &lambda.body.items { + if let RawItemKind::Statement(statement) = &item.kind { + reject_statement_comparison_chains(statement, diagnostics); + } + } + } RawExprKind::ReceiverCall { receiver, args, .. } => { reject_comparison_chain(receiver, diagnostics); for arg in args { diff --git a/app/src/core/seal/parser/expr.rs b/app/src/core/seal/parser/expr.rs index 1b19aa7..fb78f8b 100644 --- a/app/src/core/seal/parser/expr.rs +++ b/app/src/core/seal/parser/expr.rs @@ -57,6 +57,15 @@ impl Parser { pub(super) fn parse_postfix_expr(&mut self) -> RawExpr { let mut expr = self.parse_primary_expr(); loop { + if !self.at(TokenKind::Dot) { + let checkpoint = self.cursor; + while self.at(TokenKind::Newline) { + self.bump(); + } + if !self.at(TokenKind::Dot) { + self.cursor = checkpoint; + } + } if self.at(TokenKind::LParen) { let args = self.parse_call_args(); let span = expr @@ -169,6 +178,7 @@ impl Parser { TokenKind::Dollar => self.parse_prefixed_name(TokenKind::Dollar), TokenKind::Hash => self.parse_prefixed_name(TokenKind::Hash), TokenKind::Pipe => self.parse_process(), + TokenKind::LParen if self.at_lambda_start() => self.parse_lambda(), TokenKind::LParen => self.parse_group(), TokenKind::LBracket => self.parse_array(), TokenKind::LBrace => self.parse_map(), @@ -223,6 +233,76 @@ impl Parser { } } + fn at_lambda_start(&self) -> bool { + if !self.at(TokenKind::LParen) { + return false; + } + + let mut cursor = self.cursor + 1; + let mut depth = 1usize; + while let Some(token) = self.tokens.get(cursor) { + match token.kind { + TokenKind::LParen => depth += 1, + TokenKind::RParen => { + depth -= 1; + if depth == 0 { + cursor += 1; + while matches!( + self.tokens.get(cursor).map(|token| &token.kind), + Some(TokenKind::Newline | TokenKind::Semicolon) + ) { + cursor += 1; + } + return matches!( + self.tokens.get(cursor).map(|token| &token.kind), + Some(TokenKind::FatArrow) + ); + } + } + TokenKind::Eof => return false, + _ => {} + } + cursor += 1; + } + false + } + + fn parse_lambda(&mut self) -> RawExpr { + let open = self.expect(TokenKind::LParen, "expected '(' before lambda parameters"); + let mut params = Vec::new(); + self.consume_soft_separators(); + while !self.at(TokenKind::RParen) && !self.at(TokenKind::Eof) { + let param_start = self.current().span; + let name = self.expect_ident("expected lambda parameter name"); + let default = if self.eat(TokenKind::Eq).is_some() { + Some(self.parse_expr()) + } else { + None + }; + let end = default + .as_ref() + .map_or(param_start.end, |expr| expr.span.end); + params.push(RawParam { + name, + default, + span: Span::new(param_start.start, end), + }); + self.consume_soft_separators(); + if self.eat(TokenKind::Comma).is_none() { + break; + } + self.consume_soft_separators(); + } + self.expect(TokenKind::RParen, "expected ')' after lambda parameters"); + self.consume_soft_separators(); + self.expect(TokenKind::FatArrow, "expected '=>' after lambda parameters"); + let body = self.parse_block(); + RawExpr { + span: open.span.join(body.span), + kind: RawExprKind::Lambda(RawLambda { params, body }), + } + } + fn parse_array(&mut self) -> RawExpr { let open = self.expect(TokenKind::LBracket, "expected '['").span; let mut items = Vec::new(); @@ -339,12 +419,48 @@ impl Parser { patterns.push(self.parse_pattern()); } self.expect(TokenKind::FatArrow, "expected '=>' after match pattern"); - let value = self.parse_stream_expr(); + let body = if self.at(TokenKind::LBrace) && !self.brace_starts_map() { + RawMatchArmBody::Block(self.parse_block()) + } else { + RawMatchArmBody::Expr(self.parse_stream_expr()) + }; RawMatchArm { - span: start.join(value.span), + span: start.join(body.span()), patterns, - value, + body, + } + } + + fn brace_starts_map(&self) -> bool { + if !self.at(TokenKind::LBrace) { + return false; + } + + let Some(first) = self.next_significant_index(self.cursor + 1) else { + return false; + }; + if !matches!( + self.tokens[first].kind, + TokenKind::Ident | TokenKind::String + ) { + return false; + } + + matches!( + self.next_significant_index(first + 1) + .map(|index| &self.tokens[index].kind), + Some(TokenKind::Colon) + ) + } + + fn next_significant_index(&self, mut cursor: usize) -> Option<usize> { + while matches!( + self.tokens.get(cursor).map(|token| &token.kind), + Some(TokenKind::Newline | TokenKind::Semicolon) + ) { + cursor += 1; } + self.tokens.get(cursor).map(|_| cursor) } fn parse_pattern(&mut self) -> RawPattern { diff --git a/app/tests/seal.rs b/app/tests/seal.rs index 717e852..567270f 100644 --- a/app/tests/seal.rs +++ b/app/tests/seal.rs @@ -1,7 +1,7 @@ use runseal::core::seal::{ ast::{ - LetBinding, RawExprKind, RawItemKind, RawProcessArgKind, RawProcessPart, RawStatement, - RawStatementKind, + LetBinding, RawExprKind, RawItemKind, RawMatchArmBody, RawProcessArgKind, RawProcessPart, + RawStatement, RawStatementKind, }, ground::{self, GroundNode, TailOutput}, lex, parse, @@ -182,6 +182,51 @@ let workflow = match channel { assert_eq!(match_expr.arms[0].patterns.len(), 2); } +#[test] +fn match_arm_body_shapes() { + let block = parse( + r#" +match target { + "macos" => { + | sw_vers + } +} +"#, + ); + + assert!(block.diagnostics.is_empty()); + let RawItemKind::Statement(statement) = &block.file.items[0].kind else { + panic!("expected statement"); + }; + let RawStatementKind::Expr(expr) = &statement.kind else { + panic!("expected expression statement"); + }; + let RawExprKind::Match(match_expr) = &expr.kind else { + panic!("expected match expression"); + }; + assert!(matches!(match_expr.arms[0].body, RawMatchArmBody::Block(_))); + + let map = parse( + r#" +let result = match status { + "ok" => { status: "ok" } +} +"#, + ); + + assert!(map.diagnostics.is_empty()); + let RawItemKind::Statement(statement) = &map.file.items[0].kind else { + panic!("expected statement"); + }; + let RawStatementKind::Let { value, .. } = &statement.kind else { + panic!("expected let"); + }; + let RawExprKind::Match(match_expr) = &value.kind else { + panic!("expected match expression"); + }; + assert!(matches!(match_expr.arms[0].body, RawMatchArmBody::Expr(_))); +} + #[test] fn block_call() { let output = parse( @@ -205,6 +250,63 @@ let branch = @type.string { assert_eq!(block.items.len(), 1); } +#[test] +fn lambda_handler_call_arg() { + let output = parse( + r#" +@call.stdio(call, (stdin, stdout, stderr) => { + stdout >> #stdout +}) +"#, + ); + + assert!(output.diagnostics.is_empty()); + let RawItemKind::Statement(statement) = &output.file.items[0].kind else { + panic!("expected statement"); + }; + let RawStatementKind::Expr(expr) = &statement.kind else { + panic!("expected expression statement"); + }; + let RawExprKind::Call { args, .. } = &expr.kind else { + panic!("expected call expression"); + }; + assert_eq!(args.len(), 2); + let RawExprKind::Lambda(lambda) = &args[1].value.kind else { + panic!("expected handler lambda"); + }; + assert_eq!(lambda.params.len(), 3); + assert_eq!(lambda.params[0].name, "stdin"); + assert_eq!(lambda.params[1].name, "stdout"); + assert_eq!(lambda.params[2].name, "stderr"); + assert_eq!(lambda.body.items.len(), 1); +} + +#[test] +fn lambda_completion_chain_arg() { + let output = parse( + r#" +@call.completion(call, (stdin, stdout, stderr, frame) => {}) + .ok((completion) => { + "ok" >> #stdout + }) +"#, + ); + + assert!(output.diagnostics.is_empty()); + let RawItemKind::Statement(statement) = &output.file.items[0].kind else { + panic!("expected statement"); + }; + let RawStatementKind::Expr(expr) = &statement.kind else { + panic!("expected expression statement"); + }; + let RawExprKind::ReceiverCall { method, args, .. } = &expr.kind else { + panic!("expected receiver call"); + }; + assert_eq!(method, "ok"); + assert_eq!(args.len(), 1); + assert!(matches!(args[0].value.kind, RawExprKind::Lambda(_))); +} + #[test] fn parse_recovery() { let output = parse("let x =\nlet y = 1\n"); @@ -308,4 +410,50 @@ method status() { .. } )); + + let handler_stdout = parse( + r###" +method routed(call) { + @call.stdio(call, (stdin, stdout, stderr) => { + stdout >> #stdout + }) + + "done" +} +"###, + ); + assert!(handler_stdout.diagnostics.is_empty()); + let grounded = ground::ground(&handler_stdout.file); + assert!(grounded.diagnostics.is_empty()); + assert!(matches!( + grounded.file.nodes[0], + GroundNode::Method { + tail: TailOutput::Implicit { .. }, + .. + } + )); + + let match_arm_stdout = parse( + r###" +method routed(target) { + match target { + "local" => { + "local" >> #stdout + } + } + + "done" +} +"###, + ); + assert!(match_arm_stdout.diagnostics.is_empty()); + let grounded = ground::ground(&match_arm_stdout.file); + assert!(grounded.diagnostics.is_empty()); + assert!(matches!( + grounded.file.nodes[0], + GroundNode::Method { + tail: TailOutput::DisabledByStdout { .. }, + .. + } + )); } From 2bc0e447e8e51cdeb10d63609143f5c57e1b3e4d Mon Sep 17 00:00:00 2001 From: PerishCode <perishcode@gmail.com> Date: Mon, 15 Jun 2026 14:54:49 +0800 Subject: [PATCH 06/20] seal: parse structured match patterns --- app/src/core/seal/ast.rs | 9 ++++ app/src/core/seal/ground.rs | 39 ++++++++++++---- app/src/core/seal/parser/expr.rs | 15 ------- app/src/core/seal/parser/mod.rs | 1 + app/src/core/seal/parser/pattern.rs | 70 +++++++++++++++++++++++++++++ app/tests/seal_patterns.rs | 56 +++++++++++++++++++++++ 6 files changed, 166 insertions(+), 24 deletions(-) create mode 100644 app/src/core/seal/parser/pattern.rs create mode 100644 app/tests/seal_patterns.rs diff --git a/app/src/core/seal/ast.rs b/app/src/core/seal/ast.rs index 6a42996..38f56ac 100644 --- a/app/src/core/seal/ast.rs +++ b/app/src/core/seal/ast.rs @@ -234,9 +234,18 @@ pub struct RawPattern { #[derive(Debug, Clone, PartialEq)] pub enum RawPatternKind { Wildcard, + Map(Vec<RawPatternEntry>), + Array(Vec<RawPattern>), Expr(RawExpr), } +#[derive(Debug, Clone, PartialEq)] +pub struct RawPatternEntry { + pub key: String, + pub pattern: RawPattern, + pub span: Span, +} + #[derive(Debug, Clone, PartialEq)] pub struct RawProcess { pub program: Option<RawProcessArg>, diff --git a/app/src/core/seal/ground.rs b/app/src/core/seal/ground.rs index 646b412..76164af 100644 --- a/app/src/core/seal/ground.rs +++ b/app/src/core/seal/ground.rs @@ -223,12 +223,7 @@ fn find_current_stdout_expr(expr: &RawExpr) -> Option<Span> { match_expr.arms.iter().find_map(|arm| { arm.patterns .iter() - .find_map(|pattern| match &pattern.kind { - super::ast::RawPatternKind::Expr(expr) => { - find_current_stdout_expr(expr) - } - super::ast::RawPatternKind::Wildcard => None, - }) + .find_map(find_stdout_pattern) .or_else(|| find_stdout_arm_body(&arm.body)) }) }) @@ -249,6 +244,17 @@ fn find_stdout_arm_body(body: &super::ast::RawMatchArmBody) -> Option<Span> { } } +fn find_stdout_pattern(pattern: &super::ast::RawPattern) -> Option<Span> { + match &pattern.kind { + super::ast::RawPatternKind::Expr(expr) => find_current_stdout_expr(expr), + super::ast::RawPatternKind::Map(entries) => entries + .iter() + .find_map(|entry| find_stdout_pattern(&entry.pattern)), + super::ast::RawPatternKind::Array(items) => items.iter().find_map(find_stdout_pattern), + super::ast::RawPatternKind::Wildcard => None, + } +} + fn find_stdout_arg(arg: &super::ast::RawProcessArg) -> Option<Span> { match &arg.kind { super::ast::RawProcessArgKind::Spread(expr) => find_current_stdout_expr(expr), @@ -360,9 +366,7 @@ fn reject_comparison_chain(expr: &RawExpr, diagnostics: &mut Vec<Diagnostic>) { reject_comparison_chain(&match_expr.scrutinee, diagnostics); for arm in &match_expr.arms { for pattern in &arm.patterns { - if let super::ast::RawPatternKind::Expr(expr) = &pattern.kind { - reject_comparison_chain(expr, diagnostics); - } + reject_pattern_comparisons(pattern, diagnostics); } reject_arm_body_comparisons(&arm.body, diagnostics); } @@ -438,6 +442,23 @@ fn reject_comparison_chain(expr: &RawExpr, diagnostics: &mut Vec<Diagnostic>) { } } +fn reject_pattern_comparisons(pattern: &super::ast::RawPattern, diagnostics: &mut Vec<Diagnostic>) { + match &pattern.kind { + super::ast::RawPatternKind::Expr(expr) => reject_comparison_chain(expr, diagnostics), + super::ast::RawPatternKind::Map(entries) => { + for entry in entries { + reject_pattern_comparisons(&entry.pattern, diagnostics); + } + } + super::ast::RawPatternKind::Array(items) => { + for item in items { + reject_pattern_comparisons(item, diagnostics); + } + } + super::ast::RawPatternKind::Wildcard => {} + } +} + fn validate_effect_block(block: &super::ast::RawBlock, diagnostics: &mut Vec<Diagnostic>) { let statements = block .items diff --git a/app/src/core/seal/parser/expr.rs b/app/src/core/seal/parser/expr.rs index fb78f8b..9a8e3e3 100644 --- a/app/src/core/seal/parser/expr.rs +++ b/app/src/core/seal/parser/expr.rs @@ -462,19 +462,4 @@ impl Parser { } self.tokens.get(cursor).map(|_| cursor) } - - fn parse_pattern(&mut self) -> RawPattern { - if self.at(TokenKind::Underscore) { - let token = self.bump(); - return RawPattern { - span: token.span, - kind: RawPatternKind::Wildcard, - }; - } - let expr = self.parse_expr_no_block(); - RawPattern { - span: expr.span, - kind: RawPatternKind::Expr(expr), - } - } } diff --git a/app/src/core/seal/parser/mod.rs b/app/src/core/seal/parser/mod.rs index a1dedb7..da3a5a1 100644 --- a/app/src/core/seal/parser/mod.rs +++ b/app/src/core/seal/parser/mod.rs @@ -7,6 +7,7 @@ use super::{ }; mod expr; +mod pattern; mod process; mod statement; diff --git a/app/src/core/seal/parser/pattern.rs b/app/src/core/seal/parser/pattern.rs new file mode 100644 index 0000000..a4793b9 --- /dev/null +++ b/app/src/core/seal/parser/pattern.rs @@ -0,0 +1,70 @@ +use super::*; + +impl Parser { + pub(super) fn parse_pattern(&mut self) -> RawPattern { + if self.at(TokenKind::Underscore) { + let token = self.bump(); + return RawPattern { + span: token.span, + kind: RawPatternKind::Wildcard, + }; + } + if self.at(TokenKind::LBrace) { + return self.parse_map_pattern(); + } + if self.at(TokenKind::LBracket) { + return self.parse_array_pattern(); + } + let expr = self.parse_expr_no_block(); + RawPattern { + span: expr.span, + kind: RawPatternKind::Expr(expr), + } + } + + fn parse_map_pattern(&mut self) -> RawPattern { + let open = self.expect(TokenKind::LBrace, "expected '{' before map pattern"); + let mut entries = Vec::new(); + self.consume_soft_separators(); + while !self.at(TokenKind::RBrace) && !self.at(TokenKind::Eof) { + let key_token = self.current().clone(); + let key = self.expect_ident("expected map pattern key"); + self.expect(TokenKind::Colon, "expected ':' after map pattern key"); + let pattern = self.parse_pattern(); + entries.push(RawPatternEntry { + key, + span: key_token.span.join(pattern.span), + pattern, + }); + self.consume_soft_separators(); + if self.eat(TokenKind::Comma).is_none() { + break; + } + self.consume_soft_separators(); + } + let close = self.expect(TokenKind::RBrace, "expected '}' after map pattern"); + RawPattern { + span: open.span.join(close.span), + kind: RawPatternKind::Map(entries), + } + } + + fn parse_array_pattern(&mut self) -> RawPattern { + let open = self.expect(TokenKind::LBracket, "expected '[' before array pattern"); + let mut items = Vec::new(); + self.consume_soft_separators(); + while !self.at(TokenKind::RBracket) && !self.at(TokenKind::Eof) { + items.push(self.parse_pattern()); + self.consume_soft_separators(); + if self.eat(TokenKind::Comma).is_none() { + break; + } + self.consume_soft_separators(); + } + let close = self.expect(TokenKind::RBracket, "expected ']' after array pattern"); + RawPattern { + span: open.span.join(close.span), + kind: RawPatternKind::Array(items), + } + } +} diff --git a/app/tests/seal_patterns.rs b/app/tests/seal_patterns.rs new file mode 100644 index 0000000..86d0200 --- /dev/null +++ b/app/tests/seal_patterns.rs @@ -0,0 +1,56 @@ +use runseal::core::seal::{ + ast::{RawExprKind, RawItemKind, RawPatternKind, RawStatementKind}, + ground, parse, +}; + +#[test] +fn structured_patterns() { + let output = parse( + r#" +match completion { + { status: "ok" } => "ok" + { status: "failed", exit: [code, message] } => message +} +"#, + ); + + assert!(output.diagnostics.is_empty()); + let RawItemKind::Statement(statement) = &output.file.items[0].kind else { + panic!("expected statement"); + }; + let RawStatementKind::Expr(expr) = &statement.kind else { + panic!("expected expression statement"); + }; + let RawExprKind::Match(match_expr) = &expr.kind else { + panic!("expected match expression"); + }; + let RawPatternKind::Map(entries) = &match_expr.arms[0].patterns[0].kind else { + panic!("expected map pattern"); + }; + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].key, "status"); + let RawPatternKind::Map(entries) = &match_expr.arms[1].patterns[0].kind else { + panic!("expected map pattern"); + }; + assert_eq!(entries.len(), 2); + assert!(matches!(entries[1].pattern.kind, RawPatternKind::Array(_))); +} + +#[test] +fn pattern_comparisons() { + let output = parse( + r#" +match value { + { nested: a < b < c } => "bad" +} +"#, + ); + + assert!(output.diagnostics.is_empty()); + let grounded = ground::ground(&output.file); + assert_eq!(grounded.diagnostics.len(), 1); + assert_eq!( + grounded.diagnostics[0].message, + "comparison operators cannot be chained" + ); +} From 51ba5878efc081d3dee4a7078c7b1fbf7b9e4a96 Mon Sep 17 00:00:00 2001 From: PerishCode <perishcode@gmail.com> Date: Mon, 15 Jun 2026 14:58:52 +0800 Subject: [PATCH 07/20] seal: stop process args at closing delimiters --- app/src/core/seal/parser/mod.rs | 2 ++ app/tests/seal.rs | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/app/src/core/seal/parser/mod.rs b/app/src/core/seal/parser/mod.rs index da3a5a1..1749fe6 100644 --- a/app/src/core/seal/parser/mod.rs +++ b/app/src/core/seal/parser/mod.rs @@ -238,6 +238,8 @@ impl Parser { | TokenKind::Newline | TokenKind::Semicolon | TokenKind::RBrace + | TokenKind::RParen + | TokenKind::RBracket | TokenKind::ShiftRight | TokenKind::ShiftLeft ) diff --git a/app/tests/seal.rs b/app/tests/seal.rs index 567270f..8976eb5 100644 --- a/app/tests/seal.rs +++ b/app/tests/seal.rs @@ -84,6 +84,27 @@ fn process_whitespace() { ); } +#[test] +fn process_call_arg_boundary() { + let output = parse(r#"let branch = @type.string(| git branch --show-current)"#); + + assert!(output.diagnostics.is_empty()); + let RawItemKind::Statement(statement) = &output.file.items[0].kind else { + panic!("expected statement"); + }; + let RawStatementKind::Let { value, .. } = &statement.kind else { + panic!("expected let"); + }; + let RawExprKind::Call { args, .. } = &value.kind else { + panic!("expected call"); + }; + assert_eq!(args.len(), 1); + let RawExprKind::Process(process) = &args[0].value.kind else { + panic!("expected process arg"); + }; + assert_eq!(process.args.len(), 2); +} + #[test] fn with_env_scope() { let output = parse( From 06c0e5c1184c4fcb7b1ea6cd8d7f7399a4318dcc Mon Sep 17 00:00:00 2001 From: PerishCode <perishcode@gmail.com> Date: Mon, 15 Jun 2026 15:06:38 +0800 Subject: [PATCH 08/20] seal: validate literal frame events --- app/src/core/seal/ground.rs | 3 + app/src/core/seal/ground/frame.rs | 92 +++++++++++++++++++++++++++++++ app/tests/seal_frame.rs | 90 ++++++++++++++++++++++++++++++ 3 files changed, 185 insertions(+) create mode 100644 app/src/core/seal/ground/frame.rs create mode 100644 app/tests/seal_frame.rs diff --git a/app/src/core/seal/ground.rs b/app/src/core/seal/ground.rs index 76164af..8c5bd60 100644 --- a/app/src/core/seal/ground.rs +++ b/app/src/core/seal/ground.rs @@ -4,6 +4,8 @@ use super::{ span::Span, }; +mod frame; + #[derive(Debug, Clone, PartialEq)] pub struct GroundOutput { pub file: GroundFile, @@ -418,6 +420,7 @@ fn reject_comparison_chain(expr: &RawExpr, diagnostics: &mut Vec<Diagnostic>) { } } RawExprKind::StreamFlow { left, right, .. } => { + frame::validate_frame_event(expr, diagnostics); reject_comparison_chain(left, diagnostics); reject_comparison_chain(right, diagnostics); } diff --git a/app/src/core/seal/ground/frame.rs b/app/src/core/seal/ground/frame.rs new file mode 100644 index 0000000..314f30a --- /dev/null +++ b/app/src/core/seal/ground/frame.rs @@ -0,0 +1,92 @@ +use crate::core::seal::{ + ast::{RawExpr, RawExprKind, RawLiteral, RawMapEntry}, + diag::Diagnostic, + span::Span, +}; + +pub(super) fn validate_frame_event(expr: &RawExpr, diagnostics: &mut Vec<Diagnostic>) { + let RawExprKind::StreamFlow { left, right, .. } = &expr.kind else { + return; + }; + if !matches!(&right.kind, RawExprKind::Channel(name) if name == "frame") { + return; + } + let RawExprKind::Map(entries) = &left.kind else { + return; + }; + + let Some(event_type) = string_field(entries, "type") else { + diagnostics.push(Diagnostic::new( + left.span, + "frame event map must include string literal field 'type'", + )); + return; + }; + + match event_type { + "ok" => {} + "failed" => require_field(entries, "exit", left.span, diagnostics), + "fault" => require_field(entries, "fault", left.span, diagnostics), + "cancelled" => { + require_field(entries, "source", left.span, diagnostics); + require_field(entries, "signal", left.span, diagnostics); + } + "cleanup" => validate_cleanup(entries, left.span, diagnostics), + _ => diagnostics.push(Diagnostic::new( + left.span, + format!("unknown frame event type '{event_type}'"), + )), + } +} + +fn validate_cleanup(entries: &[RawMapEntry], span: Span, diagnostics: &mut Vec<Diagnostic>) { + let Some(run) = field(entries, "run") else { + diagnostics.push(Diagnostic::new( + span, + "cleanup frame event requires field 'run'", + )); + return; + }; + if !matches!(run.kind, RawExprKind::Lambda(_)) { + diagnostics.push(Diagnostic::new( + run.span, + "cleanup frame event field 'run' must be a lambda", + )); + } +} + +fn require_field( + entries: &[RawMapEntry], + key: &str, + span: Span, + diagnostics: &mut Vec<Diagnostic>, +) { + if field(entries, key).is_none() { + diagnostics.push(Diagnostic::new( + span, + format!("frame event type requires field '{key}'"), + )); + } +} + +fn string_field<'a>(entries: &'a [RawMapEntry], key: &str) -> Option<&'a str> { + let field = field(entries, key)?; + let RawExprKind::Literal(RawLiteral::String(value)) = &field.kind else { + return None; + }; + Some(string_content(value)) +} + +fn string_content(value: &str) -> &str { + value + .strip_prefix('"') + .and_then(|value| value.strip_suffix('"')) + .unwrap_or(value) +} + +fn field<'a>(entries: &'a [RawMapEntry], key: &str) -> Option<&'a RawExpr> { + entries + .iter() + .find(|entry| entry.key == key) + .map(|entry| &entry.value) +} diff --git a/app/tests/seal_frame.rs b/app/tests/seal_frame.rs new file mode 100644 index 0000000..2653659 --- /dev/null +++ b/app/tests/seal_frame.rs @@ -0,0 +1,90 @@ +use runseal::core::seal::{ground, parse}; + +#[test] +fn cleanup_frame_event() { + let output = parse( + r#" +{ + type: "cleanup", + run: () => { + @file.remove(tmp) + }, +} >> #frame +"#, + ); + + assert!(output.diagnostics.is_empty()); + let grounded = ground::ground(&output.file); + assert!(grounded.diagnostics.is_empty()); +} + +#[test] +fn cleanup_requires_run_lambda() { + let missing = parse( + r#" +{ + type: "cleanup", +} >> #frame +"#, + ); + assert!(missing.diagnostics.is_empty()); + let grounded = ground::ground(&missing.file); + assert_eq!(grounded.diagnostics.len(), 1); + assert_eq!( + grounded.diagnostics[0].message, + "cleanup frame event requires field 'run'" + ); + + let not_lambda = parse( + r#" +{ + type: "cleanup", + run: "tmp-cleanup", +} >> #frame +"#, + ); + assert!(not_lambda.diagnostics.is_empty()); + let grounded = ground::ground(¬_lambda.file); + assert_eq!(grounded.diagnostics.len(), 1); + assert_eq!( + grounded.diagnostics[0].message, + "cleanup frame event field 'run' must be a lambda" + ); +} + +#[test] +fn frame_event_required_fields() { + let valid = parse( + r#" +{ type: "ok" } >> #frame + +{ + type: "failed", + exit: { code: 1, signal: null }, +} >> #frame + +{ + type: "fault", + fault: { kind: "shape", message: "bad", data: {} }, +} >> #frame + +{ + type: "cancelled", + source: "operator", + signal: "interrupt", +} >> #frame +"#, + ); + assert!(valid.diagnostics.is_empty()); + let grounded = ground::ground(&valid.file); + assert!(grounded.diagnostics.is_empty()); + + let invalid = parse(r#"{ type: "failed" } >> #frame"#); + assert!(invalid.diagnostics.is_empty()); + let grounded = ground::ground(&invalid.file); + assert_eq!(grounded.diagnostics.len(), 1); + assert_eq!( + grounded.diagnostics[0].message, + "frame event type requires field 'exit'" + ); +} From b966c74aff45289ae238ad3a9733f0c3e8bc4f36 Mon Sep 17 00:00:00 2001 From: PerishCode <perishcode@gmail.com> Date: Mon, 15 Jun 2026 15:09:34 +0800 Subject: [PATCH 09/20] seal: sharpen frame event diagnostics --- app/src/core/seal/ground/frame.rs | 16 +++++++++---- app/tests/seal_frame.rs | 39 +++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/app/src/core/seal/ground/frame.rs b/app/src/core/seal/ground/frame.rs index 314f30a..a6b9d70 100644 --- a/app/src/core/seal/ground/frame.rs +++ b/app/src/core/seal/ground/frame.rs @@ -15,10 +15,17 @@ pub(super) fn validate_frame_event(expr: &RawExpr, diagnostics: &mut Vec<Diagnos return; }; - let Some(event_type) = string_field(entries, "type") else { + let Some(type_value) = field(entries, "type") else { diagnostics.push(Diagnostic::new( left.span, - "frame event map must include string literal field 'type'", + "frame event map must include field 'type'", + )); + return; + }; + let Some(event_type) = string_literal(type_value) else { + diagnostics.push(Diagnostic::new( + type_value.span, + "frame event field 'type' must be a string literal", )); return; }; @@ -69,9 +76,8 @@ fn require_field( } } -fn string_field<'a>(entries: &'a [RawMapEntry], key: &str) -> Option<&'a str> { - let field = field(entries, key)?; - let RawExprKind::Literal(RawLiteral::String(value)) = &field.kind else { +fn string_literal(expr: &RawExpr) -> Option<&str> { + let RawExprKind::Literal(RawLiteral::String(value)) = &expr.kind else { return None; }; Some(string_content(value)) diff --git a/app/tests/seal_frame.rs b/app/tests/seal_frame.rs index 2653659..342b965 100644 --- a/app/tests/seal_frame.rs +++ b/app/tests/seal_frame.rs @@ -88,3 +88,42 @@ fn frame_event_required_fields() { "frame event type requires field 'exit'" ); } + +#[test] +fn frame_event_type_diagnostics() { + let missing = parse(r#"{ exit: { code: 0, signal: null } } >> #frame"#); + assert!(missing.diagnostics.is_empty()); + let grounded = ground::ground(&missing.file); + assert_eq!(grounded.diagnostics.len(), 1); + assert_eq!( + grounded.diagnostics[0].message, + "frame event map must include field 'type'" + ); + + let non_string = parse(r#"{ type: status } >> #frame"#); + assert!(non_string.diagnostics.is_empty()); + let grounded = ground::ground(&non_string.file); + assert_eq!(grounded.diagnostics.len(), 1); + assert_eq!( + grounded.diagnostics[0].message, + "frame event field 'type' must be a string literal" + ); + + let unknown = parse(r#"{ type: "later" } >> #frame"#); + assert!(unknown.diagnostics.is_empty()); + let grounded = ground::ground(&unknown.file); + assert_eq!(grounded.diagnostics.len(), 1); + assert_eq!( + grounded.diagnostics[0].message, + "unknown frame event type 'later'" + ); +} + +#[test] +fn dynamic_frame_event() { + let output = parse("event >> #frame"); + + assert!(output.diagnostics.is_empty()); + let grounded = ground::ground(&output.file); + assert!(grounded.diagnostics.is_empty()); +} From 0138276caa925d8ef73db84c9038c0e4c6876d7b Mon Sep 17 00:00:00 2001 From: PerishCode <perishcode@gmail.com> Date: Mon, 15 Jun 2026 15:15:21 +0800 Subject: [PATCH 10/20] seal: validate labeled call arguments --- app/src/core/seal/ground.rs | 2 + app/src/core/seal/ground/call.rs | 58 +++++++++++++++++++++ app/tests/{seal_frame.rs => seal_ground.rs} | 55 +++++++++++++++++++ 3 files changed, 115 insertions(+) create mode 100644 app/src/core/seal/ground/call.rs rename app/tests/{seal_frame.rs => seal_ground.rs} (68%) diff --git a/app/src/core/seal/ground.rs b/app/src/core/seal/ground.rs index 8c5bd60..59a2b98 100644 --- a/app/src/core/seal/ground.rs +++ b/app/src/core/seal/ground.rs @@ -4,6 +4,7 @@ use super::{ span::Span, }; +mod call; mod frame; #[derive(Debug, Clone, PartialEq)] @@ -377,6 +378,7 @@ fn reject_comparison_chain(expr: &RawExpr, diagnostics: &mut Vec<Diagnostic>) { reject_comparison_chain(expr, diagnostics); } RawExprKind::Call { callee, args } => { + call::validate_args(callee, args, diagnostics); reject_comparison_chain(callee, diagnostics); for arg in args { reject_comparison_chain(&arg.value, diagnostics); diff --git a/app/src/core/seal/ground/call.rs b/app/src/core/seal/ground/call.rs new file mode 100644 index 0000000..6690819 --- /dev/null +++ b/app/src/core/seal/ground/call.rs @@ -0,0 +1,58 @@ +use std::collections::BTreeSet; + +use crate::core::seal::{ + ast::{RawArg, RawExpr, RawExprKind}, + diag::Diagnostic, +}; + +pub(super) fn validate_args(callee: &RawExpr, args: &[RawArg], diagnostics: &mut Vec<Diagnostic>) { + reject_duplicate_labels(args, diagnostics); + + let labeled = args.iter().filter(|arg| arg.label.is_some()); + if callee_is_call_forward(callee) { + for arg in labeled { + diagnostics.push(Diagnostic::new( + arg.span, + "@call.forward arguments are positional-only", + )); + } + return; + } + + if callee_accepts_labels(callee) { + return; + } + + for arg in labeled { + diagnostics.push(Diagnostic::new( + arg.span, + "labeled call arguments require a static method or @ helper callee", + )); + } +} + +fn reject_duplicate_labels(args: &[RawArg], diagnostics: &mut Vec<Diagnostic>) { + let mut seen = BTreeSet::new(); + for arg in args { + let Some(label) = &arg.label else { + continue; + }; + if !seen.insert(label) { + diagnostics.push(Diagnostic::new( + arg.span, + format!("duplicate labeled argument '{label}'"), + )); + } + } +} + +fn callee_accepts_labels(callee: &RawExpr) -> bool { + matches!(&callee.kind, RawExprKind::Ident(_) | RawExprKind::AtName(_)) +} + +fn callee_is_call_forward(callee: &RawExpr) -> bool { + matches!( + &callee.kind, + RawExprKind::AtName(parts) if parts.len() == 2 && parts[0] == "call" && parts[1] == "forward" + ) +} diff --git a/app/tests/seal_frame.rs b/app/tests/seal_ground.rs similarity index 68% rename from app/tests/seal_frame.rs rename to app/tests/seal_ground.rs index 342b965..7518801 100644 --- a/app/tests/seal_frame.rs +++ b/app/tests/seal_ground.rs @@ -127,3 +127,58 @@ fn dynamic_frame_event() { let grounded = ground::ground(&output.file); assert!(grounded.diagnostics.is_empty()); } + +#[test] +fn labeled_static_calls() { + let output = parse( + r#" +deploy(channel: "beta") +@fs.mkdir(tmp, mode: 700) +"#, + ); + + assert!(output.diagnostics.is_empty()); + let grounded = ground::ground(&output.file); + assert!(grounded.diagnostics.is_empty()); +} + +#[test] +fn labeled_dynamic_call() { + let output = parse(r#"make_runner()(mode: "fast")"#); + + assert!(output.diagnostics.is_empty()); + let grounded = ground::ground(&output.file); + assert_eq!(grounded.diagnostics.len(), 1); + assert_eq!( + grounded.diagnostics[0].message, + "labeled call arguments require a static method or @ helper callee" + ); +} + +#[test] +fn forward_is_positional() { + let output = parse(r#"@call.forward(target: deploy, args: ["prod"])"#); + + assert!(output.diagnostics.is_empty()); + let grounded = ground::ground(&output.file); + assert_eq!(grounded.diagnostics.len(), 2); + assert!( + grounded + .diagnostics + .iter() + .all(|diagnostic| diagnostic.message == "@call.forward arguments are positional-only") + ); +} + +#[test] +fn duplicate_labels() { + let output = parse(r#"@fs.mkdir(tmp, mode: 700, mode: 755)"#); + + assert!(output.diagnostics.is_empty()); + let grounded = ground::ground(&output.file); + assert_eq!(grounded.diagnostics.len(), 1); + assert_eq!( + grounded.diagnostics[0].message, + "duplicate labeled argument 'mode'" + ); +} From 714263013e454c41a63086412f2b826d8306c715 Mon Sep 17 00:00:00 2001 From: PerishCode <perishcode@gmail.com> Date: Mon, 15 Jun 2026 15:19:27 +0800 Subject: [PATCH 11/20] seal: validate loop control statements --- app/src/core/seal/ground.rs | 3 + app/src/core/seal/ground/control.rs | 145 ++++++++++++++++++++++++++++ app/tests/seal_ground.rs | 54 +++++++++++ 3 files changed, 202 insertions(+) create mode 100644 app/src/core/seal/ground/control.rs diff --git a/app/src/core/seal/ground.rs b/app/src/core/seal/ground.rs index 59a2b98..df4b05f 100644 --- a/app/src/core/seal/ground.rs +++ b/app/src/core/seal/ground.rs @@ -5,6 +5,7 @@ use super::{ }; mod call; +mod control; mod frame; #[derive(Debug, Clone, PartialEq)] @@ -56,6 +57,7 @@ pub fn ground(file: &SourceFile) -> GroundOutput { match &item.kind { RawItemKind::Comment(_) => {} RawItemKind::Method(method) => { + control::validate_block(&method.body, false, &mut diagnostics); let tail = method_tail_output(&method.body, &mut diagnostics); nodes.push(GroundNode::Method { name: method.name.clone(), @@ -64,6 +66,7 @@ pub fn ground(file: &SourceFile) -> GroundOutput { }); } RawItemKind::Statement(statement) => { + control::validate_statement(statement, false, &mut diagnostics); nodes.push(ground_statement(statement, &mut diagnostics)); } RawItemKind::Error => nodes.push(GroundNode::Error { span: item.span }), diff --git a/app/src/core/seal/ground/control.rs b/app/src/core/seal/ground/control.rs new file mode 100644 index 0000000..6592d29 --- /dev/null +++ b/app/src/core/seal/ground/control.rs @@ -0,0 +1,145 @@ +use crate::core::seal::{ + ast::{RawBlock, RawExpr, RawExprKind, RawItemKind, RawStatement, RawStatementKind}, + diag::Diagnostic, +}; + +pub(super) fn validate_block(block: &RawBlock, in_loop: bool, diagnostics: &mut Vec<Diagnostic>) { + for item in &block.items { + match &item.kind { + RawItemKind::Statement(statement) => { + validate_statement(statement, in_loop, diagnostics) + } + RawItemKind::Method(method) => validate_block(&method.body, false, diagnostics), + RawItemKind::Comment(_) | RawItemKind::Error => {} + } + } +} + +pub(super) fn validate_statement( + statement: &RawStatement, + in_loop: bool, + diagnostics: &mut Vec<Diagnostic>, +) { + match &statement.kind { + RawStatementKind::Break if !in_loop => { + diagnostics.push(Diagnostic::new(statement.span, "'break' outside loop")); + } + RawStatementKind::Continue if !in_loop => { + diagnostics.push(Diagnostic::new(statement.span, "'continue' outside loop")); + } + RawStatementKind::For { iterable, body, .. } => { + validate_expr(iterable, in_loop, diagnostics); + validate_block(body, true, diagnostics); + } + RawStatementKind::While { condition, body } => { + validate_expr(condition, in_loop, diagnostics); + validate_block(body, true, diagnostics); + } + RawStatementKind::If { + branches, + else_branch, + } => { + for branch in branches { + validate_expr(&branch.condition, in_loop, diagnostics); + validate_block(&branch.body, in_loop, diagnostics); + } + if let Some(block) = else_branch { + validate_block(block, in_loop, diagnostics); + } + } + RawStatementKind::WithEnv { bindings, body } => { + for binding in bindings { + validate_expr(&binding.value, in_loop, diagnostics); + } + validate_block(body, in_loop, diagnostics); + } + RawStatementKind::Let { value, .. } => validate_expr(value, in_loop, diagnostics), + RawStatementKind::Assign { target, value } => { + validate_expr(target, in_loop, diagnostics); + validate_expr(value, in_loop, diagnostics); + } + RawStatementKind::Expr(expr) | RawStatementKind::Effect(expr) => { + validate_expr(expr, in_loop, diagnostics); + } + RawStatementKind::Break | RawStatementKind::Continue | RawStatementKind::Error => {} + } +} + +fn validate_expr(expr: &RawExpr, in_loop: bool, diagnostics: &mut Vec<Diagnostic>) { + match &expr.kind { + RawExprKind::Unary { expr, .. } | RawExprKind::Group(expr) => { + validate_expr(expr, in_loop, diagnostics); + } + RawExprKind::Binary { left, right, .. } | RawExprKind::StreamFlow { left, right, .. } => { + validate_expr(left, in_loop, diagnostics); + validate_expr(right, in_loop, diagnostics); + } + RawExprKind::Call { callee, args } => { + validate_expr(callee, in_loop, diagnostics); + for arg in args { + validate_expr(&arg.value, in_loop, diagnostics); + } + } + RawExprKind::BlockCall { callee, block } => { + validate_expr(callee, in_loop, diagnostics); + validate_block(block, in_loop, diagnostics); + } + RawExprKind::ReceiverCall { receiver, args, .. } => { + validate_expr(receiver, in_loop, diagnostics); + for arg in args { + validate_expr(&arg.value, in_loop, diagnostics); + } + } + RawExprKind::Lambda(lambda) => { + for param in &lambda.params { + if let Some(default) = ¶m.default { + validate_expr(default, in_loop, diagnostics); + } + } + validate_block(&lambda.body, false, diagnostics); + } + RawExprKind::Array(items) => { + for item in items { + validate_expr(item, in_loop, diagnostics); + } + } + RawExprKind::Map(entries) => { + for entry in entries { + validate_expr(&entry.value, in_loop, diagnostics); + } + } + RawExprKind::Match(match_expr) => { + validate_expr(&match_expr.scrutinee, in_loop, diagnostics); + for arm in &match_expr.arms { + match &arm.body { + crate::core::seal::ast::RawMatchArmBody::Expr(expr) => { + validate_expr(expr, in_loop, diagnostics); + } + crate::core::seal::ast::RawMatchArmBody::Block(block) => { + validate_block(block, in_loop, diagnostics); + } + } + } + } + RawExprKind::Process(process) => { + for arg in process.program.iter().chain(process.args.iter()) { + if let crate::core::seal::ast::RawProcessArgKind::Spread(expr) = &arg.kind { + validate_expr(expr, in_loop, diagnostics); + } + if let crate::core::seal::ast::RawProcessArgKind::Word(parts) = &arg.kind { + for part in parts { + if let crate::core::seal::ast::RawProcessPart::Interpolation(expr) = part { + validate_expr(expr, in_loop, diagnostics); + } + } + } + } + } + RawExprKind::Ident(_) + | RawExprKind::Literal(_) + | RawExprKind::AtName(_) + | RawExprKind::Env(_) + | RawExprKind::Channel(_) + | RawExprKind::Error => {} + } +} diff --git a/app/tests/seal_ground.rs b/app/tests/seal_ground.rs index 7518801..0c0adf2 100644 --- a/app/tests/seal_ground.rs +++ b/app/tests/seal_ground.rs @@ -182,3 +182,57 @@ fn duplicate_labels() { "duplicate labeled argument 'mode'" ); } + +#[test] +fn loop_control_statements() { + let valid = parse( + r#" +while true { + continue + break +} + +for item in items { + match item { + "stop" => { + break + } + _ => "ok" + } +} +"#, + ); + assert!(valid.diagnostics.is_empty()); + let grounded = ground::ground(&valid.file); + assert!(grounded.diagnostics.is_empty()); + + let invalid = parse( + r#" +break +continue +"#, + ); + assert!(invalid.diagnostics.is_empty()); + let grounded = ground::ground(&invalid.file); + assert_eq!(grounded.diagnostics.len(), 2); + assert_eq!(grounded.diagnostics[0].message, "'break' outside loop"); + assert_eq!(grounded.diagnostics[1].message, "'continue' outside loop"); +} + +#[test] +fn lambda_loop_context() { + let output = parse( + r#" +while true { + @call.stdio(call, (stdin, stdout, stderr) => { + break + }) +} +"#, + ); + + assert!(output.diagnostics.is_empty()); + let grounded = ground::ground(&output.file); + assert_eq!(grounded.diagnostics.len(), 1); + assert_eq!(grounded.diagnostics[0].message, "'break' outside loop"); +} From 4a05219085d70009ceffbb06a1a24dca17073d34 Mon Sep 17 00:00:00 2001 From: PerishCode <perishcode@gmail.com> Date: Mon, 15 Jun 2026 15:23:40 +0800 Subject: [PATCH 12/20] seal: reject duplicate map keys --- app/src/core/seal/ground.rs | 3 +++ app/src/core/seal/ground/map.rs | 40 +++++++++++++++++++++++++++++++++ app/tests/seal_ground.rs | 36 +++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+) create mode 100644 app/src/core/seal/ground/map.rs diff --git a/app/src/core/seal/ground.rs b/app/src/core/seal/ground.rs index df4b05f..aea3c5b 100644 --- a/app/src/core/seal/ground.rs +++ b/app/src/core/seal/ground.rs @@ -7,6 +7,7 @@ use super::{ mod call; mod control; mod frame; +mod map; #[derive(Debug, Clone, PartialEq)] pub struct GroundOutput { @@ -420,6 +421,7 @@ fn reject_comparison_chain(expr: &RawExpr, diagnostics: &mut Vec<Diagnostic>) { } } RawExprKind::Map(entries) => { + map::validate_expr_entries(entries, diagnostics); for entry in entries { reject_comparison_chain(&entry.value, diagnostics); } @@ -454,6 +456,7 @@ fn reject_pattern_comparisons(pattern: &super::ast::RawPattern, diagnostics: &mu match &pattern.kind { super::ast::RawPatternKind::Expr(expr) => reject_comparison_chain(expr, diagnostics), super::ast::RawPatternKind::Map(entries) => { + map::validate_pattern_entries(entries, diagnostics); for entry in entries { reject_pattern_comparisons(&entry.pattern, diagnostics); } diff --git a/app/src/core/seal/ground/map.rs b/app/src/core/seal/ground/map.rs new file mode 100644 index 0000000..af13b50 --- /dev/null +++ b/app/src/core/seal/ground/map.rs @@ -0,0 +1,40 @@ +use std::collections::BTreeMap; + +use crate::core::seal::{ + ast::{RawMapEntry, RawPatternEntry}, + diag::Diagnostic, +}; + +pub(super) fn validate_expr_entries(entries: &[RawMapEntry], diagnostics: &mut Vec<Diagnostic>) { + let mut seen = BTreeMap::new(); + for entry in entries { + let key = key_name(&entry.key); + if seen.insert(key, entry.span).is_some() { + diagnostics.push(Diagnostic::new( + entry.span, + format!("duplicate map key '{key}'"), + )); + } + } +} + +pub(super) fn validate_pattern_entries( + entries: &[RawPatternEntry], + diagnostics: &mut Vec<Diagnostic>, +) { + let mut seen = BTreeMap::new(); + for entry in entries { + if seen.insert(entry.key.as_str(), entry.span).is_some() { + diagnostics.push(Diagnostic::new( + entry.span, + format!("duplicate map pattern key '{}'", entry.key), + )); + } + } +} + +fn key_name(key: &str) -> &str { + key.strip_prefix('"') + .and_then(|key| key.strip_suffix('"')) + .unwrap_or(key) +} diff --git a/app/tests/seal_ground.rs b/app/tests/seal_ground.rs index 0c0adf2..682ced9 100644 --- a/app/tests/seal_ground.rs +++ b/app/tests/seal_ground.rs @@ -236,3 +236,39 @@ while true { assert_eq!(grounded.diagnostics.len(), 1); assert_eq!(grounded.diagnostics[0].message, "'break' outside loop"); } + +#[test] +fn duplicate_map_keys() { + let output = parse( + r#" +let config = { + mode: "fast", + "mode": "slow", +} +"#, + ); + + assert!(output.diagnostics.is_empty()); + let grounded = ground::ground(&output.file); + assert_eq!(grounded.diagnostics.len(), 1); + assert_eq!(grounded.diagnostics[0].message, "duplicate map key 'mode'"); +} + +#[test] +fn duplicate_pattern_keys() { + let output = parse( + r#" +match event { + { status: "failed", status: "faulted" } => "bad" +} +"#, + ); + + assert!(output.diagnostics.is_empty()); + let grounded = ground::ground(&output.file); + assert_eq!(grounded.diagnostics.len(), 1); + assert_eq!( + grounded.diagnostics[0].message, + "duplicate map pattern key 'status'" + ); +} From 014379fc49c360b9b6eda150671b6749451d6f66 Mon Sep 17 00:00:00 2001 From: PerishCode <perishcode@gmail.com> Date: Mon, 15 Jun 2026 15:32:35 +0800 Subject: [PATCH 13/20] seal: validate call forward shape --- app/src/core/seal/ground.rs | 2 +- app/src/core/seal/ground/call.rs | 46 +++++++++++++++++++++++++++++++- app/tests/seal_ground.rs | 32 ++++++++++++++++++++++ 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/app/src/core/seal/ground.rs b/app/src/core/seal/ground.rs index aea3c5b..b6422be 100644 --- a/app/src/core/seal/ground.rs +++ b/app/src/core/seal/ground.rs @@ -382,7 +382,7 @@ fn reject_comparison_chain(expr: &RawExpr, diagnostics: &mut Vec<Diagnostic>) { reject_comparison_chain(expr, diagnostics); } RawExprKind::Call { callee, args } => { - call::validate_args(callee, args, diagnostics); + call::validate_args(expr.span, callee, args, diagnostics); reject_comparison_chain(callee, diagnostics); for arg in args { reject_comparison_chain(&arg.value, diagnostics); diff --git a/app/src/core/seal/ground/call.rs b/app/src/core/seal/ground/call.rs index 6690819..8a64cf0 100644 --- a/app/src/core/seal/ground/call.rs +++ b/app/src/core/seal/ground/call.rs @@ -3,9 +3,15 @@ use std::collections::BTreeSet; use crate::core::seal::{ ast::{RawArg, RawExpr, RawExprKind}, diag::Diagnostic, + span::Span, }; -pub(super) fn validate_args(callee: &RawExpr, args: &[RawArg], diagnostics: &mut Vec<Diagnostic>) { +pub(super) fn validate_args( + call_span: Span, + callee: &RawExpr, + args: &[RawArg], + diagnostics: &mut Vec<Diagnostic>, +) { reject_duplicate_labels(args, diagnostics); let labeled = args.iter().filter(|arg| arg.label.is_some()); @@ -16,6 +22,7 @@ pub(super) fn validate_args(callee: &RawExpr, args: &[RawArg], diagnostics: &mut "@call.forward arguments are positional-only", )); } + validate_call_forward_shape(call_span, args, diagnostics); return; } @@ -31,6 +38,29 @@ pub(super) fn validate_args(callee: &RawExpr, args: &[RawArg], diagnostics: &mut } } +fn validate_call_forward_shape( + call_span: Span, + args: &[RawArg], + diagnostics: &mut Vec<Diagnostic>, +) { + if args.len() != 2 { + diagnostics.push(Diagnostic::new( + call_span, + "@call.forward expects exactly 2 arguments", + )); + } + + let Some(bundle) = args.get(1) else { + return; + }; + if static_non_array(&bundle.value) { + diagnostics.push(Diagnostic::new( + bundle.value.span, + "@call.forward second argument must be an array bundle", + )); + } +} + fn reject_duplicate_labels(args: &[RawArg], diagnostics: &mut Vec<Diagnostic>) { let mut seen = BTreeSet::new(); for arg in args { @@ -56,3 +86,17 @@ fn callee_is_call_forward(callee: &RawExpr) -> bool { RawExprKind::AtName(parts) if parts.len() == 2 && parts[0] == "call" && parts[1] == "forward" ) } + +fn static_non_array(expr: &RawExpr) -> bool { + match &expr.kind { + RawExprKind::Array(_) => false, + RawExprKind::Group(expr) => static_non_array(expr), + RawExprKind::Literal(_) + | RawExprKind::Map(_) + | RawExprKind::Lambda(_) + | RawExprKind::Env(_) + | RawExprKind::Channel(_) + | RawExprKind::Process(_) => true, + _ => false, + } +} diff --git a/app/tests/seal_ground.rs b/app/tests/seal_ground.rs index 682ced9..4d7a7f0 100644 --- a/app/tests/seal_ground.rs +++ b/app/tests/seal_ground.rs @@ -170,6 +170,38 @@ fn forward_is_positional() { ); } +#[test] +fn forward_shape() { + let valid = parse( + r#" +@call.forward(deploy, ["prod"]) +@call.forward(deploy, args) +"#, + ); + + assert!(valid.diagnostics.is_empty()); + let grounded = ground::ground(&valid.file); + assert!(grounded.diagnostics.is_empty()); + + let wrong_arity = parse(r#"@call.forward(deploy)"#); + assert!(wrong_arity.diagnostics.is_empty()); + let grounded = ground::ground(&wrong_arity.file); + assert_eq!(grounded.diagnostics.len(), 1); + assert_eq!( + grounded.diagnostics[0].message, + "@call.forward expects exactly 2 arguments" + ); + + let non_array_bundle = parse(r#"@call.forward(deploy, "prod")"#); + assert!(non_array_bundle.diagnostics.is_empty()); + let grounded = ground::ground(&non_array_bundle.file); + assert_eq!(grounded.diagnostics.len(), 1); + assert_eq!( + grounded.diagnostics[0].message, + "@call.forward second argument must be an array bundle" + ); +} + #[test] fn duplicate_labels() { let output = parse(r#"@fs.mkdir(tmp, mode: 700, mode: 755)"#); From bb908dd8793868c221ddb9a9578ebb1c64ea39d7 Mon Sep 17 00:00:00 2001 From: PerishCode <perishcode@gmail.com> Date: Mon, 15 Jun 2026 15:37:11 +0800 Subject: [PATCH 14/20] seal: validate call handler shape --- app/src/core/seal/ground/call.rs | 89 +++++++++++++++++++++++++------- app/tests/seal_ground.rs | 42 +++++++++++++++ 2 files changed, 111 insertions(+), 20 deletions(-) diff --git a/app/src/core/seal/ground/call.rs b/app/src/core/seal/ground/call.rs index 8a64cf0..a47b1bf 100644 --- a/app/src/core/seal/ground/call.rs +++ b/app/src/core/seal/ground/call.rs @@ -14,23 +14,28 @@ pub(super) fn validate_args( ) { reject_duplicate_labels(args, diagnostics); - let labeled = args.iter().filter(|arg| arg.label.is_some()); - if callee_is_call_forward(callee) { - for arg in labeled { - diagnostics.push(Diagnostic::new( - arg.span, - "@call.forward arguments are positional-only", - )); + if let Some(call_name) = callee_call_name(callee) { + match call_name { + "forward" => { + for arg in args.iter().filter(|arg| arg.label.is_some()) { + diagnostics.push(Diagnostic::new( + arg.span, + "@call.forward arguments are positional-only", + )); + } + validate_call_forward(call_span, args, diagnostics); + } + "stdio" => validate_io_call("@call.stdio", 3, call_span, args, diagnostics), + "completion" => validate_io_call("@call.completion", 4, call_span, args, diagnostics), + _ => {} } - validate_call_forward_shape(call_span, args, diagnostics); - return; } if callee_accepts_labels(callee) { return; } - for arg in labeled { + for arg in args.iter().filter(|arg| arg.label.is_some()) { diagnostics.push(Diagnostic::new( arg.span, "labeled call arguments require a static method or @ helper callee", @@ -38,11 +43,7 @@ pub(super) fn validate_args( } } -fn validate_call_forward_shape( - call_span: Span, - args: &[RawArg], - diagnostics: &mut Vec<Diagnostic>, -) { +fn validate_call_forward(call_span: Span, args: &[RawArg], diagnostics: &mut Vec<Diagnostic>) { if args.len() != 2 { diagnostics.push(Diagnostic::new( call_span, @@ -61,6 +62,40 @@ fn validate_call_forward_shape( } } +fn validate_io_call( + name: &str, + params: usize, + call_span: Span, + args: &[RawArg], + diagnostics: &mut Vec<Diagnostic>, +) { + if args.len() != 2 { + diagnostics.push(Diagnostic::new( + call_span, + format!("{name} expects exactly 2 arguments"), + )); + } + + let Some(handler) = args.get(1) else { + return; + }; + let RawExprKind::Lambda(lambda) = &handler.value.kind else { + if static_non_lambda(&handler.value) { + diagnostics.push(Diagnostic::new( + handler.value.span, + format!("{name} second argument must be a handler lambda"), + )); + } + return; + }; + if lambda.params.len() != params { + diagnostics.push(Diagnostic::new( + handler.value.span, + format!("{name} handler must accept exactly {params} parameters"), + )); + } +} + fn reject_duplicate_labels(args: &[RawArg], diagnostics: &mut Vec<Diagnostic>) { let mut seen = BTreeSet::new(); for arg in args { @@ -80,11 +115,25 @@ fn callee_accepts_labels(callee: &RawExpr) -> bool { matches!(&callee.kind, RawExprKind::Ident(_) | RawExprKind::AtName(_)) } -fn callee_is_call_forward(callee: &RawExpr) -> bool { - matches!( - &callee.kind, - RawExprKind::AtName(parts) if parts.len() == 2 && parts[0] == "call" && parts[1] == "forward" - ) +fn callee_call_name(callee: &RawExpr) -> Option<&str> { + match &callee.kind { + RawExprKind::AtName(parts) if parts.len() == 2 && parts[0] == "call" => { + Some(parts[1].as_str()) + } + _ => None, + } +} + +fn static_non_lambda(expr: &RawExpr) -> bool { + match &expr.kind { + RawExprKind::Lambda(_) => false, + RawExprKind::Group(expr) => static_non_lambda(expr), + RawExprKind::Ident(_) + | RawExprKind::AtName(_) + | RawExprKind::Call { .. } + | RawExprKind::ReceiverCall { .. } => false, + _ => true, + } } fn static_non_array(expr: &RawExpr) -> bool { diff --git a/app/tests/seal_ground.rs b/app/tests/seal_ground.rs index 4d7a7f0..73c66b9 100644 --- a/app/tests/seal_ground.rs +++ b/app/tests/seal_ground.rs @@ -202,6 +202,48 @@ fn forward_shape() { ); } +#[test] +fn call_io_handler_shape() { + let valid = parse( + r#" +@call.stdio(call, (stdin, stdout, stderr) => {}) +@call.completion(call, (stdin, stdout, stderr, frame) => {}) +@call.stdio(call, handler) +"#, + ); + + assert!(valid.diagnostics.is_empty()); + let grounded = ground::ground(&valid.file); + assert!(grounded.diagnostics.is_empty()); + + let wrong_arity = parse(r#"@call.stdio(call)"#); + assert!(wrong_arity.diagnostics.is_empty()); + let grounded = ground::ground(&wrong_arity.file); + assert_eq!(grounded.diagnostics.len(), 1); + assert_eq!( + grounded.diagnostics[0].message, + "@call.stdio expects exactly 2 arguments" + ); + + let not_lambda = parse(r#"@call.completion(call, [])"#); + assert!(not_lambda.diagnostics.is_empty()); + let grounded = ground::ground(¬_lambda.file); + assert_eq!(grounded.diagnostics.len(), 1); + assert_eq!( + grounded.diagnostics[0].message, + "@call.completion second argument must be a handler lambda" + ); + + let wrong_params = parse(r#"@call.completion(call, (stdin, stdout, stderr) => {})"#); + assert!(wrong_params.diagnostics.is_empty()); + let grounded = ground::ground(&wrong_params.file); + assert_eq!(grounded.diagnostics.len(), 1); + assert_eq!( + grounded.diagnostics[0].message, + "@call.completion handler must accept exactly 4 parameters" + ); +} + #[test] fn duplicate_labels() { let output = parse(r#"@fs.mkdir(tmp, mode: 700, mode: 755)"#); From e53360e4284e30e85d51fe7637ba74224e6ef6af Mon Sep 17 00:00:00 2001 From: PerishCode <perishcode@gmail.com> Date: Mon, 15 Jun 2026 15:41:53 +0800 Subject: [PATCH 15/20] seal: validate completion chain handlers --- app/src/core/seal/ground.rs | 7 +++- app/src/core/seal/ground/call.rs | 57 +++++++++++++++++++++++++++++--- app/tests/seal_ground.rs | 45 +++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 6 deletions(-) diff --git a/app/src/core/seal/ground.rs b/app/src/core/seal/ground.rs index b6422be..472978e 100644 --- a/app/src/core/seal/ground.rs +++ b/app/src/core/seal/ground.rs @@ -409,7 +409,12 @@ fn reject_comparison_chain(expr: &RawExpr, diagnostics: &mut Vec<Diagnostic>) { } } } - RawExprKind::ReceiverCall { receiver, args, .. } => { + RawExprKind::ReceiverCall { + receiver, + method, + args, + } => { + call::validate_receiver(expr.span, method, args, diagnostics); reject_comparison_chain(receiver, diagnostics); for arg in args { reject_comparison_chain(&arg.value, diagnostics); diff --git a/app/src/core/seal/ground/call.rs b/app/src/core/seal/ground/call.rs index a47b1bf..d0b85eb 100644 --- a/app/src/core/seal/ground/call.rs +++ b/app/src/core/seal/ground/call.rs @@ -43,6 +43,35 @@ pub(super) fn validate_args( } } +pub(super) fn validate_receiver( + call_span: Span, + method: &str, + args: &[RawArg], + diagnostics: &mut Vec<Diagnostic>, +) { + let Some(params) = chain_params(method) else { + return; + }; + + if args.len() != 1 { + diagnostics.push(Diagnostic::new( + call_span, + format!("completion handler .{method} expects exactly 1 argument"), + )); + } + + let Some(handler) = args.first() else { + return; + }; + validate_handler( + &format!("completion handler .{method}"), + "argument", + params, + &handler.value, + diagnostics, + ); +} + fn validate_call_forward(call_span: Span, args: &[RawArg], diagnostics: &mut Vec<Diagnostic>) { if args.len() != 2 { diagnostics.push(Diagnostic::new( @@ -79,18 +108,28 @@ fn validate_io_call( let Some(handler) = args.get(1) else { return; }; - let RawExprKind::Lambda(lambda) = &handler.value.kind else { - if static_non_lambda(&handler.value) { + validate_handler(name, "second argument", params, &handler.value, diagnostics); +} + +fn validate_handler( + name: &str, + arg_name: &str, + params: usize, + handler: &RawExpr, + diagnostics: &mut Vec<Diagnostic>, +) { + let RawExprKind::Lambda(lambda) = &handler.kind else { + if static_non_lambda(handler) { diagnostics.push(Diagnostic::new( - handler.value.span, - format!("{name} second argument must be a handler lambda"), + handler.span, + format!("{name} {arg_name} must be a handler lambda"), )); } return; }; if lambda.params.len() != params { diagnostics.push(Diagnostic::new( - handler.value.span, + handler.span, format!("{name} handler must accept exactly {params} parameters"), )); } @@ -124,6 +163,14 @@ fn callee_call_name(callee: &RawExpr) -> Option<&str> { } } +fn chain_params(method: &str) -> Option<usize> { + match method { + "ok" | "always" => Some(1), + "failed" | "faulted" | "cancelled" => Some(2), + _ => None, + } +} + fn static_non_lambda(expr: &RawExpr) -> bool { match &expr.kind { RawExprKind::Lambda(_) => false, diff --git a/app/tests/seal_ground.rs b/app/tests/seal_ground.rs index 73c66b9..1c6e073 100644 --- a/app/tests/seal_ground.rs +++ b/app/tests/seal_ground.rs @@ -244,6 +244,51 @@ fn call_io_handler_shape() { ); } +#[test] +fn completion_chain_handler_shape() { + let valid = parse( + r#" +@call.completion(call, (stdin, stdout, stderr, frame) => {}) + .ok((completion) => {}) + .failed((exit, completion) => {}) + .faulted((faults, completion) => {}) + .cancelled((cancelled, completion) => {}) + .always(handler) +"#, + ); + + assert!(valid.diagnostics.is_empty()); + let grounded = ground::ground(&valid.file); + assert!(grounded.diagnostics.is_empty()); + + let wrong_arity = parse(r#"completion.ok()"#); + assert!(wrong_arity.diagnostics.is_empty()); + let grounded = ground::ground(&wrong_arity.file); + assert_eq!(grounded.diagnostics.len(), 1); + assert_eq!( + grounded.diagnostics[0].message, + "completion handler .ok expects exactly 1 argument" + ); + + let not_lambda = parse(r#"completion.always([])"#); + assert!(not_lambda.diagnostics.is_empty()); + let grounded = ground::ground(¬_lambda.file); + assert_eq!(grounded.diagnostics.len(), 1); + assert_eq!( + grounded.diagnostics[0].message, + "completion handler .always argument must be a handler lambda" + ); + + let wrong_params = parse(r#"completion.failed((completion) => {})"#); + assert!(wrong_params.diagnostics.is_empty()); + let grounded = ground::ground(&wrong_params.file); + assert_eq!(grounded.diagnostics.len(), 1); + assert_eq!( + grounded.diagnostics[0].message, + "completion handler .failed handler must accept exactly 2 parameters" + ); +} + #[test] fn duplicate_labels() { let output = parse(r#"@fs.mkdir(tmp, mode: 700, mode: 755)"#); From 433a043c50a6b81f0cea3ad307cabcd76ee823e0 Mon Sep 17 00:00:00 2001 From: PerishCode <perishcode@gmail.com> Date: Mon, 15 Jun 2026 15:45:37 +0800 Subject: [PATCH 16/20] seal: validate call exit shape --- app/src/core/seal/ground/call.rs | 38 +++++++++++++++++++++++++++ app/src/core/seal/ground/frame.rs | 21 ++++++++------- app/tests/seal_ground.rs | 43 +++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 9 deletions(-) diff --git a/app/src/core/seal/ground/call.rs b/app/src/core/seal/ground/call.rs index d0b85eb..7c40268 100644 --- a/app/src/core/seal/ground/call.rs +++ b/app/src/core/seal/ground/call.rs @@ -6,6 +6,8 @@ use crate::core::seal::{ span::Span, }; +use super::frame; + pub(super) fn validate_args( call_span: Span, callee: &RawExpr, @@ -27,6 +29,7 @@ pub(super) fn validate_args( } "stdio" => validate_io_call("@call.stdio", 3, call_span, args, diagnostics), "completion" => validate_io_call("@call.completion", 4, call_span, args, diagnostics), + "exit" => validate_call_exit(call_span, args, diagnostics), _ => {} } } @@ -91,6 +94,27 @@ fn validate_call_forward(call_span: Span, args: &[RawArg], diagnostics: &mut Vec } } +fn validate_call_exit(call_span: Span, args: &[RawArg], diagnostics: &mut Vec<Diagnostic>) { + if args.len() > 2 { + diagnostics.push(Diagnostic::new( + call_span, + "@call.exit expects at most 2 arguments", + )); + } + + let Some(event) = args.get(1) else { + return; + }; + if static_non_map(&event.value) { + diagnostics.push(Diagnostic::new( + event.value.span, + "@call.exit event argument must be a frame event map", + )); + return; + } + frame::validate_event_expr(&event.value, diagnostics); +} + fn validate_io_call( name: &str, params: usize, @@ -196,3 +220,17 @@ fn static_non_array(expr: &RawExpr) -> bool { _ => false, } } + +fn static_non_map(expr: &RawExpr) -> bool { + match &expr.kind { + RawExprKind::Map(_) => false, + RawExprKind::Group(expr) => static_non_map(expr), + RawExprKind::Literal(_) + | RawExprKind::Array(_) + | RawExprKind::Lambda(_) + | RawExprKind::Env(_) + | RawExprKind::Channel(_) + | RawExprKind::Process(_) => true, + _ => false, + } +} diff --git a/app/src/core/seal/ground/frame.rs b/app/src/core/seal/ground/frame.rs index a6b9d70..430385d 100644 --- a/app/src/core/seal/ground/frame.rs +++ b/app/src/core/seal/ground/frame.rs @@ -11,13 +11,16 @@ pub(super) fn validate_frame_event(expr: &RawExpr, diagnostics: &mut Vec<Diagnos if !matches!(&right.kind, RawExprKind::Channel(name) if name == "frame") { return; } - let RawExprKind::Map(entries) = &left.kind else { + validate_event_expr(left, diagnostics); +} + +pub(super) fn validate_event_expr(expr: &RawExpr, diagnostics: &mut Vec<Diagnostic>) { + let RawExprKind::Map(entries) = &expr.kind else { return; }; - let Some(type_value) = field(entries, "type") else { diagnostics.push(Diagnostic::new( - left.span, + expr.span, "frame event map must include field 'type'", )); return; @@ -32,15 +35,15 @@ pub(super) fn validate_frame_event(expr: &RawExpr, diagnostics: &mut Vec<Diagnos match event_type { "ok" => {} - "failed" => require_field(entries, "exit", left.span, diagnostics), - "fault" => require_field(entries, "fault", left.span, diagnostics), + "failed" => require_field(entries, "exit", expr.span, diagnostics), + "fault" => require_field(entries, "fault", expr.span, diagnostics), "cancelled" => { - require_field(entries, "source", left.span, diagnostics); - require_field(entries, "signal", left.span, diagnostics); + require_field(entries, "source", expr.span, diagnostics); + require_field(entries, "signal", expr.span, diagnostics); } - "cleanup" => validate_cleanup(entries, left.span, diagnostics), + "cleanup" => validate_cleanup(entries, expr.span, diagnostics), _ => diagnostics.push(Diagnostic::new( - left.span, + expr.span, format!("unknown frame event type '{event_type}'"), )), } diff --git a/app/tests/seal_ground.rs b/app/tests/seal_ground.rs index 1c6e073..0451769 100644 --- a/app/tests/seal_ground.rs +++ b/app/tests/seal_ground.rs @@ -289,6 +289,49 @@ fn completion_chain_handler_shape() { ); } +#[test] +fn call_exit_shape() { + let valid = parse( + r#" +@call.exit() +@call.exit("done") +@call.exit("done", { type: "ok" }) +@call.exit("done", event) +"#, + ); + + assert!(valid.diagnostics.is_empty()); + let grounded = ground::ground(&valid.file); + assert!(grounded.diagnostics.is_empty()); + + let too_many = parse(r#"@call.exit("done", { type: "ok" }, "extra")"#); + assert!(too_many.diagnostics.is_empty()); + let grounded = ground::ground(&too_many.file); + assert_eq!(grounded.diagnostics.len(), 1); + assert_eq!( + grounded.diagnostics[0].message, + "@call.exit expects at most 2 arguments" + ); + + let not_map = parse(r#"@call.exit("done", "ok")"#); + assert!(not_map.diagnostics.is_empty()); + let grounded = ground::ground(¬_map.file); + assert_eq!(grounded.diagnostics.len(), 1); + assert_eq!( + grounded.diagnostics[0].message, + "@call.exit event argument must be a frame event map" + ); + + let bad_event = parse(r#"@call.exit("done", { type: status })"#); + assert!(bad_event.diagnostics.is_empty()); + let grounded = ground::ground(&bad_event.file); + assert_eq!(grounded.diagnostics.len(), 1); + assert_eq!( + grounded.diagnostics[0].message, + "frame event field 'type' must be a string literal" + ); +} + #[test] fn duplicate_labels() { let output = parse(r#"@fs.mkdir(tmp, mode: 700, mode: 755)"#); From 5b2619f73eb7ccf99edbf0d8ab92dba00c2dbc43 Mon Sep 17 00:00:00 2001 From: PerishCode <perishcode@gmail.com> Date: Mon, 15 Jun 2026 15:51:15 +0800 Subject: [PATCH 17/20] docs: add seal lowering handoff --- docs/spec/README.md | 2 +- docs/spec/seal-language.md | 3 + docs/spec/seal-lowering-handoff.md | 148 +++++++++++++++++++++++++++++ 3 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 docs/spec/seal-lowering-handoff.md diff --git a/docs/spec/README.md b/docs/spec/README.md index 17f56ed..5fb89b7 100644 --- a/docs/spec/README.md +++ b/docs/spec/README.md @@ -3,4 +3,4 @@ These files describe repository-owned language and runtime contracts. - [Seal language specification](./seal-language.md) - +- [Seal lowering handoff](./seal-lowering-handoff.md) diff --git a/docs/spec/seal-language.md b/docs/spec/seal-language.md index 5b131ee..fa1344c 100644 --- a/docs/spec/seal-language.md +++ b/docs/spec/seal-language.md @@ -548,3 +548,6 @@ unresolved source-syntax decisions. Remaining work is runtime design: stream scheduling, completion-timing details for `@type.*` and `:=`, buffering/spooling policy, and the concrete implementation of built-in helper namespaces. + +`seal-lowering-handoff.md` records the Raw AST and Grounded AST guarantees that +IR design may rely on before implementing those runtime details. diff --git a/docs/spec/seal-lowering-handoff.md b/docs/spec/seal-lowering-handoff.md new file mode 100644 index 0000000..88d3fca --- /dev/null +++ b/docs/spec/seal-lowering-handoff.md @@ -0,0 +1,148 @@ +# Seal Lowering Handoff + +Status: implementation handoff draft. + +This document describes the boundary between the current Seal parser/Grounded +AST work and later IR/runtime work. It is intentionally not a runtime +scheduling spec. Its job is to say what lowering may rely on after Raw AST and +Grounded AST have accepted a source file, and what still belongs to runtime +design. + +The source contract remains `seal-language.md`. This file is the bridge from +that source contract to the first IR design. + +## Pipeline Boundary + +The cold-start implementation shape is: + +```text +Lexer -> Parser -> Raw AST -> Grounded AST -> IR -> Runtime +``` + +The stages should keep these responsibilities separate: + +- Lexer owns tokens, trivia spans, comments, strings, and process-marker + recognition. +- Parser owns source structure, recovery, Raw AST nodes, block-vs-map + structure, process argv structure, and comment attachment. +- Raw AST preserves source-relevant information even when later stages drop it: + spans, comments, syntactic call forms, labels, stream operators, process argv + atoms, match arm body shape, and lambda bodies. +- Grounded AST owns source-level semantic shape checks and metadata. It may + reject source that cannot be lowered coherently, but it should not become a + static type system. +- IR owns canonical operation structure, stream graph nodes, frame events, + completion values, and explicit runtime edges. +- Runtime owns scheduling, buffering/spooling, stream progress, completion + timing, cleanup execution, and concrete built-in behavior. + +## Raw AST Guarantees + +Lowering may rely on Raw AST to preserve these distinctions: + +- Comments exist as source nodes and attachments. Lowering may ignore them, but + formatters and source tools do not need to reconstruct them from trivia. +- A process node preserves its program and argv atoms. Bare words, strings, + text blocks, interpolation parts, and `*expr` spreads are distinct. +- Process nodes end at statement/effect boundaries and containing close + delimiters. Commas inside bare argv words remain argv text. +- A map literal and a statement block are distinct after parsing. Match arms + preserve expression bodies separately from statement-block bodies. +- Lambda bodies preserve callable-frame boundaries. They are not just nested + statement blocks. +- Receiver calls, direct calls, `@` calls, block calls, process nodes, stream + flow, and grouped expressions remain syntactically distinct. +- Pattern structure is preserved for wildcard, expression, array, and map + patterns. + +## Grounded AST Guarantees + +Lowering may rely on Grounded AST diagnostics to reject these source shapes: + +- Chained comparison operators. +- Effect blocks that do not contain exactly one stream graph. +- Duplicate labels in call arguments. +- Labeled arguments on dynamic callables. +- Labeled arguments on `@call.forward(...)`. +- `@call.forward(...)` with the wrong arity or a statically visible non-array + argument bundle. +- `@call.stdio(...)` and `@call.completion(...)` with the wrong arity, + statically visible non-lambda handlers, or literal handlers with the wrong + parameter count. +- Completion-chain handlers `.ok(...)`, `.failed(...)`, `.faulted(...)`, + `.cancelled(...)`, and `.always(...)` with the wrong arity, statically + visible non-lambda handlers, or literal handlers with the wrong parameter + count. +- `@call.exit(...)` with more than two arguments or a statically visible + non-map event argument. +- Literal frame event maps with missing or invalid `type`, unknown event types, + missing required fields, or invalid cleanup `run` handlers. +- Duplicate keys in map literals and map patterns. +- `break` and `continue` outside loop bodies, with lambda bodies starting a + fresh non-loop control context. + +Grounded AST also records method tail-output metadata: + +- `Implicit` means the method has a final expression and no explicit + current-frame `#stdout` use in that callable. +- `DisabledByStdout` means current-frame `#stdout` is explicitly referenced in + that callable. Nested lambdas and handlers do not disable the outer callable. +- `None` means there is no expression tail to lower as implicit stdout. + +## Canonical Lowering Targets + +The first IR should model these source forms as canonical operation structures: + +```text +foo(a, b) -> @call.forward(foo, [a, b]) +| gh pr view {number} -> @call.process("gh", ["pr", "view", number]) +text.trim() -> @call.self(text, @string.trim, []) +a >> b -> @stream.flow(a, b) +a << b -> @stream.flow(b, a) +long stream chain -> @stream.pipeline([...]) +@type.string(call) -> call + stdout absorption + conversion +let x := call -> call + stdout readonly stream view +@stream.dupe(call) -> call + writable stream materialization +@call.exit(value, event?) -> value to #stdout + event-or-ok to #frame + stop +``` + +These are lowering shapes, not a promise that source must be rewritten into +literal `@` calls before IR construction. IR may build the canonical structure +directly from Raw/Grounded nodes. + +## Frame Boundaries + +Lowering must preserve callable-frame boundaries: + +- Method bodies are operation frames. +- External process nodes instantiate operation frames. +- Tool and builtin calls instantiate operation frames unless their optimized + lowering says otherwise. +- Lambda and handler bodies are independent callable frames. +- `match`, `if`, `for`, `while`, and `with env` blocks are same-frame control + structure unless they contain nested callables. + +Current-frame channels are lexical with respect to the callable frame: + +- `#stdin`, `#stdout`, `#stderr`, and `#frame` inside a handler refer to the + handler frame. +- Target-call streams passed to `@call.stdio(...)` and `@call.completion(...)` + handlers are ordinary parameters. +- `#frame` is an event stream, not a frame object. + +## Runtime Work Still Open + +Grounded AST closure does not settle these runtime decisions: + +- How stream scheduling prevents deadlock between a target call and its handler. +- When `:= call` observes or propagates the backing call completion. +- Buffering, replay, and spooling policy for stream views. +- Exact optimized execution strategy for `@type.*(...)`. +- Concrete built-in namespace implementations. +- Cleanup ordering beyond the source-visible frame event shape. +- Whether common completion-plus-stdout workflows get additional helper names. + +IR work should start by consuming the guarantees above. If an IR design needs a +new source distinction that is not listed here, that is a signal to reopen the +Raw/Grounded boundary explicitly rather than smuggling runtime assumptions into +lowering. From 7ac5c8e73979bc4daec9fb9a47b65a293d597cb6 Mon Sep 17 00:00:00 2001 From: PerishCode <perishcode@gmail.com> Date: Mon, 15 Jun 2026 15:56:40 +0800 Subject: [PATCH 18/20] seal: add ir shape seed --- app/src/core/seal/ir.rs | 239 +++++++++++++++++++++++++++++++++++++++ app/src/core/seal/mod.rs | 1 + app/tests/seal_ground.rs | 56 ++++++++- 3 files changed, 295 insertions(+), 1 deletion(-) create mode 100644 app/src/core/seal/ir.rs diff --git a/app/src/core/seal/ir.rs b/app/src/core/seal/ir.rs new file mode 100644 index 0000000..a4ed209 --- /dev/null +++ b/app/src/core/seal/ir.rs @@ -0,0 +1,239 @@ +use super::{ground::TailOutput, span::Span}; + +#[derive(Debug, Clone, PartialEq)] +pub struct IrProgram { + pub items: Vec<IrItem>, + pub span: Span, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum IrItem { + Method(IrMethod), + Statement(IrStatement), + Error { span: Span }, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct IrMethod { + pub name: String, + pub frame: IrFrame, + pub tail: IrTailOutput, + pub span: Span, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct IrFrame { + pub kind: IrFrameKind, + pub body: Vec<IrStatement>, + pub span: Span, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IrFrameKind { + Method, + Lambda, + Handler, + Process, + Tool, + Builtin, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum IrStatement { + Let { + name: String, + binding: IrBinding, + value: IrExpr, + span: Span, + }, + Expr { + expr: IrExpr, + span: Span, + }, + Effect { + effect: IrEffect, + span: Span, + }, + Break { + span: Span, + }, + Continue { + span: Span, + }, + Error { + span: Span, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IrBinding { + Value, + StreamView, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum IrExpr { + Local { + name: String, + span: Span, + }, + Literal { + value: IrLiteral, + span: Span, + }, + Array { + items: Vec<IrExpr>, + span: Span, + }, + Map { + entries: Vec<(String, IrExpr)>, + span: Span, + }, + Call(Box<IrCall>), +} + +#[derive(Debug, Clone, PartialEq)] +pub enum IrLiteral { + String(String), + Int(String), + Bool(bool), + Null, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct IrCall { + pub kind: IrCallKind, + pub span: Span, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum IrCallKind { + Forward { + callable: Box<IrExpr>, + args: Vec<IrExpr>, + }, + Process { + program: IrArgv, + args: Vec<IrArgv>, + }, + Receiver { + receiver: Box<IrExpr>, + method: String, + args: Vec<IrExpr>, + }, + Named { + path: Vec<String>, + args: Vec<IrArg>, + }, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct IrArg { + pub label: Option<String>, + pub value: IrExpr, + pub span: Span, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum IrArgv { + Text { value: String, span: Span }, + Expr { expr: IrExpr, span: Span }, + Spread { expr: IrExpr, span: Span }, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum IrEffect { + Call(Box<IrCall>), + Flow { + left: Box<IrEffect>, + right: Box<IrEffect>, + span: Span, + }, + Pipeline { + stages: Vec<IrEffect>, + span: Span, + }, + TypeAbsorb { + kind: IrTypeKind, + call: Box<IrCall>, + span: Span, + }, + StreamDupe { + call: Box<IrCall>, + span: Span, + }, + Exit { + value: Option<IrExpr>, + event: Option<IrExpr>, + span: Span, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IrTypeKind { + String, + Bytes, + Array, + Map, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IrTailOutput { + Implicit { span: Span }, + DisabledByStdout { span: Span }, + None, +} + +impl IrTailOutput { + pub fn from_ground(tail: &TailOutput) -> Self { + match tail { + TailOutput::Implicit { span } => Self::Implicit { span: *span }, + TailOutput::DisabledByStdout { span } => Self::DisabledByStdout { span: *span }, + TailOutput::None => Self::None, + } + } +} + +impl IrExpr { + pub fn local(name: impl Into<String>, span: Span) -> Self { + Self::Local { + name: name.into(), + span, + } + } +} + +impl IrCall { + pub fn forward(callable: IrExpr, args: Vec<IrExpr>, span: Span) -> Self { + Self { + kind: IrCallKind::Forward { + callable: Box::new(callable), + args, + }, + span, + } + } + + pub fn process(program: IrArgv, args: Vec<IrArgv>, span: Span) -> Self { + Self { + kind: IrCallKind::Process { program, args }, + span, + } + } + + pub fn receiver( + receiver: IrExpr, + method: impl Into<String>, + args: Vec<IrExpr>, + span: Span, + ) -> Self { + Self { + kind: IrCallKind::Receiver { + receiver: Box::new(receiver), + method: method.into(), + args, + }, + span, + } + } +} diff --git a/app/src/core/seal/mod.rs b/app/src/core/seal/mod.rs index 3ae1345..83849f5 100644 --- a/app/src/core/seal/mod.rs +++ b/app/src/core/seal/mod.rs @@ -1,6 +1,7 @@ pub mod ast; pub mod diag; pub mod ground; +pub mod ir; pub mod lexer; pub mod parser; pub mod span; diff --git a/app/tests/seal_ground.rs b/app/tests/seal_ground.rs index 0451769..f5ff8ae 100644 --- a/app/tests/seal_ground.rs +++ b/app/tests/seal_ground.rs @@ -1,4 +1,4 @@ -use runseal::core::seal::{ground, parse}; +use runseal::core::seal::{ground, ir, parse, span::Span}; #[test] fn cleanup_frame_event() { @@ -434,3 +434,57 @@ match event { "duplicate map pattern key 'status'" ); } + +#[test] +fn ir_tail_from_ground() { + let span = Span::new(4, 9); + + assert_eq!( + ir::IrTailOutput::from_ground(&ground::TailOutput::Implicit { span }), + ir::IrTailOutput::Implicit { span } + ); + assert_eq!( + ir::IrTailOutput::from_ground(&ground::TailOutput::DisabledByStdout { span }), + ir::IrTailOutput::DisabledByStdout { span } + ); + assert_eq!( + ir::IrTailOutput::from_ground(&ground::TailOutput::None), + ir::IrTailOutput::None + ); +} + +#[test] +fn ir_canonical_call_shapes() { + let span = Span::new(0, 12); + let callable = ir::IrExpr::local("deploy", span); + let env = ir::IrExpr::local("environment", span); + let forward = ir::IrCall::forward(callable, vec![env], span); + + let ir::IrCallKind::Forward { args, .. } = &forward.kind else { + panic!("expected forward call"); + }; + assert_eq!(args.len(), 1); + + let process = ir::IrCall::process( + ir::IrArgv::Text { + value: "gh".to_string(), + span, + }, + vec![ir::IrArgv::Text { + value: "pr".to_string(), + span, + }], + span, + ); + let ir::IrCallKind::Process { args, .. } = &process.kind else { + panic!("expected process call"); + }; + assert_eq!(args.len(), 1); + + let receiver = ir::IrCall::receiver(ir::IrExpr::local("text", span), "trim", Vec::new(), span); + let ir::IrCallKind::Receiver { method, args, .. } = &receiver.kind else { + panic!("expected receiver call"); + }; + assert_eq!(method, "trim"); + assert!(args.is_empty()); +} From bb709328db7a121ac3f7ca3a270fa41ebf6bf15d Mon Sep 17 00:00:00 2001 From: PerishCode <perishcode@gmail.com> Date: Mon, 15 Jun 2026 16:06:47 +0800 Subject: [PATCH 19/20] seal: lower grounded skeleton to ir --- app/src/core/seal/ir.rs | 48 ++++++++++++++++++-- app/tests/seal_ground.rs | 94 +++++++++++++++++++++------------------- 2 files changed, 94 insertions(+), 48 deletions(-) diff --git a/app/src/core/seal/ir.rs b/app/src/core/seal/ir.rs index a4ed209..bf33296 100644 --- a/app/src/core/seal/ir.rs +++ b/app/src/core/seal/ir.rs @@ -1,4 +1,7 @@ -use super::{ground::TailOutput, span::Span}; +use super::{ + ground::{GroundFile, GroundNode, TailOutput}, + span::Span, +}; #[derive(Debug, Clone, PartialEq)] pub struct IrProgram { @@ -43,15 +46,15 @@ pub enum IrStatement { Let { name: String, binding: IrBinding, - value: IrExpr, + value: Option<IrExpr>, span: Span, }, Expr { - expr: IrExpr, + expr: Option<IrExpr>, span: Span, }, Effect { - effect: IrEffect, + effect: Option<IrEffect>, span: Span, }, Break { @@ -184,6 +187,43 @@ pub enum IrTailOutput { None, } +pub fn lower(file: &GroundFile) -> IrProgram { + IrProgram { + items: file.nodes.iter().map(lower_node).collect(), + span: file.span, + } +} + +fn lower_node(node: &GroundNode) -> IrItem { + match node { + GroundNode::Method { name, tail, span } => IrItem::Method(IrMethod { + name: name.clone(), + frame: IrFrame { + kind: IrFrameKind::Method, + body: Vec::new(), + span: *span, + }, + tail: IrTailOutput::from_ground(tail), + span: *span, + }), + GroundNode::Let { name, span } => IrItem::Statement(IrStatement::Let { + name: name.clone(), + binding: IrBinding::Value, + value: None, + span: *span, + }), + GroundNode::Expr { span } => IrItem::Statement(IrStatement::Expr { + expr: None, + span: *span, + }), + GroundNode::Effect { span } => IrItem::Statement(IrStatement::Effect { + effect: None, + span: *span, + }), + GroundNode::Error { span } => IrItem::Error { span: *span }, + } +} + impl IrTailOutput { pub fn from_ground(tail: &TailOutput) -> Self { match tail { diff --git a/app/tests/seal_ground.rs b/app/tests/seal_ground.rs index f5ff8ae..9de5e89 100644 --- a/app/tests/seal_ground.rs +++ b/app/tests/seal_ground.rs @@ -1,4 +1,4 @@ -use runseal::core::seal::{ground, ir, parse, span::Span}; +use runseal::core::seal::{ground, ir, parse}; #[test] fn cleanup_frame_event() { @@ -436,55 +436,61 @@ match event { } #[test] -fn ir_tail_from_ground() { - let span = Span::new(4, 9); +fn ir_skeleton_lowering() { + let output = parse( + r#" +method named() { + "value" +} - assert_eq!( - ir::IrTailOutput::from_ground(&ground::TailOutput::Implicit { span }), - ir::IrTailOutput::Implicit { span } - ); - assert_eq!( - ir::IrTailOutput::from_ground(&ground::TailOutput::DisabledByStdout { span }), - ir::IrTailOutput::DisabledByStdout { span } - ); - assert_eq!( - ir::IrTailOutput::from_ground(&ground::TailOutput::None), - ir::IrTailOutput::None +let answer = 42 +"done" >> #stdout +"#, ); -} -#[test] -fn ir_canonical_call_shapes() { - let span = Span::new(0, 12); - let callable = ir::IrExpr::local("deploy", span); - let env = ir::IrExpr::local("environment", span); - let forward = ir::IrCall::forward(callable, vec![env], span); - - let ir::IrCallKind::Forward { args, .. } = &forward.kind else { - panic!("expected forward call"); + assert!(output.diagnostics.is_empty()); + let grounded = ground::ground(&output.file); + assert!(grounded.diagnostics.is_empty()); + + let program = ir::lower(&grounded.file); + assert_eq!(program.items.len(), 3); + let ir::IrItem::Method(method) = &program.items[0] else { + panic!("expected method"); }; - assert_eq!(args.len(), 1); + assert_eq!(method.name, "named"); + assert!(matches!(method.tail, ir::IrTailOutput::Implicit { .. })); - let process = ir::IrCall::process( - ir::IrArgv::Text { - value: "gh".to_string(), - span, - }, - vec![ir::IrArgv::Text { - value: "pr".to_string(), - span, - }], - span, - ); - let ir::IrCallKind::Process { args, .. } = &process.kind else { - panic!("expected process call"); + let ir::IrItem::Statement(ir::IrStatement::Let { name, value, .. }) = &program.items[1] else { + panic!("expected let skeleton"); }; - assert_eq!(args.len(), 1); + assert_eq!(name, "answer"); + assert!(value.is_none()); - let receiver = ir::IrCall::receiver(ir::IrExpr::local("text", span), "trim", Vec::new(), span); - let ir::IrCallKind::Receiver { method, args, .. } = &receiver.kind else { - panic!("expected receiver call"); + let ir::IrItem::Statement(ir::IrStatement::Effect { effect, .. }) = &program.items[2] else { + panic!("expected effect skeleton"); }; - assert_eq!(method, "trim"); - assert!(args.is_empty()); + assert!(effect.is_none()); + + let call = ir::IrCall::forward( + ir::IrExpr::local("deploy", method.span), + vec![ir::IrExpr::local("prod", method.span)], + method.span, + ); + assert!(matches!(call.kind, ir::IrCallKind::Forward { .. })); + let call = ir::IrCall::process( + ir::IrArgv::Text { + value: "gh".to_string(), + span: method.span, + }, + Vec::new(), + method.span, + ); + assert!(matches!(call.kind, ir::IrCallKind::Process { .. })); + let call = ir::IrCall::receiver( + ir::IrExpr::local("text", method.span), + "trim", + Vec::new(), + method.span, + ); + assert!(matches!(call.kind, ir::IrCallKind::Receiver { .. })); } From 2803a331ccc21b74a792c9a70a8c2e2c204385ed Mon Sep 17 00:00:00 2001 From: PerishCode <perishcode@gmail.com> Date: Mon, 15 Jun 2026 17:22:09 +0800 Subject: [PATCH 20/20] seal: lower grounded payloads to ir --- app/src/core/seal/ground.rs | 180 +++--------------- app/src/core/seal/ground/frame.rs | 9 +- app/src/core/seal/ground/payload.rs | 275 ++++++++++++++++++++++++++++ app/src/core/seal/ground/tail.rs | 158 ++++++++++++++++ app/src/core/seal/ir.rs | 202 ++++++++++++++++++-- app/src/core/seal/parser/expr.rs | 13 +- app/src/core/seal/parser/mod.rs | 58 ++++++ app/src/core/seal/parser/process.rs | 6 +- app/tests/seal.rs | 5 + app/tests/seal/ir.rs | 183 ++++++++++++++++++ app/tests/seal/parser.rs | 72 ++++++++ app/tests/seal_ground.rs | 62 +------ 12 files changed, 984 insertions(+), 239 deletions(-) create mode 100644 app/src/core/seal/ground/payload.rs create mode 100644 app/src/core/seal/ground/tail.rs create mode 100644 app/tests/seal/ir.rs create mode 100644 app/tests/seal/parser.rs diff --git a/app/src/core/seal/ground.rs b/app/src/core/seal/ground.rs index 472978e..ee784a5 100644 --- a/app/src/core/seal/ground.rs +++ b/app/src/core/seal/ground.rs @@ -1,5 +1,5 @@ use super::{ - ast::{RawExpr, RawExprKind, RawItemKind, RawStatementKind, SourceFile}, + ast::{LetBinding, RawExpr, RawExprKind, RawItemKind, RawStatementKind, SourceFile}, diag::Diagnostic, span::Span, }; @@ -8,6 +8,13 @@ mod call; mod control; mod frame; mod map; +mod payload; +mod tail; + +pub use payload::{ + GroundArgv, GroundEffect, GroundExpr, GroundLiteral, GroundTypeKind, GroundValueSource, +}; +pub use tail::TailOutput; #[derive(Debug, Clone, PartialEq)] pub struct GroundOutput { @@ -30,12 +37,16 @@ pub enum GroundNode { }, Let { name: String, + binding: LetBinding, + source: Option<GroundValueSource>, span: Span, }, Expr { + expr: Option<GroundExpr>, span: Span, }, Effect { + effect: Option<GroundEffect>, span: Span, }, Error { @@ -43,13 +54,6 @@ pub enum GroundNode { }, } -#[derive(Debug, Clone, PartialEq)] -pub enum TailOutput { - Implicit { span: Span }, - DisabledByStdout { span: Span }, - None, -} - pub fn ground(file: &SourceFile) -> GroundOutput { let mut diagnostics = Vec::new(); let mut nodes = Vec::new(); @@ -59,7 +63,7 @@ pub fn ground(file: &SourceFile) -> GroundOutput { RawItemKind::Comment(_) => {} RawItemKind::Method(method) => { control::validate_block(&method.body, false, &mut diagnostics); - let tail = method_tail_output(&method.body, &mut diagnostics); + let tail = tail::method_tail_output(&method.body, &mut diagnostics); nodes.push(GroundNode::Method { name: method.name.clone(), tail, @@ -83,39 +87,21 @@ pub fn ground(file: &SourceFile) -> GroundOutput { } } -fn method_tail_output( - body: &super::ast::RawBlock, - diagnostics: &mut Vec<Diagnostic>, -) -> TailOutput { - if let Some(span) = find_current_stdout_block(body) { - return TailOutput::DisabledByStdout { span }; - } - - for item in body.items.iter().rev() { - match &item.kind { - RawItemKind::Comment(_) => continue, - RawItemKind::Statement(statement) => { - reject_statement_comparison_chains(statement, diagnostics); - return match &statement.kind { - RawStatementKind::Expr(expr) => TailOutput::Implicit { span: expr.span }, - _ => TailOutput::None, - }; - } - RawItemKind::Method(_) | RawItemKind::Error => return TailOutput::None, - } - } - TailOutput::None -} - fn ground_statement( statement: &super::ast::RawStatement, diagnostics: &mut Vec<Diagnostic>, ) -> GroundNode { match &statement.kind { - RawStatementKind::Let { name, value, .. } => { + RawStatementKind::Let { + name, + binding, + value, + } => { reject_comparison_chain(value, diagnostics); GroundNode::Let { name: name.clone(), + binding: *binding, + source: payload::ground_value_source(*binding, value), span: statement.span, } } @@ -123,6 +109,7 @@ fn ground_statement( reject_comparison_chain(target, diagnostics); reject_comparison_chain(value, diagnostics); GroundNode::Expr { + expr: None, span: statement.span, } } @@ -132,22 +119,26 @@ fn ground_statement( | RawStatementKind::WithEnv { .. } => { reject_statement_comparison_chains(statement, diagnostics); GroundNode::Expr { + expr: None, span: statement.span, } } RawStatementKind::Effect(expr) => { reject_comparison_chain(expr, diagnostics); GroundNode::Effect { + effect: payload::ground_effect(expr), span: statement.span, } } RawStatementKind::Expr(expr) => { reject_comparison_chain(expr, diagnostics); GroundNode::Expr { + expr: payload::ground_expr(expr), span: statement.span, } } RawStatementKind::Break | RawStatementKind::Continue => GroundNode::Expr { + expr: None, span: statement.span, }, RawStatementKind::Error => GroundNode::Error { @@ -156,126 +147,7 @@ fn ground_statement( } } -fn find_current_stdout_block(block: &super::ast::RawBlock) -> Option<Span> { - block.items.iter().find_map(|item| match &item.kind { - RawItemKind::Statement(statement) => find_current_stdout_statement(statement), - RawItemKind::Comment(_) | RawItemKind::Method(_) | RawItemKind::Error => None, - }) -} - -fn find_current_stdout_statement(statement: &super::ast::RawStatement) -> Option<Span> { - match &statement.kind { - RawStatementKind::Let { value, .. } => find_current_stdout_expr(value), - RawStatementKind::Assign { target, value } => { - find_current_stdout_expr(target).or_else(|| find_current_stdout_expr(value)) - } - RawStatementKind::If { - branches, - else_branch, - } => { - for branch in branches { - if let Some(span) = find_current_stdout_expr(&branch.condition) - .or_else(|| find_current_stdout_block(&branch.body)) - { - return Some(span); - } - } - else_branch.as_ref().and_then(find_current_stdout_block) - } - RawStatementKind::For { iterable, body, .. } => { - find_current_stdout_expr(iterable).or_else(|| find_current_stdout_block(body)) - } - RawStatementKind::While { condition, body } => { - find_current_stdout_expr(condition).or_else(|| find_current_stdout_block(body)) - } - RawStatementKind::WithEnv { bindings, body } => bindings - .iter() - .find_map(|binding| find_current_stdout_expr(&binding.value)) - .or_else(|| find_current_stdout_block(body)), - RawStatementKind::Expr(expr) | RawStatementKind::Effect(expr) => { - find_current_stdout_expr(expr) - } - RawStatementKind::Break | RawStatementKind::Continue | RawStatementKind::Error => None, - } -} - -fn find_current_stdout_expr(expr: &RawExpr) -> Option<Span> { - match &expr.kind { - RawExprKind::Channel(name) if name == "stdout" => Some(expr.span), - RawExprKind::Binary { left, right, .. } | RawExprKind::StreamFlow { left, right, .. } => { - find_current_stdout_expr(left).or_else(|| find_current_stdout_expr(right)) - } - RawExprKind::Unary { expr, .. } | RawExprKind::Group(expr) => { - find_current_stdout_expr(expr) - } - RawExprKind::Call { callee, args } => find_current_stdout_expr(callee).or_else(|| { - args.iter() - .find_map(|arg| find_current_stdout_expr(&arg.value)) - }), - RawExprKind::BlockCall { callee, block } => { - find_current_stdout_expr(callee).or_else(|| find_current_stdout_block(block)) - } - RawExprKind::Lambda(_) => None, - RawExprKind::ReceiverCall { receiver, args, .. } => find_current_stdout_expr(receiver) - .or_else(|| { - args.iter() - .find_map(|arg| find_current_stdout_expr(&arg.value)) - }), - RawExprKind::Array(items) => items.iter().find_map(find_current_stdout_expr), - RawExprKind::Map(entries) => entries - .iter() - .find_map(|entry| find_current_stdout_expr(&entry.value)), - RawExprKind::Match(match_expr) => { - find_current_stdout_expr(&match_expr.scrutinee).or_else(|| { - match_expr.arms.iter().find_map(|arm| { - arm.patterns - .iter() - .find_map(find_stdout_pattern) - .or_else(|| find_stdout_arm_body(&arm.body)) - }) - }) - } - RawExprKind::Process(process) => process - .program - .iter() - .chain(process.args.iter()) - .find_map(find_stdout_arg), - _ => None, - } -} - -fn find_stdout_arm_body(body: &super::ast::RawMatchArmBody) -> Option<Span> { - match body { - super::ast::RawMatchArmBody::Expr(expr) => find_current_stdout_expr(expr), - super::ast::RawMatchArmBody::Block(block) => find_current_stdout_block(block), - } -} - -fn find_stdout_pattern(pattern: &super::ast::RawPattern) -> Option<Span> { - match &pattern.kind { - super::ast::RawPatternKind::Expr(expr) => find_current_stdout_expr(expr), - super::ast::RawPatternKind::Map(entries) => entries - .iter() - .find_map(|entry| find_stdout_pattern(&entry.pattern)), - super::ast::RawPatternKind::Array(items) => items.iter().find_map(find_stdout_pattern), - super::ast::RawPatternKind::Wildcard => None, - } -} - -fn find_stdout_arg(arg: &super::ast::RawProcessArg) -> Option<Span> { - match &arg.kind { - super::ast::RawProcessArgKind::Spread(expr) => find_current_stdout_expr(expr), - super::ast::RawProcessArgKind::Word(parts) => parts.iter().find_map(|part| match part { - super::ast::RawProcessPart::Interpolation(expr) => find_current_stdout_expr(expr), - super::ast::RawProcessPart::Text(_) => None, - }), - super::ast::RawProcessArgKind::String(_) - | super::ast::RawProcessArgKind::TextBlock(_) - | super::ast::RawProcessArgKind::Error => None, - } -} - -fn reject_statement_comparison_chains( +pub(super) fn reject_statement_comparison_chains( statement: &super::ast::RawStatement, diagnostics: &mut Vec<Diagnostic>, ) { diff --git a/app/src/core/seal/ground/frame.rs b/app/src/core/seal/ground/frame.rs index 430385d..ac7fef9 100644 --- a/app/src/core/seal/ground/frame.rs +++ b/app/src/core/seal/ground/frame.rs @@ -83,14 +83,7 @@ fn string_literal(expr: &RawExpr) -> Option<&str> { let RawExprKind::Literal(RawLiteral::String(value)) = &expr.kind else { return None; }; - Some(string_content(value)) -} - -fn string_content(value: &str) -> &str { - value - .strip_prefix('"') - .and_then(|value| value.strip_suffix('"')) - .unwrap_or(value) + Some(value) } fn field<'a>(entries: &'a [RawMapEntry], key: &str) -> Option<&'a RawExpr> { diff --git a/app/src/core/seal/ground/payload.rs b/app/src/core/seal/ground/payload.rs new file mode 100644 index 0000000..b68aaae --- /dev/null +++ b/app/src/core/seal/ground/payload.rs @@ -0,0 +1,275 @@ +use crate::core::seal::{ + ast::{ + LetBinding, RawExpr, RawExprKind, RawItemKind, RawLiteral, RawProcessArg, + RawProcessArgKind, RawProcessPart, RawStatementKind, StreamOp, + }, + span::Span, +}; + +#[derive(Debug, Clone, PartialEq)] +pub enum GroundExpr { + Local { + name: String, + span: Span, + }, + Env { + name: String, + span: Span, + }, + Channel { + name: String, + span: Span, + }, + Literal { + value: GroundLiteral, + span: Span, + }, + Array { + items: Vec<GroundExpr>, + span: Span, + }, + Map { + entries: Vec<(String, GroundExpr)>, + span: Span, + }, + Process { + program: GroundArgv, + args: Vec<GroundArgv>, + span: Span, + }, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum GroundValueSource { + Pure(GroundExpr), + StreamView(GroundExpr), + TypeAbsorb { + kind: GroundTypeKind, + call: GroundExpr, + span: Span, + }, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum GroundLiteral { + String(String), + Int(String), + Bool(bool), + Null, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GroundTypeKind { + String, + Bytes, + Array, + Map, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum GroundArgv { + Text { value: String, span: Span }, + Expr { expr: Box<GroundExpr>, span: Span }, + Spread { expr: Box<GroundExpr>, span: Span }, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum GroundEffect { + Call { + expr: GroundExpr, + span: Span, + }, + Flow { + op: StreamOp, + left: GroundExpr, + right: GroundExpr, + span: Span, + }, +} + +pub(super) fn ground_value_source( + binding: LetBinding, + value: &RawExpr, +) -> Option<GroundValueSource> { + match binding { + LetBinding::Value => { + ground_type_absorb(value).or_else(|| Some(GroundValueSource::Pure(ground_expr(value)?))) + } + LetBinding::Stream => Some(GroundValueSource::StreamView(ground_expr(value)?)), + } +} + +pub(super) fn ground_expr(expr: &RawExpr) -> Option<GroundExpr> { + match &expr.kind { + RawExprKind::Ident(name) => Some(GroundExpr::Local { + name: name.clone(), + span: expr.span, + }), + RawExprKind::Env(name) => Some(GroundExpr::Env { + name: name.clone(), + span: expr.span, + }), + RawExprKind::Channel(name) => Some(GroundExpr::Channel { + name: name.clone(), + span: expr.span, + }), + RawExprKind::Literal(literal) => Some(GroundExpr::Literal { + value: ground_literal(literal), + span: expr.span, + }), + RawExprKind::Array(items) => Some(GroundExpr::Array { + items: items.iter().filter_map(ground_expr).collect(), + span: expr.span, + }), + RawExprKind::Map(entries) => Some(GroundExpr::Map { + entries: entries + .iter() + .filter_map(|entry| Some((entry.key.clone(), ground_expr(&entry.value)?))) + .collect(), + span: expr.span, + }), + RawExprKind::Process(process) => { + let program = process.program.as_ref().and_then(ground_argv)?; + Some(GroundExpr::Process { + program, + args: process.args.iter().filter_map(ground_argv).collect(), + span: expr.span, + }) + } + RawExprKind::StreamFlow { .. } => None, + RawExprKind::Group(inner) => ground_expr(inner), + _ => None, + } +} + +pub(super) fn ground_effect(expr: &RawExpr) -> Option<GroundEffect> { + match &expr.kind { + RawExprKind::Process(_) => Some(GroundEffect::Call { + expr: ground_expr(expr)?, + span: expr.span, + }), + RawExprKind::StreamFlow { op, left, right } => Some(GroundEffect::Flow { + op: *op, + left: ground_expr(left)?, + right: ground_expr(right)?, + span: expr.span, + }), + RawExprKind::Group(inner) => ground_effect(inner), + _ => None, + } +} + +fn ground_type_absorb(expr: &RawExpr) -> Option<GroundValueSource> { + match &expr.kind { + RawExprKind::Call { callee, args } => { + let kind = type_absorb_kind(callee)?; + let [arg] = args.as_slice() else { + return None; + }; + if arg.label.is_some() { + return None; + } + let call = ground_type_absorb_call(&arg.value)?; + Some(GroundValueSource::TypeAbsorb { + kind, + call, + span: expr.span, + }) + } + RawExprKind::BlockCall { callee, block } => { + let kind = type_absorb_kind(callee)?; + let call = ground_type_absorb_block(block)?; + Some(GroundValueSource::TypeAbsorb { + kind, + call, + span: expr.span, + }) + } + RawExprKind::Group(inner) => ground_type_absorb(inner), + _ => None, + } +} + +fn ground_type_absorb_block(block: &crate::core::seal::ast::RawBlock) -> Option<GroundExpr> { + let mut statements = block.items.iter().filter_map(|item| match &item.kind { + RawItemKind::Statement(statement) => Some(statement), + RawItemKind::Comment(_) | RawItemKind::Method(_) | RawItemKind::Error => None, + }); + let statement = statements.next()?; + if statements.next().is_some() { + return None; + } + let RawStatementKind::Effect(expr) = &statement.kind else { + return None; + }; + ground_type_absorb_call(expr) +} + +fn ground_type_absorb_call(expr: &RawExpr) -> Option<GroundExpr> { + match &expr.kind { + RawExprKind::Process(_) => ground_expr(expr), + RawExprKind::Group(inner) => ground_type_absorb_call(inner), + _ => None, + } +} + +fn type_absorb_kind(callee: &RawExpr) -> Option<GroundTypeKind> { + let RawExprKind::AtName(parts) = &callee.kind else { + return None; + }; + let [namespace, name] = parts.as_slice() else { + return None; + }; + if namespace != "type" { + return None; + } + match name.as_str() { + "string" => Some(GroundTypeKind::String), + "bytes" => Some(GroundTypeKind::Bytes), + "array" => Some(GroundTypeKind::Array), + "map" => Some(GroundTypeKind::Map), + _ => None, + } +} + +fn ground_literal(literal: &RawLiteral) -> GroundLiteral { + match literal { + RawLiteral::String(value) | RawLiteral::TextBlock(value) => { + GroundLiteral::String(value.clone()) + } + RawLiteral::Int(value) => GroundLiteral::Int(value.clone()), + RawLiteral::Bool(value) => GroundLiteral::Bool(*value), + RawLiteral::Null => GroundLiteral::Null, + } +} + +fn ground_argv(arg: &RawProcessArg) -> Option<GroundArgv> { + match &arg.kind { + RawProcessArgKind::Word(parts) => ground_word_argv(parts, arg.span), + RawProcessArgKind::String(value) | RawProcessArgKind::TextBlock(value) => { + Some(GroundArgv::Text { + value: value.clone(), + span: arg.span, + }) + } + RawProcessArgKind::Spread(expr) => Some(GroundArgv::Spread { + expr: Box::new(ground_expr(expr)?), + span: arg.span, + }), + RawProcessArgKind::Error => None, + } +} + +fn ground_word_argv(parts: &[RawProcessPart], span: Span) -> Option<GroundArgv> { + match parts { + [RawProcessPart::Text(value)] => Some(GroundArgv::Text { + value: value.clone(), + span, + }), + [RawProcessPart::Interpolation(expr)] => Some(GroundArgv::Expr { + expr: Box::new(ground_expr(expr)?), + span, + }), + _ => None, + } +} diff --git a/app/src/core/seal/ground/tail.rs b/app/src/core/seal/ground/tail.rs new file mode 100644 index 0000000..253ebac --- /dev/null +++ b/app/src/core/seal/ground/tail.rs @@ -0,0 +1,158 @@ +use crate::core::seal::{ + ast::{RawBlock, RawExpr, RawExprKind, RawItemKind, RawStatement, RawStatementKind}, + diag::Diagnostic, + span::Span, +}; + +#[derive(Debug, Clone, PartialEq)] +pub enum TailOutput { + Implicit { span: Span }, + DisabledByStdout { span: Span }, + None, +} + +pub(super) fn method_tail_output(body: &RawBlock, diagnostics: &mut Vec<Diagnostic>) -> TailOutput { + if let Some(span) = find_current_stdout_block(body) { + return TailOutput::DisabledByStdout { span }; + } + + for item in body.items.iter().rev() { + match &item.kind { + RawItemKind::Comment(_) => continue, + RawItemKind::Statement(statement) => { + super::reject_statement_comparison_chains(statement, diagnostics); + return match &statement.kind { + RawStatementKind::Expr(expr) => TailOutput::Implicit { span: expr.span }, + _ => TailOutput::None, + }; + } + RawItemKind::Method(_) | RawItemKind::Error => return TailOutput::None, + } + } + TailOutput::None +} + +fn find_current_stdout_block(block: &RawBlock) -> Option<Span> { + block.items.iter().find_map(|item| match &item.kind { + RawItemKind::Statement(statement) => find_current_stdout_statement(statement), + RawItemKind::Comment(_) | RawItemKind::Method(_) | RawItemKind::Error => None, + }) +} + +fn find_current_stdout_statement(statement: &RawStatement) -> Option<Span> { + match &statement.kind { + RawStatementKind::Let { value, .. } => find_current_stdout_expr(value), + RawStatementKind::Assign { target, value } => { + find_current_stdout_expr(target).or_else(|| find_current_stdout_expr(value)) + } + RawStatementKind::If { + branches, + else_branch, + } => { + for branch in branches { + if let Some(span) = find_current_stdout_expr(&branch.condition) + .or_else(|| find_current_stdout_block(&branch.body)) + { + return Some(span); + } + } + else_branch.as_ref().and_then(find_current_stdout_block) + } + RawStatementKind::For { iterable, body, .. } => { + find_current_stdout_expr(iterable).or_else(|| find_current_stdout_block(body)) + } + RawStatementKind::While { condition, body } => { + find_current_stdout_expr(condition).or_else(|| find_current_stdout_block(body)) + } + RawStatementKind::WithEnv { bindings, body } => bindings + .iter() + .find_map(|binding| find_current_stdout_expr(&binding.value)) + .or_else(|| find_current_stdout_block(body)), + RawStatementKind::Expr(expr) | RawStatementKind::Effect(expr) => { + find_current_stdout_expr(expr) + } + RawStatementKind::Break | RawStatementKind::Continue | RawStatementKind::Error => None, + } +} + +fn find_current_stdout_expr(expr: &RawExpr) -> Option<Span> { + match &expr.kind { + RawExprKind::Channel(name) if name == "stdout" => Some(expr.span), + RawExprKind::Binary { left, right, .. } | RawExprKind::StreamFlow { left, right, .. } => { + find_current_stdout_expr(left).or_else(|| find_current_stdout_expr(right)) + } + RawExprKind::Unary { expr, .. } | RawExprKind::Group(expr) => { + find_current_stdout_expr(expr) + } + RawExprKind::Call { callee, args } => find_current_stdout_expr(callee).or_else(|| { + args.iter() + .find_map(|arg| find_current_stdout_expr(&arg.value)) + }), + RawExprKind::BlockCall { callee, block } => { + find_current_stdout_expr(callee).or_else(|| find_current_stdout_block(block)) + } + RawExprKind::Lambda(_) => None, + RawExprKind::ReceiverCall { receiver, args, .. } => find_current_stdout_expr(receiver) + .or_else(|| { + args.iter() + .find_map(|arg| find_current_stdout_expr(&arg.value)) + }), + RawExprKind::Array(items) => items.iter().find_map(find_current_stdout_expr), + RawExprKind::Map(entries) => entries + .iter() + .find_map(|entry| find_current_stdout_expr(&entry.value)), + RawExprKind::Match(match_expr) => { + find_current_stdout_expr(&match_expr.scrutinee).or_else(|| { + match_expr.arms.iter().find_map(|arm| { + arm.patterns + .iter() + .find_map(find_stdout_pattern) + .or_else(|| find_stdout_arm_body(&arm.body)) + }) + }) + } + RawExprKind::Process(process) => process + .program + .iter() + .chain(process.args.iter()) + .find_map(find_stdout_arg), + _ => None, + } +} + +fn find_stdout_arm_body(body: &crate::core::seal::ast::RawMatchArmBody) -> Option<Span> { + match body { + crate::core::seal::ast::RawMatchArmBody::Expr(expr) => find_current_stdout_expr(expr), + crate::core::seal::ast::RawMatchArmBody::Block(block) => find_current_stdout_block(block), + } +} + +fn find_stdout_pattern(pattern: &crate::core::seal::ast::RawPattern) -> Option<Span> { + match &pattern.kind { + crate::core::seal::ast::RawPatternKind::Expr(expr) => find_current_stdout_expr(expr), + crate::core::seal::ast::RawPatternKind::Map(entries) => entries + .iter() + .find_map(|entry| find_stdout_pattern(&entry.pattern)), + crate::core::seal::ast::RawPatternKind::Array(items) => { + items.iter().find_map(find_stdout_pattern) + } + crate::core::seal::ast::RawPatternKind::Wildcard => None, + } +} + +fn find_stdout_arg(arg: &crate::core::seal::ast::RawProcessArg) -> Option<Span> { + match &arg.kind { + crate::core::seal::ast::RawProcessArgKind::Spread(expr) => find_current_stdout_expr(expr), + crate::core::seal::ast::RawProcessArgKind::Word(parts) => { + parts.iter().find_map(|part| match part { + crate::core::seal::ast::RawProcessPart::Interpolation(expr) => { + find_current_stdout_expr(expr) + } + crate::core::seal::ast::RawProcessPart::Text(_) => None, + }) + } + crate::core::seal::ast::RawProcessArgKind::String(_) + | crate::core::seal::ast::RawProcessArgKind::TextBlock(_) + | crate::core::seal::ast::RawProcessArgKind::Error => None, + } +} diff --git a/app/src/core/seal/ir.rs b/app/src/core/seal/ir.rs index bf33296..be2c4a3 100644 --- a/app/src/core/seal/ir.rs +++ b/app/src/core/seal/ir.rs @@ -1,5 +1,9 @@ use super::{ - ground::{GroundFile, GroundNode, TailOutput}, + ast::LetBinding, + ground::{ + GroundArgv, GroundEffect, GroundExpr, GroundFile, GroundLiteral, GroundNode, + GroundTypeKind, TailOutput, + }, span::Span, }; @@ -46,7 +50,7 @@ pub enum IrStatement { Let { name: String, binding: IrBinding, - value: Option<IrExpr>, + source: Option<IrValueSource>, span: Span, }, Expr { @@ -74,12 +78,31 @@ pub enum IrBinding { StreamView, } +#[derive(Debug, Clone, PartialEq)] +pub enum IrValueSource { + Pure(IrExpr), + StreamView(IrExpr), + TypeAbsorb { + kind: IrTypeKind, + call: Box<IrCall>, + span: Span, + }, +} + #[derive(Debug, Clone, PartialEq)] pub enum IrExpr { Local { name: String, span: Span, }, + Env { + name: String, + span: Span, + }, + Channel { + name: String, + span: Span, + }, Literal { value: IrLiteral, span: Span, @@ -148,8 +171,9 @@ pub enum IrArgv { pub enum IrEffect { Call(Box<IrCall>), Flow { - left: Box<IrEffect>, - right: Box<IrEffect>, + op: IrStreamOp, + left: Box<IrExpr>, + right: Box<IrExpr>, span: Span, }, Pipeline { @@ -172,6 +196,12 @@ pub enum IrEffect { }, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IrStreamOp { + To, + From, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum IrTypeKind { String, @@ -206,18 +236,23 @@ fn lower_node(node: &GroundNode) -> IrItem { tail: IrTailOutput::from_ground(tail), span: *span, }), - GroundNode::Let { name, span } => IrItem::Statement(IrStatement::Let { + GroundNode::Let { + name, + binding, + source, + span, + } => IrItem::Statement(IrStatement::Let { name: name.clone(), - binding: IrBinding::Value, - value: None, + binding: IrBinding::from_raw(*binding), + source: source.as_ref().map(IrValueSource::from_ground), span: *span, }), - GroundNode::Expr { span } => IrItem::Statement(IrStatement::Expr { - expr: None, + GroundNode::Expr { expr, span } => IrItem::Statement(IrStatement::Expr { + expr: expr.as_ref().map(IrExpr::from_ground), span: *span, }), - GroundNode::Effect { span } => IrItem::Statement(IrStatement::Effect { - effect: None, + GroundNode::Effect { effect, span } => IrItem::Statement(IrStatement::Effect { + effect: effect.as_ref().map(IrEffect::from_ground), span: *span, }), GroundNode::Error { span } => IrItem::Error { span: *span }, @@ -234,6 +269,15 @@ impl IrTailOutput { } } +impl IrBinding { + fn from_raw(binding: LetBinding) -> Self { + match binding { + LetBinding::Value => Self::Value, + LetBinding::Stream => Self::StreamView, + } + } +} + impl IrExpr { pub fn local(name: impl Into<String>, span: Span) -> Self { Self::Local { @@ -241,6 +285,142 @@ impl IrExpr { span, } } + + fn from_ground(expr: &GroundExpr) -> Self { + match expr { + GroundExpr::Local { name, span } => Self::Local { + name: name.clone(), + span: *span, + }, + GroundExpr::Env { name, span } => Self::Env { + name: name.clone(), + span: *span, + }, + GroundExpr::Channel { name, span } => Self::Channel { + name: name.clone(), + span: *span, + }, + GroundExpr::Literal { value, span } => Self::Literal { + value: IrLiteral::from_ground(value), + span: *span, + }, + GroundExpr::Array { items, span } => Self::Array { + items: items.iter().map(Self::from_ground).collect(), + span: *span, + }, + GroundExpr::Map { entries, span } => Self::Map { + entries: entries + .iter() + .map(|(key, value)| (key.clone(), Self::from_ground(value))) + .collect(), + span: *span, + }, + GroundExpr::Process { + program, + args, + span, + } => Self::Call(Box::new(IrCall::process( + IrArgv::from_ground(program), + args.iter().map(IrArgv::from_ground).collect(), + *span, + ))), + } + } +} + +impl IrValueSource { + fn from_ground(source: &super::ground::GroundValueSource) -> Self { + match source { + super::ground::GroundValueSource::Pure(expr) => Self::Pure(IrExpr::from_ground(expr)), + super::ground::GroundValueSource::StreamView(expr) => { + Self::StreamView(IrExpr::from_ground(expr)) + } + super::ground::GroundValueSource::TypeAbsorb { kind, call, span } => { + let IrExpr::Call(call) = IrExpr::from_ground(call) else { + unreachable!("@type.* value sources currently lower call expressions only"); + }; + Self::TypeAbsorb { + kind: IrTypeKind::from_ground(*kind), + call, + span: *span, + } + } + } + } +} + +impl IrLiteral { + fn from_ground(literal: &GroundLiteral) -> Self { + match literal { + GroundLiteral::String(value) => Self::String(value.clone()), + GroundLiteral::Int(value) => Self::Int(value.clone()), + GroundLiteral::Bool(value) => Self::Bool(*value), + GroundLiteral::Null => Self::Null, + } + } +} + +impl IrArgv { + fn from_ground(arg: &GroundArgv) -> Self { + match arg { + GroundArgv::Text { value, span } => Self::Text { + value: value.clone(), + span: *span, + }, + GroundArgv::Expr { expr, span } => Self::Expr { + expr: IrExpr::from_ground(expr), + span: *span, + }, + GroundArgv::Spread { expr, span } => Self::Spread { + expr: IrExpr::from_ground(expr), + span: *span, + }, + } + } +} + +impl IrEffect { + fn from_ground(effect: &GroundEffect) -> Self { + match effect { + GroundEffect::Call { expr, .. } => { + let IrExpr::Call(call) = IrExpr::from_ground(expr) else { + unreachable!("ground call effects only contain call expressions"); + }; + Self::Call(call) + } + GroundEffect::Flow { + op, + left, + right, + span, + } => Self::Flow { + op: IrStreamOp::from_ground(*op), + left: Box::new(IrExpr::from_ground(left)), + right: Box::new(IrExpr::from_ground(right)), + span: *span, + }, + } + } +} + +impl IrStreamOp { + fn from_ground(op: super::ast::StreamOp) -> Self { + match op { + super::ast::StreamOp::To => Self::To, + super::ast::StreamOp::From => Self::From, + } + } +} + +impl IrTypeKind { + fn from_ground(kind: GroundTypeKind) -> Self { + match kind { + GroundTypeKind::String => Self::String, + GroundTypeKind::Bytes => Self::Bytes, + GroundTypeKind::Array => Self::Array, + GroundTypeKind::Map => Self::Map, + } + } } impl IrCall { diff --git a/app/src/core/seal/parser/expr.rs b/app/src/core/seal/parser/expr.rs index 9a8e3e3..d72944f 100644 --- a/app/src/core/seal/parser/expr.rs +++ b/app/src/core/seal/parser/expr.rs @@ -145,16 +145,18 @@ impl Parser { } TokenKind::String => { self.bump(); + let value = self.string_literal_value(&token); RawExpr { span: token.span, - kind: RawExprKind::Literal(RawLiteral::String(token.text)), + kind: RawExprKind::Literal(RawLiteral::String(value)), } } TokenKind::TextBlock => { self.bump(); + let value = self.text_block_value(&token); RawExpr { span: token.span, - kind: RawExprKind::Literal(RawLiteral::TextBlock(token.text)), + kind: RawExprKind::Literal(RawLiteral::TextBlock(value)), } } TokenKind::Keyword(Keyword::True) | TokenKind::Keyword(Keyword::False) => { @@ -329,14 +331,19 @@ impl Parser { while !self.at(TokenKind::RBrace) && !self.at(TokenKind::Eof) { let key_token = self.current().clone(); let key = match key_token.kind { - TokenKind::Ident | TokenKind::String => { + TokenKind::Ident => { self.bump(); key_token.text } + TokenKind::String => { + self.bump(); + self.string_literal_value(&key_token) + } _ => { self.diagnostics .push(Diagnostic::new(key_token.span, "expected map key")); self.recover_until_expr_boundary(); + self.consume_soft_separators(); break; } }; diff --git a/app/src/core/seal/parser/mod.rs b/app/src/core/seal/parser/mod.rs index 1749fe6..66f2b1c 100644 --- a/app/src/core/seal/parser/mod.rs +++ b/app/src/core/seal/parser/mod.rs @@ -47,10 +47,18 @@ impl Parser { let mut items = Vec::new(); self.consume_separators_as_items(&mut items); while !self.at(TokenKind::Eof) { + let cursor_before = self.cursor; let mut item = self.parse_item_or_recover(); item.trailing_comments .extend(self.consume_trailing_comments()); items.push(item); + if self.cursor == cursor_before && !self.at(TokenKind::Eof) { + let token = self.bump(); + self.diagnostics.push(Diagnostic::new( + token.span, + format!("skipping unexpected token {:?}", token.kind), + )); + } self.consume_separators_as_items(&mut items); } let end = self.current().span.end; @@ -255,6 +263,56 @@ impl Parser { } } + fn string_literal_value(&mut self, token: &Token) -> String { + let content = token + .text + .strip_prefix('"') + .unwrap_or(&token.text) + .strip_suffix('"') + .unwrap_or_else(|| token.text.strip_prefix('"').unwrap_or(&token.text)); + let mut value = String::new(); + let mut chars = content.chars(); + while let Some(ch) = chars.next() { + if ch != '\\' { + value.push(ch); + continue; + } + + let Some(escaped) = chars.next() else { + self.diagnostics.push(Diagnostic::new( + token.span, + "unterminated string escape sequence", + )); + break; + }; + match escaped { + '"' => value.push('"'), + '\\' => value.push('\\'), + 'n' => value.push('\n'), + 'r' => value.push('\r'), + 't' => value.push('\t'), + other => { + self.diagnostics.push(Diagnostic::new( + token.span, + format!("unknown string escape '\\{other}'"), + )); + value.push(other); + } + } + } + value + } + + fn text_block_value(&self, token: &Token) -> String { + token + .text + .strip_prefix('`') + .unwrap_or(&token.text) + .strip_suffix('`') + .unwrap_or_else(|| token.text.strip_prefix('`').unwrap_or(&token.text)) + .to_string() + } + fn expect(&mut self, kind: TokenKind, message: &str) -> Token { if self.current().kind == kind { self.bump() diff --git a/app/src/core/seal/parser/process.rs b/app/src/core/seal/parser/process.rs index 0c1c178..96e54e8 100644 --- a/app/src/core/seal/parser/process.rs +++ b/app/src/core/seal/parser/process.rs @@ -43,16 +43,18 @@ impl Parser { TokenKind::Star => self.parse_process_spread(), TokenKind::String => { self.bump(); + let value = self.string_literal_value(&token); RawProcessArg { span: token.span, - kind: RawProcessArgKind::String(token.text), + kind: RawProcessArgKind::String(value), } } TokenKind::TextBlock => { self.bump(); + let value = self.text_block_value(&token); RawProcessArg { span: token.span, - kind: RawProcessArgKind::TextBlock(token.text), + kind: RawProcessArgKind::TextBlock(value), } } _ => self.parse_process_word(), diff --git a/app/tests/seal.rs b/app/tests/seal.rs index 8976eb5..f5a589c 100644 --- a/app/tests/seal.rs +++ b/app/tests/seal.rs @@ -1,3 +1,8 @@ +#[path = "seal/ir.rs"] +mod ir; +#[path = "seal/parser.rs"] +mod parser; + use runseal::core::seal::{ ast::{ LetBinding, RawExprKind, RawItemKind, RawMatchArmBody, RawProcessArgKind, RawProcessPart, diff --git a/app/tests/seal/ir.rs b/app/tests/seal/ir.rs new file mode 100644 index 0000000..459e930 --- /dev/null +++ b/app/tests/seal/ir.rs @@ -0,0 +1,183 @@ +use runseal::core::seal::{ground, ir, parse}; + +#[test] +fn payload_lowering() { + let output = parse( + r#" +method named() { + "value" +} + +let answer = 42 +let logs := | git status +let branch = @type.string(| git branch --show-current) +let commit = @type.string { + | git rev-parse HEAD +} +"done" >> #stdout +"#, + ); + + assert!(output.diagnostics.is_empty()); + let grounded = ground::ground(&output.file); + assert!(grounded.diagnostics.is_empty()); + + let program = ir::lower(&grounded.file); + assert_eq!(program.items.len(), 6); + let ir::IrItem::Method(method) = &program.items[0] else { + panic!("expected method"); + }; + assert_eq!(method.name, "named"); + assert!(matches!(method.tail, ir::IrTailOutput::Implicit { .. })); + + let ir::IrItem::Statement(ir::IrStatement::Let { + name, + binding, + source, + .. + }) = &program.items[1] + else { + panic!("expected value let"); + }; + assert_eq!(name, "answer"); + assert!(matches!(binding, ir::IrBinding::Value)); + assert!(matches!( + source, + Some(ir::IrValueSource::Pure(ir::IrExpr::Literal { + value: ir::IrLiteral::Int(value), + .. + })) if value == "42" + )); + + let ir::IrItem::Statement(ir::IrStatement::Let { + binding, source, .. + }) = &program.items[2] + else { + panic!("expected stream let"); + }; + assert!(matches!(binding, ir::IrBinding::StreamView)); + let Some(ir::IrValueSource::StreamView(ir::IrExpr::Call(call))) = source else { + panic!("expected process call value"); + }; + let ir::IrCallKind::Process { + program: argv_program, + args, + } = &call.kind + else { + panic!("expected process call"); + }; + assert!(matches!( + argv_program, + ir::IrArgv::Text { value, .. } if value == "git" + )); + assert_eq!(args.len(), 1); + assert!(matches!( + &args[0], + ir::IrArgv::Text { value, .. } if value == "status" + )); + + let ir::IrItem::Statement(ir::IrStatement::Let { + binding, source, .. + }) = &program.items[3] + else { + panic!("expected type absorb let"); + }; + assert!(matches!(binding, ir::IrBinding::Value)); + let Some(ir::IrValueSource::TypeAbsorb { kind, call, .. }) = source else { + panic!("expected type absorb source"); + }; + assert!(matches!(kind, ir::IrTypeKind::String)); + let ir::IrCallKind::Process { + program: argv_program, + args, + } = &call.kind + else { + panic!("expected type absorb process call"); + }; + assert!(matches!( + argv_program, + ir::IrArgv::Text { value, .. } if value == "git" + )); + assert_eq!(args.len(), 2); + + let ir::IrItem::Statement(ir::IrStatement::Let { source, .. }) = &program.items[4] else { + panic!("expected block type absorb let"); + }; + let Some(ir::IrValueSource::TypeAbsorb { kind, call, .. }) = source else { + panic!("expected block type absorb source"); + }; + assert!(matches!(kind, ir::IrTypeKind::String)); + let ir::IrCallKind::Process { + program: argv_program, + args, + } = &call.kind + else { + panic!("expected block type absorb process call"); + }; + assert!(matches!( + argv_program, + ir::IrArgv::Text { value, .. } if value == "git" + )); + assert_eq!(args.len(), 2); + + let ir::IrItem::Statement(ir::IrStatement::Effect { effect, .. }) = &program.items[5] else { + panic!("expected effect"); + }; + let Some(ir::IrEffect::Flow { + op, left, right, .. + }) = effect + else { + panic!("expected stream flow"); + }; + assert!(matches!(op, ir::IrStreamOp::To)); + assert!(matches!( + left.as_ref(), + ir::IrExpr::Literal { + value: ir::IrLiteral::String(value), + .. + } if value == "done" + )); + assert!(matches!( + right.as_ref(), + ir::IrExpr::Channel { name, .. } if name == "stdout" + )); +} + +#[test] +fn canonical_call_shapes() { + let span = runseal::core::seal::span::Span::new(0, 12); + let call = ir::IrCall::forward( + ir::IrExpr::local("deploy", span), + vec![ir::IrExpr::local("prod", span)], + span, + ); + assert!(matches!(call.kind, ir::IrCallKind::Forward { .. })); + + let call = ir::IrCall::process( + ir::IrArgv::Text { + value: "gh".to_string(), + span, + }, + Vec::new(), + span, + ); + assert!(matches!(call.kind, ir::IrCallKind::Process { .. })); + + let call = ir::IrCall::receiver(ir::IrExpr::local("text", span), "trim", Vec::new(), span); + assert!(matches!(call.kind, ir::IrCallKind::Receiver { .. })); +} + +#[test] +fn unsupported_type_absorb() { + let output = parse(r#"let text = @type.string("literal")"#); + + assert!(output.diagnostics.is_empty()); + let grounded = ground::ground(&output.file); + assert!(grounded.diagnostics.is_empty()); + + let program = ir::lower(&grounded.file); + let ir::IrItem::Statement(ir::IrStatement::Let { source, .. }) = &program.items[0] else { + panic!("expected let statement"); + }; + assert!(source.is_none()); +} diff --git a/app/tests/seal/parser.rs b/app/tests/seal/parser.rs new file mode 100644 index 0000000..57ea817 --- /dev/null +++ b/app/tests/seal/parser.rs @@ -0,0 +1,72 @@ +use runseal::core::seal::{ + ast::{LetBinding, RawExprKind, RawItemKind, RawLiteral, RawProcessArgKind, RawStatementKind}, + parse, +}; + +#[test] +fn string_values_decode() { + let output = parse("\"line\\nvalue\"\n| printf \"hello\\tthere\"\n`raw\\ntext`\n"); + + assert!(output.diagnostics.is_empty()); + let RawItemKind::Statement(statement) = &output.file.items[0].kind else { + panic!("expected string statement"); + }; + let RawStatementKind::Expr(expr) = &statement.kind else { + panic!("expected string expression"); + }; + assert!(matches!( + &expr.kind, + RawExprKind::Literal(RawLiteral::String(value)) if value == "line\nvalue" + )); + + let RawItemKind::Statement(statement) = &output.file.items[1].kind else { + panic!("expected process statement"); + }; + let RawStatementKind::Effect(expr) = &statement.kind else { + panic!("expected process effect"); + }; + let RawExprKind::Process(process) = &expr.kind else { + panic!("expected process"); + }; + assert!(matches!( + &process.args[0].kind, + RawProcessArgKind::String(value) if value == "hello\tthere" + )); + + let RawItemKind::Statement(statement) = &output.file.items[2].kind else { + panic!("expected text block statement"); + }; + let RawStatementKind::Expr(expr) = &statement.kind else { + panic!("expected text block expression"); + }; + assert!(matches!( + &expr.kind, + RawExprKind::Literal(RawLiteral::TextBlock(value)) if value == "raw\\ntext" + )); +} + +#[test] +fn recovery_braced_binding() { + let output = parse( + r#" +let logs := { + | git status +} +let ok = 1 +"#, + ); + + assert!(!output.diagnostics.is_empty()); + let last = output.file.items.last().expect("expected recovered item"); + let RawItemKind::Statement(statement) = &last.kind else { + panic!("expected recovered statement"); + }; + assert!(matches!( + statement.kind, + RawStatementKind::Let { + ref name, + binding: LetBinding::Value, + .. + } if name == "ok" + )); +} diff --git a/app/tests/seal_ground.rs b/app/tests/seal_ground.rs index 9de5e89..0451769 100644 --- a/app/tests/seal_ground.rs +++ b/app/tests/seal_ground.rs @@ -1,4 +1,4 @@ -use runseal::core::seal::{ground, ir, parse}; +use runseal::core::seal::{ground, parse}; #[test] fn cleanup_frame_event() { @@ -434,63 +434,3 @@ match event { "duplicate map pattern key 'status'" ); } - -#[test] -fn ir_skeleton_lowering() { - let output = parse( - r#" -method named() { - "value" -} - -let answer = 42 -"done" >> #stdout -"#, - ); - - assert!(output.diagnostics.is_empty()); - let grounded = ground::ground(&output.file); - assert!(grounded.diagnostics.is_empty()); - - let program = ir::lower(&grounded.file); - assert_eq!(program.items.len(), 3); - let ir::IrItem::Method(method) = &program.items[0] else { - panic!("expected method"); - }; - assert_eq!(method.name, "named"); - assert!(matches!(method.tail, ir::IrTailOutput::Implicit { .. })); - - let ir::IrItem::Statement(ir::IrStatement::Let { name, value, .. }) = &program.items[1] else { - panic!("expected let skeleton"); - }; - assert_eq!(name, "answer"); - assert!(value.is_none()); - - let ir::IrItem::Statement(ir::IrStatement::Effect { effect, .. }) = &program.items[2] else { - panic!("expected effect skeleton"); - }; - assert!(effect.is_none()); - - let call = ir::IrCall::forward( - ir::IrExpr::local("deploy", method.span), - vec![ir::IrExpr::local("prod", method.span)], - method.span, - ); - assert!(matches!(call.kind, ir::IrCallKind::Forward { .. })); - let call = ir::IrCall::process( - ir::IrArgv::Text { - value: "gh".to_string(), - span: method.span, - }, - Vec::new(), - method.span, - ); - assert!(matches!(call.kind, ir::IrCallKind::Process { .. })); - let call = ir::IrCall::receiver( - ir::IrExpr::local("text", method.span), - "trim", - Vec::new(), - method.span, - ); - assert!(matches!(call.kind, ir::IrCallKind::Receiver { .. })); -}