Skip to main content

opy_rs/
preprocess.rs

1//! `.opy` preprocessing: includes, `#!define` 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 keeps the main file). Invalid include
11//! graphs (cycles, missing files) and recursive defines fail deterministically
12//! 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 root-relative at the define site (missing files are a
19//! `script-not-found` diagnostic, mirroring the reference's ENOENT failure),
20//! and each expansion runs the script through [`opy_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 `opy-macro-js` (see its
40//! `hooks` test suite).
41
42use std::collections::BTreeMap;
43use std::path::{Path, PathBuf};
44
45use opy_macro_js::{Limits, MacroArg, MacroError, MacroRuntime};
46
47use crate::diag::{OpyError, OpyResult, Span};
48use crate::hir::types::{
49    DirectiveRecord, DirectiveValue, OptimizationState, PreprocessingSnapshot, PreprocessingState,
50    TranslationState,
51};
52use crate::lexer::{LexInput, Token, TokenKind, lex};
53use crate::settings::SettingsBlock;
54
55/// A recorded preprocessing define (HIR provenance).
56#[derive(Debug, Clone, PartialEq)]
57pub struct DefineRecord {
58    pub name: String,
59    pub is_function: bool,
60    pub span: Option<Span>,
61}
62
63/// A resolved `__script__("…")` macro backing.
64#[derive(Debug, Clone, PartialEq)]
65pub struct ScriptMacro {
66    /// The script path as declared (root-relative), used for diagnostics and
67    /// runtime attribution.
68    pub path: String,
69    /// The script text, read at the define site.
70    pub source: String,
71}
72
73/// A registered `#!postCompileHook` script (the declaration record).
74///
75/// The frontend recognizes, parses, validates, and records the directive; it
76/// never executes the hook. Execution against the final Workshop text is
77/// lowering-dependent (issue #8).
78#[derive(Debug, Clone, PartialEq)]
79pub struct PostCompileHook {
80    /// The script path as declared (root-relative).
81    pub path: String,
82    /// The script text, read at the directive site.
83    pub source: String,
84    /// The directive's source span, used for error attribution.
85    pub span: Span,
86}
87
88/// The result of preprocessing.
89#[derive(Debug, Clone)]
90pub struct Preprocessed {
91    /// The expanded, single-file token stream.
92    pub tokens: Vec<Token>,
93    /// The recorded defines in definition order.
94    pub defines: Vec<DefineRecord>,
95    /// The top-of-file `settings { ... }` block, when present (#86).
96    pub settings: Option<SettingsBlock>,
97    /// The registered `#!postCompileHook` script, when declared.
98    pub post_compile_hook: Option<PostCompileHook>,
99    /// Frontend-visible preprocessing state; backend effects are not run.
100    pub preprocessing: PreprocessingState,
101}
102
103/// The output file registry: the main file only (reference convention).
104#[derive(Debug, Clone, PartialEq)]
105pub struct FileRecord {
106    pub id: u32,
107    pub path: String,
108}
109
110/// Preprocess the main source text with its include root.
111pub fn preprocess(
112    main_text: &str,
113    main_path: &str,
114    root: &Path,
115) -> OpyResult<(Preprocessed, Vec<FileRecord>)> {
116    preprocess_with_overlay(main_text, main_path, root, &BTreeMap::new())
117}
118
119/// Preprocess with open-document overlays: includes resolve to overlay text
120/// (keyed by the include string or the resolved canonical path) before the
121/// filesystem. Overlays model unsaved editor buffers without changing the
122/// compiler's source-loading contract.
123pub fn preprocess_with_overlay(
124    main_text: &str,
125    main_path: &str,
126    root: &Path,
127    overlay: &BTreeMap<String, String>,
128) -> OpyResult<(Preprocessed, Vec<FileRecord>)> {
129    preprocess_with_overlay_outcome(main_text, main_path, root, overlay).result
130}
131
132/// The outcome of preprocessing with overlays, retaining the file registry
133/// registered so far even when a directive or expansion fails, so callers can
134/// map an error's span file id to its actual source.
135pub struct PreprocessOutcome {
136    pub result: OpyResult<(Preprocessed, Vec<FileRecord>)>,
137    pub files: Vec<FileRecord>,
138}
139
140/// Preprocess with open-document overlays while retaining the file registry
141/// registered so far on failure.
142pub fn preprocess_with_overlay_outcome(
143    main_text: &str,
144    main_path: &str,
145    root: &Path,
146    overlay: &BTreeMap<String, String>,
147) -> PreprocessOutcome {
148    let mut pre = Preprocessor {
149        files: vec![FileRecord {
150            id: 0,
151            path: main_path.to_string(),
152        }],
153        next_file_id: 1,
154        root: root.to_path_buf(),
155        overlay: overlay.clone(),
156        include_stack: Vec::new(),
157        macros: Vec::new(),
158        defines: Vec::new(),
159        post_compile_hook: None,
160        preprocessing: PreprocessingState::default(),
161    };
162    let mut owned_main_text = None;
163    let mut source_file_id = 0;
164    let first_line = main_text.lines().next().unwrap_or_default();
165    if first_line.trim_start().starts_with("#!mainFile")
166        && first_main_file_directive(main_text).is_none()
167    {
168        let span = Span::new(
169            0,
170            crate::diag::Position::new(1, 1),
171            crate::diag::Position::new(1, first_line.chars().count() as u32 + 1),
172        );
173        return PreprocessOutcome {
174            result: Err(OpyError::at(
175                "main-file-invalid",
176                "`#!mainFile` expects one quoted path on the first line",
177                span,
178            )),
179            files: pre.files,
180        };
181    }
182    if let Some((main_file, span)) = first_main_file_directive(main_text) {
183        let candidate = root.join(&main_file);
184        let canonical = std::fs::canonicalize(&candidate).ok();
185        let overlay_text = overlay
186            .get(&main_file)
187            .or_else(|| {
188                canonical
189                    .as_ref()
190                    .and_then(|path| overlay.get(&path.to_string_lossy().into_owned()))
191            })
192            .cloned();
193        let (text, display_path, new_root) = match overlay_text {
194            Some(text) => {
195                let display_path = candidate.to_string_lossy().into_owned();
196                let new_root = candidate
197                    .parent()
198                    .map(Path::to_path_buf)
199                    .unwrap_or_else(|| root.to_path_buf());
200                (text, display_path, new_root)
201            }
202            None => {
203                let Some(canonical) = canonical else {
204                    return PreprocessOutcome {
205                        result: Err(OpyError::at(
206                            "main-file-not-found",
207                            format!("cannot find main file '{main_file}'"),
208                            span,
209                        )),
210                        files: pre.files,
211                    };
212                };
213                let text = match std::fs::read_to_string(&canonical) {
214                    Ok(text) => text,
215                    Err(error) => {
216                        return PreprocessOutcome {
217                            result: Err(OpyError::at(
218                                "main-file-not-found",
219                                format!("cannot read main file '{main_file}': {error}"),
220                                span,
221                            )),
222                            files: pre.files,
223                        };
224                    }
225                };
226                let new_root = canonical
227                    .parent()
228                    .map(Path::to_path_buf)
229                    .unwrap_or_else(|| root.to_path_buf());
230                (text, canonical.to_string_lossy().into_owned(), new_root)
231            }
232        };
233        owned_main_text = Some(text);
234        source_file_id = 1;
235        pre.files.push(FileRecord {
236            id: source_file_id,
237            path: display_path,
238        });
239        pre.next_file_id = 2;
240        pre.root = new_root;
241        pre.preprocessing.main_file = Some(DirectiveValue {
242            value: main_file.clone(),
243            span: Some(span.into()),
244        });
245        pre.record("mainFile", Some(&main_file), span);
246    }
247    let source_text = owned_main_text.as_deref().unwrap_or(main_text);
248    // The top-of-file settings block is extracted before lexing and blanked
249    // out of the lexed text, so the lexer never sees the block's braces
250    // (scoped settings lexing, #86).
251    let settings = match crate::settings::find_blocks(source_text, source_file_id) {
252        Ok(mut blocks) => blocks.pop(),
253        Err(error) => {
254            return PreprocessOutcome {
255                result: Err(error),
256                files: pre.files,
257            };
258        }
259    };
260    let tokens = match &settings {
261        Some(block) => {
262            let sanitized = crate::settings::sanitize_for_lex(source_text, block);
263            lex(LexInput {
264                file_id: source_file_id,
265                text: &sanitized,
266            })
267        }
268        None => lex(LexInput {
269            file_id: source_file_id,
270            text: source_text,
271        }),
272    };
273    let mut tokens = match tokens {
274        Ok(tokens) => tokens,
275        Err(error) => {
276            return PreprocessOutcome {
277                result: Err(error),
278                files: pre.files,
279            };
280        }
281    };
282    if let Err(error) = pre.process_directives(&mut tokens) {
283        return PreprocessOutcome {
284            result: Err(error),
285            files: pre.files,
286        };
287    }
288    match pre.expand(tokens) {
289        Ok(tokens) => {
290            let result = Ok((
291                Preprocessed {
292                    tokens,
293                    defines: pre.defines,
294                    settings,
295                    post_compile_hook: pre.post_compile_hook,
296                    preprocessing: pre.preprocessing,
297                },
298                pre.files.clone(),
299            ));
300            PreprocessOutcome {
301                result,
302                files: pre.files,
303            }
304        }
305        Err(error) => PreprocessOutcome {
306            result: Err(error),
307            files: pre.files,
308        },
309    }
310}
311
312struct Preprocessor {
313    files: Vec<FileRecord>,
314    next_file_id: u32,
315    root: PathBuf,
316    overlay: BTreeMap<String, String>,
317    include_stack: Vec<PathBuf>,
318    macros: Vec<MacroDef>,
319    defines: Vec<DefineRecord>,
320    post_compile_hook: Option<PostCompileHook>,
321    preprocessing: PreprocessingState,
322}
323
324/// A registered macro: object-like, function-like, or a script macro.
325struct MacroDef {
326    name: String,
327    params: Vec<String>,
328    body: Vec<Token>,
329    /// True when the body came from a `#!define name(args) value` form.
330    is_function: bool,
331    /// The resolved `__script__` backing, when the replacement is one.
332    script: Option<ScriptMacro>,
333}
334
335fn first_main_file_directive(text: &str) -> Option<(String, Span)> {
336    let line = text.lines().next()?.trim_end_matches('\r');
337    let rest = line.strip_prefix("#!mainFile")?;
338    let value = rest.trim();
339    let value = strip_quoted(value)?.to_string();
340    let end_col = line.chars().count() as u32 + 1;
341    Some((
342        value,
343        Span::new(
344            0,
345            crate::diag::Position::new(1, 1),
346            crate::diag::Position::new(1, end_col),
347        ),
348    ))
349}
350
351impl Preprocessor {
352    /// Process `#!` directive tokens, splicing includes and registering
353    /// defines. Non-directive tokens are kept in place.
354    fn process_directives(&mut self, tokens: &mut Vec<Token>) -> OpyResult<()> {
355        let mut out: Vec<Token> = Vec::with_capacity(tokens.len());
356        for token in tokens.drain(..) {
357            if token.kind == TokenKind::Directive {
358                self.handle_directive(token, &mut out)?;
359            } else if token.kind == TokenKind::Ident
360                && matches!(token.text.as_str(), "rule" | "def")
361                && self.preprocessing.rule_prefix.is_some()
362            {
363                let prefix = self
364                    .preprocessing
365                    .rule_prefix
366                    .as_ref()
367                    .map(|value| value.value.clone())
368                    .unwrap_or_default();
369                out.push(Token {
370                    kind: TokenKind::RulePrefixMarker,
371                    text: prefix,
372                    raw: None,
373                    span: token.span,
374                });
375                out.push(token);
376            } else {
377                out.push(token);
378            }
379        }
380        *tokens = out;
381        Ok(())
382    }
383
384    fn handle_directive(&mut self, token: Token, out: &mut Vec<Token>) -> OpyResult<()> {
385        let text = token.text.trim();
386        let span = token.span;
387        let (name, rest) = split_directive(text);
388        if name == "include" {
389            let rest = rest.trim();
390            let include = rest
391                .strip_prefix('"')
392                .and_then(|r| r.strip_suffix('"'))
393                .or_else(|| rest.strip_prefix('\'').and_then(|r| r.strip_suffix('\'')));
394            let Some(include) = include else {
395                return Err(OpyError::at(
396                    "include-invalid",
397                    format!(
398                        "invalid include directive: `{text}` (expected `#!include \"file.opy\"`)"
399                    ),
400                    span,
401                ));
402            };
403            self.include(include, span, out)?;
404            return Ok(());
405        }
406        if name == "define" {
407            self.define(rest.trim(), span)?;
408            return Ok(());
409        }
410        if name == "undef" {
411            let name = rest.trim();
412            if name.is_empty() || name.chars().any(|ch| !is_identifier_char(ch)) {
413                return Err(OpyError::at(
414                    "undef-invalid",
415                    "malformed `#!undef` directive: expected one macro name",
416                    span,
417                ));
418            }
419            self.macros.retain(|m| m.name != name);
420            self.defines.retain(|define| define.name != name);
421            self.record("undef", Some(name), span);
422            return Ok(());
423        }
424        if name == "postCompileHook" {
425            let rest = rest.trim();
426            let Some(path) = strip_quoted(rest) else {
427                return Err(OpyError::at(
428                    "script-invalid",
429                    format!(
430                        "invalid postCompileHook directive: `{text}` (expected `#!postCompileHook \"hook.js\"`)"
431                    ),
432                    span,
433                ));
434            };
435            if self.post_compile_hook.is_some() {
436                return Err(OpyError::at(
437                    "post-compile-hook-duplicate",
438                    "post-compile hook is already defined".to_string(),
439                    span,
440                ));
441            }
442            let hook = self.resolve_script(path, span)?;
443            self.post_compile_hook = Some(PostCompileHook {
444                path: hook.path,
445                source: hook.source,
446                span,
447            });
448            self.record("postCompileHook", Some(path), span);
449            return Ok(());
450        }
451        if name == "mainFile" {
452            return Err(OpyError::at(
453                "main-file-placement",
454                "`#!mainFile` must be the first directive in the main source",
455                span,
456            ));
457        }
458        if name == "allowMacroRedeclaration" {
459            self.preprocessing.allow_macro_redeclaration = true;
460            self.record(name, None, span);
461            return Ok(());
462        }
463        if name == "translations" {
464            let languages = parse_translations(rest.trim(), span)?;
465            self.preprocessing.translations = Some(TranslationState {
466                languages: languages.clone(),
467                span: Some(span.into()),
468            });
469            self.record(name, Some(&languages.join(" ")), span);
470            return Ok(());
471        }
472        if name == "suppressWarnings" {
473            let warnings = parse_words(rest, "suppressWarnings", span)?;
474            self.preprocessing
475                .suppressed_warnings
476                .extend(warnings.clone());
477            self.record(name, Some(&warnings.join(" ")), span);
478            return Ok(());
479        }
480        if name == "rulePrefix" {
481            let prefix = strip_quoted(rest.trim()).ok_or_else(|| {
482                OpyError::at(
483                    "rule-prefix-invalid",
484                    "`#!rulePrefix` expects one quoted string",
485                    span,
486                )
487            })?;
488            self.preprocessing.rule_prefix = Some(DirectiveValue {
489                value: prefix.to_string(),
490                span: Some(span.into()),
491            });
492            self.record(name, Some(prefix), span);
493            return Ok(());
494        }
495        if name == "rulePrefixTemplate" {
496            if self.preprocessing.rule_prefix_template.is_some() {
497                return Err(OpyError::at(
498                    "rule-prefix-template-duplicate",
499                    "a rule prefix template is already defined",
500                    span,
501                ));
502            }
503            let template = if rest.trim().is_empty() {
504                r#"f"[{$pathTitle.replace('_', ' ')}] {$rule}" if $rule and not $isDelimiter else $rule"#
505            } else {
506                rest.trim()
507            };
508            self.preprocessing.rule_prefix_template = Some(DirectiveValue {
509                value: template.to_string(),
510                span: Some(span.into()),
511            });
512            self.record(name, Some(template), span);
513            return Ok(());
514        }
515        if let Some((directive, control)) = optimization_directive(name) {
516            apply_optimization(&mut self.preprocessing.optimization, control);
517            self.record(directive, None, span);
518            return Ok(());
519        }
520        if let Some(replacement) = replacement_directive(name) {
521            let family = replacement_family(name).expect("replacement directive family");
522            if self
523                .preprocessing
524                .directives
525                .iter()
526                .filter_map(|item| replacement_family(&item.name))
527                .any(|item_family| item_family == family)
528            {
529                return Err(OpyError::at(
530                    "replacement-duplicate",
531                    format!("a replacement for `{family}` is already defined"),
532                    span,
533                ));
534            }
535            self.preprocessing.replacements.push(DirectiveValue {
536                value: replacement.to_string(),
537                span: Some(span.into()),
538            });
539            self.record(name, Some(replacement), span);
540            return Ok(());
541        }
542        Err(OpyError::at(
543            "unsupported-directive",
544            format!("unsupported preprocessing directive `#!{text}`"),
545            span,
546        ))
547    }
548
549    fn record(&mut self, name: &str, value: Option<&str>, span: Span) {
550        let state = PreprocessingSnapshot {
551            allow_macro_redeclaration: self.preprocessing.allow_macro_redeclaration,
552            optimization: self.preprocessing.optimization.clone(),
553            rule_prefix: self
554                .preprocessing
555                .rule_prefix
556                .as_ref()
557                .map(|value| value.value.clone()),
558            rule_prefix_template: self
559                .preprocessing
560                .rule_prefix_template
561                .as_ref()
562                .map(|value| value.value.clone()),
563            translations: self
564                .preprocessing
565                .translations
566                .as_ref()
567                .map(|translations| translations.languages.clone()),
568            replacements: self
569                .preprocessing
570                .replacements
571                .iter()
572                .map(|value| value.value.clone())
573                .collect(),
574        };
575        self.preprocessing.directives.push(DirectiveRecord {
576            name: name.to_string(),
577            value: value.map(str::to_string),
578            scope_col: span.start.col,
579            scope_depth: self.include_stack.len() as u32,
580            state,
581            span: Some(span.into()),
582        });
583    }
584
585    /// Resolve a script path root-relative (the reference's
586    /// `getFilePaths(path, rootPath)` convention) and read its text.
587    fn resolve_script(&self, path: &str, span: Span) -> OpyResult<ScriptMacro> {
588        let canonical = self.root.join(path).canonicalize().map_err(|_| {
589            OpyError::at(
590                "script-not-found",
591                format!(
592                    "cannot find script '{path}' under root '{}'",
593                    self.root.display()
594                ),
595                span,
596            )
597        })?;
598        let source = std::fs::read_to_string(&canonical).map_err(|error| {
599            OpyError::at(
600                "script-not-found",
601                format!("cannot read script '{path}': {error}"),
602                span,
603            )
604        })?;
605        Ok(ScriptMacro {
606            path: path.to_string(),
607            source,
608        })
609    }
610
611    /// Resolve, lex, and splice one included file.
612    fn include(&mut self, include: &str, span: Span, out: &mut Vec<Token>) -> OpyResult<()> {
613        // The include base is the root; the main file is the only file in the
614        // registry (reference convention), so path resolution is root-based.
615        let candidate = self.root.join(include);
616        let canonical = std::fs::canonicalize(&candidate).ok();
617        // An open-document overlay (an unsaved editor buffer) takes
618        // precedence over the filesystem. Overlays are keyed by the include
619        // string and by the resolved canonical path, so both spellings work.
620        let overlay_text = self
621            .overlay
622            .get(include)
623            .or_else(|| {
624                canonical
625                    .as_ref()
626                    .and_then(|path| self.overlay.get(&path.to_string_lossy().into_owned()))
627            })
628            .cloned();
629
630        // The include-cycle identity: the canonical path when the file exists,
631        // otherwise the candidate path (overlays may not have a disk backing).
632        let identity = canonical.clone().unwrap_or_else(|| candidate.clone());
633        if self.include_stack.contains(&identity) {
634            return Err(OpyError::at(
635                "include-cycle",
636                format!(
637                    "include cycle detected: '{}' is already being included",
638                    identity.display()
639                ),
640                span,
641            ));
642        }
643
644        let text = match overlay_text {
645            Some(text) => text,
646            None => {
647                let canonical = canonical.ok_or_else(|| {
648                    OpyError::at(
649                        "include-not-found",
650                        format!(
651                            "cannot find included file '{include}' under root '{}'",
652                            self.root.display()
653                        ),
654                        span,
655                    )
656                })?;
657                std::fs::read_to_string(&canonical).map_err(|error| {
658                    OpyError::at(
659                        "include-not-found",
660                        format!("cannot read included file '{include}': {error}"),
661                        span,
662                    )
663                })?
664            }
665        };
666        // Each include registers a file in the registry (reference behavior).
667        let file_id = self.next_file_id;
668        self.next_file_id += 1;
669        self.files.push(FileRecord {
670            id: file_id,
671            path: include.to_string(),
672        });
673        self.include_stack.push(identity);
674        let saved_prefix = self.preprocessing.rule_prefix.clone();
675        let saved_optimization = self.preprocessing.optimization.clone();
676        // Settings blocks are only supported in the main file; an included
677        // file's block is rejected at its keyword span (file id of the
678        // included file, #86).
679        match crate::settings::find_blocks(&text, file_id) {
680            Err(error) => return Err(error),
681            Ok(blocks) if !blocks.is_empty() => {
682                return Err(OpyError::at(
683                    "settings-placement",
684                    "settings blocks are only supported in the main file".to_string(),
685                    blocks[0].keyword_span,
686                ));
687            }
688            Ok(_) => {}
689        }
690        let mut included = lex(LexInput {
691            file_id,
692            text: &text,
693        })?;
694        let processed = self.process_directives(&mut included);
695        self.preprocessing.rule_prefix = saved_prefix;
696        self.preprocessing.optimization = saved_optimization;
697        if let Err(error) = processed {
698            self.include_stack.pop();
699            return Err(error);
700        }
701        // Drop the included file's Eof token (it terminates the file, not
702        // the spliced stream).
703        included.retain(|token| token.kind != TokenKind::Eof);
704        // Included tokens keep their real positions so the parser's
705        // indentation model works; span comparison is normalized away by the
706        // differential suite. File identity beyond the main file is preserved
707        // in diagnostics (include cycles/not-found name the real path).
708        out.extend(included);
709        self.include_stack.pop();
710        self.record("include", Some(include), span);
711        Ok(())
712    }
713
714    /// Register one `#!define` (object- or function-like).
715    ///
716    /// A define is function-like when `(` immediately follows the name
717    /// (`cakeBeam(start, end)`); a parenthesized object-like value
718    /// (`#!define X (a + b)`) keeps its parentheses as value tokens.
719    fn define(&mut self, rest: &str, span: Span) -> OpyResult<()> {
720        let rest = rest.trim();
721        let first_open = rest.find('(').unwrap_or(usize::MAX);
722        let first_space = rest.find(char::is_whitespace).unwrap_or(usize::MAX);
723        let is_function_like = first_open < first_space;
724
725        let (name, params, body_text) = if is_function_like {
726            let name = rest[..first_open].trim();
727            let Some(close) = rest[first_open..].find(')') else {
728                return Err(OpyError::at(
729                    "define-invalid",
730                    format!("malformed function-like define `#!define {rest}`: missing `)`"),
731                    span,
732                ));
733            };
734            let close = first_open + close;
735            let params: Vec<String> = rest[first_open + 1..close]
736                .split(',')
737                .map(|p| p.trim().to_string())
738                .filter(|p| !p.is_empty())
739                .collect();
740            let body = rest[close + 1..].trim();
741            (name.to_string(), params, body.to_string())
742        } else {
743            let name = rest[..first_space].trim();
744            let body = if first_space == usize::MAX {
745                String::new()
746            } else {
747                rest[first_space..].trim().to_string()
748            };
749            (name.to_string(), Vec::new(), body)
750        };
751        if name.is_empty() {
752            return Err(OpyError::at(
753                "define-invalid",
754                "malformed `#!define` directive: missing macro name",
755                span,
756            ));
757        }
758        if self.macros.iter().any(|macro_def| macro_def.name == name) {
759            if !self.preprocessing.allow_macro_redeclaration {
760                return Err(OpyError::at(
761                    "macro-redeclaration",
762                    format!("macro '{name}' is already defined"),
763                    span,
764                ));
765            }
766            self.macros.retain(|macro_def| macro_def.name != name);
767            self.defines.retain(|define| define.name != name);
768        }
769        let script = if is_function_like && body_text.starts_with("__script__(") {
770            // The OverPy script-macro ABI: the replacement is exactly
771            // `__script__("path.js")`; the reference extracts the path from
772            // the text between the parentheses and resolves it root-relative
773            // at the define site (missing files fail at compile time).
774            let inner = &body_text["__script__(".len()..];
775            let inner = inner.strip_suffix(')').ok_or_else(|| {
776                OpyError::at(
777                    "script-invalid",
778                    format!(
779                        "malformed script macro `#!define {rest}`: expected `__script__(\"path.js\")`"
780                    ),
781                    span,
782                )
783            })?;
784            let Some(path) = strip_quoted(inner.trim()) else {
785                return Err(OpyError::at(
786                    "script-invalid",
787                    format!(
788                        "malformed script macro `#!define {rest}`: expected a quoted script path"
789                    ),
790                    span,
791                ));
792            };
793            Some(self.resolve_script(path, span)?)
794        } else {
795            None
796        };
797        let body_tokens = lex(LexInput {
798            file_id: span.file,
799            text: &body_text,
800        })?;
801        // Drop the trailing EOF token from the value.
802        let body_tokens: Vec<Token> = body_tokens
803            .into_iter()
804            .filter(|t| t.kind != TokenKind::Eof)
805            .collect();
806        let is_function = is_function_like;
807        self.defines.push(DefineRecord {
808            name: name.clone(),
809            is_function,
810            span: Some(span),
811        });
812        self.macros.push(MacroDef {
813            name,
814            params,
815            body: body_tokens,
816            is_function,
817            script,
818        });
819        Ok(())
820    }
821
822    /// Expand all macros across the token stream, recursively.
823    fn expand(&self, tokens: Vec<Token>) -> OpyResult<Vec<Token>> {
824        let mut out = Vec::new();
825        let mut index = 0;
826        while index < tokens.len() {
827            let token = &tokens[index];
828            if token.kind == TokenKind::Ident {
829                let name = token.text.clone();
830                if let Some(mac) = self.macros.iter().find(|m| m.name == name) {
831                    if mac.is_function {
832                        // Expect `(` args `)` immediately after the name.
833                        let cursor = index + 1;
834                        if cursor < tokens.len() && tokens[cursor].kind == TokenKind::LParen {
835                            let (args, after) = self.collect_args(&tokens, cursor)?;
836                            let mut expanded = self.expand_macro(mac, args, token.span)?;
837                            self.expand_into(&mut expanded, &mut Vec::new(), 0)?;
838                            out.append(&mut expanded);
839                            index = after;
840                            continue;
841                        }
842                        // A function-like macro used without arguments: leave
843                        // the name as an ordinary identifier.
844                        out.push(token.clone());
845                        index += 1;
846                        continue;
847                    }
848                    let mut expanded = self.expand_macro(mac, Vec::new(), token.span)?;
849                    self.expand_into(&mut expanded, &mut Vec::new(), 0)?;
850                    out.append(&mut expanded);
851                    index += 1;
852                    continue;
853                }
854            }
855            out.push(token.clone());
856            index += 1;
857        }
858        Ok(out)
859    }
860
861    /// Collect the argument token lists of a function-like macro call,
862    /// returning `(args, index_after_closing_paren)`.
863    fn collect_args(&self, tokens: &[Token], open: usize) -> OpyResult<(Vec<Vec<Token>>, usize)> {
864        let mut args: Vec<Vec<Token>> = Vec::new();
865        let mut current: Vec<Token> = Vec::new();
866        let mut depth = 0usize;
867        let mut cursor = open + 1;
868        while cursor < tokens.len() {
869            let kind = tokens[cursor].kind;
870            if kind == TokenKind::LParen {
871                depth += 1;
872                current.push(tokens[cursor].clone());
873            } else if kind == TokenKind::RParen {
874                if depth == 0 {
875                    args.push(std::mem::take(&mut current));
876                    return Ok((args, cursor + 1));
877                }
878                depth -= 1;
879                current.push(tokens[cursor].clone());
880            } else if kind == TokenKind::Comma && depth == 0 {
881                args.push(std::mem::take(&mut current));
882            } else {
883                current.push(tokens[cursor].clone());
884            }
885            cursor += 1;
886        }
887        Err(OpyError::new(
888            "macro-invalid",
889            "unterminated macro invocation: missing closing `)`",
890        ))
891    }
892
893    /// Substitute macro params with the call arguments and stamp every
894    /// expanded token with the use-site span.
895    ///
896    /// Expanded tokens share the use-site span: the differential suite
897    /// normalizes spans away, and stamping the whole expansion with one
898    /// monotonic span keeps downstream span validation trivially valid.
899    fn expand_macro(
900        &self,
901        mac: &MacroDef,
902        args: Vec<Vec<Token>>,
903        use_site: Span,
904    ) -> OpyResult<Vec<Token>> {
905        if mac.is_function && args.len() != mac.params.len() {
906            return Err(OpyError::at(
907                "macro-arity",
908                format!(
909                    "macro '{}' expects {} argument(s) but got {}",
910                    mac.name,
911                    mac.params.len(),
912                    args.len()
913                ),
914                use_site,
915            ));
916        }
917        if let Some(script) = &mac.script {
918            return self.expand_script(mac, script, args, use_site);
919        }
920        let mut out = Vec::new();
921        for token in &mac.body {
922            if mac.is_function
923                && token.kind == TokenKind::Ident
924                && mac.params.iter().any(|p| p == &token.text)
925            {
926                let param_index = mac
927                    .params
928                    .iter()
929                    .position(|p| p == &token.text)
930                    .expect("checked above");
931                let mut replacement = args.get(param_index).cloned().unwrap_or_default();
932                for replacement_token in &mut replacement {
933                    replacement_token.span = use_site;
934                }
935                out.extend(replacement);
936            } else {
937                let mut token = token.clone();
938                token.span = use_site;
939                out.push(token);
940            }
941        }
942        Ok(out)
943    }
944
945    /// Expand a script macro: run the resolved script through the bounded
946    /// runtime with the call-site arguments injected, then lex the string
947    /// completion value back into the token stream at the use site.
948    ///
949    /// Argument text is reconstructed from the call-site tokens (see
950    /// [`raw_arg_text`]); the reference injects the raw source text, and the
951    /// reconstruction is JavaScript-value-equivalent to it (string literals
952    /// are re-quoted with JSON escaping, so quoting-style differences are
953    /// unobservable to the script). The reference's per-line indentation rule
954    /// is applied to the expansion text before lexing; the frontend parser
955    /// never consumes indentation, so this is preserved in the text only.
956    fn expand_script(
957        &self,
958        mac: &MacroDef,
959        script: &ScriptMacro,
960        args: Vec<Vec<Token>>,
961        use_site: Span,
962    ) -> OpyResult<Vec<Token>> {
963        let macro_args: Vec<MacroArg> = mac
964            .params
965            .iter()
966            .zip(args.iter())
967            .map(|(param, tokens)| MacroArg::new(param.clone(), raw_arg_text(tokens)))
968            .collect();
969        // Resource limits mirror the pinned reference constants (1000 ms macro
970        // budget, 64 MiB memory, 512 KiB stack; see `opy_macro_js::Limits`).
971        let runtime = MacroRuntime::new(Limits::default());
972        let result = runtime
973            .run_macro(&script.source, &macro_args, &script.path)
974            .map_err(|error| map_macro_error(&error, &script.path, use_site))?;
975        // Reference indentation rule (`resolveMacro`): every newline in the
976        // replacement is followed by the call line's indentation.
977        let indent = " ".repeat(use_site.start.col.saturating_sub(1) as usize);
978        let indented = result.text.replace('\n', &format!("\n{indent}"));
979        let mut tokens = lex(LexInput {
980            file_id: use_site.file,
981            text: &indented,
982        })?;
983        tokens.retain(|token| token.kind != TokenKind::Eof);
984        for token in &mut tokens {
985            token.span = use_site;
986        }
987        Ok(tokens)
988    }
989
990    /// Recursively expand macros inside an already-expanded run, guarding
991    /// against direct recursion.
992    fn expand_into(
993        &self,
994        tokens: &mut Vec<Token>,
995        stack: &mut Vec<String>,
996        depth: usize,
997    ) -> OpyResult<()> {
998        if depth > 64 {
999            return Err(OpyError::new(
1000                "macro-recursion",
1001                "macro expansion exceeded the recursion limit (possible recursive define)",
1002            ));
1003        }
1004        let mut out: Vec<Token> = Vec::with_capacity(tokens.len());
1005        let mut index = 0;
1006        while index < tokens.len() {
1007            let token = &tokens[index];
1008            if token.kind == TokenKind::Ident {
1009                let name = token.text.clone();
1010                if let Some(mac) = self.macros.iter().find(|m| m.name == name) {
1011                    if stack.iter().any(|s| s == &name) {
1012                        return Err(OpyError::new(
1013                            "macro-recursion",
1014                            format!("recursive macro expansion detected for '{name}'"),
1015                        ));
1016                    }
1017                    if mac.is_function {
1018                        if index + 1 < tokens.len() && tokens[index + 1].kind == TokenKind::LParen {
1019                            let (args, after) = self.collect_args(tokens, index)?;
1020                            let mut expanded = self.expand_macro(mac, args, token.span)?;
1021                            stack.push(name.clone());
1022                            self.expand_into(&mut expanded, stack, depth + 1)?;
1023                            stack.pop();
1024                            out.append(&mut expanded);
1025                            index = after;
1026                            continue;
1027                        }
1028                        out.push(token.clone());
1029                        index += 1;
1030                        continue;
1031                    }
1032                    let mut expanded = self.expand_macro(mac, Vec::new(), token.span)?;
1033                    stack.push(name.clone());
1034                    self.expand_into(&mut expanded, stack, depth + 1)?;
1035                    stack.pop();
1036                    out.append(&mut expanded);
1037                    index += 1;
1038                    continue;
1039                }
1040            }
1041            out.push(token.clone());
1042            index += 1;
1043        }
1044        *tokens = out;
1045        Ok(())
1046    }
1047}
1048
1049fn split_directive(text: &str) -> (&str, &str) {
1050    text.split_once(char::is_whitespace)
1051        .map_or((text, ""), |(name, rest)| (name, rest))
1052}
1053
1054fn is_identifier_char(ch: char) -> bool {
1055    ch.is_ascii_alphanumeric() || ch == '_'
1056}
1057
1058fn parse_words(rest: &str, directive: &str, span: Span) -> OpyResult<Vec<String>> {
1059    let words: Vec<String> = rest.split_whitespace().map(str::to_string).collect();
1060    if words.is_empty() {
1061        return Err(OpyError::at(
1062            "directive-invalid",
1063            format!("`#!{directive}` expects at least one argument"),
1064            span,
1065        ));
1066    }
1067    if words
1068        .iter()
1069        .any(|word| word.chars().any(|ch| !is_identifier_char(ch)))
1070    {
1071        return Err(OpyError::at(
1072            "directive-invalid",
1073            format!("`#!{directive}` arguments must be identifiers"),
1074            span,
1075        ));
1076    }
1077    Ok(words)
1078}
1079
1080fn parse_translations(rest: &str, span: Span) -> OpyResult<Vec<String>> {
1081    let values: Vec<String> = rest
1082        .split_whitespace()
1083        .map(|language| language.replace('-', "_").to_lowercase())
1084        .collect();
1085    if values.is_empty() {
1086        return Err(OpyError::at(
1087            "translations-invalid",
1088            "`#!translations` expects at least one language",
1089            span,
1090        ));
1091    }
1092    const PINNED_LANGUAGES: &[&str] = &[
1093        "de", "en", "es", "es_es", "es_mx", "fr", "it", "ja", "ko", "pl", "pt", "ru", "th", "tr",
1094        "zh", "zh_cn", "zh_tw",
1095    ];
1096    if values
1097        .iter()
1098        .any(|language| !PINNED_LANGUAGES.contains(&language.as_str()))
1099    {
1100        return Err(OpyError::at(
1101            "translations-invalid",
1102            "invalid translation language; expected one of the pinned OverPy language codes",
1103            span,
1104        ));
1105    }
1106    if values.iter().any(|value| value == "es")
1107        && values
1108            .iter()
1109            .any(|value| value == "es_es" || value == "es_mx")
1110    {
1111        return Err(OpyError::at(
1112            "translations-invalid",
1113            "cannot combine `es` with `es_es` or `es_mx`",
1114            span,
1115        ));
1116    }
1117    if values.iter().any(|value| value == "zh")
1118        && values
1119            .iter()
1120            .any(|value| value == "zh_cn" || value == "zh_tw")
1121    {
1122        return Err(OpyError::at(
1123            "translations-invalid",
1124            "cannot combine `zh` with `zh_cn` or `zh_tw`",
1125            span,
1126        ));
1127    }
1128    Ok(values)
1129}
1130
1131#[derive(Clone, Copy)]
1132enum OptimizationControl {
1133    Enable,
1134    Disable,
1135    ForSize,
1136    DisableForSize,
1137    ForSizeAggressive,
1138    Strict,
1139    DisableStrict,
1140}
1141
1142fn optimization_directive(name: &str) -> Option<(&str, OptimizationControl)> {
1143    Some(match name {
1144        "disableOptimizations" => (name, OptimizationControl::Disable),
1145        "enableOptimizations" => (name, OptimizationControl::Enable),
1146        "optimizeForSize" => (name, OptimizationControl::ForSize),
1147        "disableOptimizeForSize" => (name, OptimizationControl::DisableForSize),
1148        "optimizeForSizeAggressive" => (name, OptimizationControl::ForSizeAggressive),
1149        "optimizeStrict" => (name, OptimizationControl::Strict),
1150        "disableOptimizeStrict" => (name, OptimizationControl::DisableStrict),
1151        _ => return None,
1152    })
1153}
1154
1155fn apply_optimization(state: &mut OptimizationState, control: OptimizationControl) {
1156    match control {
1157        OptimizationControl::Enable => state.enabled = true,
1158        OptimizationControl::Disable => state.enabled = false,
1159        OptimizationControl::ForSize => state.for_size = true,
1160        OptimizationControl::DisableForSize => state.for_size = false,
1161        OptimizationControl::ForSizeAggressive => state.for_size_aggressive = true,
1162        OptimizationControl::Strict => state.strict = true,
1163        OptimizationControl::DisableStrict => state.strict = false,
1164    }
1165}
1166
1167fn replacement_directive(name: &str) -> Option<&str> {
1168    Some(match name {
1169        "replace0ByCapturePercentage" => "getCapturePercentage",
1170        "replace0ByPayloadProgressPercentage" => "getPayloadProgressPercentage",
1171        "replace0ByIsMatchComplete" => "isMatchComplete",
1172        "replace1ByMatchRound" => "getMatchRound",
1173        "replaceTeam1ByControlScoringTeam" => "getControlScoringTeam",
1174        "replaceEmptyStringByEmptyArray" => "emptyArray",
1175        "replaceEmptyStringByVariable" => "variable",
1176        _ => return None,
1177    })
1178}
1179
1180fn replacement_family(name: &str) -> Option<&str> {
1181    Some(match name {
1182        "replace0ByCapturePercentage"
1183        | "replace0ByPayloadProgressPercentage"
1184        | "replace0ByIsMatchComplete" => "0",
1185        "replace1ByMatchRound" => "1",
1186        "replaceTeam1ByControlScoringTeam" => "team1",
1187        "replaceEmptyStringByEmptyArray" | "replaceEmptyStringByVariable" => "emptyString",
1188        _ => return None,
1189    })
1190}
1191
1192/// Strips a matched `"…"` or `'…'` pair, returning the inner text.
1193fn strip_quoted(text: &str) -> Option<&str> {
1194    text.strip_prefix('"')
1195        .and_then(|rest| rest.strip_suffix('"'))
1196        .or_else(|| {
1197            text.strip_prefix('\'')
1198                .and_then(|rest| rest.strip_suffix('\''))
1199        })
1200}
1201
1202/// Reconstructs the raw call-site argument text from its tokens.
1203///
1204/// The reference injects the raw source substring as `var <name>=<raw>;`; the
1205/// token model stores string values unescaped, so string tokens are re-quoted
1206/// with JSON escaping. The reconstruction is JavaScript-value-equivalent to
1207/// the reference's raw injection: identifiers, numbers, operators, and
1208/// punctuation pass through verbatim, and string literals differ only in
1209/// quoting style, which is unobservable to the script.
1210fn raw_arg_text(tokens: &[Token]) -> String {
1211    let mut out = String::new();
1212    for token in tokens {
1213        match token.kind {
1214            TokenKind::String => out.push_str(&json_string_literal(&token.text)),
1215            TokenKind::Newline => out.push('\n'),
1216            _ => out.push_str(&token.text),
1217        }
1218    }
1219    out
1220}
1221
1222/// Encodes `value` as a JSON string literal (double-quoted, escaped).
1223fn json_string_literal(value: &str) -> String {
1224    serde_json::to_string(value).expect("serializing a string is infallible")
1225}
1226
1227/// Maps a runtime [`MacroError`] to a structured frontend diagnostic with the
1228/// script path as provenance and the directive/call-site span.
1229///
1230/// The runtime's QuickJS abort messages are classified into stable codes:
1231/// `script-timeout` (`"interrupted"`), `script-memory-limit`
1232/// (`"out of memory"`), `script-stack-limit`
1233/// (`"Maximum call stack size exceeded"`), and `script-error` for thrown
1234/// exceptions (with the script path and, when the engine provided one, the
1235/// line/column). Non-string completion values are `script-result-not-string`
1236/// with the reference's wording, and engine setup failures are
1237/// `script-internal`.
1238pub(crate) fn map_macro_error(error: &MacroError, script_path: &str, span: Span) -> OpyError {
1239    match error {
1240        MacroError::Script(script) => {
1241            let code = match script.message.as_str() {
1242                "interrupted" => "script-timeout",
1243                "out of memory" => "script-memory-limit",
1244                "Maximum call stack size exceeded" => "script-stack-limit",
1245                _ => "script-error",
1246            };
1247            let location = match (script.line, script.column) {
1248                (Some(line), Some(column)) => format!(" (line {line}, column {column})"),
1249                (Some(line), None) => format!(" (line {line})"),
1250                _ => String::new(),
1251            };
1252            OpyError::at(
1253                code,
1254                format!(
1255                    "script '{}' failed: {}{}",
1256                    script_path, script.message, location
1257                ),
1258                span,
1259            )
1260        }
1261        MacroError::InvalidResult { type_name } => OpyError::at(
1262            "script-result-not-string",
1263            format!(
1264                "JavaScript macro returned value with type of {type_name}, expected string. Try using .toString()"
1265            ),
1266            span,
1267        ),
1268        MacroError::Internal(message) => OpyError::at(
1269            "script-internal",
1270            format!("script '{}' runtime failure: {message}", script_path),
1271            span,
1272        ),
1273    }
1274}
1275
1276#[cfg(test)]
1277mod tests {
1278    use super::*;
1279
1280    #[test]
1281    fn object_define_expands_at_use_site() {
1282        let (pre, _) = preprocess(
1283            "#!define SIDE 1.5\nrule \"r\":\n    x = SIDE\n",
1284            "main.opy",
1285            Path::new("."),
1286        )
1287        .unwrap();
1288        assert_eq!(pre.defines.len(), 1);
1289        assert_eq!(pre.defines[0].name, "SIDE");
1290        assert!(!pre.defines[0].is_function);
1291        let numbers: Vec<&str> = pre
1292            .tokens
1293            .iter()
1294            .filter(|t| t.kind == TokenKind::Number)
1295            .map(|t| t.text.as_str())
1296            .collect();
1297        assert_eq!(numbers, vec!["1.5"]);
1298    }
1299
1300    #[test]
1301    fn function_define_substitutes_params() {
1302        let (pre, _) = preprocess(
1303            "#!define double(x) x + x\nrule \"r\":\n    y = double(3)\n",
1304            "main.opy",
1305            Path::new("."),
1306        )
1307        .unwrap();
1308        let numbers: Vec<&str> = pre
1309            .tokens
1310            .iter()
1311            .filter(|t| t.kind == TokenKind::Number)
1312            .map(|t| t.text.as_str())
1313            .collect();
1314        assert_eq!(numbers, vec!["3", "3"]);
1315    }
1316
1317    #[test]
1318    fn recursive_defines_expand_transitively() {
1319        let (pre, _) = preprocess(
1320            "#!define A 2\n#!define B A + 1\nrule \"r\":\n    x = B\n",
1321            "main.opy",
1322            Path::new("."),
1323        )
1324        .unwrap();
1325        let numbers: Vec<&str> = pre
1326            .tokens
1327            .iter()
1328            .filter(|t| t.kind == TokenKind::Number)
1329            .map(|t| t.text.as_str())
1330            .collect();
1331        assert_eq!(numbers, vec!["2", "1"]);
1332    }
1333
1334    #[test]
1335    fn recursive_define_fails_structurally() {
1336        let error = preprocess(
1337            "#!define X X + 1\nrule \"r\":\n    x = X\n",
1338            "main.opy",
1339            Path::new("."),
1340        )
1341        .unwrap_err();
1342        assert_eq!(error.code, "macro-recursion");
1343    }
1344
1345    #[test]
1346    fn missing_include_is_structured() {
1347        let error = preprocess(
1348            "#!include \"nope.opy\"\n",
1349            "main.opy",
1350            Path::new("/nonexistent-root"),
1351        )
1352        .unwrap_err();
1353        assert_eq!(error.code, "include-not-found");
1354        assert!(error.span.is_some());
1355    }
1356
1357    #[test]
1358    fn include_cycle_is_detected() {
1359        let dir = std::env::temp_dir().join(format!("wright-opy-test-{}", std::process::id()));
1360        std::fs::create_dir_all(&dir).unwrap();
1361        std::fs::write(dir.join("a.opy"), "#!include \"b.opy\"\n").unwrap();
1362        std::fs::write(dir.join("b.opy"), "#!include \"a.opy\"\n").unwrap();
1363        let main = std::fs::read_to_string(dir.join("a.opy")).unwrap();
1364        let error = preprocess(&main, "a.opy", &dir).unwrap_err();
1365        assert_eq!(error.code, "include-cycle");
1366        let _ = std::fs::remove_dir_all(&dir);
1367    }
1368
1369    #[test]
1370    fn unsupported_directive_is_structured() {
1371        let error = preprocess("#!frobnicate\n", "main.opy", Path::new(".")).unwrap_err();
1372        assert_eq!(error.code, "unsupported-directive");
1373    }
1374
1375    #[test]
1376    fn settings_block_is_extracted_before_lexing() {
1377        let (pre, _) = preprocess(
1378            "settings {\n    \"gamemodes\": {}\n}\nrule \"r\":\n    pass\n",
1379            "main.opy",
1380            Path::new("."),
1381        )
1382        .unwrap();
1383        let block = pre.settings.expect("settings block extracted");
1384        assert!(block.text.contains("gamemodes"));
1385        // The block never enters the token stream.
1386        assert!(
1387            !pre.tokens.iter().any(|t| t.text.contains("gamemodes")),
1388            "settings content must not be lexed"
1389        );
1390    }
1391
1392    #[test]
1393    fn settings_in_include_is_rejected() {
1394        let dir =
1395            std::env::temp_dir().join(format!("wright-opy-settings-test-{}", std::process::id()));
1396        std::fs::create_dir_all(&dir).unwrap();
1397        std::fs::write(
1398            dir.join("shared.opy"),
1399            "settings {\n    \"gamemodes\": {}\n}\n",
1400        )
1401        .unwrap();
1402        let main = "#!include \"shared.opy\"\nrule \"r\":\n    pass\n";
1403        let error = preprocess(main, "main.opy", &dir).unwrap_err();
1404        assert_eq!(error.code, "settings-placement");
1405        assert_eq!(
1406            error.span.unwrap().file,
1407            1,
1408            "the span names the included file"
1409        );
1410        let _ = std::fs::remove_dir_all(&dir);
1411    }
1412
1413    #[test]
1414    fn dict_literal_braces_reach_the_parser() {
1415        // Scoped settings lexing must not consume expression-level braces.
1416        let (pre, _) = preprocess(
1417            "rule \"r\":\n    money += {\n        Mei.GENERIC: 10,\n    }\n",
1418            "main.opy",
1419            Path::new("."),
1420        )
1421        .unwrap();
1422        assert!(
1423            pre.tokens
1424                .iter()
1425                .any(|token| token.kind == TokenKind::LBrace)
1426        );
1427        assert!(
1428            pre.tokens
1429                .iter()
1430                .any(|token| token.kind == TokenKind::RBrace)
1431        );
1432    }
1433
1434    #[test]
1435    fn advanced_directives_preserve_frontend_state_without_catalog_data() {
1436        let (pre, _) = preprocess(
1437            "#!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",
1438            "main.opy",
1439            Path::new("."),
1440        )
1441        .unwrap();
1442        assert!(pre.preprocessing.allow_macro_redeclaration);
1443        assert_eq!(
1444            pre.preprocessing
1445                .translations
1446                .as_ref()
1447                .map(|state| state.languages.as_slice()),
1448            Some(["en".to_string(), "fr".to_string()].as_slice())
1449        );
1450        assert_eq!(
1451            pre.preprocessing
1452                .rule_prefix
1453                .as_ref()
1454                .map(|value| value.value.as_str()),
1455            Some("Effects")
1456        );
1457        assert!(pre.preprocessing.optimization.for_size);
1458        assert!(pre.preprocessing.optimization.strict);
1459        assert_eq!(
1460            pre.preprocessing.replacements[0].value,
1461            "getCapturePercentage"
1462        );
1463        assert_eq!(pre.defines.len(), 1);
1464    }
1465
1466    #[test]
1467    fn translations_follow_pinned_codes_without_local_deduplication() {
1468        let (pre, _) = preprocess(
1469            "#!translations EN zh-cn en\nrule \"r\":\n    pass\n",
1470            "main.opy",
1471            Path::new("."),
1472        )
1473        .unwrap();
1474        assert_eq!(
1475            pre.preprocessing.translations.unwrap().languages,
1476            vec!["en", "zh_cn", "en"]
1477        );
1478    }
1479
1480    #[test]
1481    fn translations_reject_codes_outside_the_pinned_oracle_set() {
1482        let error = preprocess(
1483            "#!translations en_US\nrule \"r\":\n    pass\n",
1484            "main.opy",
1485            Path::new("."),
1486        )
1487        .unwrap_err();
1488        assert_eq!(error.code, "translations-invalid");
1489    }
1490
1491    #[test]
1492    fn directive_records_expose_state_transitions_and_include_depth() {
1493        let root =
1494            std::env::temp_dir().join(format!("wright-opy-directive-scope-{}", std::process::id()));
1495        std::fs::create_dir_all(&root).unwrap();
1496        std::fs::write(
1497            root.join("child.opy"),
1498            "#!rulePrefix \"inner\"\n#!disableOptimizations\n",
1499        )
1500        .unwrap();
1501        let (pre, _) = preprocess(
1502            "#!rulePrefix \"outer\"\n#!include \"child.opy\"\n#!enableOptimizations\n",
1503            "main.opy",
1504            &root,
1505        )
1506        .unwrap();
1507        let records = &pre.preprocessing.directives;
1508        assert_eq!(records[0].state.rule_prefix.as_deref(), Some("outer"));
1509        assert_eq!(records[0].scope_depth, 0);
1510        assert_eq!(records[1].name, "rulePrefix");
1511        assert_eq!(records[1].state.rule_prefix.as_deref(), Some("inner"));
1512        assert!(!records[2].state.optimization.enabled);
1513        assert_eq!(records[2].scope_depth, 1);
1514        assert_eq!(records[3].name, "include");
1515        assert_eq!(records[3].state.rule_prefix.as_deref(), Some("outer"));
1516        assert_eq!(records[4].name, "enableOptimizations");
1517        assert!(records[4].state.optimization.enabled);
1518        let _ = std::fs::remove_dir_all(&root);
1519    }
1520
1521    #[test]
1522    fn malformed_translation_state_is_source_located() {
1523        let error = preprocess("#!translations\n", "main.opy", Path::new(".")).unwrap_err();
1524        assert_eq!(error.code, "translations-invalid");
1525        assert!(error.span.is_some());
1526    }
1527}