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 [`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;
43use std::path::{Path, PathBuf};
44
45use crate::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, false) {
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(
355        &mut self,
356        tokens: &mut Vec<Token>,
357        allow_leading_main_file: bool,
358    ) -> OpyResult<()> {
359        let mut out: Vec<Token> = Vec::with_capacity(tokens.len());
360        for token in tokens.drain(..) {
361            if token.kind == TokenKind::Directive {
362                let is_leading_main_file = allow_leading_main_file && token.span.start.line == 1;
363                self.handle_directive(token, &mut out, is_leading_main_file)?;
364            } else if token.kind == TokenKind::Ident
365                && matches!(token.text.as_str(), "rule" | "def")
366                && self.preprocessing.rule_prefix.is_some()
367            {
368                let prefix = self
369                    .preprocessing
370                    .rule_prefix
371                    .as_ref()
372                    .map(|value| value.value.clone())
373                    .unwrap_or_default();
374                out.push(Token {
375                    kind: TokenKind::RulePrefixMarker,
376                    text: prefix,
377                    raw: None,
378                    span: token.span,
379                });
380                out.push(token);
381            } else {
382                out.push(token);
383            }
384        }
385        *tokens = out;
386        Ok(())
387    }
388
389    fn handle_directive(
390        &mut self,
391        token: Token,
392        out: &mut Vec<Token>,
393        allow_leading_main_file: bool,
394    ) -> OpyResult<()> {
395        let text = token.text.trim();
396        let span = token.span;
397        let (name, rest) = split_directive(text);
398        if name == "include" {
399            let rest = rest.trim();
400            let include = rest
401                .strip_prefix('"')
402                .and_then(|r| r.strip_suffix('"'))
403                .or_else(|| rest.strip_prefix('\'').and_then(|r| r.strip_suffix('\'')));
404            let Some(include) = include else {
405                return Err(OpyError::at(
406                    "include-invalid",
407                    format!(
408                        "invalid include directive: `{text}` (expected `#!include \"file.opy\"`)"
409                    ),
410                    span,
411                ));
412            };
413            self.include(include, span, out)?;
414            return Ok(());
415        }
416        if name == "define" {
417            self.define(rest.trim(), span)?;
418            return Ok(());
419        }
420        if name == "undef" {
421            let name = rest.trim();
422            if name.is_empty() || name.chars().any(|ch| !is_identifier_char(ch)) {
423                return Err(OpyError::at(
424                    "undef-invalid",
425                    "malformed `#!undef` directive: expected one macro name",
426                    span,
427                ));
428            }
429            self.macros.retain(|m| m.name != name);
430            self.defines.retain(|define| define.name != name);
431            self.record("undef", Some(name), span);
432            return Ok(());
433        }
434        if name == "postCompileHook" {
435            let rest = rest.trim();
436            let Some(path) = strip_quoted(rest) else {
437                return Err(OpyError::at(
438                    "script-invalid",
439                    format!(
440                        "invalid postCompileHook directive: `{text}` (expected `#!postCompileHook \"hook.js\"`)"
441                    ),
442                    span,
443                ));
444            };
445            if self.post_compile_hook.is_some() {
446                return Err(OpyError::at(
447                    "post-compile-hook-duplicate",
448                    "post-compile hook is already defined".to_string(),
449                    span,
450                ));
451            }
452            let hook = self.resolve_script(path, span)?;
453            self.post_compile_hook = Some(PostCompileHook {
454                path: hook.path,
455                source: hook.source,
456                span,
457            });
458            self.record("postCompileHook", Some(path), span);
459            return Ok(());
460        }
461        if name == "mainFile" {
462            if allow_leading_main_file {
463                let main_file = strip_quoted(rest.trim())
464                    .filter(|main_file| !main_file.is_empty())
465                    .ok_or_else(|| {
466                        OpyError::at(
467                            "main-file-invalid",
468                            "`#!mainFile` expects one quoted path",
469                            span,
470                        )
471                    })?;
472                self.record(name, Some(main_file), span);
473                return Ok(());
474            }
475            return Err(OpyError::at(
476                "main-file-placement",
477                "`#!mainFile` must be the first directive in the main source",
478                span,
479            ));
480        }
481        if name == "allowMacroRedeclaration" {
482            self.preprocessing.allow_macro_redeclaration = true;
483            self.record(name, None, span);
484            return Ok(());
485        }
486        if name == "translations" {
487            let languages = parse_translations(rest.trim(), span)?;
488            self.preprocessing.translations = Some(TranslationState {
489                languages: languages.clone(),
490                span: Some(span.into()),
491            });
492            self.record(name, Some(&languages.join(" ")), span);
493            return Ok(());
494        }
495        if name == "suppressWarnings" {
496            let warnings = parse_words(rest, "suppressWarnings", span)?;
497            self.preprocessing
498                .suppressed_warnings
499                .extend(warnings.clone());
500            self.record(name, Some(&warnings.join(" ")), span);
501            return Ok(());
502        }
503        if name == "rulePrefix" {
504            let prefix = strip_quoted(rest.trim()).ok_or_else(|| {
505                OpyError::at(
506                    "rule-prefix-invalid",
507                    "`#!rulePrefix` expects one quoted string",
508                    span,
509                )
510            })?;
511            self.preprocessing.rule_prefix = Some(DirectiveValue {
512                value: prefix.to_string(),
513                span: Some(span.into()),
514            });
515            self.record(name, Some(prefix), span);
516            return Ok(());
517        }
518        if name == "rulePrefixTemplate" {
519            if self.preprocessing.rule_prefix_template.is_some() {
520                return Err(OpyError::at(
521                    "rule-prefix-template-duplicate",
522                    "a rule prefix template is already defined",
523                    span,
524                ));
525            }
526            let template = if rest.trim().is_empty() {
527                r#"f"[{$pathTitle.replace('_', ' ')}] {$rule}" if $rule and not $isDelimiter else $rule"#
528            } else {
529                rest.trim()
530            };
531            self.preprocessing.rule_prefix_template = Some(DirectiveValue {
532                value: template.to_string(),
533                span: Some(span.into()),
534            });
535            self.record(name, Some(template), span);
536            return Ok(());
537        }
538        if let Some((directive, control)) = optimization_directive(name) {
539            apply_optimization(&mut self.preprocessing.optimization, control);
540            self.record(directive, None, span);
541            return Ok(());
542        }
543        if let Some(replacement) = replacement_directive(name) {
544            let family = replacement_family(name).expect("replacement directive family");
545            if self
546                .preprocessing
547                .directives
548                .iter()
549                .filter_map(|item| replacement_family(&item.name))
550                .any(|item_family| item_family == family)
551            {
552                return Err(OpyError::at(
553                    "replacement-duplicate",
554                    format!("a replacement for `{family}` is already defined"),
555                    span,
556                ));
557            }
558            self.preprocessing.replacements.push(DirectiveValue {
559                value: replacement.to_string(),
560                span: Some(span.into()),
561            });
562            self.record(name, Some(replacement), span);
563            return Ok(());
564        }
565        Err(OpyError::at(
566            "unsupported-directive",
567            format!("unsupported preprocessing directive `#!{text}`"),
568            span,
569        ))
570    }
571
572    fn record(&mut self, name: &str, value: Option<&str>, span: Span) {
573        let state = PreprocessingSnapshot {
574            allow_macro_redeclaration: self.preprocessing.allow_macro_redeclaration,
575            optimization: self.preprocessing.optimization.clone(),
576            rule_prefix: self
577                .preprocessing
578                .rule_prefix
579                .as_ref()
580                .map(|value| value.value.clone()),
581            rule_prefix_template: self
582                .preprocessing
583                .rule_prefix_template
584                .as_ref()
585                .map(|value| value.value.clone()),
586            translations: self
587                .preprocessing
588                .translations
589                .as_ref()
590                .map(|translations| translations.languages.clone()),
591            replacements: self
592                .preprocessing
593                .replacements
594                .iter()
595                .map(|value| value.value.clone())
596                .collect(),
597        };
598        self.preprocessing.directives.push(DirectiveRecord {
599            name: name.to_string(),
600            value: value.map(str::to_string),
601            scope_col: span.start.col,
602            scope_depth: self.include_stack.len() as u32,
603            state,
604            span: Some(span.into()),
605        });
606    }
607
608    /// Resolve a script path root-relative (the reference's
609    /// `getFilePaths(path, rootPath)` convention) and read its text.
610    fn resolve_script(&self, path: &str, span: Span) -> OpyResult<ScriptMacro> {
611        let canonical = self.root.join(path).canonicalize().map_err(|_| {
612            OpyError::at(
613                "script-not-found",
614                format!(
615                    "cannot find script '{path}' under root '{}'",
616                    self.root.display()
617                ),
618                span,
619            )
620        })?;
621        let source = std::fs::read_to_string(&canonical).map_err(|error| {
622            OpyError::at(
623                "script-not-found",
624                format!("cannot read script '{path}': {error}"),
625                span,
626            )
627        })?;
628        Ok(ScriptMacro {
629            path: path.to_string(),
630            source,
631        })
632    }
633
634    /// Resolve, lex, and splice one included file.
635    fn include(&mut self, include: &str, span: Span, out: &mut Vec<Token>) -> OpyResult<()> {
636        // The include base is the root; the main file is the only file in the
637        // registry (reference convention), so path resolution is root-based.
638        let candidate = self.root.join(include);
639        let canonical = std::fs::canonicalize(&candidate).ok();
640        // An open-document overlay (an unsaved editor buffer) takes
641        // precedence over the filesystem. Overlays are keyed by the include
642        // string and by the resolved canonical path, so both spellings work.
643        let overlay_text = self
644            .overlay
645            .get(include)
646            .or_else(|| {
647                canonical
648                    .as_ref()
649                    .and_then(|path| self.overlay.get(&path.to_string_lossy().into_owned()))
650            })
651            .cloned();
652
653        // The include-cycle identity: the canonical path when the file exists,
654        // otherwise the candidate path (overlays may not have a disk backing).
655        let identity = canonical.clone().unwrap_or_else(|| candidate.clone());
656        if self.include_stack.contains(&identity) {
657            return Err(OpyError::at(
658                "include-cycle",
659                format!(
660                    "include cycle detected: '{}' is already being included",
661                    identity.display()
662                ),
663                span,
664            ));
665        }
666
667        let text = match overlay_text {
668            Some(text) => text,
669            None => {
670                let canonical = canonical.ok_or_else(|| {
671                    OpyError::at(
672                        "include-not-found",
673                        format!(
674                            "cannot find included file '{include}' under root '{}'",
675                            self.root.display()
676                        ),
677                        span,
678                    )
679                })?;
680                std::fs::read_to_string(&canonical).map_err(|error| {
681                    OpyError::at(
682                        "include-not-found",
683                        format!("cannot read included file '{include}': {error}"),
684                        span,
685                    )
686                })?
687            }
688        };
689        // Each include registers a file in the registry (reference behavior).
690        let file_id = self.next_file_id;
691        self.next_file_id += 1;
692        self.files.push(FileRecord {
693            id: file_id,
694            path: include.to_string(),
695        });
696        self.include_stack.push(identity);
697        let saved_prefix = self.preprocessing.rule_prefix.clone();
698        let saved_optimization = self.preprocessing.optimization.clone();
699        // Settings blocks are only supported in the main file; an included
700        // file's block is rejected at its keyword span (file id of the
701        // included file, #86).
702        match crate::settings::find_blocks(&text, file_id) {
703            Err(error) => return Err(error),
704            Ok(blocks) if !blocks.is_empty() => {
705                return Err(OpyError::at(
706                    "settings-placement",
707                    "settings blocks are only supported in the main file".to_string(),
708                    blocks[0].keyword_span,
709                ));
710            }
711            Ok(_) => {}
712        }
713        let mut included = lex(LexInput {
714            file_id,
715            text: &text,
716        })?;
717        let allow_leading_main_file = text
718            .lines()
719            .next()
720            .is_some_and(|line| line.trim_end_matches('\r').starts_with("#!mainFile"));
721        let processed = self.process_directives(&mut included, allow_leading_main_file);
722        self.preprocessing.rule_prefix = saved_prefix;
723        self.preprocessing.optimization = saved_optimization;
724        if let Err(error) = processed {
725            self.include_stack.pop();
726            return Err(error);
727        }
728        // Drop the included file's Eof token (it terminates the file, not
729        // the spliced stream).
730        included.retain(|token| token.kind != TokenKind::Eof);
731        // Included tokens keep their real positions so the parser's
732        // indentation model works; span comparison is normalized away by the
733        // differential suite. File identity beyond the main file is preserved
734        // in diagnostics (include cycles/not-found name the real path).
735        out.extend(included);
736        self.include_stack.pop();
737        self.record("include", Some(include), span);
738        Ok(())
739    }
740
741    /// Register one `#!define` (object- or function-like).
742    ///
743    /// A define is function-like when `(` immediately follows the name
744    /// (`cakeBeam(start, end)`); a parenthesized object-like value
745    /// (`#!define X (a + b)`) keeps its parentheses as value tokens.
746    fn define(&mut self, rest: &str, span: Span) -> OpyResult<()> {
747        let rest = rest.trim();
748        let first_open = rest.find('(').unwrap_or(usize::MAX);
749        let first_space = rest.find(char::is_whitespace).unwrap_or(usize::MAX);
750        let is_function_like = first_open < first_space;
751
752        let (name, params, body_text) = if is_function_like {
753            let name = rest[..first_open].trim();
754            let Some(close) = rest[first_open..].find(')') else {
755                return Err(OpyError::at(
756                    "define-invalid",
757                    format!("malformed function-like define `#!define {rest}`: missing `)`"),
758                    span,
759                ));
760            };
761            let close = first_open + close;
762            let params: Vec<String> = rest[first_open + 1..close]
763                .split(',')
764                .map(|p| p.trim().to_string())
765                .filter(|p| !p.is_empty())
766                .collect();
767            let body = rest[close + 1..].trim();
768            (name.to_string(), params, body.to_string())
769        } else {
770            let name = rest[..first_space].trim();
771            let body = if first_space == usize::MAX {
772                String::new()
773            } else {
774                rest[first_space..].trim().to_string()
775            };
776            (name.to_string(), Vec::new(), body)
777        };
778        if name.is_empty() {
779            return Err(OpyError::at(
780                "define-invalid",
781                "malformed `#!define` directive: missing macro name",
782                span,
783            ));
784        }
785        if self.macros.iter().any(|macro_def| macro_def.name == name) {
786            if !self.preprocessing.allow_macro_redeclaration {
787                return Err(OpyError::at(
788                    "macro-redeclaration",
789                    format!("macro '{name}' is already defined"),
790                    span,
791                ));
792            }
793            self.macros.retain(|macro_def| macro_def.name != name);
794            self.defines.retain(|define| define.name != name);
795        }
796        let script = if is_function_like && body_text.starts_with("__script__(") {
797            // The OverPy script-macro ABI: the replacement is exactly
798            // `__script__("path.js")`; the reference extracts the path from
799            // the text between the parentheses and resolves it root-relative
800            // at the define site (missing files fail at compile time).
801            let inner = &body_text["__script__(".len()..];
802            let inner = inner.strip_suffix(')').ok_or_else(|| {
803                OpyError::at(
804                    "script-invalid",
805                    format!(
806                        "malformed script macro `#!define {rest}`: expected `__script__(\"path.js\")`"
807                    ),
808                    span,
809                )
810            })?;
811            let Some(path) = strip_quoted(inner.trim()) else {
812                return Err(OpyError::at(
813                    "script-invalid",
814                    format!(
815                        "malformed script macro `#!define {rest}`: expected a quoted script path"
816                    ),
817                    span,
818                ));
819            };
820            Some(self.resolve_script(path, span)?)
821        } else {
822            None
823        };
824        let body_tokens = lex(LexInput {
825            file_id: span.file,
826            text: &body_text,
827        })?;
828        // Drop the trailing EOF token from the value.
829        let body_tokens: Vec<Token> = body_tokens
830            .into_iter()
831            .filter(|t| t.kind != TokenKind::Eof)
832            .collect();
833        let is_function = is_function_like;
834        self.defines.push(DefineRecord {
835            name: name.clone(),
836            is_function,
837            span: Some(span),
838        });
839        self.macros.push(MacroDef {
840            name,
841            params,
842            body: body_tokens,
843            is_function,
844            script,
845        });
846        Ok(())
847    }
848
849    /// Expand all macros across the token stream, recursively.
850    fn expand(&self, tokens: Vec<Token>) -> OpyResult<Vec<Token>> {
851        let mut out = Vec::new();
852        let mut index = 0;
853        while index < tokens.len() {
854            let token = &tokens[index];
855            if token.kind == TokenKind::Ident {
856                let name = token.text.clone();
857                if let Some(mac) = self.macros.iter().find(|m| m.name == name) {
858                    if mac.is_function {
859                        // Expect `(` args `)` immediately after the name.
860                        let cursor = index + 1;
861                        if cursor < tokens.len() && tokens[cursor].kind == TokenKind::LParen {
862                            let (args, after) = self.collect_args(&tokens, cursor)?;
863                            let mut expanded = self.expand_macro(mac, args, token.span)?;
864                            self.expand_into(&mut expanded, &mut Vec::new(), 0)?;
865                            out.append(&mut expanded);
866                            index = after;
867                            continue;
868                        }
869                        // A function-like macro used without arguments: leave
870                        // the name as an ordinary identifier.
871                        out.push(token.clone());
872                        index += 1;
873                        continue;
874                    }
875                    let mut expanded = self.expand_macro(mac, Vec::new(), token.span)?;
876                    self.expand_into(&mut expanded, &mut Vec::new(), 0)?;
877                    out.append(&mut expanded);
878                    index += 1;
879                    continue;
880                }
881            }
882            out.push(token.clone());
883            index += 1;
884        }
885        Ok(out)
886    }
887
888    /// Collect the argument token lists of a function-like macro call,
889    /// returning `(args, index_after_closing_paren)`.
890    fn collect_args(&self, tokens: &[Token], open: usize) -> OpyResult<(Vec<Vec<Token>>, usize)> {
891        let mut args: Vec<Vec<Token>> = Vec::new();
892        let mut current: Vec<Token> = Vec::new();
893        let mut depth = 0usize;
894        let mut cursor = open + 1;
895        while cursor < tokens.len() {
896            let kind = tokens[cursor].kind;
897            if kind == TokenKind::LParen {
898                depth += 1;
899                current.push(tokens[cursor].clone());
900            } else if kind == TokenKind::RParen {
901                if depth == 0 {
902                    args.push(std::mem::take(&mut current));
903                    return Ok((args, cursor + 1));
904                }
905                depth -= 1;
906                current.push(tokens[cursor].clone());
907            } else if kind == TokenKind::Comma && depth == 0 {
908                args.push(std::mem::take(&mut current));
909            } else {
910                current.push(tokens[cursor].clone());
911            }
912            cursor += 1;
913        }
914        Err(OpyError::new(
915            "macro-invalid",
916            "unterminated macro invocation: missing closing `)`",
917        ))
918    }
919
920    /// Substitute macro params with the call arguments and stamp every
921    /// expanded token with the use-site span.
922    ///
923    /// Expanded tokens share the use-site span: the differential suite
924    /// normalizes spans away, and stamping the whole expansion with one
925    /// monotonic span keeps downstream span validation trivially valid.
926    fn expand_macro(
927        &self,
928        mac: &MacroDef,
929        args: Vec<Vec<Token>>,
930        use_site: Span,
931    ) -> OpyResult<Vec<Token>> {
932        if mac.is_function && args.len() != mac.params.len() {
933            return Err(OpyError::at(
934                "macro-arity",
935                format!(
936                    "macro '{}' expects {} argument(s) but got {}",
937                    mac.name,
938                    mac.params.len(),
939                    args.len()
940                ),
941                use_site,
942            ));
943        }
944        if let Some(script) = &mac.script {
945            return self.expand_script(mac, script, args, use_site);
946        }
947        let mut out = Vec::new();
948        for token in &mac.body {
949            if mac.is_function
950                && token.kind == TokenKind::Ident
951                && mac.params.iter().any(|p| p == &token.text)
952            {
953                let param_index = mac
954                    .params
955                    .iter()
956                    .position(|p| p == &token.text)
957                    .expect("checked above");
958                let mut replacement = args.get(param_index).cloned().unwrap_or_default();
959                for replacement_token in &mut replacement {
960                    replacement_token.span = use_site;
961                }
962                out.extend(replacement);
963            } else {
964                let mut token = token.clone();
965                token.span = use_site;
966                out.push(token);
967            }
968        }
969        Ok(out)
970    }
971
972    /// Expand a script macro: run the resolved script through the bounded
973    /// runtime with the call-site arguments injected, then lex the string
974    /// completion value back into the token stream at the use site.
975    ///
976    /// Argument text is reconstructed from the call-site tokens (see
977    /// [`raw_arg_text`]); the reference injects the raw source text, and the
978    /// reconstruction is JavaScript-value-equivalent to it (string literals
979    /// are re-quoted with JSON escaping, so quoting-style differences are
980    /// unobservable to the script). The reference's per-line indentation rule
981    /// is applied to the expansion text before lexing; the frontend parser
982    /// never consumes indentation, so this is preserved in the text only.
983    fn expand_script(
984        &self,
985        mac: &MacroDef,
986        script: &ScriptMacro,
987        args: Vec<Vec<Token>>,
988        use_site: Span,
989    ) -> OpyResult<Vec<Token>> {
990        let macro_args: Vec<MacroArg> = mac
991            .params
992            .iter()
993            .zip(args.iter())
994            .map(|(param, tokens)| MacroArg::new(param.clone(), raw_arg_text(tokens)))
995            .collect();
996        // Resource limits mirror the pinned reference constants (1000 ms macro
997        // budget, 64 MiB memory, 512 KiB stack; see `crate::macro_js::Limits`).
998        let runtime = MacroRuntime::new(Limits::default());
999        let result = runtime
1000            .run_macro(&script.source, &macro_args, &script.path)
1001            .map_err(|error| map_macro_error(&error, &script.path, use_site))?;
1002        // Reference indentation rule (`resolveMacro`): every newline in the
1003        // replacement is followed by the call line's indentation.
1004        let indent = " ".repeat(use_site.start.col.saturating_sub(1) as usize);
1005        let indented = result.text.replace('\n', &format!("\n{indent}"));
1006        let mut tokens = lex(LexInput {
1007            file_id: use_site.file,
1008            text: &indented,
1009        })?;
1010        tokens.retain(|token| token.kind != TokenKind::Eof);
1011        for token in &mut tokens {
1012            token.span = use_site;
1013        }
1014        Ok(tokens)
1015    }
1016
1017    /// Recursively expand macros inside an already-expanded run, guarding
1018    /// against direct recursion.
1019    fn expand_into(
1020        &self,
1021        tokens: &mut Vec<Token>,
1022        stack: &mut Vec<String>,
1023        depth: usize,
1024    ) -> OpyResult<()> {
1025        if depth > 64 {
1026            return Err(OpyError::new(
1027                "macro-recursion",
1028                "macro expansion exceeded the recursion limit (possible recursive define)",
1029            ));
1030        }
1031        let mut out: Vec<Token> = Vec::with_capacity(tokens.len());
1032        let mut index = 0;
1033        while index < tokens.len() {
1034            let token = &tokens[index];
1035            if token.kind == TokenKind::Ident {
1036                let name = token.text.clone();
1037                if let Some(mac) = self.macros.iter().find(|m| m.name == name) {
1038                    if stack.iter().any(|s| s == &name) {
1039                        return Err(OpyError::new(
1040                            "macro-recursion",
1041                            format!("recursive macro expansion detected for '{name}'"),
1042                        ));
1043                    }
1044                    if mac.is_function {
1045                        if index + 1 < tokens.len() && tokens[index + 1].kind == TokenKind::LParen {
1046                            let (args, after) = self.collect_args(tokens, index)?;
1047                            let mut expanded = self.expand_macro(mac, args, token.span)?;
1048                            stack.push(name.clone());
1049                            self.expand_into(&mut expanded, stack, depth + 1)?;
1050                            stack.pop();
1051                            out.append(&mut expanded);
1052                            index = after;
1053                            continue;
1054                        }
1055                        out.push(token.clone());
1056                        index += 1;
1057                        continue;
1058                    }
1059                    let mut expanded = self.expand_macro(mac, Vec::new(), token.span)?;
1060                    stack.push(name.clone());
1061                    self.expand_into(&mut expanded, stack, depth + 1)?;
1062                    stack.pop();
1063                    out.append(&mut expanded);
1064                    index += 1;
1065                    continue;
1066                }
1067            }
1068            out.push(token.clone());
1069            index += 1;
1070        }
1071        *tokens = out;
1072        Ok(())
1073    }
1074}
1075
1076fn split_directive(text: &str) -> (&str, &str) {
1077    text.split_once(char::is_whitespace)
1078        .map_or((text, ""), |(name, rest)| (name, rest))
1079}
1080
1081fn is_identifier_char(ch: char) -> bool {
1082    ch.is_ascii_alphanumeric() || ch == '_'
1083}
1084
1085fn parse_words(rest: &str, directive: &str, span: Span) -> OpyResult<Vec<String>> {
1086    let words: Vec<String> = rest.split_whitespace().map(str::to_string).collect();
1087    if words.is_empty() {
1088        return Err(OpyError::at(
1089            "directive-invalid",
1090            format!("`#!{directive}` expects at least one argument"),
1091            span,
1092        ));
1093    }
1094    if words
1095        .iter()
1096        .any(|word| word.chars().any(|ch| !is_identifier_char(ch)))
1097    {
1098        return Err(OpyError::at(
1099            "directive-invalid",
1100            format!("`#!{directive}` arguments must be identifiers"),
1101            span,
1102        ));
1103    }
1104    Ok(words)
1105}
1106
1107fn parse_translations(rest: &str, span: Span) -> OpyResult<Vec<String>> {
1108    let values: Vec<String> = rest
1109        .split_whitespace()
1110        .map(|language| language.replace('-', "_").to_lowercase())
1111        .collect();
1112    if values.is_empty() {
1113        return Err(OpyError::at(
1114            "translations-invalid",
1115            "`#!translations` expects at least one language",
1116            span,
1117        ));
1118    }
1119    const PINNED_LANGUAGES: &[&str] = &[
1120        "de", "en", "es", "es_es", "es_mx", "fr", "it", "ja", "ko", "pl", "pt", "ru", "th", "tr",
1121        "zh", "zh_cn", "zh_tw",
1122    ];
1123    if values
1124        .iter()
1125        .any(|language| !PINNED_LANGUAGES.contains(&language.as_str()))
1126    {
1127        return Err(OpyError::at(
1128            "translations-invalid",
1129            "invalid translation language; expected one of the pinned OverPy language codes",
1130            span,
1131        ));
1132    }
1133    if values.iter().any(|value| value == "es")
1134        && values
1135            .iter()
1136            .any(|value| value == "es_es" || value == "es_mx")
1137    {
1138        return Err(OpyError::at(
1139            "translations-invalid",
1140            "cannot combine `es` with `es_es` or `es_mx`",
1141            span,
1142        ));
1143    }
1144    if values.iter().any(|value| value == "zh")
1145        && values
1146            .iter()
1147            .any(|value| value == "zh_cn" || value == "zh_tw")
1148    {
1149        return Err(OpyError::at(
1150            "translations-invalid",
1151            "cannot combine `zh` with `zh_cn` or `zh_tw`",
1152            span,
1153        ));
1154    }
1155    Ok(values)
1156}
1157
1158#[derive(Clone, Copy)]
1159enum OptimizationControl {
1160    Enable,
1161    Disable,
1162    ForSize,
1163    DisableForSize,
1164    ForSizeAggressive,
1165    Strict,
1166    DisableStrict,
1167}
1168
1169fn optimization_directive(name: &str) -> Option<(&str, OptimizationControl)> {
1170    Some(match name {
1171        "disableOptimizations" => (name, OptimizationControl::Disable),
1172        "enableOptimizations" => (name, OptimizationControl::Enable),
1173        "optimizeForSize" => (name, OptimizationControl::ForSize),
1174        "disableOptimizeForSize" => (name, OptimizationControl::DisableForSize),
1175        "optimizeForSizeAggressive" => (name, OptimizationControl::ForSizeAggressive),
1176        "optimizeStrict" => (name, OptimizationControl::Strict),
1177        "disableOptimizeStrict" => (name, OptimizationControl::DisableStrict),
1178        _ => return None,
1179    })
1180}
1181
1182fn apply_optimization(state: &mut OptimizationState, control: OptimizationControl) {
1183    match control {
1184        OptimizationControl::Enable => state.enabled = true,
1185        OptimizationControl::Disable => state.enabled = false,
1186        OptimizationControl::ForSize => state.for_size = true,
1187        OptimizationControl::DisableForSize => state.for_size = false,
1188        OptimizationControl::ForSizeAggressive => state.for_size_aggressive = true,
1189        OptimizationControl::Strict => state.strict = true,
1190        OptimizationControl::DisableStrict => state.strict = false,
1191    }
1192}
1193
1194fn replacement_directive(name: &str) -> Option<&str> {
1195    Some(match name {
1196        "replace0ByCapturePercentage" => "getCapturePercentage",
1197        "replace0ByPayloadProgressPercentage" => "getPayloadProgressPercentage",
1198        "replace0ByIsMatchComplete" => "isMatchComplete",
1199        "replace1ByMatchRound" => "getMatchRound",
1200        "replaceTeam1ByControlScoringTeam" => "getControlScoringTeam",
1201        "replaceEmptyStringByEmptyArray" => "emptyArray",
1202        "replaceEmptyStringByVariable" => "variable",
1203        _ => return None,
1204    })
1205}
1206
1207fn replacement_family(name: &str) -> Option<&str> {
1208    Some(match name {
1209        "replace0ByCapturePercentage"
1210        | "replace0ByPayloadProgressPercentage"
1211        | "replace0ByIsMatchComplete" => "0",
1212        "replace1ByMatchRound" => "1",
1213        "replaceTeam1ByControlScoringTeam" => "team1",
1214        "replaceEmptyStringByEmptyArray" | "replaceEmptyStringByVariable" => "emptyString",
1215        _ => return None,
1216    })
1217}
1218
1219/// Strips a matched `"…"` or `'…'` pair, returning the inner text.
1220fn strip_quoted(text: &str) -> Option<&str> {
1221    text.strip_prefix('"')
1222        .and_then(|rest| rest.strip_suffix('"'))
1223        .or_else(|| {
1224            text.strip_prefix('\'')
1225                .and_then(|rest| rest.strip_suffix('\''))
1226        })
1227}
1228
1229/// Reconstructs the raw call-site argument text from its tokens.
1230///
1231/// The reference injects the raw source substring as `var <name>=<raw>;`; the
1232/// token model stores string values unescaped, so string tokens are re-quoted
1233/// with JSON escaping. The reconstruction is JavaScript-value-equivalent to
1234/// the reference's raw injection: identifiers, numbers, operators, and
1235/// punctuation pass through verbatim, and string literals differ only in
1236/// quoting style, which is unobservable to the script.
1237fn raw_arg_text(tokens: &[Token]) -> String {
1238    let mut out = String::new();
1239    for token in tokens {
1240        match token.kind {
1241            TokenKind::String => out.push_str(&json_string_literal(&token.text)),
1242            TokenKind::Newline => out.push('\n'),
1243            _ => out.push_str(&token.text),
1244        }
1245    }
1246    out
1247}
1248
1249/// Encodes `value` as a JSON string literal (double-quoted, escaped).
1250fn json_string_literal(value: &str) -> String {
1251    serde_json::to_string(value).expect("serializing a string is infallible")
1252}
1253
1254/// Maps a runtime [`MacroError`] to a structured frontend diagnostic with the
1255/// script path as provenance and the directive/call-site span.
1256///
1257/// The runtime's QuickJS abort messages are classified into stable codes:
1258/// `script-timeout` (`"interrupted"`), `script-memory-limit`
1259/// (`"out of memory"`), `script-stack-limit`
1260/// (`"Maximum call stack size exceeded"`), and `script-error` for thrown
1261/// exceptions (with the script path and, when the engine provided one, the
1262/// line/column). Non-string completion values are `script-result-not-string`
1263/// with the reference's wording, and engine setup failures are
1264/// `script-internal`.
1265pub(crate) fn map_macro_error(error: &MacroError, script_path: &str, span: Span) -> OpyError {
1266    match error {
1267        MacroError::Script(script) => {
1268            let code = match script.message.as_str() {
1269                "interrupted" => "script-timeout",
1270                "out of memory" => "script-memory-limit",
1271                "Maximum call stack size exceeded" => "script-stack-limit",
1272                _ => "script-error",
1273            };
1274            let location = match (script.line, script.column) {
1275                (Some(line), Some(column)) => format!(" (line {line}, column {column})"),
1276                (Some(line), None) => format!(" (line {line})"),
1277                _ => String::new(),
1278            };
1279            OpyError::at(
1280                code,
1281                format!(
1282                    "script '{}' failed: {}{}",
1283                    script_path, script.message, location
1284                ),
1285                span,
1286            )
1287        }
1288        MacroError::InvalidResult { type_name } => OpyError::at(
1289            "script-result-not-string",
1290            format!(
1291                "JavaScript macro returned value with type of {type_name}, expected string. Try using .toString()"
1292            ),
1293            span,
1294        ),
1295        MacroError::Internal(message) => OpyError::at(
1296            "script-internal",
1297            format!("script '{}' runtime failure: {message}", script_path),
1298            span,
1299        ),
1300    }
1301}
1302
1303#[cfg(test)]
1304mod tests {
1305    use super::*;
1306
1307    #[test]
1308    fn object_define_expands_at_use_site() {
1309        let (pre, _) = preprocess(
1310            "#!define SIDE 1.5\nrule \"r\":\n    x = SIDE\n",
1311            "main.opy",
1312            Path::new("."),
1313        )
1314        .unwrap();
1315        assert_eq!(pre.defines.len(), 1);
1316        assert_eq!(pre.defines[0].name, "SIDE");
1317        assert!(!pre.defines[0].is_function);
1318        let numbers: Vec<&str> = pre
1319            .tokens
1320            .iter()
1321            .filter(|t| t.kind == TokenKind::Number)
1322            .map(|t| t.text.as_str())
1323            .collect();
1324        assert_eq!(numbers, vec!["1.5"]);
1325    }
1326
1327    #[test]
1328    fn function_define_substitutes_params() {
1329        let (pre, _) = preprocess(
1330            "#!define double(x) x + x\nrule \"r\":\n    y = double(3)\n",
1331            "main.opy",
1332            Path::new("."),
1333        )
1334        .unwrap();
1335        let numbers: Vec<&str> = pre
1336            .tokens
1337            .iter()
1338            .filter(|t| t.kind == TokenKind::Number)
1339            .map(|t| t.text.as_str())
1340            .collect();
1341        assert_eq!(numbers, vec!["3", "3"]);
1342    }
1343
1344    #[test]
1345    fn macro_expanded_string_can_concatenate_with_following_literal() {
1346        let (pre, _) = preprocess(
1347            "#!define PREFIX \"one\"\nrule \"r\":\n    debug(PREFIX\n        \"two\")\n",
1348            "main.opy",
1349            Path::new("."),
1350        )
1351        .unwrap();
1352        let output = crate::parser::parse(&pre.tokens);
1353        assert!(
1354            output.errors.is_empty(),
1355            "unexpected errors: {:?}",
1356            output.errors
1357        );
1358        let program = output.program.expect("expanded source must parse");
1359        let crate::cst::RuleEntry::Rule(rule) = &program.rules[0] else {
1360            panic!("expected rule");
1361        };
1362        let crate::cst::Stmt::Expr { expr, .. } = &rule.actions[0] else {
1363            panic!("expected expression statement");
1364        };
1365        let crate::cst::Expr::Call { args, .. } = expr else {
1366            panic!("expected call");
1367        };
1368        assert!(matches!(
1369            &args[0].value,
1370            crate::cst::Expr::String { value, .. } if value == "onetwo"
1371        ));
1372    }
1373
1374    #[test]
1375    fn recursive_defines_expand_transitively() {
1376        let (pre, _) = preprocess(
1377            "#!define A 2\n#!define B A + 1\nrule \"r\":\n    x = B\n",
1378            "main.opy",
1379            Path::new("."),
1380        )
1381        .unwrap();
1382        let numbers: Vec<&str> = pre
1383            .tokens
1384            .iter()
1385            .filter(|t| t.kind == TokenKind::Number)
1386            .map(|t| t.text.as_str())
1387            .collect();
1388        assert_eq!(numbers, vec!["2", "1"]);
1389    }
1390
1391    #[test]
1392    fn recursive_define_fails_structurally() {
1393        let error = preprocess(
1394            "#!define X X + 1\nrule \"r\":\n    x = X\n",
1395            "main.opy",
1396            Path::new("."),
1397        )
1398        .unwrap_err();
1399        assert_eq!(error.code, "macro-recursion");
1400    }
1401
1402    #[test]
1403    fn missing_include_is_structured() {
1404        let error = preprocess(
1405            "#!include \"nope.opy\"\n",
1406            "main.opy",
1407            Path::new("/nonexistent-root"),
1408        )
1409        .unwrap_err();
1410        assert_eq!(error.code, "include-not-found");
1411        assert!(error.span.is_some());
1412    }
1413
1414    #[test]
1415    fn include_cycle_is_detected() {
1416        let dir = std::env::temp_dir().join(format!("wright-opy-test-{}", std::process::id()));
1417        std::fs::create_dir_all(&dir).unwrap();
1418        std::fs::write(dir.join("a.opy"), "#!include \"b.opy\"\n").unwrap();
1419        std::fs::write(dir.join("b.opy"), "#!include \"a.opy\"\n").unwrap();
1420        let main = std::fs::read_to_string(dir.join("a.opy")).unwrap();
1421        let error = preprocess(&main, "a.opy", &dir).unwrap_err();
1422        assert_eq!(error.code, "include-cycle");
1423        let _ = std::fs::remove_dir_all(&dir);
1424    }
1425
1426    #[test]
1427    fn unsupported_directive_is_structured() {
1428        let error = preprocess("#!frobnicate\n", "main.opy", Path::new(".")).unwrap_err();
1429        assert_eq!(error.code, "unsupported-directive");
1430    }
1431
1432    #[test]
1433    fn settings_block_is_extracted_before_lexing() {
1434        let (pre, _) = preprocess(
1435            "settings {\n    \"gamemodes\": {}\n}\nrule \"r\":\n    pass\n",
1436            "main.opy",
1437            Path::new("."),
1438        )
1439        .unwrap();
1440        let block = pre.settings.expect("settings block extracted");
1441        assert!(block.text.contains("gamemodes"));
1442        // The block never enters the token stream.
1443        assert!(
1444            !pre.tokens.iter().any(|t| t.text.contains("gamemodes")),
1445            "settings content must not be lexed"
1446        );
1447    }
1448
1449    #[test]
1450    fn settings_in_include_is_rejected() {
1451        let dir =
1452            std::env::temp_dir().join(format!("wright-opy-settings-test-{}", std::process::id()));
1453        std::fs::create_dir_all(&dir).unwrap();
1454        std::fs::write(
1455            dir.join("shared.opy"),
1456            "settings {\n    \"gamemodes\": {}\n}\n",
1457        )
1458        .unwrap();
1459        let main = "#!include \"shared.opy\"\nrule \"r\":\n    pass\n";
1460        let error = preprocess(main, "main.opy", &dir).unwrap_err();
1461        assert_eq!(error.code, "settings-placement");
1462        assert_eq!(
1463            error.span.unwrap().file,
1464            1,
1465            "the span names the included file"
1466        );
1467        let _ = std::fs::remove_dir_all(&dir);
1468    }
1469
1470    #[test]
1471    fn dict_literal_braces_reach_the_parser() {
1472        // Scoped settings lexing must not consume expression-level braces.
1473        let (pre, _) = preprocess(
1474            "rule \"r\":\n    money += {\n        Mei.GENERIC: 10,\n    }\n",
1475            "main.opy",
1476            Path::new("."),
1477        )
1478        .unwrap();
1479        assert!(
1480            pre.tokens
1481                .iter()
1482                .any(|token| token.kind == TokenKind::LBrace)
1483        );
1484        assert!(
1485            pre.tokens
1486                .iter()
1487                .any(|token| token.kind == TokenKind::RBrace)
1488        );
1489    }
1490
1491    #[test]
1492    fn advanced_directives_preserve_frontend_state_without_catalog_data() {
1493        let (pre, _) = preprocess(
1494            "#!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",
1495            "main.opy",
1496            Path::new("."),
1497        )
1498        .unwrap();
1499        assert!(pre.preprocessing.allow_macro_redeclaration);
1500        assert_eq!(
1501            pre.preprocessing
1502                .translations
1503                .as_ref()
1504                .map(|state| state.languages.as_slice()),
1505            Some(["en".to_string(), "fr".to_string()].as_slice())
1506        );
1507        assert_eq!(
1508            pre.preprocessing
1509                .rule_prefix
1510                .as_ref()
1511                .map(|value| value.value.as_str()),
1512            Some("Effects")
1513        );
1514        assert!(pre.preprocessing.optimization.for_size);
1515        assert!(pre.preprocessing.optimization.strict);
1516        assert_eq!(
1517            pre.preprocessing.replacements[0].value,
1518            "getCapturePercentage"
1519        );
1520        assert_eq!(pre.defines.len(), 1);
1521    }
1522
1523    #[test]
1524    fn translations_follow_pinned_codes_without_local_deduplication() {
1525        let (pre, _) = preprocess(
1526            "#!translations EN zh-cn en\nrule \"r\":\n    pass\n",
1527            "main.opy",
1528            Path::new("."),
1529        )
1530        .unwrap();
1531        assert_eq!(
1532            pre.preprocessing.translations.unwrap().languages,
1533            vec!["en", "zh_cn", "en"]
1534        );
1535    }
1536
1537    #[test]
1538    fn translations_reject_codes_outside_the_pinned_oracle_set() {
1539        let error = preprocess(
1540            "#!translations en_US\nrule \"r\":\n    pass\n",
1541            "main.opy",
1542            Path::new("."),
1543        )
1544        .unwrap_err();
1545        assert_eq!(error.code, "translations-invalid");
1546    }
1547
1548    #[test]
1549    fn directive_records_expose_state_transitions_and_include_depth() {
1550        let root =
1551            std::env::temp_dir().join(format!("wright-opy-directive-scope-{}", std::process::id()));
1552        std::fs::create_dir_all(&root).unwrap();
1553        std::fs::write(
1554            root.join("child.opy"),
1555            "#!rulePrefix \"inner\"\n#!disableOptimizations\n",
1556        )
1557        .unwrap();
1558        let (pre, _) = preprocess(
1559            "#!rulePrefix \"outer\"\n#!include \"child.opy\"\n#!enableOptimizations\n",
1560            "main.opy",
1561            &root,
1562        )
1563        .unwrap();
1564        let records = &pre.preprocessing.directives;
1565        assert_eq!(records[0].state.rule_prefix.as_deref(), Some("outer"));
1566        assert_eq!(records[0].scope_depth, 0);
1567        assert_eq!(records[1].name, "rulePrefix");
1568        assert_eq!(records[1].state.rule_prefix.as_deref(), Some("inner"));
1569        assert!(!records[2].state.optimization.enabled);
1570        assert_eq!(records[2].scope_depth, 1);
1571        assert_eq!(records[3].name, "include");
1572        assert_eq!(records[3].state.rule_prefix.as_deref(), Some("outer"));
1573        assert_eq!(records[4].name, "enableOptimizations");
1574        assert!(records[4].state.optimization.enabled);
1575        let _ = std::fs::remove_dir_all(&root);
1576    }
1577
1578    #[test]
1579    fn malformed_translation_state_is_source_located() {
1580        let error = preprocess("#!translations\n", "main.opy", Path::new(".")).unwrap_err();
1581        assert_eq!(error.code, "translations-invalid");
1582        assert!(error.span.is_some());
1583    }
1584}