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