Skip to main content

opy_rs/
preprocess.rs

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