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