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