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