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    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::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; backend effects are not run.
109    pub preprocessing: PreprocessingState,
110}
111
112/// The output file registry, in include order, preserving source provenance.
113#[derive(Debug, Clone, PartialEq)]
114pub struct FileRecord {
115    pub id: u32,
116    pub path: String,
117}
118
119/// A source-attributed preprocessing warning.
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct PreprocessWarning {
122    pub code: String,
123    pub message: String,
124    pub span: Span,
125}
126
127/// Preprocess the main source text with its include root.
128pub fn preprocess(
129    main_text: &str,
130    main_path: &str,
131    root: &Path,
132) -> OpyResult<(Preprocessed, Vec<FileRecord>)> {
133    preprocess_with_overlay(main_text, main_path, root, &BTreeMap::new())
134}
135
136/// Preprocess with open-document overlays: includes resolve to overlay text
137/// (keyed by the include string or the resolved canonical path) before the
138/// filesystem. Overlays model unsaved editor buffers without changing the
139/// compiler's source-loading contract.
140pub fn preprocess_with_overlay(
141    main_text: &str,
142    main_path: &str,
143    root: &Path,
144    overlay: &BTreeMap<String, String>,
145) -> OpyResult<(Preprocessed, Vec<FileRecord>)> {
146    preprocess_with_overlay_outcome(main_text, main_path, root, overlay).result
147}
148
149/// The outcome of preprocessing with overlays, retaining the file registry
150/// registered so far even when a directive or expansion fails, so callers can
151/// map an error's span file id to its actual source.
152pub struct PreprocessOutcome {
153    pub result: OpyResult<(Preprocessed, Vec<FileRecord>)>,
154    pub files: Vec<FileRecord>,
155    pub warnings: Vec<PreprocessWarning>,
156}
157
158/// Preprocess with open-document overlays while retaining the file registry
159/// registered so far on failure.
160pub fn preprocess_with_overlay_outcome(
161    main_text: &str,
162    main_path: &str,
163    root: &Path,
164    overlay: &BTreeMap<String, String>,
165) -> PreprocessOutcome {
166    let resolved_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
167    let mut pre = Preprocessor {
168        files: vec![FileRecord {
169            id: 0,
170            path: main_path.to_string(),
171        }],
172        next_file_id: 1,
173        root: resolved_root.clone(),
174        display_root: resolved_root,
175        overlay: overlay.clone(),
176        include_stack: Vec::new(),
177        last_macro_include_path: None,
178        imported_files: BTreeSet::new(),
179        macros: Vec::new(),
180        defines: Vec::new(),
181        post_compile_hook: None,
182        settings: None,
183        warnings: Vec::new(),
184        preprocessing: PreprocessingState::default(),
185    };
186    let mut owned_main_text = None;
187    let mut source_file_id = 0;
188    let first_line = main_text.lines().next().unwrap_or_default();
189    if first_line.trim_start().starts_with("#!mainFile")
190        && first_main_file_directive(main_text).is_none()
191    {
192        let span = Span::new(
193            0,
194            crate::diag::Position::new(1, 1),
195            crate::diag::Position::new(1, first_line.chars().count() as u32 + 1),
196        );
197        return PreprocessOutcome {
198            result: Err(OpyError::at(
199                "main-file-invalid",
200                "`#!mainFile` expects one quoted path on the first line",
201                span,
202            )),
203            files: pre.files,
204            warnings: pre.warnings,
205        };
206    }
207    if let Some((main_file, span)) = first_main_file_directive(main_text) {
208        let candidate = pre.root.join(&main_file);
209        let canonical = std::fs::canonicalize(&candidate).ok();
210        let overlay_text = overlay
211            .get(&main_file)
212            .or_else(|| {
213                canonical
214                    .as_ref()
215                    .and_then(|path| overlay.get(&path.to_string_lossy().into_owned()))
216            })
217            .cloned();
218        let (text, canonical_path, new_root) = match overlay_text {
219            Some(text) => {
220                let new_root = candidate
221                    .parent()
222                    .map(Path::to_path_buf)
223                    .unwrap_or_else(|| pre.root.clone());
224                (text, canonical, new_root)
225            }
226            None => {
227                let Some(canonical) = canonical else {
228                    return PreprocessOutcome {
229                        result: Err(OpyError::at(
230                            "main-file-not-found",
231                            format!("cannot find main file '{main_file}'"),
232                            span,
233                        )),
234                        files: pre.files,
235                        warnings: pre.warnings,
236                    };
237                };
238                let text = match std::fs::read_to_string(&canonical) {
239                    Ok(text) => text,
240                    Err(error) => {
241                        return PreprocessOutcome {
242                            result: Err(OpyError::at(
243                                "main-file-not-found",
244                                format!("cannot read main file '{main_file}': {error}"),
245                                span,
246                            )),
247                            files: pre.files,
248                            warnings: pre.warnings,
249                        };
250                    }
251                };
252                let new_root = canonical
253                    .parent()
254                    .map(Path::to_path_buf)
255                    .unwrap_or_else(|| pre.root.clone());
256                (text, Some(canonical), new_root)
257            }
258        };
259        let display_path =
260            display_path(&candidate, canonical_path.as_deref(), &new_root, &main_file);
261        owned_main_text = Some(text);
262        source_file_id = 1;
263        pre.files.push(FileRecord {
264            id: source_file_id,
265            path: display_path,
266        });
267        pre.next_file_id = 2;
268        pre.root = new_root.clone();
269        pre.display_root = new_root;
270        pre.preprocessing.main_file = Some(DirectiveValue {
271            value: main_file.clone(),
272            span: Some(span.into()),
273        });
274        pre.record("mainFile", Some(&main_file), span);
275    }
276    let source_text = owned_main_text.as_deref().unwrap_or(main_text);
277    // The project settings block is extracted before lexing and blanked out of
278    // the owning file's lexed text, so the lexer never sees its braces (#86).
279    let settings = match crate::settings::find_blocks(source_text, source_file_id) {
280        Ok(mut blocks) => blocks.pop(),
281        Err(error) => {
282            return PreprocessOutcome {
283                result: Err(error),
284                files: pre.files,
285                warnings: pre.warnings,
286            };
287        }
288    };
289    pre.settings = settings.clone();
290    let tokens = match &settings {
291        Some(block) => {
292            let sanitized = crate::settings::sanitize_for_lex(source_text, block);
293            lex(LexInput {
294                file_id: source_file_id,
295                text: &sanitized,
296            })
297        }
298        None => lex(LexInput {
299            file_id: source_file_id,
300            text: source_text,
301        }),
302    };
303    let mut tokens = match tokens {
304        Ok(tokens) => tokens,
305        Err(error) => {
306            return PreprocessOutcome {
307                result: Err(error),
308                files: pre.files,
309                warnings: pre.warnings,
310            };
311        }
312    };
313    if let Err(error) = pre.process_directives(&mut tokens, false) {
314        return PreprocessOutcome {
315            result: Err(error),
316            files: pre.files,
317            warnings: pre.warnings,
318        };
319    }
320    match pre.expand(tokens) {
321        Ok(tokens) => {
322            let settings = match pre
323                .settings
324                .take()
325                .map(|block| pre.expand_settings(block))
326                .transpose()
327            {
328                Ok(settings) => settings,
329                Err(error) => {
330                    return PreprocessOutcome {
331                        result: Err(error),
332                        files: pre.files,
333                        warnings: pre.warnings,
334                    };
335                }
336            };
337            let result = Ok((
338                Preprocessed {
339                    tokens,
340                    defines: pre.defines,
341                    settings,
342                    warnings: pre.warnings.clone(),
343                    post_compile_hook: pre.post_compile_hook,
344                    preprocessing: pre.preprocessing,
345                },
346                pre.files.clone(),
347            ));
348            PreprocessOutcome {
349                result,
350                files: pre.files,
351                warnings: pre.warnings,
352            }
353        }
354        Err(error) => PreprocessOutcome {
355            result: Err(error),
356            files: pre.files,
357            warnings: pre.warnings,
358        },
359    }
360}
361
362fn render_tokens(tokens: &[Token]) -> String {
363    let mut rendered = String::new();
364    let mut previous: Option<&Token> = None;
365    for token in tokens {
366        if token.kind == TokenKind::Eof {
367            continue;
368        }
369        if let Some(previous) = previous
370            && can_merge_without_separator(previous.kind, token.kind)
371        {
372            rendered.push(' ');
373        }
374        if token.kind == TokenKind::Newline {
375            rendered.push('\n');
376        } else if token.kind == TokenKind::String {
377            rendered.push('"');
378            rendered.push_str(token.raw.as_deref().unwrap_or(&token.text));
379            rendered.push('"');
380        } else {
381            rendered.push_str(&token.text);
382        }
383        previous = Some(token);
384    }
385    rendered
386}
387
388fn can_merge_without_separator(previous: TokenKind, current: TokenKind) -> bool {
389    matches!(previous, TokenKind::Ident | TokenKind::Number)
390        && matches!(current, TokenKind::Ident | TokenKind::Number)
391}
392
393fn shift_settings_span(span: Span, origin: crate::diag::Position) -> Span {
394    fn shift(
395        position: crate::diag::Position,
396        origin: crate::diag::Position,
397    ) -> crate::diag::Position {
398        crate::diag::Position::new(
399            origin.line + position.line.saturating_sub(1),
400            if position.line == 1 {
401                origin.col + position.col.saturating_sub(1)
402            } else {
403                position.col
404            },
405        )
406    }
407    Span::new(
408        span.file,
409        shift(span.start, origin),
410        shift(span.end, origin),
411    )
412}
413
414struct Preprocessor {
415    files: Vec<FileRecord>,
416    next_file_id: u32,
417    root: PathBuf,
418    display_root: PathBuf,
419    overlay: BTreeMap<String, String>,
420    include_stack: Vec<PathBuf>,
421    // OverPy retains this file context after multiline macro expansion in an
422    // included settings block; established projects rely on that lookup base.
423    last_macro_include_path: Option<PathBuf>,
424    imported_files: BTreeSet<PathBuf>,
425    macros: Vec<MacroDef>,
426    settings: Option<SettingsBlock>,
427    defines: Vec<DefineRecord>,
428    post_compile_hook: Option<PostCompileHook>,
429    warnings: Vec<PreprocessWarning>,
430    preprocessing: PreprocessingState,
431}
432
433impl Preprocessor {
434    /// Apply the same token macro expansion used by ordinary OPY source to
435    /// the extracted settings values. Settings are removed before the source
436    /// token stream is processed, so this pass is the point where `#!define`
437    /// values become visible to the settings parser.
438    fn expand_settings(&self, block: SettingsBlock) -> OpyResult<SettingsBlock> {
439        let tokens = lex(LexInput {
440            file_id: block.span.file,
441            text: &block.text,
442        })?;
443        let mut tokens = tokens;
444        for token in &mut tokens {
445            token.span = shift_settings_span(token.span, block.text_start);
446        }
447        let tokens = self.expand(tokens)?;
448        Ok(SettingsBlock {
449            text: render_tokens(&tokens),
450            ..block
451        })
452    }
453}
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458
459    #[test]
460    fn object_define_expands_at_use_site() {
461        let (pre, _) = preprocess(
462            "#!define SIDE 1.5\nrule \"r\":\n    x = SIDE\n",
463            "main.opy",
464            Path::new("."),
465        )
466        .unwrap();
467        assert_eq!(pre.defines.len(), 1);
468        assert_eq!(pre.defines[0].name, "SIDE");
469        assert!(!pre.defines[0].is_function);
470        assert!(!pre.defines[0].is_member);
471        let numbers: Vec<&str> = pre
472            .tokens
473            .iter()
474            .filter(|t| t.kind == TokenKind::Number)
475            .map(|t| t.text.as_str())
476            .collect();
477        assert_eq!(numbers, vec!["1.5"]);
478    }
479
480    #[test]
481    fn function_define_substitutes_params() {
482        let (pre, _) = preprocess(
483            "#!define double(x) x + x\nrule \"r\":\n    y = double(3)\n",
484            "main.opy",
485            Path::new("."),
486        )
487        .unwrap();
488        let numbers: Vec<&str> = pre
489            .tokens
490            .iter()
491            .filter(|t| t.kind == TokenKind::Number)
492            .map(|t| t.text.as_str())
493            .collect();
494        assert_eq!(numbers, vec!["3", "3"]);
495    }
496
497    #[test]
498    fn function_define_keeps_commas_inside_nested_collections() {
499        let (pre, _) = preprocess(
500            "#!define first(xs, fallback) xs[0]\nrule \"r\":\n    x = first([1, 2], 3)\n",
501            "main.opy",
502            Path::new("."),
503        )
504        .unwrap();
505        let numbers: Vec<&str> = pre
506            .tokens
507            .iter()
508            .filter(|token| token.kind == TokenKind::Number)
509            .map(|token| token.text.as_str())
510            .collect();
511        assert_eq!(numbers, vec!["1", "2", "0"]);
512    }
513
514    #[test]
515    fn zero_argument_function_define_accepts_empty_invocation() {
516        let (pre, _) = preprocess(
517            "#!define value() 3\nrule \"r\":\n    x = value()\n",
518            "main.opy",
519            Path::new("."),
520        )
521        .unwrap();
522        let numbers: Vec<&str> = pre
523            .tokens
524            .iter()
525            .filter(|token| token.kind == TokenKind::Number)
526            .map(|token| token.text.as_str())
527            .collect();
528        assert_eq!(numbers, vec!["3"]);
529    }
530
531    #[test]
532    fn macro_expanded_string_can_concatenate_with_following_literal() {
533        let (pre, _) = preprocess(
534            "#!define PREFIX \"one\"\nrule \"r\":\n    debug(PREFIX\n        \"two\")\n",
535            "main.opy",
536            Path::new("."),
537        )
538        .unwrap();
539        let output = crate::parser::parse(&pre.tokens);
540        assert!(
541            output.errors.is_empty(),
542            "unexpected errors: {:?}",
543            output.errors
544        );
545        let program = output.program.expect("expanded source must parse");
546        let crate::cst::RuleEntry::Rule(rule) = &program.rules[0] else {
547            panic!("expected rule");
548        };
549        let crate::cst::Stmt::Expr { expr, .. } = &rule.actions[0] else {
550            panic!("expected expression statement");
551        };
552        let crate::cst::Expr::Call { args, .. } = expr else {
553            panic!("expected call");
554        };
555        assert!(matches!(
556            &args[0].value,
557            crate::cst::Expr::String { value, .. } if value == "onetwo"
558        ));
559    }
560
561    #[test]
562    fn recursive_defines_expand_transitively() {
563        let (pre, _) = preprocess(
564            "#!define A 2\n#!define B A + 1\nrule \"r\":\n    x = B\n",
565            "main.opy",
566            Path::new("."),
567        )
568        .unwrap();
569        let numbers: Vec<&str> = pre
570            .tokens
571            .iter()
572            .filter(|t| t.kind == TokenKind::Number)
573            .map(|t| t.text.as_str())
574            .collect();
575        assert_eq!(numbers, vec!["2", "1"]);
576    }
577
578    #[test]
579    fn recursive_define_fails_structurally() {
580        let error = preprocess(
581            "#!define X X + 1\nrule \"r\":\n    x = X\n",
582            "main.opy",
583            Path::new("."),
584        )
585        .unwrap_err();
586        assert_eq!(error.code, "macro-recursion");
587    }
588
589    #[test]
590    fn missing_include_is_structured() {
591        let error = preprocess(
592            "#!include \"nope.opy\"\n",
593            "main.opy",
594            Path::new("/nonexistent-root"),
595        )
596        .unwrap_err();
597        assert_eq!(error.code, "include-not-found");
598        assert!(error.span.is_some());
599    }
600
601    #[test]
602    fn include_cycle_is_detected() {
603        let dir = std::env::temp_dir().join(format!("wright-opy-test-{}", std::process::id()));
604        std::fs::create_dir_all(&dir).unwrap();
605        std::fs::write(dir.join("a.opy"), "#!include \"b.opy\"\n").unwrap();
606        std::fs::write(dir.join("b.opy"), "#!include \"a.opy\"\n").unwrap();
607        let main = std::fs::read_to_string(dir.join("a.opy")).unwrap();
608        let error = preprocess(&main, "a.opy", &dir).unwrap_err();
609        assert_eq!(error.code, "include-cycle");
610        let _ = std::fs::remove_dir_all(&dir);
611    }
612
613    #[test]
614    fn unsupported_directive_is_structured() {
615        let error = preprocess("#!frobnicate\n", "main.opy", Path::new(".")).unwrap_err();
616        assert_eq!(error.code, "unsupported-directive");
617    }
618
619    #[test]
620    fn settings_block_is_extracted_before_lexing() {
621        let (pre, _) = preprocess(
622            "settings {\n    \"gamemodes\": {}\n}\nrule \"r\":\n    pass\n",
623            "main.opy",
624            Path::new("."),
625        )
626        .unwrap();
627        let block = pre.settings.expect("settings block extracted");
628        assert!(block.text.contains("gamemodes"));
629        // The block never enters the token stream.
630        assert!(
631            !pre.tokens.iter().any(|t| t.text.contains("gamemodes")),
632            "settings content must not be lexed"
633        );
634    }
635
636    #[test]
637    fn settings_in_include_is_extracted_with_source_provenance() {
638        let overlay = BTreeMap::from([(
639            "shared.opy".to_string(),
640            "settings {\n    \"gamemodes\": {}\n}\n".to_string(),
641        )]);
642        let main = "#!include \"shared.opy\"\nrule \"r\":\n    pass\n";
643        let (pre, files) = preprocess_with_overlay(main, "main.opy", Path::new("."), &overlay)
644            .expect("included settings must be extracted");
645        let block = pre.settings.expect("included settings block");
646        assert_eq!(block.keyword_span.file, 1);
647        assert_eq!(files[1].path, "shared.opy");
648        assert!(!pre.tokens.iter().any(|token| token.text == "gamemodes"));
649    }
650
651    #[test]
652    fn duplicate_include_is_skipped_without_redeclaring_macros() {
653        let overlay =
654            BTreeMap::from([("shared.opy".to_string(), "#!define VALUE 2\n".to_string())]);
655        let main =
656            "#!include \"shared.opy\"\n#!include \"shared.opy\"\nrule \"r\":\n    x = VALUE\n";
657        let (pre, files) = preprocess_with_overlay(main, "main.opy", Path::new("."), &overlay)
658            .expect("duplicate includes must not redeclare macros");
659        assert_eq!(pre.defines.len(), 1);
660        assert_eq!(files.len(), 2);
661        assert_eq!(pre.warnings.len(), 1);
662        assert_eq!(pre.warnings[0].code, "w_already_imported");
663        assert_eq!(
664            pre.preprocessing
665                .directives
666                .iter()
667                .filter(|directive| directive.name == "include")
668                .count(),
669            2
670        );
671    }
672
673    #[test]
674    fn alias_include_paths_are_distinct_imports() {
675        let overlay = BTreeMap::from([
676            ("shared.opy".to_string(), "#!define FIRST 1\n".to_string()),
677            (
678                "dir/../shared.opy".to_string(),
679                "#!define SECOND 2\n".to_string(),
680            ),
681        ]);
682        let main = "#!include \"shared.opy\"\n#!include \"dir/../shared.opy\"\nrule \"r\":\n    x = FIRST\n    y = SECOND\n";
683        let (pre, files) = preprocess_with_overlay(main, "main.opy", Path::new("."), &overlay)
684            .expect("alias include paths must remain distinct imports");
685        assert_eq!(files.len(), 3);
686        assert_eq!(files[1].path, "shared.opy");
687        assert_eq!(files[2].path, "dir/../shared.opy");
688        assert_eq!(pre.defines.len(), 2);
689        assert!(pre.warnings.is_empty());
690    }
691
692    #[test]
693    fn dict_literal_braces_reach_the_parser() {
694        // Scoped settings lexing must not consume expression-level braces.
695        let (pre, _) = preprocess(
696            "rule \"r\":\n    money += {\n        Mei.GENERIC: 10,\n    }\n",
697            "main.opy",
698            Path::new("."),
699        )
700        .unwrap();
701        assert!(
702            pre.tokens
703                .iter()
704                .any(|token| token.kind == TokenKind::LBrace)
705        );
706        assert!(
707            pre.tokens
708                .iter()
709                .any(|token| token.kind == TokenKind::RBrace)
710        );
711    }
712
713    #[test]
714    fn advanced_directives_preserve_frontend_state_without_catalog_data() {
715        let (pre, _) = preprocess(
716            "#!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",
717            "main.opy",
718            Path::new("."),
719        )
720        .unwrap();
721        assert!(pre.preprocessing.allow_macro_redeclaration);
722        assert_eq!(
723            pre.preprocessing
724                .translations
725                .as_ref()
726                .map(|state| state.languages.as_slice()),
727            Some(["en".to_string(), "fr".to_string()].as_slice())
728        );
729        assert_eq!(
730            pre.preprocessing
731                .rule_prefix
732                .as_ref()
733                .map(|value| value.value.as_str()),
734            Some("Effects")
735        );
736        assert!(pre.preprocessing.optimization.for_size);
737        assert!(pre.preprocessing.optimization.strict);
738        assert_eq!(
739            pre.preprocessing.replacements[0].value,
740            "getCapturePercentage"
741        );
742        assert_eq!(pre.defines.len(), 1);
743    }
744
745    #[test]
746    fn backend_only_directives_are_validated_and_recorded() {
747        let (pre, _) = preprocess(
748            "#!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",
749            "main.opy",
750            Path::new("."),
751        )
752        .unwrap();
753        let names: Vec<&str> = pre
754            .preprocessing
755            .directives
756            .iter()
757            .map(|directive| directive.name.as_str())
758            .collect();
759        assert_eq!(
760            names,
761            vec![
762                "excludeVariablesInCompilation",
763                "extension",
764                "setupTags",
765                "setupTx",
766                "translateWithPlayerVar",
767                "disableInspector",
768                "writeToOutputFile",
769                "disableTranslationSourceLines",
770                "keepUnusedTranslations",
771                "useVariableForCompressionAlphabet",
772                "debugElementCount",
773                "globalvarInitRuleName",
774                "playervarInitRuleName",
775            ]
776        );
777        assert_eq!(
778            pre.preprocessing.directives[1].value.as_deref(),
779            Some("projectiles")
780        );
781        assert_eq!(
782            pre.preprocessing.directives[4].value.as_deref(),
783            Some("noDetectionRule noTlErr")
784        );
785    }
786
787    #[test]
788    fn extension_directive_rejects_unknown_schema_values() {
789        let error = preprocess(
790            "#!extension notAnExtension\nrule \"r\":\n    pass\n",
791            "main.opy",
792            Path::new("."),
793        )
794        .unwrap_err();
795        assert_eq!(error.code, "directive-invalid");
796    }
797
798    #[test]
799    fn translations_follow_pinned_codes_without_local_deduplication() {
800        let (pre, _) = preprocess(
801            "#!translations EN zh-cn en\nrule \"r\":\n    pass\n",
802            "main.opy",
803            Path::new("."),
804        )
805        .unwrap();
806        assert_eq!(
807            pre.preprocessing.translations.unwrap().languages,
808            vec!["en", "zh_cn", "en"]
809        );
810    }
811
812    #[test]
813    fn translations_reject_codes_outside_the_pinned_oracle_set() {
814        let error = preprocess(
815            "#!translations en_US\nrule \"r\":\n    pass\n",
816            "main.opy",
817            Path::new("."),
818        )
819        .unwrap_err();
820        assert_eq!(error.code, "translations-invalid");
821    }
822
823    #[test]
824    fn directive_records_expose_state_transitions_and_include_depth() {
825        let root =
826            std::env::temp_dir().join(format!("wright-opy-directive-scope-{}", std::process::id()));
827        std::fs::create_dir_all(&root).unwrap();
828        std::fs::write(
829            root.join("child.opy"),
830            "#!rulePrefix \"inner\"\n#!disableOptimizations\n",
831        )
832        .unwrap();
833        let (pre, _) = preprocess(
834            "#!rulePrefix \"outer\"\n#!include \"child.opy\"\n#!enableOptimizations\n",
835            "main.opy",
836            &root,
837        )
838        .unwrap();
839        let records = &pre.preprocessing.directives;
840        assert_eq!(records[0].state.rule_prefix.as_deref(), Some("outer"));
841        assert_eq!(records[0].scope_depth, 0);
842        assert_eq!(records[1].name, "rulePrefix");
843        assert_eq!(records[1].state.rule_prefix.as_deref(), Some("inner"));
844        assert!(!records[2].state.optimization.enabled);
845        assert_eq!(records[2].scope_depth, 1);
846        assert_eq!(records[3].name, "include");
847        assert_eq!(records[3].state.rule_prefix.as_deref(), Some("outer"));
848        assert_eq!(records[4].name, "enableOptimizations");
849        assert!(records[4].state.optimization.enabled);
850        let _ = std::fs::remove_dir_all(&root);
851    }
852
853    #[test]
854    fn malformed_translation_state_is_source_located() {
855        let error = preprocess("#!translations\n", "main.opy", Path::new(".")).unwrap_err();
856        assert_eq!(error.code, "translations-invalid");
857        assert!(error.span.is_some());
858    }
859}