Skip to main content

opy_rs/
preprocess.rs

1//! `.opy` preprocessing: includes, `#!define`/`#!defineMember` macros (textual and
2//! `__script__` JavaScript-backed), `#!postCompileHook`, and expansion.
3//!
4//! Operates at the token level, matching the reference frontend's observable
5//! behavior: `#!include "file.opy"` splices the included file's tokens at the
6//! directive site; `#!define NAME value` and `#!define name(args) value`
7//! register macros that expand at their use sites, recursively (a macro may
8//! reference earlier macros). The output is a single-file token stream whose
9//! spans point at use sites, mirroring the reference adapter's provenance
10//! convention (the HIR file registry records the included sources). Invalid
11//! include graphs (cycles, missing files) and recursive defines fail
12//! deterministically with structured diagnostics that name the offending file/line.
13//!
14//! # JavaScript macros and hooks
15//!
16//! A function-like define whose replacement starts with `__script__("…")`
17//! (OverPy 9.7.10 ABI, `src/compiler/tokenizer.ts`) is a script macro: the
18//! script path resolves relative to the definition file (missing files are a
19//! `script-not-found` diagnostic, mirroring the reference's ENOENT failure),
20//! and each expansion runs the script through [`crate::macro_js::MacroRuntime`]
21//! with the call-site arguments injected as `var <name>=<raw>;` declarations
22//! (the reference's `resolveMacro`). The string completion value is lexed
23//! back into the token stream at the call site, with the reference's
24//! per-line indentation rule applied to the text; the frontend token model
25//! makes indentation unobservable (the parser never consumes it), so the rule
26//! is preserved in the expansion text only. Runtime failures map to the
27//! structured `script-*` diagnostics with the script path, line, and column.
28//!
29//! `#!postCompileHook "hook.js"` registers the post-compile hook script
30//! (duplicate declarations are rejected like the reference). The frontend
31//! recognizes, parses, validates, and records the directive only — it never
32//! executes the hook: real hook execution receives the final Workshop text
33//! produced by lowering and is lowering-dependent (workshop-rs emission,
34//! issue #8); the frontend never fabricates a Workshop payload.
35//!
36//! Boundary: `__script__` macros expand at compile time through the runtime
37//! (source-supported); `#!postCompileHook` is recorded and executed only
38//! against the real Workshop output (lowering-dependent). The runtime's hook
39//! ABI is tested separately on synthetic content in the internal macro runtime
40//! module (see its `hooks` test suite).
41
42use std::collections::{BTreeMap, BTreeSet};
43use std::path::{Path, PathBuf};
44
45use crate::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 is_member: bool,
61    pub span: Option<Span>,
62}
63
64/// A resolved `__script__("…")` macro backing.
65#[derive(Debug, Clone, PartialEq)]
66pub struct ScriptMacro {
67    /// The resolved project-relative script path, used for diagnostics and
68    /// runtime attribution.
69    pub path: String,
70    /// The script text, read at the define site.
71    pub source: String,
72}
73
74/// A registered `#!postCompileHook` script (the declaration record).
75///
76/// The frontend recognizes, parses, validates, and records the directive; it
77/// never executes the hook. Execution against the final Workshop text is
78/// lowering-dependent (issue #8).
79#[derive(Debug, Clone, PartialEq)]
80pub struct PostCompileHook {
81    /// The resolved project-relative script path.
82    pub path: String,
83    /// The script text, read at the directive site.
84    pub source: String,
85    /// The directive's source span, used for error attribution.
86    pub span: Span,
87}
88
89/// The result of preprocessing.
90#[derive(Debug, Clone)]
91pub struct Preprocessed {
92    /// The expanded, single-file token stream.
93    pub tokens: Vec<Token>,
94    /// The recorded defines in definition order.
95    pub defines: Vec<DefineRecord>,
96    /// The project `settings { ... }` block, when present (#86).
97    pub settings: Option<SettingsBlock>,
98    /// Warnings emitted while composing the project.
99    pub warnings: Vec<PreprocessWarning>,
100    /// The registered `#!postCompileHook` script, when declared.
101    pub post_compile_hook: Option<PostCompileHook>,
102    /// Frontend-visible preprocessing state; backend effects are not run.
103    pub preprocessing: PreprocessingState,
104}
105
106/// The output file registry, in include order, preserving source provenance.
107#[derive(Debug, Clone, PartialEq)]
108pub struct FileRecord {
109    pub id: u32,
110    pub path: String,
111}
112
113/// A source-attributed preprocessing warning.
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct PreprocessWarning {
116    pub code: String,
117    pub message: String,
118    pub span: Span,
119}
120
121/// Preprocess the main source text with its include root.
122pub fn preprocess(
123    main_text: &str,
124    main_path: &str,
125    root: &Path,
126) -> OpyResult<(Preprocessed, Vec<FileRecord>)> {
127    preprocess_with_overlay(main_text, main_path, root, &BTreeMap::new())
128}
129
130/// Preprocess with open-document overlays: includes resolve to overlay text
131/// (keyed by the include string or the resolved canonical path) before the
132/// filesystem. Overlays model unsaved editor buffers without changing the
133/// compiler's source-loading contract.
134pub fn preprocess_with_overlay(
135    main_text: &str,
136    main_path: &str,
137    root: &Path,
138    overlay: &BTreeMap<String, String>,
139) -> OpyResult<(Preprocessed, Vec<FileRecord>)> {
140    preprocess_with_overlay_outcome(main_text, main_path, root, overlay).result
141}
142
143/// The outcome of preprocessing with overlays, retaining the file registry
144/// registered so far even when a directive or expansion fails, so callers can
145/// map an error's span file id to its actual source.
146pub struct PreprocessOutcome {
147    pub result: OpyResult<(Preprocessed, Vec<FileRecord>)>,
148    pub files: Vec<FileRecord>,
149    pub warnings: Vec<PreprocessWarning>,
150}
151
152/// Preprocess with open-document overlays while retaining the file registry
153/// registered so far on failure.
154pub fn preprocess_with_overlay_outcome(
155    main_text: &str,
156    main_path: &str,
157    root: &Path,
158    overlay: &BTreeMap<String, String>,
159) -> PreprocessOutcome {
160    let resolved_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
161    let mut pre = Preprocessor {
162        files: vec![FileRecord {
163            id: 0,
164            path: main_path.to_string(),
165        }],
166        next_file_id: 1,
167        root: resolved_root.clone(),
168        display_root: resolved_root,
169        overlay: overlay.clone(),
170        include_stack: Vec::new(),
171        imported_files: BTreeSet::new(),
172        macros: Vec::new(),
173        defines: Vec::new(),
174        post_compile_hook: None,
175        settings: None,
176        warnings: Vec::new(),
177        preprocessing: PreprocessingState::default(),
178    };
179    let mut owned_main_text = None;
180    let mut source_file_id = 0;
181    let first_line = main_text.lines().next().unwrap_or_default();
182    if first_line.trim_start().starts_with("#!mainFile")
183        && first_main_file_directive(main_text).is_none()
184    {
185        let span = Span::new(
186            0,
187            crate::diag::Position::new(1, 1),
188            crate::diag::Position::new(1, first_line.chars().count() as u32 + 1),
189        );
190        return PreprocessOutcome {
191            result: Err(OpyError::at(
192                "main-file-invalid",
193                "`#!mainFile` expects one quoted path on the first line",
194                span,
195            )),
196            files: pre.files,
197            warnings: pre.warnings,
198        };
199    }
200    if let Some((main_file, span)) = first_main_file_directive(main_text) {
201        let candidate = pre.root.join(&main_file);
202        let canonical = std::fs::canonicalize(&candidate).ok();
203        let overlay_text = overlay
204            .get(&main_file)
205            .or_else(|| {
206                canonical
207                    .as_ref()
208                    .and_then(|path| overlay.get(&path.to_string_lossy().into_owned()))
209            })
210            .cloned();
211        let (text, canonical_path, new_root) = match overlay_text {
212            Some(text) => {
213                let new_root = candidate
214                    .parent()
215                    .map(Path::to_path_buf)
216                    .unwrap_or_else(|| pre.root.clone());
217                (text, canonical, new_root)
218            }
219            None => {
220                let Some(canonical) = canonical else {
221                    return PreprocessOutcome {
222                        result: Err(OpyError::at(
223                            "main-file-not-found",
224                            format!("cannot find main file '{main_file}'"),
225                            span,
226                        )),
227                        files: pre.files,
228                        warnings: pre.warnings,
229                    };
230                };
231                let text = match std::fs::read_to_string(&canonical) {
232                    Ok(text) => text,
233                    Err(error) => {
234                        return PreprocessOutcome {
235                            result: Err(OpyError::at(
236                                "main-file-not-found",
237                                format!("cannot read main file '{main_file}': {error}"),
238                                span,
239                            )),
240                            files: pre.files,
241                            warnings: pre.warnings,
242                        };
243                    }
244                };
245                let new_root = canonical
246                    .parent()
247                    .map(Path::to_path_buf)
248                    .unwrap_or_else(|| pre.root.clone());
249                (text, Some(canonical), new_root)
250            }
251        };
252        let display_path =
253            display_path(&candidate, canonical_path.as_deref(), &new_root, &main_file);
254        owned_main_text = Some(text);
255        source_file_id = 1;
256        pre.files.push(FileRecord {
257            id: source_file_id,
258            path: display_path,
259        });
260        pre.next_file_id = 2;
261        pre.root = new_root.clone();
262        pre.display_root = new_root;
263        pre.preprocessing.main_file = Some(DirectiveValue {
264            value: main_file.clone(),
265            span: Some(span.into()),
266        });
267        pre.record("mainFile", Some(&main_file), span);
268    }
269    let source_text = owned_main_text.as_deref().unwrap_or(main_text);
270    // The project settings block is extracted before lexing and blanked out of
271    // the owning file's lexed text, so the lexer never sees its braces (#86).
272    let settings = match crate::settings::find_blocks(source_text, source_file_id) {
273        Ok(mut blocks) => blocks.pop(),
274        Err(error) => {
275            return PreprocessOutcome {
276                result: Err(error),
277                files: pre.files,
278                warnings: pre.warnings,
279            };
280        }
281    };
282    pre.settings = settings.clone();
283    let tokens = match &settings {
284        Some(block) => {
285            let sanitized = crate::settings::sanitize_for_lex(source_text, block);
286            lex(LexInput {
287                file_id: source_file_id,
288                text: &sanitized,
289            })
290        }
291        None => lex(LexInput {
292            file_id: source_file_id,
293            text: source_text,
294        }),
295    };
296    let mut tokens = match tokens {
297        Ok(tokens) => tokens,
298        Err(error) => {
299            return PreprocessOutcome {
300                result: Err(error),
301                files: pre.files,
302                warnings: pre.warnings,
303            };
304        }
305    };
306    if let Err(error) = pre.process_directives(&mut tokens, false) {
307        return PreprocessOutcome {
308            result: Err(error),
309            files: pre.files,
310            warnings: pre.warnings,
311        };
312    }
313    match pre.expand(tokens) {
314        Ok(tokens) => {
315            let result = Ok((
316                Preprocessed {
317                    tokens,
318                    defines: pre.defines,
319                    settings: pre.settings,
320                    warnings: pre.warnings.clone(),
321                    post_compile_hook: pre.post_compile_hook,
322                    preprocessing: pre.preprocessing,
323                },
324                pre.files.clone(),
325            ));
326            PreprocessOutcome {
327                result,
328                files: pre.files,
329                warnings: pre.warnings,
330            }
331        }
332        Err(error) => PreprocessOutcome {
333            result: Err(error),
334            files: pre.files,
335            warnings: pre.warnings,
336        },
337    }
338}
339
340struct Preprocessor {
341    files: Vec<FileRecord>,
342    next_file_id: u32,
343    root: PathBuf,
344    display_root: PathBuf,
345    overlay: BTreeMap<String, String>,
346    include_stack: Vec<PathBuf>,
347    imported_files: BTreeSet<PathBuf>,
348    macros: Vec<MacroDef>,
349    settings: Option<SettingsBlock>,
350    defines: Vec<DefineRecord>,
351    post_compile_hook: Option<PostCompileHook>,
352    warnings: Vec<PreprocessWarning>,
353    preprocessing: PreprocessingState,
354}
355
356/// A registered macro: object-like, function-like, or a script macro.
357struct MacroDef {
358    name: String,
359    params: Vec<String>,
360    body: Vec<Token>,
361    /// True when the body came from a `#!define name(args) value` form.
362    is_function: bool,
363    /// The resolved `__script__` backing, when the replacement is one.
364    script: Option<ScriptMacro>,
365}
366
367fn first_main_file_directive(text: &str) -> Option<(String, Span)> {
368    let line = text.lines().next()?.trim_end_matches('\r');
369    let rest = line.strip_prefix("#!mainFile")?;
370    let value = rest.trim();
371    let value = strip_quoted(value)?.to_string();
372    let end_col = line.chars().count() as u32 + 1;
373    Some((
374        value,
375        Span::new(
376            0,
377            crate::diag::Position::new(1, 1),
378            crate::diag::Position::new(1, end_col),
379        ),
380    ))
381}
382
383fn display_path(candidate: &Path, canonical: Option<&Path>, root: &Path, fallback: &str) -> String {
384    let path = canonical.unwrap_or(candidate);
385    let Some(relative) = path.strip_prefix(root).ok() else {
386        return path.to_string_lossy().replace('\\', "/");
387    };
388    let mut components = Vec::new();
389    for component in relative.components() {
390        match component {
391            std::path::Component::CurDir => {}
392            std::path::Component::ParentDir => {
393                components.push("..".to_string());
394            }
395            std::path::Component::Normal(component) => {
396                components.push(component.to_string_lossy().into_owned());
397            }
398            _ => {}
399        }
400    }
401    if components.is_empty() {
402        fallback.to_string()
403    } else {
404        components.join("/")
405    }
406}
407
408impl Preprocessor {
409    /// Process `#!` directive tokens, splicing includes and registering
410    /// defines. Non-directive tokens are kept in place.
411    fn process_directives(
412        &mut self,
413        tokens: &mut Vec<Token>,
414        allow_leading_main_file: bool,
415    ) -> OpyResult<()> {
416        let mut out: Vec<Token> = Vec::with_capacity(tokens.len());
417        for token in tokens.drain(..) {
418            if token.kind == TokenKind::Directive {
419                let is_leading_main_file = allow_leading_main_file && token.span.start.line == 1;
420                self.handle_directive(token, &mut out, is_leading_main_file)?;
421            } else if token.kind == TokenKind::Ident
422                && matches!(token.text.as_str(), "rule" | "def")
423                && self.preprocessing.rule_prefix.is_some()
424            {
425                let prefix = self
426                    .preprocessing
427                    .rule_prefix
428                    .as_ref()
429                    .map(|value| value.value.clone())
430                    .unwrap_or_default();
431                out.push(Token {
432                    kind: TokenKind::RulePrefixMarker,
433                    text: prefix,
434                    raw: None,
435                    span: token.span,
436                });
437                out.push(token);
438            } else {
439                out.push(token);
440            }
441        }
442        *tokens = out;
443        Ok(())
444    }
445
446    fn handle_directive(
447        &mut self,
448        token: Token,
449        out: &mut Vec<Token>,
450        allow_leading_main_file: bool,
451    ) -> OpyResult<()> {
452        let text = token.text.trim();
453        let span = token.span;
454        let (name, rest) = split_directive(text);
455        if name == "include" {
456            let rest = rest.trim();
457            let include = rest
458                .strip_prefix('"')
459                .and_then(|r| r.strip_suffix('"'))
460                .or_else(|| rest.strip_prefix('\'').and_then(|r| r.strip_suffix('\'')));
461            let Some(include) = include else {
462                return Err(OpyError::at(
463                    "include-invalid",
464                    format!(
465                        "invalid include directive: `{text}` (expected `#!include \"file.opy\"`)"
466                    ),
467                    span,
468                ));
469            };
470            self.include(include, span, out)?;
471            return Ok(());
472        }
473        if matches!(name, "define" | "defineMember") {
474            self.define(rest.trim(), span, name == "defineMember")?;
475            return Ok(());
476        }
477        if name == "undef" {
478            let name = rest.trim();
479            if name.is_empty() || name.chars().any(|ch| !is_identifier_char(ch)) {
480                return Err(OpyError::at(
481                    "undef-invalid",
482                    "malformed `#!undef` directive: expected one macro name",
483                    span,
484                ));
485            }
486            self.macros.retain(|m| m.name != name);
487            self.defines.retain(|define| define.name != name);
488            self.record("undef", Some(name), span);
489            return Ok(());
490        }
491        if name == "postCompileHook" {
492            let rest = rest.trim();
493            let Some(path) = strip_quoted(rest) else {
494                return Err(OpyError::at(
495                    "script-invalid",
496                    format!(
497                        "invalid postCompileHook directive: `{text}` (expected `#!postCompileHook \"hook.js\"`)"
498                    ),
499                    span,
500                ));
501            };
502            if self.post_compile_hook.is_some() {
503                return Err(OpyError::at(
504                    "post-compile-hook-duplicate",
505                    "post-compile hook is already defined".to_string(),
506                    span,
507                ));
508            }
509            let hook = self.resolve_script(path, span, &self.root)?;
510            self.post_compile_hook = Some(PostCompileHook {
511                path: hook.path,
512                source: hook.source,
513                span,
514            });
515            self.record("postCompileHook", Some(path), span);
516            return Ok(());
517        }
518        if matches!(name, "setupTags" | "setupTx") {
519            require_no_arguments(name, rest, span)?;
520            self.record(name, None, span);
521            return Ok(());
522        }
523        if name == "mainFile" {
524            if allow_leading_main_file {
525                let main_file = strip_quoted(rest.trim())
526                    .filter(|main_file| !main_file.is_empty())
527                    .ok_or_else(|| {
528                        OpyError::at(
529                            "main-file-invalid",
530                            "`#!mainFile` expects one quoted path",
531                            span,
532                        )
533                    })?;
534                self.record(name, Some(main_file), span);
535                return Ok(());
536            }
537            return Err(OpyError::at(
538                "main-file-placement",
539                "`#!mainFile` must be the first directive in the main source",
540                span,
541            ));
542        }
543        if name == "allowMacroRedeclaration" {
544            self.preprocessing.allow_macro_redeclaration = true;
545            self.record(name, None, span);
546            return Ok(());
547        }
548        if name == "excludeVariablesInCompilation" {
549            require_no_arguments(name, rest, span)?;
550            self.record(name, None, span);
551            return Ok(());
552        }
553        if name == "extension" {
554            let extension = parse_single_word(rest, name, span)?;
555            validate_extension_name(extension, span)?;
556            self.record(name, Some(extension), span);
557            return Ok(());
558        }
559        if name == "translateWithPlayerVar" {
560            let options = rest.split_whitespace().collect::<Vec<_>>();
561            if options
562                .iter()
563                .any(|option| !matches!(*option, "noDetectionRule" | "noTlErr"))
564            {
565                return Err(OpyError::at(
566                    "directive-invalid",
567                    "`#!translateWithPlayerVar` accepts only `noDetectionRule` and `noTlErr`",
568                    span,
569                ));
570            }
571            let value = (!options.is_empty()).then(|| options.join(" "));
572            self.record(name, value.as_deref(), span);
573            return Ok(());
574        }
575        if matches!(
576            name,
577            "disableInspector"
578                | "writeToOutputFile"
579                | "disableTranslationSourceLines"
580                | "keepUnusedTranslations"
581                | "useVariableForCompressionAlphabet"
582                | "debugElementCount"
583        ) {
584            require_no_arguments(name, rest, span)?;
585            self.record(name, None, span);
586            return Ok(());
587        }
588        if matches!(name, "globalvarInitRuleName" | "playervarInitRuleName") {
589            let value = strip_quoted(rest.trim()).ok_or_else(|| {
590                OpyError::at(
591                    "directive-invalid",
592                    format!("`#!{name}` expects one quoted string"),
593                    span,
594                )
595            })?;
596            self.record(name, Some(value), span);
597            return Ok(());
598        }
599        if name == "translations" {
600            let languages = parse_translations(rest.trim(), span)?;
601            self.preprocessing.translations = Some(TranslationState {
602                languages: languages.clone(),
603                span: Some(span.into()),
604            });
605            self.record(name, Some(&languages.join(" ")), span);
606            return Ok(());
607        }
608        if name == "suppressWarnings" {
609            let warnings = parse_words(rest, "suppressWarnings", span)?;
610            self.preprocessing
611                .suppressed_warnings
612                .extend(warnings.clone());
613            self.record(name, Some(&warnings.join(" ")), span);
614            return Ok(());
615        }
616        if name == "rulePrefix" {
617            let prefix = strip_quoted(rest.trim()).ok_or_else(|| {
618                OpyError::at(
619                    "rule-prefix-invalid",
620                    "`#!rulePrefix` expects one quoted string",
621                    span,
622                )
623            })?;
624            self.preprocessing.rule_prefix = Some(DirectiveValue {
625                value: prefix.to_string(),
626                span: Some(span.into()),
627            });
628            self.record(name, Some(prefix), span);
629            return Ok(());
630        }
631        if name == "rulePrefixTemplate" {
632            if self.preprocessing.rule_prefix_template.is_some() {
633                return Err(OpyError::at(
634                    "rule-prefix-template-duplicate",
635                    "a rule prefix template is already defined",
636                    span,
637                ));
638            }
639            let template = if rest.trim().is_empty() {
640                r#"f"[{$pathTitle.replace('_', ' ')}] {$rule}" if $rule and not $isDelimiter else $rule"#
641            } else {
642                rest.trim()
643            };
644            self.preprocessing.rule_prefix_template = Some(DirectiveValue {
645                value: template.to_string(),
646                span: Some(span.into()),
647            });
648            self.record(name, Some(template), span);
649            return Ok(());
650        }
651        if let Some((directive, control)) = optimization_directive(name) {
652            apply_optimization(&mut self.preprocessing.optimization, control);
653            self.record(directive, None, span);
654            return Ok(());
655        }
656        if let Some(replacement) = replacement_directive(name) {
657            let family = replacement_family(name).expect("replacement directive family");
658            if self
659                .preprocessing
660                .directives
661                .iter()
662                .filter_map(|item| replacement_family(&item.name))
663                .any(|item_family| item_family == family)
664            {
665                return Err(OpyError::at(
666                    "replacement-duplicate",
667                    format!("a replacement for `{family}` is already defined"),
668                    span,
669                ));
670            }
671            self.preprocessing.replacements.push(DirectiveValue {
672                value: replacement.to_string(),
673                span: Some(span.into()),
674            });
675            self.record(name, Some(replacement), span);
676            return Ok(());
677        }
678        Err(OpyError::at(
679            "unsupported-directive",
680            format!("unsupported preprocessing directive `#!{text}`"),
681            span,
682        ))
683    }
684
685    fn record(&mut self, name: &str, value: Option<&str>, span: Span) {
686        let state = PreprocessingSnapshot {
687            allow_macro_redeclaration: self.preprocessing.allow_macro_redeclaration,
688            optimization: self.preprocessing.optimization.clone(),
689            rule_prefix: self
690                .preprocessing
691                .rule_prefix
692                .as_ref()
693                .map(|value| value.value.clone()),
694            rule_prefix_template: self
695                .preprocessing
696                .rule_prefix_template
697                .as_ref()
698                .map(|value| value.value.clone()),
699            translations: self
700                .preprocessing
701                .translations
702                .as_ref()
703                .map(|translations| translations.languages.clone()),
704            replacements: self
705                .preprocessing
706                .replacements
707                .iter()
708                .map(|value| value.value.clone())
709                .collect(),
710        };
711        self.preprocessing.directives.push(DirectiveRecord {
712            name: name.to_string(),
713            value: value.map(str::to_string),
714            scope_col: span.start.col,
715            scope_depth: self.include_stack.len() as u32,
716            state,
717            span: Some(span.into()),
718        });
719    }
720
721    /// Resolve a script path relative to the supplied base and read its text.
722    fn resolve_script(&self, path: &str, span: Span, base: &Path) -> OpyResult<ScriptMacro> {
723        let path = path.replace('\\', "/");
724        let candidate = base.join(&path);
725        let canonical = candidate.canonicalize().ok();
726        let resolved_path =
727            display_path(&candidate, canonical.as_deref(), &self.display_root, &path);
728        let overlay_source = self
729            .overlay
730            .get(&path)
731            .or_else(|| self.overlay.get(&candidate.to_string_lossy().into_owned()))
732            .or_else(|| self.overlay.get(&resolved_path))
733            .or_else(|| {
734                canonical
735                    .as_ref()
736                    .and_then(|path| self.overlay.get(&path.to_string_lossy().into_owned()))
737            })
738            .cloned();
739        let source = match overlay_source {
740            Some(source) => source,
741            None => {
742                let canonical = canonical.ok_or_else(|| {
743                    OpyError::at(
744                        "script-not-found",
745                        format!(
746                            "cannot find script '{path}' under root '{}'",
747                            base.display()
748                        ),
749                        span,
750                    )
751                })?;
752                std::fs::read_to_string(&canonical).map_err(|error| {
753                    OpyError::at(
754                        "script-not-found",
755                        format!("cannot read script '{path}': {error}"),
756                        span,
757                    )
758                })?
759            }
760        };
761        Ok(ScriptMacro {
762            path: resolved_path,
763            source,
764        })
765    }
766
767    /// Resolve, lex, and splice one included file or directory.
768    fn include(&mut self, include: &str, span: Span, out: &mut Vec<Token>) -> OpyResult<()> {
769        // Includes resolve relative to the source file containing the
770        // directive. The main source uses the project root as its base.
771        let include = include.replace('\\', "/");
772        let candidate = self.include_base().join(&include);
773        let canonical = std::fs::canonicalize(&candidate).ok();
774        if canonical.as_deref().is_some_and(Path::is_dir) {
775            let mut files = std::fs::read_dir(&candidate)
776                .map_err(|error| {
777                    OpyError::at(
778                        "include-not-found",
779                        format!("cannot read included directory '{include}': {error}"),
780                        span,
781                    )
782                })?
783                .filter_map(Result::ok)
784                .map(|entry| entry.path())
785                .filter(|path| {
786                    path.extension()
787                        .is_some_and(|extension| extension.eq_ignore_ascii_case("opy"))
788                        && path.is_file()
789                })
790                .collect::<Vec<_>>();
791            files.sort();
792            if files.is_empty() {
793                return Err(OpyError::at(
794                    "include-not-found",
795                    format!("included directory '{include}' has no .opy files"),
796                    span,
797                ));
798            }
799            for file in files {
800                self.include_file(&file, &include, span, out)?;
801            }
802        } else {
803            self.include_file(&candidate, &include, span, out)?;
804        }
805        self.record("include", Some(&include), span);
806        Ok(())
807    }
808
809    fn include_file(
810        &mut self,
811        candidate: &Path,
812        requested: &str,
813        span: Span,
814        out: &mut Vec<Token>,
815    ) -> OpyResult<()> {
816        let canonical = std::fs::canonicalize(candidate).ok();
817        let candidate_path = candidate.to_string_lossy().into_owned();
818        let canonical_path = display_path(
819            candidate,
820            canonical.as_deref(),
821            &self.display_root,
822            requested,
823        );
824        let lexical_path = display_path(candidate, None, &self.display_root, requested);
825        let overlay_text = self
826            .overlay
827            .get(requested)
828            .or_else(|| self.overlay.get(&candidate_path))
829            .or_else(|| self.overlay.get(&lexical_path))
830            .or_else(|| self.overlay.get(&canonical_path))
831            .or_else(|| {
832                canonical
833                    .as_ref()
834                    .and_then(|path| self.overlay.get(&path.to_string_lossy().into_owned()))
835            })
836            .cloned();
837        let uses_overlay = overlay_text.is_some();
838        let identity = canonical.clone().unwrap_or_else(|| candidate.to_path_buf());
839        if self.include_stack.contains(&identity) {
840            return Err(OpyError::at(
841                "include-cycle",
842                format!(
843                    "include cycle detected: '{}' is already being included",
844                    identity.display()
845                ),
846                span,
847            ));
848        }
849        let import_identity = candidate.to_path_buf();
850        if self.imported_files.contains(&import_identity) {
851            self.warnings.push(PreprocessWarning {
852                code: "w_already_imported".to_string(),
853                message: format!(
854                    "The file '{}' was already imported and will not be imported again.",
855                    import_identity.display()
856                ),
857                span,
858            });
859            return Ok(());
860        }
861        self.imported_files.insert(import_identity);
862
863        let text = match overlay_text {
864            Some(text) => text,
865            None => {
866                let canonical = canonical.ok_or_else(|| {
867                    OpyError::at(
868                        "include-not-found",
869                        format!(
870                            "cannot find included file '{requested}' under root '{}'",
871                            self.root.display()
872                        ),
873                        span,
874                    )
875                })?;
876                std::fs::read_to_string(&canonical).map_err(|error| {
877                    OpyError::at(
878                        "include-not-found",
879                        format!("cannot read included file '{requested}': {error}"),
880                        span,
881                    )
882                })?
883            }
884        };
885        let file_id = self.next_file_id;
886        self.next_file_id += 1;
887        self.files.push(FileRecord {
888            id: file_id,
889            path: if uses_overlay {
890                lexical_path
891            } else {
892                canonical_path
893            },
894        });
895        self.include_stack.push(identity);
896        let saved_prefix = self.preprocessing.rule_prefix.clone();
897        let saved_optimization = self.preprocessing.optimization.clone();
898        let result = (|| {
899            let settings = match crate::settings::find_blocks(&text, file_id) {
900                Err(error) => return Err(error),
901                Ok(mut blocks) => blocks.pop(),
902            };
903            if let Some(block) = settings {
904                if self.settings.is_some() {
905                    return Err(OpyError::at(
906                        "settings-placement",
907                        "only one settings block is supported in a project".to_string(),
908                        block.keyword_span,
909                    ));
910                }
911                self.settings = Some(block);
912            }
913            let sanitized = self
914                .settings
915                .as_ref()
916                .filter(|block| block.span.file == file_id)
917                .map(|block| crate::settings::sanitize_for_lex(&text, block));
918            let mut included = lex(LexInput {
919                file_id,
920                text: sanitized.as_deref().unwrap_or(&text),
921            })?;
922            let allow_leading_main_file = text
923                .lines()
924                .next()
925                .is_some_and(|line| line.trim_end_matches('\r').starts_with("#!mainFile"));
926            self.process_directives(&mut included, allow_leading_main_file)?;
927            included.retain(|token| token.kind != TokenKind::Eof);
928            Ok(included)
929        })();
930        self.preprocessing.rule_prefix = saved_prefix;
931        self.preprocessing.optimization = saved_optimization;
932        self.include_stack.pop();
933        out.extend(result?);
934        Ok(())
935    }
936
937    fn include_base(&self) -> PathBuf {
938        self.include_stack
939            .last()
940            .and_then(|path| path.parent())
941            .map(Path::to_path_buf)
942            .unwrap_or_else(|| self.root.clone())
943    }
944
945    /// Register one `#!define` (object- or function-like).
946    ///
947    /// A define is function-like when `(` immediately follows the name
948    /// (`cakeBeam(start, end)`); a parenthesized object-like value
949    /// (`#!define X (a + b)`) keeps its parentheses as value tokens.
950    fn define(&mut self, rest: &str, span: Span, is_member: bool) -> OpyResult<()> {
951        let rest = rest.trim();
952        let first_open = rest.find('(').unwrap_or(rest.len());
953        let first_space = rest.find(char::is_whitespace).unwrap_or(rest.len());
954        let is_function_like = first_open < first_space;
955
956        let (name, params, body_text) = if is_function_like {
957            let name = rest[..first_open].trim();
958            let Some(close) = rest[first_open..].find(')') else {
959                return Err(OpyError::at(
960                    "define-invalid",
961                    format!("malformed function-like define `#!define {rest}`: missing `)`"),
962                    span,
963                ));
964            };
965            let close = first_open + close;
966            let params: Vec<String> = rest[first_open + 1..close]
967                .split(',')
968                .map(|p| p.trim().to_string())
969                .filter(|p| !p.is_empty())
970                .collect();
971            let body = rest[close + 1..].trim();
972            (name.to_string(), params, body.to_string())
973        } else {
974            let name = rest[..first_space].trim();
975            let body = rest[first_space..].trim().to_string();
976            (name.to_string(), Vec::new(), body)
977        };
978        if name.is_empty() {
979            return Err(OpyError::at(
980                "define-invalid",
981                "malformed `#!define` directive: missing macro name",
982                span,
983            ));
984        }
985        if body_text.is_empty() {
986            return Err(OpyError::at(
987                "define-invalid",
988                format!("malformed `#!define {rest}`: missing replacement"),
989                span,
990            ));
991        }
992        if self.macros.iter().any(|macro_def| macro_def.name == name) {
993            if !self.preprocessing.allow_macro_redeclaration {
994                return Err(OpyError::at(
995                    "macro-redeclaration",
996                    format!("macro '{name}' is already defined"),
997                    span,
998                ));
999            }
1000            self.macros.retain(|macro_def| macro_def.name != name);
1001            self.defines.retain(|define| define.name != name);
1002        }
1003        let script = if is_function_like && body_text.starts_with("__script__(") {
1004            // The OverPy script-macro ABI: the replacement is exactly
1005            // `__script__("path.js")`; the reference extracts the path from
1006            // the text between the parentheses and resolves it relative to the
1007            // definition file (missing files fail at compile time).
1008            let inner = &body_text["__script__(".len()..];
1009            let inner = inner.strip_suffix(')').ok_or_else(|| {
1010                OpyError::at(
1011                    "script-invalid",
1012                    format!(
1013                        "malformed script macro `#!define {rest}`: expected `__script__(\"path.js\")`"
1014                    ),
1015                    span,
1016                )
1017            })?;
1018            let Some(path) = strip_quoted(inner.trim()) else {
1019                return Err(OpyError::at(
1020                    "script-invalid",
1021                    format!(
1022                        "malformed script macro `#!define {rest}`: expected a quoted script path"
1023                    ),
1024                    span,
1025                ));
1026            };
1027            let base = self.include_base();
1028            Some(self.resolve_script(path, span, &base)?)
1029        } else {
1030            None
1031        };
1032        let body_tokens = lex(LexInput {
1033            file_id: span.file,
1034            text: &body_text,
1035        })?;
1036        // Drop the trailing EOF token from the value.
1037        let body_tokens: Vec<Token> = body_tokens
1038            .into_iter()
1039            .filter(|t| t.kind != TokenKind::Eof)
1040            .collect();
1041        let is_function = is_function_like;
1042        self.defines.push(DefineRecord {
1043            name: name.clone(),
1044            is_function,
1045            is_member,
1046            span: Some(span),
1047        });
1048        self.macros.push(MacroDef {
1049            name,
1050            params,
1051            body: body_tokens,
1052            is_function,
1053            script,
1054        });
1055        Ok(())
1056    }
1057
1058    /// Expand all macros across the token stream, recursively.
1059    fn expand(&self, tokens: Vec<Token>) -> OpyResult<Vec<Token>> {
1060        let mut out = Vec::new();
1061        let mut index = 0;
1062        while index < tokens.len() {
1063            let token = &tokens[index];
1064            if token.kind == TokenKind::Ident {
1065                let name = token.text.clone();
1066                if let Some(mac) = self.macros.iter().find(|m| m.name == name) {
1067                    if mac.is_function {
1068                        // Expect `(` args `)` immediately after the name.
1069                        let cursor = index + 1;
1070                        if cursor < tokens.len() && tokens[cursor].kind == TokenKind::LParen {
1071                            let (args, after) = self.collect_args(&tokens, cursor)?;
1072                            let mut expanded = self.expand_macro(mac, args, token.span)?;
1073                            self.expand_into(&mut expanded, &mut Vec::new(), 0)?;
1074                            out.append(&mut expanded);
1075                            index = after;
1076                            continue;
1077                        }
1078                        // A function-like macro used without arguments: leave
1079                        // the name as an ordinary identifier.
1080                        out.push(token.clone());
1081                        index += 1;
1082                        continue;
1083                    }
1084                    let mut expanded = self.expand_macro(mac, Vec::new(), token.span)?;
1085                    self.expand_into(&mut expanded, &mut Vec::new(), 0)?;
1086                    out.append(&mut expanded);
1087                    index += 1;
1088                    continue;
1089                }
1090            }
1091            out.push(token.clone());
1092            index += 1;
1093        }
1094        Ok(out)
1095    }
1096
1097    /// Collect the argument token lists of a function-like macro call,
1098    /// returning `(args, index_after_closing_paren)`.
1099    fn collect_args(&self, tokens: &[Token], open: usize) -> OpyResult<(Vec<Vec<Token>>, usize)> {
1100        let mut args: Vec<Vec<Token>> = Vec::new();
1101        let mut current: Vec<Token> = Vec::new();
1102        let mut depth = 0usize;
1103        let mut cursor = open + 1;
1104        while cursor < tokens.len() {
1105            let kind = tokens[cursor].kind;
1106            if kind == TokenKind::LParen {
1107                depth += 1;
1108                current.push(tokens[cursor].clone());
1109            } else if kind == TokenKind::RParen {
1110                if depth == 0 {
1111                    if !current.is_empty() || !args.is_empty() {
1112                        args.push(std::mem::take(&mut current));
1113                    }
1114                    return Ok((args, cursor + 1));
1115                }
1116                depth -= 1;
1117                current.push(tokens[cursor].clone());
1118            } else if kind == TokenKind::Comma && depth == 0 {
1119                args.push(std::mem::take(&mut current));
1120            } else {
1121                current.push(tokens[cursor].clone());
1122            }
1123            cursor += 1;
1124        }
1125        Err(OpyError::new(
1126            "macro-invalid",
1127            "unterminated macro invocation: missing closing `)`",
1128        ))
1129    }
1130
1131    /// Substitute macro params with the call arguments and stamp every
1132    /// expanded token with the use-site span.
1133    ///
1134    /// Expanded tokens share the use-site span: the differential suite
1135    /// normalizes spans away, and stamping the whole expansion with one
1136    /// monotonic span keeps downstream span validation trivially valid.
1137    fn expand_macro(
1138        &self,
1139        mac: &MacroDef,
1140        args: Vec<Vec<Token>>,
1141        use_site: Span,
1142    ) -> OpyResult<Vec<Token>> {
1143        if mac.is_function && args.len() != mac.params.len() {
1144            return Err(OpyError::at(
1145                "macro-arity",
1146                format!(
1147                    "macro '{}' expects {} argument(s) but got {}",
1148                    mac.name,
1149                    mac.params.len(),
1150                    args.len()
1151                ),
1152                use_site,
1153            ));
1154        }
1155        if let Some(script) = &mac.script {
1156            return self.expand_script(mac, script, args, use_site);
1157        }
1158        let mut out = Vec::new();
1159        for token in &mac.body {
1160            if mac.is_function
1161                && token.kind == TokenKind::Ident
1162                && mac.params.iter().any(|p| p == &token.text)
1163            {
1164                let param_index = mac
1165                    .params
1166                    .iter()
1167                    .position(|p| p == &token.text)
1168                    .expect("checked above");
1169                let mut replacement = args.get(param_index).cloned().unwrap_or_default();
1170                for replacement_token in &mut replacement {
1171                    replacement_token.span = use_site;
1172                }
1173                out.extend(replacement);
1174            } else {
1175                let mut token = token.clone();
1176                token.span = use_site;
1177                out.push(token);
1178            }
1179        }
1180        Ok(out)
1181    }
1182
1183    /// Expand a script macro: run the resolved script through the bounded
1184    /// runtime with the call-site arguments injected, then lex the string
1185    /// completion value back into the token stream at the use site.
1186    ///
1187    /// Argument text is reconstructed from the call-site tokens (see
1188    /// [`raw_arg_text`]); the reference injects the raw source text, and the
1189    /// reconstruction is JavaScript-value-equivalent to it (string literals
1190    /// are re-quoted with JSON escaping, so quoting-style differences are
1191    /// unobservable to the script). The reference's per-line indentation rule
1192    /// is applied to the expansion text before lexing; the frontend parser
1193    /// never consumes indentation, so this is preserved in the text only.
1194    fn expand_script(
1195        &self,
1196        mac: &MacroDef,
1197        script: &ScriptMacro,
1198        args: Vec<Vec<Token>>,
1199        use_site: Span,
1200    ) -> OpyResult<Vec<Token>> {
1201        let macro_args: Vec<MacroArg> = mac
1202            .params
1203            .iter()
1204            .zip(args.iter())
1205            .map(|(param, tokens)| MacroArg::new(param.clone(), raw_arg_text(tokens)))
1206            .collect();
1207        // Resource limits mirror the pinned reference constants (1000 ms macro
1208        // budget, 64 MiB memory, 512 KiB stack; see `crate::macro_js::Limits`).
1209        let runtime = MacroRuntime::new(Limits::default());
1210        let result = runtime
1211            .run_macro(&script.source, &macro_args, &script.path)
1212            .map_err(|error| map_macro_error(&error, &script.path, use_site))?;
1213        // Reference indentation rule (`resolveMacro`): every newline in the
1214        // replacement is followed by the call line's indentation.
1215        let indent = " ".repeat(use_site.start.col.saturating_sub(1) as usize);
1216        let indented = result.text.replace('\n', &format!("\n{indent}"));
1217        let mut tokens = lex(LexInput {
1218            file_id: use_site.file,
1219            text: &indented,
1220        })?;
1221        tokens.retain(|token| token.kind != TokenKind::Eof);
1222        for token in &mut tokens {
1223            token.span = use_site;
1224        }
1225        Ok(tokens)
1226    }
1227
1228    /// Recursively expand macros inside an already-expanded run, guarding
1229    /// against direct recursion.
1230    fn expand_into(
1231        &self,
1232        tokens: &mut Vec<Token>,
1233        stack: &mut Vec<String>,
1234        depth: usize,
1235    ) -> OpyResult<()> {
1236        if depth > 64 {
1237            return Err(OpyError::new(
1238                "macro-recursion",
1239                "macro expansion exceeded the recursion limit (possible recursive define)",
1240            ));
1241        }
1242        let mut out: Vec<Token> = Vec::with_capacity(tokens.len());
1243        let mut index = 0;
1244        while index < tokens.len() {
1245            let token = &tokens[index];
1246            if token.kind == TokenKind::Ident {
1247                let name = token.text.clone();
1248                if let Some(mac) = self.macros.iter().find(|m| m.name == name) {
1249                    if stack.iter().any(|s| s == &name) {
1250                        return Err(OpyError::new(
1251                            "macro-recursion",
1252                            format!("recursive macro expansion detected for '{name}'"),
1253                        ));
1254                    }
1255                    if mac.is_function {
1256                        if index + 1 < tokens.len() && tokens[index + 1].kind == TokenKind::LParen {
1257                            let (args, after) = self.collect_args(tokens, index)?;
1258                            let mut expanded = self.expand_macro(mac, args, token.span)?;
1259                            stack.push(name.clone());
1260                            self.expand_into(&mut expanded, stack, depth + 1)?;
1261                            stack.pop();
1262                            out.append(&mut expanded);
1263                            index = after;
1264                            continue;
1265                        }
1266                        out.push(token.clone());
1267                        index += 1;
1268                        continue;
1269                    }
1270                    let mut expanded = self.expand_macro(mac, Vec::new(), token.span)?;
1271                    stack.push(name.clone());
1272                    self.expand_into(&mut expanded, stack, depth + 1)?;
1273                    stack.pop();
1274                    out.append(&mut expanded);
1275                    index += 1;
1276                    continue;
1277                }
1278            }
1279            out.push(token.clone());
1280            index += 1;
1281        }
1282        *tokens = out;
1283        Ok(())
1284    }
1285}
1286
1287fn split_directive(text: &str) -> (&str, &str) {
1288    text.split_once(char::is_whitespace)
1289        .map_or((text, ""), |(name, rest)| (name, rest))
1290}
1291
1292fn require_no_arguments(name: &str, rest: &str, span: Span) -> OpyResult<()> {
1293    if rest.trim().is_empty() {
1294        Ok(())
1295    } else {
1296        Err(OpyError::at(
1297            "directive-invalid",
1298            format!("`#!{name}` does not accept arguments"),
1299            span,
1300        ))
1301    }
1302}
1303
1304fn parse_single_word<'a>(rest: &'a str, name: &str, span: Span) -> OpyResult<&'a str> {
1305    let value = rest.trim();
1306    if value.is_empty() || value.chars().any(char::is_whitespace) {
1307        return Err(OpyError::at(
1308            "directive-invalid",
1309            format!("`#!{name}` expects one argument"),
1310            span,
1311        ));
1312    }
1313    Ok(value)
1314}
1315
1316fn validate_extension_name(extension: &str, span: Span) -> OpyResult<()> {
1317    let path = [
1318        workshop_rs::settings::table::PathPart::Part("extensions"),
1319        workshop_rs::settings::table::PathPart::Part(extension),
1320    ];
1321    if workshop_rs::settings::definition(&path).is_some() {
1322        Ok(())
1323    } else {
1324        Err(OpyError::at(
1325            "directive-invalid",
1326            format!("unknown Workshop extension `{extension}`"),
1327            span,
1328        ))
1329    }
1330}
1331
1332fn is_identifier_char(ch: char) -> bool {
1333    ch.is_ascii_alphanumeric() || ch == '_'
1334}
1335
1336fn parse_words(rest: &str, directive: &str, span: Span) -> OpyResult<Vec<String>> {
1337    let words: Vec<String> = rest.split_whitespace().map(str::to_string).collect();
1338    if words.is_empty() {
1339        return Err(OpyError::at(
1340            "directive-invalid",
1341            format!("`#!{directive}` expects at least one argument"),
1342            span,
1343        ));
1344    }
1345    if words
1346        .iter()
1347        .any(|word| word.chars().any(|ch| !is_identifier_char(ch)))
1348    {
1349        return Err(OpyError::at(
1350            "directive-invalid",
1351            format!("`#!{directive}` arguments must be identifiers"),
1352            span,
1353        ));
1354    }
1355    Ok(words)
1356}
1357
1358fn parse_translations(rest: &str, span: Span) -> OpyResult<Vec<String>> {
1359    let values: Vec<String> = rest
1360        .split_whitespace()
1361        .map(|language| language.replace('-', "_").to_lowercase())
1362        .collect();
1363    if values.is_empty() {
1364        return Err(OpyError::at(
1365            "translations-invalid",
1366            "`#!translations` expects at least one language",
1367            span,
1368        ));
1369    }
1370    const PINNED_LANGUAGES: &[&str] = &[
1371        "de", "en", "es", "es_es", "es_mx", "fr", "it", "ja", "ko", "pl", "pt", "ru", "th", "tr",
1372        "zh", "zh_cn", "zh_tw",
1373    ];
1374    if values
1375        .iter()
1376        .any(|language| !PINNED_LANGUAGES.contains(&language.as_str()))
1377    {
1378        return Err(OpyError::at(
1379            "translations-invalid",
1380            "invalid translation language; expected one of the pinned OverPy language codes",
1381            span,
1382        ));
1383    }
1384    if values.iter().any(|value| value == "es")
1385        && values
1386            .iter()
1387            .any(|value| value == "es_es" || value == "es_mx")
1388    {
1389        return Err(OpyError::at(
1390            "translations-invalid",
1391            "cannot combine `es` with `es_es` or `es_mx`",
1392            span,
1393        ));
1394    }
1395    if values.iter().any(|value| value == "zh")
1396        && values
1397            .iter()
1398            .any(|value| value == "zh_cn" || value == "zh_tw")
1399    {
1400        return Err(OpyError::at(
1401            "translations-invalid",
1402            "cannot combine `zh` with `zh_cn` or `zh_tw`",
1403            span,
1404        ));
1405    }
1406    Ok(values)
1407}
1408
1409#[derive(Clone, Copy)]
1410enum OptimizationControl {
1411    Enable,
1412    Disable,
1413    ForSize,
1414    DisableForSize,
1415    ForSizeAggressive,
1416    Strict,
1417    DisableStrict,
1418}
1419
1420fn optimization_directive(name: &str) -> Option<(&str, OptimizationControl)> {
1421    Some(match name {
1422        "disableOptimizations" => (name, OptimizationControl::Disable),
1423        "enableOptimizations" => (name, OptimizationControl::Enable),
1424        "optimizeForSize" => (name, OptimizationControl::ForSize),
1425        "disableOptimizeForSize" => (name, OptimizationControl::DisableForSize),
1426        "optimizeForSizeAggressive" => (name, OptimizationControl::ForSizeAggressive),
1427        "optimizeStrict" => (name, OptimizationControl::Strict),
1428        "disableOptimizeStrict" => (name, OptimizationControl::DisableStrict),
1429        _ => return None,
1430    })
1431}
1432
1433fn apply_optimization(state: &mut OptimizationState, control: OptimizationControl) {
1434    match control {
1435        OptimizationControl::Enable => state.enabled = true,
1436        OptimizationControl::Disable => state.enabled = false,
1437        OptimizationControl::ForSize => state.for_size = true,
1438        OptimizationControl::DisableForSize => state.for_size = false,
1439        OptimizationControl::ForSizeAggressive => state.for_size_aggressive = true,
1440        OptimizationControl::Strict => state.strict = true,
1441        OptimizationControl::DisableStrict => state.strict = false,
1442    }
1443}
1444
1445fn replacement_directive(name: &str) -> Option<&str> {
1446    Some(match name {
1447        "replace0ByCapturePercentage" => "getCapturePercentage",
1448        "replace0ByPayloadProgressPercentage" => "getPayloadProgressPercentage",
1449        "replace0ByIsMatchComplete" => "isMatchComplete",
1450        "replace1ByMatchRound" => "getMatchRound",
1451        "replaceTeam1ByControlScoringTeam" => "getControlScoringTeam",
1452        "replaceEmptyStringByEmptyArray" => "emptyArray",
1453        "replaceEmptyStringByVariable" => "variable",
1454        _ => return None,
1455    })
1456}
1457
1458fn replacement_family(name: &str) -> Option<&str> {
1459    Some(match name {
1460        "replace0ByCapturePercentage"
1461        | "replace0ByPayloadProgressPercentage"
1462        | "replace0ByIsMatchComplete" => "0",
1463        "replace1ByMatchRound" => "1",
1464        "replaceTeam1ByControlScoringTeam" => "team1",
1465        "replaceEmptyStringByEmptyArray" | "replaceEmptyStringByVariable" => "emptyString",
1466        _ => return None,
1467    })
1468}
1469
1470/// Strips a matched `"…"` or `'…'` pair, returning the inner text.
1471fn strip_quoted(text: &str) -> Option<&str> {
1472    text.strip_prefix('"')
1473        .and_then(|rest| rest.strip_suffix('"'))
1474        .or_else(|| {
1475            text.strip_prefix('\'')
1476                .and_then(|rest| rest.strip_suffix('\''))
1477        })
1478}
1479
1480/// Reconstructs the raw call-site argument text from its tokens.
1481///
1482/// The reference injects the raw source substring as `var <name>=<raw>;`; the
1483/// token model stores string values unescaped, so string tokens are re-quoted
1484/// with JSON escaping. The reconstruction is JavaScript-value-equivalent to
1485/// the reference's raw injection: identifiers, numbers, operators, and
1486/// punctuation pass through verbatim, and string literals differ only in
1487/// quoting style, which is unobservable to the script.
1488fn raw_arg_text(tokens: &[Token]) -> String {
1489    let mut out = String::new();
1490    for token in tokens {
1491        match token.kind {
1492            TokenKind::String => out.push_str(&json_string_literal(&token.text)),
1493            TokenKind::Newline => out.push('\n'),
1494            _ => out.push_str(&token.text),
1495        }
1496    }
1497    out
1498}
1499
1500/// Encodes `value` as a JSON string literal (double-quoted, escaped).
1501fn json_string_literal(value: &str) -> String {
1502    serde_json::to_string(value).expect("serializing a string is infallible")
1503}
1504
1505/// Maps a runtime [`MacroError`] to a structured frontend diagnostic with the
1506/// script path as provenance and the directive/call-site span.
1507///
1508/// The runtime's QuickJS abort messages are classified into stable codes:
1509/// `script-timeout` (`"interrupted"`), `script-memory-limit`
1510/// (`"out of memory"`), `script-stack-limit`
1511/// (`"Maximum call stack size exceeded"`), and `script-error` for thrown
1512/// exceptions (with the script path and, when the engine provided one, the
1513/// line/column). Non-string completion values are `script-result-not-string`
1514/// with the reference's wording, and engine setup failures are
1515/// `script-internal`.
1516pub(crate) fn map_macro_error(error: &MacroError, script_path: &str, span: Span) -> OpyError {
1517    match error {
1518        MacroError::Script(script) => {
1519            let code = match script.message.as_str() {
1520                "interrupted" => "script-timeout",
1521                "out of memory" => "script-memory-limit",
1522                "Maximum call stack size exceeded" => "script-stack-limit",
1523                _ => "script-error",
1524            };
1525            let location = match (script.line, script.column) {
1526                (Some(line), Some(column)) => format!(" (line {line}, column {column})"),
1527                (Some(line), None) => format!(" (line {line})"),
1528                _ => String::new(),
1529            };
1530            OpyError::at(
1531                code,
1532                format!(
1533                    "script '{}' failed: {}{}",
1534                    script_path, script.message, location
1535                ),
1536                span,
1537            )
1538        }
1539        MacroError::InvalidResult { type_name } => OpyError::at(
1540            "script-result-not-string",
1541            format!(
1542                "JavaScript macro returned value with type of {type_name}, expected string. Try using .toString()"
1543            ),
1544            span,
1545        ),
1546        MacroError::Internal(message) => OpyError::at(
1547            "script-internal",
1548            format!("script '{}' runtime failure: {message}", script_path),
1549            span,
1550        ),
1551    }
1552}
1553
1554#[cfg(test)]
1555mod tests {
1556    use super::*;
1557
1558    #[test]
1559    fn object_define_expands_at_use_site() {
1560        let (pre, _) = preprocess(
1561            "#!define SIDE 1.5\nrule \"r\":\n    x = SIDE\n",
1562            "main.opy",
1563            Path::new("."),
1564        )
1565        .unwrap();
1566        assert_eq!(pre.defines.len(), 1);
1567        assert_eq!(pre.defines[0].name, "SIDE");
1568        assert!(!pre.defines[0].is_function);
1569        assert!(!pre.defines[0].is_member);
1570        let numbers: Vec<&str> = pre
1571            .tokens
1572            .iter()
1573            .filter(|t| t.kind == TokenKind::Number)
1574            .map(|t| t.text.as_str())
1575            .collect();
1576        assert_eq!(numbers, vec!["1.5"]);
1577    }
1578
1579    #[test]
1580    fn function_define_substitutes_params() {
1581        let (pre, _) = preprocess(
1582            "#!define double(x) x + x\nrule \"r\":\n    y = double(3)\n",
1583            "main.opy",
1584            Path::new("."),
1585        )
1586        .unwrap();
1587        let numbers: Vec<&str> = pre
1588            .tokens
1589            .iter()
1590            .filter(|t| t.kind == TokenKind::Number)
1591            .map(|t| t.text.as_str())
1592            .collect();
1593        assert_eq!(numbers, vec!["3", "3"]);
1594    }
1595
1596    #[test]
1597    fn zero_argument_function_define_accepts_empty_invocation() {
1598        let (pre, _) = preprocess(
1599            "#!define value() 3\nrule \"r\":\n    x = value()\n",
1600            "main.opy",
1601            Path::new("."),
1602        )
1603        .unwrap();
1604        let numbers: Vec<&str> = pre
1605            .tokens
1606            .iter()
1607            .filter(|token| token.kind == TokenKind::Number)
1608            .map(|token| token.text.as_str())
1609            .collect();
1610        assert_eq!(numbers, vec!["3"]);
1611    }
1612
1613    #[test]
1614    fn macro_expanded_string_can_concatenate_with_following_literal() {
1615        let (pre, _) = preprocess(
1616            "#!define PREFIX \"one\"\nrule \"r\":\n    debug(PREFIX\n        \"two\")\n",
1617            "main.opy",
1618            Path::new("."),
1619        )
1620        .unwrap();
1621        let output = crate::parser::parse(&pre.tokens);
1622        assert!(
1623            output.errors.is_empty(),
1624            "unexpected errors: {:?}",
1625            output.errors
1626        );
1627        let program = output.program.expect("expanded source must parse");
1628        let crate::cst::RuleEntry::Rule(rule) = &program.rules[0] else {
1629            panic!("expected rule");
1630        };
1631        let crate::cst::Stmt::Expr { expr, .. } = &rule.actions[0] else {
1632            panic!("expected expression statement");
1633        };
1634        let crate::cst::Expr::Call { args, .. } = expr else {
1635            panic!("expected call");
1636        };
1637        assert!(matches!(
1638            &args[0].value,
1639            crate::cst::Expr::String { value, .. } if value == "onetwo"
1640        ));
1641    }
1642
1643    #[test]
1644    fn recursive_defines_expand_transitively() {
1645        let (pre, _) = preprocess(
1646            "#!define A 2\n#!define B A + 1\nrule \"r\":\n    x = B\n",
1647            "main.opy",
1648            Path::new("."),
1649        )
1650        .unwrap();
1651        let numbers: Vec<&str> = pre
1652            .tokens
1653            .iter()
1654            .filter(|t| t.kind == TokenKind::Number)
1655            .map(|t| t.text.as_str())
1656            .collect();
1657        assert_eq!(numbers, vec!["2", "1"]);
1658    }
1659
1660    #[test]
1661    fn recursive_define_fails_structurally() {
1662        let error = preprocess(
1663            "#!define X X + 1\nrule \"r\":\n    x = X\n",
1664            "main.opy",
1665            Path::new("."),
1666        )
1667        .unwrap_err();
1668        assert_eq!(error.code, "macro-recursion");
1669    }
1670
1671    #[test]
1672    fn missing_include_is_structured() {
1673        let error = preprocess(
1674            "#!include \"nope.opy\"\n",
1675            "main.opy",
1676            Path::new("/nonexistent-root"),
1677        )
1678        .unwrap_err();
1679        assert_eq!(error.code, "include-not-found");
1680        assert!(error.span.is_some());
1681    }
1682
1683    #[test]
1684    fn include_cycle_is_detected() {
1685        let dir = std::env::temp_dir().join(format!("wright-opy-test-{}", std::process::id()));
1686        std::fs::create_dir_all(&dir).unwrap();
1687        std::fs::write(dir.join("a.opy"), "#!include \"b.opy\"\n").unwrap();
1688        std::fs::write(dir.join("b.opy"), "#!include \"a.opy\"\n").unwrap();
1689        let main = std::fs::read_to_string(dir.join("a.opy")).unwrap();
1690        let error = preprocess(&main, "a.opy", &dir).unwrap_err();
1691        assert_eq!(error.code, "include-cycle");
1692        let _ = std::fs::remove_dir_all(&dir);
1693    }
1694
1695    #[test]
1696    fn unsupported_directive_is_structured() {
1697        let error = preprocess("#!frobnicate\n", "main.opy", Path::new(".")).unwrap_err();
1698        assert_eq!(error.code, "unsupported-directive");
1699    }
1700
1701    #[test]
1702    fn settings_block_is_extracted_before_lexing() {
1703        let (pre, _) = preprocess(
1704            "settings {\n    \"gamemodes\": {}\n}\nrule \"r\":\n    pass\n",
1705            "main.opy",
1706            Path::new("."),
1707        )
1708        .unwrap();
1709        let block = pre.settings.expect("settings block extracted");
1710        assert!(block.text.contains("gamemodes"));
1711        // The block never enters the token stream.
1712        assert!(
1713            !pre.tokens.iter().any(|t| t.text.contains("gamemodes")),
1714            "settings content must not be lexed"
1715        );
1716    }
1717
1718    #[test]
1719    fn settings_in_include_is_extracted_with_source_provenance() {
1720        let overlay = BTreeMap::from([(
1721            "shared.opy".to_string(),
1722            "settings {\n    \"gamemodes\": {}\n}\n".to_string(),
1723        )]);
1724        let main = "#!include \"shared.opy\"\nrule \"r\":\n    pass\n";
1725        let (pre, files) = preprocess_with_overlay(main, "main.opy", Path::new("."), &overlay)
1726            .expect("included settings must be extracted");
1727        let block = pre.settings.expect("included settings block");
1728        assert_eq!(block.keyword_span.file, 1);
1729        assert_eq!(files[1].path, "shared.opy");
1730        assert!(!pre.tokens.iter().any(|token| token.text == "gamemodes"));
1731    }
1732
1733    #[test]
1734    fn duplicate_include_is_skipped_without_redeclaring_macros() {
1735        let overlay =
1736            BTreeMap::from([("shared.opy".to_string(), "#!define VALUE 2\n".to_string())]);
1737        let main =
1738            "#!include \"shared.opy\"\n#!include \"shared.opy\"\nrule \"r\":\n    x = VALUE\n";
1739        let (pre, files) = preprocess_with_overlay(main, "main.opy", Path::new("."), &overlay)
1740            .expect("duplicate includes must not redeclare macros");
1741        assert_eq!(pre.defines.len(), 1);
1742        assert_eq!(files.len(), 2);
1743        assert_eq!(pre.warnings.len(), 1);
1744        assert_eq!(pre.warnings[0].code, "w_already_imported");
1745        assert_eq!(
1746            pre.preprocessing
1747                .directives
1748                .iter()
1749                .filter(|directive| directive.name == "include")
1750                .count(),
1751            2
1752        );
1753    }
1754
1755    #[test]
1756    fn alias_include_paths_are_distinct_imports() {
1757        let overlay = BTreeMap::from([
1758            ("shared.opy".to_string(), "#!define FIRST 1\n".to_string()),
1759            (
1760                "dir/../shared.opy".to_string(),
1761                "#!define SECOND 2\n".to_string(),
1762            ),
1763        ]);
1764        let main = "#!include \"shared.opy\"\n#!include \"dir/../shared.opy\"\nrule \"r\":\n    x = FIRST\n    y = SECOND\n";
1765        let (pre, files) = preprocess_with_overlay(main, "main.opy", Path::new("."), &overlay)
1766            .expect("alias include paths must remain distinct imports");
1767        assert_eq!(files.len(), 3);
1768        assert_eq!(files[1].path, "shared.opy");
1769        assert_eq!(files[2].path, "dir/../shared.opy");
1770        assert_eq!(pre.defines.len(), 2);
1771        assert!(pre.warnings.is_empty());
1772    }
1773
1774    #[test]
1775    fn dict_literal_braces_reach_the_parser() {
1776        // Scoped settings lexing must not consume expression-level braces.
1777        let (pre, _) = preprocess(
1778            "rule \"r\":\n    money += {\n        Mei.GENERIC: 10,\n    }\n",
1779            "main.opy",
1780            Path::new("."),
1781        )
1782        .unwrap();
1783        assert!(
1784            pre.tokens
1785                .iter()
1786                .any(|token| token.kind == TokenKind::LBrace)
1787        );
1788        assert!(
1789            pre.tokens
1790                .iter()
1791                .any(|token| token.kind == TokenKind::RBrace)
1792        );
1793    }
1794
1795    #[test]
1796    fn advanced_directives_preserve_frontend_state_without_catalog_data() {
1797        let (pre, _) = preprocess(
1798            "#!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",
1799            "main.opy",
1800            Path::new("."),
1801        )
1802        .unwrap();
1803        assert!(pre.preprocessing.allow_macro_redeclaration);
1804        assert_eq!(
1805            pre.preprocessing
1806                .translations
1807                .as_ref()
1808                .map(|state| state.languages.as_slice()),
1809            Some(["en".to_string(), "fr".to_string()].as_slice())
1810        );
1811        assert_eq!(
1812            pre.preprocessing
1813                .rule_prefix
1814                .as_ref()
1815                .map(|value| value.value.as_str()),
1816            Some("Effects")
1817        );
1818        assert!(pre.preprocessing.optimization.for_size);
1819        assert!(pre.preprocessing.optimization.strict);
1820        assert_eq!(
1821            pre.preprocessing.replacements[0].value,
1822            "getCapturePercentage"
1823        );
1824        assert_eq!(pre.defines.len(), 1);
1825    }
1826
1827    #[test]
1828    fn backend_only_directives_are_validated_and_recorded() {
1829        let (pre, _) = preprocess(
1830            "#!excludeVariablesInCompilation\n#!extension projectiles\n#!setupTags\n#!setupTx\n#!translateWithPlayerVar noDetectionRule noTlErr\n#!disableInspector\n#!writeToOutputFile\n#!disableTranslationSourceLines\n#!keepUnusedTranslations\n#!useVariableForCompressionAlphabet\n#!debugElementCount\n#!globalvarInitRuleName \"Init globals\"\n#!playervarInitRuleName \"Init players\"\nrule \"r\":\n    pass\n",
1831            "main.opy",
1832            Path::new("."),
1833        )
1834        .unwrap();
1835        let names: Vec<&str> = pre
1836            .preprocessing
1837            .directives
1838            .iter()
1839            .map(|directive| directive.name.as_str())
1840            .collect();
1841        assert_eq!(
1842            names,
1843            vec![
1844                "excludeVariablesInCompilation",
1845                "extension",
1846                "setupTags",
1847                "setupTx",
1848                "translateWithPlayerVar",
1849                "disableInspector",
1850                "writeToOutputFile",
1851                "disableTranslationSourceLines",
1852                "keepUnusedTranslations",
1853                "useVariableForCompressionAlphabet",
1854                "debugElementCount",
1855                "globalvarInitRuleName",
1856                "playervarInitRuleName",
1857            ]
1858        );
1859        assert_eq!(
1860            pre.preprocessing.directives[1].value.as_deref(),
1861            Some("projectiles")
1862        );
1863        assert_eq!(
1864            pre.preprocessing.directives[4].value.as_deref(),
1865            Some("noDetectionRule noTlErr")
1866        );
1867    }
1868
1869    #[test]
1870    fn extension_directive_rejects_unknown_schema_values() {
1871        let error = preprocess(
1872            "#!extension notAnExtension\nrule \"r\":\n    pass\n",
1873            "main.opy",
1874            Path::new("."),
1875        )
1876        .unwrap_err();
1877        assert_eq!(error.code, "directive-invalid");
1878    }
1879
1880    #[test]
1881    fn translations_follow_pinned_codes_without_local_deduplication() {
1882        let (pre, _) = preprocess(
1883            "#!translations EN zh-cn en\nrule \"r\":\n    pass\n",
1884            "main.opy",
1885            Path::new("."),
1886        )
1887        .unwrap();
1888        assert_eq!(
1889            pre.preprocessing.translations.unwrap().languages,
1890            vec!["en", "zh_cn", "en"]
1891        );
1892    }
1893
1894    #[test]
1895    fn translations_reject_codes_outside_the_pinned_oracle_set() {
1896        let error = preprocess(
1897            "#!translations en_US\nrule \"r\":\n    pass\n",
1898            "main.opy",
1899            Path::new("."),
1900        )
1901        .unwrap_err();
1902        assert_eq!(error.code, "translations-invalid");
1903    }
1904
1905    #[test]
1906    fn directive_records_expose_state_transitions_and_include_depth() {
1907        let root =
1908            std::env::temp_dir().join(format!("wright-opy-directive-scope-{}", std::process::id()));
1909        std::fs::create_dir_all(&root).unwrap();
1910        std::fs::write(
1911            root.join("child.opy"),
1912            "#!rulePrefix \"inner\"\n#!disableOptimizations\n",
1913        )
1914        .unwrap();
1915        let (pre, _) = preprocess(
1916            "#!rulePrefix \"outer\"\n#!include \"child.opy\"\n#!enableOptimizations\n",
1917            "main.opy",
1918            &root,
1919        )
1920        .unwrap();
1921        let records = &pre.preprocessing.directives;
1922        assert_eq!(records[0].state.rule_prefix.as_deref(), Some("outer"));
1923        assert_eq!(records[0].scope_depth, 0);
1924        assert_eq!(records[1].name, "rulePrefix");
1925        assert_eq!(records[1].state.rule_prefix.as_deref(), Some("inner"));
1926        assert!(!records[2].state.optimization.enabled);
1927        assert_eq!(records[2].scope_depth, 1);
1928        assert_eq!(records[3].name, "include");
1929        assert_eq!(records[3].state.rule_prefix.as_deref(), Some("outer"));
1930        assert_eq!(records[4].name, "enableOptimizations");
1931        assert!(records[4].state.optimization.enabled);
1932        let _ = std::fs::remove_dir_all(&root);
1933    }
1934
1935    #[test]
1936    fn malformed_translation_state_is_source_located() {
1937        let error = preprocess("#!translations\n", "main.opy", Path::new(".")).unwrap_err();
1938        assert_eq!(error.code, "translations-invalid");
1939        assert!(error.span.is_some());
1940    }
1941}