diff --git a/compatibility/compiler-expectations.json b/compatibility/compiler-expectations.json index eb3a29a..63259a9 100644 --- a/compatibility/compiler-expectations.json +++ b/compatibility/compiler-expectations.json @@ -427,9 +427,9 @@ "provenance:real-world/6v6-adjustments/fixture.json" ], "owner": "opy-rs#88", - "note": "The full real-world project accepts included-file #!mainFile directives and reaches the next unsupported #!defineMember frontend boundary.", + "note": "The full real-world project now resolves included-file #!mainFile and #!defineMember directives; it remains outside the compiler baseline at the existing included settings-placement boundary.", "failureClass": "frontend", - "diagnosticCode": "unsupported-directive" + "diagnosticCode": "settings-placement" }, { "fixture": "real-world/overpy-broken-weapons", @@ -593,9 +593,9 @@ "provenance:real-world/ow1-emulator/fixture.json" ], "owner": "opy-rs#88", - "note": "The full real-world project accepts included-file #!mainFile directives and reaches the next unsupported #!defineMember frontend boundary.", + "note": "The full real-world project now resolves included-file #!mainFile and #!defineMember directives; it remains outside the compiler baseline at the next lexer line-continuation boundary.", "failureClass": "frontend", - "diagnosticCode": "unsupported-directive" + "diagnosticCode": "lex-error" }, { "fixture": "synthetic/chase-condition-agentlab", diff --git a/compatibility/differential-expectations.json b/compatibility/differential-expectations.json index 0511a18..99ddaac 100644 --- a/compatibility/differential-expectations.json +++ b/compatibility/differential-expectations.json @@ -46,8 +46,8 @@ {"fixture": "real-world/overpy-parabola", "nativeStatus": "failure", "classification": "known-gap", "ruleNames": false, "evidence": ["oracle:real-world/overpy-parabola/oracle.json", "regression:real-world/overpy-parabola/fixture.json"], "note": "The numeric enum member regression now resolves; the full project reaches the next multiline parenthesized expression gap at parabola.opy:50."}, {"fixture": "real-world/overpy-meipocalypse", "nativeStatus": "failure", "classification": "match", "ruleNames": false, "evidence": ["oracle:real-world/overpy-meipocalypse/oracle.json", "provenance:real-world/overpy-meipocalypse/fixture.json"], "note": "The reference rejects the missing script hook; native rejection is retained with a different earlier diagnostic."}, {"fixture": "real-world/overpy-zencopter", "nativeStatus": "failure", "classification": "match", "ruleNames": false, "evidence": ["oracle:real-world/overpy-zencopter/oracle.json", "provenance:real-world/overpy-zencopter/fixture.json"], "note": "Both sides reject the pinned upstream example; native diagnostic wording is not used as semantic evidence."}, - {"fixture": "real-world/ow1-emulator", "nativeStatus": "failure", "classification": "match", "ruleNames": false, "evidence": ["oracle:real-world/ow1-emulator/oracle.json", "provenance:real-world/ow1-emulator/fixture.json"], "note": "The full project accepts included-file #!mainFile directives and remains preserved as a failure corpus case with the next unsupported #!defineMember boundary."}, - {"fixture": "real-world/6v6-adjustments", "nativeStatus": "failure", "classification": "match", "ruleNames": false, "evidence": ["oracle:real-world/6v6-adjustments/oracle.json", "provenance:real-world/6v6-adjustments/fixture.json"], "note": "The full project accepts included-file #!mainFile directives and remains preserved as a failure corpus case with the next unsupported #!defineMember boundary."}, + {"fixture": "real-world/ow1-emulator", "nativeStatus": "failure", "classification": "match", "ruleNames": false, "evidence": ["oracle:real-world/ow1-emulator/oracle.json", "provenance:real-world/ow1-emulator/fixture.json"], "note": "The full project now resolves included-file #!mainFile and #!defineMember directives; the failure case remains at the next lexer line-continuation boundary."}, + {"fixture": "real-world/6v6-adjustments", "nativeStatus": "failure", "classification": "match", "ruleNames": false, "evidence": ["oracle:real-world/6v6-adjustments/oracle.json", "provenance:real-world/6v6-adjustments/fixture.json"], "note": "The full project now resolves included-file #!mainFile and #!defineMember directives; the failure case remains at the existing included settings-placement boundary."}, {"fixture": "synthetic/issue-35-integration", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:synthetic/issue-35-integration/oracle.json", "implementation-invariant:opy-rs::compiler-vertical-slice"], "note": "The OPY source implementation resolves the source fixture; the compiler test independently lowers it through canonical WIR validation and deterministic workshop-rs emission."}, {"fixture": "synthetic/issue-40-structural", "nativeStatus": "success", "classification": "match", "ruleNames": false, "evidence": ["oracle:synthetic/issue-40-structural/oracle.json", "implementation-invariant:opy-rs::compiler-structural-lowering"], "note": "The pinned oracle records subroutine source identity, deterministic explicit/implicit variable allocation, and player event filters; the compiler test independently asserts those structures in canonical WIR."}, {"fixture": "synthetic/issue-46-primitives", "nativeStatus": "success", "classification": "match", "ruleNames": false, "evidence": ["oracle:synthetic/issue-46-primitives/oracle.json", "implementation-invariant:opy-rs::compiler-primitive-lowering", "test:opy-rs::compiler-issue-46-native-wir-equivalence"], "note": "The pinned oracle records the evidenced assignment and modification lowering (including **= and single-level indexed forms), value expressions, array indexing (including the firstOf index-0 read normalization), not-comparison negation, implicit default variables at fixed Workshop slots, and non-null variable initializers; null-default initializer semantics and deeper indexed writes remain explicit follow-ups. The compiler test compares native lowered WIR directly with the parsed oracle WIR and asserts structural equivalence."}, diff --git a/compatibility/support-matrix.json b/compatibility/support-matrix.json index dc85abd..3508f65 100644 --- a/compatibility/support-matrix.json +++ b/compatibility/support-matrix.json @@ -400,7 +400,7 @@ }, { "id": "preprocessing/include", - "name": "#!include: root-relative resolution, cycle detection, missing-file diagnostics, file registry", + "name": "#!include: including-file-relative resolution, cycle detection, missing-file diagnostics, file registry", "category": "preprocessing", "state": "source-supported", "evidence": [ @@ -411,24 +411,26 @@ "upstream:src/tests/include3.opy", "upstream:src/tests/include5.opy" ], - "notes": "Root-relative include resolution, cycle detection, missing-file diagnostics, file registry; deterministic multi-file tests." + "notes": "Includes resolve from the declaring source file, including nested closures; cycle detection, missing-file diagnostics, file registry, and deterministic multi-file provenance are covered." }, { "id": "preprocessing/define-undef", - "name": "#!define (object- and function-like), #!undef, recursive expansion, recursion guard", + "name": "#!define/#!defineMember (object- and function-like), #!undef, recursive expansion, recursion guard", "category": "preprocessing", "state": "source-supported", "evidence": [ "fixtures:synthetic/preprocessing", "fixtures:real-world/overpy-cake", + "test:opy-rs::compiler::integration_tests::issue_142_preprocessing::included_define_member_expands_with_definition_provenance", + "test:opy-rs::compiler::integration_tests::issue_142_preprocessing::nested_includes_resolve_relative_to_the_including_file", "upstream:src/tests/macros.opy", "upstream:src/tests/builtinmacros.opy" ], - "notes": "Object- and function-like #!define, #!undef, recursive expansion with recursion guard; preprocessing fixtures." + "notes": "Object- and function-like #!define/#!defineMember, #!undef, recursive expansion with recursion guard; member defines share the pinned textual macro contract and retain definition-site provenance." }, { "id": "preprocessing/advanced-directives", - "name": "#!mainFile, #!allowMacroRedeclaration, #!optimize*/#!replace0By* family, #!rulePrefix*", + "name": "#!mainFile, #!allowMacroRedeclaration, backend-only directive state, #!optimize*/#!replace0By* family, #!rulePrefix*", "category": "preprocessing", "state": "semantic-supported", "evidence": [ @@ -436,9 +438,11 @@ "fixtures:synthetic/issue-29-main-file", "upstream:src/tests/rulePrefix.opy", "upstream:src/tests/compression.opy", - "upstream:src/data/opy/preprocessing.ts" + "upstream:src/data/opy/preprocessing.ts", + "test:opy-rs::preprocess::tests::backend_only_directives_are_validated_and_recorded", + "test:opy-rs::preprocess::tests::extension_directive_rejects_unknown_schema_values" ], - "notes": "Issue #29: source implementation records directive state, macro-redeclaration policy, main-file provenance, and rule-prefix application. Optimizer and generated Workshop effects remain separate." + "notes": "Issue #29 plus #142: source implementation validates and records main-file provenance, macro-redeclaration policy, backend-only directive state, canonical extension names, and rule-prefix application. Optimizer, localization, extension point accounting, output-file, compression, and generated Workshop effects remain separate." }, { "id": "preprocessing/directive-effects", diff --git a/crates/opy-rs/src/compiler/integration_tests.rs b/crates/opy-rs/src/compiler/integration_tests.rs index 9b46e93..bc7d115 100644 --- a/crates/opy-rs/src/compiler/integration_tests.rs +++ b/crates/opy-rs/src/compiler/integration_tests.rs @@ -18,6 +18,8 @@ mod issue_129_included_main_file; mod issue_130_horizontal_facing_angle; #[path = "tests/issue_131_spec_visibility.rs"] mod issue_131_spec_visibility; +#[path = "tests/issue_142_preprocessing.rs"] +mod issue_142_preprocessing; #[path = "tests/issue_42_oracle.rs"] mod issue_42_oracle; #[path = "tests/issue_46_oracle.rs"] diff --git a/crates/opy-rs/src/compiler/tests/issue_142_preprocessing.rs b/crates/opy-rs/src/compiler/tests/issue_142_preprocessing.rs new file mode 100644 index 0000000..e8fbd95 --- /dev/null +++ b/crates/opy-rs/src/compiler/tests/issue_142_preprocessing.rs @@ -0,0 +1,78 @@ +//! Public compile-path coverage for issue #142's preprocessor member defines. + +use std::collections::BTreeMap; + +use crate::compile_with_overlay; +use crate::hir::{Expr, RuleEntry, Stmt}; + +#[test] +fn included_define_member_expands_with_definition_provenance() { + let mut overlay = BTreeMap::new(); + overlay.insert( + "shared.opy".to_string(), + "#!defineMember VALUE 2\n".to_string(), + ); + let hir = compile_with_overlay( + "#!include \"shared.opy\"\nrule \"member define\":\n @Event global\n A = VALUE\n", + "main.opy", + std::path::Path::new("."), + &overlay, + ) + .expect("included member defines must expand through the public compile path"); + + assert!(hir.dump().contains("assign A = 2"), "{}", hir.dump()); + assert_eq!(hir.defines.len(), 1); + assert_eq!(hir.defines[0].name, "VALUE"); + assert!(hir.defines[0].is_member); + assert_eq!(hir.defines[0].span.expect("define span").file, 1); + assert_eq!(hir.files[1].path, "shared.opy"); +} + +#[test] +fn function_define_member_uses_the_same_textual_macro_contract() { + let hir = crate::compile( + "#!defineMember add(value) value + 1\nrule \"member function\":\n @Event global\n A = add(2)\n", + "main.opy", + std::path::Path::new("."), + ) + .expect("function-like member defines must expand"); + assert!(hir.defines[0].is_member); + + let RuleEntry::Rule(rule) = &hir.rules[0] else { + panic!("expected a rule"); + }; + let Stmt::Assign { value, .. } = &rule.actions[0] else { + panic!("expected an assignment"); + }; + let Expr::Binary { left, right, .. } = &**value else { + panic!("expected the expanded expression to preserve its operands"); + }; + assert!(matches!(&**left, Expr::Number { value, .. } if *value == 2.0)); + assert!(matches!(&**right, Expr::Number { value, .. } if *value == 1.0)); +} + +#[test] +fn nested_includes_resolve_relative_to_the_including_file() { + let overlay = BTreeMap::from([ + ( + "dir/child.opy".to_string(), + "#!include \"grandchild.opy\"\n".to_string(), + ), + ( + "dir/grandchild.opy".to_string(), + "#!defineMember VALUE 2\n".to_string(), + ), + ]); + let hir = compile_with_overlay( + "#!include \"dir/child.opy\"\nrule \"nested include\":\n @Event global\n A = VALUE\n", + "main.opy", + std::path::Path::new("."), + &overlay, + ) + .expect("nested include paths must be relative to the including file"); + + assert!(hir.dump().contains("assign A = 2"), "{}", hir.dump()); + assert_eq!(hir.defines[0].span.expect("define span").file, 2); + assert_eq!(hir.files[1].path, "dir/child.opy"); + assert_eq!(hir.files[2].path, "dir/grandchild.opy"); +} diff --git a/crates/opy-rs/src/hir/dump.rs b/crates/opy-rs/src/hir/dump.rs index 60d508e..9985358 100644 --- a/crates/opy-rs/src/hir/dump.rs +++ b/crates/opy-rs/src/hir/dump.rs @@ -29,13 +29,14 @@ pub fn dump(program: &Program) -> String { out.push_str("defines:\n"); for define in &program.defines { out.push_str(&format!( - " {} ({}){}\n", + " {} ({}{}){}\n", define.name, if define.is_function { "function" } else { "constant" }, + if define.is_member { ", member" } else { "" }, span_suffix(define.span.as_ref()), )); } diff --git a/crates/opy-rs/src/hir/types.rs b/crates/opy-rs/src/hir/types.rs index 91e5b6c..b3d203b 100644 --- a/crates/opy-rs/src/hir/types.rs +++ b/crates/opy-rs/src/hir/types.rs @@ -77,6 +77,9 @@ pub struct Define { pub name: String, #[serde(default)] pub is_function: bool, + /// Whether the definition used OverPy's `#!defineMember` surface. + #[serde(default)] + pub is_member: bool, #[serde(skip_serializing_if = "Option::is_none")] pub span: Option, } diff --git a/crates/opy-rs/src/lib.rs b/crates/opy-rs/src/lib.rs index b730c66..52f8e3c 100644 --- a/crates/opy-rs/src/lib.rs +++ b/crates/opy-rs/src/lib.rs @@ -2,7 +2,7 @@ //! //! Owns the OPY source-language surface of the `opy-rs` repository: a lexer, //! an indentation-aware CST/parser with structured diagnostics and recovery, -//! token-level preprocessing (includes and `#!define` macros), semantic +//! token-level preprocessing (includes and `#!define`/`#!defineMember` macros), semantic //! resolution, and lowering into the opy-rs-owned Opy HIR contract //! ([`hir::Program`]). Everything from source through the Opy HIR semantic //! model is Workshop-independent: source analysis never depends on `workshop-rs`, diff --git a/crates/opy-rs/src/preprocess.rs b/crates/opy-rs/src/preprocess.rs index ca0749c..272470a 100644 --- a/crates/opy-rs/src/preprocess.rs +++ b/crates/opy-rs/src/preprocess.rs @@ -1,4 +1,4 @@ -//! `.opy` preprocessing: includes, `#!define` macros (textual and +//! `.opy` preprocessing: includes, `#!define`/`#!defineMember` macros (textual and //! `__script__` JavaScript-backed), `#!postCompileHook`, and expansion. //! //! Operates at the token level, matching the reference frontend's observable @@ -7,9 +7,9 @@ //! register macros that expand at their use sites, recursively (a macro may //! reference earlier macros). The output is a single-file token stream whose //! spans point at use sites, mirroring the reference adapter's provenance -//! convention (the HIR file registry keeps the main file). Invalid include -//! graphs (cycles, missing files) and recursive defines fail deterministically -//! with structured diagnostics that name the offending file/line. +//! convention (the HIR file registry records the included sources). Invalid +//! include graphs (cycles, missing files) and recursive defines fail +//! deterministically with structured diagnostics that name the offending file/line. //! //! # JavaScript macros and hooks //! @@ -57,6 +57,7 @@ use crate::settings::SettingsBlock; pub struct DefineRecord { pub name: String, pub is_function: bool, + pub is_member: bool, pub span: Option, } @@ -413,8 +414,8 @@ impl Preprocessor { self.include(include, span, out)?; return Ok(()); } - if name == "define" { - self.define(rest.trim(), span)?; + if matches!(name, "define" | "defineMember") { + self.define(rest.trim(), span, name == "defineMember")?; return Ok(()); } if name == "undef" { @@ -458,6 +459,11 @@ impl Preprocessor { self.record("postCompileHook", Some(path), span); return Ok(()); } + if matches!(name, "setupTags" | "setupTx") { + require_no_arguments(name, rest, span)?; + self.record(name, None, span); + return Ok(()); + } if name == "mainFile" { if allow_leading_main_file { let main_file = strip_quoted(rest.trim()) @@ -483,6 +489,57 @@ impl Preprocessor { self.record(name, None, span); return Ok(()); } + if name == "excludeVariablesInCompilation" { + require_no_arguments(name, rest, span)?; + self.record(name, None, span); + return Ok(()); + } + if name == "extension" { + let extension = parse_single_word(rest, name, span)?; + validate_extension_name(extension, span)?; + self.record(name, Some(extension), span); + return Ok(()); + } + if name == "translateWithPlayerVar" { + let options = rest.split_whitespace().collect::>(); + if options + .iter() + .any(|option| !matches!(*option, "noDetectionRule" | "noTlErr")) + { + return Err(OpyError::at( + "directive-invalid", + "`#!translateWithPlayerVar` accepts only `noDetectionRule` and `noTlErr`", + span, + )); + } + let value = (!options.is_empty()).then(|| options.join(" ")); + self.record(name, value.as_deref(), span); + return Ok(()); + } + if matches!( + name, + "disableInspector" + | "writeToOutputFile" + | "disableTranslationSourceLines" + | "keepUnusedTranslations" + | "useVariableForCompressionAlphabet" + | "debugElementCount" + ) { + require_no_arguments(name, rest, span)?; + self.record(name, None, span); + return Ok(()); + } + if matches!(name, "globalvarInitRuleName" | "playervarInitRuleName") { + let value = strip_quoted(rest.trim()).ok_or_else(|| { + OpyError::at( + "directive-invalid", + format!("`#!{name}` expects one quoted string"), + span, + ) + })?; + self.record(name, Some(value), span); + return Ok(()); + } if name == "translations" { let languages = parse_translations(rest.trim(), span)?; self.preprocessing.translations = Some(TranslationState { @@ -605,8 +662,7 @@ impl Preprocessor { }); } - /// Resolve a script path root-relative (the reference's - /// `getFilePaths(path, rootPath)` convention) and read its text. + /// Resolve a script path relative to the project root and read its text. fn resolve_script(&self, path: &str, span: Span) -> OpyResult { let canonical = self.root.join(path).canonicalize().map_err(|_| { OpyError::at( @@ -633,16 +689,23 @@ impl Preprocessor { /// Resolve, lex, and splice one included file. fn include(&mut self, include: &str, span: Span, out: &mut Vec) -> OpyResult<()> { - // The include base is the root; the main file is the only file in the - // registry (reference convention), so path resolution is root-based. - let candidate = self.root.join(include); + // Includes resolve relative to the source file containing the + // directive. The main source uses the project root as its base. + let candidate = self.include_base().join(include); let canonical = std::fs::canonicalize(&candidate).ok(); + let candidate_path = candidate.to_string_lossy().into_owned(); + let candidate_without_dot = candidate_path + .strip_prefix("./") + .unwrap_or(&candidate_path) + .to_string(); // An open-document overlay (an unsaved editor buffer) takes // precedence over the filesystem. Overlays are keyed by the include // string and by the resolved canonical path, so both spellings work. let overlay_text = self .overlay .get(include) + .or_else(|| self.overlay.get(&candidate_path)) + .or_else(|| self.overlay.get(&candidate_without_dot)) .or_else(|| { canonical .as_ref() @@ -689,9 +752,10 @@ impl Preprocessor { // Each include registers a file in the registry (reference behavior). let file_id = self.next_file_id; self.next_file_id += 1; + let display_path = self.include_display_path(&candidate, include); self.files.push(FileRecord { id: file_id, - path: include.to_string(), + path: display_path, }); self.include_stack.push(identity); let saved_prefix = self.preprocessing.rule_prefix.clone(); @@ -738,12 +802,33 @@ impl Preprocessor { Ok(()) } + fn include_base(&self) -> PathBuf { + self.include_stack + .last() + .and_then(|path| path.parent()) + .map(Path::to_path_buf) + .unwrap_or_else(|| self.root.clone()) + } + + fn include_display_path(&self, candidate: &Path, fallback: &str) -> String { + candidate + .strip_prefix(&self.root) + .ok() + .and_then(|path| (!path.as_os_str().is_empty()).then_some(path)) + .map(|path| path.to_string_lossy().into_owned()) + .or_else(|| { + let path = candidate.to_string_lossy(); + Some(path.strip_prefix("./").unwrap_or(&path).to_string()) + }) + .unwrap_or_else(|| fallback.to_string()) + } + /// Register one `#!define` (object- or function-like). /// /// A define is function-like when `(` immediately follows the name /// (`cakeBeam(start, end)`); a parenthesized object-like value /// (`#!define X (a + b)`) keeps its parentheses as value tokens. - fn define(&mut self, rest: &str, span: Span) -> OpyResult<()> { + fn define(&mut self, rest: &str, span: Span, is_member: bool) -> OpyResult<()> { let rest = rest.trim(); let first_open = rest.find('(').unwrap_or(usize::MAX); let first_space = rest.find(char::is_whitespace).unwrap_or(usize::MAX); @@ -834,6 +919,7 @@ impl Preprocessor { self.defines.push(DefineRecord { name: name.clone(), is_function, + is_member, span: Some(span), }); self.macros.push(MacroDef { @@ -1078,6 +1164,46 @@ fn split_directive(text: &str) -> (&str, &str) { .map_or((text, ""), |(name, rest)| (name, rest)) } +fn require_no_arguments(name: &str, rest: &str, span: Span) -> OpyResult<()> { + if rest.trim().is_empty() { + Ok(()) + } else { + Err(OpyError::at( + "directive-invalid", + format!("`#!{name}` does not accept arguments"), + span, + )) + } +} + +fn parse_single_word<'a>(rest: &'a str, name: &str, span: Span) -> OpyResult<&'a str> { + let value = rest.trim(); + if value.is_empty() || value.chars().any(char::is_whitespace) { + return Err(OpyError::at( + "directive-invalid", + format!("`#!{name}` expects one argument"), + span, + )); + } + Ok(value) +} + +fn validate_extension_name(extension: &str, span: Span) -> OpyResult<()> { + let path = [ + workshop_rs::settings::table::PathPart::Part("extensions"), + workshop_rs::settings::table::PathPart::Part(extension), + ]; + if workshop_rs::settings::definition(&path).is_some() { + Ok(()) + } else { + Err(OpyError::at( + "directive-invalid", + format!("unknown Workshop extension `{extension}`"), + span, + )) + } +} + fn is_identifier_char(ch: char) -> bool { ch.is_ascii_alphanumeric() || ch == '_' } @@ -1315,6 +1441,7 @@ mod tests { assert_eq!(pre.defines.len(), 1); assert_eq!(pre.defines[0].name, "SIDE"); assert!(!pre.defines[0].is_function); + assert!(!pre.defines[0].is_member); let numbers: Vec<&str> = pre .tokens .iter() @@ -1520,6 +1647,59 @@ mod tests { assert_eq!(pre.defines.len(), 1); } + #[test] + fn backend_only_directives_are_validated_and_recorded() { + let (pre, _) = preprocess( + "#!excludeVariablesInCompilation\n#!extension projectiles\n#!setupTags\n#!setupTx\n#!translateWithPlayerVar noDetectionRule noTlErr\n#!disableInspector\n#!writeToOutputFile\n#!disableTranslationSourceLines\n#!keepUnusedTranslations\n#!useVariableForCompressionAlphabet\n#!debugElementCount\n#!globalvarInitRuleName \"Init globals\"\n#!playervarInitRuleName \"Init players\"\nrule \"r\":\n pass\n", + "main.opy", + Path::new("."), + ) + .unwrap(); + let names: Vec<&str> = pre + .preprocessing + .directives + .iter() + .map(|directive| directive.name.as_str()) + .collect(); + assert_eq!( + names, + vec![ + "excludeVariablesInCompilation", + "extension", + "setupTags", + "setupTx", + "translateWithPlayerVar", + "disableInspector", + "writeToOutputFile", + "disableTranslationSourceLines", + "keepUnusedTranslations", + "useVariableForCompressionAlphabet", + "debugElementCount", + "globalvarInitRuleName", + "playervarInitRuleName", + ] + ); + assert_eq!( + pre.preprocessing.directives[1].value.as_deref(), + Some("projectiles") + ); + assert_eq!( + pre.preprocessing.directives[4].value.as_deref(), + Some("noDetectionRule noTlErr") + ); + } + + #[test] + fn extension_directive_rejects_unknown_schema_values() { + let error = preprocess( + "#!extension notAnExtension\nrule \"r\":\n pass\n", + "main.opy", + Path::new("."), + ) + .unwrap_err(); + assert_eq!(error.code, "directive-invalid"); + } + #[test] fn translations_follow_pinned_codes_without_local_deduplication() { let (pre, _) = preprocess( diff --git a/crates/opy-rs/src/tooling.rs b/crates/opy-rs/src/tooling.rs index 22a60f2..80c8ea7 100644 --- a/crates/opy-rs/src/tooling.rs +++ b/crates/opy-rs/src/tooling.rs @@ -136,6 +136,7 @@ pub fn check_with_overlay( .map(|define| Define { name: define.name.clone(), is_function: define.is_function, + is_member: define.is_member, span: define.span.map(Into::into), }) .collect(); diff --git a/crates/opy-rs/support-matrix.json b/crates/opy-rs/support-matrix.json index dc85abd..3508f65 100644 --- a/crates/opy-rs/support-matrix.json +++ b/crates/opy-rs/support-matrix.json @@ -400,7 +400,7 @@ }, { "id": "preprocessing/include", - "name": "#!include: root-relative resolution, cycle detection, missing-file diagnostics, file registry", + "name": "#!include: including-file-relative resolution, cycle detection, missing-file diagnostics, file registry", "category": "preprocessing", "state": "source-supported", "evidence": [ @@ -411,24 +411,26 @@ "upstream:src/tests/include3.opy", "upstream:src/tests/include5.opy" ], - "notes": "Root-relative include resolution, cycle detection, missing-file diagnostics, file registry; deterministic multi-file tests." + "notes": "Includes resolve from the declaring source file, including nested closures; cycle detection, missing-file diagnostics, file registry, and deterministic multi-file provenance are covered." }, { "id": "preprocessing/define-undef", - "name": "#!define (object- and function-like), #!undef, recursive expansion, recursion guard", + "name": "#!define/#!defineMember (object- and function-like), #!undef, recursive expansion, recursion guard", "category": "preprocessing", "state": "source-supported", "evidence": [ "fixtures:synthetic/preprocessing", "fixtures:real-world/overpy-cake", + "test:opy-rs::compiler::integration_tests::issue_142_preprocessing::included_define_member_expands_with_definition_provenance", + "test:opy-rs::compiler::integration_tests::issue_142_preprocessing::nested_includes_resolve_relative_to_the_including_file", "upstream:src/tests/macros.opy", "upstream:src/tests/builtinmacros.opy" ], - "notes": "Object- and function-like #!define, #!undef, recursive expansion with recursion guard; preprocessing fixtures." + "notes": "Object- and function-like #!define/#!defineMember, #!undef, recursive expansion with recursion guard; member defines share the pinned textual macro contract and retain definition-site provenance." }, { "id": "preprocessing/advanced-directives", - "name": "#!mainFile, #!allowMacroRedeclaration, #!optimize*/#!replace0By* family, #!rulePrefix*", + "name": "#!mainFile, #!allowMacroRedeclaration, backend-only directive state, #!optimize*/#!replace0By* family, #!rulePrefix*", "category": "preprocessing", "state": "semantic-supported", "evidence": [ @@ -436,9 +438,11 @@ "fixtures:synthetic/issue-29-main-file", "upstream:src/tests/rulePrefix.opy", "upstream:src/tests/compression.opy", - "upstream:src/data/opy/preprocessing.ts" + "upstream:src/data/opy/preprocessing.ts", + "test:opy-rs::preprocess::tests::backend_only_directives_are_validated_and_recorded", + "test:opy-rs::preprocess::tests::extension_directive_rejects_unknown_schema_values" ], - "notes": "Issue #29: source implementation records directive state, macro-redeclaration policy, main-file provenance, and rule-prefix application. Optimizer and generated Workshop effects remain separate." + "notes": "Issue #29 plus #142: source implementation validates and records main-file provenance, macro-redeclaration policy, backend-only directive state, canonical extension names, and rule-prefix application. Optimizer, localization, extension point accounting, output-file, compression, and generated Workshop effects remain separate." }, { "id": "preprocessing/directive-effects", diff --git a/crates/opy-rs/tests/tooling.rs b/crates/opy-rs/tests/tooling.rs index 1b40697..be0e97a 100644 --- a/crates/opy-rs/tests/tooling.rs +++ b/crates/opy-rs/tests/tooling.rs @@ -149,6 +149,21 @@ fn multi_file_project_checks_and_resolves_end_to_end() { assert!(json["enums"].as_array().expect("enums array").len() == 1); } +#[test] +fn tooling_exposes_define_member_identity() { + let outcome = check( + "#!defineMember VALUE 2\nglobalvar result\nrule \"member define\":\n @Event global\n result = VALUE\n", + "main.opy", + Path::new("."), + ); + let model = outcome + .model + .expect("member define project must check clean"); + + assert_eq!(model.defines().len(), 1); + assert!(model.defines()[0].is_member); +} + /// Representative malformed inputs with their stable diagnostic codes (the /// machine contract: codes and source locations, not wording). #[rustfmt::skip] diff --git a/docs/hir/opy-hir-v1.md b/docs/hir/opy-hir-v1.md index 42cc9bc..6cb7279 100644 --- a/docs/hir/opy-hir-v1.md +++ b/docs/hir/opy-hir-v1.md @@ -102,13 +102,14 @@ Every payload is a JSON object with the following top-level fields. ### 2.4 `defines` -Preprocessing definitions (`#!define` constants and function macros) that the -source implementation expanded before parsing. They are recorded for provenance so a -diagnostic can explain where a value came from; they carry no semantic -payload because expansion already happened. +Preprocessing definitions (`#!define` constants and function macros, including +`#!defineMember`) that the source implementation expanded before parsing. They +are recorded for provenance and retain whether the definition came from the +member-macro surface; they carry no semantic payload because expansion already +happened. ```jsonc -{ "name": "CAKE_SIDE_LENGTH", "isFunction": false, "span": { "file": 0, "start": { "line": 10, "col": 1 }, "end": { "line": 10, "col": 24 } } } +{ "name": "CAKE_SIDE_LENGTH", "is_function": false, "is_member": false, "span": { "file": 0, "start": { "line": 10, "col": 1 }, "end": { "line": 10, "col": 24 } } } ``` ### 2.5 `settings` (v1.1.0, additive) diff --git a/docs/language-support/registries.md b/docs/language-support/registries.md index c156426..d2e7725 100644 --- a/docs/language-support/registries.md +++ b/docs/language-support/registries.md @@ -51,38 +51,39 @@ Reference: `889d9749d1def17f146548cbddb94ea1ab015847`. | Upstream key | Status | | --- | --- | -| `allowMacroRedeclaration` | 🚧 Coming soon | -| `define` | ✅ Supported | -| `debugElementCount` | 🚧 Coming soon | -| `disableInspector` | 🚧 Coming soon | -| `suppressWarnings` | 🚧 Coming soon | +| `allowMacroRedeclaration` | ✅ Source-supported | +| `define`, `defineMember` | ✅ Source-supported | +| `debugElementCount` | ✅ Source-supported | +| `disableInspector` | ✅ Source-supported | +| `suppressWarnings` | ✅ Source-supported | | `mainFile` | ✅ Supported | | `include` | ✅ Supported | -| `excludeVariablesInCompilation` | 🚧 Coming soon | -| `setupTags` | 🚧 Coming soon | -| `disableOptimizations` | 🚧 Coming soon | -| `enableOptimizations` | 🚧 Coming soon | -| `optimizeForSize` | 🚧 Coming soon | -| `optimizeForSizeAggressive` | 🚧 Coming soon | -| `disableOptimizeForSize` | 🚧 Coming soon | -| `optimizeStrict` | 🚧 Coming soon | -| `disableOptimizeStrict` | 🚧 Coming soon | -| `replace0ByCapturePercentage` | 🚧 Coming soon | -| `replace0ByPayloadProgressPercentage` | 🚧 Coming soon | -| `replace0ByIsMatchComplete` | 🚧 Coming soon | -| `replace1ByMatchRound` | 🚧 Coming soon | -| `replaceTeam1ByControlScoringTeam` | 🚧 Coming soon | -| `replaceEmptyStringByEmptyArray` | 🚧 Coming soon | -| `replaceEmptyStringByVariable` | 🚧 Coming soon | -| `translations` | 🚧 Coming soon | -| `translateWithPlayerVar` | 🚧 Coming soon | -| `useVariableForCompressionAlphabet` | 🚧 Coming soon | -| `extension` | 🚧 Coming soon | -| `globalvarInitRuleName` | 🚧 Coming soon | -| `playervarInitRuleName` | 🚧 Coming soon | -| `keepUnusedTranslations` | 🚧 Coming soon | -| `disableTranslationSourceLines` | 🚧 Coming soon | -| `writeToOutputFile` | 🚧 Coming soon | +| `excludeVariablesInCompilation` | ✅ Source-supported | +| `setupTags` | ✅ Source-supported | +| `setupTx` | ✅ Source-supported | +| `disableOptimizations` | ✅ Source-supported | +| `enableOptimizations` | ✅ Source-supported | +| `optimizeForSize` | ✅ Source-supported | +| `optimizeForSizeAggressive` | ✅ Source-supported | +| `disableOptimizeForSize` | ✅ Source-supported | +| `optimizeStrict` | ✅ Source-supported | +| `disableOptimizeStrict` | ✅ Source-supported | +| `replace0ByCapturePercentage` | ✅ Source-supported | +| `replace0ByPayloadProgressPercentage` | ✅ Source-supported | +| `replace0ByIsMatchComplete` | ✅ Source-supported | +| `replace1ByMatchRound` | ✅ Source-supported | +| `replaceTeam1ByControlScoringTeam` | ✅ Source-supported | +| `replaceEmptyStringByEmptyArray` | ✅ Source-supported | +| `replaceEmptyStringByVariable` | ✅ Source-supported | +| `translations` | ✅ Source-supported | +| `translateWithPlayerVar` | ✅ Source-supported | +| `useVariableForCompressionAlphabet` | ✅ Source-supported | +| `extension` | ✅ Source-supported | +| `globalvarInitRuleName` | ✅ Source-supported | +| `playervarInitRuleName` | ✅ Source-supported | +| `keepUnusedTranslations` | ✅ Source-supported | +| `disableTranslationSourceLines` | ✅ Source-supported | +| `writeToOutputFile` | ✅ Source-supported | | `postCompileHook` | ✅ Bounded compiler slice | | `rulePrefix`, `rulePrefixTemplate` | ✅ Supported | diff --git a/docs/language-support/syntax-and-projects.md b/docs/language-support/syntax-and-projects.md index 718129e..4d7dc5e 100644 --- a/docs/language-support/syntax-and-projects.md +++ b/docs/language-support/syntax-and-projects.md @@ -58,7 +58,7 @@ Source: pinned OverPy `9.7.10`, content commit | `continue` in loops | 🚧 Coming soon | Upstream keyword exists; end-to-end support is incomplete. | | `goto`, labels and dynamic `loc+` targets | 🚧 Coming soon | Audited from keyword registry and `src/tests/gotos.opy`. | | `pass` and `return` statements | ✅ Supported | Context restrictions remain conformance work. | -| `#!include` root-relative composition | ✅ Supported | Missing files and cycles have distinct failures. | +| `#!include` including-file-relative composition | ✅ Supported | Main-file includes resolve from the project root; nested includes resolve from the including source file. Missing files and cycles have distinct failures. | | Nested include closure and main-file selection | ✅ Supported | Project behavior is not inferred from one-file tests. | ## Settings, strings and translations diff --git a/docs/language-support/tooling-and-backend.md b/docs/language-support/tooling-and-backend.md index 3e869d6..1437139 100644 --- a/docs/language-support/tooling-and-backend.md +++ b/docs/language-support/tooling-and-backend.md @@ -9,13 +9,14 @@ upstream README, `overpy.d.ts`, `cli.js`, compiler/decompiler sources, | Feature | Status | Notes | | --- | --- | --- | -| `#!define` object/function macros and `#!undef` | ✅ Supported | Expansion, precedence and recursion are distinct checks. | -| `#!allowMacroRedeclaration` | 🚧 Coming soon | Changes duplicate-definition failure behavior. | -| `#!mainFile`, `#!include`, `#!excludeVariablesInCompilation` | ✅ Supported | Selection and output filtering have separate effects. | -| Optimization controls (`#!enableOptimizations`, `#!disableOptimizations`, `#!optimize*`) | 🚧 Coming soon | Recognition is not backend-effect support. | -| Replacement directives (`#!replace0By*`, team/string replacements) | 🚧 Coming soon | Each replacement target has its own output contract. | +| `#!define`/`#!defineMember` object/function macros and `#!undef` | ✅ Supported | Member defines use the same textual expansion contract and preserve definition-site provenance. | +| `#!allowMacroRedeclaration` | ✅ Supported | Duplicate-definition policy is represented in preprocessing state. | +| `#!mainFile`, `#!include`, `#!excludeVariablesInCompilation` | ✅ Supported | Main-file selection, including-file-relative composition, and output filtering have separate effects. | +| Optimization controls (`#!enableOptimizations`, `#!disableOptimizations`, `#!optimize*`) | ✅ Source-supported | Directive state is recorded; optimizer output remains a backend concern. | +| Replacement directives (`#!replace0By*`, team/string replacements) | ✅ Source-supported | Directive state is recorded; semantic replacement effects remain a backend concern. | | `#!rulePrefix` and `#!rulePrefixTemplate` | ✅ Supported | Source preprocessing applies the resulting rule names before compiler lowering. | -| `#!extension` and extension-point accounting | 🚧 Coming soon | Output metadata is part of the contract. | +| `#!extension` and extension-point accounting | ✅ Source-supported | The extension name is checked against the canonical Workshop schema and recorded; extension point accounting remains outside opy-rs. | +| Remaining pinned backend-only directives | ✅ Source-supported | `setupTags`, `setupTx`, translation/inspection/output/compression controls, and init-rule names are validated and recorded without approximating backend effects. | | `macro name(params)` function/constant macros | ✅ Supported | Defaults, keywords and member macros differ. | | `__script__` JavaScript macros | 🚧 Coming soon | QuickJS return ABI and limits are observable. | | `#!postCompileHook` | ✅ Bounded compiler slice | Runs only after final Workshop emission; failures keep directive and script provenance. | diff --git a/docs/opy/tooling-api.md b/docs/opy/tooling-api.md index 0bde708..978ab6d 100644 --- a/docs/opy/tooling-api.md +++ b/docs/opy/tooling-api.md @@ -80,7 +80,7 @@ pub struct CheckOutcome { | --- | --- | | `declarations()` | HIR declarations (globals, players, subroutines, constants, macros) | | `rules()` | Rule listing (rules and `def` subroutine definitions) | -| `defines()` | Recorded `#!define` macros with their definition-site spans | +| `defines()` | Recorded `#!define`/`#!defineMember` macros with their definition-site spans and member identity | | `enums()` | Custom `enum` declarations (CST-retained; they fold to constants in the HIR) | | `symbols()` / `symbol(name)` | Program-scope bindings with declaration site and reference sites | | `symbol_at(span)` | The binding or reference owner at a span | @@ -97,8 +97,9 @@ declarations (custom enums fold instead). Source provenance: the file registry maps every span's file id to its path. id 0 is the main file, then one entry per include, in include order. Macro expansion stamps expanded tokens with the use-site span; the recorded -`defines` carry their definition-site spans, so both define attribution and -include attribution are queryable through `provenance`. +`defines` carry their definition-site spans and whether they came from +`#!defineMember`, so both define attribution and include attribution are +queryable through `provenance`. ## Diagnostics contract