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