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            token.layout = token.span;
572        }
573        let tokens = self.expand(tokens)?;
574        Ok(SettingsBlock {
575            text: render_tokens(&tokens),
576            ..block
577        })
578    }
579}
580
581fn parse_po(text: &str, language: &str, span: Option<Span>) -> OpyResult<Vec<TranslationEntry>> {
582    let span = span.unwrap_or(Span::new(
583        0,
584        crate::diag::Position::new(1, 1),
585        crate::diag::Position::new(1, 1),
586    ));
587    let mut header_language = None;
588    let mut entries = Vec::new();
589    let mut current: Option<(Option<String>, String, String)> = None;
590    let mut field: Option<&str> = None;
591    for line in text.lines().chain(std::iter::once("")) {
592        let trimmed = line.trim();
593        if trimmed.is_empty() {
594            if let Some((context, msgid, msgstr)) = current.take()
595                && !msgid.is_empty()
596            {
597                let mut translations = BTreeMap::new();
598                translations.insert(language.to_string(), msgstr);
599                entries.push(TranslationEntry {
600                    msgid,
601                    context,
602                    translations,
603                });
604            }
605            field = None;
606            continue;
607        }
608        if trimmed.starts_with('#') {
609            continue;
610        }
611        if trimmed.starts_with('"') {
612            let value = serde_json::from_str::<String>(trimmed).map_err(|error| {
613                OpyError::at(
614                    "translations-invalid",
615                    format!("malformed PO string: {error}"),
616                    span,
617                )
618            })?;
619            let Some((context, msgid, msgstr)) = current.as_mut() else {
620                return Err(OpyError::at(
621                    "translations-invalid",
622                    "PO continuation has no preceding field",
623                    span,
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,
635                    ));
636                }
637            }
638            if msgid.is_empty() {
639                for line in msgstr.lines() {
640                    if let Some(value) = line.strip_prefix("Language:") {
641                        header_language = Some(value.trim().to_string());
642                    }
643                }
644            }
645            continue;
646        }
647        let (name, value) = trimmed
648            .split_once(' ')
649            .ok_or_else(|| OpyError::at("translations-invalid", "malformed PO entry", span))?;
650        let value = serde_json::from_str::<String>(value).map_err(|error| {
651            OpyError::at(
652                "translations-invalid",
653                format!("malformed PO string: {error}"),
654                span,
655            )
656        })?;
657        match name {
658            "msgctxt" => {
659                if let Some((context, _, _)) = current.as_mut() {
660                    *context = Some(value);
661                } else {
662                    current = Some((Some(value), String::new(), String::new()));
663                }
664                field = Some("msgctxt");
665            }
666            "msgid" => {
667                if let Some((_, msgid, _)) = current.as_mut() {
668                    *msgid = value;
669                } else {
670                    current = Some((None, value, String::new()));
671                }
672                field = Some("msgid");
673            }
674            "msgstr" => {
675                if let Some((_, _, msgstr)) = current.as_mut() {
676                    *msgstr = value;
677                } else {
678                    current = Some((None, String::new(), value));
679                }
680                field = Some("msgstr");
681            }
682            _ => {
683                return Err(OpyError::at(
684                    "translations-invalid",
685                    format!("unsupported PO field '{name}'"),
686                    span,
687                ));
688            }
689        }
690        if name == "msgstr"
691            && current
692                .as_ref()
693                .is_some_and(|(_, msgid, _)| msgid.is_empty())
694        {
695            if let Some((_, value, _)) = current.as_ref() {
696                for line in value.lines() {
697                    if let Some(value) = line.strip_prefix("Language:") {
698                        header_language = Some(value.trim().to_string());
699                    }
700                }
701            }
702        }
703    }
704    if header_language.as_deref() != Some(language) {
705        return Err(OpyError::at(
706            "translations-invalid",
707            format!("PO language header does not match '{language}'"),
708            span,
709        ));
710    }
711    Ok(entries)
712}
713
714#[cfg(test)]
715mod tests {
716    use super::*;
717
718    #[test]
719    fn object_define_expands_at_use_site() {
720        let (pre, _) = preprocess(
721            "#!define SIDE 1.5\nrule \"r\":\n    x = SIDE\n",
722            "main.opy",
723            Path::new("."),
724        )
725        .unwrap();
726        assert_eq!(pre.defines.len(), 1);
727        assert_eq!(pre.defines[0].name, "SIDE");
728        assert!(!pre.defines[0].is_function);
729        assert!(!pre.defines[0].is_member);
730        let numbers: Vec<&str> = pre
731            .tokens
732            .iter()
733            .filter(|t| t.kind == TokenKind::Number)
734            .map(|t| t.text.as_str())
735            .collect();
736        assert_eq!(numbers, vec!["1.5"]);
737    }
738
739    #[test]
740    fn expanded_tokens_carry_the_use_site_span() {
741        // #506: an expansion wider than its invocation must not record
742        // positions past the authored line; every expanded token attributes
743        // to the authored macro name.
744        let (pre, _) = preprocess(
745            "#!define T a_much_wider_replacement + another_token\nrule \"r\":\n    x = T\n",
746            "main.opy",
747            Path::new("."),
748        )
749        .unwrap();
750        let use_site = pre
751            .tokens
752            .iter()
753            .find(|token| token.text == "a_much_wider_replacement")
754            .expect("expanded token")
755            .span;
756        for token in pre.tokens.iter().filter(|token| {
757            matches!(
758                token.text.as_str(),
759                "a_much_wider_replacement" | "+" | "another_token"
760            )
761        }) {
762            assert_eq!(
763                token.span, use_site,
764                "{} must attribute to the use site",
765                token.text
766            );
767        }
768        // `T` sits at 3:9-3:10 in the authored source; the replacement is far
769        // wider, so shifted coordinates would have overrun the line.
770        assert_eq!(use_site.start, crate::diag::Position::new(3, 9));
771        assert_eq!(use_site.end, crate::diag::Position::new(3, 10));
772    }
773
774    #[test]
775    fn function_define_substitutes_params() {
776        let (pre, _) = preprocess(
777            "#!define double(x) x + x\nrule \"r\":\n    y = double(3)\n",
778            "main.opy",
779            Path::new("."),
780        )
781        .unwrap();
782        let numbers: Vec<&str> = pre
783            .tokens
784            .iter()
785            .filter(|t| t.kind == TokenKind::Number)
786            .map(|t| t.text.as_str())
787            .collect();
788        assert_eq!(numbers, vec!["3", "3"]);
789    }
790
791    #[test]
792    fn function_define_keeps_commas_inside_nested_collections() {
793        let (pre, _) = preprocess(
794            "#!define first(xs, fallback) xs[0]\nrule \"r\":\n    x = first([1, 2], 3)\n",
795            "main.opy",
796            Path::new("."),
797        )
798        .unwrap();
799        let numbers: Vec<&str> = pre
800            .tokens
801            .iter()
802            .filter(|token| token.kind == TokenKind::Number)
803            .map(|token| token.text.as_str())
804            .collect();
805        assert_eq!(numbers, vec!["1", "2", "0"]);
806    }
807
808    #[test]
809    fn zero_argument_function_define_accepts_empty_invocation() {
810        let (pre, _) = preprocess(
811            "#!define value() 3\nrule \"r\":\n    x = value()\n",
812            "main.opy",
813            Path::new("."),
814        )
815        .unwrap();
816        let numbers: Vec<&str> = pre
817            .tokens
818            .iter()
819            .filter(|token| token.kind == TokenKind::Number)
820            .map(|token| token.text.as_str())
821            .collect();
822        assert_eq!(numbers, vec!["3"]);
823    }
824
825    #[test]
826    fn macro_expanded_string_can_concatenate_with_following_literal() {
827        let (pre, _) = preprocess(
828            "#!define PREFIX \"one\"\nrule \"r\":\n    debug(PREFIX\n        \"two\")\n",
829            "main.opy",
830            Path::new("."),
831        )
832        .unwrap();
833        let output = crate::parser::parse(&pre.tokens);
834        assert!(
835            output.errors.is_empty(),
836            "unexpected errors: {:?}",
837            output.errors
838        );
839        let program = output.program.expect("expanded source must parse");
840        let crate::cst::RuleEntry::Rule(rule) = &program.rules[0] else {
841            panic!("expected rule");
842        };
843        let crate::cst::Stmt::Expr { expr, .. } = &rule.actions[0] else {
844            panic!("expected expression statement");
845        };
846        let crate::cst::Expr::Call { args, .. } = expr else {
847            panic!("expected call");
848        };
849        assert!(matches!(
850            &args[0].value,
851            crate::cst::Expr::String { value, .. } if value == "onetwo"
852        ));
853    }
854
855    #[test]
856    fn recursive_defines_expand_transitively() {
857        let (pre, _) = preprocess(
858            "#!define A 2\n#!define B A + 1\nrule \"r\":\n    x = B\n",
859            "main.opy",
860            Path::new("."),
861        )
862        .unwrap();
863        let numbers: Vec<&str> = pre
864            .tokens
865            .iter()
866            .filter(|t| t.kind == TokenKind::Number)
867            .map(|t| t.text.as_str())
868            .collect();
869        assert_eq!(numbers, vec!["2", "1"]);
870    }
871
872    #[test]
873    fn recursive_define_fails_structurally() {
874        let error = preprocess(
875            "#!define X X + 1\nrule \"r\":\n    x = X\n",
876            "main.opy",
877            Path::new("."),
878        )
879        .unwrap_err();
880        assert_eq!(error.code, "macro-recursion");
881    }
882
883    #[test]
884    fn defines_are_only_visible_after_their_directive() {
885        let (pre, _) = preprocess(
886            "VALUE\n#!define VALUE 1\nVALUE\n#!allowMacroRedeclaration\n#!define VALUE 2\nVALUE\n",
887            "main.opy",
888            Path::new("."),
889        )
890        .expect("ordered define expansion");
891        let numbers: Vec<&str> = pre
892            .tokens
893            .iter()
894            .filter(|token| token.kind == TokenKind::Number)
895            .map(|token| token.text.as_str())
896            .collect();
897        assert_eq!(numbers, vec!["1", "2"]);
898        assert!(pre.tokens.iter().any(|token| {
899            token.kind == TokenKind::Ident && token.text == "VALUE" && token.span.start.line == 1
900        }));
901    }
902
903    #[test]
904    fn function_define_substitutes_inside_string_text() {
905        let (pre, _) = preprocess(
906            "#!define wrap(value) \"value\"\nwrap(foo + bar)\n",
907            "main.opy",
908            Path::new("."),
909        )
910        .expect("textual define expansion");
911        let strings: Vec<&str> = pre
912            .tokens
913            .iter()
914            .filter(|token| token.kind == TokenKind::String)
915            .map(|token| token.text.as_str())
916            .collect();
917        assert_eq!(strings, vec!["foo + bar"]);
918    }
919
920    #[test]
921    fn multiline_define_expanded_tokens_attribute_to_the_use_site() {
922        // #506: a multi-line expansion attributes every token to the
923        // invocation — shifted line numbers would point at unrelated
924        // authored lines.
925        let (pre, _) = preprocess(
926            "#!define block() A = 1\\\n    A = 2\nblock()\n",
927            "main.opy",
928            Path::new("."),
929        )
930        .expect("multiline define expansion");
931        let expected = Span::new(
932            0,
933            crate::diag::Position::new(3, 1),
934            crate::diag::Position::new(3, 6),
935        );
936        let numbers: Vec<Span> = pre
937            .tokens
938            .iter()
939            .filter(|token| token.kind == TokenKind::Number)
940            .map(|token| token.span)
941            .collect();
942        assert_eq!(numbers, vec![expected; 2]);
943    }
944
945    #[test]
946    fn function_define_requires_a_call_with_the_expected_arity() {
947        let error = preprocess(
948            "#!define value(argument) argument + 1\nvalue\n",
949            "main.opy",
950            Path::new("."),
951        )
952        .expect_err("a function-like define used without parentheses must fail arity checking");
953        assert_eq!(error.code, "macro-arity");
954    }
955
956    #[test]
957    fn exact_object_define_self_reference_is_rejected_at_definition() {
958        let error = preprocess("#!define VALUE VALUE\n", "main.opy", Path::new("."))
959            .expect_err("an exact object define self-reference must fail at definition");
960        assert_eq!(error.code, "macro-recursion");
961
962        let error = preprocess(
963            "#!define value(argument) value(argument)\n",
964            "main.opy",
965            Path::new("."),
966        )
967        .expect_err("an exact function define self-reference must fail at definition");
968        assert_eq!(error.code, "macro-recursion");
969    }
970
971    #[test]
972    fn missing_include_is_structured() {
973        let error = preprocess(
974            "#!include \"nope.opy\"\n",
975            "main.opy",
976            Path::new("/nonexistent-root"),
977        )
978        .unwrap_err();
979        assert_eq!(error.code, "include-not-found");
980        assert!(error.span.is_some());
981    }
982
983    #[test]
984    fn include_cycle_is_detected() {
985        let dir = std::env::temp_dir().join(format!("wright-opy-test-{}", std::process::id()));
986        std::fs::create_dir_all(&dir).unwrap();
987        std::fs::write(dir.join("a.opy"), "#!include \"b.opy\"\n").unwrap();
988        std::fs::write(dir.join("b.opy"), "#!include \"a.opy\"\n").unwrap();
989        let main = std::fs::read_to_string(dir.join("a.opy")).unwrap();
990        let error = preprocess(&main, "a.opy", &dir).unwrap_err();
991        assert_eq!(error.code, "include-cycle");
992        let _ = std::fs::remove_dir_all(&dir);
993    }
994
995    #[test]
996    fn unsupported_directive_is_structured() {
997        let error = preprocess("#!frobnicate\n", "main.opy", Path::new(".")).unwrap_err();
998        assert_eq!(error.code, "unsupported-directive");
999    }
1000
1001    #[test]
1002    fn settings_block_is_extracted_before_lexing() {
1003        let (pre, _) = preprocess(
1004            "settings {\n    \"gamemodes\": {}\n}\nrule \"r\":\n    pass\n",
1005            "main.opy",
1006            Path::new("."),
1007        )
1008        .unwrap();
1009        let block = pre.settings.expect("settings block extracted");
1010        assert!(block.text.contains("gamemodes"));
1011        // The block never enters the token stream.
1012        assert!(
1013            !pre.tokens.iter().any(|t| t.text.contains("gamemodes")),
1014            "settings content must not be lexed"
1015        );
1016    }
1017
1018    #[test]
1019    fn external_settings_file_is_loaded_before_lexing() {
1020        let overlay = BTreeMap::from([(
1021            "settings.opy.json".to_string(),
1022            "{\n  \"gamemodes\": {},\n  \"lobby\": {\"maxPlayers\": 12}\n}\n".to_string(),
1023        )]);
1024        let (pre, _) = preprocess_with_overlay(
1025            "settings \"settings.opy.json\"\nrule \"r\":\n    pass\n",
1026            "main.opy",
1027            Path::new("."),
1028            &overlay,
1029        )
1030        .expect("external settings should load");
1031        let block = pre.settings.expect("external settings block");
1032        assert_eq!(block.external_path.as_deref(), Some("settings.opy.json"));
1033        assert!(block.text.contains("maxPlayers"));
1034        let parsed = crate::settings::parse_block(&block).expect("external JSONC object");
1035        assert_eq!(parsed.children.len(), 2);
1036    }
1037
1038    #[test]
1039    fn settings_in_include_is_extracted_with_source_provenance() {
1040        let overlay = BTreeMap::from([(
1041            "shared.opy".to_string(),
1042            "settings {\n    \"gamemodes\": {}\n}\n".to_string(),
1043        )]);
1044        let main = "#!include \"shared.opy\"\nrule \"r\":\n    pass\n";
1045        let (pre, files) = preprocess_with_overlay(main, "main.opy", Path::new("."), &overlay)
1046            .expect("included settings must be extracted");
1047        let block = pre.settings.expect("included settings block");
1048        assert_eq!(block.keyword_span.file, 1);
1049        assert_eq!(files[1].path, "shared.opy");
1050        assert!(!pre.tokens.iter().any(|token| token.text == "gamemodes"));
1051    }
1052
1053    #[test]
1054    fn duplicate_include_is_skipped_without_redeclaring_macros() {
1055        let overlay =
1056            BTreeMap::from([("shared.opy".to_string(), "#!define VALUE 2\n".to_string())]);
1057        let main =
1058            "#!include \"shared.opy\"\n#!include \"shared.opy\"\nrule \"r\":\n    x = VALUE\n";
1059        let (pre, files) = preprocess_with_overlay(main, "main.opy", Path::new("."), &overlay)
1060            .expect("duplicate includes must not redeclare macros");
1061        assert_eq!(pre.defines.len(), 1);
1062        assert_eq!(files.len(), 2);
1063        assert_eq!(pre.warnings.len(), 1);
1064        assert_eq!(pre.warnings[0].code, "w_already_imported");
1065        assert_eq!(
1066            pre.preprocessing
1067                .directives
1068                .iter()
1069                .filter(|directive| directive.name == "include")
1070                .count(),
1071            2
1072        );
1073    }
1074
1075    #[test]
1076    fn alias_include_paths_are_distinct_imports() {
1077        let overlay = BTreeMap::from([
1078            ("shared.opy".to_string(), "#!define FIRST 1\n".to_string()),
1079            (
1080                "dir/../shared.opy".to_string(),
1081                "#!define SECOND 2\n".to_string(),
1082            ),
1083        ]);
1084        let main = "#!include \"shared.opy\"\n#!include \"dir/../shared.opy\"\nrule \"r\":\n    x = FIRST\n    y = SECOND\n";
1085        let (pre, files) = preprocess_with_overlay(main, "main.opy", Path::new("."), &overlay)
1086            .expect("alias include paths must remain distinct imports");
1087        assert_eq!(files.len(), 3);
1088        assert_eq!(files[1].path, "shared.opy");
1089        assert_eq!(files[2].path, "dir/../shared.opy");
1090        assert_eq!(pre.defines.len(), 2);
1091        assert!(pre.warnings.is_empty());
1092    }
1093
1094    #[test]
1095    fn dict_literal_braces_reach_the_parser() {
1096        // Scoped settings lexing must not consume expression-level braces.
1097        let (pre, _) = preprocess(
1098            "rule \"r\":\n    money += {\n        Mei.GENERIC: 10,\n    }\n",
1099            "main.opy",
1100            Path::new("."),
1101        )
1102        .unwrap();
1103        assert!(
1104            pre.tokens
1105                .iter()
1106                .any(|token| token.kind == TokenKind::LBrace)
1107        );
1108        assert!(
1109            pre.tokens
1110                .iter()
1111                .any(|token| token.kind == TokenKind::RBrace)
1112        );
1113    }
1114
1115    #[test]
1116    fn advanced_directives_preserve_frontend_state_without_catalog_data() {
1117        let (pre, _) = preprocess(
1118            "#!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",
1119            "main.opy",
1120            Path::new("."),
1121        )
1122        .unwrap();
1123        assert!(pre.preprocessing.allow_macro_redeclaration);
1124        assert_eq!(
1125            pre.preprocessing
1126                .translations
1127                .as_ref()
1128                .map(|state| state.languages.as_slice()),
1129            Some(["en".to_string(), "fr".to_string()].as_slice())
1130        );
1131        assert_eq!(
1132            pre.preprocessing
1133                .rule_prefix
1134                .as_ref()
1135                .map(|value| value.value.as_str()),
1136            Some("Effects")
1137        );
1138        assert!(pre.preprocessing.optimization.for_size);
1139        assert!(pre.preprocessing.optimization.strict);
1140        assert_eq!(
1141            pre.preprocessing.replacements[0].value,
1142            "getCapturePercentage"
1143        );
1144        assert_eq!(pre.defines.len(), 1);
1145    }
1146
1147    #[test]
1148    fn backend_only_directives_are_validated_and_recorded() {
1149        let (pre, _) = preprocess(
1150            "#!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",
1151            "main.opy",
1152            Path::new("."),
1153        )
1154        .unwrap();
1155        let names: Vec<&str> = pre
1156            .preprocessing
1157            .directives
1158            .iter()
1159            .map(|directive| directive.name.as_str())
1160            .collect();
1161        assert_eq!(
1162            names,
1163            vec![
1164                "excludeVariablesInCompilation",
1165                "extension",
1166                "setupTags",
1167                "setupTx",
1168                "translateWithPlayerVar",
1169                "disableInspector",
1170                "writeToOutputFile",
1171                "disableTranslationSourceLines",
1172                "keepUnusedTranslations",
1173                "useVariableForCompressionAlphabet",
1174                "debugElementCount",
1175                "globalvarInitRuleName",
1176                "playervarInitRuleName",
1177            ]
1178        );
1179        assert_eq!(
1180            pre.preprocessing.directives[1].value.as_deref(),
1181            Some("projectiles")
1182        );
1183        assert_eq!(
1184            pre.preprocessing.directives[4].value.as_deref(),
1185            Some("noDetectionRule noTlErr")
1186        );
1187    }
1188
1189    #[test]
1190    fn extension_directive_rejects_unknown_schema_values() {
1191        let error = preprocess(
1192            "#!extension notAnExtension\nrule \"r\":\n    pass\n",
1193            "main.opy",
1194            Path::new("."),
1195        )
1196        .unwrap_err();
1197        assert_eq!(error.code, "directive-invalid");
1198        assert_eq!(error.span.expect("directive provenance").start.line, 1);
1199    }
1200
1201    #[test]
1202    fn translations_follow_pinned_codes_without_local_deduplication() {
1203        let (pre, _) = preprocess(
1204            "#!translations EN zh-cn en\nrule \"r\":\n    pass\n",
1205            "main.opy",
1206            Path::new("."),
1207        )
1208        .unwrap();
1209        assert_eq!(
1210            pre.preprocessing.translations.unwrap().languages,
1211            vec!["en", "zh_cn", "en"]
1212        );
1213    }
1214
1215    #[test]
1216    fn translations_reject_codes_outside_the_pinned_oracle_set() {
1217        let error = preprocess(
1218            "#!translations en_US\nrule \"r\":\n    pass\n",
1219            "main.opy",
1220            Path::new("."),
1221        )
1222        .unwrap_err();
1223        assert_eq!(error.code, "translations-invalid");
1224    }
1225
1226    #[test]
1227    fn directive_records_expose_state_transitions_and_include_depth() {
1228        let root =
1229            std::env::temp_dir().join(format!("wright-opy-directive-scope-{}", std::process::id()));
1230        std::fs::create_dir_all(&root).unwrap();
1231        std::fs::write(
1232            root.join("child.opy"),
1233            "#!rulePrefix \"inner\"\n#!disableOptimizations\n",
1234        )
1235        .unwrap();
1236        let (pre, _) = preprocess(
1237            "#!rulePrefix \"outer\"\n#!include \"child.opy\"\n#!enableOptimizations\n",
1238            "main.opy",
1239            &root,
1240        )
1241        .unwrap();
1242        let records = &pre.preprocessing.directives;
1243        assert_eq!(records[0].state.rule_prefix.as_deref(), Some("outer"));
1244        assert_eq!(records[0].scope_depth, 0);
1245        assert_eq!(records[1].name, "rulePrefix");
1246        assert_eq!(records[1].state.rule_prefix.as_deref(), Some("inner"));
1247        assert!(!records[2].state.optimization.enabled);
1248        assert_eq!(records[2].scope_depth, 1);
1249        assert_eq!(records[3].name, "include");
1250        assert_eq!(records[3].state.rule_prefix.as_deref(), Some("outer"));
1251        assert_eq!(records[4].name, "enableOptimizations");
1252        assert!(records[4].state.optimization.enabled);
1253        let _ = std::fs::remove_dir_all(&root);
1254    }
1255
1256    #[test]
1257    fn malformed_translation_state_is_source_located() {
1258        let error = preprocess("#!translations\n", "main.opy", Path::new(".")).unwrap_err();
1259        assert_eq!(error.code, "translations-invalid");
1260        assert!(error.span.is_some());
1261    }
1262}