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