Skip to main content

opy_rs/
preprocess.rs

1//! `.opy` preprocessing: includes, `#!define`/`#!defineMember` macros (textual and
2//! `__script__` JavaScript-backed), `#!postCompileHook`, and expansion.
3//!
4//! Operates at the token level, matching the reference frontend's observable
5//! behavior: `#!include "file.opy"` splices the included file's tokens at the
6//! directive site; `#!define NAME value` and `#!define name(args) value`
7//! register macros that expand at their use sites, recursively (a macro may
8//! reference earlier macros). The output is a single-file token stream whose
9//! spans point at use sites, mirroring the reference adapter's provenance
10//! convention (the HIR file registry records the included sources). Invalid
11//! include graphs (cycles, missing files) and recursive defines fail
12//! deterministically with structured diagnostics that name the offending file/line.
13//!
14//! # JavaScript macros and hooks
15//!
16//! A function-like define whose replacement starts with `__script__("…")`
17//! (OverPy 9.7.10 ABI, `src/compiler/tokenizer.ts`) is a script macro: the
18//! script path resolves relative to the definition file (missing files are a
19//! `script-not-found` diagnostic, mirroring the reference's ENOENT failure),
20//! and each expansion runs the script through [`crate::macro_js::MacroRuntime`]
21//! with the call-site arguments injected as `var <name>=<raw>;` declarations
22//! (the reference's `resolveMacro`). The string completion value is lexed
23//! back into the token stream at the call site, with the reference's
24//! per-line indentation rule applied to the text; the frontend token model
25//! makes indentation unobservable (the parser never consumes it), so the rule
26//! is preserved in the expansion text only. Runtime failures map to the
27//! structured `script-*` diagnostics with the script path, line, and column.
28//!
29//! `#!postCompileHook "hook.js"` registers the post-compile hook script
30//! (duplicate declarations are rejected like the reference). The frontend
31//! recognizes, parses, validates, and records the directive only — it never
32//! executes the hook: real hook execution receives the final Workshop text
33//! produced by lowering and is lowering-dependent (workshop-rs emission,
34//! issue #8); the frontend never fabricates a Workshop payload.
35//!
36//! Boundary: `__script__` macros expand at compile time through the runtime
37//! (source-supported); `#!postCompileHook` is recorded and executed only
38//! against the real Workshop output (lowering-dependent). The runtime's hook
39//! ABI is tested separately on synthetic content in the internal macro runtime
40//! module (see its `hooks` test suite).
41
42use std::collections::{BTreeMap, BTreeSet};
43use std::path::{Path, PathBuf};
44
45use crate::diag::{OpyError, OpyResult, Span};
46use crate::hir::types::{
47    DirectiveRecord, DirectiveValue, OptimizationState, PreprocessingSnapshot, PreprocessingState,
48    TranslationState,
49};
50use crate::lexer::{LexInput, Token, TokenKind, lex};
51use crate::settings::SettingsBlock;
52
53mod directives;
54mod macros;
55mod project;
56mod scripts;
57
58use macros::{MacroArgument, MacroDef};
59use project::{display_path, first_main_file_directive};
60
61/// A recorded preprocessing define (HIR provenance).
62#[derive(Debug, Clone, PartialEq)]
63pub struct DefineRecord {
64    pub name: String,
65    pub is_function: bool,
66    pub is_member: bool,
67    pub span: Option<Span>,
68}
69
70/// A resolved `__script__("…")` macro backing.
71#[derive(Debug, Clone, PartialEq)]
72pub struct ScriptMacro {
73    /// The resolved project-relative script path, used for diagnostics and
74    /// runtime attribution.
75    pub path: String,
76    /// The script text, read at the define site.
77    pub source: String,
78}
79
80/// A registered `#!postCompileHook` script (the declaration record).
81///
82/// The frontend recognizes, parses, validates, and records the directive; it
83/// never executes the hook. Execution against the final Workshop text is
84/// lowering-dependent (issue #8).
85#[derive(Debug, Clone, PartialEq)]
86pub struct PostCompileHook {
87    /// The resolved project-relative script path.
88    pub path: String,
89    /// The script text, read at the directive site.
90    pub source: String,
91    /// The directive's source span, used for error attribution.
92    pub span: Span,
93}
94
95/// The result of preprocessing.
96#[derive(Debug, Clone)]
97pub struct Preprocessed {
98    /// The expanded, single-file token stream.
99    pub tokens: Vec<Token>,
100    /// The recorded defines in definition order.
101    pub defines: Vec<DefineRecord>,
102    /// The project `settings { ... }` block, when present (#86).
103    pub settings: Option<SettingsBlock>,
104    /// Warnings emitted while composing the project.
105    pub warnings: Vec<PreprocessWarning>,
106    /// The registered `#!postCompileHook` script, when declared.
107    pub post_compile_hook: Option<PostCompileHook>,
108    /// Frontend-visible preprocessing state; strict-sensitive lowering is
109    /// handled by the native compiler while other backend effects are not run.
110    pub preprocessing: PreprocessingState,
111}
112
113/// The output file registry, in include order, preserving source provenance.
114#[derive(Debug, Clone, PartialEq)]
115pub struct FileRecord {
116    pub id: u32,
117    pub path: String,
118}
119
120/// A source-attributed preprocessing warning.
121#[derive(Debug, Clone, PartialEq, Eq)]
122pub struct PreprocessWarning {
123    pub code: String,
124    pub message: String,
125    pub span: Span,
126}
127
128/// Preprocess the main source text with its include root.
129pub fn preprocess(
130    main_text: &str,
131    main_path: &str,
132    root: &Path,
133) -> OpyResult<(Preprocessed, Vec<FileRecord>)> {
134    preprocess_with_overlay(main_text, main_path, root, &BTreeMap::new())
135}
136
137/// Preprocess with open-document overlays: includes resolve to overlay text
138/// (keyed by the include string or the resolved canonical path) before the
139/// filesystem. Overlays model unsaved editor buffers without changing the
140/// compiler's source-loading contract.
141pub fn preprocess_with_overlay(
142    main_text: &str,
143    main_path: &str,
144    root: &Path,
145    overlay: &BTreeMap<String, String>,
146) -> OpyResult<(Preprocessed, Vec<FileRecord>)> {
147    preprocess_with_overlay_outcome(main_text, main_path, root, overlay).result
148}
149
150/// The outcome of preprocessing with overlays, retaining the file registry
151/// registered so far even when a directive or expansion fails, so callers can
152/// map an error's span file id to its actual source.
153pub struct PreprocessOutcome {
154    pub result: OpyResult<(Preprocessed, Vec<FileRecord>)>,
155    pub files: Vec<FileRecord>,
156    pub warnings: Vec<PreprocessWarning>,
157}
158
159/// Preprocess with open-document overlays while retaining the file registry
160/// registered so far on failure.
161pub fn preprocess_with_overlay_outcome(
162    main_text: &str,
163    main_path: &str,
164    root: &Path,
165    overlay: &BTreeMap<String, String>,
166) -> PreprocessOutcome {
167    let resolved_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
168    let mut pre = Preprocessor {
169        files: vec![FileRecord {
170            id: 0,
171            path: main_path.to_string(),
172        }],
173        next_file_id: 1,
174        root: resolved_root.clone(),
175        display_root: resolved_root,
176        overlay: overlay.clone(),
177        source_texts: BTreeMap::from([(0, main_text.to_string())]),
178        include_stack: Vec::new(),
179        last_macro_include_path: None,
180        imported_files: BTreeSet::new(),
181        macros: Vec::new(),
182        defines: Vec::new(),
183        post_compile_hook: None,
184        settings: None,
185        warnings: Vec::new(),
186        preprocessing: PreprocessingState::default(),
187    };
188    pre.preprocessing
189        .source_file_initial_optimization
190        .insert(0, pre.preprocessing.optimization.clone());
191    let mut owned_main_text = None;
192    let mut source_file_id = 0;
193    let first_line = main_text.lines().next().unwrap_or_default();
194    if first_line.trim_start().starts_with("#!mainFile")
195        && first_main_file_directive(main_text).is_none()
196    {
197        let span = Span::new(
198            0,
199            crate::diag::Position::new(1, 1),
200            crate::diag::Position::new(1, first_line.chars().count() as u32 + 1),
201        );
202        return PreprocessOutcome {
203            result: Err(OpyError::at(
204                "main-file-invalid",
205                "`#!mainFile` expects one quoted path on the first line",
206                span,
207            )),
208            files: pre.files,
209            warnings: pre.warnings,
210        };
211    }
212    if let Some((main_file, span)) = first_main_file_directive(main_text) {
213        let candidate = pre.root.join(&main_file);
214        let canonical = std::fs::canonicalize(&candidate).ok();
215        let overlay_text = overlay
216            .get(&main_file)
217            .or_else(|| {
218                canonical
219                    .as_ref()
220                    .and_then(|path| overlay.get(&path.to_string_lossy().into_owned()))
221            })
222            .cloned();
223        let (text, canonical_path, new_root) = match overlay_text {
224            Some(text) => {
225                let new_root = candidate
226                    .parent()
227                    .map(Path::to_path_buf)
228                    .unwrap_or_else(|| pre.root.clone());
229                (text, canonical, new_root)
230            }
231            None => {
232                let Some(canonical) = canonical else {
233                    return PreprocessOutcome {
234                        result: Err(OpyError::at(
235                            "main-file-not-found",
236                            format!("cannot find main file '{main_file}'"),
237                            span,
238                        )),
239                        files: pre.files,
240                        warnings: pre.warnings,
241                    };
242                };
243                let text = match std::fs::read_to_string(&canonical) {
244                    Ok(text) => text,
245                    Err(error) => {
246                        return PreprocessOutcome {
247                            result: Err(OpyError::at(
248                                "main-file-not-found",
249                                format!("cannot read main file '{main_file}': {error}"),
250                                span,
251                            )),
252                            files: pre.files,
253                            warnings: pre.warnings,
254                        };
255                    }
256                };
257                let new_root = canonical
258                    .parent()
259                    .map(Path::to_path_buf)
260                    .unwrap_or_else(|| pre.root.clone());
261                (text, Some(canonical), new_root)
262            }
263        };
264        let display_path =
265            display_path(&candidate, canonical_path.as_deref(), &new_root, &main_file);
266        source_file_id = 1;
267        pre.source_texts.insert(source_file_id, text.clone());
268        owned_main_text = Some(text);
269        pre.files.push(FileRecord {
270            id: source_file_id,
271            path: display_path,
272        });
273        pre.next_file_id = 2;
274        pre.preprocessing
275            .source_file_initial_optimization
276            .insert(source_file_id, pre.preprocessing.optimization.clone());
277        pre.root = new_root.clone();
278        pre.display_root = new_root;
279        pre.preprocessing.main_file = Some(DirectiveValue {
280            value: main_file.clone(),
281            span: Some(span.into()),
282        });
283        pre.record("mainFile", Some(&main_file), span);
284    }
285    let source_text = owned_main_text.as_deref().unwrap_or(main_text);
286    // The project settings block is extracted before lexing and blanked out of
287    // the owning file's lexed text, so the lexer never sees its braces (#86).
288    let settings = match crate::settings::find_blocks(source_text, source_file_id) {
289        Ok(mut blocks) => blocks.pop(),
290        Err(error) => {
291            return PreprocessOutcome {
292                result: Err(error),
293                files: pre.files,
294                warnings: pre.warnings,
295            };
296        }
297    };
298    let tokens = match &settings {
299        Some(block) => {
300            let sanitized = crate::settings::sanitize_for_lex(source_text, block);
301            lex(LexInput {
302                file_id: source_file_id,
303                text: &sanitized,
304            })
305        }
306        None => lex(LexInput {
307            file_id: source_file_id,
308            text: source_text,
309        }),
310    };
311    let mut tokens = match tokens {
312        Ok(tokens) => tokens,
313        Err(error) => {
314            return PreprocessOutcome {
315                result: Err(error),
316                files: pre.files,
317                warnings: pre.warnings,
318            };
319        }
320    };
321    if let Err(error) = pre.process_directives(&mut tokens, false, settings) {
322        return PreprocessOutcome {
323            result: Err(error),
324            files: pre.files,
325            warnings: pre.warnings,
326        };
327    }
328    let result = Ok((
329        Preprocessed {
330            tokens,
331            defines: pre.defines,
332            settings: pre.settings,
333            warnings: pre.warnings.clone(),
334            post_compile_hook: pre.post_compile_hook,
335            preprocessing: pre.preprocessing,
336        },
337        pre.files.clone(),
338    ));
339    PreprocessOutcome {
340        result,
341        files: pre.files,
342        warnings: pre.warnings,
343    }
344}
345
346fn render_tokens(tokens: &[Token]) -> String {
347    let mut rendered = String::new();
348    let mut previous: Option<&Token> = None;
349    for token in tokens {
350        if token.kind == TokenKind::Eof {
351            continue;
352        }
353        if let Some(previous) = previous
354            && can_merge_without_separator(previous.kind, token.kind)
355        {
356            rendered.push(' ');
357        }
358        if token.kind == TokenKind::Newline {
359            rendered.push('\n');
360        } else if token.kind == TokenKind::String {
361            rendered.push('"');
362            rendered.push_str(token.raw.as_deref().unwrap_or(&token.text));
363            rendered.push('"');
364        } else {
365            rendered.push_str(&token.text);
366        }
367        previous = Some(token);
368    }
369    rendered
370}
371
372fn can_merge_without_separator(previous: TokenKind, current: TokenKind) -> bool {
373    matches!(previous, TokenKind::Ident | TokenKind::Number)
374        && matches!(current, TokenKind::Ident | TokenKind::Number)
375}
376
377fn shift_settings_span(span: Span, origin: crate::diag::Position) -> Span {
378    fn shift(
379        position: crate::diag::Position,
380        origin: crate::diag::Position,
381    ) -> crate::diag::Position {
382        crate::diag::Position::new(
383            origin.line + position.line.saturating_sub(1),
384            if position.line == 1 {
385                origin.col + position.col.saturating_sub(1)
386            } else {
387                position.col
388            },
389        )
390    }
391    Span::new(
392        span.file,
393        shift(span.start, origin),
394        shift(span.end, origin),
395    )
396}
397
398struct Preprocessor {
399    files: Vec<FileRecord>,
400    next_file_id: u32,
401    root: PathBuf,
402    display_root: PathBuf,
403    overlay: BTreeMap<String, String>,
404    source_texts: BTreeMap<u32, String>,
405    include_stack: Vec<PathBuf>,
406    // OverPy retains this file context after multiline macro expansion in an
407    // included settings block; established projects rely on that lookup base.
408    last_macro_include_path: Option<PathBuf>,
409    imported_files: BTreeSet<PathBuf>,
410    macros: Vec<MacroDef>,
411    settings: Option<SettingsBlock>,
412    defines: Vec<DefineRecord>,
413    post_compile_hook: Option<PostCompileHook>,
414    warnings: Vec<PreprocessWarning>,
415    preprocessing: PreprocessingState,
416}
417
418impl Preprocessor {
419    /// Apply the same token macro expansion used by ordinary OPY source to
420    /// the extracted settings values. Settings are removed before the source
421    /// token stream is processed, so this pass is the point where `#!define`
422    /// values become visible to the settings parser.
423    pub(super) fn expand_settings(&self, block: SettingsBlock) -> OpyResult<SettingsBlock> {
424        let tokens = lex(LexInput {
425            file_id: block.span.file,
426            text: &block.text,
427        })?;
428        let mut tokens = tokens;
429        for token in &mut tokens {
430            token.span = shift_settings_span(token.span, block.text_start);
431        }
432        let tokens = self.expand(tokens)?;
433        Ok(SettingsBlock {
434            text: render_tokens(&tokens),
435            ..block
436        })
437    }
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443
444    #[test]
445    fn object_define_expands_at_use_site() {
446        let (pre, _) = preprocess(
447            "#!define SIDE 1.5\nrule \"r\":\n    x = SIDE\n",
448            "main.opy",
449            Path::new("."),
450        )
451        .unwrap();
452        assert_eq!(pre.defines.len(), 1);
453        assert_eq!(pre.defines[0].name, "SIDE");
454        assert!(!pre.defines[0].is_function);
455        assert!(!pre.defines[0].is_member);
456        let numbers: Vec<&str> = pre
457            .tokens
458            .iter()
459            .filter(|t| t.kind == TokenKind::Number)
460            .map(|t| t.text.as_str())
461            .collect();
462        assert_eq!(numbers, vec!["1.5"]);
463    }
464
465    #[test]
466    fn function_define_substitutes_params() {
467        let (pre, _) = preprocess(
468            "#!define double(x) x + x\nrule \"r\":\n    y = double(3)\n",
469            "main.opy",
470            Path::new("."),
471        )
472        .unwrap();
473        let numbers: Vec<&str> = pre
474            .tokens
475            .iter()
476            .filter(|t| t.kind == TokenKind::Number)
477            .map(|t| t.text.as_str())
478            .collect();
479        assert_eq!(numbers, vec!["3", "3"]);
480    }
481
482    #[test]
483    fn function_define_keeps_commas_inside_nested_collections() {
484        let (pre, _) = preprocess(
485            "#!define first(xs, fallback) xs[0]\nrule \"r\":\n    x = first([1, 2], 3)\n",
486            "main.opy",
487            Path::new("."),
488        )
489        .unwrap();
490        let numbers: Vec<&str> = pre
491            .tokens
492            .iter()
493            .filter(|token| token.kind == TokenKind::Number)
494            .map(|token| token.text.as_str())
495            .collect();
496        assert_eq!(numbers, vec!["1", "2", "0"]);
497    }
498
499    #[test]
500    fn zero_argument_function_define_accepts_empty_invocation() {
501        let (pre, _) = preprocess(
502            "#!define value() 3\nrule \"r\":\n    x = value()\n",
503            "main.opy",
504            Path::new("."),
505        )
506        .unwrap();
507        let numbers: Vec<&str> = pre
508            .tokens
509            .iter()
510            .filter(|token| token.kind == TokenKind::Number)
511            .map(|token| token.text.as_str())
512            .collect();
513        assert_eq!(numbers, vec!["3"]);
514    }
515
516    #[test]
517    fn macro_expanded_string_can_concatenate_with_following_literal() {
518        let (pre, _) = preprocess(
519            "#!define PREFIX \"one\"\nrule \"r\":\n    debug(PREFIX\n        \"two\")\n",
520            "main.opy",
521            Path::new("."),
522        )
523        .unwrap();
524        let output = crate::parser::parse(&pre.tokens);
525        assert!(
526            output.errors.is_empty(),
527            "unexpected errors: {:?}",
528            output.errors
529        );
530        let program = output.program.expect("expanded source must parse");
531        let crate::cst::RuleEntry::Rule(rule) = &program.rules[0] else {
532            panic!("expected rule");
533        };
534        let crate::cst::Stmt::Expr { expr, .. } = &rule.actions[0] else {
535            panic!("expected expression statement");
536        };
537        let crate::cst::Expr::Call { args, .. } = expr else {
538            panic!("expected call");
539        };
540        assert!(matches!(
541            &args[0].value,
542            crate::cst::Expr::String { value, .. } if value == "onetwo"
543        ));
544    }
545
546    #[test]
547    fn recursive_defines_expand_transitively() {
548        let (pre, _) = preprocess(
549            "#!define A 2\n#!define B A + 1\nrule \"r\":\n    x = B\n",
550            "main.opy",
551            Path::new("."),
552        )
553        .unwrap();
554        let numbers: Vec<&str> = pre
555            .tokens
556            .iter()
557            .filter(|t| t.kind == TokenKind::Number)
558            .map(|t| t.text.as_str())
559            .collect();
560        assert_eq!(numbers, vec!["2", "1"]);
561    }
562
563    #[test]
564    fn recursive_define_fails_structurally() {
565        let error = preprocess(
566            "#!define X X + 1\nrule \"r\":\n    x = X\n",
567            "main.opy",
568            Path::new("."),
569        )
570        .unwrap_err();
571        assert_eq!(error.code, "macro-recursion");
572    }
573
574    #[test]
575    fn defines_are_only_visible_after_their_directive() {
576        let (pre, _) = preprocess(
577            "VALUE\n#!define VALUE 1\nVALUE\n#!allowMacroRedeclaration\n#!define VALUE 2\nVALUE\n",
578            "main.opy",
579            Path::new("."),
580        )
581        .expect("ordered define expansion");
582        let numbers: Vec<&str> = pre
583            .tokens
584            .iter()
585            .filter(|token| token.kind == TokenKind::Number)
586            .map(|token| token.text.as_str())
587            .collect();
588        assert_eq!(numbers, vec!["1", "2"]);
589        assert!(pre.tokens.iter().any(|token| {
590            token.kind == TokenKind::Ident && token.text == "VALUE" && token.span.start.line == 1
591        }));
592    }
593
594    #[test]
595    fn function_define_substitutes_inside_string_text() {
596        let (pre, _) = preprocess(
597            "#!define wrap(value) \"value\"\nwrap(foo + bar)\n",
598            "main.opy",
599            Path::new("."),
600        )
601        .expect("textual define expansion");
602        let strings: Vec<&str> = pre
603            .tokens
604            .iter()
605            .filter(|token| token.kind == TokenKind::String)
606            .map(|token| token.text.as_str())
607            .collect();
608        assert_eq!(strings, vec!["foo + bar"]);
609    }
610
611    #[test]
612    fn multiline_define_preserves_relative_expansion_spans() {
613        let (pre, _) = preprocess(
614            "#!define block() A = 1\\\n    A = 2\nblock()\n",
615            "main.opy",
616            Path::new("."),
617        )
618        .expect("multiline define expansion");
619        let numbers: Vec<Span> = pre
620            .tokens
621            .iter()
622            .filter(|token| token.kind == TokenKind::Number)
623            .map(|token| token.span)
624            .collect();
625        assert_eq!(numbers.len(), 2);
626        assert_eq!(numbers[0].start.line, 3);
627        assert_eq!(numbers[1].start.line, 4);
628        assert_eq!(numbers[1].start.col, 9);
629    }
630
631    #[test]
632    fn function_define_requires_a_call_with_the_expected_arity() {
633        let error = preprocess(
634            "#!define value(argument) argument + 1\nvalue\n",
635            "main.opy",
636            Path::new("."),
637        )
638        .expect_err("a function-like define used without parentheses must fail arity checking");
639        assert_eq!(error.code, "macro-arity");
640    }
641
642    #[test]
643    fn exact_object_define_self_reference_is_rejected_at_definition() {
644        let error = preprocess("#!define VALUE VALUE\n", "main.opy", Path::new("."))
645            .expect_err("an exact object define self-reference must fail at definition");
646        assert_eq!(error.code, "macro-recursion");
647
648        let error = preprocess(
649            "#!define value(argument) value(argument)\n",
650            "main.opy",
651            Path::new("."),
652        )
653        .expect_err("an exact function define self-reference must fail at definition");
654        assert_eq!(error.code, "macro-recursion");
655    }
656
657    #[test]
658    fn missing_include_is_structured() {
659        let error = preprocess(
660            "#!include \"nope.opy\"\n",
661            "main.opy",
662            Path::new("/nonexistent-root"),
663        )
664        .unwrap_err();
665        assert_eq!(error.code, "include-not-found");
666        assert!(error.span.is_some());
667    }
668
669    #[test]
670    fn include_cycle_is_detected() {
671        let dir = std::env::temp_dir().join(format!("wright-opy-test-{}", std::process::id()));
672        std::fs::create_dir_all(&dir).unwrap();
673        std::fs::write(dir.join("a.opy"), "#!include \"b.opy\"\n").unwrap();
674        std::fs::write(dir.join("b.opy"), "#!include \"a.opy\"\n").unwrap();
675        let main = std::fs::read_to_string(dir.join("a.opy")).unwrap();
676        let error = preprocess(&main, "a.opy", &dir).unwrap_err();
677        assert_eq!(error.code, "include-cycle");
678        let _ = std::fs::remove_dir_all(&dir);
679    }
680
681    #[test]
682    fn unsupported_directive_is_structured() {
683        let error = preprocess("#!frobnicate\n", "main.opy", Path::new(".")).unwrap_err();
684        assert_eq!(error.code, "unsupported-directive");
685    }
686
687    #[test]
688    fn settings_block_is_extracted_before_lexing() {
689        let (pre, _) = preprocess(
690            "settings {\n    \"gamemodes\": {}\n}\nrule \"r\":\n    pass\n",
691            "main.opy",
692            Path::new("."),
693        )
694        .unwrap();
695        let block = pre.settings.expect("settings block extracted");
696        assert!(block.text.contains("gamemodes"));
697        // The block never enters the token stream.
698        assert!(
699            !pre.tokens.iter().any(|t| t.text.contains("gamemodes")),
700            "settings content must not be lexed"
701        );
702    }
703
704    #[test]
705    fn settings_in_include_is_extracted_with_source_provenance() {
706        let overlay = BTreeMap::from([(
707            "shared.opy".to_string(),
708            "settings {\n    \"gamemodes\": {}\n}\n".to_string(),
709        )]);
710        let main = "#!include \"shared.opy\"\nrule \"r\":\n    pass\n";
711        let (pre, files) = preprocess_with_overlay(main, "main.opy", Path::new("."), &overlay)
712            .expect("included settings must be extracted");
713        let block = pre.settings.expect("included settings block");
714        assert_eq!(block.keyword_span.file, 1);
715        assert_eq!(files[1].path, "shared.opy");
716        assert!(!pre.tokens.iter().any(|token| token.text == "gamemodes"));
717    }
718
719    #[test]
720    fn duplicate_include_is_skipped_without_redeclaring_macros() {
721        let overlay =
722            BTreeMap::from([("shared.opy".to_string(), "#!define VALUE 2\n".to_string())]);
723        let main =
724            "#!include \"shared.opy\"\n#!include \"shared.opy\"\nrule \"r\":\n    x = VALUE\n";
725        let (pre, files) = preprocess_with_overlay(main, "main.opy", Path::new("."), &overlay)
726            .expect("duplicate includes must not redeclare macros");
727        assert_eq!(pre.defines.len(), 1);
728        assert_eq!(files.len(), 2);
729        assert_eq!(pre.warnings.len(), 1);
730        assert_eq!(pre.warnings[0].code, "w_already_imported");
731        assert_eq!(
732            pre.preprocessing
733                .directives
734                .iter()
735                .filter(|directive| directive.name == "include")
736                .count(),
737            2
738        );
739    }
740
741    #[test]
742    fn alias_include_paths_are_distinct_imports() {
743        let overlay = BTreeMap::from([
744            ("shared.opy".to_string(), "#!define FIRST 1\n".to_string()),
745            (
746                "dir/../shared.opy".to_string(),
747                "#!define SECOND 2\n".to_string(),
748            ),
749        ]);
750        let main = "#!include \"shared.opy\"\n#!include \"dir/../shared.opy\"\nrule \"r\":\n    x = FIRST\n    y = SECOND\n";
751        let (pre, files) = preprocess_with_overlay(main, "main.opy", Path::new("."), &overlay)
752            .expect("alias include paths must remain distinct imports");
753        assert_eq!(files.len(), 3);
754        assert_eq!(files[1].path, "shared.opy");
755        assert_eq!(files[2].path, "dir/../shared.opy");
756        assert_eq!(pre.defines.len(), 2);
757        assert!(pre.warnings.is_empty());
758    }
759
760    #[test]
761    fn dict_literal_braces_reach_the_parser() {
762        // Scoped settings lexing must not consume expression-level braces.
763        let (pre, _) = preprocess(
764            "rule \"r\":\n    money += {\n        Mei.GENERIC: 10,\n    }\n",
765            "main.opy",
766            Path::new("."),
767        )
768        .unwrap();
769        assert!(
770            pre.tokens
771                .iter()
772                .any(|token| token.kind == TokenKind::LBrace)
773        );
774        assert!(
775            pre.tokens
776                .iter()
777                .any(|token| token.kind == TokenKind::RBrace)
778        );
779    }
780
781    #[test]
782    fn advanced_directives_preserve_frontend_state_without_catalog_data() {
783        let (pre, _) = preprocess(
784            "#!allowMacroRedeclaration\n#!translations en fr\n#!rulePrefix \"Effects\"\n#!optimizeForSize\n#!optimizeStrict\n#!replace0ByCapturePercentage\n#!define VALUE 1\n#!define VALUE 2\nrule \"r\":\n    x = VALUE\n",
785            "main.opy",
786            Path::new("."),
787        )
788        .unwrap();
789        assert!(pre.preprocessing.allow_macro_redeclaration);
790        assert_eq!(
791            pre.preprocessing
792                .translations
793                .as_ref()
794                .map(|state| state.languages.as_slice()),
795            Some(["en".to_string(), "fr".to_string()].as_slice())
796        );
797        assert_eq!(
798            pre.preprocessing
799                .rule_prefix
800                .as_ref()
801                .map(|value| value.value.as_str()),
802            Some("Effects")
803        );
804        assert!(pre.preprocessing.optimization.for_size);
805        assert!(pre.preprocessing.optimization.strict);
806        assert_eq!(
807            pre.preprocessing.replacements[0].value,
808            "getCapturePercentage"
809        );
810        assert_eq!(pre.defines.len(), 1);
811    }
812
813    #[test]
814    fn backend_only_directives_are_validated_and_recorded() {
815        let (pre, _) = preprocess(
816            "#!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",
817            "main.opy",
818            Path::new("."),
819        )
820        .unwrap();
821        let names: Vec<&str> = pre
822            .preprocessing
823            .directives
824            .iter()
825            .map(|directive| directive.name.as_str())
826            .collect();
827        assert_eq!(
828            names,
829            vec![
830                "excludeVariablesInCompilation",
831                "extension",
832                "setupTags",
833                "setupTx",
834                "translateWithPlayerVar",
835                "disableInspector",
836                "writeToOutputFile",
837                "disableTranslationSourceLines",
838                "keepUnusedTranslations",
839                "useVariableForCompressionAlphabet",
840                "debugElementCount",
841                "globalvarInitRuleName",
842                "playervarInitRuleName",
843            ]
844        );
845        assert_eq!(
846            pre.preprocessing.directives[1].value.as_deref(),
847            Some("projectiles")
848        );
849        assert_eq!(
850            pre.preprocessing.directives[4].value.as_deref(),
851            Some("noDetectionRule noTlErr")
852        );
853    }
854
855    #[test]
856    fn extension_directive_rejects_unknown_schema_values() {
857        let error = preprocess(
858            "#!extension notAnExtension\nrule \"r\":\n    pass\n",
859            "main.opy",
860            Path::new("."),
861        )
862        .unwrap_err();
863        assert_eq!(error.code, "directive-invalid");
864        assert_eq!(error.span.expect("directive provenance").start.line, 1);
865    }
866
867    #[test]
868    fn translations_follow_pinned_codes_without_local_deduplication() {
869        let (pre, _) = preprocess(
870            "#!translations EN zh-cn en\nrule \"r\":\n    pass\n",
871            "main.opy",
872            Path::new("."),
873        )
874        .unwrap();
875        assert_eq!(
876            pre.preprocessing.translations.unwrap().languages,
877            vec!["en", "zh_cn", "en"]
878        );
879    }
880
881    #[test]
882    fn translations_reject_codes_outside_the_pinned_oracle_set() {
883        let error = preprocess(
884            "#!translations en_US\nrule \"r\":\n    pass\n",
885            "main.opy",
886            Path::new("."),
887        )
888        .unwrap_err();
889        assert_eq!(error.code, "translations-invalid");
890    }
891
892    #[test]
893    fn directive_records_expose_state_transitions_and_include_depth() {
894        let root =
895            std::env::temp_dir().join(format!("wright-opy-directive-scope-{}", std::process::id()));
896        std::fs::create_dir_all(&root).unwrap();
897        std::fs::write(
898            root.join("child.opy"),
899            "#!rulePrefix \"inner\"\n#!disableOptimizations\n",
900        )
901        .unwrap();
902        let (pre, _) = preprocess(
903            "#!rulePrefix \"outer\"\n#!include \"child.opy\"\n#!enableOptimizations\n",
904            "main.opy",
905            &root,
906        )
907        .unwrap();
908        let records = &pre.preprocessing.directives;
909        assert_eq!(records[0].state.rule_prefix.as_deref(), Some("outer"));
910        assert_eq!(records[0].scope_depth, 0);
911        assert_eq!(records[1].name, "rulePrefix");
912        assert_eq!(records[1].state.rule_prefix.as_deref(), Some("inner"));
913        assert!(!records[2].state.optimization.enabled);
914        assert_eq!(records[2].scope_depth, 1);
915        assert_eq!(records[3].name, "include");
916        assert_eq!(records[3].state.rule_prefix.as_deref(), Some("outer"));
917        assert_eq!(records[4].name, "enableOptimizations");
918        assert!(records[4].state.optimization.enabled);
919        let _ = std::fs::remove_dir_all(&root);
920    }
921
922    #[test]
923    fn malformed_translation_state_is_source_located() {
924        let error = preprocess("#!translations\n", "main.opy", Path::new(".")).unwrap_err();
925        assert_eq!(error.code, "translations-invalid");
926        assert!(error.span.is_some());
927    }
928}