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::diag::{OpyError, OpyResult, Span};
46use crate::hir::types::{
47    DirectiveRecord, DirectiveValue, OptimizationState, PreprocessingSnapshot, PreprocessingState,
48    TranslationEntry, TranslationState,
49};
50use crate::lexer::{LexInput, Token, TokenKind, lex};
51use crate::settings::SettingsBlock;
52
53mod directives;
54mod macros;
55mod project;
56mod scripts;
57
58use macros::{MacroArgument, MacroDef};
59use project::{display_path, first_main_file_directive};
60
61/// A recorded preprocessing define (HIR provenance).
62#[derive(Debug, Clone, PartialEq)]
63pub struct DefineRecord {
64    pub name: String,
65    pub is_function: bool,
66    pub is_member: bool,
67    pub span: Option<Span>,
68}
69
70/// A resolved `__script__("…")` macro backing.
71#[derive(Debug, Clone, PartialEq)]
72pub struct ScriptMacro {
73    /// The resolved project-relative script path, used for diagnostics and
74    /// runtime attribution.
75    pub path: String,
76    /// The script text, read at the define site.
77    pub source: String,
78}
79
80/// A registered `#!postCompileHook` script (the declaration record).
81///
82/// The frontend recognizes, parses, validates, and records the directive; it
83/// never executes the hook. Execution against the final Workshop text is
84/// lowering-dependent (issue #8).
85#[derive(Debug, Clone, PartialEq)]
86pub struct PostCompileHook {
87    /// The resolved project-relative script path.
88    pub path: String,
89    /// The script text, read at the directive site.
90    pub source: String,
91    /// The directive's source span, used for error attribution.
92    pub span: Span,
93}
94
95/// The result of preprocessing.
96#[derive(Debug, Clone)]
97pub struct Preprocessed {
98    /// The expanded, single-file token stream.
99    pub tokens: Vec<Token>,
100    /// The recorded defines in definition order.
101    pub defines: Vec<DefineRecord>,
102    /// The project `settings { ... }` block, when present (#86).
103    pub settings: Option<SettingsBlock>,
104    /// Warnings emitted while composing the project.
105    pub warnings: Vec<PreprocessWarning>,
106    /// The registered `#!postCompileHook` script, when declared.
107    pub post_compile_hook: Option<PostCompileHook>,
108    /// Frontend-visible preprocessing state; strict-sensitive lowering is
109    /// handled by the native compiler while other backend effects are not run.
110    pub preprocessing: PreprocessingState,
111}
112
113/// The output file registry, in include order, preserving source provenance.
114#[derive(Debug, Clone, PartialEq)]
115pub struct FileRecord {
116    pub id: u32,
117    pub path: String,
118}
119
120/// A source-attributed preprocessing warning.
121#[derive(Debug, Clone, PartialEq, Eq)]
122pub struct PreprocessWarning {
123    pub code: String,
124    pub message: String,
125    pub span: Span,
126}
127
128/// Preprocess the main source text with its include root.
129pub fn preprocess(
130    main_text: &str,
131    main_path: &str,
132    root: &Path,
133) -> OpyResult<(Preprocessed, Vec<FileRecord>)> {
134    preprocess_with_overlay(main_text, main_path, root, &BTreeMap::new())
135}
136
137/// Preprocess with open-document overlays: includes resolve to overlay text
138/// (keyed by the include string or the resolved canonical path) before the
139/// filesystem. Overlays model unsaved editor buffers without changing the
140/// compiler's source-loading contract.
141pub fn preprocess_with_overlay(
142    main_text: &str,
143    main_path: &str,
144    root: &Path,
145    overlay: &BTreeMap<String, String>,
146) -> OpyResult<(Preprocessed, Vec<FileRecord>)> {
147    preprocess_with_overlay_outcome(main_text, main_path, root, overlay).result
148}
149
150/// The outcome of preprocessing with overlays, retaining the file registry
151/// registered so far even when a directive or expansion fails, so callers can
152/// map an error's span file id to its actual source.
153pub struct PreprocessOutcome {
154    pub result: OpyResult<(Preprocessed, Vec<FileRecord>)>,
155    pub files: Vec<FileRecord>,
156    /// The directory the file-registry `display_path`s resolve against — the
157    /// `#!mainFile` effective directory when the first line redirects the
158    /// project root, otherwise the canonicalized input root.
159    pub display_root: PathBuf,
160    pub warnings: Vec<PreprocessWarning>,
161}
162
163impl PreprocessOutcome {
164    fn failure(error: OpyError, pre: Preprocessor) -> Self {
165        Self {
166            result: Err(error),
167            files: pre.files,
168            display_root: pre.display_root,
169            warnings: pre.warnings,
170        }
171    }
172}
173
174/// Preprocess with open-document overlays while retaining the file registry
175/// registered so far on failure.
176pub fn preprocess_with_overlay_outcome(
177    main_text: &str,
178    main_path: &str,
179    root: &Path,
180    overlay: &BTreeMap<String, String>,
181) -> PreprocessOutcome {
182    let resolved_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
183    let mut pre = Preprocessor {
184        files: vec![FileRecord {
185            id: 0,
186            path: main_path.to_string(),
187        }],
188        next_file_id: 1,
189        root: resolved_root.clone(),
190        display_root: resolved_root,
191        overlay: overlay.clone(),
192        source_texts: BTreeMap::from([(0, main_text.to_string())]),
193        include_stack: Vec::new(),
194        last_macro_include_path: None,
195        imported_files: BTreeSet::new(),
196        macros: Vec::new(),
197        defines: Vec::new(),
198        post_compile_hook: None,
199        settings: None,
200        warnings: Vec::new(),
201        preprocessing: PreprocessingState::default(),
202    };
203    pre.preprocessing
204        .source_file_initial_optimization
205        .insert(0, pre.preprocessing.optimization.clone());
206    let mut owned_main_text = None;
207    let mut source_file_id = 0;
208    let first_line = main_text.lines().next().unwrap_or_default();
209    if first_line.trim_start().starts_with("#!mainFile")
210        && first_main_file_directive(main_text).is_none()
211    {
212        let span = Span::new(
213            0,
214            crate::diag::Position::new(1, 1),
215            crate::diag::Position::new(1, first_line.chars().count() as u32 + 1),
216        );
217        return PreprocessOutcome::failure(
218            OpyError::at(
219                "main-file-invalid",
220                "`#!mainFile` expects one quoted path on the first line",
221                span,
222            ),
223            pre,
224        );
225    }
226    if let Some((main_file, span)) = first_main_file_directive(main_text) {
227        let candidate = pre.root.join(&main_file);
228        let canonical = std::fs::canonicalize(&candidate).ok();
229        let overlay_text = overlay
230            .get(&main_file)
231            .or_else(|| overlay.get(&candidate.to_string_lossy().into_owned()))
232            .or_else(|| {
233                canonical
234                    .as_ref()
235                    .and_then(|path| overlay.get(&path.to_string_lossy().into_owned()))
236            })
237            .cloned();
238        let (text, canonical_path, new_root) = match overlay_text {
239            Some(text) => {
240                let new_root = candidate
241                    .parent()
242                    .map(Path::to_path_buf)
243                    .unwrap_or_else(|| pre.root.clone());
244                (text, canonical, new_root)
245            }
246            None => {
247                let Some(canonical) = canonical else {
248                    return PreprocessOutcome::failure(
249                        OpyError::at(
250                            "main-file-not-found",
251                            format!("cannot find main file '{main_file}'"),
252                            span,
253                        ),
254                        pre,
255                    );
256                };
257                let text = match std::fs::read_to_string(&canonical) {
258                    Ok(text) => text,
259                    Err(error) => {
260                        return PreprocessOutcome::failure(
261                            OpyError::at(
262                                "main-file-not-found",
263                                format!("cannot read main file '{main_file}': {error}"),
264                                span,
265                            ),
266                            pre,
267                        );
268                    }
269                };
270                let new_root = canonical
271                    .parent()
272                    .map(Path::to_path_buf)
273                    .unwrap_or_else(|| pre.root.clone());
274                (text, Some(canonical), new_root)
275            }
276        };
277        let display_path =
278            display_path(&candidate, canonical_path.as_deref(), &new_root, &main_file);
279        source_file_id = 1;
280        pre.source_texts.insert(source_file_id, text.clone());
281        owned_main_text = Some(text);
282        pre.files.push(FileRecord {
283            id: source_file_id,
284            path: display_path,
285        });
286        pre.next_file_id = 2;
287        pre.preprocessing
288            .source_file_initial_optimization
289            .insert(source_file_id, pre.preprocessing.optimization.clone());
290        pre.root = new_root.clone();
291        pre.display_root = new_root;
292        pre.preprocessing.main_file = Some(DirectiveValue {
293            value: main_file.clone(),
294            span: Some(span.into()),
295        });
296        pre.record("mainFile", Some(&main_file), span);
297    }
298    let source_text = owned_main_text.as_deref().unwrap_or(main_text);
299    // The project settings block is extracted before lexing and blanked out of
300    // the owning file's lexed text, so the lexer never sees its braces (#86).
301    let settings = match crate::settings::find_blocks(source_text, source_file_id) {
302        Ok(mut blocks) => blocks.pop(),
303        Err(error) => return PreprocessOutcome::failure(error, pre),
304    };
305    let settings = match settings {
306        Some(block) => match pre.resolve_settings_source(block) {
307            Ok(block) => Some(block),
308            Err(error) => return PreprocessOutcome::failure(error, pre),
309        },
310        None => None,
311    };
312    let tokens = match &settings {
313        Some(block) => {
314            let sanitized = crate::settings::sanitize_for_lex(source_text, block);
315            lex(LexInput {
316                file_id: source_file_id,
317                text: &sanitized,
318            })
319        }
320        None => lex(LexInput {
321            file_id: source_file_id,
322            text: source_text,
323        }),
324    };
325    let mut tokens = match tokens {
326        Ok(tokens) => tokens,
327        Err(error) => return PreprocessOutcome::failure(error, pre),
328    };
329    if let Err(error) = pre.process_directives(&mut tokens, false, settings) {
330        return PreprocessOutcome::failure(error, pre);
331    }
332    if let Err(error) = pre.load_translation_catalog() {
333        return PreprocessOutcome::failure(error, pre);
334    }
335    let result = Ok((
336        Preprocessed {
337            tokens,
338            defines: pre.defines,
339            settings: pre.settings,
340            warnings: pre.warnings.clone(),
341            post_compile_hook: pre.post_compile_hook,
342            preprocessing: pre.preprocessing,
343        },
344        pre.files.clone(),
345    ));
346    PreprocessOutcome {
347        result,
348        files: pre.files,
349        display_root: pre.display_root,
350        warnings: pre.warnings,
351    }
352}
353
354fn render_tokens(tokens: &[Token]) -> String {
355    let mut rendered = String::new();
356    let mut previous: Option<&Token> = None;
357    for token in tokens {
358        if token.kind == TokenKind::Eof {
359            continue;
360        }
361        if let Some(previous) = previous
362            && can_merge_without_separator(previous.kind, token.kind)
363        {
364            rendered.push(' ');
365        }
366        if token.kind == TokenKind::Newline {
367            rendered.push('\n');
368        } else if token.kind == TokenKind::String {
369            rendered.push('"');
370            rendered.push_str(token.raw.as_deref().unwrap_or(&token.text));
371            rendered.push('"');
372        } else {
373            rendered.push_str(&token.text);
374        }
375        previous = Some(token);
376    }
377    rendered
378}
379
380fn can_merge_without_separator(previous: TokenKind, current: TokenKind) -> bool {
381    matches!(previous, TokenKind::Ident | TokenKind::Number)
382        && matches!(current, TokenKind::Ident | TokenKind::Number)
383}
384
385fn shift_settings_span(span: Span, origin: crate::diag::Position) -> Span {
386    Span::new(
387        span.file,
388        crate::diag::shift_position(span.start, origin),
389        crate::diag::shift_position(span.end, origin),
390    )
391}
392
393fn external_text_span(file: u32, text: &str) -> Span {
394    let mut line = 1;
395    let mut col = 1;
396    for character in text.chars() {
397        if character == '\n' {
398            line += 1;
399            col = 1;
400        } else {
401            col += 1;
402        }
403    }
404    Span::new(
405        file,
406        crate::diag::Position::new(1, 1),
407        crate::diag::Position::new(line, col),
408    )
409}
410
411struct Preprocessor {
412    files: Vec<FileRecord>,
413    next_file_id: u32,
414    root: PathBuf,
415    display_root: PathBuf,
416    overlay: BTreeMap<String, String>,
417    source_texts: BTreeMap<u32, String>,
418    include_stack: Vec<PathBuf>,
419    // OverPy retains this file context after multiline macro expansion in an
420    // included settings block; established projects rely on that lookup base.
421    last_macro_include_path: Option<PathBuf>,
422    imported_files: BTreeSet<PathBuf>,
423    macros: Vec<MacroDef>,
424    settings: Option<SettingsBlock>,
425    defines: Vec<DefineRecord>,
426    post_compile_hook: Option<PostCompileHook>,
427    warnings: Vec<PreprocessWarning>,
428    preprocessing: PreprocessingState,
429}
430
431impl Preprocessor {
432    fn load_translation_catalog(&mut self) -> OpyResult<()> {
433        let Some(translations) = self.preprocessing.translations.as_ref() else {
434            return Ok(());
435        };
436        let languages = translations.languages.clone();
437        let source_path = self
438            .files
439            .get(1)
440            .or_else(|| self.files.first())
441            .map(|file| PathBuf::from(&file.path))
442            .unwrap_or_else(|| PathBuf::from("main.opy"));
443        let base = self.root.join(
444            source_path
445                .file_name()
446                .unwrap_or_else(|| std::ffi::OsStr::new("main.opy")),
447        );
448        let mut entries = BTreeMap::<(Option<String>, String), TranslationEntry>::new();
449        for language in languages.iter().skip(1) {
450            let po_path = base.with_extension(format!("{language}.po"));
451            let Some(text) = std::fs::read_to_string(&po_path).ok() else {
452                continue;
453            };
454            let file_id = self.register_external_file(&po_path, language, text.clone());
455            let parsed = parse_po(&text, language, Some(external_text_span(file_id, &text)))?;
456            for entry in parsed {
457                let key = (entry.context.clone(), entry.msgid.clone());
458                entries
459                    .entry(key)
460                    .and_modify(|existing| existing.translations.extend(entry.translations.clone()))
461                    .or_insert(entry);
462            }
463        }
464        if let Some(translations) = self.preprocessing.translations.as_mut() {
465            translations.entries = entries.into_values().collect();
466        }
467        Ok(())
468    }
469
470    /// Load an external custom-game-settings object relative to the owning
471    /// source file. Open-document overlays take precedence over the filesystem.
472    pub(super) fn resolve_settings_source(
473        &mut self,
474        mut block: SettingsBlock,
475    ) -> OpyResult<SettingsBlock> {
476        let Some(requested) = block.external_path.clone() else {
477            return Ok(block);
478        };
479        let requested = requested.replace('\\', "/");
480        let candidate = self.include_base().join(&requested);
481        let candidate_path = candidate.to_string_lossy().into_owned();
482        let canonical = std::fs::canonicalize(&candidate).ok();
483        let lexical_path = candidate.to_string_lossy().replace('\\', "/");
484        let content = self
485            .overlay
486            .get(&requested)
487            .or_else(|| self.overlay.get(&candidate_path))
488            .or_else(|| self.overlay.get(&lexical_path))
489            .or_else(|| {
490                canonical
491                    .as_ref()
492                    .and_then(|path| self.overlay.get(&path.to_string_lossy().into_owned()))
493            })
494            .cloned()
495            .or_else(|| canonical.and_then(|path| std::fs::read_to_string(path).ok()))
496            .ok_or_else(|| {
497                OpyError::at(
498                    "settings-not-found",
499                    format!("cannot find external settings file '{requested}'"),
500                    block.keyword_span,
501                )
502            })?;
503        let file_id = self.register_external_file(&candidate, &requested, content.clone());
504        block.content_file = file_id;
505        block.text_start = crate::diag::Position::new(1, 1);
506        block.content_span = external_text_span(file_id, &content);
507        block.text = content;
508        Ok(block)
509    }
510
511    fn register_external_file(&mut self, candidate: &Path, requested: &str, text: String) -> u32 {
512        let canonical = std::fs::canonicalize(candidate).ok();
513        let file_id = self.next_file_id;
514        self.next_file_id += 1;
515        self.source_texts.insert(file_id, text);
516        self.files.push(FileRecord {
517            id: file_id,
518            path: display_path(
519                candidate,
520                canonical.as_deref(),
521                &self.display_root,
522                requested,
523            ),
524        });
525        file_id
526    }
527
528    /// Apply the same token macro expansion used by ordinary OPY source to
529    /// the extracted settings values. Settings are removed before the source
530    /// token stream is processed, so this pass is the point where `#!define`
531    /// values become visible to the settings parser.
532    pub(super) fn expand_settings(&self, block: SettingsBlock) -> OpyResult<SettingsBlock> {
533        let tokens = lex(LexInput {
534            file_id: block.content_file,
535            text: &block.text,
536        })?;
537        let mut tokens = tokens;
538        for token in &mut tokens {
539            token.span = shift_settings_span(token.span, block.text_start);
540        }
541        let tokens = self.expand(tokens)?;
542        Ok(SettingsBlock {
543            text: render_tokens(&tokens),
544            ..block
545        })
546    }
547}
548
549fn parse_po(text: &str, language: &str, span: Option<Span>) -> OpyResult<Vec<TranslationEntry>> {
550    let span = span.unwrap_or(Span::new(
551        0,
552        crate::diag::Position::new(1, 1),
553        crate::diag::Position::new(1, 1),
554    ));
555    let mut header_language = None;
556    let mut entries = Vec::new();
557    let mut current: Option<(Option<String>, String, String)> = None;
558    let mut field: Option<&str> = None;
559    for line in text.lines().chain(std::iter::once("")) {
560        let trimmed = line.trim();
561        if trimmed.is_empty() {
562            if let Some((context, msgid, msgstr)) = current.take()
563                && !msgid.is_empty()
564            {
565                let mut translations = BTreeMap::new();
566                translations.insert(language.to_string(), msgstr);
567                entries.push(TranslationEntry {
568                    msgid,
569                    context,
570                    translations,
571                });
572            }
573            field = None;
574            continue;
575        }
576        if trimmed.starts_with('#') {
577            continue;
578        }
579        if trimmed.starts_with('"') {
580            let value = serde_json::from_str::<String>(trimmed).map_err(|error| {
581                OpyError::at(
582                    "translations-invalid",
583                    format!("malformed PO string: {error}"),
584                    span,
585                )
586            })?;
587            let Some((context, msgid, msgstr)) = current.as_mut() else {
588                return Err(OpyError::at(
589                    "translations-invalid",
590                    "PO continuation has no preceding field",
591                    span,
592                ));
593            };
594            match field {
595                Some("msgid") => msgid.push_str(&value),
596                Some("msgstr") => msgstr.push_str(&value),
597                Some("msgctxt") => context.get_or_insert_default().push_str(&value),
598                _ => {
599                    return Err(OpyError::at(
600                        "translations-invalid",
601                        "PO continuation has no recognized field",
602                        span,
603                    ));
604                }
605            }
606            if msgid.is_empty() {
607                for line in msgstr.lines() {
608                    if let Some(value) = line.strip_prefix("Language:") {
609                        header_language = Some(value.trim().to_string());
610                    }
611                }
612            }
613            continue;
614        }
615        let (name, value) = trimmed
616            .split_once(' ')
617            .ok_or_else(|| OpyError::at("translations-invalid", "malformed PO entry", span))?;
618        let value = serde_json::from_str::<String>(value).map_err(|error| {
619            OpyError::at(
620                "translations-invalid",
621                format!("malformed PO string: {error}"),
622                span,
623            )
624        })?;
625        match name {
626            "msgctxt" => {
627                if let Some((context, _, _)) = current.as_mut() {
628                    *context = Some(value);
629                } else {
630                    current = Some((Some(value), String::new(), String::new()));
631                }
632                field = Some("msgctxt");
633            }
634            "msgid" => {
635                if let Some((_, msgid, _)) = current.as_mut() {
636                    *msgid = value;
637                } else {
638                    current = Some((None, value, String::new()));
639                }
640                field = Some("msgid");
641            }
642            "msgstr" => {
643                if let Some((_, _, msgstr)) = current.as_mut() {
644                    *msgstr = value;
645                } else {
646                    current = Some((None, String::new(), value));
647                }
648                field = Some("msgstr");
649            }
650            _ => {
651                return Err(OpyError::at(
652                    "translations-invalid",
653                    format!("unsupported PO field '{name}'"),
654                    span,
655                ));
656            }
657        }
658        if name == "msgstr"
659            && current
660                .as_ref()
661                .is_some_and(|(_, msgid, _)| msgid.is_empty())
662        {
663            if let Some((_, value, _)) = current.as_ref() {
664                for line in value.lines() {
665                    if let Some(value) = line.strip_prefix("Language:") {
666                        header_language = Some(value.trim().to_string());
667                    }
668                }
669            }
670        }
671    }
672    if header_language.as_deref() != Some(language) {
673        return Err(OpyError::at(
674            "translations-invalid",
675            format!("PO language header does not match '{language}'"),
676            span,
677        ));
678    }
679    Ok(entries)
680}
681
682#[cfg(test)]
683mod tests {
684    use super::*;
685
686    #[test]
687    fn object_define_expands_at_use_site() {
688        let (pre, _) = preprocess(
689            "#!define SIDE 1.5\nrule \"r\":\n    x = SIDE\n",
690            "main.opy",
691            Path::new("."),
692        )
693        .unwrap();
694        assert_eq!(pre.defines.len(), 1);
695        assert_eq!(pre.defines[0].name, "SIDE");
696        assert!(!pre.defines[0].is_function);
697        assert!(!pre.defines[0].is_member);
698        let numbers: Vec<&str> = pre
699            .tokens
700            .iter()
701            .filter(|t| t.kind == TokenKind::Number)
702            .map(|t| t.text.as_str())
703            .collect();
704        assert_eq!(numbers, vec!["1.5"]);
705    }
706
707    #[test]
708    fn function_define_substitutes_params() {
709        let (pre, _) = preprocess(
710            "#!define double(x) x + x\nrule \"r\":\n    y = double(3)\n",
711            "main.opy",
712            Path::new("."),
713        )
714        .unwrap();
715        let numbers: Vec<&str> = pre
716            .tokens
717            .iter()
718            .filter(|t| t.kind == TokenKind::Number)
719            .map(|t| t.text.as_str())
720            .collect();
721        assert_eq!(numbers, vec!["3", "3"]);
722    }
723
724    #[test]
725    fn function_define_keeps_commas_inside_nested_collections() {
726        let (pre, _) = preprocess(
727            "#!define first(xs, fallback) xs[0]\nrule \"r\":\n    x = first([1, 2], 3)\n",
728            "main.opy",
729            Path::new("."),
730        )
731        .unwrap();
732        let numbers: Vec<&str> = pre
733            .tokens
734            .iter()
735            .filter(|token| token.kind == TokenKind::Number)
736            .map(|token| token.text.as_str())
737            .collect();
738        assert_eq!(numbers, vec!["1", "2", "0"]);
739    }
740
741    #[test]
742    fn zero_argument_function_define_accepts_empty_invocation() {
743        let (pre, _) = preprocess(
744            "#!define value() 3\nrule \"r\":\n    x = value()\n",
745            "main.opy",
746            Path::new("."),
747        )
748        .unwrap();
749        let numbers: Vec<&str> = pre
750            .tokens
751            .iter()
752            .filter(|token| token.kind == TokenKind::Number)
753            .map(|token| token.text.as_str())
754            .collect();
755        assert_eq!(numbers, vec!["3"]);
756    }
757
758    #[test]
759    fn macro_expanded_string_can_concatenate_with_following_literal() {
760        let (pre, _) = preprocess(
761            "#!define PREFIX \"one\"\nrule \"r\":\n    debug(PREFIX\n        \"two\")\n",
762            "main.opy",
763            Path::new("."),
764        )
765        .unwrap();
766        let output = crate::parser::parse(&pre.tokens);
767        assert!(
768            output.errors.is_empty(),
769            "unexpected errors: {:?}",
770            output.errors
771        );
772        let program = output.program.expect("expanded source must parse");
773        let crate::cst::RuleEntry::Rule(rule) = &program.rules[0] else {
774            panic!("expected rule");
775        };
776        let crate::cst::Stmt::Expr { expr, .. } = &rule.actions[0] else {
777            panic!("expected expression statement");
778        };
779        let crate::cst::Expr::Call { args, .. } = expr else {
780            panic!("expected call");
781        };
782        assert!(matches!(
783            &args[0].value,
784            crate::cst::Expr::String { value, .. } if value == "onetwo"
785        ));
786    }
787
788    #[test]
789    fn recursive_defines_expand_transitively() {
790        let (pre, _) = preprocess(
791            "#!define A 2\n#!define B A + 1\nrule \"r\":\n    x = B\n",
792            "main.opy",
793            Path::new("."),
794        )
795        .unwrap();
796        let numbers: Vec<&str> = pre
797            .tokens
798            .iter()
799            .filter(|t| t.kind == TokenKind::Number)
800            .map(|t| t.text.as_str())
801            .collect();
802        assert_eq!(numbers, vec!["2", "1"]);
803    }
804
805    #[test]
806    fn recursive_define_fails_structurally() {
807        let error = preprocess(
808            "#!define X X + 1\nrule \"r\":\n    x = X\n",
809            "main.opy",
810            Path::new("."),
811        )
812        .unwrap_err();
813        assert_eq!(error.code, "macro-recursion");
814    }
815
816    #[test]
817    fn defines_are_only_visible_after_their_directive() {
818        let (pre, _) = preprocess(
819            "VALUE\n#!define VALUE 1\nVALUE\n#!allowMacroRedeclaration\n#!define VALUE 2\nVALUE\n",
820            "main.opy",
821            Path::new("."),
822        )
823        .expect("ordered define expansion");
824        let numbers: Vec<&str> = pre
825            .tokens
826            .iter()
827            .filter(|token| token.kind == TokenKind::Number)
828            .map(|token| token.text.as_str())
829            .collect();
830        assert_eq!(numbers, vec!["1", "2"]);
831        assert!(pre.tokens.iter().any(|token| {
832            token.kind == TokenKind::Ident && token.text == "VALUE" && token.span.start.line == 1
833        }));
834    }
835
836    #[test]
837    fn function_define_substitutes_inside_string_text() {
838        let (pre, _) = preprocess(
839            "#!define wrap(value) \"value\"\nwrap(foo + bar)\n",
840            "main.opy",
841            Path::new("."),
842        )
843        .expect("textual define expansion");
844        let strings: Vec<&str> = pre
845            .tokens
846            .iter()
847            .filter(|token| token.kind == TokenKind::String)
848            .map(|token| token.text.as_str())
849            .collect();
850        assert_eq!(strings, vec!["foo + bar"]);
851    }
852
853    #[test]
854    fn multiline_define_preserves_relative_expansion_spans() {
855        let (pre, _) = preprocess(
856            "#!define block() A = 1\\\n    A = 2\nblock()\n",
857            "main.opy",
858            Path::new("."),
859        )
860        .expect("multiline define expansion");
861        let numbers: Vec<Span> = pre
862            .tokens
863            .iter()
864            .filter(|token| token.kind == TokenKind::Number)
865            .map(|token| token.span)
866            .collect();
867        assert_eq!(numbers.len(), 2);
868        assert_eq!(numbers[0].start.line, 3);
869        assert_eq!(numbers[1].start.line, 4);
870        assert_eq!(numbers[1].start.col, 9);
871    }
872
873    #[test]
874    fn function_define_requires_a_call_with_the_expected_arity() {
875        let error = preprocess(
876            "#!define value(argument) argument + 1\nvalue\n",
877            "main.opy",
878            Path::new("."),
879        )
880        .expect_err("a function-like define used without parentheses must fail arity checking");
881        assert_eq!(error.code, "macro-arity");
882    }
883
884    #[test]
885    fn exact_object_define_self_reference_is_rejected_at_definition() {
886        let error = preprocess("#!define VALUE VALUE\n", "main.opy", Path::new("."))
887            .expect_err("an exact object define self-reference must fail at definition");
888        assert_eq!(error.code, "macro-recursion");
889
890        let error = preprocess(
891            "#!define value(argument) value(argument)\n",
892            "main.opy",
893            Path::new("."),
894        )
895        .expect_err("an exact function define self-reference must fail at definition");
896        assert_eq!(error.code, "macro-recursion");
897    }
898
899    #[test]
900    fn missing_include_is_structured() {
901        let error = preprocess(
902            "#!include \"nope.opy\"\n",
903            "main.opy",
904            Path::new("/nonexistent-root"),
905        )
906        .unwrap_err();
907        assert_eq!(error.code, "include-not-found");
908        assert!(error.span.is_some());
909    }
910
911    #[test]
912    fn include_cycle_is_detected() {
913        let dir = std::env::temp_dir().join(format!("wright-opy-test-{}", std::process::id()));
914        std::fs::create_dir_all(&dir).unwrap();
915        std::fs::write(dir.join("a.opy"), "#!include \"b.opy\"\n").unwrap();
916        std::fs::write(dir.join("b.opy"), "#!include \"a.opy\"\n").unwrap();
917        let main = std::fs::read_to_string(dir.join("a.opy")).unwrap();
918        let error = preprocess(&main, "a.opy", &dir).unwrap_err();
919        assert_eq!(error.code, "include-cycle");
920        let _ = std::fs::remove_dir_all(&dir);
921    }
922
923    #[test]
924    fn unsupported_directive_is_structured() {
925        let error = preprocess("#!frobnicate\n", "main.opy", Path::new(".")).unwrap_err();
926        assert_eq!(error.code, "unsupported-directive");
927    }
928
929    #[test]
930    fn settings_block_is_extracted_before_lexing() {
931        let (pre, _) = preprocess(
932            "settings {\n    \"gamemodes\": {}\n}\nrule \"r\":\n    pass\n",
933            "main.opy",
934            Path::new("."),
935        )
936        .unwrap();
937        let block = pre.settings.expect("settings block extracted");
938        assert!(block.text.contains("gamemodes"));
939        // The block never enters the token stream.
940        assert!(
941            !pre.tokens.iter().any(|t| t.text.contains("gamemodes")),
942            "settings content must not be lexed"
943        );
944    }
945
946    #[test]
947    fn external_settings_file_is_loaded_before_lexing() {
948        let overlay = BTreeMap::from([(
949            "settings.opy.json".to_string(),
950            "{\n  \"gamemodes\": {},\n  \"lobby\": {\"maxPlayers\": 12}\n}\n".to_string(),
951        )]);
952        let (pre, _) = preprocess_with_overlay(
953            "settings \"settings.opy.json\"\nrule \"r\":\n    pass\n",
954            "main.opy",
955            Path::new("."),
956            &overlay,
957        )
958        .expect("external settings should load");
959        let block = pre.settings.expect("external settings block");
960        assert_eq!(block.external_path.as_deref(), Some("settings.opy.json"));
961        assert!(block.text.contains("maxPlayers"));
962        let parsed = crate::settings::parse_block(&block).expect("external JSONC object");
963        assert_eq!(parsed.children.len(), 2);
964    }
965
966    #[test]
967    fn settings_in_include_is_extracted_with_source_provenance() {
968        let overlay = BTreeMap::from([(
969            "shared.opy".to_string(),
970            "settings {\n    \"gamemodes\": {}\n}\n".to_string(),
971        )]);
972        let main = "#!include \"shared.opy\"\nrule \"r\":\n    pass\n";
973        let (pre, files) = preprocess_with_overlay(main, "main.opy", Path::new("."), &overlay)
974            .expect("included settings must be extracted");
975        let block = pre.settings.expect("included settings block");
976        assert_eq!(block.keyword_span.file, 1);
977        assert_eq!(files[1].path, "shared.opy");
978        assert!(!pre.tokens.iter().any(|token| token.text == "gamemodes"));
979    }
980
981    #[test]
982    fn duplicate_include_is_skipped_without_redeclaring_macros() {
983        let overlay =
984            BTreeMap::from([("shared.opy".to_string(), "#!define VALUE 2\n".to_string())]);
985        let main =
986            "#!include \"shared.opy\"\n#!include \"shared.opy\"\nrule \"r\":\n    x = VALUE\n";
987        let (pre, files) = preprocess_with_overlay(main, "main.opy", Path::new("."), &overlay)
988            .expect("duplicate includes must not redeclare macros");
989        assert_eq!(pre.defines.len(), 1);
990        assert_eq!(files.len(), 2);
991        assert_eq!(pre.warnings.len(), 1);
992        assert_eq!(pre.warnings[0].code, "w_already_imported");
993        assert_eq!(
994            pre.preprocessing
995                .directives
996                .iter()
997                .filter(|directive| directive.name == "include")
998                .count(),
999            2
1000        );
1001    }
1002
1003    #[test]
1004    fn alias_include_paths_are_distinct_imports() {
1005        let overlay = BTreeMap::from([
1006            ("shared.opy".to_string(), "#!define FIRST 1\n".to_string()),
1007            (
1008                "dir/../shared.opy".to_string(),
1009                "#!define SECOND 2\n".to_string(),
1010            ),
1011        ]);
1012        let main = "#!include \"shared.opy\"\n#!include \"dir/../shared.opy\"\nrule \"r\":\n    x = FIRST\n    y = SECOND\n";
1013        let (pre, files) = preprocess_with_overlay(main, "main.opy", Path::new("."), &overlay)
1014            .expect("alias include paths must remain distinct imports");
1015        assert_eq!(files.len(), 3);
1016        assert_eq!(files[1].path, "shared.opy");
1017        assert_eq!(files[2].path, "dir/../shared.opy");
1018        assert_eq!(pre.defines.len(), 2);
1019        assert!(pre.warnings.is_empty());
1020    }
1021
1022    #[test]
1023    fn dict_literal_braces_reach_the_parser() {
1024        // Scoped settings lexing must not consume expression-level braces.
1025        let (pre, _) = preprocess(
1026            "rule \"r\":\n    money += {\n        Mei.GENERIC: 10,\n    }\n",
1027            "main.opy",
1028            Path::new("."),
1029        )
1030        .unwrap();
1031        assert!(
1032            pre.tokens
1033                .iter()
1034                .any(|token| token.kind == TokenKind::LBrace)
1035        );
1036        assert!(
1037            pre.tokens
1038                .iter()
1039                .any(|token| token.kind == TokenKind::RBrace)
1040        );
1041    }
1042
1043    #[test]
1044    fn advanced_directives_preserve_frontend_state_without_catalog_data() {
1045        let (pre, _) = preprocess(
1046            "#!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",
1047            "main.opy",
1048            Path::new("."),
1049        )
1050        .unwrap();
1051        assert!(pre.preprocessing.allow_macro_redeclaration);
1052        assert_eq!(
1053            pre.preprocessing
1054                .translations
1055                .as_ref()
1056                .map(|state| state.languages.as_slice()),
1057            Some(["en".to_string(), "fr".to_string()].as_slice())
1058        );
1059        assert_eq!(
1060            pre.preprocessing
1061                .rule_prefix
1062                .as_ref()
1063                .map(|value| value.value.as_str()),
1064            Some("Effects")
1065        );
1066        assert!(pre.preprocessing.optimization.for_size);
1067        assert!(pre.preprocessing.optimization.strict);
1068        assert_eq!(
1069            pre.preprocessing.replacements[0].value,
1070            "getCapturePercentage"
1071        );
1072        assert_eq!(pre.defines.len(), 1);
1073    }
1074
1075    #[test]
1076    fn backend_only_directives_are_validated_and_recorded() {
1077        let (pre, _) = preprocess(
1078            "#!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",
1079            "main.opy",
1080            Path::new("."),
1081        )
1082        .unwrap();
1083        let names: Vec<&str> = pre
1084            .preprocessing
1085            .directives
1086            .iter()
1087            .map(|directive| directive.name.as_str())
1088            .collect();
1089        assert_eq!(
1090            names,
1091            vec![
1092                "excludeVariablesInCompilation",
1093                "extension",
1094                "setupTags",
1095                "setupTx",
1096                "translateWithPlayerVar",
1097                "disableInspector",
1098                "writeToOutputFile",
1099                "disableTranslationSourceLines",
1100                "keepUnusedTranslations",
1101                "useVariableForCompressionAlphabet",
1102                "debugElementCount",
1103                "globalvarInitRuleName",
1104                "playervarInitRuleName",
1105            ]
1106        );
1107        assert_eq!(
1108            pre.preprocessing.directives[1].value.as_deref(),
1109            Some("projectiles")
1110        );
1111        assert_eq!(
1112            pre.preprocessing.directives[4].value.as_deref(),
1113            Some("noDetectionRule noTlErr")
1114        );
1115    }
1116
1117    #[test]
1118    fn extension_directive_rejects_unknown_schema_values() {
1119        let error = preprocess(
1120            "#!extension notAnExtension\nrule \"r\":\n    pass\n",
1121            "main.opy",
1122            Path::new("."),
1123        )
1124        .unwrap_err();
1125        assert_eq!(error.code, "directive-invalid");
1126        assert_eq!(error.span.expect("directive provenance").start.line, 1);
1127    }
1128
1129    #[test]
1130    fn translations_follow_pinned_codes_without_local_deduplication() {
1131        let (pre, _) = preprocess(
1132            "#!translations EN zh-cn en\nrule \"r\":\n    pass\n",
1133            "main.opy",
1134            Path::new("."),
1135        )
1136        .unwrap();
1137        assert_eq!(
1138            pre.preprocessing.translations.unwrap().languages,
1139            vec!["en", "zh_cn", "en"]
1140        );
1141    }
1142
1143    #[test]
1144    fn translations_reject_codes_outside_the_pinned_oracle_set() {
1145        let error = preprocess(
1146            "#!translations en_US\nrule \"r\":\n    pass\n",
1147            "main.opy",
1148            Path::new("."),
1149        )
1150        .unwrap_err();
1151        assert_eq!(error.code, "translations-invalid");
1152    }
1153
1154    #[test]
1155    fn directive_records_expose_state_transitions_and_include_depth() {
1156        let root =
1157            std::env::temp_dir().join(format!("wright-opy-directive-scope-{}", std::process::id()));
1158        std::fs::create_dir_all(&root).unwrap();
1159        std::fs::write(
1160            root.join("child.opy"),
1161            "#!rulePrefix \"inner\"\n#!disableOptimizations\n",
1162        )
1163        .unwrap();
1164        let (pre, _) = preprocess(
1165            "#!rulePrefix \"outer\"\n#!include \"child.opy\"\n#!enableOptimizations\n",
1166            "main.opy",
1167            &root,
1168        )
1169        .unwrap();
1170        let records = &pre.preprocessing.directives;
1171        assert_eq!(records[0].state.rule_prefix.as_deref(), Some("outer"));
1172        assert_eq!(records[0].scope_depth, 0);
1173        assert_eq!(records[1].name, "rulePrefix");
1174        assert_eq!(records[1].state.rule_prefix.as_deref(), Some("inner"));
1175        assert!(!records[2].state.optimization.enabled);
1176        assert_eq!(records[2].scope_depth, 1);
1177        assert_eq!(records[3].name, "include");
1178        assert_eq!(records[3].state.rule_prefix.as_deref(), Some("outer"));
1179        assert_eq!(records[4].name, "enableOptimizations");
1180        assert!(records[4].state.optimization.enabled);
1181        let _ = std::fs::remove_dir_all(&root);
1182    }
1183
1184    #[test]
1185    fn malformed_translation_state_is_source_located() {
1186        let error = preprocess("#!translations\n", "main.opy", Path::new(".")).unwrap_err();
1187        assert_eq!(error.code, "translations-invalid");
1188        assert!(error.span.is_some());
1189    }
1190}