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::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 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 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 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 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 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 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}