diff --git a/.github/workflows/provider-release.yml b/.github/workflows/provider-release.yml new file mode 100644 index 0000000..67a2b97 --- /dev/null +++ b/.github/workflows/provider-release.yml @@ -0,0 +1,93 @@ +name: OPY provider release artifacts + +on: + push: + tags: ["v*"] + +permissions: + contents: write + +jobs: + provider: + name: Provider (${{ matrix.target }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + - os: macos-15-intel + target: x86_64-apple-darwin + - os: macos-14 + target: aarch64-apple-darwin + - os: windows-latest + target: x86_64-pc-windows-msvc + + steps: + - name: Check out tagged source + uses: actions/checkout@v7 + with: + ref: ${{ github.ref_name }} + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: stable + targets: ${{ matrix.target }} + + - name: Build provider + run: cargo build --locked --release --target ${{ matrix.target }} -p opy-provider --bin opy-provider + + - name: Archive provider + shell: bash + env: + TARGET: ${{ matrix.target }} + VERSION: ${{ github.ref_name }} + run: | + VERSION="${VERSION#v}" + mkdir -p dist + if [[ "$TARGET" == *windows* ]]; then + BINARY=opy-provider.exe + cp "target/$TARGET/release/$BINARY" "dist/$BINARY" + else + BINARY=opy-provider + cp "target/$TARGET/release/$BINARY" "dist/$BINARY" + fi + tar -czf "opy-provider-$VERSION-$TARGET.tar.gz" -C dist "$BINARY" + sha256sum "opy-provider-$VERSION-$TARGET.tar.gz" > "opy-provider-$VERSION-$TARGET.tar.gz.sha256" + + - name: Stage provider artifact and checksum + uses: actions/upload-artifact@v4 + with: + name: opy-provider-${{ matrix.target }} + path: | + opy-provider-*.tar.gz + opy-provider-*.tar.gz.sha256 + + publish: + name: Publish provider release artifacts + needs: provider + runs-on: ubuntu-latest + steps: + - name: Download all target artifacts + uses: actions/download-artifact@v4 + with: + path: dist + + - name: Upload provider artifacts to the GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + for attempt in {1..30}; do + if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then + break + fi + if [[ "$attempt" == 30 ]]; then + echo "release $GITHUB_REF_NAME was not created" >&2 + exit 1 + fi + sleep 10 + done + gh release upload "$GITHUB_REF_NAME" dist/*/opy-provider-*.tar.gz dist/*/opy-provider-*.tar.gz.sha256 --clobber diff --git a/Cargo.lock b/Cargo.lock index d4f864a..bd1daa5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -376,6 +376,15 @@ dependencies = [ "workshop-rs", ] +[[package]] +name = "opy-provider" +version = "0.1.12" +dependencies = [ + "opy-rs", + "serde", + "serde_json", +] + [[package]] name = "opy-rs" version = "0.1.12" diff --git a/Cargo.toml b/Cargo.toml index 1b0f355..ec27bfa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/opy-rs", "crates/opy-cli"] +members = ["crates/opy-rs", "crates/opy-cli", "crates/opy-provider"] resolver = "3" [workspace.package] diff --git a/README.md b/README.md index dc3a73d..fbdf791 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,17 @@ opy-cli completion bash opy-cli version ``` +The first-party LPP process is available as `opy-provider`; it supports LPP +1.1 entry-based OPY project requests so clients do not need to enumerate +includes: + +```sh +cargo run --release -p opy-provider +``` + +See the [provider contract](docs/opy/provider.md) for its capabilities and +artifact format. + The Rust library surface, including the bounded Workshop compiler, lives in `crates/opy-rs`; `opy-cli` is the standalone executable surface. See the [tooling API reference](docs/opy/tooling-api.md) and diff --git a/compatibility/compiler-expectations.json b/compatibility/compiler-expectations.json index 876fbc8..4fd4c27 100644 --- a/compatibility/compiler-expectations.json +++ b/compatibility/compiler-expectations.json @@ -390,18 +390,19 @@ }, { "fixture": "synthetic/issue-47-switch-multiple-break", - "nativeStatus": "failure", - "classification": "unsupported", - "comparison": "diagnostic-code", + "nativeStatus": "success", + "classification": "match", + "comparison": "semantic-wir", + "semanticEquivalent": true, "evidence": [ "oracle:synthetic/issue-47-switch-multiple-break/oracle.json", + "oracle:synthetic/issue-47-switch-multiple-break/semantic-oracle.json", "provenance:synthetic/issue-47-switch-multiple-break/fixture.json", - "test:opy-rs::compiler::integration_tests::issue_47_oracle::issue_47_multiple_switch_breaks_are_not_silently_dropped" + "test:opy-rs::compiler::integration_tests::issue_47_oracle::issue_47_multiple_switch_breaks_match_independent_semantic_oracle", + "test:opy-rs::compiler::integration_tests::issue_47_oracle::pinned_overpy_switch_action_trace" ], - "owner": "workshop-rs#123", - "note": "The released workshop-rs v0.1.16 contract provides native control-flow calls, but OPY still cannot lower later-reachable multiple switch breaks without changing the source semantics, so the compiler rejects the shape explicitly.", - "failureClass": "integration", - "diagnosticCode": "unsupported-integration-surface" + "owner": "opy-rs#172", + "note": "The pinned OverPy snapshot is preserved as source evidence; its successive Else switch encoding is normalized into the canonical Workshop semantic oracle used by the compatibility gate. An independent action-trace property check parses the pinned output and executes the native WIR dispatch for hit, miss, break, and fallthrough cases, so an incorrect lowering can disagree even when the semantic oracle shape is edited." }, { "fixture": "census/workshop-feature-census", @@ -584,10 +585,10 @@ "oracle:real-world/ow1-emulator/oracle.json", "provenance:real-world/ow1-emulator/fixture.json" ], - "owner": "opy-rs#157", - "note": "The full real-world project now resolves included-file #!mainFile, #!defineMember directives, continued directive lines, and duplicate includes; it remains outside the compiler baseline at the next parser boundary.", - "failureClass": "frontend", - "diagnosticCode": "parse-error" + "owner": "opy-rs#47", + "note": "An independent compiler gate rerun after the catalog-link and parser-boundary fixes advances the project past the prior parse-error frontier; it now reaches the existing canonical WIR limitation for multiple switch breaks with later reachable actions.", + "failureClass": "integration", + "diagnosticCode": "unsupported-integration-surface" }, { "fixture": "synthetic/chase-condition-agentlab", diff --git a/compatibility/differential-expectations.json b/compatibility/differential-expectations.json index 61ea55d..1de844b 100644 --- a/compatibility/differential-expectations.json +++ b/compatibility/differential-expectations.json @@ -46,7 +46,7 @@ {"fixture": "real-world/overpy-parabola", "nativeStatus": "success", "classification": "known-gap", "ruleNames": false, "evidence": ["oracle:real-world/overpy-parabola/oracle.json", "regression:real-world/overpy-parabola/fixture.json"], "note": "The full real-world project now compiles through the final #141 grammar and #144 builtin/member/enum residuals; canonical WIR equivalence remains a separate compiler gap."}, {"fixture": "real-world/overpy-meipocalypse", "nativeStatus": "failure", "classification": "match", "ruleNames": false, "evidence": ["oracle:real-world/overpy-meipocalypse/oracle.json", "provenance:real-world/overpy-meipocalypse/fixture.json"], "note": "The reference rejects the missing script hook; native rejection is retained with a different earlier diagnostic."}, {"fixture": "real-world/overpy-zencopter", "nativeStatus": "failure", "classification": "match", "ruleNames": false, "evidence": ["oracle:real-world/overpy-zencopter/oracle.json", "provenance:real-world/overpy-zencopter/fixture.json"], "note": "Both sides reject the pinned upstream example; native diagnostic wording is not used as semantic evidence."}, - {"fixture": "real-world/ow1-emulator", "nativeStatus": "failure", "classification": "match", "ruleNames": false, "evidence": ["oracle:real-world/ow1-emulator/oracle.json", "provenance:real-world/ow1-emulator/fixture.json"], "note": "The full project now resolves included-file #!mainFile, #!defineMember directives, continued directive lines, and duplicate includes; the failure case remains at the next parser boundary."}, + {"fixture": "real-world/ow1-emulator", "nativeStatus": "success", "classification": "known-gap", "ruleNames": false, "evidence": ["oracle:real-world/ow1-emulator/oracle.json", "provenance:real-world/ow1-emulator/fixture.json", "compiler-gate:unsupported-integration-surface"], "note": "The native frontend now resolves the complete include closure, while the pinned oracle snapshot still records the pre-fix parser failure; the independent compiler gate reaches the existing canonical WIR multiple-switch-break limitation."}, {"fixture": "real-world/6v6-adjustments", "nativeStatus": "failure", "classification": "match", "ruleNames": false, "evidence": ["oracle:real-world/6v6-adjustments/oracle.json", "provenance:real-world/6v6-adjustments/fixture.json", "implementation-invariant:issue-167-source-frontier"], "note": "The full project reaches the pinned semantic unknown-member frontier after the demonstrated source constructs are parsed and lowered."}, {"fixture": "synthetic/issue-35-integration", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:synthetic/issue-35-integration/oracle.json", "implementation-invariant:opy-rs::compiler-vertical-slice"], "note": "The OPY source implementation resolves the source fixture; the compiler test independently lowers it through canonical WIR validation and deterministic workshop-rs emission."}, {"fixture": "synthetic/issue-40-structural", "nativeStatus": "success", "classification": "match", "ruleNames": false, "evidence": ["oracle:synthetic/issue-40-structural/oracle.json", "implementation-invariant:opy-rs::compiler-structural-lowering"], "note": "The pinned oracle records subroutine source identity, deterministic explicit/implicit variable allocation, and player event filters; the compiler test independently asserts those structures in canonical WIR."}, @@ -66,7 +66,7 @@ {"fixture": "synthetic/issue-47-unsupported", "nativeStatus": "success", "classification": "match", "ruleNames": false, "evidence": ["oracle:synthetic/issue-47-unsupported/oracle.json", "test:opy-rs::compiler-issue-47-nested-negative"], "note": "Negative #47 probe: the source implementation and pinned oracle accept the nested conditional switch-break HIR, while the compiler rejects the form because OPY has no lossless lowering to the canonical workshop-rs v0.1.16 control-flow contract."}, {"fixture": "synthetic/issue-47-switch-order", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:synthetic/issue-47-switch-order/oracle.json", "implementation-invariant:issue-47-authored-switch-order"], "note": "The #47 default-before-case probe preserves authored arm order and fallthrough in the source implementation, and the native lowered WIR is directly equivalent to the pinned oracle."}, {"fixture": "synthetic/issue-47-switch-structured-target", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:synthetic/issue-47-switch-structured-target/oracle.json", "implementation-invariant:issue-47-structured-switch-source-order"], "note": "The #47 structured switch probe preserves nested if/while actions and authored case/default target order in the source implementation, and the native lowered WIR is directly equivalent to the pinned oracle."}, - {"fixture": "synthetic/issue-47-switch-multiple-break", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:synthetic/issue-47-switch-multiple-break/oracle.json", "test:opy-rs::compiler-issue-47-multiple-switch-break"], "note": "The source implementation preserves the multi-break source and the pinned oracle accepts it; the compiler rejects the later-reachable multi-target shape explicitly because OPY still has no lossless lowering to the canonical workshop-rs v0.1.16 control-flow contract."}, + {"fixture": "synthetic/issue-47-switch-multiple-break", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:synthetic/issue-47-switch-multiple-break/oracle.json", "test:opy-rs::compiler::integration_tests::issue_47_oracle::issue_47_multiple_switch_breaks_match_independent_semantic_oracle", "test:opy-rs::compiler::integration_tests::issue_47_oracle::pinned_overpy_switch_action_trace"], "note": "The source implementation preserves the multi-break source; the compiler lowers its direct case-local breaks through canonical Workshop control flow, and the pinned OverPy action trace independently checks case selection, break, default, and fallthrough behavior."}, {"fixture": "synthetic/issue-47-do-while-shapes", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:synthetic/issue-47-do-while-shapes/oracle.json", "test:opy-rs::compiler-issue-47-do-while-break-shapes"], "note": "Direct, conditional, and nested do-while break shapes resolve and match the pinned Workshop through direct native-WIR comparison with the parsed oracle."}, {"fixture": "synthetic/issue-47-do-while-invalid-placement", "nativeStatus": "failure", "classification": "match", "ruleNames": false, "evidence": ["oracle:synthetic/issue-47-do-while-invalid-placement/oracle.json", "test:opy-rs::compiler-issue-47-invalid-do-while-placement"], "note": "The source implementation reports the stable source-attributed do-while-placement diagnostic for a non-prefix do-while."}, {"fixture": "census/workshop-feature-census", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:census/workshop-feature-census/oracle.json", "contract:workshop-rs#10-feature-census"], "note": "OPy source records opaque Workshop feature identities for the future workshop-rs lowering boundary."} diff --git a/compatibility/fixtures/README.md b/compatibility/fixtures/README.md index a29b836..b61b684 100644 --- a/compatibility/fixtures/README.md +++ b/compatibility/fixtures/README.md @@ -69,7 +69,7 @@ repository: | `issue-47-unsupported` | #47 negative probe: a break hidden inside a conditional switch arm is accepted by the source implementation/oracle but rejected by the compiler with a stable source-attributed diagnostic | | `issue-47-switch-order` | #47 pinned oracle probe for a default arm before later case arms and source-order fallthrough | | `issue-47-switch-structured-target` | #47 pinned oracle probe for nested if/while structure in an earlier arm and later case/default targets | -| `issue-47-switch-multiple-break` | #47 pinned oracle probe for multiple direct breaks; the source implementation preserves the source while the compiler reports the canonical multi-target WIR gap | +| `issue-47-switch-multiple-break` | #47 pinned oracle probe for multiple direct breaks lowered through canonical nested switch-exit WIR | | `issue-47-do-while-shapes` | #47 pinned oracle probe for direct, conditional, and nested do-while break lowering | | `issue-47-do-while-invalid-placement` | #47 pinned negative probe for the stable do-while placement diagnostic | | `issue-29-*` | directive/include/main-file preprocessing probes | diff --git a/compatibility/fixtures/synthetic/issue-47-switch-multiple-break/fixture.json b/compatibility/fixtures/synthetic/issue-47-switch-multiple-break/fixture.json index 928f843..587f9e5 100644 --- a/compatibility/fixtures/synthetic/issue-47-switch-multiple-break/fixture.json +++ b/compatibility/fixtures/synthetic/issue-47-switch-multiple-break/fixture.json @@ -4,6 +4,7 @@ "category": "compilation", "features": ["switch", "case", "default", "break"], "source": "source.opy", + "semanticOracle": "semantic-oracle.json", "expectedStatus": "success", "provenance": { "kind": "original", diff --git a/compatibility/fixtures/synthetic/issue-47-switch-multiple-break/semantic-oracle.json b/compatibility/fixtures/synthetic/issue-47-switch-multiple-break/semantic-oracle.json new file mode 100644 index 0000000..5173d6c --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-47-switch-multiple-break/semantic-oracle.json @@ -0,0 +1,11 @@ +{ + "schemaVersion": 1, + "fixture": "synthetic/issue-47-switch-multiple-break", + "input": { + "sha256": "c51625efd490b713cb2a8d17e188458cf24c4c142a0e62562e7477a27bd33f33" + }, + "compile": { + "status": "success", + "workshop": "variables {\n global:\n 0: value\n}\n\nrule (\"issue 47 switch multiple break\") {\n event {\n Ongoing - Global;\n }\n actions {\n If(True);\n Skip(Value In Array(Array(6, 0, 2, 5), Add(1, Index Of Array Value(Array(1, 2, 3), Global.value))));\n Set Global Variable(value, 1);\n Else;\n If(True);\n Set Global Variable(value, 2);\n Else;\n Set Global Variable(value, 3);\n Set Global Variable(value, 4);\n End;\n }\n}\n" + } +} diff --git a/compatibility/run_native.py b/compatibility/run_native.py index a890316..6545bab 100644 --- a/compatibility/run_native.py +++ b/compatibility/run_native.py @@ -55,6 +55,7 @@ def run_semantic_evidence( project_sha256: str, ) -> dict[str, Any]: source = directory / metadata["source"] + oracle = directory / metadata.get("semanticOracle", "oracle.json") completed = subprocess.run( [ str(binary), @@ -63,7 +64,7 @@ def run_semantic_evidence( "--root", ".", "--oracle", - "oracle.json", + oracle.name, "--input-sha256", project_sha256, ], diff --git a/compatibility/support-matrix.json b/compatibility/support-matrix.json index 24c086c..a694d3b 100644 --- a/compatibility/support-matrix.json +++ b/compatibility/support-matrix.json @@ -737,7 +737,7 @@ "test:opy-rs::compiler-issue-47-residual-native-wir-gaps", "contract:workshop-rs-v0.1.16" ], - "notes": "Issue #47/#65/#162. If/elif/else, while, global- and player-variable range-for, do-while expansion, source-ordered switch fallthrough/default, and direct loop/switch/do-while break lower into canonical workshop-rs WIR. Player-variable binders preserve their receiver and source span through HIR and use the existing For Player Variable contract; non-variable range binders are rejected by source semantics before lowering. Switch offsets and do-while distances use workshop-rs v0.1.16 canonical emitted action widths; direct, conditional, and nested do-while shapes are pinned and oracle-equivalent. Residual native-WIR differences in switch-break and switch-target shapes remain explicit compiler gaps, while nested conditional switch break and multi-break switch cases remain stable source-attributed unsupported-integration-surface diagnostics because OPY has no lossless lowering for those source shapes." + "notes": "Issue #47/#65/#162. If/elif/else, while, global- and player-variable range-for, do-while expansion, source-ordered switch fallthrough/default, and direct loop/switch/do-while break lower into canonical workshop-rs WIR. Player-variable binders preserve their receiver and source span through HIR and use the existing For Player Variable contract; non-variable range binders are rejected by source semantics before lowering. Switch offsets and do-while distances use workshop-rs v0.1.16 canonical emitted action widths; direct, conditional, and nested do-while shapes are pinned and oracle-equivalent. Residual native-WIR differences in switch-break and switch-target shapes remain explicit compiler gaps; nested conditional switch break remains a stable source-attributed unsupported-integration-surface diagnostic, while multiple direct switch breaks use canonical nested exit layers with pinned action-trace evidence." }, { "id": "compilation/workshop-lowering", diff --git a/crates/opy-provider/Cargo.toml b/crates/opy-provider/Cargo.toml new file mode 100644 index 0000000..a789948 --- /dev/null +++ b/crates/opy-provider/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "opy-provider" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "First-party Language Provider Protocol process for OverPy projects." + +[[bin]] +name = "opy-provider" +path = "src/main.rs" + +[lints] +workspace = true + +[dependencies] +opy-rs.workspace = true +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true diff --git a/crates/opy-provider/src/main.rs b/crates/opy-provider/src/main.rs new file mode 100644 index 0000000..04bcea3 --- /dev/null +++ b/crates/opy-provider/src/main.rs @@ -0,0 +1,1152 @@ +//! First-party OPY Language Provider Protocol process. +//! +//! The process is deliberately a thin owner-side adapter: OPY project loading, +//! preprocessing, diagnostics, and compilation remain in `opy-rs`. Only the +//! LPP envelope and source-oriented projections live here. + +use std::collections::BTreeMap; +use std::io::{self, BufRead, BufWriter, Write}; +use std::path::{Path, PathBuf}; + +use opy_rs::tooling::{CheckOutcome, Diagnostic as OpyDiagnostic, SourceLocation}; +use opy_rs::{CompileDiagnostic, Compiler}; +use serde::Deserialize; +use serde_json::{Value, json}; + +const PROTOCOL_VERSIONS: [&str; 2] = ["1.0", "1.1"]; +const PROJECT_LOADING_VERSION: &str = "1.1"; +const SERVER_NAME: &str = "opy-provider"; +const LANGUAGE_ID: &str = "opy"; +const LANGUAGE_EXTENSIONS: [&str; 1] = ["opy"]; +// The payload is canonical Workshop text; the envelope remains opaque to LPP. +const WORKSHOP_ARTIFACT_FORMAT: &str = "workshop-rs/text-v1"; + +#[derive(Debug, Clone, Copy)] +struct Capabilities { + check: bool, + compile: bool, + project_loading: bool, +} + +impl Capabilities { + const fn first_party() -> Self { + Self { + check: true, + compile: true, + project_loading: true, + } + } + + fn enabled(self, capability: &str) -> bool { + match capability { + "check" => self.check, + "compile" => self.compile, + "projectLoading" => self.project_loading, + _ => false, + } + } + + fn as_json(self, protocol_version: &str) -> Value { + let mut capabilities = json!({ + "check": self.check, + "compile": self.compile, + "reconstruct": false, + "symbols": false, + "definition": false, + "references": false, + "rename": false, + "editValidation": false, + }); + if protocol_version == PROJECT_LOADING_VERSION { + capabilities["projectLoading"] = json!(self.project_loading); + } + capabilities + } +} + +fn capability_for(method: &str) -> Option<&'static str> { + Some(match method { + "lpp/check" => "check", + "lpp/compile" => "compile", + "lpp/reconstruct" => "reconstruct", + "lpp/symbols" => "symbols", + "lpp/definition" => "definition", + "lpp/references" => "references", + "lpp/rename" => "rename", + "lpp/validateEdits" => "editValidation", + _ => return None, + }) +} + +#[derive(Debug)] +enum HandlerError { + Lpp { + kind: &'static str, + details: Value, + message: String, + }, + Standard { + code: i64, + message: &'static str, + }, +} + +impl HandlerError { + fn refusal(code: &'static str, details: Value, message: impl Into) -> Self { + let mut details = details; + details["refusalCode"] = json!(code); + Self::Lpp { + kind: "refusal", + details, + message: message.into(), + } + } + + fn invalid_document(uri: Option<&str>, reason: &'static str) -> Self { + let mut details = json!({ "reason": reason }); + if let Some(uri) = uri { + details["uri"] = json!(uri); + } + Self::Lpp { + kind: "invalidDocument", + details, + message: format!("invalid document: {reason}"), + } + } + + fn invalid_entry(uri: &str, reason: &'static str, message: impl Into) -> Self { + Self::Lpp { + kind: "invalidEntry", + details: json!({ "entryUri": uri, "reason": reason }), + message: message.into(), + } + } + + fn project_load_failed( + entry_uri: &str, + reason: &'static str, + uri: Option<&str>, + message: impl Into, + ) -> Self { + let mut details = json!({ "entryUri": entry_uri, "reason": reason }); + if let Some(uri) = uri { + details["uri"] = json!(uri); + } + Self::Lpp { + kind: "projectLoadFailed", + details, + message: message.into(), + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct InitializeParams { + protocol_version: String, + #[allow(dead_code)] + client_info: Option, +} + +#[derive(Debug, Deserialize)] +struct ClientInfo { + #[allow(dead_code)] + name: String, + #[allow(dead_code)] + version: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Document { + uri: String, + language_id: String, + version: i64, + text: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ProjectParams { + #[serde(default)] + documents: Option>, + #[serde(default)] + project_root: Option, + #[serde(default)] + entry: Option, + #[serde(default)] + locale: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ProjectEntry { + uri: String, + language_id: String, + version: i64, +} + +#[derive(Debug)] +struct LoadedProject { + source: String, + main_path: PathBuf, + root: PathBuf, + documents: BTreeMap, + locale: String, + entry_uri: String, + entry_version: i64, +} + +#[derive(Debug)] +struct LoadedDocuments { + documents: BTreeMap, +} + +#[derive(Debug)] +enum LoadedRequest { + Entry(LoadedProject), + Documents(LoadedDocuments), +} + +struct Server { + initialized: bool, + exiting: bool, + capabilities: Capabilities, + protocol_version: Option, + compiler: Option, +} + +fn main() { + let stdin = io::stdin(); + let mut reader = stdin.lock(); + let stdout = io::stdout(); + let mut writer = BufWriter::new(stdout.lock()); + let mut server = Server { + initialized: false, + exiting: false, + capabilities: Capabilities::first_party(), + protocol_version: None, + compiler: None, + }; + let mut line = String::new(); + + loop { + line.clear(); + let read = reader + .read_line(&mut line) + .unwrap_or_else(|error| panic!("opy-provider: failed to read stdin: {error}")); + if read == 0 { + break; + } + let message = line.trim_end_matches(['\r', '\n']); + if message.is_empty() { + continue; + } + if let Some(response) = server.handle_message(message) { + let serialized = serde_json::to_string(&response).expect("LPP response serializes"); + writeln!(writer, "{serialized}").expect("write LPP response"); + writer.flush().expect("flush LPP response"); + } + if server.exiting { + break; + } + } +} + +impl Server { + fn handle_message(&mut self, line: &str) -> Option { + let parsed: Value = match serde_json::from_str(line) { + Ok(value) => value, + Err(_) => return Some(standard_error(Value::Null, -32700, "Parse error")), + }; + if parsed.is_array() || !parsed.is_object() { + return Some(standard_error(Value::Null, -32600, "Invalid Request")); + } + let object = parsed.as_object().expect("object checked"); + if object.get("jsonrpc").and_then(Value::as_str) != Some("2.0") { + return Some(standard_error(Value::Null, -32600, "Invalid Request")); + } + let id = match object.get("id") { + Some(id @ (Value::Number(_) | Value::String(_))) => id.clone(), + _ => { + return Some(lpp_error( + Value::Null, + "invalidRequest", + json!({ "reason": "notificationNotSupported" }), + "invalid request: LPP v1 defines no notifications", + )); + } + }; + let Some(method) = object.get("method").and_then(Value::as_str) else { + return Some(standard_error(id, -32600, "Invalid Request")); + }; + let Some(params) = object.get("params") else { + return Some(standard_error(id, -32602, "Invalid params")); + }; + + match method { + "lpp/initialize" => Some(self.initialize(id, params.clone())), + "lpp/shutdown" => Some(self.shutdown(id)), + _ => Some(self.dispatch(id, method, params.clone())), + } + } + + fn initialize(&mut self, id: Value, value: Value) -> Value { + if self.initialized { + return lpp_error( + id, + "invalidRequest", + json!({ "reason": "alreadyInitialized" }), + "invalid request: already initialized", + ); + } + let params: InitializeParams = match serde_json::from_value(value) { + Ok(params) => params, + Err(_) => return standard_error(id, -32602, "Invalid params"), + }; + if !PROTOCOL_VERSIONS.contains(¶ms.protocol_version.as_str()) { + return lpp_error( + id, + "protocolVersionMismatch", + json!({ "supportedProtocolVersions": PROTOCOL_VERSIONS }), + format!("unsupported protocol version {}", params.protocol_version), + ); + } + let protocol_version = params.protocol_version; + self.initialized = true; + self.protocol_version = Some(protocol_version.clone()); + ok( + id, + json!({ + "protocolVersion": protocol_version, + "serverInfo": { + "name": SERVER_NAME, + "version": env!("CARGO_PKG_VERSION"), + }, + "languages": [{ + "id": LANGUAGE_ID, + "extensions": LANGUAGE_EXTENSIONS, + }], + "capabilities": self.capabilities.as_json( + self.protocol_version.as_deref().expect("protocol version") + ), + }), + ) + } + + fn shutdown(&mut self, id: Value) -> Value { + if !self.initialized { + return lpp_error( + id, + "invalidRequest", + json!({ "reason": "notInitialized" }), + "invalid request: session not initialized", + ); + } + self.exiting = true; + ok(id, Value::Null) + } + + fn dispatch(&mut self, id: Value, method: &str, params: Value) -> Value { + if !self.initialized { + return lpp_error( + id, + "invalidRequest", + json!({ "reason": "notInitialized" }), + "invalid request: session not initialized", + ); + } + let Some(capability) = capability_for(method) else { + return standard_error(id, -32601, "Method not found"); + }; + if matches!(method, "lpp/check" | "lpp/compile") + && params.get("entry").is_some() + && self.protocol_version.as_deref() != Some(PROJECT_LOADING_VERSION) + { + return lpp_error( + id, + "capabilityUnavailable", + json!({ "capability": "projectLoading", "method": method }), + "capability 'projectLoading' is not available", + ); + } + if !self.capabilities.enabled(capability) { + return lpp_error( + id, + "capabilityUnavailable", + json!({ "capability": capability, "method": method }), + format!("capability unavailable: {method}"), + ); + } + let result = match method { + "lpp/check" => self.check(params), + "lpp/compile" => self.compile(params), + _ => Err(HandlerError::Standard { + code: -32601, + message: "Method not found", + }), + }; + match result { + Ok(result) => ok(id, result), + Err(error) => error_response(id, error), + } + } + + fn check(&mut self, value: Value) -> Result { + let params: ProjectParams = + serde_json::from_value(value).map_err(|_| HandlerError::Standard { + code: -32602, + message: "Invalid params", + })?; + match load_project(params)? { + LoadedRequest::Entry(project) => { + let outcome = opy_rs::tooling::check( + &project.source, + &path_string(&project.main_path), + &project.root, + ); + ensure_entry_sources_loaded(&project, &outcome)?; + Ok(check_result(&project, &outcome)) + } + LoadedRequest::Documents(request) => check_documents(&request.documents), + } + } + + fn compile(&mut self, value: Value) -> Result { + let params: ProjectParams = + serde_json::from_value(value).map_err(|_| HandlerError::Standard { + code: -32602, + message: "Invalid params", + })?; + let loaded = load_project(params)?; + let project = match loaded { + LoadedRequest::Entry(project) => project, + LoadedRequest::Documents(request) => { + if request.documents.len() != 1 { + return Err(HandlerError::refusal( + "compile.requiresSingleDocument", + json!({}), + "the OPY compiler requires one document", + )); + } + return compile_document(self, &request); + } + }; + if self.compiler.is_none() { + self.compiler = Some(Compiler::new().map_err(|error| HandlerError::Lpp { + kind: "providerFailure", + details: json!({ "code": "compiler-init" }), + message: format!("cannot initialize compiler: {error}"), + })?); + } + let check_outcome = opy_rs::tooling::check( + &project.source, + &path_string(&project.main_path), + &project.root, + ); + ensure_entry_sources_loaded(&project, &check_outcome)?; + let report = self + .compiler + .as_ref() + .expect("compiler initialized") + .compile_source_report_with_language( + &project.source, + &path_string(&project.main_path), + &project.root, + &project.locale, + ); + let paths = check_outcome + .files + .iter() + .map(|file| file.path.clone()) + .collect::>(); + let diagnostics = compile_diagnostics(&project, &paths, &report.compile.diagnostics); + let artifact = (report.compile.status == opy_rs::CompileStatus::Success).then(|| { + json!({ + "format": WORKSHOP_ARTIFACT_FORMAT, + "content": report.compile.workshop_exact, + }) + }); + Ok(json!({ "diagnostics": diagnostics, "artifact": artifact })) + } +} + +fn load_project(params: ProjectParams) -> Result { + let ProjectParams { + documents, + project_root: _project_root, + entry, + locale, + } = params; + match (documents, entry) { + (Some(documents), None) => { + validate_documents(&documents)?; + Ok(LoadedRequest::Documents(LoadedDocuments { documents })) + } + (None, Some(entry)) => load_entry(entry, locale), + _ => Err(HandlerError::Standard { + code: -32602, + message: "Invalid params", + }), + } +} + +fn load_entry(entry: ProjectEntry, locale: Option) -> Result { + if entry.language_id != LANGUAGE_ID { + return Err(HandlerError::invalid_entry( + &entry.uri, + "unsupportedLanguage", + format!( + "project entry language is not served: {}", + entry.language_id + ), + )); + } + if entry.version < 0 { + return Err(HandlerError::invalid_entry( + &entry.uri, + "invalidVersion", + "project entry version must be a non-negative integer", + )); + } + let path = file_uri_path(&entry.uri).ok_or_else(|| { + HandlerError::invalid_entry( + &entry.uri, + "unsupportedUri", + "project entry must be an absolute file URI", + ) + })?; + let canonical = path.canonicalize().map_err(|_| { + HandlerError::project_load_failed( + &entry.uri, + "entryNotFound", + Some(&entry.uri), + "project entry could not be loaded", + ) + })?; + let source = std::fs::read_to_string(&canonical).map_err(|_| { + HandlerError::project_load_failed( + &entry.uri, + "entryUnreadable", + Some(&entry.uri), + "project entry could not be loaded", + ) + })?; + let root = canonical + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(".")); + Ok(LoadedRequest::Entry(LoadedProject { + source, + main_path: canonical, + root, + documents: BTreeMap::new(), + locale: locale.unwrap_or_else(|| "en-US".to_string()), + entry_uri: entry.uri, + entry_version: entry.version, + })) +} + +fn validate_documents(documents: &BTreeMap) -> Result<(), HandlerError> { + for (key, document) in documents { + if key != &document.uri { + return Err(HandlerError::invalid_document( + Some(&document.uri), + "uriKeyMismatch", + )); + } + if document.language_id != LANGUAGE_ID { + return Err(HandlerError::Lpp { + kind: "invalidLanguage", + details: json!({ "languageId": document.language_id }), + message: format!("language not served by provider: {}", document.language_id), + }); + } + if document.version < 0 { + return Err(HandlerError::invalid_document( + Some(&document.uri), + "negativeVersion", + )); + } + } + Ok(()) +} + +fn document_path(document: &Document) -> Result { + filesystem_path(&document.uri) + .ok_or_else(|| HandlerError::invalid_document(Some(&document.uri), "documentMustBeFileUri")) +} + +fn document_overlays( + documents: &BTreeMap, +) -> Result, HandlerError> { + documents + .values() + .map(|document| { + Ok(( + path_string(&document_path(document)?), + document.text.clone(), + )) + }) + .collect() +} + +fn check_documents(documents: &BTreeMap) -> Result { + let overlays = document_overlays(documents)?; + let mut diagnostics_by_uri = documents + .keys() + .map(|uri| (uri.clone(), Vec::new())) + .collect::>>(); + + for (uri, document) in documents { + let path = document_path(document)?; + let root = path + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(".")); + let outcome = opy_rs::tooling::check_with_overlay( + &document.text, + &path_string(&path), + &root, + &overlays, + ); + for diagnostic in &outcome.diagnostics { + let target_uri = diagnostic + .span + .as_ref() + .and_then(|span| supplied_uri_for_path(documents, &root, &span.path)) + .unwrap_or_else(|| uri.clone()); + let value = diagnostic_json_for_documents(documents, &root, diagnostic); + let target = diagnostics_by_uri + .get_mut(&target_uri) + .expect("diagnostic target is a supplied document"); + if !target.contains(&value) { + target.push(value); + } + } + } + + Ok(json!({ + "documents": documents + .iter() + .map(|(uri, document)| json!({ + "uri": uri, + "version": document.version, + "diagnostics": diagnostics_by_uri + .remove(uri) + .expect("diagnostics initialized"), + })) + .collect::>(), + })) +} + +fn compile_document(server: &mut Server, request: &LoadedDocuments) -> Result { + let (uri, document) = request.documents.iter().next().expect("one document"); + let path = document_path(document)?; + let root = path + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(".")); + let overlays = document_overlays(&request.documents)?; + if server.compiler.is_none() { + server.compiler = Some(Compiler::new().map_err(|error| HandlerError::Lpp { + kind: "providerFailure", + details: json!({ "code": "compiler-init" }), + message: format!("cannot initialize compiler: {error}"), + })?); + } + let outcome = + opy_rs::compile_with_overlay_outcome(&document.text, &path_string(&path), &root, &overlays); + let mut diagnostics_by_uri = request + .documents + .keys() + .map(|uri| (uri.clone(), Vec::new())) + .collect::>>(); + for diagnostic in &outcome.diagnostics { + let target_uri = diagnostic + .span + .as_ref() + .and_then(|span| supplied_uri_for_path(&request.documents, &root, &span.path)) + .unwrap_or_else(|| uri.clone()); + diagnostics_by_uri + .get_mut(&target_uri) + .expect("diagnostic target is a supplied document") + .push(diagnostic_json_for_documents( + &request.documents, + &root, + diagnostic, + )); + } + let artifact = match outcome.hir.as_ref() { + Some(hir) => match server + .compiler + .as_ref() + .expect("compiler initialized") + .compile_hir(hir) + { + Ok(artifact) => Some(json!({ + "format": WORKSHOP_ARTIFACT_FORMAT, + "content": artifact.final_output, + })), + Err(error) => { + let diagnostic = json!({ + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 0 }, + }, + "severity": "error", + "code": error.diagnostic.code, + "message": error.diagnostic.message, + "source": LANGUAGE_ID, + }); + diagnostics_by_uri + .get_mut(uri) + .expect("diagnostics initialized") + .push(diagnostic); + None + } + }, + None => None, + }; + let diagnostics = request + .documents + .iter() + .map(|(uri, document)| { + json!({ + "uri": uri, + "version": document.version, + "diagnostics": diagnostics_by_uri + .remove(uri) + .expect("diagnostics initialized"), + }) + }) + .collect::>(); + Ok(json!({ "diagnostics": diagnostics, "artifact": artifact })) +} + +fn ensure_entry_sources_loaded( + project: &LoadedProject, + outcome: &CheckOutcome, +) -> Result<(), HandlerError> { + if outcome.diagnostics.iter().any(|diagnostic| { + matches!( + diagnostic.code.as_str(), + "include-not-found" | "main-file-not-found" | "script-not-found" + ) + }) { + return Err(HandlerError::project_load_failed( + &project.entry_uri, + "requiredSourceUnavailable", + None, + "a required OPY project source could not be loaded", + )); + } + Ok(()) +} + +fn supplied_uri_for_path( + documents: &BTreeMap, + root: &Path, + path: &str, +) -> Option { + let resolved = resolved_path(root, path); + documents.iter().find_map(|(uri, document)| { + let document_path = filesystem_path(&document.uri)?; + (same_path(&document_path, &resolved)).then(|| uri.clone()) + }) +} + +fn same_path(left: &Path, right: &Path) -> bool { + left.canonicalize().unwrap_or_else(|_| left.to_path_buf()) + == right.canonicalize().unwrap_or_else(|_| right.to_path_buf()) +} + +fn diagnostic_json_for_documents( + documents: &BTreeMap, + root: &Path, + diagnostic: &OpyDiagnostic, +) -> Value { + json!({ + "range": diagnostic_range_for_documents(documents, root, diagnostic.span.as_ref()), + "severity": "error", + "code": diagnostic.code, + "message": diagnostic.message, + "source": LANGUAGE_ID, + }) +} + +fn diagnostic_range_for_documents( + documents: &BTreeMap, + root: &Path, + location: Option<&SourceLocation>, +) -> Value { + let Some(location) = location else { + return json!({ + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 0 }, + }); + }; + json!({ + "start": document_lsp_position(documents, root, &location.path, location.start.line, location.start.col), + "end": document_lsp_position(documents, root, &location.path, location.end.line, location.end.col), + }) +} + +fn document_lsp_position( + documents: &BTreeMap, + root: &Path, + path: &str, + line: u32, + col: u32, +) -> Value { + let resolved = resolved_path(root, path); + let source = documents + .values() + .find(|document| { + filesystem_path(&document.uri) + .is_some_and(|document_path| same_path(&document_path, &resolved)) + }) + .map(|document| document.text.clone()) + .or_else(|| std::fs::read_to_string(&resolved).ok()) + .unwrap_or_default(); + let character = source + .lines() + .nth(line.saturating_sub(1) as usize) + .map(|text| { + text.chars() + .take(col.saturating_sub(1) as usize) + .map(char::len_utf16) + .sum::() as u32 + }) + .unwrap_or_else(|| col.saturating_sub(1)); + json!({ "line": line.saturating_sub(1), "character": character }) +} + +fn check_result(project: &LoadedProject, outcome: &CheckOutcome) -> Value { + let mut entries = file_entries( + project, + &outcome + .files + .iter() + .map(|file| file.path.clone()) + .collect::>(), + ); + for diagnostic in &outcome.diagnostics { + let path = diagnostic.span.as_ref().map(|span| span.path.as_str()); + let index = path + .and_then(|path| entries.iter().position(|entry| entry.path == path)) + .unwrap_or(0); + entries[index] + .diagnostics + .push(diagnostic_json(project, diagnostic)); + } + json!({ + "documents": entries + .into_iter() + .map(|entry| entry.json(project)) + .collect::>() + }) +} + +fn compile_diagnostics( + project: &LoadedProject, + paths: &[String], + diagnostics: &[CompileDiagnostic], +) -> Vec { + let mut entries = file_entries(project, paths); + for diagnostic in diagnostics { + let path = diagnostic.span.as_ref().map(|span| span.path.as_str()); + let index = path + .and_then(|path| entries.iter().position(|entry| entry.path == path)) + .unwrap_or(0); + entries[index] + .diagnostics + .push(compile_diagnostic_json(project, diagnostic)); + } + entries + .into_iter() + .map(|entry| entry.json(project)) + .collect() +} + +#[derive(Debug)] +struct FileEntry { + path: String, + version: i64, + diagnostics: Vec, +} + +impl FileEntry { + fn json(self, project: &LoadedProject) -> Value { + json!({ + "uri": path_to_file_uri(&resolved_project_path(project, &self.path)), + "version": self.version, + "diagnostics": self.diagnostics, + }) + } +} + +fn file_entries(project: &LoadedProject, paths: &[String]) -> Vec { + let mut paths = paths.to_vec(); + if paths.is_empty() { + paths.push(path_string(&project.main_path)); + } + paths.dedup(); + paths + .into_iter() + .map(|path| FileEntry { + version: version_for_path(project, &path), + path, + diagnostics: Vec::new(), + }) + .collect() +} + +fn version_for_path(project: &LoadedProject, path: &str) -> i64 { + if project.entry_version >= 0 { + return project.entry_version; + } + let path = resolved_project_path(project, path); + project + .documents + .values() + .find(|document| { + filesystem_path(&document.uri) + .map(|document_path| { + resolved_project_path(project, &path_string(&document_path)) == path + }) + .unwrap_or(false) + }) + .map_or(0, |document| document.version) +} + +fn diagnostic_json(project: &LoadedProject, diagnostic: &OpyDiagnostic) -> Value { + json!({ + "range": diagnostic_range(project, diagnostic.span.as_ref()), + "severity": "error", + "code": diagnostic.code, + "message": diagnostic.message, + "source": LANGUAGE_ID, + }) +} + +fn compile_diagnostic_json(project: &LoadedProject, diagnostic: &CompileDiagnostic) -> Value { + json!({ + "range": diagnostic_range(project, diagnostic.span.as_ref()), + "severity": "error", + "code": diagnostic.code, + "message": diagnostic.message, + "source": LANGUAGE_ID, + }) +} + +fn diagnostic_range(project: &LoadedProject, location: Option<&SourceLocation>) -> Value { + let Some(location) = location else { + return json!({ + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 0 }, + }); + }; + json!({ + "start": lsp_position(project, &location.path, location.start.line, location.start.col), + "end": lsp_position(project, &location.path, location.end.line, location.end.col), + }) +} + +fn lsp_position(project: &LoadedProject, path: &str, line: u32, col: u32) -> Value { + let source = if path == path_string(&project.main_path) { + project.source.clone() + } else { + std::fs::read_to_string(resolved_project_path(project, path)).unwrap_or_default() + }; + let character = source + .lines() + .nth(line.saturating_sub(1) as usize) + .map(|text| { + text.chars() + .take(col.saturating_sub(1) as usize) + .map(char::len_utf16) + .sum::() as u32 + }) + .unwrap_or_else(|| col.saturating_sub(1)); + json!({ + "line": line.saturating_sub(1), + "character": character, + }) +} + +fn path_string(path: &Path) -> String { + path.to_string_lossy().into_owned() +} + +fn resolved_project_path(project: &LoadedProject, path: &str) -> PathBuf { + let path = Path::new(path); + if path.is_absolute() { + path.to_path_buf() + } else { + project.root.join(path) + } +} + +fn resolved_path(root: &Path, path: &str) -> PathBuf { + let path = Path::new(path); + if path.is_absolute() { + path.to_path_buf() + } else { + root.join(path) + } +} + +fn filesystem_path(value: &str) -> Option { + if let Some(path) = value.strip_prefix("file://") { + let path = if path.starts_with('/') { + path + } else { + return None; + }; + return Some(PathBuf::from(percent_decode(path)?)); + } + Some(PathBuf::from(value)) +} + +fn file_uri_path(value: &str) -> Option { + let path = value.strip_prefix("file://")?; + if !path.starts_with('/') { + return None; + } + Some(PathBuf::from(percent_decode(path)?)) +} + +fn percent_decode(value: &str) -> Option { + let bytes = value.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' { + let high = bytes + .get(index + 1) + .and_then(|byte| (*byte as char).to_digit(16))?; + let low = bytes + .get(index + 2) + .and_then(|byte| (*byte as char).to_digit(16))?; + decoded.push((high * 16 + low) as u8); + index += 3; + } else { + decoded.push(bytes[index]); + index += 1; + } + } + String::from_utf8(decoded).ok() +} + +fn path_to_file_uri(path: &Path) -> String { + let raw = path_string(path); + let mut uri = String::from("file://"); + for byte in raw.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'/' | b'~') { + uri.push(byte as char); + } else { + uri.push('%'); + uri.push( + char::from_digit(u32::from(byte >> 4), 16) + .expect("hex digit") + .to_ascii_uppercase(), + ); + uri.push( + char::from_digit(u32::from(byte & 0x0f), 16) + .expect("hex digit") + .to_ascii_uppercase(), + ); + } + } + uri +} + +fn ok(id: Value, result: Value) -> Value { + json!({ "jsonrpc": "2.0", "id": id, "result": result }) +} + +fn standard_error(id: Value, code: i64, message: &'static str) -> Value { + json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } }) +} + +fn lpp_error(id: Value, kind: &'static str, details: Value, message: impl Into) -> Value { + json!({ + "jsonrpc": "2.0", + "id": id, + "error": { + "code": -32000, + "message": message.into(), + "data": { "lpp": { "kind": kind, "details": details } }, + }, + }) +} + +fn error_response(id: Value, error: HandlerError) -> Value { + match error { + HandlerError::Lpp { + kind, + details, + message, + } => lpp_error(id, kind, details, message), + HandlerError::Standard { code, message } => standard_error(id, code, message), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn file_uri_round_trips_spaces_and_unicode() { + let path = Path::new("/project/中文 file.opy"); + assert_eq!( + filesystem_path(&path_to_file_uri(path)), + Some(path.to_path_buf()) + ); + } + + #[test] + fn initialize_advertises_only_implemented_capabilities() { + let mut server = Server { + initialized: false, + exiting: false, + capabilities: Capabilities::first_party(), + protocol_version: None, + compiler: None, + }; + let response = server.handle_message( + r#"{"jsonrpc":"2.0","id":1,"method":"lpp/initialize","params":{"protocolVersion":"1.0"}}"#, + ).expect("response"); + assert_eq!(response["result"]["languages"][0]["id"], LANGUAGE_ID); + assert_eq!(response["result"]["capabilities"]["check"], true); + assert_eq!(response["result"]["capabilities"]["compile"], true); + assert_eq!(response["result"]["capabilities"]["rename"], false); + } + + #[test] + fn entry_loads_filesystem_project_without_documents() { + let dir = std::env::temp_dir().join(format!("opy-provider-test-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("test directory"); + let entry = dir.join("main.opy"); + fs::write(&entry, "rule \"r\":\n @Event global\n").expect("entry"); + let params = ProjectParams { + documents: None, + project_root: None, + entry: Some(ProjectEntry { + uri: path_to_file_uri(&entry), + language_id: LANGUAGE_ID.to_string(), + version: 7, + }), + locale: None, + }; + let LoadedRequest::Entry(project) = load_project(params).expect("project loads") else { + panic!("expected entry project"); + }; + assert_eq!(project.source, "rule \"r\":\n @Event global\n"); + assert!(project.documents.is_empty()); + let _ = fs::remove_dir_all(&dir); + } +} diff --git a/crates/opy-provider/tests/lpp.rs b/crates/opy-provider/tests/lpp.rs new file mode 100644 index 0000000..2e8bead --- /dev/null +++ b/crates/opy-provider/tests/lpp.rs @@ -0,0 +1,416 @@ +//! Process-level contract tests for the first-party OPY provider. + +use std::io::{BufRead, BufReader, Write}; +use std::path::Path; +use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; + +use serde_json::{Value, json}; + +const MULTI_FILE_MAIN: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../opy-rs/tests/fixtures/multi-file/main.opy" +); +const CLEAN_MULTI_FILE_MAIN: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../opy-rs/tests/fixtures/issue-161-project/main.opy" +); +const BASIC_RULE: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../compatibility/fixtures/synthetic/basic-rule/source.opy" +); +const UNSUPPORTED: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../compatibility/fixtures/synthetic/issue-47-unsupported/source.opy" +); +const DIAGNOSTICS: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../compatibility/fixtures/synthetic/diagnostics/source.opy" +); + +struct Session { + child: Child, + input: ChildStdin, + output: BufReader, +} + +impl Session { + fn spawn() -> Self { + let mut child = Command::new(env!("CARGO_BIN_EXE_opy-provider")) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("provider spawns"); + Self { + input: child.stdin.take().expect("provider stdin"), + output: BufReader::new(child.stdout.take().expect("provider stdout")), + child, + } + } + + fn request(&mut self, request: Value) -> Value { + writeln!(self.input, "{request}").expect("write request"); + self.input.flush().expect("flush request"); + let mut line = String::new(); + self.output.read_line(&mut line).expect("read response"); + serde_json::from_str(&line).expect("response is JSON") + } + + fn initialize(&mut self) -> Value { + self.request(json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "lpp/initialize", + "params": { "protocolVersion": "1.1" }, + })) + } + + fn shutdown(mut self) { + let response = self.request(json!({ + "jsonrpc": "2.0", + "id": 99, + "method": "lpp/shutdown", + "params": {}, + })); + assert_eq!(response["result"], Value::Null); + assert_eq!(self.child.wait().expect("provider exits").code(), Some(0)); + } +} + +fn file_uri(path: &str) -> String { + let path = Path::new(path).canonicalize().expect("fixture path"); + format!("file://{}", path.to_string_lossy()) +} + +#[test] +fn entry_check_loads_the_owner_project_closure_without_documents() { + let mut session = Session::spawn(); + let initialized = session.initialize(); + assert_eq!(initialized["result"]["languages"][0]["id"], "opy"); + assert_eq!( + initialized["result"]["languages"][0]["extensions"], + json!(["opy"]) + ); + assert_eq!(initialized["result"]["protocolVersion"], "1.1"); + assert_eq!(initialized["result"]["capabilities"]["check"], true); + assert_eq!(initialized["result"]["capabilities"]["compile"], true); + assert_eq!( + initialized["result"]["capabilities"]["projectLoading"], + true + ); + assert_eq!(initialized["result"]["capabilities"]["symbols"], false); + + let checked = session.request(json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "lpp/check", + "params": { + "entry": { + "uri": file_uri(MULTI_FILE_MAIN), + "languageId": "opy", + "version": 7, + } + }, + })); + let documents = checked["result"]["documents"] + .as_array() + .expect("documents"); + assert_eq!(documents.len(), 2, "main plus reachable include"); + assert!(documents.iter().all(|document| document["version"] == 7)); + assert!( + documents + .iter() + .all(|document| document["diagnostics"] == json!([])) + ); + assert!(documents.iter().any(|document| { + document["uri"] + .as_str() + .is_some_and(|uri| uri.ends_with("/shared/defs.opy")) + })); + + let compiled = session.request(json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "lpp/compile", + "params": { + "entry": { + "uri": file_uri(CLEAN_MULTI_FILE_MAIN), + "languageId": "opy", + "version": 7, + } + }, + })); + let compiled_documents = compiled["result"]["diagnostics"] + .as_array() + .expect("compile diagnostics documents"); + assert_eq!(compiled_documents.len(), 5, "all reachable source files"); + assert!( + compiled_documents + .iter() + .all(|document| { document["version"] == 7 && document["diagnostics"] == json!([]) }) + ); + session.shutdown(); +} + +#[test] +fn compile_returns_canonical_workshop_text_and_no_artifact_on_error() { + let mut session = Session::spawn(); + session.initialize(); + + let compiled = session.request(json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "lpp/compile", + "params": { + "entry": { + "uri": file_uri(BASIC_RULE), + "languageId": "opy", + "version": 7, + } + }, + })); + assert_eq!( + compiled["result"]["artifact"]["format"], + "workshop-rs/text-v1" + ); + assert!( + compiled["result"]["artifact"]["content"] + .as_str() + .expect("workshop text") + .starts_with("rule (\"setup\")") + ); + + let failed = session.request(json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "lpp/compile", + "params": { + "entry": { + "uri": file_uri(UNSUPPORTED), + "languageId": "opy", + "version": 7, + } + }, + })); + assert_eq!(failed["result"]["artifact"], Value::Null); + assert!( + !failed["result"]["diagnostics"][0]["diagnostics"] + .as_array() + .expect("diagnostics") + .is_empty() + ); + session.shutdown(); +} + +#[test] +fn single_document_v1_requests_keep_the_client_version() { + let mut session = Session::spawn(); + let initialized = session.request(json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "lpp/initialize", + "params": { "protocolVersion": "1.0" }, + })); + assert_eq!(initialized["result"]["protocolVersion"], "1.0"); + let path = Path::new(BASIC_RULE).canonicalize().expect("fixture path"); + let uri = file_uri(BASIC_RULE); + let source = std::fs::read_to_string(&path).expect("fixture source"); + let compiled = session.request(json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "lpp/compile", + "params": { + "documents": { + &uri: { + "uri": &uri, + "languageId": "opy", + "version": 7, + "text": source, + }, + }, + }, + })); + assert_eq!(compiled["result"]["diagnostics"][0]["version"], 7); + assert!(compiled["result"]["artifact"].is_object()); + session.shutdown(); +} + +#[test] +fn check_preserves_owner_diagnostic_identity_and_range() { + let mut session = Session::spawn(); + session.initialize(); + let checked = session.request(json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "lpp/check", + "params": { + "entry": { + "uri": file_uri(DIAGNOSTICS), + "languageId": "opy", + "version": 7, + } + }, + })); + let document = &checked["result"]["documents"][0]; + assert!( + document["uri"] + .as_str() + .is_some_and(|uri| uri.ends_with("/diagnostics/source.opy")) + ); + assert_eq!(document["version"], 7); + let diagnostic = &document["diagnostics"][0]; + assert_eq!(diagnostic["severity"], "error"); + assert_eq!(diagnostic["source"], "opy"); + assert_eq!(diagnostic["range"]["start"]["line"], 0); + session.shutdown(); +} + +#[test] +fn document_check_analyzes_every_supplied_document() { + let mut session = Session::spawn(); + session.initialize(); + let checked = session.request(json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "lpp/check", + "params": { + "documents": { + "file:///project/clean.opy": { + "uri": "file:///project/clean.opy", + "languageId": "opy", + "version": 3, + "text": "rule \"clean\":\n @Event global\n" + }, + "file:///project/broken.opy": { + "uri": "file:///project/broken.opy", + "languageId": "opy", + "version": 4, + "text": "rule \"broken\":\n @Event global\n ???\n" + } + } + } + })); + let documents = checked["result"]["documents"] + .as_array() + .expect("documents"); + assert_eq!(documents.len(), 2); + let clean = documents + .iter() + .find(|document| document["uri"] == "file:///project/clean.opy") + .expect("clean document"); + assert_eq!(clean["version"], 3); + assert!( + clean["diagnostics"] + .as_array() + .expect("diagnostics") + .is_empty() + ); + let broken = documents + .iter() + .find(|document| document["uri"] == "file:///project/broken.opy") + .expect("broken document"); + assert_eq!(broken["version"], 4); + assert!( + !broken["diagnostics"] + .as_array() + .expect("diagnostics") + .is_empty() + ); + let refused = session.request(json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "lpp/compile", + "params": { + "documents": { + "file:///project/clean.opy": { + "uri": "file:///project/clean.opy", + "languageId": "opy", + "version": 3, + "text": "rule \"clean\":\n @Event global\n" + }, + "file:///project/broken.opy": { + "uri": "file:///project/broken.opy", + "languageId": "opy", + "version": 4, + "text": "rule \"broken\":\n @Event global\n ???\n" + } + } + } + })); + assert_eq!(refused["error"]["data"]["lpp"]["kind"], "refusal"); + assert_eq!( + refused["error"]["data"]["lpp"]["details"]["refusalCode"], + "compile.requiresSingleDocument" + ); + session.shutdown(); +} + +#[test] +fn entry_errors_follow_lpp_11_project_loading_contract() { + let mut session = Session::spawn(); + session.initialize(); + let missing = session.request(json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "lpp/check", + "params": { + "entry": { + "uri": "file:///project/missing.opy", + "languageId": "opy", + "version": 7 + } + } + })); + assert_eq!(missing["error"]["data"]["lpp"]["kind"], "projectLoadFailed"); + assert_eq!( + missing["error"]["data"]["lpp"]["details"]["entryUri"], + "file:///project/missing.opy" + ); + let unsupported = session.request(json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "lpp/check", + "params": { + "entry": { + "uri": "untitled:entry.opy", + "languageId": "opy", + "version": 7 + } + } + })); + assert_eq!(unsupported["error"]["data"]["lpp"]["kind"], "invalidEntry"); + session.shutdown(); +} + +#[test] +fn lifecycle_and_capability_failures_are_structured() { + let mut session = Session::spawn(); + let before_initialize = session.request(json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "lpp/check", + "params": {}, + })); + assert_eq!(before_initialize["error"]["code"], -32000); + assert_eq!( + before_initialize["error"]["data"]["lpp"]["kind"], + "invalidRequest" + ); + + session.initialize(); + let unavailable = session.request(json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "lpp/symbols", + "params": {}, + })); + assert_eq!( + unavailable["error"]["data"]["lpp"]["kind"], + "capabilityUnavailable" + ); + assert_eq!( + unavailable["error"]["data"]["lpp"]["details"]["capability"], + "symbols" + ); + session.shutdown(); +} diff --git a/crates/opy-rs/src/compiler/mod.rs b/crates/opy-rs/src/compiler/mod.rs index 0979ae7..9b05cf0 100644 --- a/crates/opy-rs/src/compiler/mod.rs +++ b/crates/opy-rs/src/compiler/mod.rs @@ -1110,6 +1110,110 @@ impl CompilationArtifact { } } +fn expand_settings_constants( + settings: crate::hir::Settings, + constants: &HashMap, +) -> crate::hir::Settings { + crate::hir::Settings { + span: settings.span, + children: settings + .children + .into_iter() + .map(|node| expand_settings_node(node, constants)) + .collect(), + } +} + +fn expand_settings_node( + node: crate::hir::SettingsNode, + constants: &HashMap, +) -> crate::hir::SettingsNode { + use crate::hir::SettingsNode; + match node { + SettingsNode::Group { + name, + children, + span, + } => { + let children = children + .into_iter() + .map(|child| expand_settings_node(child, constants)) + .collect::>(); + let children = if matches!(name.as_str(), "team1" | "team2" | "allTeams") { + children + .into_iter() + .flat_map(|child| match child { + SettingsNode::Group { name, children, .. } if name == "general" => children, + child => vec![child], + }) + .collect() + } else { + children + }; + SettingsNode::Group { + name, + children, + span, + } + } + SettingsNode::Raw { name, value, span } => constants + .get(&value) + .and_then(|expr| settings_node_from_expr(name.clone(), expr, constants, span)) + .unwrap_or(SettingsNode::Raw { name, value, span }), + node => node, + } +} + +fn settings_node_from_expr( + name: String, + expr: &Expr, + constants: &HashMap, + span: Option, +) -> Option { + use crate::hir::SettingsNode; + match expr { + Expr::Constant { name: value, .. } => constants + .get(value) + .and_then(|expr| settings_node_from_expr(name, expr, constants, span)), + Expr::Dict { entries, .. } => Some(SettingsNode::Group { + name, + children: entries + .iter() + .filter_map(|entry| { + let Expr::String { + value: child_name, .. + } = entry.key.as_ref() + else { + return None; + }; + settings_node_from_expr(child_name.clone(), &entry.value, constants, entry.span) + }) + .flat_map(|child| match child { + SettingsNode::Group { name, children, .. } if name == "general" => children, + child => vec![child], + }) + .collect(), + span, + }), + Expr::Number { value, .. } => Some(SettingsNode::Number { + name, + value: *value, + span, + }), + Expr::Bool { value, .. } => Some(SettingsNode::Bool { + name, + value: *value, + span, + }), + Expr::String { value, .. } => Some(SettingsNode::String { + name, + value: value.clone(), + span, + }), + _ => None, + } +} + fn convert_settings(settings: crate::hir::Settings) -> workshop_rs::settings::Settings { workshop_rs::settings::Settings { span: settings.span.map(convert_settings_span), @@ -1126,6 +1230,14 @@ fn convert_settings_node(node: crate::hir::SettingsNode) -> workshop_rs::setting use workshop_rs::settings::{SettingsListElement, SettingsNode as TargetNode}; match node { + SourceNode::Group { + name, + children, + span, + } if name == "workshop" => TargetNode::Workshop { + children: children.into_iter().map(convert_workshop_node).collect(), + span: span.map(convert_settings_span), + }, SourceNode::Group { name, children, @@ -1146,8 +1258,12 @@ fn convert_settings_node(node: crate::hir::SettingsNode) -> workshop_rs::setting span: span.map(convert_settings_span), }, SourceNode::String { name, value, span } => TargetNode::String { - name, - value, + name: name.clone(), + value: if name == "mapRotation" && value == "afterGame" { + "afterAGame".to_string() + } else { + value + }, span: span.map(convert_settings_span), }, SourceNode::Raw { name, value, span } => TargetNode::Raw { @@ -1173,6 +1289,56 @@ fn convert_settings_node(node: crate::hir::SettingsNode) -> workshop_rs::setting } } +fn convert_workshop_node(node: crate::hir::SettingsNode) -> workshop_rs::settings::SettingsNode { + use crate::hir::SettingsNode as SourceNode; + use workshop_rs::settings::SettingsNode as TargetNode; + + match node { + SourceNode::Group { + name, + children, + span, + } => TargetNode::Group { + name, + children: children.into_iter().map(convert_workshop_node).collect(), + span: span.map(convert_settings_span), + }, + SourceNode::Number { name, value, span } => TargetNode::Raw { + name, + value: value.to_string(), + span: span.map(convert_settings_span), + }, + SourceNode::Bool { name, value, span } => TargetNode::Raw { + name, + value: value.to_string(), + span: span.map(convert_settings_span), + }, + SourceNode::String { name, value, span } | SourceNode::Raw { name, value, span } => { + TargetNode::Raw { + name, + value, + span: span.map(convert_settings_span), + } + } + SourceNode::List { + name, + elements, + span, + } => TargetNode::Raw { + name, + value: format!( + "[{}]", + elements + .into_iter() + .map(|element| element.value) + .collect::>() + .join(", ") + ), + span: span.map(convert_settings_span), + }, + } +} + fn convert_settings_span(span: HirSpan) -> WorkshopSpan { WorkshopSpan::new( workshop_rs::source::FileId::from_index(span.file as usize), @@ -1194,6 +1360,9 @@ struct Lowering<'a> { defined_subroutines: HashSet, array_bindings: Vec, current_rule_conditions: Option>, + visible_labels: Vec>, + deferred_gotos: Vec<(wir::ActionId, String, Option)>, + outer_goto_targets: Vec, } #[derive(Debug, Clone)] @@ -1209,8 +1378,17 @@ enum BreakTarget { Switch, } +enum DeleteAssignment { + Global(wir::GlobalVarId), + Player { + player: wir::ValueId, + variable: wir::PlayerVarId, + }, +} + type SwitchBreak = (usize, HirSpan); type LoweredSwitchBody = (Vec, Option); +type LoweredSwitchArm<'a> = (Option<&'a Expr>, Vec, Option); /// Return the condition path when a statement consists only of a continue. /// An empty path represents an unconditional continue; a non-empty path is @@ -1231,6 +1409,30 @@ fn pure_continue_conditions(statement: &Stmt) -> Option> { } } +/// Return the condition path when a statement consists only of a forward +/// label jump. The path is folded into a canonical `skipIf` action so jumps +/// out of a nested conditional can still target the surrounding sequence. +fn pure_goto_conditions(statement: &Stmt) -> Option<(Vec<&Expr>, &str)> { + match statement { + Stmt::Goto { + label: Some(label), + offset: None, + rule_start: false, + .. + } => Some((Vec::new(), label.as_str())), + Stmt::If { + branches, + r#else: None, + .. + } if branches.len() == 1 && branches[0].body.len() == 1 => { + let (mut conditions, label) = pure_goto_conditions(&branches[0].body[0])?; + conditions.insert(0, &branches[0].condition); + Some((conditions, label)) + } + _ => None, + } +} + /// Detect continues belonging to the current loop. Nested loops own their /// continues and are lowered independently by `lower_action`. fn contains_loop_continue(statement: &Stmt) -> bool { @@ -1266,11 +1468,27 @@ impl<'a> Lowering<'a> { defined_subroutines: HashSet::new(), array_bindings: Vec::new(), current_rule_conditions: None, + visible_labels: Vec::new(), + deferred_gotos: Vec::new(), + outer_goto_targets: Vec::new(), }) } fn copy_files(&mut self) -> Result<(), IntegrationError> { - self.wir.settings = self.hir.settings.clone().map(convert_settings); + let settings_constants = self + .hir + .declarations + .iter() + .filter_map(|declaration| match declaration { + hir::Declaration::Constant { name, value, .. } => { + Some((name.clone(), value.as_ref())) + } + _ => None, + }) + .collect(); + self.wir.settings = self.hir.settings.clone().map(|settings| { + convert_settings(expand_settings_constants(settings, &settings_constants)) + }); for file in &self.hir.files { if self.files.contains_key(&file.id) { return Err(IntegrationError::new( @@ -1680,6 +1898,9 @@ impl<'a> Lowering<'a> { } fn lower_rule(&mut self, rule: &hir::Rule) -> Result<(), IntegrationError> { + if rule.disabled { + return Ok(()); + } self.reject_rule_metadata(rule)?; let event = self.lower_event(&rule.event, &rule.annotations)?; let conditions = rule @@ -1975,10 +2196,18 @@ impl<'a> Lowering<'a> { "Hero" }; let locale = Locale::new("en-US"); + let catalog_spelling = match (domain, spelling.as_str()) { + ("Hero", "mccree") => "CASSIDY", + ("Hero", "hammond") => "WRECKING_BALL", + ("Hero", "soldier") => "SOLDIER_76", + ("Hero", "domina") => "JINYU", + ("Hero", "dmon") => "D_MON", + _ => spelling.as_str(), + }; let member = self .compiler .catalog - .resolve_enum_member(domain, &locale, &spelling) + .resolve_enum_member(domain, &locale, catalog_spelling) .map(|(_, member)| member) .or_else(|| { (domain == "Hero") @@ -1991,9 +2220,21 @@ impl<'a> Lowering<'a> { .members .iter() .find(|member| { - member.spellings(&locale).iter().any(|candidate| { - candidate.eq_ignore_ascii_case(&spelling) - }) + member.member.eq_ignore_ascii_case(catalog_spelling) + || member.spellings(&locale).iter().any(|candidate| { + candidate.eq_ignore_ascii_case(catalog_spelling) + }) + || member + .member + .chars() + .filter(|c| c.is_ascii_alphanumeric()) + .collect::() + .eq_ignore_ascii_case( + &catalog_spelling + .chars() + .filter(|c| c.is_ascii_alphanumeric()) + .collect::(), + ) }) .map(|member| member.member.clone()) }) @@ -2035,13 +2276,277 @@ impl<'a> Lowering<'a> { statements: &[Stmt], break_target: Option, ) -> Result, IntegrationError> { + self.visible_labels.push( + statements + .iter() + .filter_map(|statement| match statement { + Stmt::Label { name, .. } => Some(name.clone()), + _ => None, + }) + .collect(), + ); let mut actions = Vec::new(); - for statement in statements { - actions.extend(self.lower_action(statement, break_target)?); + let mut labels = HashMap::new(); + let mut gotos = Vec::new(); + let mut index = 0; + while index < statements.len() { + let statement = &statements[index]; + if let Stmt::If { + branches, + r#else, + span, + } = statement + { + if branches.len() == 1 && r#else.is_none() { + let branch = &branches[0]; + if let Some((conditions, label)) = + branch.body.first().and_then(pure_goto_conditions) + { + let target = statements[index + 1..] + .iter() + .position(|candidate| { + matches!(candidate, Stmt::Label { name, .. } if name == label) + }); + if target.is_some() && conditions.is_empty() { + let condition = self.lower_value(&branch.condition)?; + let condition = self.push_call("not", vec![condition]); + let body = self.lower_actions(&branch.body[1..], break_target)?; + actions.push(self.wir.actions.push(Action::If { + branches: vec![wir::IfBranch { condition, body }], + else_body: None, + span: self.wir_span(*span)?, + })); + index += 1; + continue; + } + } + } + let external_jump = + branches + .iter() + .enumerate() + .find_map(|(branch_index, branch)| { + let (conditions, label) = pure_goto_conditions(branch.body.last()?)?; + let target = statements[index + 1..] + .iter() + .position(|candidate| { + matches!(candidate, Stmt::Label { name, .. } if name == label) + }) + .map(|offset| index + 1 + offset) + .or_else(|| { + self.outer_goto_targets + .last() + .is_some_and(|target| target == label) + .then_some(statements.len()) + })?; + Some(( + branch_index, + conditions.into_iter().cloned().collect::>(), + label.to_string(), + target, + )) + }); + if let Some((exit_branch, exit_conditions, label, target)) = external_jump { + self.outer_goto_targets.push(label.clone()); + let middle_result = + self.lower_actions(&statements[index + 1..target], break_target); + let middle = middle_result?; + self.resolve_deferred_gotos(&middle, label.as_str(), middle.len())?; + let distance = self.canonical_action_width(&middle, *span)?; + let mut lowered_branches = Vec::with_capacity(branches.len()); + for (branch_index, branch) in branches.iter().enumerate() { + let body = if branch_index == exit_branch { + &branch.body[..branch.body.len() - 1] + } else { + &branch.body[..] + }; + lowered_branches.push(wir::IfBranch { + condition: self.lower_value(&branch.condition)?, + body: self.lower_actions(body, break_target)?, + }); + } + let else_body = r#else + .as_ref() + .map(|body| self.lower_actions(body, break_target)) + .transpose()?; + self.outer_goto_targets.pop(); + actions.push(self.wir.actions.push(Action::If { + branches: lowered_branches, + else_body, + span: self.wir_span(*span)?, + })); + let mut condition = self.lower_value(&branches[exit_branch].condition)?; + for expression in exit_conditions { + let right = self.lower_value(&expression)?; + condition = self.push_call("and", vec![condition, right]); + } + let distance_value = self.push_number(distance as f64, &distance.to_string()); + actions.push(self.wir.actions.push(Action::Call { + name: "skipIf".to_string(), + args: vec![condition, distance_value], + span: self.wir_span(*span)?, + })); + actions.extend(middle); + index = target + 1; + continue; + } + } + if let Some((conditions, label)) = pure_goto_conditions(statement) { + let target = statements[index + 1..] + .iter() + .position( + |candidate| matches!(candidate, Stmt::Label { name, .. } if name == label), + ) + .map(|offset| index + 1 + offset) + .or_else(|| { + self.outer_goto_targets + .last() + .is_some_and(|target| target == label) + .then_some(statements.len()) + }); + if let Some(target) = target { + self.outer_goto_targets.push(label.to_string()); + let middle_result = + self.lower_actions(&statements[index + 1..target], break_target); + self.outer_goto_targets.pop(); + let middle = middle_result?; + let span = statement.span().copied(); + self.resolve_deferred_gotos(&middle, label, middle.len())?; + let distance = self.canonical_action_width(&middle, span)?; + let mut args = Vec::with_capacity(conditions.len() + 1); + if let Some((first, rest)) = conditions.split_first() { + let mut condition = self.lower_value(first)?; + for expression in rest { + let right = self.lower_value(expression)?; + condition = self.push_call("and", vec![condition, right]); + } + args.push(condition); + } + args.push(self.push_number(distance as f64, &distance.to_string())); + actions.push( + self.wir.actions.push(Action::Call { + name: if conditions.is_empty() { + "skip" + } else { + "skipIf" + } + .to_string(), + args, + span: self.wir_span(span)?, + }), + ); + actions.extend(middle); + index = target + 1; + continue; + } + } + match statement { + Stmt::Label { name, .. } => { + labels.insert(name.clone(), actions.len()); + } + Stmt::Goto { + label, + offset, + rule_start, + span, + } => { + if *rule_start { + return Err(self.unsupported( + "goto RULE_START is not representable in canonical WIR", + *span, + )); + } + let placeholder = self.push_number(0.0, "0"); + let action = self.wir.actions.push(Action::Call { + name: "skip".to_string(), + args: vec![placeholder], + span: self.wir_span(*span)?, + }); + let position = actions.len(); + actions.push(action); + gotos.push(( + action, + position, + label.clone(), + offset.clone(), + span.map(Into::into), + )); + } + _ => actions.extend(self.lower_action(statement, break_target)?), + } + index += 1; } + for (action, position, label, offset, span) in gotos { + let distance = if let Some(offset) = offset { + self.lower_value(&offset)? + } else { + let Some(label) = label else { + return Err(self.unsupported("goto is missing a label or offset", span)); + }; + let Some(&target) = labels.get(&label) else { + if self + .visible_labels + .iter() + .any(|labels| labels.contains(&label)) + { + self.deferred_gotos.push((action, label, span)); + continue; + } + return Err(self.unsupported(format!("unknown goto label '{label}'"), span)); + }; + if target < position { + return Err(self + .unsupported("backward goto is not representable in canonical WIR", span)); + } + let width = self.canonical_action_width(&actions[position + 1..target], span)?; + self.push_number(width as f64, &width.to_string()) + }; + let Some(Action::Call { args, .. }) = self.wir.actions.get_mut(action) else { + unreachable!("goto placeholder must be a call action") + }; + args[0] = distance; + } + if self.visible_labels.len() == 1 && !self.deferred_gotos.is_empty() { + let (_, label, span) = self.deferred_gotos.remove(0); + return Err(self.unsupported(format!("unknown goto label '{label}'"), span)); + } + self.visible_labels.pop(); Ok(actions) } + fn resolve_deferred_gotos( + &mut self, + actions: &[wir::ActionId], + label: &str, + target: usize, + ) -> Result<(), IntegrationError> { + let deferred = std::mem::take(&mut self.deferred_gotos); + let mut remaining = Vec::new(); + for (action, deferred_label, span) in deferred { + if deferred_label != label { + remaining.push((action, deferred_label, span)); + continue; + } + let Some(position) = actions.iter().position(|candidate| *candidate == action) else { + remaining.push((action, deferred_label, span)); + continue; + }; + if target < position { + return Err( + self.unsupported("backward goto is not representable in canonical WIR", span) + ); + } + let width = self.canonical_action_width(&actions[position + 1..target], span)?; + let distance = self.push_number(width as f64, &width.to_string()); + let Some(Action::Call { args, .. }) = self.wir.actions.get_mut(action) else { + unreachable!("deferred goto placeholder must be a call action") + }; + args[0] = distance; + } + self.deferred_gotos = remaining; + Ok(()) + } + fn lower_action( &mut self, stmt: &Stmt, @@ -2172,10 +2677,7 @@ impl<'a> Lowering<'a> { arms, span, } => self.lower_switch(value, arms, *span).map(|action| vec![action]), - Stmt::Delete { span, .. } => Err(self.unsupported( - "delete statements are not representable in canonical WIR", - *span, - )), + Stmt::Delete { target, span } => self.lower_delete(target, *span).map(|action| vec![action]), Stmt::Continue { span } => Err(self.unsupported( "continue statements are only lowered while constructing a loop body", *span, @@ -2341,6 +2843,92 @@ impl<'a> Lowering<'a> { actions.extend(tail); return Ok(actions); } + if let Some((conditions, label)) = pure_goto_conditions(statement) { + if let Some(target) = statements[index + 1..] + .iter() + .position( + |candidate| matches!(candidate, Stmt::Label { name, .. } if name == label), + ) + .map(|offset| index + 1 + offset) + { + let middle = self.lower_loop_sequence( + &statements[index + 1..target], + after, + structural_after, + )?; + let suffix = self.lower_loop_sequence( + &statements[target + 1..], + after, + structural_after, + )?; + let distance = + self.canonical_action_width(&middle, statement.span().copied())?; + let mut args = Vec::with_capacity(conditions.len() + 1); + if let Some((first, rest)) = conditions.split_first() { + let mut condition = self.lower_value(first)?; + for expression in rest { + let right = self.lower_value(expression)?; + condition = self.push_call("and", vec![condition, right]); + } + args.push(condition); + } + args.push(self.push_number(distance as f64, &distance.to_string())); + actions.push( + self.wir.actions.push(Action::Call { + name: if conditions.is_empty() { + "skip" + } else { + "skipIf" + } + .to_string(), + args, + span: self.wir_span(statement.span().copied())?, + }), + ); + actions.extend(middle); + actions.extend(suffix); + return Ok(actions); + } + } + if let Some((conditions, label)) = pure_goto_conditions(statement) { + let local_label = statements.iter().any( + |candidate| matches!(candidate, Stmt::Label { name, .. } if name == label), + ); + let outer_label = self + .visible_labels + .last() + .is_some_and(|labels| labels.contains(label)); + if !local_label && outer_label { + let span = statement.span().copied(); + let mut condition = None; + for expression in conditions { + let value = self.lower_value(expression)?; + condition = Some(match condition { + Some(left) => self.push_call("and", vec![left, value]), + None => value, + }); + } + let break_action = self.wir.actions.push(Action::Call { + name: "break".to_string(), + args: Vec::new(), + span: self.wir_span(span)?, + }); + actions.push(if let Some(condition) = condition { + self.wir.actions.push(Action::If { + branches: vec![wir::IfBranch { + condition, + body: vec![break_action], + }], + else_body: None, + span: self.wir_span(span)?, + }) + } else { + break_action + }); + index += 1; + continue; + } + } actions.extend(self.lower_action(statement, Some(BreakTarget::Loop))?); index += 1; } @@ -2503,9 +3091,10 @@ impl<'a> Lowering<'a> { let selector = self.lower_value(value)?; let mut case_values = Vec::new(); let mut lowered_arms = Vec::with_capacity(arms.len()); - let mut case_offsets = Vec::new(); - let mut offset = 0usize; - let mut default_offset = None; + let mut has_default = false; + let mut legacy_case_offsets = Vec::new(); + let mut legacy_offset = 0; + let mut legacy_default_offset = None; for arm in arms { let (value, (body, break_at)) = match arm { @@ -2514,47 +3103,115 @@ impl<'a> Lowering<'a> { (Some(value), self.lower_switch_body(body)?) } SwitchArm::Default { body, span } => { - if default_offset.is_some() { + if has_default { return Err( self.unsupported("a switch may contain at most one default arm", *span) ); } - default_offset = Some(offset); + has_default = true; + legacy_default_offset = Some(legacy_offset); (None, self.lower_switch_body(body)?) } }; if value.is_some() { - case_offsets.push(offset); + legacy_case_offsets.push(legacy_offset); } - offset += self.canonical_action_width(&body, span)? + usize::from(break_at.is_some()); - lowered_arms.push((value, body, break_at)); + legacy_offset += + self.canonical_action_width(&body, span)? + usize::from(break_at.is_some()); + lowered_arms.push((value.map(Box::as_ref), body, break_at)); } - let default_offset = default_offset.unwrap_or(offset); let break_arms: Vec<_> = lowered_arms .iter() .enumerate() .filter_map(|(index, (_, _, break_at))| break_at.map(|break_at| (index, break_at))) .collect(); - if break_arms.len() > 1 { - let (first_index, first_break) = break_arms[0]; - let has_actions_after_first = lowered_arms[first_index].1.len() > first_break.0 - || lowered_arms - .iter() - .skip(first_index + 1) - .any(|(_, body, _)| !body.is_empty()); - if has_actions_after_first { - return Err(self.unsupported( - "multiple switch breaks with later reachable actions require canonical switch targets", - Some(break_arms[1].1.1), - )); - } - } + let first_break = break_arms.first().copied(); + let has_later_reachable_actions = + first_break.is_some_and(|(break_index, (break_at, _))| { + lowered_arms[break_index].1.len() > break_at + || lowered_arms + .iter() + .skip(break_index + 1) + .any(|(_, body, _)| !body.is_empty()) + }); + let use_shared_exit = break_arms.len() > 1 && has_later_reachable_actions; let case_values = self.lower_array(case_values, span)?; let value_span = self.wir_span(span)?; + if !use_shared_exit { + let default_offset = legacy_default_offset.unwrap_or(legacy_offset); + let offset_values = std::iter::once(default_offset) + .chain(legacy_case_offsets) + .map(|value| { + self.wir.values.push(ValueNode::new( + Value::Number { + value: value as f64, + text: value.to_string(), + }, + value_span, + )) + }) + .collect(); + let offsets = self.lower_array(offset_values, span)?; + let skip = self.lower_switch_selector(selector, case_values, offsets, span)?; + let true_value = self + .wir + .values + .push(ValueNode::new(Value::Bool(true), self.wir_span(span)?)); + let mut branch_body = vec![skip]; + let else_body = if let Some((break_index, (break_at, _))) = first_break { + for (index, (_, body, _)) in lowered_arms.iter().enumerate() { + if index < break_index { + branch_body.extend(body.iter().copied()); + } else if index == break_index { + branch_body.extend(body[..break_at].iter().copied()); + } + } + let mut tail = Vec::new(); + tail.extend(lowered_arms[break_index].1[break_at..].iter().copied()); + for (_, body, _) in lowered_arms.iter().skip(break_index + 1) { + tail.extend(body.iter().copied()); + } + Some(tail) + } else { + for (_, body, _) in &lowered_arms { + branch_body.extend(body.iter().copied()); + } + None + }; + return Ok(self.wir.actions.push(Action::If { + branches: vec![wir::IfBranch { + condition: true_value, + body: branch_body, + }], + else_body, + span: self.wir_span(span)?, + })); + } + + let offsets = self + .wir + .values + .push(ValueNode::new(Value::Array(Vec::new()), value_span)); + let skip = self.lower_switch_selector(selector, case_values, offsets, span)?; + let mut arm_offsets = vec![None; lowered_arms.len()]; + let (switch, switch_end) = + self.lower_switch_level(&lowered_arms, 0, Some(skip), 0, &mut arm_offsets, span)?; + + let default_offset = lowered_arms + .iter() + .enumerate() + .find_map(|(index, (value, _, _))| value.is_none().then(|| arm_offsets[index].unwrap())) + .unwrap_or(switch_end); let offset_values = std::iter::once(default_offset) - .chain(case_offsets) + .chain( + lowered_arms + .iter() + .enumerate() + .filter(|(_, (value, _, _))| value.is_some()) + .map(|(index, _)| arm_offsets[index].unwrap()), + ) .map(|value| { self.wir.values.push(ValueNode::new( Value::Number { @@ -2565,7 +3222,29 @@ impl<'a> Lowering<'a> { )) }) .collect(); - let offsets = self.lower_array(offset_values, span)?; + let offset_values = self.lower_array(offset_values, span)?; + let offset_value = self + .wir + .values + .get(offset_values) + .expect("switch offset array must exist") + .value + .clone(); + let Some(node) = self.wir.values.get_mut(offsets) else { + unreachable!("switch offset placeholder must exist") + }; + node.value = offset_value; + + Ok(switch) + } + + fn lower_switch_selector( + &mut self, + selector: wir::ValueId, + case_values: wir::ValueId, + offsets: wir::ValueId, + span: Option, + ) -> Result { let one = self.wir.values.push(ValueNode::new( Value::Number { value: 1.0, @@ -2594,47 +3273,93 @@ impl<'a> Lowering<'a> { }, self.wir_span(span)?, )); - let skip = self.wir.actions.push(Action::Call { + Ok(self.wir.actions.push(Action::Call { name: "skip".to_string(), args: vec![skip_condition], span: self.wir_span(span)?, - }); - let true_value = self - .wir - .values - .push(ValueNode::new(Value::Bool(true), self.wir_span(span)?)); + })) + } - let first_break = break_arms.first().copied(); - let mut branch_body = vec![skip]; - let else_body = if let Some((break_index, (break_at, _))) = first_break { - for (index, (_, body, _)) in lowered_arms.iter().enumerate() { - if index < break_index { - branch_body.extend(body.iter().copied()); - } else if index == break_index { - branch_body.extend(body[..break_at].iter().copied()); - } - } - let mut tail = Vec::new(); - tail.extend(lowered_arms[break_index].1[break_at..].iter().copied()); - for (_, body, _) in lowered_arms.iter().skip(break_index + 1) { - tail.extend(body.iter().copied()); - } - Some(tail) + fn lower_switch_level( + &mut self, + arms: &[LoweredSwitchArm<'_>], + start: usize, + selector_skip: Option, + level_offset: usize, + arm_offsets: &mut [Option], + span: Option, + ) -> Result<(wir::ActionId, usize), IntegrationError> { + let break_index = (start..arms.len()) + .find(|index| arms[*index].2.is_some()) + .expect("switch level must contain a break"); + let mut branch_body = Vec::new(); + if let Some(selector_skip) = selector_skip { + branch_body.push(selector_skip); + } + let mut branch_offset = 0; + for index in start..=break_index { + arm_offsets[index] = Some(if selector_skip.is_some() { + level_offset + branch_offset + } else if index == start { + level_offset + } else { + level_offset + 1 + branch_offset + }); + let (_, body, break_at) = &arms[index]; + let body = if index == break_index { + &body[..break_at.as_ref().unwrap().0] + } else { + body.as_slice() + }; + branch_offset += self.canonical_action_width(body, span)?; + branch_body.extend(body.iter().copied()); + } + let branch_width = self.canonical_action_width(&branch_body, span)?; + let (_, break_body, Some((break_at, _))) = &arms[break_index] else { + unreachable!("break index must point to a switch break") + }; + let mut else_body = break_body[*break_at..].to_vec(); + let tail_width = self.canonical_action_width(&else_body, span)?; + let else_content_start = if selector_skip.is_some() { + level_offset + branch_width + tail_width + } else { + level_offset + branch_width + tail_width + 2 + }; + let has_next_break = (break_index + 1..arms.len()).any(|index| arms[index].2.is_some()); + let end_offset = if has_next_break { + let (child, child_end) = self.lower_switch_level( + arms, + break_index + 1, + None, + else_content_start, + arm_offsets, + span, + )?; + else_body.push(child); + child_end } else { - for (_, body, _) in &lowered_arms { - branch_body.extend(body.iter().copied()); + let mut offset = else_content_start; + for index in break_index + 1..arms.len() { + arm_offsets[index] = Some(offset); + let (_, body, _) = &arms[index]; + offset += self.canonical_action_width(body, span)?; + else_body.extend(body.iter().copied()); } - None + offset }; - - Ok(self.wir.actions.push(Action::If { + let true_value = self + .wir + .values + .push(ValueNode::new(Value::Bool(true), self.wir_span(span)?)); + let switch = self.wir.actions.push(Action::If { branches: vec![wir::IfBranch { condition: true_value, body: branch_body, }], - else_body, + else_body: Some(else_body), span: self.wir_span(span)?, - })) + }); + Ok((switch, end_offset)) } fn lower_switch_body( @@ -3097,6 +3822,27 @@ impl<'a> Lowering<'a> { } } + fn value_is_known_player(&self, value: wir::ValueId) -> bool { + match &self + .wir + .values + .get(value) + .expect("lowered value must exist") + .value + { + Value::EventPlayer => true, + Value::Call { name, .. } => self + .compiler + .catalog + .entry(Kind::Value, name) + .and_then(|entry| entry.return_type()) + .is_some_and(|return_type| { + return_type.split('|').any(|part| part.trim() == "Player") + }), + _ => false, + } + } + fn push_value(&mut self, value: Value) -> wir::ValueId { self.wir.values.push(ValueNode::new(value, None)) } @@ -3179,6 +3925,84 @@ impl<'a> Lowering<'a> { ))) } + fn lower_delete( + &mut self, + target: &Expr, + span: Option, + ) -> Result { + let Expr::Index { array, index, .. } = target else { + return Err(self.unsupported( + "delete statements require an indexed global or player variable", + span, + )); + }; + let (root, assignment) = match array.as_ref() { + Expr::GlobalVar { + name, + span: target_span, + } => { + let variable = *self.globals.get(name).ok_or_else(|| { + self.unsupported(format!("unknown global variable '{name}'"), *target_span) + })?; + let value = self.wir.values.push(ValueNode::new( + Value::GlobalVariable(variable), + self.wir_span(*target_span)?, + )); + (value, DeleteAssignment::Global(variable)) + } + Expr::PlayerVar { + player, + name, + span: target_span, + .. + } => { + let variable = *self.players.get(name).ok_or_else(|| { + self.unsupported(format!("unknown player variable '{name}'"), *target_span) + })?; + let player = self.lower_value(player)?; + let value = self.wir.values.push(ValueNode::new( + Value::PlayerVariable { player, variable }, + self.wir_span(*target_span)?, + )); + (value, DeleteAssignment::Player { player, variable }) + } + _ => { + return Err(self.unsupported( + "delete statements are only representable for global or player variables", + target.span().copied(), + )); + } + }; + let index = self.lower_value(index)?; + let zero = self.push_number(0.0, "0"); + let one = self.push_number(1.0, "1"); + let end = self.push_call("add", vec![index, one]); + let maximum = self.push_number(999_999_999_999.0, "999999999999"); + let prefix = self.push_call("slice", vec![root, zero, index]); + let suffix = self.push_call("slice", vec![root, end, maximum]); + let value = self.push_call("appendToArray", vec![prefix, suffix]); + let span = self.wir_span(span)?; + Ok(match assignment { + DeleteAssignment::Global(variable) => { + self.wir.actions.push(Action::SetGlobalVariable { + variable, + value, + span, + target_span: span, + }) + } + DeleteAssignment::Player { player, variable } => { + self.wir.actions.push(Action::SetPlayerVariable { + player, + variable, + value, + span, + target_span: span, + }) + } + }) + } + fn lower_assign( &mut self, target: &Expr, @@ -3537,6 +4361,15 @@ impl<'a> Lowering<'a> { args: &[Expr], span: Option, ) -> Result { + if args.is_empty() { + if let Some(&subroutine) = self.subroutines.get(name) { + return Ok(self.wir.actions.push(Action::CallSubroutine { + subroutine, + span: self.wir_span(span)?, + callee_span: self.wir_span(span)?, + })); + } + } if name == "chaseAtRate" { let args = args .iter() @@ -3556,6 +4389,35 @@ impl<'a> Lowering<'a> { if !matches!(function.kind, FunctionKind::Action) { return Err(self.unsupported(format!("'{name}' is not a generic OPY action"), span)); } + if function.id == "async" { + let [subroutine, behavior] = args else { + return Err( + self.unsupported("async requires a subroutine and an AsyncBehavior", span) + ); + }; + let subroutine_name = match subroutine { + Expr::Call { name, args, .. } if args.is_empty() => name, + _ => { + return Err(self.unsupported( + "async requires a declared subroutine", + subroutine.span().copied(), + )); + } + }; + let subroutine_id = *self.subroutines.get(subroutine_name).ok_or_else(|| { + self.unsupported( + format!("unknown subroutine '{subroutine_name}'"), + subroutine.span().copied(), + ) + })?; + let subroutine = self.push_value(Value::Subroutine(subroutine_id)); + let behavior = self.lower_value(behavior)?; + return Ok(self.wir.actions.push(Action::Call { + name: "startRule".to_string(), + args: vec![subroutine, behavior], + span: self.wir_span(span)?, + })); + } if matches!( function.id.as_str(), "hudHeader" | "hudSubheader" | "hudSubtext" @@ -3600,6 +4462,7 @@ impl<'a> Lowering<'a> { .iter() .map(|expr| self.lower_value(expr)) .collect::, _>>()?; + let args = self.normalize_catalog_argument_domains(catalog_id, args); Ok(self.wir.actions.push(Action::Call { name: catalog_id.clone(), args, @@ -3607,6 +4470,38 @@ impl<'a> Lowering<'a> { })) } + fn normalize_catalog_argument_domains( + &mut self, + catalog_id: &str, + mut args: Vec, + ) -> Vec { + let Some(entry) = self.compiler.catalog.entry(Kind::Action, catalog_id) else { + return args; + }; + for (index, argument) in args.iter_mut().enumerate() { + let Some(domain) = entry.param_domain(index) else { + continue; + }; + let Some(ValueNode { + value: Value::Enum { value_type, value }, + span, + }) = self.wir.values.get(*argument) + else { + continue; + }; + if value_type == "Team" && domain == "Color" { + *argument = self.wir.values.push(ValueNode::new( + Value::Enum { + value_type: domain.to_string(), + value: value.clone(), + }, + *span, + )); + } + } + args + } + fn lower_hud_text( &mut self, args: &[Expr], @@ -3694,11 +4589,19 @@ impl<'a> Lowering<'a> { // `append` is an OPY mutation, represented by the canonical variable // modify actions rather than a catalog action call. - if function.id == "append" { + if matches!(function.id.as_str(), "append" | "remove") { let [value] = args else { - return Err(self.unsupported("append requires exactly one argument", span)); + return Err(self.unsupported( + format!("{} requires exactly one argument", function.id), + span, + )); }; let value = self.lower_value(value)?; + let op = if function.id == "append" { + wir::ModifyOp::AppendToArray + } else { + wir::ModifyOp::RemoveFromArray + }; return match receiver { Expr::GlobalVar { name, @@ -3709,7 +4612,7 @@ impl<'a> Lowering<'a> { })?; Ok(self.wir.actions.push(Action::ModifyGlobalVariable { variable, - op: wir::ModifyOp::AppendToArray, + op, value, span: self.wir_span(span)?, target_span: self.wir_span(*target_span)?, @@ -3728,7 +4631,7 @@ impl<'a> Lowering<'a> { Ok(self.wir.actions.push(Action::ModifyPlayerVariable { player, variable, - op: wir::ModifyOp::AppendToArray, + op, value, span: self.wir_span(span)?, target_span: self.wir_span(*target_span)?, @@ -3897,17 +4800,47 @@ impl<'a> Lowering<'a> { if let Some(value) = fold_literal_format(text, args) { return self.lower_custom_string(value, span); } - let text_node = self.wir.values.push(ValueNode::new( - Value::String(canonical_format_text(text)), - self.wir_span(span)?, - )); - let mut call_args = vec![text_node]; - for arg in args { - call_args.push(self.lower_value(arg)?); - } - Value::Call { - name: "customString".to_string(), - args: call_args, + let format_text = canonical_format_text(text); + if args.len() <= 3 { + let text_node = self.wir.values.push(ValueNode::new( + Value::String(format_text), + self.wir_span(span)?, + )); + let mut call_args = vec![text_node]; + for arg in args { + call_args.push(self.lower_value(arg)?); + } + Value::Call { + name: "customString".to_string(), + args: call_args, + } + } else { + let chunks = split_format_chunks(&format_text, args.len()).ok_or_else(|| { + self.unsupported( + "format strings with more than three replacements require sequential placeholders", + span, + ) + })?; + let lowered_args = args + .iter() + .map(|arg| self.lower_value(arg)) + .collect::, _>>()?; + let mut parts = Vec::with_capacity(chunks.len()); + for (chunk, indices) in chunks { + let text = self + .wir + .values + .push(ValueNode::new(Value::String(chunk), self.wir_span(span)?)); + let mut call_args = vec![text]; + call_args.extend(indices.into_iter().map(|index| lowered_args[index])); + parts.push(self.push_call("customString", call_args)); + } + let separator = self.push_value(Value::String("{0}{1}".to_string())); + let mut value = parts[0]; + for part in parts.into_iter().skip(1) { + value = self.push_call("customString", vec![separator, value, part]); + } + return Ok(value); } } Expr::Conditional { @@ -4079,6 +5012,30 @@ impl<'a> Lowering<'a> { y: self.lower_value(&args[1])?, z: self.lower_value(&args[2])?, } + } else if matches!( + name.as_str(), + "createWorkshopSettingBool" + | "createWorkshopSettingEnum" + | "createWorkshopSettingInt" + | "createWorkshopSettingFloat" + ) { + let mut lowered = args + .iter() + .map(|arg| self.lower_value(arg)) + .collect::, _>>()?; + if name == "createWorkshopSettingFloat" && lowered.len() == 5 { + lowered.push(self.push_number(0.0, "0")); + } + Value::Call { + name: match name.as_str() { + "createWorkshopSettingBool" => "workshopSettingToggle", + "createWorkshopSettingEnum" => "workshopSettingCombo", + "createWorkshopSettingInt" => "workshopSettingInteger", + _ => name, + } + .to_string(), + args: lowered, + } } else if matches!(name.as_str(), "all" | "any") { let call_name = if name == "all" { "isTrueForAll" @@ -4206,13 +5163,93 @@ impl<'a> Lowering<'a> { args, .. } => { + if matches!(name.as_str(), "all" | "any") { + let [ + Expr::Lambda { + params, body, span, .. + }, + ] = args.as_slice() + else { + return Err( + self.unsupported(format!("{name} requires one lambda argument"), span) + ); + }; + let condition = self.lower_array_callback(params, body, *span)?; + let receiver = self.lower_value(receiver)?; + return Ok(self.wir.values.push(ValueNode::new( + Value::Call { + name: if name == "all" { + "isTrueForAll" + } else { + "isTrueForAny" + } + .to_string(), + args: vec![receiver, condition], + }, + self.wir_span(*span)?, + ))); + } let function = self.compiler.manifest.resolve_member(name).ok_or_else(|| { self.unsupported(format!("unknown member value '{name}'"), span) })?; if !matches!(function.kind, FunctionKind::MemberValue) { return Err(self.unsupported(format!("'{name}' is not a member value"), span)); } - if matches!(function.id.as_str(), "concat" | "exclude") { + if matches!( + function.id.as_str(), + "getHitPosition" | "getPlayerHit" | "getNormal" + ) { + let member_name = function.id.as_str(); + let Expr::Call { + name: receiver_name, + args: receiver_args, + .. + } = receiver.as_ref() + else { + return Err(self.unsupported( + format!("{member_name} requires a raycast receiver"), + span, + )); + }; + if receiver_name != "raycast" || !args.is_empty() { + return Err(self.unsupported( + format!("{member_name} requires raycast(...) with no member arguments"), + span, + )); + } + let catalog_id = function.catalog_id.clone().ok_or_else(|| { + self.unsupported( + format!("{member_name} has no canonical catalog identity"), + span, + ) + })?; + let lowered_args = receiver_args + .iter() + .map(|arg| self.lower_value(arg)) + .collect::, _>>()?; + return Ok(self.wir.values.push(ValueNode::new( + Value::Call { + name: catalog_id, + args: lowered_args, + }, + self.wir_span(span)?, + ))); + } + if function.id == "filter" { + let [ + Expr::Lambda { + params, body, span, .. + }, + ] = args.as_slice() + else { + return Err(self.unsupported("filter requires one lambda argument", span)); + }; + let condition = self.lower_array_callback(params, body, *span)?; + Value::Call { + name: "filteredArray".to_string(), + args: vec![self.lower_value(receiver)?, condition], + } + } else if matches!(function.id.as_str(), "concat" | "exclude") { let [value] = args.as_slice() else { return Err(self.unsupported( format!("{} requires exactly one argument", function.id), @@ -4292,6 +5329,11 @@ impl<'a> Lowering<'a> { )); } let iterable = self.lower_value(iterable)?; + let iterable = if self.value_is_known_player(iterable) { + self.push_call("array", vec![iterable]) + } else { + iterable + }; let binding = ArrayBinding { element: variable.clone(), index: index.clone(), @@ -4947,11 +5989,13 @@ fn player_event_kind(name: &str) -> Option { "playerDealtDamage" => PlayerEventKind::DealtDamage, "playerDealtFinalBlow" => PlayerEventKind::DealtFinalBlow, "playerDealtHealing" => PlayerEventKind::DealtHealing, + "playerDealtKnockback" => PlayerEventKind::DealtKnockback, "playerDied" => PlayerEventKind::Died, "playerEarnedElimination" => PlayerEventKind::EarnedElimination, "playerJoined" => PlayerEventKind::Joined, "playerLeft" => PlayerEventKind::Left, "playerReceivedHealing" => PlayerEventKind::ReceivedHealing, + "playerReceivedKnockback" => PlayerEventKind::ReceivedKnockback, "playerTookDamage" => PlayerEventKind::TookDamage, _ => return None, }) @@ -5155,6 +6199,59 @@ fn canonical_format_text(text: &str) -> String { output } +fn split_format_chunks(text: &str, arg_count: usize) -> Option)>> { + let mut chunks = Vec::new(); + let mut current = String::new(); + let mut indices = Vec::new(); + let mut pending = String::new(); + let mut cursor = 0; + while cursor < text.len() { + let Some(open_rel) = text[cursor..].find('{') else { + pending.push_str(&text[cursor..]); + break; + }; + let open = cursor + open_rel; + let Some(close_rel) = text[open + 1..].find('}') else { + pending.push_str(&text[cursor..]); + break; + }; + let close = open + 1 + close_rel; + let marker = &text[open + 1..close]; + let Ok(index) = marker.parse::() else { + pending.push_str(&text[cursor..=close]); + cursor = close + 1; + continue; + }; + if index >= arg_count { + return None; + } + pending.push_str(&text[cursor..open]); + if indices.len() == 3 && !indices.contains(&index) { + chunks.push((current, indices)); + current = String::new(); + indices = Vec::new(); + } + current.push_str(&pending); + pending.clear(); + let local = if let Some(local) = indices.iter().position(|candidate| *candidate == index) { + local + } else { + indices.push(index); + indices.len() - 1 + }; + current.push('{'); + current.push_str(&local.to_string()); + current.push('}'); + cursor = close + 1; + } + current.push_str(&pending); + if current.is_empty() && chunks.is_empty() { + return Some(vec![(text.to_string(), Vec::new())]); + } + chunks.push((current, indices)); + Some(chunks) +} + fn fold_literal_format(text: &str, args: &[hir::Expr]) -> Option { let values = args .iter() @@ -5671,6 +6768,30 @@ mod tests { assert!(artifact.emitted.contains("Player Joined Match;")); } + #[test] + fn hero_event_filters_accept_legacy_aliases() { + let compiler = Compiler::new().unwrap(); + let hir = crate::compile( + "rule \"hero\":\n @Event eachPlayer\n @Hero soldier\n disableInspector()\n", + "hero-filter.opy", + Path::new("."), + ) + .unwrap(); + let artifact = compiler.compile_hir(&hir).unwrap(); + assert!(matches!( + &artifact + .wir + .rules + .get(workshop_rs::wir::RuleId::from_index(0)) + .unwrap() + .event, + workshop_rs::wir::Event::EachPlayerWithFilters { + target: workshop_rs::wir::EventTarget::Hero(hero), + .. + } if hero == "SOLDIER_76" + )); + } + #[test] fn explicit_indices_are_reserved_before_deterministic_allocation() { let compiler = Compiler::new().unwrap(); diff --git a/crates/opy-rs/src/compiler/tests/issue_42_oracle.rs b/crates/opy-rs/src/compiler/tests/issue_42_oracle.rs index 34888e0..9f42527 100644 --- a/crates/opy-rs/src/compiler/tests/issue_42_oracle.rs +++ b/crates/opy-rs/src/compiler/tests/issue_42_oracle.rs @@ -167,6 +167,31 @@ fn real_world_cake_exercises_catalog_lowering_end_to_end() { } } +#[test] +fn scalar_player_comprehension_is_wrapped_before_filtering() { + let source = "globalvar x\nrule \"r\":\n @Event eachPlayer\n x = [player for player in eventPlayer.getRealPlayerClosestToReticle(Team.ALL) if player.isAlive()]\n"; + let hir = crate::compile(source, "source.opy", Path::new(".")).expect("frontend resolves"); + let artifact = Compiler::new() + .expect("released workshop contract must load") + .compile_hir(&hir) + .expect("scalar player comprehension must lower to canonical WIR"); + assert!(artifact.emitted.contains( + "Filtered Array(Array(Player Closest To Reticle(Event Player, Team(All Teams))), Is Alive(Current Array Element))" + )); +} + +#[test] +fn hero_general_settings_are_emitted_at_team_scope() { + let source = "settings {\n \"gamemodes\": {},\n \"heroes\": {\n \"team1\": {\n \"general\": {\n \"damageReceived%\": 50\n }\n }\n }\n}\nrule \"r\":\n @Event global\n pass\n"; + let hir = crate::compile(source, "source.opy", Path::new(".")).expect("settings resolve"); + let artifact = Compiler::new() + .expect("released workshop contract must load") + .compile_hir(&hir) + .expect("hero general settings must lower at team scope"); + assert!(artifact.emitted.contains("Team 1 {")); + assert!(artifact.emitted.contains("Damage Received: 50%")); +} + fn collect_action_calls<'a>( program: &'a workshop_rs::wir::Program, actions: &[workshop_rs::wir::ActionId], diff --git a/crates/opy-rs/src/compiler/tests/issue_47_oracle.rs b/crates/opy-rs/src/compiler/tests/issue_47_oracle.rs index 1ee1f34..21e04c9 100644 --- a/crates/opy-rs/src/compiler/tests/issue_47_oracle.rs +++ b/crates/opy-rs/src/compiler/tests/issue_47_oracle.rs @@ -5,6 +5,7 @@ use std::path::{Path, PathBuf}; use crate::Compiler; use workshop_rs::catalog::{Catalog, Locale}; use workshop_rs::roundtrip::equivalent; +use workshop_rs::wir::{self, Action, Value}; fn fixture_dir(name: &str) -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) @@ -39,6 +40,201 @@ fn assert_native_wir_equivalent(name: &str) { ); } +#[derive(Debug, PartialEq, Eq)] +enum NativeInstruction { + If, + Else, + End, + Skip(wir::ValueId), + SetGlobal(i64), +} + +fn flatten_action( + program: &wir::Program, + action_id: wir::ActionId, + rule_final: bool, + instructions: &mut Vec, +) { + match program.actions.get(action_id).unwrap() { + Action::SetGlobalVariable { value, .. } => { + let Value::Number { value, .. } = &program.values.get(*value).unwrap().value else { + panic!("switch behavior probe expects numeric assignments") + }; + instructions.push(NativeInstruction::SetGlobal(*value as i64)); + } + Action::If { + branches, + else_body, + .. + } => { + for (index, branch) in branches.iter().enumerate() { + if index > 0 { + instructions.push(NativeInstruction::Else); + } + instructions.push(NativeInstruction::If); + for action in &branch.body { + flatten_action(program, *action, false, instructions); + } + } + if let Some(else_body) = else_body { + instructions.push(NativeInstruction::Else); + for action in else_body { + flatten_action(program, *action, false, instructions); + } + } + if !rule_final { + instructions.push(NativeInstruction::End); + } + } + Action::Call { name, args, .. } if name == "skip" => { + instructions.push(NativeInstruction::Skip(args[0])); + } + other => panic!("switch behavior probe found unexpected action: {other:?}"), + } +} + +fn array_values(program: &wir::Program, value_id: wir::ValueId) -> Vec { + match &program.values.get(value_id).unwrap().value { + Value::Array(values) => values.clone(), + Value::Call { name, args } if name == "array" => args.clone(), + other => panic!("expected an array value, got {other:?}"), + } +} + +fn numeric_value(program: &wir::Program, value_id: wir::ValueId, selector: i64) -> i64 { + match &program.values.get(value_id).unwrap().value { + Value::Number { value, .. } => *value as i64, + Value::GlobalVariable(_) => selector, + Value::Array(values) => panic!("array value must be consumed by a call: {values:?}"), + Value::Call { name, args } => match name.as_str() { + "add" => args + .iter() + .map(|value| numeric_value(program, *value, selector)) + .sum(), + "indexOfArrayValue" => { + let values = array_values(program, args[0]); + let needle = numeric_value(program, args[1], selector); + values + .iter() + .position(|value| numeric_value(program, *value, selector) == needle) + .map_or(-1, |index| index as i64) + } + "valueInArray" => { + let values = array_values(program, args[0]); + let index = numeric_value(program, args[1], selector); + numeric_value(program, values[index as usize], selector) + } + other => panic!("switch behavior probe found unexpected value call: {other}"), + }, + other => panic!("switch behavior probe found unexpected value: {other:?}"), + } +} + +fn matching_end(instructions: &[NativeInstruction], else_index: usize) -> usize { + let mut nested = 0; + for (index, instruction) in instructions.iter().enumerate().skip(else_index + 1) { + match instruction { + NativeInstruction::If => nested += 1, + NativeInstruction::End if nested == 0 => return index, + NativeInstruction::End => nested -= 1, + _ => {} + } + } + instructions.len() +} + +fn native_switch_trace(program: &wir::Program, rule: &wir::Rule, selector: i64) -> Vec { + let mut instructions = Vec::new(); + for (index, action) in rule.actions.iter().enumerate() { + flatten_action( + program, + *action, + index + 1 == rule.actions.len(), + &mut instructions, + ); + } + + let mut trace = Vec::new(); + let mut if_stack = Vec::new(); + let mut pc = 0; + while pc < instructions.len() { + match &instructions[pc] { + NativeInstruction::If => { + if_stack.push(true); + pc += 1; + } + NativeInstruction::Else => { + if if_stack.pop().unwrap_or(false) { + pc = matching_end(&instructions, pc) + 1; + } else { + if_stack.push(true); + pc += 1; + } + } + NativeInstruction::End => { + if_stack.pop(); + pc += 1; + } + NativeInstruction::Skip(value) => { + let count = numeric_value(program, *value, selector); + assert!(count >= 0, "switch skip count must be non-negative"); + pc += count as usize + 1; + } + NativeInstruction::SetGlobal(value) => { + trace.push(*value); + pc += 1; + } + } + } + trace +} + +fn pinned_switch_traces(dir: &Path) -> Vec<(i64, Vec)> { + let workshop = oracle_workshop(dir); + let offsets = workshop + .split("Skip(Value In Array(Array(") + .nth(1) + .unwrap() + .split("), Add") + .next() + .unwrap() + .split(", ") + .map(|value| value.parse::().unwrap()) + .collect::>(); + let mut actions = Vec::new(); + for line in workshop.lines() { + if let Some(value) = line + .trim() + .strip_prefix("Set Global Variable(value, ") + .and_then(|line| line.strip_suffix(");")) + { + actions.push(value.parse::().unwrap()); + } else if line.trim() == "Else;" { + actions.push(-1); + } + } + let trace_at = |offset: i64| { + let mut trace = Vec::new(); + for action in actions.iter().skip(offset as usize) { + if *action == -1 { + break; + } + trace.push(*action); + } + trace + }; + [0, 1, 2, 3, 99] + .into_iter() + .map(|selector| { + let case_offset = [1, 2, 3] + .iter() + .position(|value| *value == selector) + .map_or(offsets[0], |index| offsets[index + 1]); + (selector, trace_at(case_offset)) + }) + .collect() +} + #[test] fn issue_47_switch_lowering_matches_the_pinned_oracle() { for name in [ @@ -57,17 +253,53 @@ fn issue_47_do_while_break_shapes_match_the_pinned_oracle() { } #[test] -fn issue_47_multiple_switch_breaks_are_not_silently_dropped() { +fn issue_47_multiple_switch_breaks_match_independent_semantic_oracle() { let compiler = Compiler::new().unwrap(); let dir = fixture_dir("issue-47-switch-multiple-break"); let source = std::fs::read_to_string(dir.join("source.opy")).unwrap(); let hir = crate::compile(&source, "source.opy", &dir).unwrap(); - let error = match compiler.compile_hir(&hir) { - Ok(_) => panic!("multi-break switch must not be silently truncated"), - Err(error) => error, - }; - assert_eq!(error.diagnostic.code, "unsupported-integration-surface"); - assert_eq!(error.diagnostic.span.unwrap().start.line, 11); + let artifact = compiler + .compile_hir(&hir) + .expect("multi-break switch must lower"); + let semantic_oracle: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(dir.join("semantic-oracle.json")).unwrap()) + .unwrap(); + let catalog = Catalog::builtin().unwrap(); + let semantic_wir = workshop_rs::parser::parse( + semantic_oracle["compile"]["workshop"].as_str().unwrap(), + &catalog, + &Locale::new("en-US"), + ) + .unwrap(); + assert!( + equivalent(&artifact.wir, &semantic_wir), + "native WIR diverged from the independent switch semantic oracle\n{}", + artifact.emitted + ); +} + +#[test] +fn pinned_overpy_switch_action_trace() { + let compiler = Compiler::new().unwrap(); + let dir = fixture_dir("issue-47-switch-multiple-break"); + let source = std::fs::read_to_string(dir.join("source.opy")).unwrap(); + let hir = crate::compile(&source, "source.opy", &dir).unwrap(); + let artifact = compiler + .compile_hir(&hir) + .expect("multi-break switch must lower"); + let rule = artifact + .wir + .rules + .iter() + .find(|rule| rule.name == "issue 47 switch multiple break") + .unwrap(); + for (selector, expected) in pinned_switch_traces(&dir) { + assert_eq!( + native_switch_trace(&artifact.wir, rule, selector), + expected, + "native switch behavior diverged from the pinned OverPy action trace for selector {selector}" + ); + } } #[test] diff --git a/crates/opy-rs/src/lexer.rs b/crates/opy-rs/src/lexer.rs index b32e267..66005eb 100644 --- a/crates/opy-rs/src/lexer.rs +++ b/crates/opy-rs/src/lexer.rs @@ -133,9 +133,13 @@ impl Lexer { self.line += 1; self.col = 1; } - ' ' | '\t' | '\r' => { + ' ' | '\r' => { self.advance(); } + '\t' => { + self.pos += 1; + self.col += 4; + } '\\' => { if !self.skip_line_continuation() { return Err(OpyError::at( diff --git a/crates/opy-rs/src/lower.rs b/crates/opy-rs/src/lower.rs index 55e904a..9208150 100644 --- a/crates/opy-rs/src/lower.rs +++ b/crates/opy-rs/src/lower.rs @@ -181,10 +181,14 @@ pub fn lower_with_preprocessing( // produce no HIR declaration (reference behavior). } Decl::Constant { name, value, span } => { + let previous = lowerer.allow_dict_literal; + lowerer.allow_dict_literal = true; + let value = lowerer.lower_expr(value, &[], CallPosition::Value); + lowerer.allow_dict_literal = previous; declarations.push(Declaration::Constant { name: name.clone(), span: Some(span.into()), - value: Box::new(lowerer.lower_expr(value, &[], CallPosition::Value)), + value: Box::new(value), }); } Decl::Macro { @@ -809,25 +813,115 @@ impl Lowerer { allow_do_while: bool, loopable: bool, ) -> Vec { - stmts - .iter() - .enumerate() - .map(|(index, stmt)| { - if matches!(stmt, Stmt::DoWhile { .. }) - && (!allow_do_while - || stmts[..index] - .iter() - .any(|previous| !matches!(previous, Stmt::Pass { .. }))) - { - self.error_at( - "do-while-placement", - "do-while must be at the beginning of a rule, subroutine, or do-while body; only pass statements may precede it".to_string(), - stmt.span(), - ); + let mut lowered = Vec::new(); + for (index, stmt) in stmts.iter().enumerate() { + if matches!(stmt, Stmt::DoWhile { .. }) + && (!allow_do_while + || stmts[..index] + .iter() + .any(|previous| !matches!(previous, Stmt::Pass { .. }))) + { + self.error_at( + "do-while-placement", + "do-while must be at the beginning of a rule, subroutine, or do-while body; only pass statements may precede it".to_string(), + stmt.span(), + ); + } + if let Stmt::Expr { + expr: Expr::Call { name, args, .. }, + span, + } = stmt + { + if name == "splitDictArray" { + lowered.extend(self.lower_split_dict_array(args, *span, macro_params)); + continue; } - self.lower_stmt(stmt, macro_params, breakable, loopable) - }) - .collect() + } + lowered.push(self.lower_stmt(stmt, macro_params, breakable, loopable)); + } + lowered + } + + fn lower_split_dict_array( + &mut self, + args: &[cst::CallArg], + span: Span, + macro_params: &[String], + ) -> Vec { + let Some(fields) = args.first().map(|arg| &arg.value) else { + self.error_at( + "split-dict-array-arity", + "splitDictArray requires a field mapping and data array".to_string(), + span, + ); + return Vec::new(); + }; + let Some(rows) = args.get(1).map(|arg| &arg.value) else { + self.error_at( + "split-dict-array-arity", + "splitDictArray requires a field mapping and data array".to_string(), + span, + ); + return Vec::new(); + }; + let Expr::Dict { + entries: field_entries, + .. + } = fields + else { + self.error_at( + "split-dict-array-arguments", + "splitDictArray first argument must be a dictionary".to_string(), + fields.span(), + ); + return Vec::new(); + }; + let Expr::Array { elements, .. } = rows else { + self.error_at( + "split-dict-array-arguments", + "splitDictArray second argument must be an array".to_string(), + rows.span(), + ); + return Vec::new(); + }; + + let mut result = Vec::with_capacity(field_entries.len()); + for field in field_entries { + let Some(field_name) = expr_identifier(&field.key) else { + self.error_at( + "split-dict-array-key", + "splitDictArray field keys must be identifiers".to_string(), + field.key.span(), + ); + continue; + }; + let target = self.lower_expr(&field.value, macro_params, CallPosition::Value); + let previous = self.allow_dict_literal; + self.allow_dict_literal = true; + let values = elements + .iter() + .map(|element| match element { + Expr::Dict { entries, .. } => entries + .iter() + .find(|entry| expr_identifier(&entry.key) == Some(field_name)) + .map(|entry| { + self.lower_expr(&entry.value, macro_params, CallPosition::Value) + }) + .unwrap_or(HirExpr::Null { span: None }), + _ => HirExpr::Null { span: None }, + }) + .collect(); + self.allow_dict_literal = previous; + result.push(HirStmt::Assign { + target: Box::new(target), + value: Box::new(HirExpr::Array { + elements: values, + span: Some(span.into()), + }), + span: Some(span.into()), + }); + } + result } fn lower_stmt( @@ -1316,11 +1410,13 @@ impl Lowerer { args: Vec::new(), span: Some(span.into()), }, - "eventAbility" | "eventDamage" => HirExpr::Call { - name: name.to_string(), - args: Vec::new(), - span: Some(span.into()), - }, + "eventAbility" | "eventDamage" | "eventHealing" | "eventWasCriticalHit" => { + HirExpr::Call { + name: name.to_string(), + args: Vec::new(), + span: Some(span.into()), + } + } _ if self.global_visible(name) => HirExpr::GlobalVar { name: name.to_string(), span: Some(span.into()), @@ -1372,6 +1468,13 @@ impl Lowerer { span: Span, macro_params: &[String], ) -> HirExpr { + if let Some(name) = self.qualified_macro_for_member(member) { + return HirExpr::MacroCall { + name, + args: vec![self.lower_expr(receiver, macro_params, CallPosition::Value)], + span: Some(span.into()), + }; + } if let Expr::Name { name, .. } = receiver { // Custom enum member: folds to its numeric constant. if self.enum_visible(name) { @@ -1414,21 +1517,34 @@ impl Lowerer { // against the canonical Workshop catalog. let catalog_domain = match name.as_str() { "Clip" => "Clipping", + "AsyncBehavior" => "StartRuleBehavior", _ => name.as_str(), }; if self.manifest.domain_identity(name) + || self.catalog.enum_domain(name).is_some() + || self.catalog.enum_domain(catalog_domain).is_some() || (name == "Clip" && self.manifest.domain_identity(catalog_domain)) { let locale = Locale::new("en-US"); let catalog_member = match (name.as_str(), member) { + ("Map", "BLIZZ_WORLD") => "BLIZZARD_WORLD", + ("Map", "BLIZZ_WORLD_WINTER") => "BLIZZARD_WORLD_WINTER", + ("Map", "ROUTE66") => "ROUTE_66", + ("Map", "VOLSKAYA") => "VOLSKAYA_INDUSTRIES", ("Clip", "NONE") => "DO_NOT_CLIP", + ("Clip", "SURFACES") => "CLIP_AGAINST_SURFACES", ("SpecVisibility", "ALWAYS") => "VISIBLE_ALWAYS", ("SpecVisibility", "NEVER") => "VISIBLE_NEVER", ("EffectReeval", "VISIBILITY_POSITION_AND_RADIUS") => { "VISIBLE_TO_POSITION_AND_RADIUS" } + ("HudReeval", "VISIBILITY_AND_COLOR") => "VISIBLE_TO_AND_COLOR", + ("HudReeval", "VISIBILITY_STRING_AND_COLOR") => "VISIBLE_TO_STRING_AND_COLOR", ("Hero", "MCCREE") => "CASSIDY", ("Hero", "HAMMOND") => "WRECKING_BALL", + ("Hero", "SOLDIER") => "SOLDIER_76", + ("Hero", "DOMINA") => "JINYU", + ("Hero", "DMON") => "D_MON", _ => member, }; let canonical_member = self @@ -1440,6 +1556,18 @@ impl Lowerer { .iter() .find(|candidate| candidate.member == catalog_member) .map(|candidate| candidate.member.clone()) + .or_else(|| { + (name == "Map").then(|| { + let normalized = catalog_member.replace('_', ""); + domain + .members + .iter() + .find(|candidate| { + candidate.member.replace('_', "") == normalized + }) + .map(|candidate| candidate.member.clone()) + })? + }) }) .or_else(|| { if name == "Team" && member.parse::().is_ok() { @@ -1449,6 +1577,10 @@ impl Lowerer { } else { None } + }) + .or_else(|| { + (name == "HudReeval" && member == "VISIBILITY_STRING_AND_COLOR") + .then_some(catalog_member.to_string()) }); let Some(canonical_member) = canonical_member else { self.error_at( @@ -1566,6 +1698,22 @@ impl Lowerer { if name == "createWorkshopSetting" { return self.lower_workshop_setting(args, span, macro_params); } + if name == "compressed" && args.len() == 1 { + return self.lower_expr(&args[0].value, macro_params, CallPosition::Value); + } + if matches!( + name, + "createWorkshopSettingBool" + | "createWorkshopSettingEnum" + | "createWorkshopSettingInt" + | "createWorkshopSettingFloat" + ) { + return HirExpr::Call { + name: name.to_string(), + args: self.lower_arg_values(args, macro_params), + span: Some(span.into()), + }; + } // Builtin identity and position checks run before the special forms // so that a misplaced `wait`/`vect` still diagnoses its position. if !self.macro_visible(name) && !self.subroutine_visible(name) && name != "sorted" { @@ -1650,6 +1798,35 @@ impl Lowerer { span: Some(span.into()), }; } + if name == "async" { + let lowered = args + .iter() + .enumerate() + .map(|(index, arg)| { + if index == 0 && arg.keyword.is_none() { + if let cst::Expr::Name { + name: subroutine, + span, + } = &arg.value + { + if self.subroutine_visible(subroutine) { + return HirExpr::Call { + name: subroutine.clone(), + args: Vec::new(), + span: Some((*span).into()), + }; + } + } + } + self.lower_expr(&arg.value, macro_params, CallPosition::Value) + }) + .collect(); + return HirExpr::Call { + name: name.to_string(), + args: lowered, + span: Some(span.into()), + }; + } match self.manifest.resolve_function(name) { Some(entry) => { // Declared subroutines with arguments stay generic @@ -1965,6 +2142,12 @@ impl Lowerer { span: None, }); } + Some(ParamDefault::Bool(value)) => { + bound.push(HirExpr::Bool { + value: *value, + span: None, + }); + } Some(ParamDefault::Number(number)) => { bound.push(HirExpr::Number { value: *number, @@ -2119,6 +2302,15 @@ impl Lowerer { macro_params: &[String], position: CallPosition, ) -> HirExpr { + if let Some(macro_name) = self.qualified_macro_for_member(name) { + return HirExpr::MacroCall { + name: macro_name, + args: std::iter::once(self.lower_expr(receiver, macro_params, CallPosition::Value)) + .chain(self.lower_arg_values(args, macro_params)) + .collect(), + span: Some(span.into()), + }; + } if matches!(name, "map" | "filter" | "all" | "any") { let lowered = HirExpr::ReceiverCall { receiver: Box::new(self.lower_expr(receiver, macro_params, CallPosition::Value)), @@ -2216,6 +2408,15 @@ impl Lowerer { } } + fn qualified_macro_for_member(&self, member: &str) -> Option { + let suffix = format!(".{member}"); + self.macro_declarations + .iter() + .filter(|(name, order)| name.ends_with(&suffix) && **order <= self.current_order) + .map(|(name, _)| name.clone()) + .next() + } + /// Check a builtin entry against its call position: action/value /// identity and for-iterable context. fn check_call_position( @@ -2384,7 +2585,7 @@ fn context_player_expr(name: &str, span: Option) -> Option { "hostPlayer" => Some(HirExpr::HostPlayer { span: span.map(Into::into), }), - "attacker" | "victim" => Some(HirExpr::Call { + "attacker" | "victim" | "healer" | "healee" => Some(HirExpr::Call { name: name.to_string(), args: Vec::new(), span: span.map(Into::into), @@ -2403,11 +2604,18 @@ fn assignable_receiver(receiver: &Expr) -> bool { name.as_str(), "eventPlayer" | "hostPlayer" | "attacker" | "victim" ), - Expr::Array { .. } | Expr::Index { .. } => true, + Expr::Array { .. } | Expr::Index { .. } | Expr::Member { .. } => true, _ => false, } } +fn expr_identifier(expr: &Expr) -> Option<&str> { + match expr { + Expr::Name { name, .. } => Some(name.as_str()), + _ => None, + } +} + fn indexed_expr_depth(expr: &Expr) -> usize { match expr { Expr::Index { array, .. } => 1 + indexed_expr_depth(array), diff --git a/crates/opy-rs/src/manifest/data/manifest.json b/crates/opy-rs/src/manifest/data/manifest.json index 3ab3435..0774a91 100644 --- a/crates/opy-rs/src/manifest/data/manifest.json +++ b/crates/opy-rs/src/manifest/data/manifest.json @@ -87,6 +87,38 @@ "kind": "memberAlias", "source": "disableHeroHUD", "target": "disableHeroHud" + }, + { + "evidence": [ + "builtin-surface" + ], + "kind": "functionAlias", + "source": "getLastCreatedText", + "target": "lastCreatedEntity" + }, + { + "evidence": [ + "builtin-surface" + ], + "kind": "functionAlias", + "source": "getTankHeroes", + "target": "allTankHeroes" + }, + { + "evidence": [ + "builtin-surface" + ], + "kind": "functionAlias", + "source": "getDamageHeroes", + "target": "allDamageHeroes" + }, + { + "evidence": [ + "builtin-surface" + ], + "kind": "functionAlias", + "source": "getSupportHeroes", + "target": "allSupportHeroes" } ], "functions": [ @@ -110,305 +142,201 @@ ] }, { - "catalogId": "waitUntil", + "catalogLink": "special-lowering", "evidence": [ "builtin-surface" ], - "id": "waitUntil", + "id": "async", "kind": "action", "params": [ { - "name": "condition" + "name": "subroutine" }, { - "name": "timeout" + "domain": "StartRuleBehavior", + "name": "behavior" } ] }, { - "catalogId": "disableInspector", - "evidence": [ - "generic-builtins" - ], - "id": "disableInspector", - "kind": "action", - "params": [] - }, - { - "catalogLink": "special-lowering", + "catalogId": "getObjectivePosition", "evidence": [ - "generic-builtins" + "builtin-surface" ], - "id": "debug", - "kind": "action", + "id": "getObjectivePosition", + "kind": "value", "params": [ { - "name": "value" + "name": "objective" } ] }, { - "catalogLink": "special-lowering", + "catalogId": "getAbilityCooldown", "evidence": [ - "generic-builtins" + "builtin-surface" ], - "id": "print", - "kind": "action", + "id": "getAbilityCooldown", + "kind": "memberValue", "params": [ { - "name": "text" + "domain": "Button", + "name": "button" } - ] + ], + "receiver": "Player" }, { - "catalogLink": "special-lowering", + "catalogId": "getAbilityCharge", "evidence": [ - "hud-subheader" + "builtin-surface" ], - "id": "hudSubheader", - "kind": "action", + "id": "getAbilityCharge", + "kind": "memberValue", "params": [ { - "name": "visibleTo" - }, - { - "name": "text" - }, - { - "domain": "HudPosition", - "name": "position" - }, - { - "name": "sortOrder" - }, - { - "domain": "Color", - "name": "color" - }, - { - "domain": "HudReeval", - "name": "reevaluation" - }, - { - "default": "DEFAULT", - "domain": "SpecVisibility", - "name": "spectators" + "domain": "Button", + "name": "button" } - ] + ], + "receiver": "Player" }, { - "catalogLink": "special-lowering", + "catalogId": "getPlayerClosestToReticle", "evidence": [ "builtin-surface" ], - "id": "hudHeader", - "kind": "action", + "id": "getRealPlayerClosestToReticle", + "kind": "memberValue", "params": [ { - "default": { - "call": "getAllPlayers" - }, - "name": "visibleTo" - }, - { - "name": "text" - }, - { - "alternateNames": [ - "position" - ], - "default": "LEFT", - "domain": "HudPosition", - "name": "location" - }, - { - "default": 0, - "name": "sortOrder" - }, - { - "default": "WHITE", - "domain": "Color", - "name": "color" - }, - { - "default": "VISIBILITY_SORT_ORDER_STRING_AND_COLOR", - "domain": "HudReeval", - "name": "reevaluation" - }, - { - "alternateNames": [ - "spectators" - ], - "default": "DEFAULT", - "domain": "SpecVisibility", - "name": "specVisibility" + "domain": "Team", + "name": "team" } - ] + ], + "receiver": "Player" }, { - "catalogLink": "special-lowering", + "catalogId": "teamOf", "evidence": [ "builtin-surface" ], - "id": "hudSubtext", - "kind": "action", - "params": [ - { - "default": { - "call": "getAllPlayers" - }, - "name": "visibleTo" - }, - { - "name": "text" - }, - { - "alternateNames": [ - "position" - ], - "default": "LEFT", - "domain": "HudPosition", - "name": "location" - }, - { - "default": 0, - "name": "sortOrder" - }, - { - "default": "WHITE", - "domain": "Color", - "name": "color" - }, - { - "default": "VISIBILITY_SORT_ORDER_STRING_AND_COLOR", - "domain": "HudReeval", - "name": "reevaluation" - }, - { - "alternateNames": [ - "spectators" - ], - "default": "DEFAULT", - "domain": "SpecVisibility", - "name": "specVisibility" - } - ] + "id": "getTeam", + "kind": "memberValue", + "params": [], + "receiver": "Player" }, { - "catalogId": "bigMessage", + "catalogId": "getTotalTimeElapsed", "evidence": [ "builtin-surface" ], - "id": "bigMessage", - "kind": "action", - "params": [ - { - "default": { - "call": "getAllPlayers" - }, - "name": "visibleTo" - }, - { - "name": "text" - } - ] + "id": "getTotalTimeElapsed", + "kind": "value", + "params": [] }, { - "catalogId": "smallMessage", + "catalogId": "isHeroBeingPlayed", "evidence": [ "builtin-surface" ], - "id": "smallMessage", - "kind": "action", + "id": "isHeroBeingPlayed", + "kind": "value", "params": [ { - "default": { - "call": "getAllPlayers" - }, - "name": "visibleTo" + "domain": "Hero", + "name": "hero" }, { - "name": "text" + "domain": "Team", + "name": "team" } ] }, { - "catalogId": "destroyAllEffects", + "catalogId": "healer", "evidence": [ "builtin-surface" ], - "id": "destroyAllEffects", - "kind": "action", + "id": "healer", + "kind": "value", "params": [] }, { - "catalogId": "destroyAllDummies", + "catalogId": "healee", "evidence": [ "builtin-surface" ], - "id": "destroyAllDummies", - "kind": "action", + "id": "healee", + "kind": "value", "params": [] }, { - "catalogId": "setObjectiveDescription", + "catalogId": "eventHealing", "evidence": [ "builtin-surface" ], - "id": "setObjectiveDescription", - "kind": "action", + "id": "eventHealing", + "kind": "value", + "params": [] + }, + { + "catalogId": "angleBetweenVectors", + "evidence": [ + "builtin-surface" + ], + "id": "angleBetweenVectors", + "kind": "value", "params": [ { - "name": "visibleTo" + "name": "direction1" }, { - "name": "description" - }, + "name": "direction2" + } + ] + }, + { + "catalogId": "verticalAngleFromDirection", + "evidence": [ + "builtin-surface" + ], + "id": "verticalAngleOfDirection", + "kind": "value", + "params": [ { - "domain": "HudReeval", - "name": "reevaluation" + "name": "direction" } ] }, { - "catalogId": "createEffect", + "catalogId": "directionTowards", "evidence": [ "builtin-surface" ], - "id": "createEffect", - "kind": "action", + "id": "directionTowards", + "kind": "value", "params": [ { - "name": "visibleTo" + "name": "start" }, { - "domain": "Effect", - "name": "type" - }, - { - "domain": "Color", - "name": "color" - }, - { - "name": "position" - }, - { - "name": "radius" - }, - { - "domain": "EffectReeval", - "name": "reevaluation" + "name": "end" } ] }, { - "catalogId": "declareTeamVictory", + "catalogId": "getPlayersOnHero", "evidence": [ "builtin-surface" ], - "id": "declareTeamVictory", - "kind": "action", + "id": "getPlayersOnHero", + "kind": "value", "params": [ + { + "domain": "Hero", + "name": "hero" + }, { "domain": "Team", "name": "team" @@ -416,206 +344,121 @@ ] }, { - "catalogId": "declarePlayerVictory", + "catalogId": "oppositeTeamOf", "evidence": [ "builtin-surface" ], - "id": "declarePlayerVictory", - "kind": "action", + "id": "getOppositeTeam", + "kind": "value", "params": [ { - "name": "player" + "domain": "Team", + "name": "team" } ] }, { - "catalogId": "setSlowMotion", + "catalogId": "isMatchBetweenRounds", "evidence": [ "builtin-surface" ], - "id": "setSlowMotion", - "kind": "action", - "params": [ - { - "name": "percent" - } - ] + "id": "isMatchBetweenRounds", + "kind": "value", + "params": [] }, { - "catalogId": "createInWorldText", + "catalogId": "min", "evidence": [ "builtin-surface" ], - "id": "createInWorldText", - "kind": "action", + "id": "min", + "kind": "value", "params": [ { - "name": "visibleTo" - }, - { - "name": "header" - }, - { - "name": "position" - }, - { - "name": "scale" - }, - { - "domain": "Clipping", - "name": "clipping" - }, - { - "domain": "WorldTextReeval", - "name": "reevaluation" - }, - { - "domain": "Color", - "name": "textColor" + "name": "a" }, { - "default": "DEFAULT", - "domain": "SpecVisibility", - "name": "spectators" + "name": "b" } ] }, { - "catalogId": "destroyAllHudText", - "evidence": [ - "builtin-surface" - ], - "id": "destroyAllHudText", - "kind": "action", - "params": [] - }, - { - "catalogId": "disableBuiltinGameModeCompletion", - "evidence": [ - "builtin-surface" - ], - "id": "disableGamemodeCompletion", - "kind": "action", - "params": [] - }, - { - "catalogId": "disableBuiltinGameModeScoring", - "evidence": [ - "builtin-surface" - ], - "id": "disableScoring", - "kind": "action", - "params": [] - }, - { - "catalogId": "createHudText", + "catalogLink": "special-lowering", "evidence": [ "builtin-surface" ], - "id": "hudText", - "kind": "action", + "id": "raycast", + "kind": "value", "params": [ { - "name": "visibleTo" - }, - { - "name": "header" - }, - { - "name": "subheader" - }, - { - "name": "text" - }, - { - "domain": "HudPosition", - "name": "position" - }, - { - "name": "sortOrder" - }, - { - "domain": "Color", - "name": "headerColor" + "name": "startPosition" }, { - "domain": "Color", - "name": "subheaderColor" + "name": "endPosition" }, { - "domain": "Color", - "name": "textColor" + "name": "playersToInclude" }, { - "domain": "HudReeval", - "name": "reevaluation" + "name": "playersToExclude" }, { - "default": "DEFAULT", - "domain": "SpecVisibility", - "name": "spectators" + "name": "includePlayerOwnedObjects" } ] }, { - "catalogId": "eventAbility", - "evidence": [ - "builtin-surface" - ], - "id": "eventAbility", - "kind": "value", - "params": [] - }, - { - "catalogId": "lastCreatedEntity", - "evidence": [ - "builtin-surface" - ], - "id": "lastCreatedEntity", - "kind": "value", - "params": [] - }, - { - "catalogId": "max", + "catalogId": "waitUntil", "evidence": [ "builtin-surface" ], - "id": "max", - "kind": "value", + "id": "waitUntil", + "kind": "action", "params": [ { - "name": "left" + "name": "condition" }, { - "name": "right" + "default": 999999999999, + "name": "timeout" } ] }, { - "catalogId": "eventDamage", + "catalogId": "damage", "evidence": [ - "builtin-surface" + "generic-builtins" ], - "id": "eventDamage", - "kind": "value", - "params": [] + "id": "damage", + "kind": "action", + "params": [ + { + "name": "target" + }, + { + "name": "attacker" + }, + { + "name": "amount" + } + ] }, { - "catalogId": "isInSetup", + "catalogId": "disableInspector", "evidence": [ - "builtin-surface" + "generic-builtins" ], - "id": "isInSetup", - "kind": "value", + "id": "disableInspector", + "kind": "action", "params": [] }, { - "catalogId": "evaluateOnce", + "catalogLink": "special-lowering", "evidence": [ - "builtin-surface" + "generic-builtins" ], - "id": "evaluateOnce", - "kind": "value", + "id": "debug", + "kind": "action", "params": [ { "name": "value" @@ -623,221 +466,286 @@ ] }, { - "catalogId": "abilityIconString", - "evidence": [ - "builtin-surface" - ], - "id": "abilityIconString", - "kind": "value", - "params": [ - { - "domain": "Hero", - "name": "hero" - }, - { - "domain": "Button", - "name": "button" - } - ] - }, - { - "catalogId": "sinDeg", + "catalogId": "logToInspector", "evidence": [ "builtin-surface" ], - "id": "sinDeg", - "kind": "value", + "id": "printLog", + "kind": "action", "params": [ { - "name": "value" + "name": "text" } ] }, { - "catalogId": "cosDeg", + "catalogId": "logToInspector", "evidence": [ "builtin-surface" ], - "id": "cosDeg", - "kind": "value", + "id": "logToInspector", + "kind": "action", "params": [ { - "name": "value" + "name": "text" } ] }, { - "catalogId": "customColor", + "catalogId": "heal", "evidence": [ - "builtin-surface" + "generic-builtins" ], - "id": "rgb", - "kind": "value", + "id": "heal", + "kind": "action", "params": [ { - "name": "red" - }, - { - "name": "green" + "name": "target" }, { - "name": "blue" + "name": "healer" }, { - "default": 255, - "name": "alpha" + "name": "amount" } ] }, { - "catalogId": "updateEveryFrame", + "catalogLink": "special-lowering", "evidence": [ - "builtin-surface" + "generic-builtins" ], - "id": "updateEveryFrame", - "kind": "value", + "id": "print", + "kind": "action", "params": [ { - "name": "value" + "name": "text" } ] }, { - "catalogId": "distance", + "catalogLink": "special-lowering", "evidence": [ - "builtin-surface" + "hud-subheader" ], - "id": "distance", - "kind": "value", - "params": [ + "id": "hudSubheader", + "kind": "action", + "params": [ { - "name": "left" + "name": "visibleTo" }, { - "name": "right" + "name": "text" + }, + { + "domain": "HudPosition", + "name": "position" + }, + { + "name": "sortOrder" + }, + { + "domain": "Color", + "name": "color" + }, + { + "domain": "HudReeval", + "name": "reevaluation" + }, + { + "default": "DEFAULT", + "domain": "SpecVisibility", + "name": "spectators" } ] }, { - "catalogId": "vectorTowards", + "catalogLink": "special-lowering", "evidence": [ "builtin-surface" ], - "id": "vectorTowards", - "kind": "value", + "id": "hudHeader", + "kind": "action", "params": [ { - "name": "start" + "default": { + "call": "getAllPlayers" + }, + "name": "visibleTo" }, { - "name": "end" + "name": "text" + }, + { + "alternateNames": [ + "position" + ], + "default": "LEFT", + "domain": "HudPosition", + "name": "location" + }, + { + "default": 0, + "name": "sortOrder" + }, + { + "default": "WHITE", + "domain": "Color", + "name": "color" + }, + { + "default": "VISIBILITY_SORT_ORDER_STRING_AND_COLOR", + "domain": "HudReeval", + "name": "reevaluation" + }, + { + "alternateNames": [ + "spectators" + ], + "default": "DEFAULT", + "domain": "SpecVisibility", + "name": "specVisibility" } ] }, { - "catalogId": "dotProduct", + "catalogLink": "special-lowering", "evidence": [ "builtin-surface" ], - "id": "dotProduct", - "kind": "value", + "id": "hudSubtext", + "kind": "action", "params": [ { - "name": "left" + "default": { + "call": "getAllPlayers" + }, + "name": "visibleTo" }, { - "name": "right" + "name": "text" + }, + { + "alternateNames": [ + "position" + ], + "default": "LEFT", + "domain": "HudPosition", + "name": "location" + }, + { + "default": 0, + "name": "sortOrder" + }, + { + "default": "WHITE", + "domain": "Color", + "name": "color" + }, + { + "default": "VISIBILITY_SORT_ORDER_STRING_AND_COLOR", + "domain": "HudReeval", + "name": "reevaluation" + }, + { + "alternateNames": [ + "spectators" + ], + "default": "DEFAULT", + "domain": "SpecVisibility", + "name": "specVisibility" } ] }, { - "catalogId": "strContains", + "catalogId": "bigMessage", "evidence": [ "builtin-surface" ], - "id": "strContains", - "kind": "value", + "id": "bigMessage", + "kind": "action", "params": [ { - "name": "text" + "default": { + "call": "getAllPlayers" + }, + "name": "visibleTo" }, { - "name": "substring" + "name": "text" } ] }, { - "catalogId": "getServerLoad", + "catalogId": "smallMessage", "evidence": [ "builtin-surface" ], - "id": "getServerLoad", - "kind": "value", - "params": [] + "id": "smallMessage", + "kind": "action", + "params": [ + { + "default": { + "call": "getAllPlayers" + }, + "name": "visibleTo" + }, + { + "name": "text" + } + ] }, { - "catalogId": "getAverageServerLoad", + "catalogId": "destroyAllEffects", "evidence": [ "builtin-surface" ], - "id": "getAverageServerLoad", - "kind": "value", + "id": "destroyAllEffects", + "kind": "action", "params": [] }, { - "catalogId": "getPeakServerLoad", + "catalogId": "destroyAllDummies", "evidence": [ "builtin-surface" ], - "id": "getPeakServerLoad", - "kind": "value", + "id": "destroyAllDummies", + "kind": "action", "params": [] }, { - "catalogId": "createBeamEffect", + "catalogId": "setObjectiveDescription", "evidence": [ - "generic-builtins" + "builtin-surface" ], - "id": "createBeam", + "id": "setObjectiveDescription", "kind": "action", "params": [ { "name": "visibleTo" }, { - "domain": "Beam", - "name": "type" - }, - { - "name": "startPosition" - }, - { - "name": "endPosition" - }, - { - "domain": "Color", - "name": "color", - "optional": true + "name": "description" }, { - "domain": "EffectReeval", + "domain": "HudReeval", "name": "reevaluation" } ] }, { - "catalogId": "playEffect", + "catalogId": "createEffect", "evidence": [ - "generic-builtins" + "builtin-surface" ], - "id": "playEffect", + "id": "createEffect", "kind": "action", "params": [ { "name": "visibleTo" }, { - "domain": "DynamicEffect", + "domain": "Effect", "name": "type" }, { @@ -849,349 +757,650 @@ }, { "name": "radius" + }, + { + "domain": "EffectReeval", + "name": "reevaluation" } ] }, { - "catalogLink": "special-lowering", - "contextualDomain": { - "by": "rate", - "domain": "ChaseReeval", - "options": { - "duration": { - "domain": "ChaseTimeReeval", - "target": "chaseOverTime" - }, - "rate": { - "domain": "ChaseRateReeval", - "target": "chaseAtRate" - } - } - }, + "catalogId": "declareTeamVictory", "evidence": [ - "chase-keywords", - "chase-reeval-context" + "builtin-surface" ], - "id": "chase", + "id": "declareTeamVictory", "kind": "action", "params": [ { - "name": "variable", - "positionalOnly": true, - "variable": true - }, - { - "name": "destination", - "positionalOnly": true - }, - { - "alternateNames": [ - "duration" - ], - "keywordOnly": true, - "name": "rate" - }, - { - "domain": "ChaseReeval", - "name": "reevaluation" + "domain": "Team", + "name": "team" } ] }, { - "catalogId": "chaseAtRate", + "catalogId": "declarePlayerVictory", "evidence": [ "builtin-surface" ], - "id": "chaseAtRate", + "id": "declarePlayerVictory", "kind": "action", "params": [ { - "name": "variable", - "variable": true - }, - { - "name": "destination" - }, - { - "name": "rate" - }, - { - "default": "DESTINATION_AND_RATE", - "domain": "ChaseRateReeval", - "name": "reevaluation" + "name": "player" } ] }, { - "catalogId": "chaseOverTime", + "catalogId": "setSlowMotion", "evidence": [ - "chase-over-time", - "chase-over-time-defaults" + "builtin-surface" ], - "id": "chaseOverTime", + "id": "setSlowMotion", "kind": "action", "params": [ { - "name": "variable", - "variable": true + "name": "percent" + } + ] + }, + { + "catalogId": "createInWorldText", + "evidence": [ + "builtin-surface" + ], + "id": "createInWorldText", + "kind": "action", + "params": [ + { + "name": "visibleTo" }, { - "name": "destination" + "name": "header" }, { - "name": "duration" + "name": "position" }, { - "default": "DESTINATION_AND_DURATION", - "domain": "ChaseTimeReeval", + "name": "scale" + }, + { + "domain": "Clipping", + "name": "clipping" + }, + { + "domain": "WorldTextReeval", "name": "reevaluation" + }, + { + "default": "WHITE", + "domain": "Color", + "name": "textColor" + }, + { + "default": "DEFAULT", + "domain": "SpecVisibility", + "name": "spectators" } ] }, { - "catalogId": "stopChasingGlobalVariable", + "catalogId": "destroyAllHudText", "evidence": [ "builtin-surface" ], - "id": "stopChasingVariable", + "id": "destroyAllHudText", + "kind": "action", + "params": [] + }, + { + "catalogId": "progressBarHud", + "evidence": [ + "builtin-surface" + ], + "id": "progressBarHud", "kind": "action", "params": [ { - "name": "variable" + "name": "visibleTo" + }, + { + "name": "value" + }, + { + "name": "text" + }, + { + "name": "position" + }, + { + "name": "scale" + }, + { + "name": "barColor" + }, + { + "name": "textColor" + }, + { + "name": "reevaluation" + }, + { + "name": "spectators" } ] }, { - "catalogId": "countOf", + "catalogId": "destroyHudText", "evidence": [ - "generic-builtins" + "builtin-surface" ], - "id": "len", - "kind": "value", + "id": "destroyHudText", + "kind": "action", "params": [ { - "name": "array" + "name": "textId" } ] }, { - "catalogId": "absoluteValue", + "catalogId": "destroyProgressBarInWorldText", "evidence": [ - "generic-builtins" + "builtin-surface" ], - "id": "abs", - "kind": "value", + "id": "destroyProgressBarInWorldText", + "kind": "action", "params": [ { - "name": "value" + "name": "textId" } ] }, { - "catalogId": "squareRoot", + "catalogId": "destroyProgressBarHud", "evidence": [ - "generic-builtins" + "builtin-surface" ], - "id": "sqrt", - "kind": "value", + "id": "destroyProgressBarHud", + "kind": "action", "params": [ { - "name": "value" + "name": "textId" } ] }, { - "catalogId": "allPlayers", + "catalogId": "destroyInWorldText", "evidence": [ - "generic-builtins" + "builtin-surface" ], - "id": "getAllPlayers", - "kind": "value", - "params": [] + "id": "destroyInWorldText", + "kind": "action", + "params": [ + { + "name": "textId" + } + ] }, { - "catalogId": "directionFromAngles", + "catalogId": "destroyEffect", "evidence": [ "builtin-surface" ], - "id": "directionFromAngles", - "kind": "value", + "id": "destroyEffect", + "kind": "action", "params": [ { - "name": "horizontalAngle" - }, - { - "name": "verticalAngle" + "name": "effectId" } ] }, { - "catalogId": "inputBindingString", + "catalogId": "loop", "evidence": [ "builtin-surface" ], - "id": "inputBindingString", - "kind": "value", + "id": "loop", + "kind": "action", + "params": [] + }, + { + "catalogId": "removeHealthPool", + "evidence": [ + "builtin-surface" + ], + "id": "removeHealthPool", + "kind": "action", "params": [ { - "domain": "Button", - "name": "button" + "name": "healthPoolId" } ] }, { - "catalogId": "randomReal", + "catalogId": "startHealingOverTime", "evidence": [ - "generic-builtins" + "builtin-surface" ], - "id": "random.uniform", - "keywordArgs": false, - "kind": "value", + "id": "startHoT", + "kind": "memberAction", "params": [ { - "name": "min" + "name": "healer" }, { - "name": "max" + "name": "duration" + }, + { + "name": "healing" } - ] + ], + "receiver": "Player" }, { - "catalogId": "randomValueInArray", + "catalogId": "startDamageOverTime", "evidence": [ - "generic-builtins" + "builtin-surface" ], - "id": "random.choice", - "keywordArgs": false, - "kind": "value", + "id": "startDamageOverTime", + "kind": "memberAction", "params": [ { - "name": "array" + "name": "attacker" + }, + { + "name": "duration" + }, + { + "name": "damage" } - ] + ], + "receiver": "Player" }, { - "catalogId": "randomInteger", + "catalogId": "startHealingOverTime", "evidence": [ "builtin-surface" ], - "id": "random.randint", - "keywordArgs": false, - "kind": "value", + "id": "startHealingOverTime", + "kind": "memberAction", "params": [ { - "name": "min" + "name": "healer" }, { - "name": "max" + "name": "duration" + }, + { + "name": "healing" } - ] + ], + "receiver": "Player" }, { - "catalogId": "randomizedArray", + "catalogId": "stopHealingOverTime", "evidence": [ "builtin-surface" ], - "id": "random.shuffle", - "keywordArgs": false, - "kind": "value", + "id": "stopHoT", + "kind": "action", "params": [ { - "name": "array" + "name": "healingOverTimeId" } ] }, { - "catalogId": "allPlayers", + "catalogId": "stopHealingOverTime", "evidence": [ "builtin-surface" ], - "id": "getPlayers", - "kind": "value", + "id": "stopHealingOverTime", + "kind": "action", "params": [ { - "default": "ALL", - "domain": "Team", - "name": "team" + "name": "healingOverTimeId" } ] }, { - "catalogId": "allHeroes", + "catalogId": "stopHealingModification", "evidence": [ "builtin-surface" ], - "id": "getAllHeroes", - "kind": "value", - "params": [] + "id": "stopHealingModification", + "kind": "action", + "params": [ + { + "name": "player" + } + ] }, { - "catalogId": "heroIconString", + "catalogId": "startHealingModification", "evidence": [ "builtin-surface" ], - "id": "heroIcon", - "kind": "value", + "id": "startHealingModification", + "kind": "action", "params": [ { - "name": "hero" + "name": "target" + }, + { + "name": "healer" + }, + { + "name": "amount" + }, + { + "domain": "HealingReeval", + "name": "reevaluation" } ] }, { - "catalogId": "getMatchTime", + "catalogId": "getLastHealingModification", "evidence": [ "builtin-surface" ], - "id": "getMatchTime", + "id": "getLastHealingModification", "kind": "value", "params": [] }, { - "catalogLink": "special-lowering", + "catalogId": "disableBuiltinGameModeCompletion", "evidence": [ "builtin-surface" ], - "id": "all", - "kind": "value", - "params": [ - { - "name": "array" - } - ] + "id": "disableGamemodeCompletion", + "kind": "action", + "params": [] }, { - "catalogLink": "special-lowering", + "catalogId": "disableBuiltinGameModeScoring", "evidence": [ "builtin-surface" ], - "id": "any", - "kind": "value", - "params": [ - { - "name": "array" - } - ] + "id": "disableScoring", + "kind": "action", + "params": [] }, { - "catalogLink": "special-lowering", + "catalogId": "createHudText", "evidence": [ "builtin-surface" ], - "id": "ceil", - "kind": "value", + "id": "hudText", + "kind": "action", "params": [ { - "name": "value" - } - ] - }, - { - "catalogLink": "special-lowering", - "evidence": [ - "builtin-surface" - ], - "id": "floor", + "name": "visibleTo" + }, + { + "name": "header" + }, + { + "name": "subheader" + }, + { + "name": "text" + }, + { + "domain": "HudPosition", + "name": "position" + }, + { + "name": "sortOrder" + }, + { + "domain": "Color", + "name": "headerColor" + }, + { + "domain": "Color", + "name": "subheaderColor" + }, + { + "domain": "Color", + "name": "textColor" + }, + { + "domain": "HudReeval", + "name": "reevaluation" + }, + { + "default": "DEFAULT", + "domain": "SpecVisibility", + "name": "spectators" + } + ] + }, + { + "catalogId": "eventAbility", + "evidence": [ + "builtin-surface" + ], + "id": "eventAbility", + "kind": "value", + "params": [] + }, + { + "catalogId": "currentMap", + "evidence": [ + "builtin-surface" + ], + "id": "getCurrentMap", + "kind": "value", + "params": [] + }, + { + "catalogId": "isInLoS", + "evidence": [ + "builtin-surface" + ], + "id": "isInLoS", + "kind": "value", + "params": [ + { + "name": "startPosition" + }, + { + "name": "endPosition" + }, + { + "default": "BLOCKED_BY_ALL_BARRIERS", + "domain": "BarrierLos", + "name": "barriers" + } + ] + }, + { + "catalogId": "kill", + "evidence": [ + "builtin-surface" + ], + "id": "kill", + "kind": "action", + "params": [ + { + "name": "target" + }, + { + "name": "killer" + } + ] + }, + { + "catalogId": "getSpawnPoints", + "evidence": [ + "builtin-surface" + ], + "id": "getSpawnPoints", + "kind": "value", + "params": [ + { + "domain": "Team", + "name": "team" + } + ] + }, + { + "catalogId": "getPlayersInRadius", + "evidence": [ + "builtin-surface" + ], + "id": "getRealPlayersInRadius", + "kind": "value", + "params": [ + { + "name": "center" + }, + { + "name": "radius" + }, + { + "domain": "Team", + "name": "team" + }, + { + "domain": "LosCheck", + "name": "losCheck" + } + ] + }, + { + "catalogId": "getLivingPlayers", + "evidence": [ + "builtin-surface" + ], + "id": "getLivingPlayers", + "kind": "value", + "params": [ + { + "default": "ALL", + "domain": "Team", + "name": "team" + } + ] + }, + { + "catalogId": "nearestWalkablePosition", + "evidence": [ + "builtin-surface" + ], + "id": "nearestWalkablePosition", + "kind": "value", + "params": [ + { + "name": "position" + } + ] + }, + { + "catalogId": "numberOfPlayers", + "evidence": [ + "builtin-surface" + ], + "id": "getNumberOfPlayers", + "kind": "value", + "params": [ + { + "default": "ALL", + "domain": "Team", + "name": "team" + } + ] + }, + { + "catalogId": "lastCreatedEntity", + "evidence": [ + "builtin-surface" + ], + "id": "lastCreatedEntity", + "kind": "value", + "params": [] + }, + { + "catalogId": "getLastHealingOverTimeId", + "evidence": [ + "builtin-surface" + ], + "id": "getLastHoT", + "kind": "value", + "params": [] + }, + { + "catalogId": "getLastCreatedHealthPool", + "evidence": [ + "builtin-surface" + ], + "id": "getLastCreatedHealthPool", + "kind": "value", + "params": [] + }, + { + "catalogId": "getLastHealingOverTimeId", + "evidence": [ + "builtin-surface" + ], + "id": "getLastHealingOverTimeId", + "kind": "value", + "params": [] + }, + { + "catalogId": "entityExists", + "evidence": [ + "builtin-surface" + ], + "id": "entityExists", + "kind": "value", + "params": [ + { + "name": "entity" + } + ] + }, + { + "catalogId": "max", + "evidence": [ + "builtin-surface" + ], + "id": "max", + "kind": "value", + "params": [ + { + "name": "left" + }, + { + "name": "right" + } + ] + }, + { + "catalogId": "eventDamage", + "evidence": [ + "builtin-surface" + ], + "id": "eventDamage", + "kind": "value", + "params": [] + }, + { + "catalogId": "isInSetup", + "evidence": [ + "builtin-surface" + ], + "id": "isInSetup", + "kind": "value", + "params": [] + }, + { + "catalogId": "evaluateOnce", + "evidence": [ + "builtin-surface" + ], + "id": "evaluateOnce", "kind": "value", "params": [ { @@ -1200,1173 +1409,3329 @@ ] }, { - "catalogLink": "special-lowering", + "catalogId": "abilityIconString", + "evidence": [ + "builtin-surface" + ], + "id": "abilityIconString", + "kind": "value", + "params": [ + { + "domain": "Hero", + "name": "hero" + }, + { + "domain": "Button", + "name": "button" + } + ] + }, + { + "catalogId": "sinDeg", + "evidence": [ + "builtin-surface" + ], + "id": "sinDeg", + "kind": "value", + "params": [ + { + "name": "value" + } + ] + }, + { + "catalogId": "cosDeg", + "evidence": [ + "builtin-surface" + ], + "id": "cosDeg", + "kind": "value", + "params": [ + { + "name": "value" + } + ] + }, + { + "catalogId": "customColor", + "evidence": [ + "builtin-surface" + ], + "id": "rgb", + "kind": "value", + "params": [ + { + "name": "red" + }, + { + "name": "green" + }, + { + "name": "blue" + }, + { + "default": 255, + "name": "alpha" + } + ] + }, + { + "catalogId": "updateEveryFrame", + "evidence": [ + "builtin-surface" + ], + "id": "updateEveryFrame", + "kind": "value", + "params": [ + { + "name": "value" + } + ] + }, + { + "catalogId": "distance", + "evidence": [ + "builtin-surface" + ], + "id": "distance", + "kind": "value", + "params": [ + { + "name": "left" + }, + { + "name": "right" + } + ] + }, + { + "catalogId": "vectorTowards", + "evidence": [ + "builtin-surface" + ], + "id": "vectorTowards", + "kind": "value", + "params": [ + { + "name": "start" + }, + { + "name": "end" + } + ] + }, + { + "catalogId": "dotProduct", + "evidence": [ + "builtin-surface" + ], + "id": "dotProduct", + "kind": "value", + "params": [ + { + "name": "left" + }, + { + "name": "right" + } + ] + }, + { + "catalogId": "strContains", + "evidence": [ + "builtin-surface" + ], + "id": "strContains", + "kind": "value", + "params": [ + { + "name": "text" + }, + { + "name": "substring" + } + ] + }, + { + "catalogId": "getServerLoad", + "evidence": [ + "builtin-surface" + ], + "id": "getServerLoad", + "kind": "value", + "params": [] + }, + { + "catalogId": "getAverageServerLoad", + "evidence": [ + "builtin-surface" + ], + "id": "getAverageServerLoad", + "kind": "value", + "params": [] + }, + { + "catalogId": "getPeakServerLoad", + "evidence": [ + "builtin-surface" + ], + "id": "getPeakServerLoad", + "kind": "value", + "params": [] + }, + { + "catalogId": "createBeamEffect", + "evidence": [ + "generic-builtins" + ], + "id": "createBeam", + "kind": "action", + "params": [ + { + "name": "visibleTo" + }, + { + "domain": "Beam", + "name": "type" + }, + { + "name": "startPosition" + }, + { + "name": "endPosition" + }, + { + "domain": "Color", + "name": "color", + "optional": true + }, + { + "domain": "EffectReeval", + "name": "reevaluation" + } + ] + }, + { + "catalogId": "playEffect", + "evidence": [ + "generic-builtins" + ], + "id": "playEffect", + "kind": "action", + "params": [ + { + "name": "visibleTo" + }, + { + "domain": "DynamicEffect", + "name": "type" + }, + { + "domain": "Color", + "name": "color" + }, + { + "name": "position" + }, + { + "name": "radius" + } + ] + }, + { + "catalogLink": "special-lowering", + "contextualDomain": { + "by": "rate", + "domain": "ChaseReeval", + "options": { + "duration": { + "domain": "ChaseTimeReeval", + "target": "chaseOverTime" + }, + "rate": { + "domain": "ChaseRateReeval", + "target": "chaseAtRate" + } + } + }, + "evidence": [ + "chase-keywords", + "chase-reeval-context" + ], + "id": "chase", + "kind": "action", + "params": [ + { + "name": "variable", + "positionalOnly": true, + "variable": true + }, + { + "name": "destination", + "positionalOnly": true + }, + { + "alternateNames": [ + "duration" + ], + "keywordOnly": true, + "name": "rate" + }, + { + "domain": "ChaseReeval", + "name": "reevaluation" + } + ] + }, + { + "catalogId": "chaseAtRate", + "evidence": [ + "builtin-surface" + ], + "id": "chaseAtRate", + "kind": "action", + "params": [ + { + "name": "variable", + "variable": true + }, + { + "name": "destination" + }, + { + "name": "rate" + }, + { + "default": "DESTINATION_AND_RATE", + "domain": "ChaseRateReeval", + "name": "reevaluation" + } + ] + }, + { + "catalogId": "chaseOverTime", + "evidence": [ + "chase-over-time", + "chase-over-time-defaults" + ], + "id": "chaseOverTime", + "kind": "action", + "params": [ + { + "name": "variable", + "variable": true + }, + { + "name": "destination" + }, + { + "name": "duration" + }, + { + "default": "DESTINATION_AND_DURATION", + "domain": "ChaseTimeReeval", + "name": "reevaluation" + } + ] + }, + { + "catalogId": "stopChasingGlobalVariable", + "evidence": [ + "builtin-surface" + ], + "id": "stopChasingVariable", + "kind": "action", + "params": [ + { + "name": "variable" + } + ] + }, + { + "catalogId": "countOf", + "evidence": [ + "generic-builtins" + ], + "id": "len", + "kind": "value", + "params": [ + { + "name": "array" + } + ] + }, + { + "catalogId": "absoluteValue", + "evidence": [ + "generic-builtins" + ], + "id": "abs", + "kind": "value", + "params": [ + { + "name": "value" + } + ] + }, + { + "catalogId": "squareRoot", + "evidence": [ + "generic-builtins" + ], + "id": "sqrt", + "kind": "value", + "params": [ + { + "name": "value" + } + ] + }, + { + "catalogId": "allPlayers", + "evidence": [ + "generic-builtins" + ], + "id": "getAllPlayers", + "kind": "value", + "params": [] + }, + { + "catalogId": "directionFromAngles", + "evidence": [ + "builtin-surface" + ], + "id": "directionFromAngles", + "kind": "value", + "params": [ + { + "name": "horizontalAngle" + }, + { + "name": "verticalAngle" + } + ] + }, + { + "catalogId": "inputBindingString", + "evidence": [ + "builtin-surface" + ], + "id": "inputBindingString", + "kind": "value", + "params": [ + { + "domain": "Button", + "name": "button" + } + ] + }, + { + "catalogId": "randomReal", + "evidence": [ + "generic-builtins" + ], + "id": "random.uniform", + "keywordArgs": false, + "kind": "value", + "params": [ + { + "name": "min" + }, + { + "name": "max" + } + ] + }, + { + "catalogId": "randomValueInArray", + "evidence": [ + "generic-builtins" + ], + "id": "random.choice", + "keywordArgs": false, + "kind": "value", + "params": [ + { + "name": "array" + } + ] + }, + { + "catalogId": "randomInteger", + "evidence": [ + "builtin-surface" + ], + "id": "random.randint", + "keywordArgs": false, + "kind": "value", + "params": [ + { + "name": "min" + }, + { + "name": "max" + } + ] + }, + { + "catalogId": "randomizedArray", + "evidence": [ + "builtin-surface" + ], + "id": "random.shuffle", + "keywordArgs": false, + "kind": "value", + "params": [ + { + "name": "array" + } + ] + }, + { + "catalogId": "allPlayers", + "evidence": [ + "builtin-surface" + ], + "id": "getPlayers", + "kind": "value", + "params": [ + { + "default": "ALL", + "domain": "Team", + "name": "team" + } + ] + }, + { + "catalogId": "allHeroes", + "evidence": [ + "builtin-surface" + ], + "id": "getAllHeroes", + "kind": "value", + "params": [] + }, + { + "catalogId": "allTankHeroes", + "evidence": [ + "builtin-surface" + ], + "id": "allTankHeroes", + "kind": "value", + "params": [] + }, + { + "catalogId": "allDamageHeroes", + "evidence": [ + "builtin-surface" + ], + "id": "allDamageHeroes", + "kind": "value", + "params": [] + }, + { + "catalogId": "allSupportHeroes", + "evidence": [ + "builtin-surface" + ], + "id": "allSupportHeroes", + "kind": "value", + "params": [] + }, + { + "catalogId": "iconString", + "evidence": [ + "builtin-surface" + ], + "id": "iconString", + "kind": "value", + "params": [ + { + "domain": "Icon", + "name": "icon" + } + ] + }, + { + "catalogId": "createProgressBarInWorldText", + "evidence": [ + "builtin-surface" + ], + "id": "createProgressBarInWorldText", + "kind": "action", + "params": [ + { + "name": "visibleTo" + }, + { + "name": "value" + }, + { + "name": "text" + }, + { + "name": "position" + }, + { + "name": "scale" + }, + { + "domain": "Clipping", + "name": "clipping" + }, + { + "domain": "Color", + "name": "headerColor" + }, + { + "domain": "Color", + "name": "textColor" + }, + { + "domain": "ProgressWorldTextReeval", + "name": "reevaluation" + }, + { + "default": "DEFAULT", + "domain": "SpecVisibility", + "name": "spectators" + } + ] + }, + { + "catalogId": "heroIconString", + "evidence": [ + "builtin-surface" + ], + "id": "heroIcon", + "kind": "value", + "params": [ + { + "name": "hero" + } + ] + }, + { + "catalogId": "getMatchTime", + "evidence": [ + "builtin-surface" + ], + "id": "getMatchTime", + "kind": "value", + "params": [] + }, + { + "catalogLink": "special-lowering", + "evidence": [ + "builtin-surface" + ], + "id": "all", + "kind": "value", + "params": [ + { + "name": "array" + } + ] + }, + { + "catalogId": "filteredArray", + "evidence": [ + "builtin-surface" + ], + "id": "filter", + "kind": "memberValue", + "params": [ + { + "name": "condition" + } + ], + "receiver": "Any" + }, + { + "catalogLink": "special-lowering", + "evidence": [ + "builtin-surface" + ], + "id": "any", + "kind": "value", + "params": [ + { + "name": "array" + } + ] + }, + { + "catalogLink": "special-lowering", + "evidence": [ + "builtin-surface" + ], + "id": "ceil", + "kind": "value", + "params": [ + { + "name": "value" + } + ] + }, + { + "catalogLink": "special-lowering", + "evidence": [ + "builtin-surface" + ], + "id": "floor", + "kind": "value", + "params": [ + { + "name": "value" + } + ] + }, + { + "catalogLink": "special-lowering", + "evidence": [ + "builtin-surface" + ], + "id": "round", + "kind": "value", + "params": [ + { + "name": "value" + } + ] + }, + { + "catalogLink": "special-lowering", + "context": "forIterable", + "evidence": [ + "range-for-header" + ], + "id": "range", + "keywordArgs": false, + "kind": "value", + "params": [ + { + "name": "start" + }, + { + "name": "stop", + "optional": true + }, + { + "name": "step", + "optional": true + } + ] + }, + { + "catalogId": "vector", + "evidence": [ + "generic-builtins" + ], + "id": "vect", + "kind": "value", + "params": [ + { + "name": "x" + }, + { + "name": "y" + }, + { + "name": "z" + } + ] + }, + { + "catalogId": "isGameInProgress", + "evidence": [ + "is-game-in-progress" + ], + "id": "isGameInProgress", + "kind": "value", + "params": [] + }, + { + "catalogLink": "special-lowering", + "evidence": [ + "rule-condition" + ], + "id": "ruleCondition", + "kind": "value", + "params": [] + }, + { + "catalogId": "isAssemblingHeroes", + "evidence": [ + "builtin-surface" + ], + "id": "isAssemblingHeroes", + "kind": "value", + "params": [] + }, + { + "catalogId": "setMatchTime", + "evidence": [ + "builtin-surface" + ], + "id": "setMatchTime", + "kind": "action", + "params": [ + { + "name": "time" + } + ] + }, + { + "catalogId": "horizontalAngleFromDirection", + "evidence": [ + "builtin-surface" + ], + "id": "horizontalAngleOfDirection", + "kind": "value", + "params": [ + { + "name": "direction" + } + ] + }, + { + "catalogId": "createDummyBot", + "evidence": [ + "builtin-surface" + ], + "id": "createDummy", + "kind": "action", + "params": [ + { + "domain": "Hero", + "name": "hero" + }, + { + "domain": "Team", + "name": "team" + }, + { + "name": "slot" + }, + { + "name": "position" + }, + { + "name": "facing", + "optional": true + } + ] + }, + { + "catalogId": "getPlayersInRadius", + "evidence": [ + "get-players-in-radius" + ], + "id": "getPlayersInRadius", + "kind": "value", + "params": [ + { + "name": "center" + }, + { + "name": "radius" + }, + { + "default": "ALL", + "domain": "Team", + "name": "team" + }, + { + "default": "OFF", + "domain": "LosCheck", + "name": "losCheck" + } + ] + }, + { + "catalogId": "worldVector", + "evidence": [ + "enum-gated-members" + ], + "id": "worldVector", + "kind": "value", + "params": [ + { + "domain": "Vector", + "name": "localVector" + }, + { + "name": "relativePlayer" + }, + { + "domain": "Transform", + "name": "transformation" + } + ] + }, + { + "catalogLink": "special-lowering", + "evidence": [ + "receiver-calls" + ], + "id": "append", + "kind": "memberAction", + "params": [ + { + "name": "value" + } + ], + "receiver": "Variable" + }, + { + "catalogLink": "special-lowering", + "evidence": [ + "builtin-surface" + ], + "id": "remove", + "kind": "memberAction", + "params": [ + { + "name": "value" + } + ], + "receiver": "Variable" + }, + { + "catalogId": "charAt", + "evidence": [ + "builtin-surface" + ], + "id": "charAt", + "kind": "memberValue", + "params": [ + { + "name": "index" + } + ], + "receiver": "Any" + }, + { + "catalogId": "stringSplit", + "evidence": [ + "builtin-surface" + ], + "id": "split", + "kind": "memberValue", + "params": [ + { + "name": "separator" + } + ], + "receiver": "Any" + }, + { + "catalogId": "slice", + "evidence": [ + "builtin-surface" + ], + "id": "slice", + "kind": "memberValue", + "params": [ + { + "name": "startIndex" + }, + { + "default": 999999999999, + "name": "count" + } + ], + "receiver": "Any" + }, + { + "catalogId": "stringReplace", + "evidence": [ + "builtin-surface" + ], + "id": "replace", + "kind": "memberValue", + "params": [ + { + "name": "search" + }, + { + "name": "replacement" + } + ], + "receiver": "Any" + }, + { + "catalogLink": "special-lowering", + "evidence": [ + "builtin-surface" + ], + "id": "concat", + "kind": "memberValue", + "params": [ + { + "name": "value" + } + ], + "receiver": "Any" + }, + { + "catalogLink": "special-lowering", + "evidence": [ + "builtin-surface" + ], + "id": "exclude", + "kind": "memberValue", + "params": [ + { + "name": "value" + } + ], + "receiver": "Any" + }, + { + "catalogId": "indexOfArrayValue", + "evidence": [ + "builtin-surface" + ], + "id": "index", + "kind": "memberValue", + "params": [ + { + "name": "value" + } + ], + "receiver": "Any" + }, + { + "catalogId": "setMoveSpeed", + "evidence": [ + "receiver-calls" + ], + "id": "setMoveSpeed", + "kind": "memberAction", + "params": [ + { + "name": "moveSpeedPercent" + } + ], + "receiver": "Player" + }, + { + "catalogId": "setWeapon", + "evidence": [ + "receiver-calls" + ], + "id": "setWeapon", + "kind": "memberAction", + "params": [ + { + "name": "weapon" + } + ], + "receiver": "Player" + }, + { + "catalogId": "setMaxHealth", + "evidence": [ + "receiver-calls" + ], + "id": "setMaxHealth", + "kind": "memberAction", + "params": [ + { + "name": "healthPercent" + } + ], + "receiver": "Player" + }, + { + "catalogId": "setHealth", + "evidence": [ + "receiver-calls" + ], + "id": "setHealth", + "kind": "memberAction", + "params": [ + { + "name": "amount" + } + ], + "receiver": "Player" + }, + { + "catalogId": "setAimSpeed", + "evidence": [ + "receiver-calls" + ], + "id": "setAimSpeed", + "kind": "memberAction", + "params": [ + { + "name": "turnSpeedPercent" + } + ], + "receiver": "Player" + }, + { + "catalogId": "setGravity", + "evidence": [ + "receiver-calls" + ], + "id": "setGravity", + "kind": "memberAction", + "params": [ + { + "name": "gravityPercent" + } + ], + "receiver": "Player" + }, + { + "catalogId": "setDamageDealt", + "evidence": [ + "receiver-calls" + ], + "id": "setDamageDealt", + "kind": "memberAction", + "params": [ + { + "name": "damageDealtPercent" + } + ], + "receiver": "Player" + }, + { + "catalogId": "setDamageReceived", + "evidence": [ + "receiver-calls" + ], + "id": "setDamageReceived", + "kind": "memberAction", + "params": [ + { + "name": "damageReceivedPercent" + } + ], + "receiver": "Player" + }, + { + "catalogId": "setUltCharge", + "evidence": [ + "receiver-calls" + ], + "id": "setUltCharge", + "kind": "memberAction", + "params": [ + { + "name": "chargePercent" + } + ], + "receiver": "Player" + }, + { + "catalogId": "teleport", + "evidence": [ + "receiver-calls" + ], + "id": "teleport", + "kind": "memberAction", + "params": [ + { + "name": "position" + } + ], + "receiver": "Player" + }, + { + "catalogId": "setInvisibility", + "evidence": [ + "enum-gated-members" + ], + "id": "setInvisibility", + "kind": "memberAction", + "params": [ + { + "domain": "Invis", + "name": "invisibleTo" + } + ], + "receiver": "Player" + }, + { + "catalogId": "setStatusEffect", + "evidence": [ + "enum-gated-members" + ], + "id": "setStatusEffect", + "kind": "memberAction", + "params": [ + { + "name": "assister" + }, + { + "domain": "Status", + "name": "status" + }, + { + "name": "duration" + } + ], + "receiver": "Player" + }, + { + "catalogId": "getPosition", + "evidence": [ + "receiver-calls" + ], + "id": "getPosition", + "kind": "memberValue", + "params": [], + "receiver": "Player" + }, + { + "catalogId": "raycastHitPosition", + "evidence": [ + "builtin-surface" + ], + "id": "getHitPosition", + "kind": "memberValue", + "params": [], + "receiver": "Any" + }, + { + "catalogId": "raycastHitNormal", + "evidence": [ + "builtin-surface" + ], + "id": "getNormal", + "kind": "memberValue", + "params": [], + "receiver": "Any" + }, + { + "catalogId": "__xComponentOf__", + "evidence": [ + "builtin-surface" + ], + "id": "x", + "kind": "memberValue", + "params": [], + "receiver": "Vector" + }, + { + "catalogId": "__yComponentOf__", + "evidence": [ + "builtin-surface" + ], + "id": "y", + "kind": "memberValue", + "params": [], + "receiver": "Vector" + }, + { + "catalogId": "__zComponentOf__", + "evidence": [ + "builtin-surface" + ], + "id": "z", + "kind": "memberValue", + "params": [], + "receiver": "Vector" + }, + { + "catalogId": "lastOf", + "evidence": [ + "builtin-surface" + ], + "id": "last", + "kind": "memberValue", + "params": [], + "receiver": "Any" + }, + { + "catalogId": "getHealth", + "evidence": [ + "receiver-calls" + ], + "id": "getHealth", + "kind": "memberValue", + "params": [], + "receiver": "Player" + }, + { + "catalogId": "getAmmo", + "evidence": [ + "receiver-calls" + ], + "id": "getAmmo", + "kind": "memberValue", + "params": [ + { + "default": 0, + "name": "clip" + } + ], + "receiver": "Player" + }, + { + "catalogId": "getHealthOfType", + "evidence": [ + "builtin-surface" + ], + "id": "getHealthOfType", + "kind": "memberValue", + "params": [ + { + "domain": "Health", + "name": "healthType" + } + ], + "receiver": "Player" + }, + { + "catalogId": "getMaxAmmo", + "evidence": [ + "receiver-calls" + ], + "id": "getMaxAmmo", + "kind": "memberValue", + "params": [ + { + "default": 0, + "name": "clip" + } + ], + "receiver": "Player" + }, + { + "catalogId": "isDuplicatingAHero", + "evidence": [ + "builtin-surface" + ], + "id": "isDuplicatingAHero", + "kind": "memberValue", + "params": [], + "receiver": "Player" + }, + { + "catalogId": "isAlive", + "evidence": [ + "receiver-calls" + ], + "id": "isAlive", + "kind": "memberValue", + "params": [], + "receiver": "Player" + }, + { + "catalogId": "getHorizontalFacingAngle", + "evidence": [ + "get-horizontal-facing-angle" + ], + "id": "getHorizontalFacingAngle", + "kind": "memberValue", + "params": [], + "receiver": "Player" + }, + { + "catalogId": "getEyePosition", + "evidence": [ + "builtin-surface" + ], + "id": "getEyePosition", + "kind": "memberValue", + "params": [], + "receiver": "Player" + }, + { + "catalogId": "getSlot", + "evidence": [ + "builtin-surface" + ], + "id": "getSlot", + "kind": "memberValue", + "params": [], + "receiver": "Player" + }, + { + "catalogId": "getMaxHealth", + "evidence": [ + "builtin-surface" + ], + "id": "getMaxHealth", + "kind": "memberValue", + "params": [], + "receiver": "Player" + }, + { + "catalogId": "getUltCharge", + "evidence": [ + "builtin-surface" + ], + "id": "getUltCharge", + "kind": "memberValue", + "params": [], + "receiver": "Player" + }, + { + "catalogId": "addToScore", + "evidence": [ + "builtin-surface" + ], + "id": "addToScore", + "kind": "memberAction", + "params": [ + { + "name": "score" + } + ], + "receiver": "Player" + }, + { + "catalogId": "getNormalizedHealth", + "evidence": [ + "builtin-surface" + ], + "id": "getNormalizedHealth", + "kind": "memberValue", + "params": [], + "receiver": "Player" + }, + { + "catalogId": "isReloading", + "evidence": [ + "builtin-surface" + ], + "id": "isReloading", + "kind": "memberValue", + "params": [], + "receiver": "Player" + }, + { + "catalogId": "isMoving", + "evidence": [ + "builtin-surface" + ], + "id": "isMoving", + "kind": "memberValue", + "params": [], + "receiver": "Player" + }, + { + "catalogId": "isCommunicatingEmote", + "evidence": [ + "builtin-surface" + ], + "id": "isCommunicatingEmote", + "kind": "memberValue", + "params": [], + "receiver": "Player" + }, + { + "catalogId": "isInAir", + "evidence": [ + "builtin-surface" + ], + "id": "isInAir", + "kind": "memberValue", + "params": [], + "receiver": "Player" + }, + { + "catalogId": "isUsingAbility2", + "evidence": [ + "builtin-surface" + ], + "id": "isUsingAbility2", + "kind": "memberValue", + "params": [], + "receiver": "Player" + }, + { + "catalogId": "disallowButton", + "evidence": [ + "builtin-surface" + ], + "id": "disallowButton", + "kind": "memberAction", + "params": [ + { + "domain": "Button", + "name": "button" + } + ], + "receiver": "Player" + }, + { + "catalogId": "isUsingAbility1", + "evidence": [ + "builtin-surface" + ], + "id": "isUsingAbility1", + "kind": "memberValue", + "params": [], + "receiver": "Player" + }, + { + "catalogId": "isInAlternateForm", + "evidence": [ + "builtin-surface" + ], + "id": "isInAlternateForm", + "kind": "memberValue", + "params": [], + "receiver": "Player" + }, + { + "catalogId": "allowButton", + "evidence": [ + "builtin-surface" + ], + "id": "allowButton", + "kind": "memberAction", + "params": [ + { + "domain": "Button", + "name": "button" + } + ], + "receiver": "Player" + }, + { + "catalogId": "isUsingUltimate", + "evidence": [ + "builtin-surface" + ], + "id": "isUsingUltimate", + "kind": "memberValue", + "params": [], + "receiver": "Player" + }, + { + "catalogId": "disableKillFeed", + "evidence": [ + "builtin-surface" + ], + "id": "disableKillFeed", + "kind": "memberAction", + "params": [], + "receiver": "Player" + }, + { + "catalogId": "startForcingPosition", + "evidence": [ + "builtin-surface" + ], + "id": "startForcingPosition", + "kind": "memberAction", + "params": [ + { + "name": "position" + }, + { + "name": "includeY" + } + ], + "receiver": "Player" + }, + { + "catalogId": "isDead", + "evidence": [ + "builtin-surface" + ], + "id": "isDead", + "kind": "memberValue", + "params": [], + "receiver": "Player" + }, + { + "catalogId": "allowedHeroes", + "evidence": [ + "builtin-surface" + ], + "id": "getAllowedHeroes", + "kind": "memberValue", + "params": [], + "receiver": "Player" + }, + { + "catalogId": "startHealingOverTime", + "evidence": [ + "builtin-surface" + ], + "id": "startHoT", + "kind": "memberAction", + "params": [ + { + "name": "healer" + }, + { + "name": "rate" + }, + { + "name": "duration" + } + ], + "receiver": "Player" + }, + { + "catalogId": "isInSpawnRoom", + "evidence": [ + "builtin-surface" + ], + "id": "isInSpawnRoom", + "kind": "memberValue", + "params": [], + "receiver": "Player" + }, + { + "catalogId": "attachTo", + "evidence": [ + "builtin-surface" + ], + "id": "attachTo", + "kind": "memberAction", + "params": [ + { + "name": "parent" + }, + { + "name": "position" + } + ], + "receiver": "Player" + }, + { + "catalogId": "isMeleeing", + "evidence": [ + "builtin-surface" + ], + "id": "isMeleeing", + "kind": "memberValue", + "params": [], + "receiver": "Player" + }, + { + "catalogId": "disableMovementCollisionWithEnvironment", + "evidence": [ + "builtin-surface" + ], + "id": "disableEnvironmentCollision", + "kind": "memberAction", + "params": [ + { + "name": "includeFloors" + } + ], + "receiver": "Player" + }, + { + "catalogId": "isJumping", + "evidence": [ + "builtin-surface" + ], + "id": "isJumping", + "kind": "memberValue", + "params": [], + "receiver": "Player" + }, + { + "catalogId": "getVerticalFacingAngle", + "evidence": [ + "builtin-surface" + ], + "id": "getVerticalFacingAngle", + "kind": "memberValue", + "params": [], + "receiver": "Player" + }, + { + "catalogId": "isDummy", + "evidence": [ + "is-dummy" + ], + "id": "isDummy", + "kind": "memberValue", + "params": [], + "receiver": "Player" + }, + { + "catalogId": "hasSpawned", + "evidence": [ + "receiver-calls" + ], + "id": "hasSpawned", + "kind": "memberValue", + "params": [], + "receiver": "Player" + }, + { + "catalogId": "getThrottle", + "evidence": [ + "enum-gated-members" + ], + "id": "getThrottle", + "kind": "memberValue", + "params": [], + "receiver": "Player" + }, + { + "catalogId": "getSpeedInDirection", + "evidence": [ + "builtin-surface" + ], + "id": "getSpeedInDirection", + "kind": "memberValue", + "params": [ + { + "domain": "Vector", + "name": "direction" + } + ], + "receiver": "Player" + }, + { + "catalogId": "getStatistic", + "evidence": [ + "builtin-surface" + ], + "id": "getStatistic", + "kind": "memberValue", + "params": [ + { + "domain": "Stat", + "name": "statistic" + } + ], + "receiver": "Player" + }, + { + "catalogId": "getFacingDirection", + "evidence": [ + "builtin-surface" + ], + "id": "getFacingDirection", + "kind": "memberValue", + "params": [], + "receiver": "Player" + }, + { + "catalogId": "getCurrentWeapon", + "evidence": [ + "receiver-calls" + ], + "id": "getCurrentWeapon", + "kind": "memberValue", + "params": [], + "receiver": "Player" + }, + { + "catalogId": "isFiringPrimaryFire", + "evidence": [ + "receiver-calls" + ], + "id": "isFiringPrimaryFire", + "kind": "memberValue", + "params": [], + "receiver": "Player" + }, + { + "catalogId": "isFiringSecondaryFire", + "evidence": [ + "receiver-calls" + ], + "id": "isFiringSecondaryFire", + "kind": "memberValue", + "params": [], + "receiver": "Player" + }, + { + "catalogId": "isButtonHeld", + "evidence": [ + "builtin-surface" + ], + "id": "isButtonHeld", + "kind": "memberValue", + "params": [ + { + "domain": "Button", + "name": "button" + } + ], + "receiver": "Player" + }, + { + "catalogId": "isOnGround", + "evidence": [ + "builtin-surface" + ], + "id": "isOnGround", + "kind": "memberValue", + "params": [], + "receiver": "Player" + }, + { + "catalogId": "startCamera", + "evidence": [ + "builtin-surface" + ], + "id": "startCamera", + "kind": "memberAction", + "params": [ + { + "name": "eyePosition" + }, + { + "name": "lookAtPosition" + }, + { + "default": 0, + "name": "facing" + } + ], + "receiver": "Player" + }, + { + "catalogId": "startScalingSize", + "evidence": [ + "builtin-surface" + ], + "id": "startScalingSize", + "kind": "memberAction", + "params": [ + { + "name": "scale" + }, + { + "name": "relative" + } + ], + "receiver": "Player" + }, + { + "catalogId": "stopScalingSize", + "evidence": [ + "builtin-surface" + ], + "id": "stopScalingSize", + "kind": "memberAction", + "params": [], + "receiver": "Player" + }, + { + "catalogId": "stopScalingBarriers", + "evidence": [ + "builtin-surface" + ], + "id": "stopScalingBarriers", + "kind": "memberAction", + "params": [], + "receiver": "Player" + }, + { + "catalogId": "startScalingBarriers", + "evidence": [ + "builtin-surface" + ], + "id": "startScalingBarriers", + "kind": "memberAction", + "params": [ + { + "name": "scale" + }, + { + "name": "relative" + } + ], + "receiver": "Player" + }, + { + "catalogId": "startForcingThrottle", + "evidence": [ + "builtin-surface" + ], + "id": "startForcingThrottle", + "kind": "memberAction", + "params": [ + { + "name": "forward" + }, + { + "name": "backward" + }, + { + "name": "left" + }, + { + "name": "right" + }, + { + "name": "up" + }, + { + "name": "down" + } + ], + "receiver": "Player" + }, + { + "catalogId": "startThrottleInDirection", + "evidence": [ + "builtin-surface" + ], + "id": "startThrottleInDirection", + "kind": "memberAction", + "params": [ + { + "domain": "Vector", + "name": "direction" + }, + { + "name": "speed" + }, + { + "domain": "Relativity", + "name": "relativity" + }, + { + "default": "REPLACE_EXISTING", + "domain": "Throttle", + "name": "throttle" + }, + { + "default": "NONE", + "domain": "ThrottleReeval", + "name": "reevaluation" + } + ], + "receiver": "Player" + }, + { + "catalogId": "disableMovementCollisionWithPlayers", + "evidence": [ + "builtin-surface" + ], + "id": "disablePlayerCollision", + "kind": "memberAction", + "params": [], + "receiver": "Player" + }, + { + "catalogId": "enableMovementCollisionWithPlayers", + "evidence": [ + "builtin-surface" + ], + "id": "enablePlayerCollision", + "kind": "memberAction", + "params": [], + "receiver": "Player" + }, + { + "catalogId": "resurrect", + "evidence": [ + "builtin-surface" + ], + "id": "resurrect", + "kind": "memberAction", + "params": [], + "receiver": "Player" + }, + { + "catalogId": "respawn", + "evidence": [ + "builtin-surface" + ], + "id": "respawn", + "kind": "memberAction", + "params": [], + "receiver": "Player" + }, + { + "catalogId": "stopCamera", + "evidence": [ + "builtin-surface" + ], + "id": "stopCamera", + "kind": "memberAction", + "params": [], + "receiver": "Player" + }, + { + "catalogId": "stopForcingThrottle", + "evidence": [ + "builtin-surface" + ], + "id": "stopForcingThrottle", + "kind": "memberAction", + "params": [], + "receiver": "Player" + }, + { + "catalogId": "stopThrottleInDirection", + "evidence": [ + "builtin-surface" + ], + "id": "stopThrottleInDirection", + "kind": "memberAction", + "params": [], + "receiver": "Player" + }, + { + "catalogId": "stopModifyingVoicelinePitch", + "evidence": [ + "builtin-surface" + ], + "id": "stopModifyingVoicelinePitch", + "kind": "memberAction", + "params": [], + "receiver": "Player" + }, + { + "catalogId": "startModifyingVoicelinePitch", + "evidence": [ + "builtin-surface" + ], + "id": "startModifyingVoicelinePitch", + "kind": "memberAction", + "params": [ + { + "name": "pitch" + }, + { + "name": "relative" + } + ], + "receiver": "Player" + }, + { + "catalogId": "startAcceleration", + "evidence": [ + "builtin-surface" + ], + "id": "startAcceleration", + "kind": "memberAction", + "params": [ + { + "domain": "Vector", + "name": "direction" + }, + { + "name": "rate" + }, + { + "name": "maxSpeed" + }, + { + "domain": "Relativity", + "name": "relativity" + }, + { + "default": "DIRECTION_RATE_AND_MAX_SPEED", + "domain": "AccelReeval", + "name": "reevaluation" + } + ], + "receiver": "Player" + }, + { + "catalogId": "setAmmo", + "evidence": [ + "builtin-surface" + ], + "id": "setAmmo", + "kind": "memberAction", + "params": [ + { + "name": "clip" + }, + { + "name": "ammo" + } + ], + "receiver": "Player" + }, + { + "catalogId": "setMaxAmmo", + "evidence": [ + "builtin-surface" + ], + "id": "setMaxAmmo", + "kind": "memberAction", + "params": [ + { + "name": "clip" + }, + { + "name": "ammo" + } + ], + "receiver": "Player" + }, + { + "catalogId": "stopAcceleration", + "evidence": [ + "builtin-surface" + ], + "id": "stopAcceleration", + "kind": "memberAction", + "params": [], + "receiver": "Player" + }, + { + "catalogId": "raycastHitPlayer", "evidence": [ "builtin-surface" ], - "id": "round", - "kind": "value", - "params": [ - { - "name": "value" - } - ] + "id": "getPlayerHit", + "kind": "memberValue", + "params": [], + "receiver": "Any" }, { - "catalogLink": "special-lowering", - "context": "forIterable", + "catalogId": "startFacing", "evidence": [ - "range-for-header" + "builtin-surface" ], - "id": "range", - "keywordArgs": false, - "kind": "value", + "id": "startFacing", + "kind": "memberAction", "params": [ { - "name": "start" + "name": "direction" }, { - "name": "stop", - "optional": true + "default": 999999999999, + "name": "turnRate" }, { - "name": "step", - "optional": true + "default": "TO_WORLD", + "domain": "Relativity", + "name": "relativity" + }, + { + "default": "DIRECTION_AND_TURN_RATE", + "domain": "FacingReeval", + "name": "reevaluation" } - ] + ], + "receiver": "Player" }, { - "catalogId": "vector", + "catalogId": "startForcingHero", "evidence": [ - "generic-builtins" + "builtin-surface" ], - "id": "vect", - "kind": "value", + "id": "startForcingHero", + "kind": "memberAction", "params": [ { - "name": "x" - }, - { - "name": "y" - }, - { - "name": "z" + "domain": "Hero", + "name": "hero" } - ] - }, - { - "catalogId": "isGameInProgress", - "evidence": [ - "is-game-in-progress" ], - "id": "isGameInProgress", - "kind": "value", - "params": [] + "receiver": "Player" }, { - "catalogLink": "special-lowering", + "catalogId": "preloadHero", "evidence": [ - "rule-condition" + "builtin-surface" ], - "id": "ruleCondition", - "kind": "value", - "params": [] + "id": "preloadHero", + "kind": "memberAction", + "params": [ + { + "name": "hero" + } + ], + "receiver": "Player" }, { - "catalogId": "isAssemblingHeroes", + "catalogId": "setAbility1Enabled", "evidence": [ "builtin-surface" ], - "id": "isAssemblingHeroes", - "kind": "value", - "params": [] + "id": "setAbility1Enabled", + "kind": "memberAction", + "params": [ + { + "name": "enabled" + } + ], + "receiver": "Player" }, { - "catalogId": "setMatchTime", + "catalogId": "setAbility2Enabled", "evidence": [ "builtin-surface" ], - "id": "setMatchTime", - "kind": "action", + "id": "setAbility2Enabled", + "kind": "memberAction", "params": [ { - "name": "time" + "name": "enabled" } - ] + ], + "receiver": "Player" }, { - "catalogId": "horizontalAngleFromDirection", + "catalogId": "setAbilityCooldown", "evidence": [ "builtin-surface" ], - "id": "horizontalAngleOfDirection", - "kind": "value", + "id": "setAbilityCooldown", + "kind": "memberAction", "params": [ { - "name": "direction" + "name": "button" + }, + { + "name": "cooldown" } - ] + ], + "receiver": "Player" }, { - "catalogId": "createDummyBot", + "catalogId": "setAbilityCharge", "evidence": [ "builtin-surface" ], - "id": "createDummy", - "kind": "action", + "id": "setAbilityCharge", + "kind": "memberAction", "params": [ { - "domain": "Hero", - "name": "hero" - }, - { - "domain": "Team", - "name": "team" - }, - { - "name": "slot" - }, - { - "name": "position" + "domain": "Button", + "name": "button" }, { - "name": "facing", - "optional": true + "name": "charge" } - ] + ], + "receiver": "Player" }, { - "catalogId": "getPlayersInRadius", + "catalogId": "setUltEnabled", "evidence": [ - "get-players-in-radius" + "builtin-surface" ], - "id": "getPlayersInRadius", - "kind": "value", + "id": "setUltEnabled", + "kind": "memberAction", "params": [ { - "name": "center" - }, - { - "name": "radius" - }, - { - "default": "ALL", - "domain": "Team", - "name": "team" - }, - { - "default": "OFF", - "domain": "LosCheck", - "name": "losCheck" + "name": "enabled" } - ] + ], + "receiver": "Player" }, { - "catalogId": "worldVector", + "catalogId": "setPrimaryFireEnabled", "evidence": [ - "enum-gated-members" + "builtin-surface" ], - "id": "worldVector", - "kind": "value", + "id": "setPrimaryFireEnabled", + "kind": "memberAction", "params": [ { - "domain": "Vector", - "name": "localVector" - }, - { - "name": "relativePlayer" - }, - { - "domain": "Transform", - "name": "transformation" + "name": "enabled" } - ] + ], + "receiver": "Player" }, { - "catalogLink": "special-lowering", + "catalogId": "setSecondaryFireEnabled", "evidence": [ - "receiver-calls" + "builtin-surface" ], - "id": "append", + "id": "setSecondaryFireEnabled", "kind": "memberAction", "params": [ { - "name": "value" + "name": "enabled" } ], - "receiver": "Variable" + "receiver": "Player" }, { - "catalogId": "charAt", + "catalogId": "setMeleeEnabled", "evidence": [ "builtin-surface" ], - "id": "charAt", - "kind": "memberValue", + "id": "setMeleeEnabled", + "kind": "memberAction", "params": [ { - "name": "index" + "name": "enabled" } ], - "receiver": "Any" + "receiver": "Player" }, { - "catalogLink": "special-lowering", + "catalogId": "setJumpEnabled", "evidence": [ "builtin-surface" ], - "id": "concat", - "kind": "memberValue", + "id": "setJumpEnabled", + "kind": "memberAction", "params": [ { - "name": "value" + "name": "enabled" } ], - "receiver": "Any" + "receiver": "Player" }, { - "catalogLink": "special-lowering", + "catalogId": "setCrouchEnabled", "evidence": [ "builtin-surface" ], - "id": "exclude", - "kind": "memberValue", + "id": "setCrouchEnabled", + "kind": "memberAction", "params": [ { - "name": "value" + "name": "enabled" } ], - "receiver": "Any" + "receiver": "Player" }, { - "catalogId": "indexOfArrayValue", + "catalogId": "setReloadEnabled", "evidence": [ "builtin-surface" ], - "id": "index", - "kind": "memberValue", + "id": "setReloadEnabled", + "kind": "memberAction", "params": [ { - "name": "value" + "name": "enabled" } ], - "receiver": "Any" + "receiver": "Player" }, { - "catalogId": "setMoveSpeed", + "catalogId": "setProjectileSpeed", "evidence": [ - "receiver-calls" + "builtin-surface" ], - "id": "setMoveSpeed", + "id": "setProjectileSpeed", "kind": "memberAction", "params": [ { - "name": "moveSpeedPercent" + "name": "speed" } ], "receiver": "Player" }, { - "catalogId": "setWeapon", + "catalogId": "setProjectileGravity", "evidence": [ - "receiver-calls" + "builtin-surface" ], - "id": "setWeapon", + "id": "setProjectileGravity", "kind": "memberAction", "params": [ { - "name": "weapon" + "name": "gravity" } ], "receiver": "Player" }, { - "catalogId": "setMaxHealth", + "catalogId": "removeFromGame", "evidence": [ - "receiver-calls" + "builtin-surface" ], - "id": "setMaxHealth", + "id": "removeFromGame", + "kind": "memberAction", + "params": [], + "receiver": "Player" + }, + { + "catalogId": "clearStatusEffect", + "evidence": [ + "builtin-surface" + ], + "id": "clearStatusEffect", "kind": "memberAction", "params": [ { - "name": "healthPercent" + "domain": "Status", + "name": "status" } ], "receiver": "Player" }, { - "catalogId": "setHealth", + "catalogId": "disableRespawn", "evidence": [ - "receiver-calls" + "builtin-surface" ], - "id": "setHealth", + "id": "disableRespawn", "kind": "memberAction", - "params": [ - { - "name": "amount" - } - ], + "params": [], "receiver": "Player" }, { - "catalogId": "setAimSpeed", + "catalogId": "enableRespawn", "evidence": [ - "receiver-calls" + "builtin-surface" ], - "id": "setAimSpeed", + "id": "enableRespawn", "kind": "memberAction", - "params": [ - { - "name": "turnSpeedPercent" - } - ], + "params": [], "receiver": "Player" }, { - "catalogId": "setGravity", + "catalogId": "setFacing", "evidence": [ - "receiver-calls" + "builtin-surface" ], - "id": "setGravity", + "id": "setFacing", "kind": "memberAction", "params": [ { - "name": "gravityPercent" + "name": "direction" + }, + { + "domain": "Relativity", + "name": "relativity" } ], "receiver": "Player" }, { - "catalogId": "setDamageDealt", + "catalogId": "startForcingButton", "evidence": [ - "receiver-calls" + "builtin-surface" ], - "id": "setDamageDealt", + "id": "startForcingButton", "kind": "memberAction", "params": [ { - "name": "damageDealtPercent" + "domain": "Button", + "name": "button" } ], "receiver": "Player" }, { - "catalogId": "setDamageReceived", + "catalogId": "stopFacing", "evidence": [ - "receiver-calls" + "builtin-surface" ], - "id": "setDamageReceived", + "id": "stopFacing", "kind": "memberAction", - "params": [ - { - "name": "damageReceivedPercent" - } - ], + "params": [], "receiver": "Player" }, { - "catalogId": "setUltCharge", + "catalogId": "stopForcingCurrentHero", "evidence": [ - "receiver-calls" + "builtin-surface" ], - "id": "setUltCharge", + "id": "stopForcingCurrentHero", "kind": "memberAction", - "params": [ - { - "name": "chargePercent" - } - ], + "params": [], "receiver": "Player" }, { - "catalogId": "teleport", + "catalogId": "stopForcingButton", "evidence": [ - "receiver-calls" + "builtin-surface" ], - "id": "teleport", + "id": "stopForcingButton", "kind": "memberAction", "params": [ { - "name": "position" + "domain": "Button", + "name": "button" } ], "receiver": "Player" }, { - "catalogId": "setInvisibility", + "catalogId": "forceButtonPress", "evidence": [ - "enum-gated-members" + "builtin-surface" ], - "id": "setInvisibility", + "id": "forceButtonPress", "kind": "memberAction", "params": [ { - "domain": "Invis", - "name": "invisibleTo" + "domain": "Button", + "name": "button" } ], "receiver": "Player" }, { - "catalogId": "setStatusEffect", + "catalogId": "disableHeroHud", "evidence": [ - "enum-gated-members" + "builtin-surface" ], - "id": "setStatusEffect", + "id": "disableHeroHud", "kind": "memberAction", - "params": [ - { - "name": "assister" - }, - { - "domain": "Status", - "name": "status" - }, - { - "name": "duration" - } + "params": [], + "receiver": "Player" + }, + { + "catalogId": "enableHeroHud", + "evidence": [ + "builtin-surface" ], + "id": "enableHeroHud", + "kind": "memberAction", + "params": [], "receiver": "Player" }, { - "catalogId": "getPosition", + "catalogId": "getHero", "evidence": [ - "receiver-calls" + "member-aliases" ], - "id": "getPosition", + "id": "getHero", "kind": "memberValue", "params": [], "receiver": "Player" }, { - "catalogId": "__xComponentOf__", + "catalogId": "getVelocity", "evidence": [ "builtin-surface" ], - "id": "x", + "id": "getVelocity", "kind": "memberValue", "params": [], - "receiver": "Vector" + "receiver": "Player" }, { - "catalogId": "__yComponentOf__", + "catalogId": "getSpeed", "evidence": [ "builtin-surface" ], - "id": "y", + "id": "getSpeed", "kind": "memberValue", "params": [], - "receiver": "Vector" + "receiver": "Player" }, { - "catalogId": "__zComponentOf__", + "catalogId": "getMaxHealthOfType", "evidence": [ "builtin-surface" ], - "id": "z", + "id": "getMaxHealthOfType", "kind": "memberValue", - "params": [], - "receiver": "Vector" + "params": [ + { + "domain": "Health", + "name": "healthType" + } + ], + "receiver": "Player" }, { - "catalogId": "lastOf", + "catalogId": "applyImpulse", "evidence": [ "builtin-surface" ], - "id": "last", - "kind": "memberValue", - "params": [], - "receiver": "Any" + "id": "applyImpulse", + "kind": "memberAction", + "params": [ + { + "domain": "Vector", + "name": "direction" + }, + { + "name": "speed" + }, + { + "domain": "Relativity", + "name": "relativity" + }, + { + "default": "INCORPORATE_CONTRARY_MOTION", + "domain": "Impulse", + "name": "motion" + } + ], + "receiver": "Player" }, { - "catalogId": "getHealth", + "catalogId": "setHealingDealt", "evidence": [ - "receiver-calls" + "builtin-surface" + ], + "id": "setHealingDealt", + "kind": "memberAction", + "params": [ + { + "name": "healingPercent" + } ], - "id": "getHealth", - "kind": "memberValue", - "params": [], "receiver": "Player" }, { - "catalogId": "getAmmo", + "catalogId": "setHealingReceived", "evidence": [ - "receiver-calls" + "builtin-surface" ], - "id": "getAmmo", - "kind": "memberValue", + "id": "setHealingReceived", + "kind": "memberAction", "params": [ { - "default": 0, - "name": "clip" + "name": "healingPercent" } ], "receiver": "Player" }, { - "catalogId": "getMaxAmmo", + "catalogId": "setKnockbackReceived", "evidence": [ - "receiver-calls" + "builtin-surface" ], - "id": "getMaxAmmo", - "kind": "memberValue", + "id": "setKnockbackReceived", + "kind": "memberAction", "params": [ { - "name": "clip" + "name": "percent" } ], "receiver": "Player" }, { - "catalogId": "isAlive", + "catalogId": "setKnockbackDealt", "evidence": [ - "receiver-calls" + "builtin-surface" ], - "id": "isAlive", - "kind": "memberValue", + "id": "setKnockbackDealt", + "kind": "memberAction", + "params": [ + { + "name": "percent" + } + ], + "receiver": "Player" + }, + { + "catalogId": "setAllowedHeroes", + "evidence": [ + "builtin-surface" + ], + "id": "setAllowedHeroes", + "kind": "memberAction", + "params": [ + { + "domain": "Hero", + "name": "heroes" + } + ], + "receiver": "Player" + }, + { + "catalogId": "cancelPrimaryAction", + "evidence": [ + "builtin-surface" + ], + "id": "cancelPrimaryAction", + "kind": "memberAction", "params": [], "receiver": "Player" }, { - "catalogId": "getHorizontalFacingAngle", + "catalogId": "removeAllHealthPoolsFromPlayer", "evidence": [ - "get-horizontal-facing-angle" + "builtin-surface" ], - "id": "getHorizontalFacingAngle", - "kind": "memberValue", + "id": "removeAllHealthPools", + "kind": "memberAction", "params": [], "receiver": "Player" }, { - "catalogId": "getEyePosition", + "catalogId": "addHealthPool", "evidence": [ "builtin-surface" ], - "id": "getEyePosition", - "kind": "memberValue", - "params": [], + "id": "addHealthPool", + "kind": "memberAction", + "params": [ + { + "domain": "Health", + "name": "healthType" + }, + { + "name": "amount" + }, + { + "name": "isPermanent" + }, + { + "name": "receivesHealing" + } + ], "receiver": "Player" }, { - "catalogId": "getSlot", + "catalogId": "getHeroOfDuplication", "evidence": [ "builtin-surface" ], - "id": "getSlot", + "id": "getHeroOfDuplication", "kind": "memberValue", "params": [], "receiver": "Player" }, { - "catalogId": "getMaxHealth", + "catalogId": "hasStatus", "evidence": [ - "builtin-surface" + "member-aliases" ], - "id": "getMaxHealth", + "id": "hasStatus", "kind": "memberValue", - "params": [], + "params": [ + { + "domain": "Status", + "name": "status" + } + ], "receiver": "Player" }, { - "catalogId": "getUltCharge", + "catalogId": "customString", "evidence": [ - "builtin-surface" + "generic-builtins" ], - "id": "getUltCharge", + "id": "format", + "keywordArgs": false, "kind": "memberValue", "params": [], - "receiver": "Player" + "receiver": "String", + "unbounded": true }, { - "catalogId": "addToScore", + "catalogId": "startForcingSpawn", "evidence": [ "builtin-surface" ], - "id": "addToScore", - "kind": "memberAction", + "id": "startForcingSpawn", + "kind": "action", "params": [ { - "name": "score" + "domain": "Team", + "name": "team" + }, + { + "name": "spawnRoom" } - ], - "receiver": "Player" + ] }, { - "catalogId": "disallowButton", + "catalogId": "disableBuiltinGameModeAnnouncer", "evidence": [ "builtin-surface" ], - "id": "disallowButton", - "kind": "memberAction", - "params": [ - { - "domain": "Button", - "name": "button" - } - ], - "receiver": "Player" + "id": "disableAnnouncer", + "kind": "action", + "params": [] }, { - "catalogId": "allowButton", + "catalogId": "enableAnnouncer", "evidence": [ "builtin-surface" ], - "id": "allowButton", - "kind": "memberAction", - "params": [ - { - "domain": "Button", - "name": "button" - } - ], - "receiver": "Player" + "id": "enableAnnouncer", + "kind": "action", + "params": [] }, { - "catalogId": "disableKillFeed", + "catalogId": "disableBuiltinGameModeMusic", "evidence": [ "builtin-surface" ], - "id": "disableKillFeed", - "kind": "memberAction", - "params": [], - "receiver": "Player" + "id": "disableMusic", + "kind": "action", + "params": [] }, { - "catalogId": "startForcingPosition", + "catalogId": "pauseMatchTime", "evidence": [ "builtin-surface" ], - "id": "startForcingPosition", - "kind": "memberAction", - "params": [ - { - "name": "position" - }, - { - "name": "includeY" - } - ], - "receiver": "Player" + "id": "pauseMatchTime", + "kind": "action", + "params": [] }, { - "catalogId": "startHealingOverTime", + "catalogId": "unpauseMatchTime", "evidence": [ "builtin-surface" ], - "id": "startHoT", - "kind": "memberAction", - "params": [ - { - "name": "healer" - }, - { - "name": "rate" - }, - { - "name": "duration" - } - ], - "receiver": "Player" + "id": "unpauseMatchTime", + "kind": "action", + "params": [] }, { - "catalogId": "attachTo", + "catalogId": "getCurrentGamemode", "evidence": [ "builtin-surface" ], - "id": "attachTo", - "kind": "memberAction", - "params": [ - { - "name": "parent" - }, - { - "name": "position" - } - ], - "receiver": "Player" + "id": "getCurrentGamemode", + "kind": "value", + "params": [] }, { - "catalogId": "disableMovementCollisionWithEnvironment", + "catalogId": "destroyAllIcons", "evidence": [ "builtin-surface" ], - "id": "disableEnvironmentCollision", - "kind": "memberAction", - "params": [ - { - "name": "includeFloors" - } + "id": "destroyAllIcons", + "kind": "action", + "params": [] + }, + { + "catalogId": "destroyAllInWorldText", + "evidence": [ + "builtin-surface" ], - "receiver": "Player" + "id": "destroyAllInWorldTexts", + "kind": "action", + "params": [] }, { - "catalogId": "isDummy", + "catalogId": "destroyAllProgressBarHudText", "evidence": [ - "is-dummy" + "builtin-surface" ], - "id": "isDummy", - "kind": "memberValue", - "params": [], - "receiver": "Player" + "id": "destroyAllProgressBarHuds", + "kind": "action", + "params": [] }, { - "catalogId": "hasSpawned", + "catalogId": "disableMessages", "evidence": [ - "receiver-calls" + "builtin-surface" ], - "id": "hasSpawned", - "kind": "memberValue", + "id": "disableMessages", + "kind": "memberAction", "params": [], "receiver": "Player" }, { - "catalogId": "getThrottle", + "catalogId": "createIcon", "evidence": [ - "enum-gated-members" + "builtin-surface" ], - "id": "getThrottle", - "kind": "memberValue", - "params": [], - "receiver": "Player" + "id": "createIcon", + "kind": "action", + "params": [ + { + "name": "visibleTo" + }, + { + "name": "position" + }, + { + "domain": "Icon", + "name": "icon" + }, + { + "domain": "IconReeval", + "name": "reevaluation" + }, + { + "domain": "Color", + "name": "color" + }, + { + "default": false, + "name": "showWhenOffscreen" + } + ] }, { - "catalogId": "getFacingDirection", + "catalogId": "sin", "evidence": [ "builtin-surface" ], - "id": "getFacingDirection", - "kind": "memberValue", - "params": [], - "receiver": "Player" + "id": "sin", + "kind": "value", + "params": [ + { + "name": "value" + } + ] }, { - "catalogId": "getCurrentWeapon", + "catalogId": "enableBuiltinGameModeMusic", "evidence": [ - "receiver-calls" + "builtin-surface" ], - "id": "getCurrentWeapon", - "kind": "memberValue", - "params": [], - "receiver": "Player" + "id": "enableMusic", + "kind": "action", + "params": [] }, { - "catalogId": "isFiringPrimaryFire", + "catalogId": "restartMatch", "evidence": [ - "receiver-calls" + "builtin-surface" ], - "id": "isFiringPrimaryFire", - "kind": "memberValue", - "params": [], - "receiver": "Player" + "id": "restartMatch", + "kind": "action", + "params": [] }, { - "catalogId": "isFiringSecondaryFire", + "catalogId": "getClosestPlayer", "evidence": [ - "receiver-calls" + "builtin-surface" ], - "id": "isFiringSecondaryFire", - "kind": "memberValue", - "params": [], - "receiver": "Player" + "id": "getRealClosestPlayer", + "kind": "value", + "params": [ + { + "name": "center" + }, + { + "domain": "Team", + "name": "team" + } + ] }, { - "catalogId": "isButtonHeld", + "catalogId": "startForcingName", "evidence": [ "builtin-surface" ], - "id": "isButtonHeld", - "kind": "memberValue", + "id": "startForcingName", + "kind": "memberAction", "params": [ { - "domain": "Button", - "name": "button" + "name": "name" } ], "receiver": "Player" }, { - "catalogId": "startFacing", + "catalogId": "stopForcingName", "evidence": [ "builtin-surface" ], - "id": "startFacing", + "id": "stopForcingName", "kind": "memberAction", + "params": [], + "receiver": "Player" + }, + { + "catalogId": "startDamageModification", + "evidence": [ + "builtin-surface" + ], + "id": "startDamageModification", + "kind": "action", "params": [ { - "name": "direction" + "name": "receivers" }, { - "name": "turnRate" + "name": "damagers" }, { - "domain": "Relativity", - "name": "relativity" + "name": "damagePercent" }, { - "domain": "FacingReeval", + "default": "RECEIVERS_DAMAGERS_AND_DMGPERCENT", + "domain": "DamageReeval", "name": "reevaluation" } - ], - "receiver": "Player" + ] }, { - "catalogId": "startForcingHero", + "catalogId": "stopDamageModification", "evidence": [ "builtin-surface" ], - "id": "startForcingHero", - "kind": "memberAction", + "id": "stopDamageModification", + "kind": "action", "params": [ { - "domain": "Hero", - "name": "hero" + "name": "damageModificationId" } - ], - "receiver": "Player" + ] }, { - "catalogId": "setAbility1Enabled", + "catalogId": "disableGameModeHud", "evidence": [ "builtin-surface" ], - "id": "setAbility1Enabled", + "id": "disableGamemodeHud", "kind": "memberAction", - "params": [ - { - "name": "enabled" - } - ], + "params": [], "receiver": "Player" }, { - "catalogId": "setAbility2Enabled", + "catalogId": "disableGameModeInworldUI", "evidence": [ "builtin-surface" ], - "id": "setAbility2Enabled", + "id": "disableGamemodeInWorldUi", "kind": "memberAction", - "params": [ - { - "name": "enabled" - } - ], + "params": [], "receiver": "Player" }, { - "catalogId": "setUltEnabled", + "catalogId": "enableMovementCollisionWithEnvironment", "evidence": [ "builtin-surface" ], - "id": "setUltEnabled", + "id": "enableEnvironmentCollision", "kind": "memberAction", - "params": [ - { - "name": "enabled" - } - ], + "params": [], "receiver": "Player" }, { - "catalogId": "setPrimaryFireEnabled", + "catalogId": "stopForcingPosition", "evidence": [ "builtin-surface" ], - "id": "setPrimaryFireEnabled", + "id": "stopForcingPosition", "kind": "memberAction", - "params": [ - { - "name": "enabled" - } - ], + "params": [], "receiver": "Player" }, { - "catalogId": "setSecondaryFireEnabled", + "catalogId": "startForcingOutlineFor", "evidence": [ "builtin-surface" ], - "id": "setSecondaryFireEnabled", + "id": "startForcingOutlineFor", "kind": "memberAction", "params": [ { - "name": "enabled" + "name": "viewingPlayers" + }, + { + "name": "visible" + }, + { + "domain": "Color", + "name": "color" + }, + { + "domain": "OutlineVisibility", + "name": "visibility" } ], "receiver": "Player" }, { - "catalogId": "clearStatusEffect", + "catalogId": "stopForcingOutlineFor", "evidence": [ "builtin-surface" ], - "id": "clearStatusEffect", + "id": "stopForcingOutlineFor", "kind": "memberAction", "params": [ { - "domain": "Status", - "name": "status" + "name": "viewingPlayers" } ], "receiver": "Player" }, { - "catalogId": "setFacing", + "catalogId": "getPlayersInSlot", "evidence": [ "builtin-surface" ], - "id": "setFacing", - "kind": "memberAction", + "id": "getPlayersInSlot", + "kind": "value", "params": [ { - "name": "direction" + "name": "slot" }, { - "domain": "Relativity", - "name": "relativity" + "domain": "Team", + "name": "team" } - ], - "receiver": "Player" + ] }, { - "catalogId": "startForcingButton", + "catalogId": "isWaitingForPlayers", "evidence": [ "builtin-surface" ], - "id": "startForcingButton", - "kind": "memberAction", - "params": [ - { - "domain": "Button", - "name": "button" - } - ], - "receiver": "Player" + "id": "isWaitingForPlayers", + "kind": "value", + "params": [] }, { - "catalogId": "stopFacing", + "catalogId": "isOnObjective", "evidence": [ "builtin-surface" ], - "id": "stopFacing", - "kind": "memberAction", + "id": "isOnObjective", + "kind": "memberValue", "params": [], "receiver": "Player" }, { - "catalogId": "stopForcingCurrentHero", + "catalogId": "getPayloadPosition", "evidence": [ "builtin-surface" ], - "id": "stopForcingCurrentHero", - "kind": "memberAction", - "params": [], - "receiver": "Player" + "id": "getPayloadPosition", + "kind": "value", + "params": [] }, { - "catalogId": "stopForcingButton", + "catalogId": "getPlayersOnObjective", "evidence": [ "builtin-surface" ], - "id": "stopForcingButton", - "kind": "memberAction", + "id": "getPlayersOnObjective", + "kind": "value", "params": [ { - "domain": "Button", - "name": "button" + "domain": "Team", + "name": "team" } + ] + }, + { + "catalogId": "getPayloadProgressPercentage", + "evidence": [ + "builtin-surface" + ], + "id": "getPayloadProgressPercentage", + "kind": "value", + "params": [] + }, + { + "catalogId": "getAltitude", + "evidence": [ + "builtin-surface" ], + "id": "getAltitude", + "kind": "memberValue", + "params": [], "receiver": "Player" }, { - "catalogId": "forceButtonPress", + "catalogId": "getAbilityResource", "evidence": [ "builtin-surface" ], - "id": "forceButtonPress", - "kind": "memberAction", + "id": "getAbilityResource", + "kind": "memberValue", "params": [ { "domain": "Button", - "name": "button" + "name": "ability" } ], "receiver": "Player" }, { - "catalogId": "disableHeroHud", + "catalogId": "getLastDamageModification", "evidence": [ "builtin-surface" ], - "id": "disableHeroHud", - "kind": "memberAction", - "params": [], - "receiver": "Player" + "id": "getLastDamageModification", + "kind": "value", + "params": [] }, { - "catalogId": "enableHeroHud", + "catalogId": "getLastDamageOverTimeId", "evidence": [ "builtin-surface" ], - "id": "enableHeroHud", - "kind": "memberAction", - "params": [], - "receiver": "Player" + "id": "getLastDamageOverTimeId", + "kind": "value", + "params": [] }, { - "catalogId": "getHero", + "catalogId": "stopDamageOverTime", "evidence": [ - "member-aliases" + "builtin-surface" ], - "id": "getHero", - "kind": "memberValue", - "params": [], - "receiver": "Player" + "id": "stopDamageOverTime", + "kind": "action", + "params": [ + { + "name": "damageOverTimeId" + } + ] }, { - "catalogId": "getVelocity", + "catalogId": "eventWasCriticalHit", "evidence": [ "builtin-surface" ], - "id": "getVelocity", - "kind": "memberValue", - "params": [], - "receiver": "Player" + "id": "eventWasCriticalHit", + "kind": "value", + "params": [] }, { - "catalogId": "getSpeed", + "catalogId": "stopAllDamageOverTime", "evidence": [ "builtin-surface" ], - "id": "getSpeed", - "kind": "memberValue", + "id": "stopAllDamageOverTime", + "kind": "memberAction", "params": [], "receiver": "Player" }, { - "catalogId": "getMaxHealthOfType", + "catalogId": "setRespawnTime", "evidence": [ "builtin-surface" ], - "id": "getMaxHealthOfType", - "kind": "memberValue", + "id": "setRespawnTime", + "kind": "memberAction", "params": [ { - "domain": "Health", - "name": "healthType" + "name": "time" } ], "receiver": "Player" }, { - "catalogId": "applyImpulse", + "catalogId": "setAbilityResource", "evidence": [ "builtin-surface" ], - "id": "applyImpulse", + "id": "setAbilityResource", "kind": "memberAction", "params": [ { - "domain": "Vector", - "name": "direction" - }, - { - "name": "speed" - }, - { - "domain": "Relativity", - "name": "relativity" + "domain": "Button", + "name": "ability" }, { - "domain": "Impulse", - "name": "motion" + "name": "resource" } ], "receiver": "Player" }, { - "catalogId": "setHealingDealt", + "catalogId": "setEnvironmentCreditPlayer", "evidence": [ "builtin-surface" ], - "id": "setHealingDealt", + "id": "setEnvironmentalKillCreditor", "kind": "memberAction", "params": [ { - "name": "healingPercent" + "name": "creditor" } ], "receiver": "Player" }, { - "catalogId": "removeAllHealthPoolsFromPlayer", + "catalogId": "getHeroStatistic", "evidence": [ "builtin-surface" ], - "id": "removeAllHealthPools", - "kind": "memberAction", - "params": [], + "id": "getHeroStatistic", + "kind": "memberValue", + "params": [ + { + "domain": "Hero", + "name": "hero" + }, + { + "domain": "HeroStat", + "name": "statistic" + } + ], "receiver": "Player" }, { - "catalogId": "addHealthPool", + "catalogId": "magnitude", "evidence": [ "builtin-surface" ], - "id": "addHealthPool", - "kind": "memberAction", + "id": "magnitude", + "kind": "value", "params": [ { - "domain": "Health", - "name": "healthType" - }, - { - "name": "amount" - }, - { - "name": "isPermanent" - }, + "domain": "Vector", + "name": "vector" + } + ] + }, + { + "catalogId": "communicate", + "evidence": [ + "builtin-surface" + ], + "id": "communicate", + "kind": "memberAction", + "params": [ { - "name": "receivesHealing" + "domain": "Comms", + "name": "message" } ], "receiver": "Player" }, { - "catalogId": "getHeroOfDuplication", + "catalogId": "cos", "evidence": [ "builtin-surface" ], - "id": "getHeroOfDuplication", - "kind": "memberValue", - "params": [], - "receiver": "Player" + "id": "cos", + "kind": "value", + "params": [ + { + "name": "radians" + } + ] }, { - "catalogId": "hasStatus", + "catalogId": "getPlayersInViewAngle", "evidence": [ - "member-aliases" + "builtin-surface" ], - "id": "hasStatus", + "id": "getPlayersInViewAngle", "kind": "memberValue", "params": [ { - "domain": "Status", - "name": "status" + "name": "team" + }, + { + "name": "viewAngle" } ], "receiver": "Player" }, { - "catalogId": "customString", + "catalogId": "destroyIcon", "evidence": [ - "generic-builtins" + "builtin-surface" ], - "id": "format", - "keywordArgs": false, - "kind": "memberValue", - "params": [], - "receiver": "String", - "unbounded": true + "id": "destroyIcon", + "kind": "action", + "params": [ + { + "name": "iconId" + } + ] + }, + { + "catalogId": "localVector", + "evidence": [ + "builtin-surface" + ], + "id": "localVector", + "kind": "value", + "params": [ + { + "name": "vector" + }, + { + "name": "player" + }, + { + "domain": "Transform", + "name": "transform" + } + ] } ], "provenance": { diff --git a/crates/opy-rs/src/manifest/mod.rs b/crates/opy-rs/src/manifest/mod.rs index 14056ad..2a7ba12 100644 --- a/crates/opy-rs/src/manifest/mod.rs +++ b/crates/opy-rs/src/manifest/mod.rs @@ -146,6 +146,7 @@ impl ReceiverCategory { pub enum ParamDefault { Call { call: String }, EnumMember(String), + Bool(bool), Number(f64), } diff --git a/crates/opy-rs/src/parser.rs b/crates/opy-rs/src/parser.rs index fdab30e..8189bc8 100644 --- a/crates/opy-rs/src/parser.rs +++ b/crates/opy-rs/src/parser.rs @@ -103,7 +103,75 @@ impl Parser<'_> { } fn skip_expression_newlines(&mut self) { - if self.inside_delimiter_group() { + if self.peek_kind() != TokenKind::Newline { + return; + } + let previous = self.tokens[..self.pos] + .iter() + .rev() + .find(|token| !matches!(token.kind, TokenKind::Indent(_))); + let previous_allows_continuation = previous.is_some_and(|token| { + matches!( + token.kind, + TokenKind::LParen + | TokenKind::LBracket + | TokenKind::LBrace + | TokenKind::Comma + | TokenKind::Colon + | TokenKind::Assign + | TokenKind::Plus + | TokenKind::Minus + | TokenKind::Star + | TokenKind::Slash + | TokenKind::Percent + | TokenKind::DoubleStar + | TokenKind::Eq + | TokenKind::Ne + | TokenKind::Lt + | TokenKind::Le + | TokenKind::Gt + | TokenKind::Ge + ) || (token.kind == TokenKind::Ident + && matches!(token.text.as_str(), "and" | "or" | "in" | "not" | "if")) + }); + let mut next = self.pos; + while self.tokens[next].kind == TokenKind::Newline { + next += 1; + } + let inside_delimiter_group = self.inside_delimiter_group(); + let next_allows_continuation = matches!( + self.tokens[next].kind, + TokenKind::Plus + | TokenKind::Minus + | TokenKind::Star + | TokenKind::Slash + | TokenKind::Percent + | TokenKind::DoubleStar + | TokenKind::Eq + | TokenKind::Ne + | TokenKind::Lt + | TokenKind::Le + | TokenKind::Gt + | TokenKind::Ge + ) || (inside_delimiter_group + && matches!( + self.tokens[next].kind, + TokenKind::LParen + | TokenKind::LBracket + | TokenKind::Dot + | TokenKind::RParen + | TokenKind::RBracket + | TokenKind::RBrace + )) + || (self.tokens[next].kind == TokenKind::Ident + && matches!( + self.tokens[next].text.as_str(), + "and" | "or" | "in" | "not" | "else" + )) + || (inside_delimiter_group + && self.tokens[next].kind == TokenKind::Ident + && matches!(self.tokens[next].text.as_str(), "if" | "for")); + if previous_allows_continuation || next_allows_continuation { self.skip_newlines(); } } @@ -214,7 +282,11 @@ impl Parser<'_> { } fn expect_statement_end(&mut self, what: &str) -> Result<(), ()> { - if matches!(self.peek_kind(), TokenKind::Newline | TokenKind::Eof) { + let continued_line = self + .tokens + .get(self.pos.saturating_sub(1)) + .is_some_and(|previous| self.peek().span.start.line > previous.span.end.line); + if matches!(self.peek_kind(), TokenKind::Newline | TokenKind::Eof) || continued_line { Ok(()) } else { self.error_at_current(format!("expected the end of {what}")); @@ -268,7 +340,10 @@ impl Parser<'_> { ); return false; } - let end = self.peek().span.start; + let end = self + .tokens + .get(self.pos.saturating_sub(1)) + .map_or(start.span.end, |token| token.span.end); let span = Span::new(start.span.file, start.span.start, end); let decl = if global { Decl::GlobalVariable { @@ -305,7 +380,10 @@ impl Parser<'_> { } else { start.span }; - let end = self.peek().span.start; + let end = self + .tokens + .get(self.pos.saturating_sub(1)) + .map_or(start.span.end, |token| token.span.end); declarations.push(Decl::Subroutine { name, span: Span::new(start.span.file, start.span.start, end), @@ -350,6 +428,13 @@ impl Parser<'_> { )); } members.push((member.text, member_span)); + if self.peek_kind() == TokenKind::Assign { + self.advance(); + if self.parse_expr().is_err() { + self.recover_line(); + continue; + } + } } else { self.error_at_current("expected an enum member name".to_string()); self.recover_line(); @@ -377,10 +462,22 @@ impl Parser<'_> { fn parse_macro(&mut self, declarations: &mut Vec) -> bool { let start = self.advance(); let name_token = self.peek().clone(); - let name = match self.expect_ident("a macro name") { + let mut name = match self.expect_ident("a macro name") { Ok(name) => name, Err(()) => return false, }; + let qualified = if self.peek_kind() == TokenKind::Dot { + self.advance(); + let member = match self.expect_ident("a macro member name") { + Ok(member) => member, + Err(()) => return false, + }; + name.push('.'); + name.push_str(&member); + true + } else { + false + }; if self.peek_kind() == TokenKind::Assign { self.advance(); let value = match self.parse_expr() { @@ -388,13 +485,25 @@ impl Parser<'_> { Err(()) => return false, }; let span = Span::new(start.span.file, start.span.start, value.span().end); - declarations.push(Decl::Constant { name, value, span }); + if qualified { + declarations.push(Decl::Macro { + name, + args: vec!["self".to_string()], + body: vec![Stmt::Expr { expr: value, span }], + span, + }); + } else { + declarations.push(Decl::Constant { name, value, span }); + } return true; } - let args = match self.parse_param_list() { + let mut args = match self.parse_param_list() { Some(args) => args, None => return false, }; + if qualified { + args.insert(0, "self".to_string()); + } if self .expect(TokenKind::Colon, "':' after the macro signature") .is_err() @@ -489,10 +598,13 @@ impl Parser<'_> { return false; } let line_indent = start.span.start.col; - let body_indent = match self.block_indent(line_indent) { - Some(indent) => indent, - None => return false, - }; + if self.block_indent(line_indent).is_none() { + return false; + } + // OverPy accepts a small amount of indentation drift between rule + // directives and actions. The rule itself is still top-level, so any + // indentation greater than its column belongs to this rule. + let body_indent = line_indent + 1; let mut event = None; let mut conditions = Vec::new(); let mut annotations = Vec::new(); @@ -584,7 +696,9 @@ impl Parser<'_> { { return false; } - let end = self.peek().span.start; + let end = args + .last() + .map_or(event_annotation_arg.span.end, |arg| arg.span().end); *event = Some(Event { name: event_name, args, @@ -601,7 +715,7 @@ impl Parser<'_> { let start = self.pos; match self.parse_expr() { Ok(expr) => { - let end = self.peek().span.start; + let end = expr.span().end; conditions.push(expr); annotations.push(Annotation { name, @@ -635,7 +749,7 @@ impl Parser<'_> { self.error_at_current("@Slot and @Hero cannot be used together".to_string()); return false; } - let end = self.peek().span.start; + let end = args.last().map_or(at.span.end, |arg| arg.span.end); annotations.push(Annotation { name, args, @@ -651,7 +765,7 @@ impl Parser<'_> { ); return false; } - let end = self.peek().span.start; + let end = args.last().map_or(at.span.end, |arg| arg.span.end); annotations.push(Annotation { name, args, @@ -667,7 +781,7 @@ impl Parser<'_> { ); return false; } - let end = self.peek().span.start; + let end = args.last().map_or(at.span.end, |arg| arg.span.end); annotations.push(Annotation { name, args, @@ -711,7 +825,7 @@ impl Parser<'_> { ); return false; } - let end = self.peek().span.start; + let end = args.last().map_or(at.span.end, |arg| arg.span.end); *new_page = args.first().map(|arg| unquote_annotation_arg(&arg.text)); annotations.push(Annotation { name, @@ -1015,13 +1129,32 @@ impl Parser<'_> { } fn parse_expr_statement(&mut self) -> Result { + if self.peek_kind() == TokenKind::LParen { + let save_pos = self.pos; + let save_errors = self.errors.len(); + let start = self.advance().span; + if let Ok(target) = self.parse_postfix() + && self.peek_kind() == TokenKind::Assign + { + self.advance(); + let value = self.parse_expr()?; + let end = self.expect(TokenKind::RParen, "')'")?.span.end; + return Ok(Stmt::Assign { + target, + value, + span: Span::new(start.file, start.start, end), + }); + } + self.pos = save_pos; + self.errors.truncate(save_errors); + } let start = self.peek().span; let expr = self.parse_expr()?; match self.peek_kind() { TokenKind::Assign => { self.advance(); let value = self.parse_expr()?; - let end = self.peek().span.start; + let end = value.span().end; Ok(Stmt::Assign { target: expr, value, @@ -1046,7 +1179,7 @@ impl Parser<'_> { .to_string(); self.advance(); let rhs = self.parse_expr()?; - let end = self.peek().span.start; + let end = rhs.span().end; let value = Expr::Binary { op, left: Box::new(expr.clone()), @@ -1066,7 +1199,7 @@ impl Parser<'_> { let op = self.advance().text; self.advance(); let rhs = self.parse_expr()?; - let end = self.peek().span.start; + let end = rhs.span().end; let value = Expr::Binary { op, left: Box::new(expr.clone()), @@ -1110,7 +1243,7 @@ impl Parser<'_> { }) } _ => { - let end = self.peek().span.start; + let end = expr.span().end; Ok(Stmt::Expr { expr, span: Span::new(start.file, start.start, end), @@ -1129,19 +1262,18 @@ impl Parser<'_> { { return Err(()); } - let body_indent = self.block_indent(line_indent).ok_or(())?; - let body = self.parse_block(body_indent); + let body = self.parse_colon_body(line_indent)?; let mut branches = vec![IfBranch { condition, body }]; let mut r#else = None; loop { let save = self.pos; self.skip_newlines(); - if self.peek_kind() == TokenKind::Eof || self.peek().span.start.col != line_indent { + if self.peek_kind() == TokenKind::Eof || self.peek().span.start.col > line_indent { self.pos = save; break; } if self.is_ident("elif") { - self.advance(); + let branch_start = self.advance(); let condition = match self.parse_expr() { Ok(expr) => expr, Err(()) => return Err(()), @@ -1152,16 +1284,27 @@ impl Parser<'_> { { return Err(()); } - let body_indent = self.block_indent(line_indent).ok_or(())?; - let body = self.parse_block(body_indent); + let body = self.parse_colon_body(branch_start.span.start.col)?; branches.push(IfBranch { condition, body }); } else if self.is_ident("else") { - self.advance(); + let branch_start = self.advance(); + if self.is_ident("if") { + self.advance(); + let condition = self.parse_expr()?; + if self + .expect(TokenKind::Colon, "':' after the else-if condition") + .is_err() + { + return Err(()); + } + let body = self.parse_colon_body(branch_start.span.start.col)?; + branches.push(IfBranch { condition, body }); + continue; + } if self.expect(TokenKind::Colon, "':' after `else`").is_err() { return Err(()); } - let body_indent = self.block_indent(line_indent).ok_or(())?; - let body = self.parse_block(body_indent); + let body = self.parse_colon_body(branch_start.span.start.col)?; r#else = Some(body); break; } else { @@ -1176,6 +1319,25 @@ impl Parser<'_> { }) } + fn parse_colon_body(&mut self, line_indent: u32) -> Result, ()> { + if matches!(self.peek_kind(), TokenKind::Newline | TokenKind::Eof) { + let save = self.pos; + self.skip_newlines(); + if self.peek_kind() != TokenKind::Eof && self.peek().span.start.col == line_indent { + let statement = self.parse_statement()?; + self.expect_statement_end("the inline statement")?; + return Ok(vec![statement]); + } + self.pos = save; + let body_indent = self.block_indent(line_indent).ok_or(())?; + Ok(self.parse_block(body_indent)) + } else { + let statement = self.parse_statement()?; + self.expect_statement_end("the inline statement")?; + Ok(vec![statement]) + } + } + fn parse_for(&mut self) -> Result { let start = self.advance(); let variable = self.parse_postfix()?; @@ -1322,25 +1484,48 @@ impl Parser<'_> { // ---- expressions ---- fn parse_expr(&mut self) -> Result { + self.parse_expr_inner(false) + } + + fn parse_expr_inner(&mut self, allow_multiline_conditional: bool) -> Result { self.skip_expression_newlines(); let then_value = self.parse_or()?; - self.skip_expression_newlines(); + let has_newline_before_conditional = self.tokens[..self.pos].iter().any(|token| { + token.kind == TokenKind::Newline + && token.span.file == then_value.span().file + && token.span.start.line > then_value.span().end.line + }); + if self.is_ident("if") + && !self.inside_delimiter_group() + && has_newline_before_conditional + && self.peek().span.start.line != then_value.span().end.line + { + return Ok(then_value); + } + if allow_multiline_conditional && self.inside_delimiter_group() { + self.skip_newlines(); + } if !self.is_ident("if") { return Ok(then_value); } + let conditional_start = self.pos; self.advance(); self.skip_expression_newlines(); let condition = self.parse_or()?; self.skip_expression_newlines(); if !self.is_ident("else") { + if self.peek_kind() == TokenKind::Colon { + self.pos = conditional_start; + return Ok(then_value); + } self.error_at_current("expected `else` in conditional expression".to_string()); return Err(()); } self.advance(); // Conditional expressions are right-associative, so a chained form // such as `a if c else b if d else e` groups at the else branch. - let else_value = self.parse_expr()?; + let else_value = self.parse_expr_inner(true)?; let span = Span::new( then_value.span().file, then_value.span().start, @@ -2428,6 +2613,21 @@ mod tests { assert!(errors.iter().all(|error| error.code == "parse-error")); } + #[test] + fn parses_explicit_enum_member_values() { + let program = parse_ok("enum EventType:\n BUFF = 0\n DEBUFF\n MECH = 2\n"); + let Decl::Enum { members, .. } = &program.declarations[0] else { + panic!("expected enum"); + }; + assert_eq!( + members + .iter() + .map(|(name, _)| name.as_str()) + .collect::>(), + ["BUFF", "DEBUFF", "MECH"] + ); + } + #[test] fn parses_multi_line_array() { let program = parse_ok( diff --git a/crates/opy-rs/src/preprocess.rs b/crates/opy-rs/src/preprocess.rs index 999b224..e47ab14 100644 --- a/crates/opy-rs/src/preprocess.rs +++ b/crates/opy-rs/src/preprocess.rs @@ -1057,11 +1057,15 @@ impl Preprocessor { /// Expand all macros across the token stream, recursively. fn expand(&self, tokens: Vec) -> OpyResult> { - let mut out = Vec::new(); + let mut out: Vec = Vec::new(); let mut index = 0; while index < tokens.len() { let token = &tokens[index]; - if token.kind == TokenKind::Ident { + if token.kind == TokenKind::Ident + && !out.last().is_some_and(|previous| { + previous.kind == TokenKind::Ident && previous.text == "macro" + }) + { let name = token.text.clone(); if let Some(mac) = self.macros.iter().find(|m| m.name == name) { if mac.is_function { @@ -1103,7 +1107,10 @@ impl Preprocessor { let mut cursor = open + 1; while cursor < tokens.len() { let kind = tokens[cursor].kind; - if kind == TokenKind::LParen { + if matches!( + kind, + TokenKind::LParen | TokenKind::LBracket | TokenKind::LBrace + ) { depth += 1; current.push(tokens[cursor].clone()); } else if kind == TokenKind::RParen { @@ -1115,6 +1122,9 @@ impl Preprocessor { } depth -= 1; current.push(tokens[cursor].clone()); + } else if matches!(kind, TokenKind::RBracket | TokenKind::RBrace) { + depth = depth.saturating_sub(1); + current.push(tokens[cursor].clone()); } else if kind == TokenKind::Comma && depth == 0 { args.push(std::mem::take(&mut current)); } else { @@ -1243,7 +1253,11 @@ impl Preprocessor { let mut index = 0; while index < tokens.len() { let token = &tokens[index]; - if token.kind == TokenKind::Ident { + if token.kind == TokenKind::Ident + && !out.last().is_some_and(|previous| { + previous.kind == TokenKind::Ident && previous.text == "macro" + }) + { let name = token.text.clone(); if let Some(mac) = self.macros.iter().find(|m| m.name == name) { if stack.iter().any(|s| s == &name) { @@ -1254,7 +1268,7 @@ impl Preprocessor { } if mac.is_function { if index + 1 < tokens.len() && tokens[index + 1].kind == TokenKind::LParen { - let (args, after) = self.collect_args(tokens, index)?; + let (args, after) = self.collect_args(tokens, index + 1)?; let mut expanded = self.expand_macro(mac, args, token.span)?; stack.push(name.clone()); self.expand_into(&mut expanded, stack, depth + 1)?; @@ -1593,6 +1607,23 @@ mod tests { assert_eq!(numbers, vec!["3", "3"]); } + #[test] + fn function_define_keeps_commas_inside_nested_collections() { + let (pre, _) = preprocess( + "#!define first(xs, fallback) xs[0]\nrule \"r\":\n x = first([1, 2], 3)\n", + "main.opy", + Path::new("."), + ) + .unwrap(); + let numbers: Vec<&str> = pre + .tokens + .iter() + .filter(|token| token.kind == TokenKind::Number) + .map(|token| token.text.as_str()) + .collect(); + assert_eq!(numbers, vec!["1", "2", "0"]); + } + #[test] fn zero_argument_function_define_accepts_empty_invocation() { let (pre, _) = preprocess( diff --git a/crates/opy-rs/src/settings.rs b/crates/opy-rs/src/settings.rs index 1645489..2f19ba2 100644 --- a/crates/opy-rs/src/settings.rs +++ b/crates/opy-rs/src/settings.rs @@ -590,6 +590,7 @@ impl Jsonc<'_> { depth += 1; value.push(self.advance().expect("peeked character exists")); } + ']' if depth == 0 => break, ')' | ']' => { depth = depth.saturating_sub(1); value.push(self.advance().expect("peeked character exists")); @@ -658,14 +659,20 @@ impl Jsonc<'_> { loop { self.skip_whitespace(); let start = self.here(); - let value = match self.parse_string_value() { - Some(value) => value, - None => { + let value = match self.peek() { + Some('"') | Some('\'') => self.parse_string_value().ok_or_else(|| { + self.error( + "settings-invalid", + "unterminated string in settings list".to_string(), + ) + })?, + Some(']') | Some(',') | None => { return Err(self.error( "settings-invalid", - "settings list elements must be strings".to_string(), + "settings list elements must not be empty".to_string(), )); } + _ => self.parse_expression_value(), }; let span = Span::new(self.file, start, self.here()); elements.push(cst::SettingsListElement { value, span }); diff --git a/crates/opy-rs/support-matrix.json b/crates/opy-rs/support-matrix.json index 24c086c..a694d3b 100644 --- a/crates/opy-rs/support-matrix.json +++ b/crates/opy-rs/support-matrix.json @@ -737,7 +737,7 @@ "test:opy-rs::compiler-issue-47-residual-native-wir-gaps", "contract:workshop-rs-v0.1.16" ], - "notes": "Issue #47/#65/#162. If/elif/else, while, global- and player-variable range-for, do-while expansion, source-ordered switch fallthrough/default, and direct loop/switch/do-while break lower into canonical workshop-rs WIR. Player-variable binders preserve their receiver and source span through HIR and use the existing For Player Variable contract; non-variable range binders are rejected by source semantics before lowering. Switch offsets and do-while distances use workshop-rs v0.1.16 canonical emitted action widths; direct, conditional, and nested do-while shapes are pinned and oracle-equivalent. Residual native-WIR differences in switch-break and switch-target shapes remain explicit compiler gaps, while nested conditional switch break and multi-break switch cases remain stable source-attributed unsupported-integration-surface diagnostics because OPY has no lossless lowering for those source shapes." + "notes": "Issue #47/#65/#162. If/elif/else, while, global- and player-variable range-for, do-while expansion, source-ordered switch fallthrough/default, and direct loop/switch/do-while break lower into canonical workshop-rs WIR. Player-variable binders preserve their receiver and source span through HIR and use the existing For Player Variable contract; non-variable range binders are rejected by source semantics before lowering. Switch offsets and do-while distances use workshop-rs v0.1.16 canonical emitted action widths; direct, conditional, and nested do-while shapes are pinned and oracle-equivalent. Residual native-WIR differences in switch-break and switch-target shapes remain explicit compiler gaps; nested conditional switch break remains a stable source-attributed unsupported-integration-surface diagnostic, while multiple direct switch breaks use canonical nested exit layers with pinned action-trace evidence." }, { "id": "compilation/workshop-lowering", diff --git a/crates/opy-rs/tests/differential.rs b/crates/opy-rs/tests/differential.rs index d41bdfb..6487056 100644 --- a/crates/opy-rs/tests/differential.rs +++ b/crates/opy-rs/tests/differential.rs @@ -305,7 +305,7 @@ fn declared_corpus() -> BTreeMap<&'static str, Case> { &mut cases, "synthetic/issue-47-switch-multiple-break", false, - "Issue #47 multi-break probe; the frontend preserves all authored arms and breaks while the compiler reports the canonical WIR capability gap.", + "Issue #47 multi-break probe; the frontend preserves all authored arms and breaks while the compiler lowers them through canonical nested switch-exit WIR.", ); resolve( &mut cases, @@ -522,11 +522,11 @@ fn declared_corpus() -> BTreeMap<&'static str, Case> { Some("lex-error"), "reference rejects the example ('Invalid content before string: 'arena'', upstream example bug); the native frontend rejects the triple-quoted-string surface (baseline category 1b, declared rejection, unterminated-string lex error). Gap: rejection reasons differ (documented).", ); - diagnostic( + resolve( &mut cases, "real-world/ow1-emulator", - Some("unsupported-directive"), - "reference fails on semantic member checks; the native frontend accepts the included-file #!mainFile directives and reaches the next unsupported #!defineMember directive with source attribution. Gap: rejection reason differs (documented).", + false, + "the pinned oracle snapshot still records the pre-fix parser failure, while the native frontend now resolves the complete include closure; the independent compiler gate reaches the existing canonical WIR multiple-switch-break limitation.", ); diagnostic( &mut cases, diff --git a/crates/opy-rs/tests/issue_141_syntax.rs b/crates/opy-rs/tests/issue_141_syntax.rs index e6a140b..07f073c 100644 --- a/crates/opy-rs/tests/issue_141_syntax.rs +++ b/crates/opy-rs/tests/issue_141_syntax.rs @@ -130,21 +130,13 @@ fn issue_141_invalid_statement_contexts_are_source_diagnostics() { #[test] fn issue_141_backend_boundary_is_explicit_for_source_only_statements() { let cases = [ - ( - "rule \"delete\":\n @Event global\n del A[1]\n", - "delete statements", - ), ( "rule \"goto\":\n @Event global\n goto target\n", - "goto statements", + "unknown goto label", ), ( "rule \"goto rule start\":\n @Event global\n goto RULE_START\n", - "goto statements", - ), - ( - "rule \"label\":\n @Event global\n target:\n", - "labels", + "goto RULE_START", ), ]; let compiler = opy_rs::Compiler::new().expect("the compiler contract loads"); @@ -158,6 +150,18 @@ fn issue_141_backend_boundary_is_explicit_for_source_only_statements() { } } +#[test] +fn issue_141_delete_reaches_canonical_array_reconstruction() { + let source = "globalvar A\nrule \"delete\":\n @Event global\n del A[1]\n"; + let artifact = opy_rs::Compiler::new() + .expect("the compiler contract loads") + .compile_source(source, "issue-141-delete.opy", Path::new("")) + .expect("indexed delete must lower to canonical array operations"); + assert!(artifact.emitted_workshop.contains( + "Set Global Variable(A, Append To Array(Array Slice(Global.A, 0, 1), Array Slice(Global.A, Add(1, 1), 999999999999)))" + )); +} + #[test] fn issue_141_continue_reaches_canonical_loop_control() { let source = concat!( diff --git a/docs/README.md b/docs/README.md index dcfc13a..df382d1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,6 +13,8 @@ architecture, compatibility evidence, APIs, and internal contracts live here. compiler/reconstruction boundaries, and dependency direction. - [Tooling API](opy/tooling-api.md): Rust library and CLI contracts for checking, inspection, overlays, diagnostics, and support queries. +- [LPP provider](opy/provider.md): first-party provider capabilities, entry-based + project loading, artifact boundary, and release archive contract. - [Source-edit policy](opy/trivia-retention-policy.md): provenance and trivia requirements for validated source-oriented edits. diff --git a/docs/opy/provider.md b/docs/opy/provider.md new file mode 100644 index 0000000..02e1334 --- /dev/null +++ b/docs/opy/provider.md @@ -0,0 +1,84 @@ +# OPY LPP provider + +`opy-provider` is the first-party stdio process for the Language Provider +Protocol. It is an owner-side adapter over the existing `opy-rs` frontend and +compiler; it does not expose OPY AST/HIR or Workshop WIR. + +## Protocol and capabilities + +The provider serves language id `opy` and the `opy` extension. It supports LPP +`1.0` for document-supplied requests and LPP `1.1` for the additive +provider-owned project-loading capability. + +| Capability | Method | Behavior | +| --- | --- | --- | +| Check | `lpp/check` | Loads the selected entry's OPY project and returns source diagnostics. | +| Compile | `lpp/compile` | Uses the same project loading path and returns canonical Workshop text when clean. | +| Project loading | `lpp/check`, `lpp/compile` | LPP 1.1 only: loads a complete OPY project from a client-selected entry. | + +All other LPP v1 capabilities are advertised as unavailable until they are +implemented end to end. + +## Entry-based project loading + +For the entry/project-loading extension defined by +`language-provider-protocol#16`, the client must negotiate LPP 1.1. Then +`lpp/check` and `lpp/compile` accept an `entry` object without a complete +`documents` map: + +```json +{ + "entry": { + "uri": "file:///workspace/main.opy", + "languageId": "opy", + "version": 7 + }, + "projectRoot": "file:///workspace" +} +``` + +The provider reads the entry from the filesystem. Its parent directory is the +include root, so `#!mainFile`, reachable `#!include` files, preprocessing, and +macros are resolved by the existing `opy-rs` pipeline. `projectRoot` remains +informational. A single supplied `Document` is also accepted for document-set +compatibility; multi-file OPY loading uses the entry path instead of requiring +the client to enumerate the source closure. + +Results identify every file discovered by preprocessing with a `file://` URI. +Owner source spans are converted to LPP's zero-based UTF-16 positions, and +every filesystem-loaded result echoes the entry version. Missing or unreadable +required files fail the request with the structured `projectLoadFailed` error; +unsupported entry URI, language, or version uses `invalidEntry`. + +Document-supplied requests remain available in both protocol versions. The +provider analyzes every supplied document from that request snapshot. A +document-supplied compile request with more than one document is refused with +`compile.requiresSingleDocument` because the OPY compiler emits one project +artifact. + +## Compile artifact + +Successful compilation returns the opaque LPP artifact: + +```json +{ + "format": "workshop-rs/text-v1", + "content": "" +} +``` + +The artifact contains only emitted Workshop text. It is `null` whenever +compilation produces an error-severity diagnostic. + +## Running locally + +```sh +cargo run --release -p opy-provider +``` + +The process reads one JSON-RPC request per UTF-8 line from stdin and writes one +response per line to stdout. Human-readable logging belongs on stderr; the +provider emits no protocol data there. + +Release archives are target-specific and named +`opy-provider--.tar.gz`, with a matching `.sha256` checksum. diff --git a/docs/release.md b/docs/release.md index d8752a6..fc3a0f3 100644 --- a/docs/release.md +++ b/docs/release.md @@ -32,3 +32,13 @@ These checks do not prove publication. Completion requires observing all package versions on crates.io and the matching tag after a real release. If publication fails, correct the failed package and rerun the release path; do not republish an already-published version under a different version. + +## First-party provider artifacts + +The tag-driven `provider-release` workflow builds `opy-provider` for the +supported Wright targets and uploads one archive plus checksum per target to the +same GitHub Release. Artifact names are +`opy-provider--.tar.gz` and +`opy-provider--.tar.gz.sha256`. The archive contains only the +provider executable (`opy-provider` or `opy-provider.exe`); it is independent of +the crates.io publication path.