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