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<Token> = Vec::new();
1061        let mut index = 0;
1062        while index < tokens.len() {
1063            let token = &tokens[index];
1064            if token.kind == TokenKind::Ident
1065                && !out.last().is_some_and(|previous| {
1066                    previous.kind == TokenKind::Ident && previous.text == "macro"
1067                })
1068            {
1069                let name = token.text.clone();
1070                if let Some(mac) = self.macros.iter().find(|m| m.name == name) {
1071                    if mac.is_function {
1072                        // Expect `(` args `)` immediately after the name.
1073                        let cursor = index + 1;
1074                        if cursor < tokens.len() && tokens[cursor].kind == TokenKind::LParen {
1075                            let (args, after) = self.collect_args(&tokens, cursor)?;
1076                            let mut expanded = self.expand_macro(mac, args, token.span)?;
1077                            self.expand_into(&mut expanded, &mut Vec::new(), 0)?;
1078                            out.append(&mut expanded);
1079                            index = after;
1080                            continue;
1081                        }
1082                        // A function-like macro used without arguments: leave
1083                        // the name as an ordinary identifier.
1084                        out.push(token.clone());
1085                        index += 1;
1086                        continue;
1087                    }
1088                    let mut expanded = self.expand_macro(mac, Vec::new(), token.span)?;
1089                    self.expand_into(&mut expanded, &mut Vec::new(), 0)?;
1090                    out.append(&mut expanded);
1091                    index += 1;
1092                    continue;
1093                }
1094            }
1095            out.push(token.clone());
1096            index += 1;
1097        }
1098        Ok(out)
1099    }
1100
1101    /// Collect the argument token lists of a function-like macro call,
1102    /// returning `(args, index_after_closing_paren)`.
1103    fn collect_args(&self, tokens: &[Token], open: usize) -> OpyResult<(Vec<Vec<Token>>, usize)> {
1104        let mut args: Vec<Vec<Token>> = Vec::new();
1105        let mut current: Vec<Token> = Vec::new();
1106        let mut depth = 0usize;
1107        let mut cursor = open + 1;
1108        while cursor < tokens.len() {
1109            let kind = tokens[cursor].kind;
1110            if matches!(
1111                kind,
1112                TokenKind::LParen | TokenKind::LBracket | TokenKind::LBrace
1113            ) {
1114                depth += 1;
1115                current.push(tokens[cursor].clone());
1116            } else if kind == TokenKind::RParen {
1117                if depth == 0 {
1118                    if !current.is_empty() || !args.is_empty() {
1119                        args.push(std::mem::take(&mut current));
1120                    }
1121                    return Ok((args, cursor + 1));
1122                }
1123                depth -= 1;
1124                current.push(tokens[cursor].clone());
1125            } else if matches!(kind, TokenKind::RBracket | TokenKind::RBrace) {
1126                depth = depth.saturating_sub(1);
1127                current.push(tokens[cursor].clone());
1128            } else if kind == TokenKind::Comma && depth == 0 {
1129                args.push(std::mem::take(&mut current));
1130            } else {
1131                current.push(tokens[cursor].clone());
1132            }
1133            cursor += 1;
1134        }
1135        Err(OpyError::new(
1136            "macro-invalid",
1137            "unterminated macro invocation: missing closing `)`",
1138        ))
1139    }
1140
1141    /// Substitute macro params with the call arguments and stamp every
1142    /// expanded token with the use-site span.
1143    ///
1144    /// Expanded tokens share the use-site span: the differential suite
1145    /// normalizes spans away, and stamping the whole expansion with one
1146    /// monotonic span keeps downstream span validation trivially valid.
1147    fn expand_macro(
1148        &self,
1149        mac: &MacroDef,
1150        args: Vec<Vec<Token>>,
1151        use_site: Span,
1152    ) -> OpyResult<Vec<Token>> {
1153        if mac.is_function && args.len() != mac.params.len() {
1154            return Err(OpyError::at(
1155                "macro-arity",
1156                format!(
1157                    "macro '{}' expects {} argument(s) but got {}",
1158                    mac.name,
1159                    mac.params.len(),
1160                    args.len()
1161                ),
1162                use_site,
1163            ));
1164        }
1165        if let Some(script) = &mac.script {
1166            return self.expand_script(mac, script, args, use_site);
1167        }
1168        let mut out = Vec::new();
1169        for token in &mac.body {
1170            if mac.is_function
1171                && token.kind == TokenKind::Ident
1172                && mac.params.iter().any(|p| p == &token.text)
1173            {
1174                let param_index = mac
1175                    .params
1176                    .iter()
1177                    .position(|p| p == &token.text)
1178                    .expect("checked above");
1179                let mut replacement = args.get(param_index).cloned().unwrap_or_default();
1180                for replacement_token in &mut replacement {
1181                    replacement_token.span = use_site;
1182                }
1183                out.extend(replacement);
1184            } else {
1185                let mut token = token.clone();
1186                token.span = use_site;
1187                out.push(token);
1188            }
1189        }
1190        Ok(out)
1191    }
1192
1193    /// Expand a script macro: run the resolved script through the bounded
1194    /// runtime with the call-site arguments injected, then lex the string
1195    /// completion value back into the token stream at the use site.
1196    ///
1197    /// Argument text is reconstructed from the call-site tokens (see
1198    /// [`raw_arg_text`]); the reference injects the raw source text, and the
1199    /// reconstruction is JavaScript-value-equivalent to it (string literals
1200    /// are re-quoted with JSON escaping, so quoting-style differences are
1201    /// unobservable to the script). The reference's per-line indentation rule
1202    /// is applied to the expansion text before lexing; the frontend parser
1203    /// never consumes indentation, so this is preserved in the text only.
1204    fn expand_script(
1205        &self,
1206        mac: &MacroDef,
1207        script: &ScriptMacro,
1208        args: Vec<Vec<Token>>,
1209        use_site: Span,
1210    ) -> OpyResult<Vec<Token>> {
1211        let macro_args: Vec<MacroArg> = mac
1212            .params
1213            .iter()
1214            .zip(args.iter())
1215            .map(|(param, tokens)| MacroArg::new(param.clone(), raw_arg_text(tokens)))
1216            .collect();
1217        // Resource limits mirror the pinned reference constants (1000 ms macro
1218        // budget, 64 MiB memory, 512 KiB stack; see `crate::macro_js::Limits`).
1219        let runtime = MacroRuntime::new(Limits::default());
1220        let result = runtime
1221            .run_macro(&script.source, &macro_args, &script.path)
1222            .map_err(|error| map_macro_error(&error, &script.path, use_site))?;
1223        // Reference indentation rule (`resolveMacro`): every newline in the
1224        // replacement is followed by the call line's indentation.
1225        let indent = " ".repeat(use_site.start.col.saturating_sub(1) as usize);
1226        let indented = result.text.replace('\n', &format!("\n{indent}"));
1227        let mut tokens = lex(LexInput {
1228            file_id: use_site.file,
1229            text: &indented,
1230        })?;
1231        tokens.retain(|token| token.kind != TokenKind::Eof);
1232        for token in &mut tokens {
1233            token.span = use_site;
1234        }
1235        Ok(tokens)
1236    }
1237
1238    /// Recursively expand macros inside an already-expanded run, guarding
1239    /// against direct recursion.
1240    fn expand_into(
1241        &self,
1242        tokens: &mut Vec<Token>,
1243        stack: &mut Vec<String>,
1244        depth: usize,
1245    ) -> OpyResult<()> {
1246        if depth > 64 {
1247            return Err(OpyError::new(
1248                "macro-recursion",
1249                "macro expansion exceeded the recursion limit (possible recursive define)",
1250            ));
1251        }
1252        let mut out: Vec<Token> = Vec::with_capacity(tokens.len());
1253        let mut index = 0;
1254        while index < tokens.len() {
1255            let token = &tokens[index];
1256            if token.kind == TokenKind::Ident
1257                && !out.last().is_some_and(|previous| {
1258                    previous.kind == TokenKind::Ident && previous.text == "macro"
1259                })
1260            {
1261                let name = token.text.clone();
1262                if let Some(mac) = self.macros.iter().find(|m| m.name == name) {
1263                    if stack.iter().any(|s| s == &name) {
1264                        return Err(OpyError::new(
1265                            "macro-recursion",
1266                            format!("recursive macro expansion detected for '{name}'"),
1267                        ));
1268                    }
1269                    if mac.is_function {
1270                        if index + 1 < tokens.len() && tokens[index + 1].kind == TokenKind::LParen {
1271                            let (args, after) = self.collect_args(tokens, index + 1)?;
1272                            let mut expanded = self.expand_macro(mac, args, token.span)?;
1273                            stack.push(name.clone());
1274                            self.expand_into(&mut expanded, stack, depth + 1)?;
1275                            stack.pop();
1276                            out.append(&mut expanded);
1277                            index = after;
1278                            continue;
1279                        }
1280                        out.push(token.clone());
1281                        index += 1;
1282                        continue;
1283                    }
1284                    let mut expanded = self.expand_macro(mac, Vec::new(), token.span)?;
1285                    stack.push(name.clone());
1286                    self.expand_into(&mut expanded, stack, depth + 1)?;
1287                    stack.pop();
1288                    out.append(&mut expanded);
1289                    index += 1;
1290                    continue;
1291                }
1292            }
1293            out.push(token.clone());
1294            index += 1;
1295        }
1296        *tokens = out;
1297        Ok(())
1298    }
1299}
1300
1301fn split_directive(text: &str) -> (&str, &str) {
1302    text.split_once(char::is_whitespace)
1303        .map_or((text, ""), |(name, rest)| (name, rest))
1304}
1305
1306fn require_no_arguments(name: &str, rest: &str, span: Span) -> OpyResult<()> {
1307    if rest.trim().is_empty() {
1308        Ok(())
1309    } else {
1310        Err(OpyError::at(
1311            "directive-invalid",
1312            format!("`#!{name}` does not accept arguments"),
1313            span,
1314        ))
1315    }
1316}
1317
1318fn parse_single_word<'a>(rest: &'a str, name: &str, span: Span) -> OpyResult<&'a str> {
1319    let value = rest.trim();
1320    if value.is_empty() || value.chars().any(char::is_whitespace) {
1321        return Err(OpyError::at(
1322            "directive-invalid",
1323            format!("`#!{name}` expects one argument"),
1324            span,
1325        ));
1326    }
1327    Ok(value)
1328}
1329
1330fn validate_extension_name(extension: &str, span: Span) -> OpyResult<()> {
1331    let path = [
1332        workshop_rs::settings::table::PathPart::Part("extensions"),
1333        workshop_rs::settings::table::PathPart::Part(extension),
1334    ];
1335    if workshop_rs::settings::definition(&path).is_some() {
1336        Ok(())
1337    } else {
1338        Err(OpyError::at(
1339            "directive-invalid",
1340            format!("unknown Workshop extension `{extension}`"),
1341            span,
1342        ))
1343    }
1344}
1345
1346fn is_identifier_char(ch: char) -> bool {
1347    ch.is_ascii_alphanumeric() || ch == '_'
1348}
1349
1350fn parse_words(rest: &str, directive: &str, span: Span) -> OpyResult<Vec<String>> {
1351    let words: Vec<String> = rest.split_whitespace().map(str::to_string).collect();
1352    if words.is_empty() {
1353        return Err(OpyError::at(
1354            "directive-invalid",
1355            format!("`#!{directive}` expects at least one argument"),
1356            span,
1357        ));
1358    }
1359    if words
1360        .iter()
1361        .any(|word| word.chars().any(|ch| !is_identifier_char(ch)))
1362    {
1363        return Err(OpyError::at(
1364            "directive-invalid",
1365            format!("`#!{directive}` arguments must be identifiers"),
1366            span,
1367        ));
1368    }
1369    Ok(words)
1370}
1371
1372fn parse_translations(rest: &str, span: Span) -> OpyResult<Vec<String>> {
1373    let values: Vec<String> = rest
1374        .split_whitespace()
1375        .map(|language| language.replace('-', "_").to_lowercase())
1376        .collect();
1377    if values.is_empty() {
1378        return Err(OpyError::at(
1379            "translations-invalid",
1380            "`#!translations` expects at least one language",
1381            span,
1382        ));
1383    }
1384    const PINNED_LANGUAGES: &[&str] = &[
1385        "de", "en", "es", "es_es", "es_mx", "fr", "it", "ja", "ko", "pl", "pt", "ru", "th", "tr",
1386        "zh", "zh_cn", "zh_tw",
1387    ];
1388    if values
1389        .iter()
1390        .any(|language| !PINNED_LANGUAGES.contains(&language.as_str()))
1391    {
1392        return Err(OpyError::at(
1393            "translations-invalid",
1394            "invalid translation language; expected one of the pinned OverPy language codes",
1395            span,
1396        ));
1397    }
1398    if values.iter().any(|value| value == "es")
1399        && values
1400            .iter()
1401            .any(|value| value == "es_es" || value == "es_mx")
1402    {
1403        return Err(OpyError::at(
1404            "translations-invalid",
1405            "cannot combine `es` with `es_es` or `es_mx`",
1406            span,
1407        ));
1408    }
1409    if values.iter().any(|value| value == "zh")
1410        && values
1411            .iter()
1412            .any(|value| value == "zh_cn" || value == "zh_tw")
1413    {
1414        return Err(OpyError::at(
1415            "translations-invalid",
1416            "cannot combine `zh` with `zh_cn` or `zh_tw`",
1417            span,
1418        ));
1419    }
1420    Ok(values)
1421}
1422
1423#[derive(Clone, Copy)]
1424enum OptimizationControl {
1425    Enable,
1426    Disable,
1427    ForSize,
1428    DisableForSize,
1429    ForSizeAggressive,
1430    Strict,
1431    DisableStrict,
1432}
1433
1434fn optimization_directive(name: &str) -> Option<(&str, OptimizationControl)> {
1435    Some(match name {
1436        "disableOptimizations" => (name, OptimizationControl::Disable),
1437        "enableOptimizations" => (name, OptimizationControl::Enable),
1438        "optimizeForSize" => (name, OptimizationControl::ForSize),
1439        "disableOptimizeForSize" => (name, OptimizationControl::DisableForSize),
1440        "optimizeForSizeAggressive" => (name, OptimizationControl::ForSizeAggressive),
1441        "optimizeStrict" => (name, OptimizationControl::Strict),
1442        "disableOptimizeStrict" => (name, OptimizationControl::DisableStrict),
1443        _ => return None,
1444    })
1445}
1446
1447fn apply_optimization(state: &mut OptimizationState, control: OptimizationControl) {
1448    match control {
1449        OptimizationControl::Enable => state.enabled = true,
1450        OptimizationControl::Disable => state.enabled = false,
1451        OptimizationControl::ForSize => state.for_size = true,
1452        OptimizationControl::DisableForSize => state.for_size = false,
1453        OptimizationControl::ForSizeAggressive => state.for_size_aggressive = true,
1454        OptimizationControl::Strict => state.strict = true,
1455        OptimizationControl::DisableStrict => state.strict = false,
1456    }
1457}
1458
1459fn replacement_directive(name: &str) -> Option<&str> {
1460    Some(match name {
1461        "replace0ByCapturePercentage" => "getCapturePercentage",
1462        "replace0ByPayloadProgressPercentage" => "getPayloadProgressPercentage",
1463        "replace0ByIsMatchComplete" => "isMatchComplete",
1464        "replace1ByMatchRound" => "getMatchRound",
1465        "replaceTeam1ByControlScoringTeam" => "getControlScoringTeam",
1466        "replaceEmptyStringByEmptyArray" => "emptyArray",
1467        "replaceEmptyStringByVariable" => "variable",
1468        _ => return None,
1469    })
1470}
1471
1472fn replacement_family(name: &str) -> Option<&str> {
1473    Some(match name {
1474        "replace0ByCapturePercentage"
1475        | "replace0ByPayloadProgressPercentage"
1476        | "replace0ByIsMatchComplete" => "0",
1477        "replace1ByMatchRound" => "1",
1478        "replaceTeam1ByControlScoringTeam" => "team1",
1479        "replaceEmptyStringByEmptyArray" | "replaceEmptyStringByVariable" => "emptyString",
1480        _ => return None,
1481    })
1482}
1483
1484/// Strips a matched `"…"` or `'…'` pair, returning the inner text.
1485fn strip_quoted(text: &str) -> Option<&str> {
1486    text.strip_prefix('"')
1487        .and_then(|rest| rest.strip_suffix('"'))
1488        .or_else(|| {
1489            text.strip_prefix('\'')
1490                .and_then(|rest| rest.strip_suffix('\''))
1491        })
1492}
1493
1494/// Reconstructs the raw call-site argument text from its tokens.
1495///
1496/// The reference injects the raw source substring as `var <name>=<raw>;`; the
1497/// token model stores string values unescaped, so string tokens are re-quoted
1498/// with JSON escaping. The reconstruction is JavaScript-value-equivalent to
1499/// the reference's raw injection: identifiers, numbers, operators, and
1500/// punctuation pass through verbatim, and string literals differ only in
1501/// quoting style, which is unobservable to the script.
1502fn raw_arg_text(tokens: &[Token]) -> String {
1503    let mut out = String::new();
1504    for token in tokens {
1505        match token.kind {
1506            TokenKind::String => out.push_str(&json_string_literal(&token.text)),
1507            TokenKind::Newline => out.push('\n'),
1508            _ => out.push_str(&token.text),
1509        }
1510    }
1511    out
1512}
1513
1514/// Encodes `value` as a JSON string literal (double-quoted, escaped).
1515fn json_string_literal(value: &str) -> String {
1516    serde_json::to_string(value).expect("serializing a string is infallible")
1517}
1518
1519/// Maps a runtime [`MacroError`] to a structured frontend diagnostic with the
1520/// script path as provenance and the directive/call-site span.
1521///
1522/// The runtime's QuickJS abort messages are classified into stable codes:
1523/// `script-timeout` (`"interrupted"`), `script-memory-limit`
1524/// (`"out of memory"`), `script-stack-limit`
1525/// (`"Maximum call stack size exceeded"`), and `script-error` for thrown
1526/// exceptions (with the script path and, when the engine provided one, the
1527/// line/column). Non-string completion values are `script-result-not-string`
1528/// with the reference's wording, and engine setup failures are
1529/// `script-internal`.
1530pub(crate) fn map_macro_error(error: &MacroError, script_path: &str, span: Span) -> OpyError {
1531    match error {
1532        MacroError::Script(script) => {
1533            let code = match script.message.as_str() {
1534                "interrupted" => "script-timeout",
1535                "out of memory" => "script-memory-limit",
1536                "Maximum call stack size exceeded" => "script-stack-limit",
1537                _ => "script-error",
1538            };
1539            let location = match (script.line, script.column) {
1540                (Some(line), Some(column)) => format!(" (line {line}, column {column})"),
1541                (Some(line), None) => format!(" (line {line})"),
1542                _ => String::new(),
1543            };
1544            OpyError::at(
1545                code,
1546                format!(
1547                    "script '{}' failed: {}{}",
1548                    script_path, script.message, location
1549                ),
1550                span,
1551            )
1552        }
1553        MacroError::InvalidResult { type_name } => OpyError::at(
1554            "script-result-not-string",
1555            format!(
1556                "JavaScript macro returned value with type of {type_name}, expected string. Try using .toString()"
1557            ),
1558            span,
1559        ),
1560        MacroError::Internal(message) => OpyError::at(
1561            "script-internal",
1562            format!("script '{}' runtime failure: {message}", script_path),
1563            span,
1564        ),
1565    }
1566}
1567
1568#[cfg(test)]
1569mod tests {
1570    use super::*;
1571
1572    #[test]
1573    fn object_define_expands_at_use_site() {
1574        let (pre, _) = preprocess(
1575            "#!define SIDE 1.5\nrule \"r\":\n    x = SIDE\n",
1576            "main.opy",
1577            Path::new("."),
1578        )
1579        .unwrap();
1580        assert_eq!(pre.defines.len(), 1);
1581        assert_eq!(pre.defines[0].name, "SIDE");
1582        assert!(!pre.defines[0].is_function);
1583        assert!(!pre.defines[0].is_member);
1584        let numbers: Vec<&str> = pre
1585            .tokens
1586            .iter()
1587            .filter(|t| t.kind == TokenKind::Number)
1588            .map(|t| t.text.as_str())
1589            .collect();
1590        assert_eq!(numbers, vec!["1.5"]);
1591    }
1592
1593    #[test]
1594    fn function_define_substitutes_params() {
1595        let (pre, _) = preprocess(
1596            "#!define double(x) x + x\nrule \"r\":\n    y = double(3)\n",
1597            "main.opy",
1598            Path::new("."),
1599        )
1600        .unwrap();
1601        let numbers: Vec<&str> = pre
1602            .tokens
1603            .iter()
1604            .filter(|t| t.kind == TokenKind::Number)
1605            .map(|t| t.text.as_str())
1606            .collect();
1607        assert_eq!(numbers, vec!["3", "3"]);
1608    }
1609
1610    #[test]
1611    fn function_define_keeps_commas_inside_nested_collections() {
1612        let (pre, _) = preprocess(
1613            "#!define first(xs, fallback) xs[0]\nrule \"r\":\n    x = first([1, 2], 3)\n",
1614            "main.opy",
1615            Path::new("."),
1616        )
1617        .unwrap();
1618        let numbers: Vec<&str> = pre
1619            .tokens
1620            .iter()
1621            .filter(|token| token.kind == TokenKind::Number)
1622            .map(|token| token.text.as_str())
1623            .collect();
1624        assert_eq!(numbers, vec!["1", "2", "0"]);
1625    }
1626
1627    #[test]
1628    fn zero_argument_function_define_accepts_empty_invocation() {
1629        let (pre, _) = preprocess(
1630            "#!define value() 3\nrule \"r\":\n    x = value()\n",
1631            "main.opy",
1632            Path::new("."),
1633        )
1634        .unwrap();
1635        let numbers: Vec<&str> = pre
1636            .tokens
1637            .iter()
1638            .filter(|token| token.kind == TokenKind::Number)
1639            .map(|token| token.text.as_str())
1640            .collect();
1641        assert_eq!(numbers, vec!["3"]);
1642    }
1643
1644    #[test]
1645    fn macro_expanded_string_can_concatenate_with_following_literal() {
1646        let (pre, _) = preprocess(
1647            "#!define PREFIX \"one\"\nrule \"r\":\n    debug(PREFIX\n        \"two\")\n",
1648            "main.opy",
1649            Path::new("."),
1650        )
1651        .unwrap();
1652        let output = crate::parser::parse(&pre.tokens);
1653        assert!(
1654            output.errors.is_empty(),
1655            "unexpected errors: {:?}",
1656            output.errors
1657        );
1658        let program = output.program.expect("expanded source must parse");
1659        let crate::cst::RuleEntry::Rule(rule) = &program.rules[0] else {
1660            panic!("expected rule");
1661        };
1662        let crate::cst::Stmt::Expr { expr, .. } = &rule.actions[0] else {
1663            panic!("expected expression statement");
1664        };
1665        let crate::cst::Expr::Call { args, .. } = expr else {
1666            panic!("expected call");
1667        };
1668        assert!(matches!(
1669            &args[0].value,
1670            crate::cst::Expr::String { value, .. } if value == "onetwo"
1671        ));
1672    }
1673
1674    #[test]
1675    fn recursive_defines_expand_transitively() {
1676        let (pre, _) = preprocess(
1677            "#!define A 2\n#!define B A + 1\nrule \"r\":\n    x = B\n",
1678            "main.opy",
1679            Path::new("."),
1680        )
1681        .unwrap();
1682        let numbers: Vec<&str> = pre
1683            .tokens
1684            .iter()
1685            .filter(|t| t.kind == TokenKind::Number)
1686            .map(|t| t.text.as_str())
1687            .collect();
1688        assert_eq!(numbers, vec!["2", "1"]);
1689    }
1690
1691    #[test]
1692    fn recursive_define_fails_structurally() {
1693        let error = preprocess(
1694            "#!define X X + 1\nrule \"r\":\n    x = X\n",
1695            "main.opy",
1696            Path::new("."),
1697        )
1698        .unwrap_err();
1699        assert_eq!(error.code, "macro-recursion");
1700    }
1701
1702    #[test]
1703    fn missing_include_is_structured() {
1704        let error = preprocess(
1705            "#!include \"nope.opy\"\n",
1706            "main.opy",
1707            Path::new("/nonexistent-root"),
1708        )
1709        .unwrap_err();
1710        assert_eq!(error.code, "include-not-found");
1711        assert!(error.span.is_some());
1712    }
1713
1714    #[test]
1715    fn include_cycle_is_detected() {
1716        let dir = std::env::temp_dir().join(format!("wright-opy-test-{}", std::process::id()));
1717        std::fs::create_dir_all(&dir).unwrap();
1718        std::fs::write(dir.join("a.opy"), "#!include \"b.opy\"\n").unwrap();
1719        std::fs::write(dir.join("b.opy"), "#!include \"a.opy\"\n").unwrap();
1720        let main = std::fs::read_to_string(dir.join("a.opy")).unwrap();
1721        let error = preprocess(&main, "a.opy", &dir).unwrap_err();
1722        assert_eq!(error.code, "include-cycle");
1723        let _ = std::fs::remove_dir_all(&dir);
1724    }
1725
1726    #[test]
1727    fn unsupported_directive_is_structured() {
1728        let error = preprocess("#!frobnicate\n", "main.opy", Path::new(".")).unwrap_err();
1729        assert_eq!(error.code, "unsupported-directive");
1730    }
1731
1732    #[test]
1733    fn settings_block_is_extracted_before_lexing() {
1734        let (pre, _) = preprocess(
1735            "settings {\n    \"gamemodes\": {}\n}\nrule \"r\":\n    pass\n",
1736            "main.opy",
1737            Path::new("."),
1738        )
1739        .unwrap();
1740        let block = pre.settings.expect("settings block extracted");
1741        assert!(block.text.contains("gamemodes"));
1742        // The block never enters the token stream.
1743        assert!(
1744            !pre.tokens.iter().any(|t| t.text.contains("gamemodes")),
1745            "settings content must not be lexed"
1746        );
1747    }
1748
1749    #[test]
1750    fn settings_in_include_is_extracted_with_source_provenance() {
1751        let overlay = BTreeMap::from([(
1752            "shared.opy".to_string(),
1753            "settings {\n    \"gamemodes\": {}\n}\n".to_string(),
1754        )]);
1755        let main = "#!include \"shared.opy\"\nrule \"r\":\n    pass\n";
1756        let (pre, files) = preprocess_with_overlay(main, "main.opy", Path::new("."), &overlay)
1757            .expect("included settings must be extracted");
1758        let block = pre.settings.expect("included settings block");
1759        assert_eq!(block.keyword_span.file, 1);
1760        assert_eq!(files[1].path, "shared.opy");
1761        assert!(!pre.tokens.iter().any(|token| token.text == "gamemodes"));
1762    }
1763
1764    #[test]
1765    fn duplicate_include_is_skipped_without_redeclaring_macros() {
1766        let overlay =
1767            BTreeMap::from([("shared.opy".to_string(), "#!define VALUE 2\n".to_string())]);
1768        let main =
1769            "#!include \"shared.opy\"\n#!include \"shared.opy\"\nrule \"r\":\n    x = VALUE\n";
1770        let (pre, files) = preprocess_with_overlay(main, "main.opy", Path::new("."), &overlay)
1771            .expect("duplicate includes must not redeclare macros");
1772        assert_eq!(pre.defines.len(), 1);
1773        assert_eq!(files.len(), 2);
1774        assert_eq!(pre.warnings.len(), 1);
1775        assert_eq!(pre.warnings[0].code, "w_already_imported");
1776        assert_eq!(
1777            pre.preprocessing
1778                .directives
1779                .iter()
1780                .filter(|directive| directive.name == "include")
1781                .count(),
1782            2
1783        );
1784    }
1785
1786    #[test]
1787    fn alias_include_paths_are_distinct_imports() {
1788        let overlay = BTreeMap::from([
1789            ("shared.opy".to_string(), "#!define FIRST 1\n".to_string()),
1790            (
1791                "dir/../shared.opy".to_string(),
1792                "#!define SECOND 2\n".to_string(),
1793            ),
1794        ]);
1795        let main = "#!include \"shared.opy\"\n#!include \"dir/../shared.opy\"\nrule \"r\":\n    x = FIRST\n    y = SECOND\n";
1796        let (pre, files) = preprocess_with_overlay(main, "main.opy", Path::new("."), &overlay)
1797            .expect("alias include paths must remain distinct imports");
1798        assert_eq!(files.len(), 3);
1799        assert_eq!(files[1].path, "shared.opy");
1800        assert_eq!(files[2].path, "dir/../shared.opy");
1801        assert_eq!(pre.defines.len(), 2);
1802        assert!(pre.warnings.is_empty());
1803    }
1804
1805    #[test]
1806    fn dict_literal_braces_reach_the_parser() {
1807        // Scoped settings lexing must not consume expression-level braces.
1808        let (pre, _) = preprocess(
1809            "rule \"r\":\n    money += {\n        Mei.GENERIC: 10,\n    }\n",
1810            "main.opy",
1811            Path::new("."),
1812        )
1813        .unwrap();
1814        assert!(
1815            pre.tokens
1816                .iter()
1817                .any(|token| token.kind == TokenKind::LBrace)
1818        );
1819        assert!(
1820            pre.tokens
1821                .iter()
1822                .any(|token| token.kind == TokenKind::RBrace)
1823        );
1824    }
1825
1826    #[test]
1827    fn advanced_directives_preserve_frontend_state_without_catalog_data() {
1828        let (pre, _) = preprocess(
1829            "#!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",
1830            "main.opy",
1831            Path::new("."),
1832        )
1833        .unwrap();
1834        assert!(pre.preprocessing.allow_macro_redeclaration);
1835        assert_eq!(
1836            pre.preprocessing
1837                .translations
1838                .as_ref()
1839                .map(|state| state.languages.as_slice()),
1840            Some(["en".to_string(), "fr".to_string()].as_slice())
1841        );
1842        assert_eq!(
1843            pre.preprocessing
1844                .rule_prefix
1845                .as_ref()
1846                .map(|value| value.value.as_str()),
1847            Some("Effects")
1848        );
1849        assert!(pre.preprocessing.optimization.for_size);
1850        assert!(pre.preprocessing.optimization.strict);
1851        assert_eq!(
1852            pre.preprocessing.replacements[0].value,
1853            "getCapturePercentage"
1854        );
1855        assert_eq!(pre.defines.len(), 1);
1856    }
1857
1858    #[test]
1859    fn backend_only_directives_are_validated_and_recorded() {
1860        let (pre, _) = preprocess(
1861            "#!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",
1862            "main.opy",
1863            Path::new("."),
1864        )
1865        .unwrap();
1866        let names: Vec<&str> = pre
1867            .preprocessing
1868            .directives
1869            .iter()
1870            .map(|directive| directive.name.as_str())
1871            .collect();
1872        assert_eq!(
1873            names,
1874            vec![
1875                "excludeVariablesInCompilation",
1876                "extension",
1877                "setupTags",
1878                "setupTx",
1879                "translateWithPlayerVar",
1880                "disableInspector",
1881                "writeToOutputFile",
1882                "disableTranslationSourceLines",
1883                "keepUnusedTranslations",
1884                "useVariableForCompressionAlphabet",
1885                "debugElementCount",
1886                "globalvarInitRuleName",
1887                "playervarInitRuleName",
1888            ]
1889        );
1890        assert_eq!(
1891            pre.preprocessing.directives[1].value.as_deref(),
1892            Some("projectiles")
1893        );
1894        assert_eq!(
1895            pre.preprocessing.directives[4].value.as_deref(),
1896            Some("noDetectionRule noTlErr")
1897        );
1898    }
1899
1900    #[test]
1901    fn extension_directive_rejects_unknown_schema_values() {
1902        let error = preprocess(
1903            "#!extension notAnExtension\nrule \"r\":\n    pass\n",
1904            "main.opy",
1905            Path::new("."),
1906        )
1907        .unwrap_err();
1908        assert_eq!(error.code, "directive-invalid");
1909    }
1910
1911    #[test]
1912    fn translations_follow_pinned_codes_without_local_deduplication() {
1913        let (pre, _) = preprocess(
1914            "#!translations EN zh-cn en\nrule \"r\":\n    pass\n",
1915            "main.opy",
1916            Path::new("."),
1917        )
1918        .unwrap();
1919        assert_eq!(
1920            pre.preprocessing.translations.unwrap().languages,
1921            vec!["en", "zh_cn", "en"]
1922        );
1923    }
1924
1925    #[test]
1926    fn translations_reject_codes_outside_the_pinned_oracle_set() {
1927        let error = preprocess(
1928            "#!translations en_US\nrule \"r\":\n    pass\n",
1929            "main.opy",
1930            Path::new("."),
1931        )
1932        .unwrap_err();
1933        assert_eq!(error.code, "translations-invalid");
1934    }
1935
1936    #[test]
1937    fn directive_records_expose_state_transitions_and_include_depth() {
1938        let root =
1939            std::env::temp_dir().join(format!("wright-opy-directive-scope-{}", std::process::id()));
1940        std::fs::create_dir_all(&root).unwrap();
1941        std::fs::write(
1942            root.join("child.opy"),
1943            "#!rulePrefix \"inner\"\n#!disableOptimizations\n",
1944        )
1945        .unwrap();
1946        let (pre, _) = preprocess(
1947            "#!rulePrefix \"outer\"\n#!include \"child.opy\"\n#!enableOptimizations\n",
1948            "main.opy",
1949            &root,
1950        )
1951        .unwrap();
1952        let records = &pre.preprocessing.directives;
1953        assert_eq!(records[0].state.rule_prefix.as_deref(), Some("outer"));
1954        assert_eq!(records[0].scope_depth, 0);
1955        assert_eq!(records[1].name, "rulePrefix");
1956        assert_eq!(records[1].state.rule_prefix.as_deref(), Some("inner"));
1957        assert!(!records[2].state.optimization.enabled);
1958        assert_eq!(records[2].scope_depth, 1);
1959        assert_eq!(records[3].name, "include");
1960        assert_eq!(records[3].state.rule_prefix.as_deref(), Some("outer"));
1961        assert_eq!(records[4].name, "enableOptimizations");
1962        assert!(records[4].state.optimization.enabled);
1963        let _ = std::fs::remove_dir_all(&root);
1964    }
1965
1966    #[test]
1967    fn malformed_translation_state_is_source_located() {
1968        let error = preprocess("#!translations\n", "main.opy", Path::new(".")).unwrap_err();
1969        assert_eq!(error.code, "translations-invalid");
1970        assert!(error.span.is_some());
1971    }
1972}