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