From c51fc79510d81b2e5775defa4dae50d2ac32fd7e Mon Sep 17 00:00:00 2001 From: Teakowa Date: Thu, 22 Oct 2026 03:40:04 +0800 Subject: [PATCH 1/2] refactor: make comment-only behavior explicit Receiver enforcement and variable-parameter indices now come from the manifest as the single source; the duplicated policy tables are removed. Lookup and lowering share one special-function table, and reconstruction, parser, tooling, and provider code name the behaviors that were documented only by comments. A macro isolation test pins the host-capability boundary. Fixes #633 --- crates/opy-provider/src/edits.rs | 480 ++++----- crates/opy-provider/src/edits/tests.rs | 137 +++ crates/opy-rs/src/compiler/reconstruct.rs | 936 ++---------------- .../src/compiler/reconstruct/settings.rs | 433 ++++++++ .../src/compiler/reconstruct/settings_json.rs | 435 ++++++++ .../compiler/tests/structural_convergence.rs | 2 +- crates/opy-rs/src/lib.rs | 9 +- crates/opy-rs/src/lookup.rs | 12 - crates/opy-rs/src/lower.rs | 31 +- crates/opy-rs/src/lower/expressions.rs | 22 +- crates/opy-rs/src/lower/policy.rs | 33 - crates/opy-rs/src/lower/special_forms.rs | 39 +- crates/opy-rs/src/macro_js/tests/isolation.rs | 22 + crates/opy-rs/src/manifest/mod.rs | 12 + crates/opy-rs/src/matcher.rs | 4 +- crates/opy-rs/src/parser/expressions.rs | 16 +- crates/opy-rs/src/tooling.rs | 276 +----- crates/opy-rs/src/tooling/tests.rs | 211 ++++ docs/architecture/reconstruction.md | 2 +- 19 files changed, 1619 insertions(+), 1493 deletions(-) create mode 100644 crates/opy-provider/src/edits/tests.rs create mode 100644 crates/opy-rs/src/compiler/reconstruct/settings.rs create mode 100644 crates/opy-rs/src/compiler/reconstruct/settings_json.rs create mode 100644 crates/opy-rs/src/tooling/tests.rs diff --git a/crates/opy-provider/src/edits.rs b/crates/opy-provider/src/edits.rs index 7033e371..be85b527 100644 --- a/crates/opy-provider/src/edits.rs +++ b/crates/opy-provider/src/edits.rs @@ -14,141 +14,201 @@ use crate::{ path_to_file_uri, resolved_path, same_path, validate_documents, }; -const CROSS_FILE_CODES: [&str; 7] = [ - "include-not-found", - "main-file-not-found", - "script-not-found", - "unknown-identifier", - "unknown-action", - "unknown-value", - "unknown-member", -]; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DiagnosticClass { + DocumentSyntax, + MissingSource, + NameBinding, + MergedTable, + MacroTable, + Script, + ExternalFile, + Catalog, + Capability, + Semantic, + Internal, + WarningOnly, +} -const DOCUMENT_SYNTAX_CODES: [&str; 18] = [ - "lex-error", - "parse-error", - "workshop-source", - "directive-invalid", - "unsupported-directive", - "include-invalid", - "define-invalid", - "main-file-invalid", - "main-file-placement", - "rule-prefix-invalid", - "settings-invalid", - "settings-placement", - "macro-invalid", - "do-while-placement", - "invalid-string-entity", - "unknown-string-entity", - "script-invalid", - "enum-increment", +const CODE_CLASSES: &[(DiagnosticClass, &[&str])] = &[ + ( + DiagnosticClass::DocumentSyntax, + &[ + "lex-error", + "parse-error", + "workshop-source", + "directive-invalid", + "unsupported-directive", + "include-invalid", + "define-invalid", + "main-file-invalid", + "main-file-placement", + "rule-prefix-invalid", + "settings-invalid", + "settings-placement", + "macro-invalid", + "do-while-placement", + "invalid-string-entity", + "unknown-string-entity", + "script-invalid", + "enum-increment", + ], + ), + ( + DiagnosticClass::MissingSource, + &[ + "include-not-found", + "main-file-not-found", + "script-not-found", + "unknown-identifier", + "unknown-action", + "unknown-value", + "unknown-member", + ], + ), + ( + DiagnosticClass::NameBinding, + &[ + "duplicate-argument", + "duplicate-declaration", + "duplicate-definition", + "duplicate-rule-name", + "enum-type-without-member", + "invalid-arity", + "vect-arity", + "unresolved-reference", + "unknown-keyword", + ], + ), + ( + DiagnosticClass::MergedTable, + &[ + "rule-prefix-template-duplicate", + "replacement-duplicate", + "post-compile-hook-duplicate", + "macro-redeclaration", + ], + ), + ( + DiagnosticClass::MacroTable, + &["macro-arity", "macro-recursion"], + ), + ( + DiagnosticClass::Script, + &[ + "script-error", + "script-timeout", + "script-stack-limit", + "script-memory-limit", + "script-result-not-string", + "script-internal", + ], + ), + ( + DiagnosticClass::ExternalFile, + &[ + "include-cycle", + "settings-not-found", + "translations-invalid", + "settings-expression", + "rule-prefix-template-invalid", + ], + ), + ( + DiagnosticClass::Catalog, + &[ + "catalog-error", + "manifest-error", + "unknown-enum-member", + "missing-mapping", + "unknown-texture-member", + ], + ), + ( + DiagnosticClass::Capability, + &[ + "backend-directive-unsupported", + "four-dimensional-assignment", + "four-dimensional-delete", + "index-collision", + "index-exhausted", + "index-out-of-range", + "random-indexed-delete", + "split-dict-array-arguments", + "split-dict-array-arity", + "split-dict-array-key", + "symbol-collision", + "workshop-action-layout", + "unsupported-action-call", + "unsupported-construct", + "unsupported-control-flow", + "unsupported-dangling", + "unsupported-duplicate-name", + "unsupported-enum-domain", + "unsupported-enum-domain-mismatch", + "unsupported-enum-member", + "unsupported-index", + "unsupported-indexed-initializer", + "unsupported-integration-surface", + "unsupported-invalid-argument", + "unsupported-invalid-arity", + "unsupported-localized-string", + "unsupported-member", + "unsupported-member-assignment", + "unsupported-missing-argument", + "unsupported-modify-op", + "unsupported-non-finite-number", + "unsupported-per-player-loop", + "unsupported-rule-order", + "unsupported-settings", + "unsupported-subroutine-value", + "unsupported-value-call", + ], + ), + ( + DiagnosticClass::Semantic, + &[ + "action-in-value-position", + "break-context", + "continue-context", + "dict-access", + "invalid-argument", + "invalid-call-context", + "invalid-identifier", + "invalid-iterable", + "invalid-range-binder", + "invalid-receiver", + "invalid-span", + "invalid-string-modifier", + "invalid-structure", + "keyword-required", + "keyword-unsupported", + "lambda-context", + "missing-argument", + "positional-after-keyword", + "settings-hero-lists", + "settings-workshop-value", + "tabular-arguments", + "value-in-action-position", + ], + ), + ( + DiagnosticClass::Internal, + &[ + "incompatible-protocol", + "malformed-payload", + "unsupported-node", + "workshop-emission", + ], + ), + (DiagnosticClass::WarningOnly, &["unknown-setting"]), ]; -#[cfg(test)] -const EXCLUDED_CODES: &[&str] = &[ - "action-in-value-position", - "backend-directive-unsupported", - "break-context", - "catalog-error", - "continue-context", - "dict-access", - "duplicate-argument", - "duplicate-declaration", - "duplicate-definition", - "duplicate-rule-name", - "enum-type-without-member", - "four-dimensional-assignment", - "four-dimensional-delete", - "include-cycle", - "include-not-found", - "incompatible-protocol", - "index-collision", - "index-exhausted", - "index-out-of-range", - "invalid-argument", - "invalid-arity", - "invalid-call-context", - "invalid-identifier", - "invalid-iterable", - "invalid-range-binder", - "invalid-receiver", - "invalid-span", - "invalid-string-modifier", - "invalid-structure", - "keyword-required", - "keyword-unsupported", - "lambda-context", - "macro-arity", - "macro-recursion", - "macro-redeclaration", - "main-file-not-found", - "malformed-payload", - "manifest-error", - "missing-argument", - "missing-mapping", - "positional-after-keyword", - "post-compile-hook-duplicate", - "random-indexed-delete", - "replacement-duplicate", - "rule-prefix-template-duplicate", - "rule-prefix-template-invalid", - "script-error", - "script-internal", - "script-memory-limit", - "script-not-found", - "script-result-not-string", - "script-stack-limit", - "script-timeout", - "settings-expression", - "settings-hero-lists", - "settings-not-found", - "settings-workshop-value", - "split-dict-array-arguments", - "split-dict-array-arity", - "split-dict-array-key", - "symbol-collision", - "tabular-arguments", - "translations-invalid", - "unknown-action", - "unknown-enum-member", - "unknown-identifier", - "unknown-keyword", - "unknown-member", - "unknown-setting", - "unknown-texture-member", - "unknown-value", - "unresolved-reference", - "unsupported-action-call", - "unsupported-construct", - "unsupported-control-flow", - "unsupported-dangling", - "unsupported-duplicate-name", - "unsupported-enum-domain", - "unsupported-enum-domain-mismatch", - "unsupported-enum-member", - "unsupported-index", - "unsupported-indexed-initializer", - "unsupported-integration-surface", - "unsupported-invalid-argument", - "unsupported-invalid-arity", - "unsupported-localized-string", - "unsupported-member", - "unsupported-member-assignment", - "unsupported-missing-argument", - "unsupported-modify-op", - "unsupported-node", - "unsupported-non-finite-number", - "unsupported-per-player-loop", - "unsupported-rule-order", - "unsupported-settings", - "unsupported-subroutine-value", - "unsupported-value-call", - "value-in-action-position", - "vect-arity", - "workshop-action-layout", - "workshop-emission", -]; +fn class_of(code: &str) -> Option { + CODE_CLASSES + .iter() + .find_map(|(class, codes)| codes.contains(&code).then_some(*class)) +} #[cfg(test)] const NON_DIAGNOSTIC_LITERALS: &[&str] = &["kebab-case", "non-empty"]; @@ -464,15 +524,24 @@ pub(crate) fn site_contains( file_ids.contains(&site.file_id) && site.start <= position && position < site.end } -pub(crate) fn carries_source( +const MAIN_FILE_STUB_ID: u32 = 0; + +fn is_main_file_redirect_stub( outcome: &opy_rs::tooling::CheckOutcome, file: &opy_rs::preprocess::FileRecord, ) -> bool { - file.id != 0 - || outcome + file.id == MAIN_FILE_STUB_ID + && outcome .model .as_ref() - .is_none_or(|model| model.hir.preprocessing.main_file.is_none()) + .is_some_and(|model| model.hir.preprocessing.main_file.is_some()) +} + +pub(crate) fn carries_source( + outcome: &opy_rs::tooling::CheckOutcome, + file: &opy_rs::preprocess::FileRecord, +) -> bool { + !is_main_file_redirect_stub(outcome, file) } fn refusal(code: &'static str, details: Value, message: impl Into) -> HandlerError { @@ -603,7 +672,7 @@ fn model_view( let missing: Vec<&str> = outcome .diagnostics .iter() - .filter(|diagnostic| CROSS_FILE_CODES.contains(&diagnostic.code.as_str())) + .filter(|diagnostic| class_of(&diagnostic.code) == Some(DiagnosticClass::MissingSource)) .map(|diagnostic| diagnostic.message.as_str()) .collect(); if !missing.is_empty() { @@ -1025,7 +1094,7 @@ pub(crate) fn validate_edits(params: Value) -> Result { opy_rs::tooling::check_with_overlay(&result, &path_string(&path), &root, &overlay); let malformed = outcome.diagnostics.iter().any(|diagnostic| { diagnostic.severity == opy_rs::tooling::DiagnosticSeverity::Error - && DOCUMENT_SYNTAX_CODES.contains(&diagnostic.code.as_str()) + && class_of(&diagnostic.code) == Some(DiagnosticClass::DocumentSyntax) && diagnostic.span.as_ref().is_some_and(|span| { same_path(&resolved_path(&outcome.display_root, &span.path), &path) }) @@ -1037,137 +1106,4 @@ pub(crate) fn validate_edits(params: Value) -> Result { } #[cfg(test)] -mod tests { - use super::*; - use std::collections::BTreeSet; - use std::fs; - use std::path::{Path, PathBuf}; - - #[test] - fn syntax_error_classification_is_exhaustive() { - let mut found = BTreeSet::new(); - collect_literals( - &PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../opy-rs/src"), - &mut found, - ); - - let syntax: BTreeSet<&str> = DOCUMENT_SYNTAX_CODES.iter().copied().collect(); - let excluded: BTreeSet<&str> = EXCLUDED_CODES.iter().copied().collect(); - let non_code: BTreeSet<&str> = NON_DIAGNOSTIC_LITERALS.iter().copied().collect(); - let listed: BTreeSet<&str> = syntax.union(&excluded).copied().collect(); - assert!( - syntax.is_disjoint(&excluded) - && syntax.is_disjoint(&non_code) - && excluded.is_disjoint(&non_code), - "a literal appears in more than one classification list" - ); - - let unclassified: Vec<&String> = found - .iter() - .filter(|code| !listed.contains(code.as_str()) && !non_code.contains(code.as_str())) - .collect(); - let stale: Vec<&str> = listed - .iter() - .chain(non_code.iter()) - .copied() - .filter(|code| !found.contains(*code)) - .collect(); - assert!( - unclassified.is_empty() && stale.is_empty(), - "unclassified literals: {unclassified:?}; listed but never emitted: {stale:?}" - ); - } - - fn collect_literals(dir: &Path, found: &mut BTreeSet) { - for entry in fs::read_dir(dir).expect("read opy-rs sources") { - let path = entry.expect("dir entry").path(); - let name = path.file_name().unwrap_or_default().to_string_lossy(); - if path.is_dir() { - if name != "tests" { - collect_literals(&path, found); - } - } else if name.ends_with(".rs") && !name.starts_with("test_") { - let source = fs::read_to_string(&path).expect("read source file"); - let source = source.split("#[cfg(test)]").next().unwrap_or(""); - scan(source, found); - } - } - } - - fn scan(source: &str, found: &mut BTreeSet) { - let bytes = source.as_bytes(); - let mut i = 0; - while i < bytes.len() { - match bytes[i] { - b'/' if bytes.get(i + 1) == Some(&b'/') => { - i = bytes[i..] - .iter() - .position(|b| *b == b'\n') - .map(|end| i + end) - .unwrap_or(bytes.len()); - } - b'/' if bytes.get(i + 1) == Some(&b'*') => { - i += 2; - while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') { - i += 1; - } - i += 2; - } - b'r' => { - let mut hashes = 0; - while bytes.get(i + 1 + hashes) == Some(&b'#') { - hashes += 1; - } - if bytes.get(i + 1 + hashes) == Some(&b'"') { - let mut j = i + 2 + hashes; - while j < bytes.len() - && !(bytes[j] == b'"' - && (0..hashes).all(|h| bytes.get(j + 1 + h) == Some(&b'#'))) - { - j += 1; - } - i = j + 1 + hashes; - } else { - i += 1; - } - } - b'"' => { - let start = i + 1; - let mut j = start; - while j < bytes.len() && bytes[j] != b'"' { - j += usize::from(bytes[j] == b'\\') + 1; - } - let text = &source[start..j.min(bytes.len())]; - if is_code_shape(text) { - found.insert(text.to_string()); - } - i = j + 1; - } - b'\'' => { - let next = bytes.get(i + 1).copied(); - let after = if next == Some(b'\\') { - (i + 2..bytes.len().min(i + 8)) - .find(|&j| bytes[j] == b'\'') - .map(|j| j + 1) - } else if bytes.get(i + 2) == Some(&b'\'') { - Some(i + 3) - } else { - None - }; - i = after.unwrap_or(i + 1); - } - _ => i += 1, - } - } - } - - fn is_code_shape(text: &str) -> bool { - text.contains('-') - && text.split('-').all(|part| { - !part.is_empty() - && part - .bytes() - .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()) - }) - } -} +mod tests; diff --git a/crates/opy-provider/src/edits/tests.rs b/crates/opy-provider/src/edits/tests.rs new file mode 100644 index 00000000..484c5e5d --- /dev/null +++ b/crates/opy-provider/src/edits/tests.rs @@ -0,0 +1,137 @@ +use super::*; +use std::collections::BTreeSet; +use std::fs; +use std::path::{Path, PathBuf}; + +#[test] +fn syntax_error_classification_is_exhaustive() { + let mut found = BTreeSet::new(); + collect_literals( + &PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../opy-rs/src"), + &mut found, + ); + + let classified: Vec<&str> = CODE_CLASSES + .iter() + .flat_map(|(_, codes)| codes.iter().copied()) + .collect(); + let listed: BTreeSet<&str> = classified.iter().copied().collect(); + assert_eq!( + classified.len(), + listed.len(), + "a literal is classified under more than one class" + ); + let non_code: BTreeSet<&str> = NON_DIAGNOSTIC_LITERALS.iter().copied().collect(); + assert!( + listed.is_disjoint(&non_code), + "a literal appears in both the classification table and the non-code list" + ); + + let unclassified: Vec<&String> = found + .iter() + .filter(|code| !listed.contains(code.as_str()) && !non_code.contains(code.as_str())) + .collect(); + let stale: Vec<&str> = listed + .iter() + .chain(non_code.iter()) + .copied() + .filter(|code| !found.contains(*code)) + .collect(); + assert!( + unclassified.is_empty() && stale.is_empty(), + "unclassified literals: {unclassified:?}; listed but never emitted: {stale:?}" + ); +} + +fn collect_literals(dir: &Path, found: &mut BTreeSet) { + for entry in fs::read_dir(dir).expect("read opy-rs sources") { + let path = entry.expect("dir entry").path(); + let name = path.file_name().unwrap_or_default().to_string_lossy(); + if path.is_dir() { + if name != "tests" { + collect_literals(&path, found); + } + } else if name.ends_with(".rs") && !name.starts_with("test_") && name != "tests.rs" { + let source = fs::read_to_string(&path).expect("read source file"); + let source = source.split("#[cfg(test)]").next().unwrap_or(""); + scan(source, found); + } + } +} + +fn scan(source: &str, found: &mut BTreeSet) { + let bytes = source.as_bytes(); + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'/' if bytes.get(i + 1) == Some(&b'/') => { + i = bytes[i..] + .iter() + .position(|b| *b == b'\n') + .map(|end| i + end) + .unwrap_or(bytes.len()); + } + b'/' if bytes.get(i + 1) == Some(&b'*') => { + i += 2; + while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') { + i += 1; + } + i += 2; + } + b'r' => { + let mut hashes = 0; + while bytes.get(i + 1 + hashes) == Some(&b'#') { + hashes += 1; + } + if bytes.get(i + 1 + hashes) == Some(&b'"') { + let mut j = i + 2 + hashes; + while j < bytes.len() + && !(bytes[j] == b'"' + && (0..hashes).all(|h| bytes.get(j + 1 + h) == Some(&b'#'))) + { + j += 1; + } + i = j + 1 + hashes; + } else { + i += 1; + } + } + b'"' => { + let start = i + 1; + let mut j = start; + while j < bytes.len() && bytes[j] != b'"' { + j += usize::from(bytes[j] == b'\\') + 1; + } + let text = &source[start..j.min(bytes.len())]; + if is_code_shape(text) { + found.insert(text.to_string()); + } + i = j + 1; + } + b'\'' => { + let next = bytes.get(i + 1).copied(); + let after = if next == Some(b'\\') { + (i + 2..bytes.len().min(i + 8)) + .find(|&j| bytes[j] == b'\'') + .map(|j| j + 1) + } else if bytes.get(i + 2) == Some(&b'\'') { + Some(i + 3) + } else { + None + }; + i = after.unwrap_or(i + 1); + } + _ => i += 1, + } + } +} + +fn is_code_shape(text: &str) -> bool { + text.contains('-') + && text.split('-').all(|part| { + !part.is_empty() + && part + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()) + }) +} diff --git a/crates/opy-rs/src/compiler/reconstruct.rs b/crates/opy-rs/src/compiler/reconstruct.rs index 5e39c69e..8aab3083 100644 --- a/crates/opy-rs/src/compiler/reconstruct.rs +++ b/crates/opy-rs/src/compiler/reconstruct.rs @@ -4,16 +4,21 @@ use std::fmt; use workshop_rs::Program; use workshop_rs::catalog::{Catalog, Locale}; use workshop_rs::program::{ - Action, Event, EventTarget, EventTeam, ModifyOp, Rule, Value, Variable, + Action, Condition, Event, EventTarget, EventTeam, ModifyOp, Rule, Value, Variable, }; use workshop_rs::settings::{PathPart, SettingValueDomain, SettingsNode}; use workshop_rs::source::Span; use crate::lexer::{is_ident_continue, is_ident_start, is_identifier}; use crate::lower::special_forms::SpecialValueCall; -use crate::lower::{NameNamespace, is_reserved_name, policy}; +use crate::lower::{NameNamespace, is_reserved_name}; use crate::manifest::{Function, FunctionKind, Manifest}; +mod settings; +mod settings_json; + +const DECLARATION_SLOT_RANGE: std::ops::RangeInclusive = 0..=127; + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ReconstructIssue { pub code: &'static str, @@ -284,7 +289,7 @@ impl<'a> Emitter<'a> { } fn check_index(&mut self, index: u32, taken: &mut HashSet, kind: &str, name: &str) { - if index > 127 { + if !DECLARATION_SLOT_RANGE.contains(&index) { self.issue( "unsupported-index", format!("{kind} '{name}' index {index} is outside the 0-127 slot range"), @@ -452,762 +457,6 @@ impl<'a> Emitter<'a> { Some(initializers) } - fn emit_settings(&mut self) { - let Some(settings) = self.program.settings.as_ref() else { - return; - }; - let mut unemittable = false; - for error in workshop_rs::settings::check_emission(settings) { - unemittable = true; - self.issue("unsupported-settings", error.to_string(), error.span()); - } - if unemittable { - return; - } - - let mut top: Vec<(String, SettingsJson)> = Vec::new(); - let mut extensions: Vec = Vec::new(); - let mut seen = HashSet::new(); - let last = settings.children.len().saturating_sub(1); - for (index, child) in settings.children.iter().enumerate() { - if !seen.insert(child.name().to_string()) { - self.refuse_settings(child, "is a duplicate top-level group"); - } - match child { - SettingsNode::Workshop { children, .. } => { - top.push(("workshop".to_string(), self.settings_workshop(children))); - } - SettingsNode::Group { name, children, .. } - | SettingsNode::RawGroup { name, children, .. } => match name.as_str() { - "workshop" => self.refuse_settings( - child, - "has no OPY settings form; `workshop` reparses as workshop settings", - ), - "main" | "lobby" => { - let display = display_names(&[format!("{name}.")]); - let path = [PathPart::Part(name.as_str())]; - top.push(( - name.clone(), - self.settings_object(children, &path, None, &display), - )); - } - "extensions" => { - let flags = children - .iter() - .position(|member| matches!(member, SettingsNode::Flag { .. })); - match flags { - None => { - let display = display_names(&["extensions.".to_string()]); - top.push(( - name.clone(), - self.settings_object( - children, - &[PathPart::Part("extensions")], - None, - &display, - ), - )); - } - Some(first) => { - let mut flag_names = HashSet::new(); - for member in &children[first..] { - let SettingsNode::Flag { name, .. } = member else { - self.refuse_settings( - member, - "follows a presence-only member in `extensions`", - ); - continue; - }; - if !flag_names.insert(name.clone()) { - self.refuse_settings( - member, - "is a duplicate `#!extension` name", - ); - } - let path = [ - PathPart::Part("extensions"), - PathPart::Part(name.as_str()), - ]; - match workshop_rs::settings::definition(&path) { - Some(definition) - if matches!( - definition.domain(), - SettingValueDomain::PresenceOnly - ) => - { - extensions.push(name.clone()); - } - _ => self.refuse_settings( - member, - "is not a declared Workshop extension", - ), - } - } - if first == 0 { - if index != last { - self.refuse_settings( - child, - "carries only `#!extension` directives outside the last group", - ); - } - } else { - let display = display_names(&["extensions.".to_string()]); - top.push(( - name.clone(), - self.settings_object( - &children[..first], - &[PathPart::Part("extensions")], - None, - &display, - ), - )); - } - } - } - } - "gamemodes" => { - top.push(("gamemodes".to_string(), self.settings_modes(children))); - } - "heroes" => { - top.push(("heroes".to_string(), self.settings_teams(children))); - } - _ => { - top.push((name.clone(), self.opaque_group_object(children))); - } - }, - member => self.refuse_settings( - member, - "cannot appear at the settings top level; groups are expected", - ), - } - } - - match (top.is_empty(), extensions.is_empty()) { - (true, true) => { - self.issue( - "unsupported-settings", - "an empty settings block has no OPY form", - settings.span, - ); - return; - } - (true, false) => {} - (false, _) => { - if !top.iter().any(|(key, _)| key == "gamemodes") { - self.issue( - "unsupported-settings", - "settings has no `gamemodes` group, which the OPY frontend requires", - settings.span, - ); - return; - } - } - } - if !top.is_empty() { - self.out.push_str("settings "); - write_settings_json(&mut self.out, &SettingsJson::Object(top), 0); - self.out.push_str("\n\n"); - } - for name in &extensions { - self.out.push_str("#!extension "); - self.out.push_str(name); - self.out.push('\n'); - } - if !extensions.is_empty() { - self.out.push('\n'); - } - } - - fn settings_modes(&mut self, children: &[SettingsNode]) -> SettingsJson { - let mut entries = Vec::new(); - let mut seen = HashSet::new(); - for mode in children { - let (name, children) = match mode { - SettingsNode::Group { name, children, .. } - | SettingsNode::RawGroup { name, children, .. } => (name, children), - _ => { - self.refuse_settings( - mode, - "cannot appear inside `gamemodes`; groups are expected", - ); - continue; - } - }; - if !seen.insert(name.clone()) { - self.refuse_settings(mode, "is a duplicate mode group"); - } - let display = display_names(&[ - format!("gamemodes.{name}."), - "gamemodes.general.".to_string(), - ]); - let path = [PathPart::Part("gamemodes"), PathPart::Part(name.as_str())]; - let mut members = Vec::new(); - let mut member_names = HashSet::new(); - for (index, member) in children.iter().enumerate() { - if member.name() == "enabled" { - match (index, member) { - (0, SettingsNode::Bool { value: false, .. }) => { - members.push(("enabled".to_string(), SettingsJson::Bool(false))); - member_names.insert("enabled".to_string()); - } - _ => self.refuse_settings( - member, - "is an `enabled` marker only `enabled: false` in first position reproduces", - ), - } - continue; - } - let Some((key, json)) = self.settings_member(member, &path, None, &display) else { - continue; - }; - if !member_names.insert(key.clone()) { - self.refuse_settings(member, "is a duplicate settings member"); - } - members.push((key, json)); - } - entries.push((name.clone(), SettingsJson::Object(members))); - } - SettingsJson::Object(entries) - } - - fn settings_teams(&mut self, children: &[SettingsNode]) -> SettingsJson { - const TEAM: [PathPart<'static>; 2] = [PathPart::Part("heroes"), PathPart::Team]; - const HERO: [PathPart<'static>; 3] = - [PathPart::Part("heroes"), PathPart::Team, PathPart::Hero]; - let mut entries = Vec::new(); - let mut seen = HashSet::new(); - for team in children { - let (name, children) = match team { - SettingsNode::Group { name, children, .. } - | SettingsNode::RawGroup { name, children, .. } => (name, children), - _ => { - self.refuse_settings( - team, - "cannot appear inside `heroes`; groups are expected", - ); - continue; - } - }; - if !seen.insert(name.clone()) { - self.refuse_settings(team, "is a duplicate team group"); - } - let roster = |member: &SettingsNode| { - matches!(member, SettingsNode::List { name, .. } if matches!( - name.as_str(), - "enabledHeroes" | "disabledHeroes" - )) - }; - let rest_at = children - .iter() - .position(|member| matches!(member, SettingsNode::Group { .. }) || roster(member)) - .unwrap_or(children.len()); - let rosters_at = children[rest_at..] - .iter() - .position(roster) - .map(|index| rest_at + index) - .unwrap_or(children.len()); - let general = &children[..rest_at]; - let rest = &children[rest_at..rosters_at]; - let rosters = &children[rosters_at..]; - for member in rest { - if roster(member) { - self.refuse_settings( - member, - "is a hero roster before the last hero group; rosters reparse last", - ); - } - } - for member in rosters { - if !roster(member) { - self.refuse_settings( - member, - "follows a hero roster; team `general` members reparse first", - ); - } - } - if rosters - .iter() - .filter(|member| member.name() == "enabledHeroes") - .count() - + rosters - .iter() - .filter(|member| member.name() == "disabledHeroes") - .count() - > 1 - { - self.refuse_settings( - team, - "lists both `enabledHeroes` and `disabledHeroes`, which the frontend rejects", - ); - } - - let display = display_names(&[ - "heroes..".to_string(), - "heroes.general.".to_string(), - "heroes.team1.".to_string(), - "heroes.team2.".to_string(), - "heroes.allTeams.".to_string(), - ]); - let mut members = Vec::new(); - let mut member_names = HashSet::new(); - for member in rest { - let (hero_name, hero_children) = match member { - SettingsNode::Group { name, children, .. } - | SettingsNode::RawGroup { name, children, .. } => (name, children), - _ => { - self.refuse_settings( - member, - "is a non-group member among hero groups; it would reparse as an empty group", - ); - continue; - } - }; - if !super::settings::resolves_to_hero(hero_name) { - self.refuse_settings(member, "is not a declared hero"); - continue; - } - if super::settings::canonical_hero_name(hero_name) != hero_name.as_str() { - self.refuse_settings( - member, - "is a hero alias that reparses under its canonical name", - ); - continue; - } - if !member_names.insert(hero_name.clone()) { - self.refuse_settings(member, "is a duplicate hero group"); - } - members.push(( - hero_name.clone(), - self.settings_object(hero_children, &HERO, Some(hero_name.as_str()), &display), - )); - } - for member in rosters { - let SettingsNode::List { elements, .. } = member else { - continue; - }; - for element in elements { - if super::settings::canonical_hero_name(&element.value) != element.value { - self.refuse_settings( - member, - "carries a hero alias that reparses under its canonical name", - ); - break; - } - } - if !member_names.insert(member.name().to_string()) { - self.refuse_settings(member, "is a duplicate hero roster"); - } - members.push(( - member.name().to_string(), - SettingsJson::Array( - elements - .iter() - .map(|element| SettingsJson::String(element.value.clone())) - .collect(), - ), - )); - } - if !general.is_empty() { - if !member_names.insert("general".to_string()) { - self.refuse_settings( - team, - "carries a `general` member the hero-name position cannot express", - ); - } - members.push(( - "general".to_string(), - self.settings_object(general, &TEAM, None, &display), - )); - } - for member in general { - if member.name() == "general" || roster(member) { - self.refuse_settings( - member, - "cannot be reproduced at a team's `general` position", - ); - } - } - entries.push((name.clone(), SettingsJson::Object(members))); - } - SettingsJson::Object(entries) - } - - fn settings_object( - &mut self, - children: &[SettingsNode], - path: &[PathPart<'_>], - hero: Option<&str>, - display: &HashSet, - ) -> SettingsJson { - let mut entries = Vec::new(); - let mut seen = HashSet::new(); - for member in children { - let Some(entry) = self.settings_member(member, path, hero, display) else { - continue; - }; - if !seen.insert(member.name().to_string()) { - self.refuse_settings(member, "is a duplicate settings member"); - } - entries.push(entry); - } - SettingsJson::Object(entries) - } - - fn settings_member( - &mut self, - member: &SettingsNode, - path: &[PathPart<'_>], - hero: Option<&str>, - display: &HashSet, - ) -> Option<(String, SettingsJson)> { - if matches!(path, [PathPart::Part("extensions")]) { - self.refuse_settings( - member, - "is not a presence-only extension; only `#!extension` members survive here", - ); - return None; - } - let name = member.name().to_string(); - match member { - SettingsNode::Number { value, .. } => { - let definition = self.catalogued_member(member, path, hero)?; - if !matches!( - definition.domain(), - SettingValueDomain::Number(_) | SettingValueDomain::Percent(_) - ) { - self.refuse_settings(member, "is a number member under a non-numeric setting"); - return None; - } - Some((name, SettingsJson::Number(format!("{value}")))) - } - SettingsNode::Bool { value, .. } => { - let definition = self.catalogued_member(member, path, hero)?; - if !matches!(definition.domain(), SettingValueDomain::Boolean) { - self.refuse_settings(member, "is a boolean member under a non-boolean setting"); - return None; - } - Some((name, SettingsJson::Bool(*value))) - } - SettingsNode::String { value, .. } => { - let definition = self.catalogued_member(member, path, hero)?; - if !matches!( - definition.domain(), - SettingValueDomain::String | SettingValueDomain::Enum { .. } - ) { - self.refuse_settings(member, "is a string member under a non-string setting"); - return None; - } - if crate::compile_time::has_unbound_formatters(value) { - self.refuse_settings( - member, - "carries a formatter the OPY settings evaluator rejects", - ); - return None; - } - if *value == "afterAGame" && name == "mapRotation" { - return Some((name, SettingsJson::String("afterGame".to_string()))); - } - if name == "mapRotation" && value == "afterGame" { - self.refuse_settings( - member, - "carries a `mapRotation` value the OPY frontend rewrites", - ); - return None; - } - if matches!(path, [PathPart::Part("main")]) - && matches!(name.as_str(), "modeName" | "description") - && super::lowering::escape_bad_words(value) != *value - { - self.refuse_settings( - member, - "is a `main` string the filtered-word escape rewrites", - ); - return None; - } - Some((name, SettingsJson::String(value.clone()))) - } - SettingsNode::List { elements, .. } => { - let definition = self.catalogued_member(member, path, hero)?; - match definition.domain() { - SettingValueDomain::MapList => { - if elements.iter().any(|element| element.variant.is_some()) { - self.refuse_settings(member, "carries a map variant qualifier"); - return None; - } - } - SettingValueDomain::HeroList => { - for element in elements { - if super::settings::canonical_hero_name(&element.value) != element.value - { - self.refuse_settings( - member, - "carries a hero alias that reparses under its canonical name", - ); - return None; - } - } - } - _ => { - self.refuse_settings(member, "is a list member under a non-list setting"); - return None; - } - } - Some(( - name, - SettingsJson::Array( - elements - .iter() - .map(|element| SettingsJson::String(element.value.clone())) - .collect(), - ), - )) - } - SettingsNode::Flag { .. } => { - self.refuse_settings( - member, - "is a presence-only member; only `#!extension` under `extensions` carries one", - ); - None - } - SettingsNode::Raw { value, .. } => { - if self.member_definition(member, path, hero).is_some() - || display.contains(name.as_str()) - || display.contains(format!("{name}:{value}").as_str()) - { - self.refuse_settings( - member, - "reparses under a catalogued display name rather than staying raw", - ); - return None; - } - if !emittable_member_name(name.as_str()) || !emittable_raw_value(value) { - self.refuse_settings(member, "carries text a `name: value` line cannot write"); - return None; - } - Some((name, SettingsJson::String(value.clone()))) - } - SettingsNode::RawValue { value, .. } => { - if !value.is_empty() { - self.refuse_settings(member, "has a value that reparses as a raw member"); - return None; - } - if self.member_definition(member, path, hero).is_some() - || display.contains(name.as_str()) - || !emittable_member_name(name.as_str()) - { - self.refuse_settings( - member, - "reparses under a catalogued display name rather than staying raw", - ); - return None; - } - Some((name, SettingsJson::String(String::new()))) - } - SettingsNode::Group { children, .. } | SettingsNode::RawGroup { children, .. } => { - match self.member_definition(member, path, hero) { - Some(definition) - if matches!( - definition.domain(), - SettingValueDomain::MapList | SettingValueDomain::HeroList - ) => - { - self.refuse_settings( - member, - "is a block under a list setting; it reparses as a list", - ); - None - } - // A boolean-domain member collapses every non-boolean - // value to `enabled: false` upstream-style, so a block - // never reaches the group form. - Some(definition) - if matches!(definition.domain(), SettingValueDomain::Boolean) => - { - self.refuse_settings( - member, - "is a block under a boolean setting; it reparses as `false`", - ); - None - } - Some(_) => Some((name, self.opaque_object(children))), - None => { - if display.contains(name.as_str()) || !emittable_member_name(name.as_str()) - { - self.refuse_settings( - member, - "reparses under a catalogued display name rather than staying raw", - ); - return None; - } - Some((name, self.opaque_object(children))) - } - } - } - SettingsNode::Workshop { .. } => { - self.refuse_settings(member, "has no OPY settings form at a leaf path"); - None - } - } - } - - fn member_definition( - &self, - member: &SettingsNode, - path: &[PathPart<'_>], - hero: Option<&str>, - ) -> Option { - let mut full = path.to_vec(); - full.push(PathPart::Part(member.name())); - let definition = workshop_rs::settings::definition(&full)?; - match hero { - Some(hero) if !super::settings::hero_setting_applies(hero, member.name()) => None, - _ => Some(definition), - } - } - - fn catalogued_member( - &mut self, - member: &SettingsNode, - path: &[PathPart<'_>], - hero: Option<&str>, - ) -> Option { - match self.member_definition(member, path, hero) { - Some(definition) => Some(definition), - None => { - self.refuse_settings( - member, - "is a typed member under an uncatalogued key; it reparses as raw text", - ); - None - } - } - } - - fn opaque_object(&mut self, children: &[SettingsNode]) -> SettingsJson { - let mut entries = Vec::new(); - let mut seen = HashSet::new(); - for member in children { - let entry = match member { - SettingsNode::Raw { name, value, .. } - if emittable_member_name(name) && emittable_raw_value(value) => - { - Some((name.clone(), SettingsJson::String(value.clone()))) - } - SettingsNode::RawValue { name, value, .. } - if value.is_empty() && emittable_member_name(name) => - { - Some((name.clone(), SettingsJson::String(String::new()))) - } - SettingsNode::Group { name, children, .. } - | SettingsNode::RawGroup { name, children, .. } - if emittable_member_name(name) => - { - Some((name.clone(), self.opaque_object(children))) - } - _ => { - self.refuse_settings(member, "has no OPY form inside an opaque settings block"); - None - } - }; - let Some(entry) = entry else { continue }; - if !seen.insert(member.name().to_string()) { - self.refuse_settings(member, "is a duplicate settings member"); - } - entries.push(entry); - } - SettingsJson::Object(entries) - } - - fn opaque_group_object(&mut self, children: &[SettingsNode]) -> SettingsJson { - let mut entries = Vec::new(); - let mut seen = HashSet::new(); - for member in children { - let entry = match member { - SettingsNode::Group { name, children, .. } - | SettingsNode::RawGroup { name, children, .. } - if emittable_member_name(name) => - { - Some((name.clone(), self.opaque_group_object(children))) - } - _ => { - self.refuse_settings( - member, - "has no OPY form inside an uncatalogued settings group", - ); - None - } - }; - let Some(entry) = entry else { continue }; - if !seen.insert(member.name().to_string()) { - self.refuse_settings(member, "is a duplicate settings member"); - } - entries.push(entry); - } - SettingsJson::Object(entries) - } - - fn settings_workshop(&mut self, children: &[SettingsNode]) -> SettingsJson { - let mut entries = Vec::new(); - let mut seen = HashSet::new(); - for member in children { - let entry = match member { - SettingsNode::Raw { name, value, .. } if emittable_member_name(name) => { - let json = if value == "On" { - Some(SettingsJson::Bool(true)) - } else if value == "Off" { - Some(SettingsJson::Bool(false)) - } else if emittable_raw_value(value) && super::settings::is_number_text(value) { - Some(SettingsJson::String(value.clone())) - } else if let Some(inner) = value - .strip_prefix('[') - .and_then(|rest| rest.strip_suffix(']')) - { - if crate::compile_time::has_unbound_formatters(inner) { - None - } else { - Some(SettingsJson::Array(vec![SettingsJson::String( - inner.to_string(), - )])) - } - } else { - None - }; - match json { - Some(json) => Some((name.clone(), json)), - None => { - self.refuse_settings( - member, - "is a `settings.workshop` value OPY cannot write", - ); - None - } - } - } - _ => { - self.refuse_settings( - member, - "is not a `name: value` member inside `settings.workshop`", - ); - None - } - }; - let Some(entry) = entry else { continue }; - if !seen.insert(member.name().to_string()) { - self.refuse_settings(member, "is a duplicate settings member"); - } - entries.push(entry); - } - SettingsJson::Object(entries) - } - - fn refuse_settings(&mut self, member: &SettingsNode, detail: &str) { - self.issue( - "unsupported-settings", - format!("settings member '{}' {detail}", member.name()), - member.span(), - ); - } - fn emit_program(&mut self, layout: &RuleLayout) { let global_order = index_order(&self.program.global_variables); let player_order = index_order(&self.program.player_variables); @@ -1282,21 +531,7 @@ impl<'a> Emitter<'a> { .push_str(emitted(&self.subroutine_emitted, subroutine_name)); self.out.push_str("():\n"); if rule.disabled { - self.out.push_str(" # @Disabled\n"); - if !rule.conditions.is_empty() { - for (condition_index, condition) in rule.conditions.iter().enumerate() { - let scratch = std::mem::take(&mut self.out); - self.out.push_str(" @Condition "); - self.emit_value( - &condition.value, - self.program.condition_span(*rule_index, condition_index), - ); - self.out.push('\n'); - self.flush_commented(scratch, 1); - } - } - self.emit_commented_actions(*rule_index, &rule.actions); - self.out.push_str(" pass\n"); + self.emit_disabled_def_body(*rule_index, rule); } else { self.emit_actions(*rule_index, &rule.actions, 1); if rule.actions.is_empty() { @@ -1319,14 +554,7 @@ impl<'a> Emitter<'a> { } for (condition_index, condition) in rule.conditions.iter().enumerate() { if condition.disabled { - let scratch = std::mem::take(&mut self.out); - self.out.push_str(" @Condition "); - self.emit_value( - &condition.value, - self.program.condition_span(*rule_index, condition_index), - ); - self.out.push('\n'); - self.flush_commented(scratch, 1); + self.emit_commented_condition(*rule_index, condition_index, condition); continue; } self.out.push_str(" @Condition "); @@ -1341,6 +569,31 @@ impl<'a> Emitter<'a> { } } + fn emit_disabled_def_body(&mut self, rule_index: usize, rule: &Rule) { + self.out.push_str(" # @Disabled\n"); + for (condition_index, condition) in rule.conditions.iter().enumerate() { + self.emit_commented_condition(rule_index, condition_index, condition); + } + self.emit_commented_actions(rule_index, &rule.actions); + self.out.push_str(" pass\n"); + } + + fn emit_commented_condition( + &mut self, + rule_index: usize, + condition_index: usize, + condition: &Condition, + ) { + let scratch = std::mem::take(&mut self.out); + self.out.push_str(" @Condition "); + self.emit_value( + &condition.value, + self.program.condition_span(rule_index, condition_index), + ); + self.out.push('\n'); + self.flush_commented(scratch, 1); + } + fn emit_event_annotation(&mut self, rule_index: usize, rule: &Rule) { let (kind, filters) = match &rule.event { Event::Global => ("global", None), @@ -1489,9 +742,11 @@ impl<'a> Emitter<'a> { continue; } let slot = if high { - (0..=127u32).rev().find(|slot| !taken.contains(slot)) + DECLARATION_SLOT_RANGE + .rev() + .find(|slot| !taken.contains(slot)) } else { - (0..=127u32).find(|slot| !taken.contains(slot)) + DECLARATION_SLOT_RANGE.find(|slot| !taken.contains(slot)) }; if slot != Some(index) { self.issues.push(ReconstructIssue { @@ -1885,15 +1140,14 @@ impl<'a> Emitter<'a> { let indent = Self::indent(level); match action { Action::SetGlobalVariable { variable, value } => { - self.out.push_str(&indent); - self.out.push_str(emitted(&self.global_names, variable)); - self.out.push_str(" = "); - self.emit_value( + let target = emitted(&self.global_names, variable).to_string(); + self.emit_source_set_form( + level, + &target, value, self.program .action_argument_span(rule_index, action_index, 0), ); - self.out.push('\n'); } Action::ModifyGlobalVariable { variable, @@ -1919,15 +1173,13 @@ impl<'a> Emitter<'a> { .program .action_argument_span(rule_index, action_index, 0); let target = self.player_variable_target(player, variable, player_span); - self.out.push_str(&indent); - self.out.push_str(&target); - self.out.push_str(" = "); - self.emit_value( + self.emit_source_set_form( + level, + &target, value, self.program .action_argument_span(rule_index, action_index, 1), ); - self.out.push('\n'); } Action::ModifyPlayerVariable { player, @@ -2029,6 +1281,20 @@ impl<'a> Emitter<'a> { format!("{receiver}.{}", emitted(&self.player_names, variable)) } + fn emit_source_set_form( + &mut self, + level: usize, + target: &str, + value: &Value, + span: Option, + ) { + self.out.push_str(&Self::indent(level)); + self.out.push_str(target); + self.out.push_str(" = "); + self.emit_value(value, span); + self.out.push('\n'); + } + fn render_value_as( &mut self, value: &Value, @@ -2704,7 +1970,7 @@ impl<'a> Emitter<'a> { } } } - if policy::variable_args(&entry.id).contains(&index) { + if entry.params.get(index).is_some_and(|param| param.variable) { let is_variable = matches!(arg, Value::GlobalVariable(_) | Value::PlayerVariable { .. }); if !is_variable { @@ -3347,86 +2613,6 @@ fn is_simple_ident(name: &str) -> bool { chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_') } -enum SettingsJson { - Object(Vec<(String, SettingsJson)>), - Array(Vec), - String(String), - Number(String), - Bool(bool), -} - -/// Write `value` as JSONC the settings parser reads back — the layout the -/// pinned OverPy's `JSON.stringify(result, null, 4)` produces: four-space -/// indentation, empty containers inline, and no trailing comma. -fn write_settings_json(out: &mut String, value: &SettingsJson, indent: usize) { - let pad = |out: &mut String| { - for _ in 0..indent { - out.push(' '); - } - }; - match value { - SettingsJson::Object(entries) if entries.is_empty() => out.push_str("{}"), - SettingsJson::Object(entries) => { - out.push_str("{\n"); - for (index, (key, value)) in entries.iter().enumerate() { - pad(out); - for _ in 0..4 { - out.push(' '); - } - out.push('"'); - push_json_escaped(out, key); - out.push_str("\": "); - write_settings_json(out, value, indent + 4); - if index + 1 < entries.len() { - out.push(','); - } - out.push('\n'); - } - pad(out); - out.push('}'); - } - SettingsJson::Array(elements) if elements.is_empty() => out.push_str("[]"), - SettingsJson::Array(elements) => { - out.push_str("[\n"); - for (index, element) in elements.iter().enumerate() { - pad(out); - for _ in 0..4 { - out.push(' '); - } - write_settings_json(out, element, indent + 4); - if index + 1 < elements.len() { - out.push(','); - } - out.push('\n'); - } - pad(out); - out.push(']'); - } - SettingsJson::String(value) => { - out.push('"'); - push_json_escaped(out, value); - out.push('"'); - } - SettingsJson::Number(value) => out.push_str(value), - SettingsJson::Bool(value) => out.push_str(if *value { "true" } else { "false" }), - } -} - -fn push_json_escaped(out: &mut String, text: &str) { - for character in text.chars() { - match character { - '"' => out.push_str("\\\""), - '\\' => out.push_str("\\\\"), - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - '\t' => out.push_str("\\t"), - '\u{8}' => out.push_str("\\b"), - '\u{c}' => out.push_str("\\f"), - other => out.push(other), - } - } -} - fn display_names(prefixes: &[String]) -> HashSet { workshop_rs::settings::definitions() .filter(|definition| { diff --git a/crates/opy-rs/src/compiler/reconstruct/settings.rs b/crates/opy-rs/src/compiler/reconstruct/settings.rs new file mode 100644 index 00000000..c1d4dd2e --- /dev/null +++ b/crates/opy-rs/src/compiler/reconstruct/settings.rs @@ -0,0 +1,433 @@ +use super::settings_json::{SettingsJson, write_settings_json}; +use super::*; + +const REQUIRED_SETTINGS_GROUP: &str = "gamemodes"; + +enum SettingsBlockShape { + Empty, + DirectiveOnlyExtensions, + AuthoredMembers, +} + +impl SettingsBlockShape { + fn of(top: &[(String, SettingsJson)], extensions: &[String]) -> Self { + match (top.is_empty(), extensions.is_empty()) { + (true, true) => Self::Empty, + (true, false) => Self::DirectiveOnlyExtensions, + (false, _) => Self::AuthoredMembers, + } + } +} + +impl<'a> Emitter<'a> { + pub(super) fn emit_settings(&mut self) { + let Some(settings) = self.program.settings.as_ref() else { + return; + }; + let mut unemittable = false; + for error in workshop_rs::settings::check_emission(settings) { + unemittable = true; + self.issue("unsupported-settings", error.to_string(), error.span()); + } + if unemittable { + return; + } + + let mut top: Vec<(String, SettingsJson)> = Vec::new(); + let mut extensions: Vec = Vec::new(); + let mut seen = HashSet::new(); + let last = settings.children.len().saturating_sub(1); + for (index, child) in settings.children.iter().enumerate() { + if !seen.insert(child.name().to_string()) { + self.refuse_settings(child, "is a duplicate top-level group"); + } + match child { + SettingsNode::Workshop { children, .. } => { + top.push(("workshop".to_string(), self.settings_workshop(children))); + } + SettingsNode::Group { name, children, .. } + | SettingsNode::RawGroup { name, children, .. } => match name.as_str() { + "workshop" => self.refuse_settings( + child, + "has no OPY settings form; `workshop` reparses as workshop settings", + ), + "main" | "lobby" => { + let display = display_names(&[format!("{name}.")]); + let path = [PathPart::Part(name.as_str())]; + top.push(( + name.clone(), + self.settings_object(children, &path, None, &display), + )); + } + "extensions" => { + let flags = children + .iter() + .position(|member| matches!(member, SettingsNode::Flag { .. })); + match flags { + None => { + let display = display_names(&["extensions.".to_string()]); + top.push(( + name.clone(), + self.settings_object( + children, + &[PathPart::Part("extensions")], + None, + &display, + ), + )); + } + Some(first) => { + let mut flag_names = HashSet::new(); + for member in &children[first..] { + let SettingsNode::Flag { name, .. } = member else { + self.refuse_settings( + member, + "follows a presence-only member in `extensions`", + ); + continue; + }; + if !flag_names.insert(name.clone()) { + self.refuse_settings( + member, + "is a duplicate `#!extension` name", + ); + } + let path = [ + PathPart::Part("extensions"), + PathPart::Part(name.as_str()), + ]; + match workshop_rs::settings::definition(&path) { + Some(definition) + if matches!( + definition.domain(), + SettingValueDomain::PresenceOnly + ) => + { + extensions.push(name.clone()); + } + _ => self.refuse_settings( + member, + "is not a declared Workshop extension", + ), + } + } + if first == 0 { + if index != last { + self.refuse_settings( + child, + "carries only `#!extension` directives outside the last group", + ); + } + } else { + let display = display_names(&["extensions.".to_string()]); + top.push(( + name.clone(), + self.settings_object( + &children[..first], + &[PathPart::Part("extensions")], + None, + &display, + ), + )); + } + } + } + } + "gamemodes" => { + top.push(("gamemodes".to_string(), self.settings_modes(children))); + } + "heroes" => { + top.push(("heroes".to_string(), self.settings_teams(children))); + } + _ => { + top.push((name.clone(), self.opaque_group_object(children))); + } + }, + member => self.refuse_settings( + member, + "cannot appear at the settings top level; groups are expected", + ), + } + } + + match SettingsBlockShape::of(&top, &extensions) { + SettingsBlockShape::Empty => { + self.issue( + "unsupported-settings", + "an empty settings block has no OPY form", + settings.span, + ); + return; + } + SettingsBlockShape::DirectiveOnlyExtensions => {} + SettingsBlockShape::AuthoredMembers => { + if !top.iter().any(|(key, _)| key == REQUIRED_SETTINGS_GROUP) { + self.issue( + "unsupported-settings", + "settings has no `gamemodes` group, which the OPY frontend requires", + settings.span, + ); + return; + } + } + } + if !top.is_empty() { + self.out.push_str("settings "); + write_settings_json(&mut self.out, &SettingsJson::Object(top), 0); + self.out.push_str("\n\n"); + } + for name in &extensions { + self.out.push_str("#!extension "); + self.out.push_str(name); + self.out.push('\n'); + } + if !extensions.is_empty() { + self.out.push('\n'); + } + } + + fn settings_modes(&mut self, children: &[SettingsNode]) -> SettingsJson { + let mut entries = Vec::new(); + let mut seen = HashSet::new(); + for mode in children { + let (name, children) = match mode { + SettingsNode::Group { name, children, .. } + | SettingsNode::RawGroup { name, children, .. } => (name, children), + _ => { + self.refuse_settings( + mode, + "cannot appear inside `gamemodes`; groups are expected", + ); + continue; + } + }; + if !seen.insert(name.clone()) { + self.refuse_settings(mode, "is a duplicate mode group"); + } + let display = display_names(&[ + format!("gamemodes.{name}."), + "gamemodes.general.".to_string(), + ]); + let path = [PathPart::Part("gamemodes"), PathPart::Part(name.as_str())]; + let mut members = Vec::new(); + let mut member_names = HashSet::new(); + for (index, member) in children.iter().enumerate() { + if member.name() == "enabled" { + match (index, member) { + (0, SettingsNode::Bool { value: false, .. }) => { + members.push(("enabled".to_string(), SettingsJson::Bool(false))); + member_names.insert("enabled".to_string()); + } + _ => self.refuse_settings( + member, + "is an `enabled` marker only `enabled: false` in first position reproduces", + ), + } + continue; + } + let Some((key, json)) = self.settings_member(member, &path, None, &display) else { + continue; + }; + if !member_names.insert(key.clone()) { + self.refuse_settings(member, "is a duplicate settings member"); + } + members.push((key, json)); + } + entries.push((name.clone(), SettingsJson::Object(members))); + } + SettingsJson::Object(entries) + } + + fn settings_teams(&mut self, children: &[SettingsNode]) -> SettingsJson { + const TEAM: [PathPart<'static>; 2] = [PathPart::Part("heroes"), PathPart::Team]; + const HERO: [PathPart<'static>; 3] = + [PathPart::Part("heroes"), PathPart::Team, PathPart::Hero]; + let mut entries = Vec::new(); + let mut seen = HashSet::new(); + for team in children { + let (name, children) = match team { + SettingsNode::Group { name, children, .. } + | SettingsNode::RawGroup { name, children, .. } => (name, children), + _ => { + self.refuse_settings( + team, + "cannot appear inside `heroes`; groups are expected", + ); + continue; + } + }; + if !seen.insert(name.clone()) { + self.refuse_settings(team, "is a duplicate team group"); + } + let roster = |member: &SettingsNode| { + matches!(member, SettingsNode::List { name, .. } if matches!( + name.as_str(), + "enabledHeroes" | "disabledHeroes" + )) + }; + let rest_at = children + .iter() + .position(|member| matches!(member, SettingsNode::Group { .. }) || roster(member)) + .unwrap_or(children.len()); + let rosters_at = children[rest_at..] + .iter() + .position(roster) + .map(|index| rest_at + index) + .unwrap_or(children.len()); + let general = &children[..rest_at]; + let rest = &children[rest_at..rosters_at]; + let rosters = &children[rosters_at..]; + for member in rest { + if roster(member) { + self.refuse_settings( + member, + "is a hero roster before the last hero group; rosters reparse last", + ); + } + } + for member in rosters { + if !roster(member) { + self.refuse_settings( + member, + "follows a hero roster; team `general` members reparse first", + ); + } + } + if rosters + .iter() + .filter(|member| member.name() == "enabledHeroes") + .count() + + rosters + .iter() + .filter(|member| member.name() == "disabledHeroes") + .count() + > 1 + { + self.refuse_settings( + team, + "lists both `enabledHeroes` and `disabledHeroes`, which the frontend rejects", + ); + } + + let display = display_names(&[ + "heroes..".to_string(), + "heroes.general.".to_string(), + "heroes.team1.".to_string(), + "heroes.team2.".to_string(), + "heroes.allTeams.".to_string(), + ]); + let mut members = Vec::new(); + let mut member_names = HashSet::new(); + for member in rest { + let (hero_name, hero_children) = match member { + SettingsNode::Group { name, children, .. } + | SettingsNode::RawGroup { name, children, .. } => (name, children), + _ => { + self.refuse_settings( + member, + "is a non-group member among hero groups; it would reparse as an empty group", + ); + continue; + } + }; + if !crate::compiler::settings::resolves_to_hero(hero_name) { + self.refuse_settings(member, "is not a declared hero"); + continue; + } + if crate::compiler::settings::canonical_hero_name(hero_name) != hero_name.as_str() { + self.refuse_settings( + member, + "is a hero alias that reparses under its canonical name", + ); + continue; + } + if !member_names.insert(hero_name.clone()) { + self.refuse_settings(member, "is a duplicate hero group"); + } + members.push(( + hero_name.clone(), + self.settings_object(hero_children, &HERO, Some(hero_name.as_str()), &display), + )); + } + for member in rosters { + let SettingsNode::List { elements, .. } = member else { + continue; + }; + for element in elements { + if crate::compiler::settings::canonical_hero_name(&element.value) + != element.value + { + self.refuse_settings( + member, + "carries a hero alias that reparses under its canonical name", + ); + break; + } + } + if !member_names.insert(member.name().to_string()) { + self.refuse_settings(member, "is a duplicate hero roster"); + } + members.push(( + member.name().to_string(), + SettingsJson::Array( + elements + .iter() + .map(|element| SettingsJson::String(element.value.clone())) + .collect(), + ), + )); + } + if !general.is_empty() { + if !member_names.insert("general".to_string()) { + self.refuse_settings( + team, + "carries a `general` member the hero-name position cannot express", + ); + } + members.push(( + "general".to_string(), + self.settings_object(general, &TEAM, None, &display), + )); + } + for member in general { + if member.name() == "general" || roster(member) { + self.refuse_settings( + member, + "cannot be reproduced at a team's `general` position", + ); + } + } + entries.push((name.clone(), SettingsJson::Object(members))); + } + SettingsJson::Object(entries) + } + + fn settings_object( + &mut self, + children: &[SettingsNode], + path: &[PathPart<'_>], + hero: Option<&str>, + display: &HashSet, + ) -> SettingsJson { + let mut entries = Vec::new(); + let mut seen = HashSet::new(); + for member in children { + let Some(entry) = self.settings_member(member, path, hero, display) else { + continue; + }; + if !seen.insert(member.name().to_string()) { + self.refuse_settings(member, "is a duplicate settings member"); + } + entries.push(entry); + } + SettingsJson::Object(entries) + } + + pub(super) fn refuse_settings(&mut self, member: &SettingsNode, detail: &str) { + self.issue( + "unsupported-settings", + format!("settings member '{}' {detail}", member.name()), + member.span(), + ); + } +} diff --git a/crates/opy-rs/src/compiler/reconstruct/settings_json.rs b/crates/opy-rs/src/compiler/reconstruct/settings_json.rs new file mode 100644 index 00000000..ea14ff39 --- /dev/null +++ b/crates/opy-rs/src/compiler/reconstruct/settings_json.rs @@ -0,0 +1,435 @@ +use super::*; + +pub(super) enum SettingsJson { + Object(Vec<(String, SettingsJson)>), + Array(Vec), + String(String), + Number(String), + Bool(bool), +} + +/// Write `value` as JSONC the settings parser reads back — the layout the +/// pinned OverPy's `JSON.stringify(result, null, 4)` produces: four-space +/// indentation, empty containers inline, and no trailing comma. +pub(super) fn write_settings_json(out: &mut String, value: &SettingsJson, indent: usize) { + let pad = |out: &mut String| { + for _ in 0..indent { + out.push(' '); + } + }; + match value { + SettingsJson::Object(entries) if entries.is_empty() => out.push_str("{}"), + SettingsJson::Object(entries) => { + out.push_str("{\n"); + for (index, (key, value)) in entries.iter().enumerate() { + pad(out); + for _ in 0..4 { + out.push(' '); + } + out.push('"'); + push_json_escaped(out, key); + out.push_str("\": "); + write_settings_json(out, value, indent + 4); + if index + 1 < entries.len() { + out.push(','); + } + out.push('\n'); + } + pad(out); + out.push('}'); + } + SettingsJson::Array(elements) if elements.is_empty() => out.push_str("[]"), + SettingsJson::Array(elements) => { + out.push_str("[\n"); + for (index, element) in elements.iter().enumerate() { + pad(out); + for _ in 0..4 { + out.push(' '); + } + write_settings_json(out, element, indent + 4); + if index + 1 < elements.len() { + out.push(','); + } + out.push('\n'); + } + pad(out); + out.push(']'); + } + SettingsJson::String(value) => { + out.push('"'); + push_json_escaped(out, value); + out.push('"'); + } + SettingsJson::Number(value) => out.push_str(value), + SettingsJson::Bool(value) => out.push_str(if *value { "true" } else { "false" }), + } +} + +fn push_json_escaped(out: &mut String, text: &str) { + for character in text.chars() { + match character { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + '\u{8}' => out.push_str("\\b"), + '\u{c}' => out.push_str("\\f"), + other => out.push(other), + } + } +} + +impl<'a> Emitter<'a> { + pub(super) fn settings_member( + &mut self, + member: &SettingsNode, + path: &[PathPart<'_>], + hero: Option<&str>, + display: &HashSet, + ) -> Option<(String, SettingsJson)> { + if matches!(path, [PathPart::Part("extensions")]) { + self.refuse_settings( + member, + "is not a presence-only extension; only `#!extension` members survive here", + ); + return None; + } + let name = member.name().to_string(); + match member { + SettingsNode::Number { value, .. } => { + let definition = self.catalogued_member(member, path, hero)?; + if !matches!( + definition.domain(), + SettingValueDomain::Number(_) | SettingValueDomain::Percent(_) + ) { + self.refuse_settings(member, "is a number member under a non-numeric setting"); + return None; + } + Some((name, SettingsJson::Number(format!("{value}")))) + } + SettingsNode::Bool { value, .. } => { + let definition = self.catalogued_member(member, path, hero)?; + if !matches!(definition.domain(), SettingValueDomain::Boolean) { + self.refuse_settings(member, "is a boolean member under a non-boolean setting"); + return None; + } + Some((name, SettingsJson::Bool(*value))) + } + SettingsNode::String { value, .. } => { + let definition = self.catalogued_member(member, path, hero)?; + if !matches!( + definition.domain(), + SettingValueDomain::String | SettingValueDomain::Enum { .. } + ) { + self.refuse_settings(member, "is a string member under a non-string setting"); + return None; + } + if crate::compile_time::has_unbound_formatters(value) { + self.refuse_settings( + member, + "carries a formatter the OPY settings evaluator rejects", + ); + return None; + } + if *value == "afterAGame" && name == "mapRotation" { + return Some((name, SettingsJson::String("afterGame".to_string()))); + } + if name == "mapRotation" && value == "afterGame" { + self.refuse_settings( + member, + "carries a `mapRotation` value the OPY frontend rewrites", + ); + return None; + } + if matches!(path, [PathPart::Part("main")]) + && matches!(name.as_str(), "modeName" | "description") + && crate::compiler::lowering::escape_bad_words(value) != *value + { + self.refuse_settings( + member, + "is a `main` string the filtered-word escape rewrites", + ); + return None; + } + Some((name, SettingsJson::String(value.clone()))) + } + SettingsNode::List { elements, .. } => { + let definition = self.catalogued_member(member, path, hero)?; + match definition.domain() { + SettingValueDomain::MapList => { + if elements.iter().any(|element| element.variant.is_some()) { + self.refuse_settings(member, "carries a map variant qualifier"); + return None; + } + } + SettingValueDomain::HeroList => { + for element in elements { + if crate::compiler::settings::canonical_hero_name(&element.value) + != element.value + { + self.refuse_settings( + member, + "carries a hero alias that reparses under its canonical name", + ); + return None; + } + } + } + _ => { + self.refuse_settings(member, "is a list member under a non-list setting"); + return None; + } + } + Some(( + name, + SettingsJson::Array( + elements + .iter() + .map(|element| SettingsJson::String(element.value.clone())) + .collect(), + ), + )) + } + SettingsNode::Flag { .. } => { + self.refuse_settings( + member, + "is a presence-only member; only `#!extension` under `extensions` carries one", + ); + None + } + SettingsNode::Raw { value, .. } => { + if self.member_definition(member, path, hero).is_some() + || display.contains(name.as_str()) + || display.contains(format!("{name}:{value}").as_str()) + { + self.refuse_settings( + member, + "reparses under a catalogued display name rather than staying raw", + ); + return None; + } + if !emittable_member_name(name.as_str()) || !emittable_raw_value(value) { + self.refuse_settings(member, "carries text a `name: value` line cannot write"); + return None; + } + Some((name, SettingsJson::String(value.clone()))) + } + SettingsNode::RawValue { value, .. } => { + if !value.is_empty() { + self.refuse_settings(member, "has a value that reparses as a raw member"); + return None; + } + if self.member_definition(member, path, hero).is_some() + || display.contains(name.as_str()) + || !emittable_member_name(name.as_str()) + { + self.refuse_settings( + member, + "reparses under a catalogued display name rather than staying raw", + ); + return None; + } + Some((name, SettingsJson::String(String::new()))) + } + SettingsNode::Group { children, .. } | SettingsNode::RawGroup { children, .. } => { + match self.member_definition(member, path, hero) { + Some(definition) + if matches!( + definition.domain(), + SettingValueDomain::MapList | SettingValueDomain::HeroList + ) => + { + self.refuse_settings( + member, + "is a block under a list setting; it reparses as a list", + ); + None + } + // A boolean-domain member collapses every non-boolean + // value to `enabled: false` upstream-style, so a block + // never reaches the group form. + Some(definition) + if matches!(definition.domain(), SettingValueDomain::Boolean) => + { + self.refuse_settings( + member, + "is a block under a boolean setting; it reparses as `false`", + ); + None + } + Some(_) => Some((name, self.opaque_object(children))), + None => { + if display.contains(name.as_str()) || !emittable_member_name(name.as_str()) + { + self.refuse_settings( + member, + "reparses under a catalogued display name rather than staying raw", + ); + return None; + } + Some((name, self.opaque_object(children))) + } + } + } + SettingsNode::Workshop { .. } => { + self.refuse_settings(member, "has no OPY settings form at a leaf path"); + None + } + } + } + + fn member_definition( + &self, + member: &SettingsNode, + path: &[PathPart<'_>], + hero: Option<&str>, + ) -> Option { + let mut full = path.to_vec(); + full.push(PathPart::Part(member.name())); + let definition = workshop_rs::settings::definition(&full)?; + match hero { + Some(hero) if !crate::compiler::settings::hero_setting_applies(hero, member.name()) => { + None + } + _ => Some(definition), + } + } + + fn catalogued_member( + &mut self, + member: &SettingsNode, + path: &[PathPart<'_>], + hero: Option<&str>, + ) -> Option { + match self.member_definition(member, path, hero) { + Some(definition) => Some(definition), + None => { + self.refuse_settings( + member, + "is a typed member under an uncatalogued key; it reparses as raw text", + ); + None + } + } + } + + pub(super) fn opaque_object(&mut self, children: &[SettingsNode]) -> SettingsJson { + let mut entries = Vec::new(); + let mut seen = HashSet::new(); + for member in children { + let entry = match member { + SettingsNode::Raw { name, value, .. } + if emittable_member_name(name) && emittable_raw_value(value) => + { + Some((name.clone(), SettingsJson::String(value.clone()))) + } + SettingsNode::RawValue { name, value, .. } + if value.is_empty() && emittable_member_name(name) => + { + Some((name.clone(), SettingsJson::String(String::new()))) + } + SettingsNode::Group { name, children, .. } + | SettingsNode::RawGroup { name, children, .. } + if emittable_member_name(name) => + { + Some((name.clone(), self.opaque_object(children))) + } + _ => { + self.refuse_settings(member, "has no OPY form inside an opaque settings block"); + None + } + }; + let Some(entry) = entry else { continue }; + if !seen.insert(member.name().to_string()) { + self.refuse_settings(member, "is a duplicate settings member"); + } + entries.push(entry); + } + SettingsJson::Object(entries) + } + + pub(super) fn opaque_group_object(&mut self, children: &[SettingsNode]) -> SettingsJson { + let mut entries = Vec::new(); + let mut seen = HashSet::new(); + for member in children { + let entry = match member { + SettingsNode::Group { name, children, .. } + | SettingsNode::RawGroup { name, children, .. } + if emittable_member_name(name) => + { + Some((name.clone(), self.opaque_group_object(children))) + } + _ => { + self.refuse_settings( + member, + "has no OPY form inside an uncatalogued settings group", + ); + None + } + }; + let Some(entry) = entry else { continue }; + if !seen.insert(member.name().to_string()) { + self.refuse_settings(member, "is a duplicate settings member"); + } + entries.push(entry); + } + SettingsJson::Object(entries) + } + + pub(super) fn settings_workshop(&mut self, children: &[SettingsNode]) -> SettingsJson { + let mut entries = Vec::new(); + let mut seen = HashSet::new(); + for member in children { + let entry = match member { + SettingsNode::Raw { name, value, .. } if emittable_member_name(name) => { + let json = if value == "On" { + Some(SettingsJson::Bool(true)) + } else if value == "Off" { + Some(SettingsJson::Bool(false)) + } else if emittable_raw_value(value) + && crate::compiler::settings::is_number_text(value) + { + Some(SettingsJson::String(value.clone())) + } else if let Some(inner) = value + .strip_prefix('[') + .and_then(|rest| rest.strip_suffix(']')) + { + if crate::compile_time::has_unbound_formatters(inner) { + None + } else { + Some(SettingsJson::Array(vec![SettingsJson::String( + inner.to_string(), + )])) + } + } else { + None + }; + match json { + Some(json) => Some((name.clone(), json)), + None => { + self.refuse_settings( + member, + "is a `settings.workshop` value OPY cannot write", + ); + None + } + } + } + _ => { + self.refuse_settings( + member, + "is not a `name: value` member inside `settings.workshop`", + ); + None + } + }; + let Some(entry) = entry else { continue }; + if !seen.insert(member.name().to_string()) { + self.refuse_settings(member, "is a duplicate settings member"); + } + entries.push(entry); + } + SettingsJson::Object(entries) + } +} diff --git a/crates/opy-rs/src/compiler/tests/structural_convergence.rs b/crates/opy-rs/src/compiler/tests/structural_convergence.rs index 0f0e6998..749a422a 100644 --- a/crates/opy-rs/src/compiler/tests/structural_convergence.rs +++ b/crates/opy-rs/src/compiler/tests/structural_convergence.rs @@ -170,7 +170,7 @@ fn size_optimization_action_rewrites_match_the_pinned_oracle() { } #[test] -fn context_player_components_stay_rejected_under_canonical_validation() { +fn context_player_components_reject_as_expected_pending_workshop_admission() { let dir = Path::new(env!("CARGO_MANIFEST_DIR")) .join("tests/fixtures/corpus/synthetic/context-player-components"); let source = std::fs::read_to_string(dir.join("source.opy")).expect("source must be readable"); diff --git a/crates/opy-rs/src/lib.rs b/crates/opy-rs/src/lib.rs index 90783393..890e41cc 100644 --- a/crates/opy-rs/src/lib.rs +++ b/crates/opy-rs/src/lib.rs @@ -184,12 +184,17 @@ pub fn compile_with_overlay_outcome_with_locale( source: hook.source, span: Some(hook.span), }); - CompileOutcome { + let outcome = CompileOutcome { hir: outcome.model.map(|model| model.hir), error, diagnostics: outcome.diagnostics, files: outcome.files, display_root: outcome.display_root, post_compile_hook, - } + }; + debug_assert!( + outcome.hir.is_some() || outcome.error.is_some(), + "a failed check always carries an error" + ); + outcome } diff --git a/crates/opy-rs/src/lookup.rs b/crates/opy-rs/src/lookup.rs index cdf7e419..dc0df2c5 100644 --- a/crates/opy-rs/src/lookup.rs +++ b/crates/opy-rs/src/lookup.rs @@ -1340,18 +1340,6 @@ mod tests { function.id ); } - let reported: Vec<&str> = SPECIAL_FUNCTIONS - .iter() - .map(|special| special.name) - .collect(); - let dispatched: Vec<&str> = crate::lower::special_forms::SPECIAL_VALUE_CALLS - .iter() - .map(|(name, _)| *name) - .collect(); - assert_eq!( - reported, dispatched, - "reported special-call spellings must be the dispatched set" - ); for special in SPECIAL_FUNCTIONS { let source = format!( "globalvar x\nrule \"r\":\n @Event global\n x = {}()\n", diff --git a/crates/opy-rs/src/lower.rs b/crates/opy-rs/src/lower.rs index c0c28a92..dd6a9153 100644 --- a/crates/opy-rs/src/lower.rs +++ b/crates/opy-rs/src/lower.rs @@ -2201,7 +2201,7 @@ mod tests { } #[test] - fn special_enforcement_is_typed_policy_not_manifest_metadata() { + fn receiver_and_variable_enforcement_is_manifest_metadata() { let mut file: serde_json::Value = serde_json::from_str(crate::manifest::MANIFEST_DATA).unwrap(); for function in file["functions"].as_array_mut().unwrap() { @@ -2232,31 +2232,26 @@ mod tests { "chaseAtRate(10, 0, 2, ChaseRateReeval.NONE)", "chaseOverTime(10, 0, 30, ChaseTimeReeval.NONE)", ] { - let error = lower_with_manifest(&action_source(call), manifest) - .expect_err("the chase family still requires a variable"); - assert_eq!(error.code, "invalid-argument", "{call}"); + lower_with_manifest(&action_source(call), manifest).unwrap_or_else(|error| { + panic!("removed `variable` metadata lifts the requirement: {error}") + }); } - lower_with_manifest(&action_source("chaseOverTime(g, 10, 3)"), manifest) - .expect("a variable first argument compiles"); for call in ["3.append(1)", "3.remove(1)", "print(3.format(\"{}\"))"] { - let error = lower_with_manifest(&action_source(call), manifest) - .expect_err("the enforced receiver requirement still rejects"); - assert_eq!(error.code, "invalid-receiver", "{call}"); + lower_with_manifest(&action_source(call), manifest) + .unwrap_or_else(|error| panic!("`Any` receiver lifts the requirement: {error}")); } - lower_with_manifest(&action_source("g.append(1)"), manifest) - .expect("an assignable receiver compiles"); - lower_with_manifest(&action_source("g = \"{}\".format(1)"), manifest) - .expect("a string-literal receiver compiles"); - - lower_with_manifest(&action_source("wait(1)"), manifest) - .expect("`wait`'s first argument is not variable-required"); - lower_with_manifest( + + let error = lower_with_manifest(&action_source("wait(1)"), manifest) + .expect_err("`wait`'s first argument is variable-required by metadata"); + assert_eq!(error.code, "invalid-argument"); + let error = lower_with_manifest( "globalvar g\nrule \"r\":\n @Event eachPlayer\n \ g = eventPlayer.getPosition()\n", manifest, ) - .expect("`getPosition`'s receiver is not assignable-enforced"); + .expect_err("`getPosition`'s receiver is assignable-enforced by metadata"); + assert_eq!(error.code, "invalid-receiver"); } #[test] diff --git a/crates/opy-rs/src/lower/expressions.rs b/crates/opy-rs/src/lower/expressions.rs index d465e01a..3f252bad 100644 --- a/crates/opy-rs/src/lower/expressions.rs +++ b/crates/opy-rs/src/lower/expressions.rs @@ -1,4 +1,5 @@ use super::*; +use crate::manifest::ReceiverCategory; impl Lowerer { pub(super) fn lower_expr( @@ -1142,7 +1143,7 @@ impl Lowerer { bound.push(nested); } - for &index in policy::variable_args(&entry.id) { + for index in entry.variable_param_indices() { if let Some(Some(value)) = slots.get(index) && !matches!(value, HirExpr::GlobalVar { .. } | HirExpr::PlayerVar { .. }) { @@ -1439,10 +1440,11 @@ impl Lowerer { position: CallPosition, span: Span, ) { + let for_iterable = + policy::function_context(&entry.id) == Some(policy::FunctionContext::ForIterable); match position { CallPosition::Statement => { - if policy::function_context(&entry.id) == Some(policy::FunctionContext::ForIterable) - { + if for_iterable { self.error_at( "invalid-call-context", format!("'{name}' is only valid as a for-loop iterable"), @@ -1463,9 +1465,7 @@ impl Lowerer { format!("action function '{name}' cannot be used as a value"), span, ); - } else if policy::function_context(&entry.id) - == Some(policy::FunctionContext::ForIterable) - { + } else if for_iterable { self.error_at( "invalid-call-context", format!("'{name}' is only valid as a for-loop iterable"), @@ -1474,8 +1474,7 @@ impl Lowerer { } } CallPosition::ForIterable => { - if policy::function_context(&entry.id) != Some(policy::FunctionContext::ForIterable) - { + if !for_iterable { self.error_at( "invalid-iterable", format!("for-loop iterable '{name}' must be a range(...) call"), @@ -1488,14 +1487,15 @@ impl Lowerer { } pub(super) fn check_receiver(&mut self, receiver: &Expr, entry: &Function, span: Span) { - let Some(requirement) = policy::member_receiver_requirement(&entry.id) else { + let Some(requirement) = entry.receiver.and_then(ReceiverCategory::enforced) else { return; }; let mismatch = match requirement { - policy::ReceiverRequirement::StringLiteral => { + ReceiverCategory::String => { !matches!(receiver, Expr::String { .. } | Expr::StringModifier { .. }) } - policy::ReceiverRequirement::Assignable => !assignable_receiver(receiver), + ReceiverCategory::Variable => !assignable_receiver(receiver), + _ => return, }; if mismatch { self.error_at( diff --git a/crates/opy-rs/src/lower/policy.rs b/crates/opy-rs/src/lower/policy.rs index 1b725c13..15dfa033 100644 --- a/crates/opy-rs/src/lower/policy.rs +++ b/crates/opy-rs/src/lower/policy.rs @@ -50,32 +50,6 @@ pub(crate) fn function_context(function_id: &str) -> Option { (function_id == "range").then_some(FunctionContext::ForIterable) } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum ReceiverRequirement { - Assignable, - StringLiteral, -} - -impl ReceiverRequirement { - pub(crate) fn describe(self) -> &'static str { - match self { - ReceiverRequirement::Assignable => "an assignable variable", - ReceiverRequirement::StringLiteral => "a string literal", - } - } -} - -/// The receiver requirement a member enforces, if any. Members without an -/// entry accept any receiver: the pinned reference does not type-check -/// player-oriented receivers. -pub(crate) fn member_receiver_requirement(member_id: &str) -> Option { - match member_id { - "append" | "remove" => Some(ReceiverRequirement::Assignable), - "format" => Some(ReceiverRequirement::StringLiteral), - _ => None, - } -} - /// `min`/`max` are the pinned reference's variadic builtins: positional /// arguments past the declared pair re-associate into nested two-argument /// calls upstream rather than hitting the arity rejection every other @@ -84,13 +58,6 @@ pub(crate) fn variadic(function_id: &str) -> bool { matches!(function_id, "min" | "max") } -pub(crate) fn variable_args(function_id: &str) -> &'static [usize] { - match function_id { - "chase" | "chaseAtRate" | "chaseOverTime" => &[0], - _ => &[], - } -} - pub(crate) fn validate(function: &Function) -> Result<(), String> { let Some(contextual) = contextual_domain(&function.id) else { return Ok(()); diff --git a/crates/opy-rs/src/lower/special_forms.rs b/crates/opy-rs/src/lower/special_forms.rs index 980e3327..d4c42529 100644 --- a/crates/opy-rs/src/lower/special_forms.rs +++ b/crates/opy-rs/src/lower/special_forms.rs @@ -9,33 +9,9 @@ pub(crate) enum SpecialValueCall { SettingFactory, } -pub(crate) const SPECIAL_VALUE_CALLS: &[(&str, SpecialValueCall)] = &[ - ("sorted", SpecialValueCall::Sorted), - ( - "createWorkshopSetting", - SpecialValueCall::CreateWorkshopSetting, - ), - ( - "createWorkshopSettingBool", - SpecialValueCall::SettingFactory, - ), - ( - "createWorkshopSettingEnum", - SpecialValueCall::SettingFactory, - ), - ("createWorkshopSettingInt", SpecialValueCall::SettingFactory), - ( - "createWorkshopSettingFloat", - SpecialValueCall::SettingFactory, - ), - ( - "createWorkshopSettingHero", - SpecialValueCall::SettingFactory, - ), -]; - pub(crate) struct SpecialFunction { pub(crate) name: &'static str, + pub(crate) call: SpecialValueCall, pub(crate) params: &'static [SpecialParam], } @@ -80,6 +56,7 @@ const fn optional(name: &'static str, param_type: &'static str) -> SpecialParam pub(crate) const SPECIAL_FUNCTIONS: &[SpecialFunction] = &[ SpecialFunction { name: "sorted", + call: SpecialValueCall::Sorted, params: &[ required("array", "Array"), // The reference's signature names the slot `lambda`; `key` is @@ -89,6 +66,7 @@ pub(crate) const SPECIAL_FUNCTIONS: &[SpecialFunction] = &[ }, SpecialFunction { name: "createWorkshopSetting", + call: SpecialValueCall::CreateWorkshopSetting, params: &[ required("type", "Type"), required("category", "CustomStringLiteral"), @@ -99,6 +77,7 @@ pub(crate) const SPECIAL_FUNCTIONS: &[SpecialFunction] = &[ }, SpecialFunction { name: "createWorkshopSettingBool", + call: SpecialValueCall::SettingFactory, params: &[ required("category", "CustomStringLiteral"), required("name", "CustomStringLiteral"), @@ -108,6 +87,7 @@ pub(crate) const SPECIAL_FUNCTIONS: &[SpecialFunction] = &[ }, SpecialFunction { name: "createWorkshopSettingEnum", + call: SpecialValueCall::SettingFactory, params: &[ required("category", "CustomStringLiteral"), required("name", "CustomStringLiteral"), @@ -118,6 +98,7 @@ pub(crate) const SPECIAL_FUNCTIONS: &[SpecialFunction] = &[ }, SpecialFunction { name: "createWorkshopSettingInt", + call: SpecialValueCall::SettingFactory, params: &[ required("category", "CustomStringLiteral"), required("name", "CustomStringLiteral"), @@ -129,6 +110,7 @@ pub(crate) const SPECIAL_FUNCTIONS: &[SpecialFunction] = &[ }, SpecialFunction { name: "createWorkshopSettingFloat", + call: SpecialValueCall::SettingFactory, params: &[ required("category", "CustomStringLiteral"), required("name", "CustomStringLiteral"), @@ -140,6 +122,7 @@ pub(crate) const SPECIAL_FUNCTIONS: &[SpecialFunction] = &[ }, SpecialFunction { name: "createWorkshopSettingHero", + call: SpecialValueCall::SettingFactory, params: &[ required("category", "CustomStringLiteral"), required("name", "CustomStringLiteral"), @@ -151,10 +134,10 @@ pub(crate) const SPECIAL_FUNCTIONS: &[SpecialFunction] = &[ impl SpecialValueCall { pub(crate) fn from_name(name: &str) -> Option { - SPECIAL_VALUE_CALLS + SPECIAL_FUNCTIONS .iter() - .find(|(spelling, _)| *spelling == name) - .map(|(_, special)| *special) + .find(|function| function.name == name) + .map(|function| function.call) } } diff --git a/crates/opy-rs/src/macro_js/tests/isolation.rs b/crates/opy-rs/src/macro_js/tests/isolation.rs index 41f4cbac..f4da408b 100644 --- a/crates/opy-rs/src/macro_js/tests/isolation.rs +++ b/crates/opy-rs/src/macro_js/tests/isolation.rs @@ -212,6 +212,28 @@ fn many_consecutive_invocations_stay_independent() { } } +#[test] +fn scripts_see_no_filesystem_process_or_network_capabilities() { + let rt = runtime(); + let result = rt + .run_macro( + r#"var capabilities = [ + typeof std, typeof os, typeof require, typeof process, + typeof Deno, typeof Bun, + typeof fetch, typeof XMLHttpRequest, typeof WebSocket, + ]; + var module_load = "threw"; + try { require("fs"); module_load = "resolved"; } catch (error) {} + (capabilities.every(function(capability) { + return capability === "undefined"; + }) && module_load === "threw").toString();"#, + &[], + "host-capabilities.js", + ) + .unwrap(); + assert_eq!(result.text, "true"); +} + #[test] fn builtin_intrinsics_are_fully_reset() { let rt = runtime(); diff --git a/crates/opy-rs/src/manifest/mod.rs b/crates/opy-rs/src/manifest/mod.rs index 962987bb..37bec0e3 100644 --- a/crates/opy-rs/src/manifest/mod.rs +++ b/crates/opy-rs/src/manifest/mod.rs @@ -82,6 +82,10 @@ impl ReceiverCategory { ReceiverCategory::Any => "any expression", } } + + pub fn enforced(self) -> Option { + matches!(self, ReceiverCategory::String | ReceiverCategory::Variable).then_some(self) + } } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -154,6 +158,14 @@ impl Function { .map_or(0, |index| index + 1); (min, Some(self.params.len())) } + + pub fn variable_param_indices(&self) -> impl Iterator + '_ { + self.params + .iter() + .enumerate() + .filter(|(_, param)| param.variable) + .map(|(index, _)| index) + } } fn default_keyword_args() -> bool { diff --git a/crates/opy-rs/src/matcher.rs b/crates/opy-rs/src/matcher.rs index ee756055..af4e43c5 100644 --- a/crates/opy-rs/src/matcher.rs +++ b/crates/opy-rs/src/matcher.rs @@ -49,9 +49,9 @@ where pub(crate) fn value_candidates(manifest: &Manifest, catalog: &Catalog) -> Vec { let mut pool = function_candidates(manifest, catalog, FunctionKind::is_value); pool.extend( - crate::lower::special_forms::SPECIAL_VALUE_CALLS + crate::lower::special_forms::SPECIAL_FUNCTIONS .iter() - .map(|(spelling, _)| MatchCandidate::bare(*spelling)), + .map(|function| MatchCandidate::bare(function.name)), ); pool } diff --git a/crates/opy-rs/src/parser/expressions.rs b/crates/opy-rs/src/parser/expressions.rs index 391f10e9..f1abf393 100644 --- a/crates/opy-rs/src/parser/expressions.rs +++ b/crates/opy-rs/src/parser/expressions.rs @@ -11,14 +11,7 @@ impl Parser<'_> { ) -> Result { self.skip_expression_newlines(); let then_value = self.parse_or()?; - if self.is_ident("if") - && !self.inside_delimiter_group() - && self.peek().layout.start.line != self.tokens[self.pos - 1].layout.end.line - && self.newline_after_line( - self.tokens[self.pos - 1].layout.file, - self.tokens[self.pos - 1].layout.end.line, - ) - { + if self.is_ident("if") && self.if_is_past_then_value_layout_break() { return Ok(then_value); } if allow_multiline_conditional && self.inside_delimiter_group() { @@ -641,6 +634,13 @@ impl Parser<'_> { self.delimiter_depth[self.pos] != 0 } + fn if_is_past_then_value_layout_break(&self) -> bool { + let then = &self.tokens[self.pos - 1]; + !self.inside_delimiter_group() + && self.peek().layout.start.line != then.layout.end.line + && self.newline_after_line(then.layout.file, then.layout.end.line) + } + pub(super) fn parse_dict(&mut self) -> Result { let open = self.advance(); let mut entries = Vec::new(); diff --git a/crates/opy-rs/src/tooling.rs b/crates/opy-rs/src/tooling.rs index b1c0d7d1..a3a5402c 100644 --- a/crates/opy-rs/src/tooling.rs +++ b/crates/opy-rs/src/tooling.rs @@ -170,7 +170,14 @@ pub fn check_with_overlay_and_locale( overlay: &std::collections::BTreeMap, locale: &workshop_rs::catalog::Locale, ) -> CheckOutcome { - check_pipeline(source, main_path, root, overlay, locale, true) + check_pipeline( + source, + main_path, + root, + overlay, + locale, + CheckStage::CompileStageAcceptance, + ) } pub(crate) fn check_frontend_with_overlay( @@ -180,7 +187,19 @@ pub(crate) fn check_frontend_with_overlay( overlay: &std::collections::BTreeMap, locale: &workshop_rs::catalog::Locale, ) -> CheckOutcome { - check_pipeline(source, main_path, root, overlay, locale, false) + check_pipeline( + source, + main_path, + root, + overlay, + locale, + CheckStage::FrontendOnly, + ) +} + +enum CheckStage { + FrontendOnly, + CompileStageAcceptance, } fn check_pipeline( @@ -189,7 +208,7 @@ fn check_pipeline( root: &Path, overlay: &std::collections::BTreeMap, locale: &workshop_rs::catalog::Locale, - run_compile_stage: bool, + stage: CheckStage, ) -> CheckOutcome { let PreprocessOutcome { result, @@ -299,7 +318,7 @@ fn check_pipeline( { return CheckOutcome::failure(diagnostics, files, display_root); } - if run_compile_stage { + if matches!(stage, CheckStage::CompileStageAcceptance) { let hook = preprocessed.post_compile_hook.as_ref().map(|hook| { crate::PostCompileHookRecord { script: hook.path.clone(), @@ -307,17 +326,9 @@ fn check_pipeline( span: Some(hook.span), } }); - let compiled = crate::compiler::Compiler::new().and_then(|compiler| { - compiler - .compile_hir_with_locale_and_hook(&hir, hook, locale) - .map(|_| ()) - }); - if let Err(error) = compiled { - let code = error.diagnostic.code.as_str(); - if code.starts_with("unknown-") || code == "missing-mapping" { - diagnostics.push(Diagnostic::from_integration_error(error, &files)); - return CheckOutcome::failure(diagnostics, files, display_root); - } + if let Some(error) = compile_stage_spelling_rejection(&hir, hook, locale) { + diagnostics.push(Diagnostic::from_integration_error(error, &files)); + return CheckOutcome::failure(diagnostics, files, display_root); } } CheckOutcome { @@ -335,6 +346,25 @@ fn check_pipeline( } } +fn compile_stage_spelling_rejection( + hir: &hir::types::Program, + hook: Option, + locale: &workshop_rs::catalog::Locale, +) -> Option { + crate::compiler::Compiler::new() + .and_then(|compiler| { + compiler + .compile_hir_with_locale_and_hook(hir, hook, locale) + .map(|_| ()) + }) + .err() + .filter(|error| is_spelling_rejection(&error.diagnostic.code)) +} + +fn is_spelling_rejection(code: &str) -> bool { + code.starts_with("unknown-") || code == "missing-mapping" +} + /// A structured, source-attributed diagnostic. /// /// `code` is the stable machine contract (see the module docs and @@ -812,218 +842,4 @@ fn position_leq(a: Position, b: Position) -> bool { } #[cfg(test)] -mod tests { - use super::*; - - fn check_source(source: &str) -> CheckOutcome { - check(source, "main.opy", Path::new("")) - } - - #[test] - fn clean_project_has_no_diagnostics_and_a_model() { - let outcome = check_source( - "globalvar total = 0\nrule \"r\":\n @Event global\n total += 1\n debug(total)\n", - ); - assert!( - outcome.is_clean(), - "unexpected diagnostics: {:?}", - outcome.diagnostics - ); - let model = outcome.model.expect("a clean project resolves"); - assert_eq!(outcome.files.len(), 1); - assert_eq!(model.declarations().len(), 1); - assert_eq!(model.rules().len(), 1); - } - - #[test] - fn symbols_index_declarations_and_references() { - let outcome = check_source( - "globalvar total\nplayervar P\nsubroutine reset\nmacro double(x):\n x + x\nrule \"r\":\n @Event eachPlayer\n total = 1\n eventPlayer.P = total\n reset()\n double(2)\n", - ); - let model = outcome.model.expect("clean project"); - let names: Vec<(&str, SymbolKind)> = model - .symbols() - .iter() - .map(|symbol| (symbol.name.as_str(), symbol.kind)) - .collect(); - assert_eq!( - names, - vec![ - ("total", SymbolKind::Global), - ("P", SymbolKind::Player), - ("reset", SymbolKind::Subroutine), - ("double", SymbolKind::Macro), - ] - ); - assert_eq!(model.symbol("total").expect("symbol").references.len(), 2); - assert_eq!(model.symbol("P").expect("symbol").references.len(), 1); - let reset = model.symbol("reset").expect("symbol"); - assert_eq!(reset.references.len(), 1); - assert_eq!(reset.references[0].path, "main.opy"); - assert_eq!(model.symbol("double").expect("symbol").references.len(), 1); - } - - #[test] - fn symbol_lookup_by_name_and_span() { - let outcome = - check_source("globalvar total\nrule \"r\":\n @Event global\n total = 1\n"); - let model = outcome.model.expect("clean project"); - let total = model.symbol("total").expect("symbol by name"); - assert_eq!(total.kind, SymbolKind::Global); - let at_decl = model - .symbol_at(total.declaration.to_span()) - .expect("symbol at declaration span"); - assert_eq!(at_decl.name, "total"); - let at_ref = model - .symbol_at(total.references[0].to_span()) - .expect("symbol at reference span"); - assert_eq!(at_ref.name, "total"); - assert!( - model - .symbol_at(Span::new(99, Position::new(1, 1), Position::new(1, 1))) - .is_none() - ); - } - - #[test] - fn provenance_resolves_through_the_file_registry() { - let outcome = - check_source("globalvar total\nrule \"r\":\n @Event global\n total = 1\n"); - let model = outcome.model.expect("clean project"); - let total = model.symbol("total").expect("symbol"); - let provenance = model - .provenance(total.references[0].to_span()) - .expect("provenance"); - assert_eq!(provenance.file_id, 0); - assert_eq!(provenance.path, "main.opy"); - assert_eq!(provenance.start.line, 4); - assert_eq!(model.file(0), Some("main.opy")); - assert_eq!(model.file(1), None); - } - - #[test] - fn custom_enums_are_queried_from_the_model() { - let outcome = check_source( - "globalvar x\nenum Direction:\n NORTH\n SOUTH\nrule \"r\":\n @Event global\n x = Direction.SOUTH\n", - ); - let model = outcome.model.expect("clean project"); - assert_eq!(model.enums().len(), 1); - let direction = &model.enums()[0]; - assert_eq!(direction.name, "Direction"); - let members: Vec<&str> = direction - .members - .iter() - .map(|member| member.name.as_str()) - .collect(); - assert_eq!(members, vec!["NORTH", "SOUTH"]); - assert!(direction.members[0].span.path.ends_with("main.opy")); - } - - #[test] - fn unknown_setting_names_the_keys_valid_at_the_path() { - let outcome = check_source( - "settings {\n \"gamemodes\": {\"ffa\": {\"notASetting\": true}}\n}\nrule \"a\":\n @Event global\n wait(1)\n", - ); - let diagnostic = outcome - .diagnostics - .iter() - .find(|d| d.code == "unknown-setting") - .expect("unknown setting diagnostic"); - assert_eq!(diagnostic.severity, DiagnosticSeverity::Warning); - assert!( - diagnostic.message.contains("(did you mean "), - "message: {}", - diagnostic.message - ); - assert!( - diagnostic.message.contains("'disabledMaps'"), - "message: {}", - diagnostic.message - ); - } - - #[test] - fn unknown_setting_preserves_template_paths_and_percent_suffixes() { - let outcome = check_source( - "settings {\n \"main\": {\"description\": \"t\"},\n \"gamemodes\": {},\n \"heroes\": {\"team1\": {\"general\": {\"damageReceiveed%\": 50}}}\n}\nrule \"a\":\n @Event global\n wait(1)\n", - ); - let diagnostic = outcome - .diagnostics - .iter() - .find(|d| d.code == "unknown-setting") - .expect("unknown setting diagnostic"); - assert_eq!(diagnostic.severity, DiagnosticSeverity::Warning); - assert!( - diagnostic.message.contains("'damageReceived%'"), - "message: {}", - diagnostic.message - ); - } - - #[test] - fn check_reports_every_parse_error() { - let outcome = check_source("rule \"a\"\n @Event global\nrule \"b\"\n"); - assert!(!outcome.is_clean()); - assert!(outcome.model.is_none()); - assert_eq!(outcome.diagnostics.len(), 3); - assert!( - outcome - .diagnostics - .iter() - .all(|diagnostic| diagnostic.code == "parse-error") - ); - } - - #[test] - fn diagnostics_carry_severity_code_and_span() { - let outcome = check_source("rule \"r\":\n @Event global\n frobnicate()\n"); - let diagnostic = &outcome.diagnostics[0]; - assert_eq!(diagnostic.severity, DiagnosticSeverity::Error); - assert_eq!(diagnostic.code, "unknown-action"); - let span = diagnostic.span.as_ref().expect("source-located"); - assert_eq!(span.path, "main.opy"); - assert_eq!(span.start.line, 3); - } - - #[test] - fn canonical_workshop_ids_are_source_diagnostics() { - for (statement, code) in [ - ("g = allTankHeroes()", "unknown-value"), - ("g = lastCreatedEntity()", "unknown-value"), - ("g = evaluateOnce(1)", "unknown-value"), - ("destroyAllHudText()", "unknown-action"), - ] { - let outcome = check_source(&format!( - "globalvar g\nrule \"r\":\n @Event global\n {statement}\n" - )); - assert!(!outcome.is_clean(), "{statement}"); - let diagnostic = outcome - .diagnostics - .iter() - .find(|diagnostic| diagnostic.code == code) - .unwrap_or_else(|| panic!("{statement}: expected {code}")); - let span = diagnostic.span.as_ref().expect("source-located"); - assert_eq!(span.start.line, 4, "{statement}"); - } - - let outcome = check_source( - "globalvar g\nrule \"r\":\n @Event eachPlayer\n g = eventPlayer.isButtonHeld(Button.PRIMARY_FIRE)\n", - ); - assert!(!outcome.is_clean()); - let diagnostic = outcome - .diagnostics - .iter() - .find(|diagnostic| diagnostic.code == "unknown-member") - .expect("unknown-member diagnostic"); - assert_eq!(diagnostic.span.as_ref().unwrap().start.line, 4); - - let outcome = check_source( - "globalvar g\nrule \"r\":\n @Event eachPlayer\n g = getTankHeroes()\n g = getLastCreatedEntity()\n g = evalOnce(1)\n g = eventPlayer.isHoldingButton(Button.PRIMARY_FIRE)\n destroyAllHudTexts()\n", - ); - assert!( - outcome.is_clean(), - "unexpected diagnostics: {:?}", - outcome.diagnostics - ); - } -} +mod tests; diff --git a/crates/opy-rs/src/tooling/tests.rs b/crates/opy-rs/src/tooling/tests.rs new file mode 100644 index 00000000..d859b9fe --- /dev/null +++ b/crates/opy-rs/src/tooling/tests.rs @@ -0,0 +1,211 @@ +use super::*; + +fn check_source(source: &str) -> CheckOutcome { + check(source, "main.opy", Path::new("")) +} + +#[test] +fn clean_project_has_no_diagnostics_and_a_model() { + let outcome = check_source( + "globalvar total = 0\nrule \"r\":\n @Event global\n total += 1\n debug(total)\n", + ); + assert!( + outcome.is_clean(), + "unexpected diagnostics: {:?}", + outcome.diagnostics + ); + let model = outcome.model.expect("a clean project resolves"); + assert_eq!(outcome.files.len(), 1); + assert_eq!(model.declarations().len(), 1); + assert_eq!(model.rules().len(), 1); +} + +#[test] +fn symbols_index_declarations_and_references() { + let outcome = check_source( + "globalvar total\nplayervar P\nsubroutine reset\nmacro double(x):\n x + x\nrule \"r\":\n @Event eachPlayer\n total = 1\n eventPlayer.P = total\n reset()\n double(2)\n", + ); + let model = outcome.model.expect("clean project"); + let names: Vec<(&str, SymbolKind)> = model + .symbols() + .iter() + .map(|symbol| (symbol.name.as_str(), symbol.kind)) + .collect(); + assert_eq!( + names, + vec![ + ("total", SymbolKind::Global), + ("P", SymbolKind::Player), + ("reset", SymbolKind::Subroutine), + ("double", SymbolKind::Macro), + ] + ); + assert_eq!(model.symbol("total").expect("symbol").references.len(), 2); + assert_eq!(model.symbol("P").expect("symbol").references.len(), 1); + let reset = model.symbol("reset").expect("symbol"); + assert_eq!(reset.references.len(), 1); + assert_eq!(reset.references[0].path, "main.opy"); + assert_eq!(model.symbol("double").expect("symbol").references.len(), 1); +} + +#[test] +fn symbol_lookup_by_name_and_span() { + let outcome = check_source("globalvar total\nrule \"r\":\n @Event global\n total = 1\n"); + let model = outcome.model.expect("clean project"); + let total = model.symbol("total").expect("symbol by name"); + assert_eq!(total.kind, SymbolKind::Global); + let at_decl = model + .symbol_at(total.declaration.to_span()) + .expect("symbol at declaration span"); + assert_eq!(at_decl.name, "total"); + let at_ref = model + .symbol_at(total.references[0].to_span()) + .expect("symbol at reference span"); + assert_eq!(at_ref.name, "total"); + assert!( + model + .symbol_at(Span::new(99, Position::new(1, 1), Position::new(1, 1))) + .is_none() + ); +} + +#[test] +fn provenance_resolves_through_the_file_registry() { + let outcome = check_source("globalvar total\nrule \"r\":\n @Event global\n total = 1\n"); + let model = outcome.model.expect("clean project"); + let total = model.symbol("total").expect("symbol"); + let provenance = model + .provenance(total.references[0].to_span()) + .expect("provenance"); + assert_eq!(provenance.file_id, 0); + assert_eq!(provenance.path, "main.opy"); + assert_eq!(provenance.start.line, 4); + assert_eq!(model.file(0), Some("main.opy")); + assert_eq!(model.file(1), None); +} + +#[test] +fn custom_enums_are_queried_from_the_model() { + let outcome = check_source( + "globalvar x\nenum Direction:\n NORTH\n SOUTH\nrule \"r\":\n @Event global\n x = Direction.SOUTH\n", + ); + let model = outcome.model.expect("clean project"); + assert_eq!(model.enums().len(), 1); + let direction = &model.enums()[0]; + assert_eq!(direction.name, "Direction"); + let members: Vec<&str> = direction + .members + .iter() + .map(|member| member.name.as_str()) + .collect(); + assert_eq!(members, vec!["NORTH", "SOUTH"]); + assert!(direction.members[0].span.path.ends_with("main.opy")); +} + +#[test] +fn unknown_setting_names_the_keys_valid_at_the_path() { + let outcome = check_source( + "settings {\n \"gamemodes\": {\"ffa\": {\"notASetting\": true}}\n}\nrule \"a\":\n @Event global\n wait(1)\n", + ); + let diagnostic = outcome + .diagnostics + .iter() + .find(|d| d.code == "unknown-setting") + .expect("unknown setting diagnostic"); + assert_eq!(diagnostic.severity, DiagnosticSeverity::Warning); + assert!( + diagnostic.message.contains("(did you mean "), + "message: {}", + diagnostic.message + ); + assert!( + diagnostic.message.contains("'disabledMaps'"), + "message: {}", + diagnostic.message + ); +} + +#[test] +fn unknown_setting_preserves_template_paths_and_percent_suffixes() { + let outcome = check_source( + "settings {\n \"main\": {\"description\": \"t\"},\n \"gamemodes\": {},\n \"heroes\": {\"team1\": {\"general\": {\"damageReceiveed%\": 50}}}\n}\nrule \"a\":\n @Event global\n wait(1)\n", + ); + let diagnostic = outcome + .diagnostics + .iter() + .find(|d| d.code == "unknown-setting") + .expect("unknown setting diagnostic"); + assert_eq!(diagnostic.severity, DiagnosticSeverity::Warning); + assert!( + diagnostic.message.contains("'damageReceived%'"), + "message: {}", + diagnostic.message + ); +} + +#[test] +fn check_reports_every_parse_error() { + let outcome = check_source("rule \"a\"\n @Event global\nrule \"b\"\n"); + assert!(!outcome.is_clean()); + assert!(outcome.model.is_none()); + assert_eq!(outcome.diagnostics.len(), 3); + assert!( + outcome + .diagnostics + .iter() + .all(|diagnostic| diagnostic.code == "parse-error") + ); +} + +#[test] +fn diagnostics_carry_severity_code_and_span() { + let outcome = check_source("rule \"r\":\n @Event global\n frobnicate()\n"); + let diagnostic = &outcome.diagnostics[0]; + assert_eq!(diagnostic.severity, DiagnosticSeverity::Error); + assert_eq!(diagnostic.code, "unknown-action"); + let span = diagnostic.span.as_ref().expect("source-located"); + assert_eq!(span.path, "main.opy"); + assert_eq!(span.start.line, 3); +} + +#[test] +fn canonical_workshop_ids_are_source_diagnostics() { + for (statement, code) in [ + ("g = allTankHeroes()", "unknown-value"), + ("g = lastCreatedEntity()", "unknown-value"), + ("g = evaluateOnce(1)", "unknown-value"), + ("destroyAllHudText()", "unknown-action"), + ] { + let outcome = check_source(&format!( + "globalvar g\nrule \"r\":\n @Event global\n {statement}\n" + )); + assert!(!outcome.is_clean(), "{statement}"); + let diagnostic = outcome + .diagnostics + .iter() + .find(|diagnostic| diagnostic.code == code) + .unwrap_or_else(|| panic!("{statement}: expected {code}")); + let span = diagnostic.span.as_ref().expect("source-located"); + assert_eq!(span.start.line, 4, "{statement}"); + } + + let outcome = check_source( + "globalvar g\nrule \"r\":\n @Event eachPlayer\n g = eventPlayer.isButtonHeld(Button.PRIMARY_FIRE)\n", + ); + assert!(!outcome.is_clean()); + let diagnostic = outcome + .diagnostics + .iter() + .find(|diagnostic| diagnostic.code == "unknown-member") + .expect("unknown-member diagnostic"); + assert_eq!(diagnostic.span.as_ref().unwrap().start.line, 4); + + let outcome = check_source( + "globalvar g\nrule \"r\":\n @Event eachPlayer\n g = getTankHeroes()\n g = getLastCreatedEntity()\n g = evalOnce(1)\n g = eventPlayer.isHoldingButton(Button.PRIMARY_FIRE)\n destroyAllHudTexts()\n", + ); + assert!( + outcome.is_clean(), + "unexpected diagnostics: {:?}", + outcome.diagnostics + ); +} diff --git a/docs/architecture/reconstruction.md b/docs/architecture/reconstruction.md index 7895e13a..3c5965de 100644 --- a/docs/architecture/reconstruction.md +++ b/docs/architecture/reconstruction.md @@ -272,7 +272,7 @@ and never introduces an OPY-local settings model. | `Action::Call` with no OPY form and no syntax mapping | — | refusal (`unsupported-action-call`) | | `Action::Call` `append` (dedicated lowering) | Never produced canonically | guard (`unsupported-action-call`) | | Missing `End` / malformed marker sequence | No faithful source form | guard (`unsupported-control-flow`) | -| Variable-required parameter not a variable (`chase*` arg 0) | Cannot reproduce the typed policy | guard (`unsupported-invalid-argument`) | +| Variable-required parameter not a variable (`chase*` arg 0) | Cannot reproduce the manifest `variable` requirement | guard (`unsupported-invalid-argument`) | | Omitted non-optional parameter / arity overflow | Recompiling would default-fill or reject | guard (`unsupported-missing-argument`, `unsupported-invalid-arity`) | | Dangling variable/subroutine reference | Cannot rebind | guard (`unsupported-dangling`) | | Zero-argument action ambiguous with a same-named subroutine | Ambiguous surface | refusal (`unsupported-action-call`) | From 637f45fca164164affb6e50a8b1eb5fc94e34a8f Mon Sep 17 00:00:00 2001 From: Teakowa Date: Thu, 22 Oct 2026 03:57:57 +0800 Subject: [PATCH 2/2] fix(reconstruct): materialize the slot range before iterating it The push toolchain denies const-item-mutation (.find needs &mut self) and the size guard forbids growing this file; a mutable local keeps the range usage one line and shrinks the file. --- crates/opy-rs/src/compiler/reconstruct.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/crates/opy-rs/src/compiler/reconstruct.rs b/crates/opy-rs/src/compiler/reconstruct.rs index 8aab3083..3f7c4936 100644 --- a/crates/opy-rs/src/compiler/reconstruct.rs +++ b/crates/opy-rs/src/compiler/reconstruct.rs @@ -17,7 +17,7 @@ use crate::manifest::{Function, FunctionKind, Manifest}; mod settings; mod settings_json; -const DECLARATION_SLOT_RANGE: std::ops::RangeInclusive = 0..=127; +const DECLARATION_SLOT_RANGE: std::ops::Range = 0..128; #[derive(Debug, Clone, PartialEq, Eq)] pub struct ReconstructIssue { @@ -741,12 +741,11 @@ impl<'a> Emitter<'a> { if dunder != high { continue; } + let mut range = DECLARATION_SLOT_RANGE; let slot = if high { - DECLARATION_SLOT_RANGE - .rev() - .find(|slot| !taken.contains(slot)) + range.rev().find(|slot| !taken.contains(slot)) } else { - DECLARATION_SLOT_RANGE.find(|slot| !taken.contains(slot)) + range.find(|slot| !taken.contains(slot)) }; if slot != Some(index) { self.issues.push(ReconstructIssue {