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