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 TranslationEntry, 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
159impl PreprocessOutcome {
160 fn failure(error: OpyError, pre: Preprocessor) -> Self {
161 Self {
162 result: Err(error),
163 files: pre.files,
164 warnings: pre.warnings,
165 }
166 }
167}
168
169pub fn preprocess_with_overlay_outcome(
172 main_text: &str,
173 main_path: &str,
174 root: &Path,
175 overlay: &BTreeMap<String, String>,
176) -> PreprocessOutcome {
177 let resolved_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
178 let mut pre = Preprocessor {
179 files: vec![FileRecord {
180 id: 0,
181 path: main_path.to_string(),
182 }],
183 next_file_id: 1,
184 root: resolved_root.clone(),
185 display_root: resolved_root,
186 overlay: overlay.clone(),
187 source_texts: BTreeMap::from([(0, main_text.to_string())]),
188 include_stack: Vec::new(),
189 last_macro_include_path: None,
190 imported_files: BTreeSet::new(),
191 macros: Vec::new(),
192 defines: Vec::new(),
193 post_compile_hook: None,
194 settings: None,
195 warnings: Vec::new(),
196 preprocessing: PreprocessingState::default(),
197 };
198 pre.preprocessing
199 .source_file_initial_optimization
200 .insert(0, pre.preprocessing.optimization.clone());
201 let mut owned_main_text = None;
202 let mut source_file_id = 0;
203 let first_line = main_text.lines().next().unwrap_or_default();
204 if first_line.trim_start().starts_with("#!mainFile")
205 && first_main_file_directive(main_text).is_none()
206 {
207 let span = Span::new(
208 0,
209 crate::diag::Position::new(1, 1),
210 crate::diag::Position::new(1, first_line.chars().count() as u32 + 1),
211 );
212 return PreprocessOutcome::failure(
213 OpyError::at(
214 "main-file-invalid",
215 "`#!mainFile` expects one quoted path on the first line",
216 span,
217 ),
218 pre,
219 );
220 }
221 if let Some((main_file, span)) = first_main_file_directive(main_text) {
222 let candidate = pre.root.join(&main_file);
223 let canonical = std::fs::canonicalize(&candidate).ok();
224 let overlay_text = overlay
225 .get(&main_file)
226 .or_else(|| {
227 canonical
228 .as_ref()
229 .and_then(|path| overlay.get(&path.to_string_lossy().into_owned()))
230 })
231 .cloned();
232 let (text, canonical_path, new_root) = match overlay_text {
233 Some(text) => {
234 let new_root = candidate
235 .parent()
236 .map(Path::to_path_buf)
237 .unwrap_or_else(|| pre.root.clone());
238 (text, canonical, new_root)
239 }
240 None => {
241 let Some(canonical) = canonical else {
242 return PreprocessOutcome::failure(
243 OpyError::at(
244 "main-file-not-found",
245 format!("cannot find main file '{main_file}'"),
246 span,
247 ),
248 pre,
249 );
250 };
251 let text = match std::fs::read_to_string(&canonical) {
252 Ok(text) => text,
253 Err(error) => {
254 return PreprocessOutcome::failure(
255 OpyError::at(
256 "main-file-not-found",
257 format!("cannot read main file '{main_file}': {error}"),
258 span,
259 ),
260 pre,
261 );
262 }
263 };
264 let new_root = canonical
265 .parent()
266 .map(Path::to_path_buf)
267 .unwrap_or_else(|| pre.root.clone());
268 (text, Some(canonical), new_root)
269 }
270 };
271 let display_path =
272 display_path(&candidate, canonical_path.as_deref(), &new_root, &main_file);
273 source_file_id = 1;
274 pre.source_texts.insert(source_file_id, text.clone());
275 owned_main_text = Some(text);
276 pre.files.push(FileRecord {
277 id: source_file_id,
278 path: display_path,
279 });
280 pre.next_file_id = 2;
281 pre.preprocessing
282 .source_file_initial_optimization
283 .insert(source_file_id, pre.preprocessing.optimization.clone());
284 pre.root = new_root.clone();
285 pre.display_root = new_root;
286 pre.preprocessing.main_file = Some(DirectiveValue {
287 value: main_file.clone(),
288 span: Some(span.into()),
289 });
290 pre.record("mainFile", Some(&main_file), span);
291 }
292 let source_text = owned_main_text.as_deref().unwrap_or(main_text);
293 let settings = match crate::settings::find_blocks(source_text, source_file_id) {
296 Ok(mut blocks) => blocks.pop(),
297 Err(error) => return PreprocessOutcome::failure(error, pre),
298 };
299 let settings = match settings {
300 Some(block) => match pre.resolve_settings_source(block) {
301 Ok(block) => Some(block),
302 Err(error) => return PreprocessOutcome::failure(error, pre),
303 },
304 None => None,
305 };
306 let tokens = match &settings {
307 Some(block) => {
308 let sanitized = crate::settings::sanitize_for_lex(source_text, block);
309 lex(LexInput {
310 file_id: source_file_id,
311 text: &sanitized,
312 })
313 }
314 None => lex(LexInput {
315 file_id: source_file_id,
316 text: source_text,
317 }),
318 };
319 let mut tokens = match tokens {
320 Ok(tokens) => tokens,
321 Err(error) => return PreprocessOutcome::failure(error, pre),
322 };
323 if let Err(error) = pre.process_directives(&mut tokens, false, settings) {
324 return PreprocessOutcome::failure(error, pre);
325 }
326 if let Err(error) = pre.load_translation_catalog() {
327 return PreprocessOutcome::failure(error, pre);
328 }
329 let result = Ok((
330 Preprocessed {
331 tokens,
332 defines: pre.defines,
333 settings: pre.settings,
334 warnings: pre.warnings.clone(),
335 post_compile_hook: pre.post_compile_hook,
336 preprocessing: pre.preprocessing,
337 },
338 pre.files.clone(),
339 ));
340 PreprocessOutcome {
341 result,
342 files: pre.files,
343 warnings: pre.warnings,
344 }
345}
346
347fn render_tokens(tokens: &[Token]) -> String {
348 let mut rendered = String::new();
349 let mut previous: Option<&Token> = None;
350 for token in tokens {
351 if token.kind == TokenKind::Eof {
352 continue;
353 }
354 if let Some(previous) = previous
355 && can_merge_without_separator(previous.kind, token.kind)
356 {
357 rendered.push(' ');
358 }
359 if token.kind == TokenKind::Newline {
360 rendered.push('\n');
361 } else if token.kind == TokenKind::String {
362 rendered.push('"');
363 rendered.push_str(token.raw.as_deref().unwrap_or(&token.text));
364 rendered.push('"');
365 } else {
366 rendered.push_str(&token.text);
367 }
368 previous = Some(token);
369 }
370 rendered
371}
372
373fn can_merge_without_separator(previous: TokenKind, current: TokenKind) -> bool {
374 matches!(previous, TokenKind::Ident | TokenKind::Number)
375 && matches!(current, TokenKind::Ident | TokenKind::Number)
376}
377
378fn shift_settings_span(span: Span, origin: crate::diag::Position) -> Span {
379 Span::new(
380 span.file,
381 crate::diag::shift_position(span.start, origin),
382 crate::diag::shift_position(span.end, origin),
383 )
384}
385
386fn external_text_span(file: u32, text: &str) -> Span {
387 let mut line = 1;
388 let mut col = 1;
389 for character in text.chars() {
390 if character == '\n' {
391 line += 1;
392 col = 1;
393 } else {
394 col += 1;
395 }
396 }
397 Span::new(
398 file,
399 crate::diag::Position::new(1, 1),
400 crate::diag::Position::new(line, col),
401 )
402}
403
404struct Preprocessor {
405 files: Vec<FileRecord>,
406 next_file_id: u32,
407 root: PathBuf,
408 display_root: PathBuf,
409 overlay: BTreeMap<String, String>,
410 source_texts: BTreeMap<u32, String>,
411 include_stack: Vec<PathBuf>,
412 last_macro_include_path: Option<PathBuf>,
415 imported_files: BTreeSet<PathBuf>,
416 macros: Vec<MacroDef>,
417 settings: Option<SettingsBlock>,
418 defines: Vec<DefineRecord>,
419 post_compile_hook: Option<PostCompileHook>,
420 warnings: Vec<PreprocessWarning>,
421 preprocessing: PreprocessingState,
422}
423
424impl Preprocessor {
425 fn load_translation_catalog(&mut self) -> OpyResult<()> {
426 let Some(translations) = self.preprocessing.translations.as_ref() else {
427 return Ok(());
428 };
429 let languages = translations.languages.clone();
430 let source_path = self
431 .files
432 .get(1)
433 .or_else(|| self.files.first())
434 .map(|file| PathBuf::from(&file.path))
435 .unwrap_or_else(|| PathBuf::from("main.opy"));
436 let base = self.root.join(
437 source_path
438 .file_name()
439 .unwrap_or_else(|| std::ffi::OsStr::new("main.opy")),
440 );
441 let mut entries = BTreeMap::<(Option<String>, String), TranslationEntry>::new();
442 for language in languages.iter().skip(1) {
443 let po_path = base.with_extension(format!("{language}.po"));
444 let Some(text) = std::fs::read_to_string(&po_path).ok() else {
445 continue;
446 };
447 let file_id = self.register_external_file(&po_path, language, text.clone());
448 let parsed = parse_po(&text, language, Some(external_text_span(file_id, &text)))?;
449 for entry in parsed {
450 let key = (entry.context.clone(), entry.msgid.clone());
451 entries
452 .entry(key)
453 .and_modify(|existing| existing.translations.extend(entry.translations.clone()))
454 .or_insert(entry);
455 }
456 }
457 if let Some(translations) = self.preprocessing.translations.as_mut() {
458 translations.entries = entries.into_values().collect();
459 }
460 Ok(())
461 }
462
463 pub(super) fn resolve_settings_source(
466 &mut self,
467 mut block: SettingsBlock,
468 ) -> OpyResult<SettingsBlock> {
469 let Some(requested) = block.external_path.clone() else {
470 return Ok(block);
471 };
472 let requested = requested.replace('\\', "/");
473 let candidate = self.include_base().join(&requested);
474 let candidate_path = candidate.to_string_lossy().into_owned();
475 let canonical = std::fs::canonicalize(&candidate).ok();
476 let lexical_path = candidate.to_string_lossy().replace('\\', "/");
477 let content = self
478 .overlay
479 .get(&requested)
480 .or_else(|| self.overlay.get(&candidate_path))
481 .or_else(|| self.overlay.get(&lexical_path))
482 .or_else(|| {
483 canonical
484 .as_ref()
485 .and_then(|path| self.overlay.get(&path.to_string_lossy().into_owned()))
486 })
487 .cloned()
488 .or_else(|| canonical.and_then(|path| std::fs::read_to_string(path).ok()))
489 .ok_or_else(|| {
490 OpyError::at(
491 "settings-not-found",
492 format!("cannot find external settings file '{requested}'"),
493 block.keyword_span,
494 )
495 })?;
496 let file_id = self.register_external_file(&candidate, &requested, content.clone());
497 block.content_file = file_id;
498 block.text_start = crate::diag::Position::new(1, 1);
499 block.content_span = external_text_span(file_id, &content);
500 block.text = content;
501 Ok(block)
502 }
503
504 fn register_external_file(&mut self, candidate: &Path, requested: &str, text: String) -> u32 {
505 let canonical = std::fs::canonicalize(candidate).ok();
506 let file_id = self.next_file_id;
507 self.next_file_id += 1;
508 self.source_texts.insert(file_id, text);
509 self.files.push(FileRecord {
510 id: file_id,
511 path: display_path(
512 candidate,
513 canonical.as_deref(),
514 &self.display_root,
515 requested,
516 ),
517 });
518 file_id
519 }
520
521 pub(super) fn expand_settings(&self, block: SettingsBlock) -> OpyResult<SettingsBlock> {
526 let tokens = lex(LexInput {
527 file_id: block.content_file,
528 text: &block.text,
529 })?;
530 let mut tokens = tokens;
531 for token in &mut tokens {
532 token.span = shift_settings_span(token.span, block.text_start);
533 }
534 let tokens = self.expand(tokens)?;
535 Ok(SettingsBlock {
536 text: render_tokens(&tokens),
537 ..block
538 })
539 }
540}
541
542fn parse_po(text: &str, language: &str, span: Option<Span>) -> OpyResult<Vec<TranslationEntry>> {
543 let span = span.unwrap_or(Span::new(
544 0,
545 crate::diag::Position::new(1, 1),
546 crate::diag::Position::new(1, 1),
547 ));
548 let mut header_language = None;
549 let mut entries = Vec::new();
550 let mut current: Option<(Option<String>, String, String)> = None;
551 let mut field: Option<&str> = None;
552 for line in text.lines().chain(std::iter::once("")) {
553 let trimmed = line.trim();
554 if trimmed.is_empty() {
555 if let Some((context, msgid, msgstr)) = current.take()
556 && !msgid.is_empty()
557 {
558 let mut translations = BTreeMap::new();
559 translations.insert(language.to_string(), msgstr);
560 entries.push(TranslationEntry {
561 msgid,
562 context,
563 translations,
564 });
565 }
566 field = None;
567 continue;
568 }
569 if trimmed.starts_with('#') {
570 continue;
571 }
572 if trimmed.starts_with('"') {
573 let value = serde_json::from_str::<String>(trimmed).map_err(|error| {
574 OpyError::at(
575 "translations-invalid",
576 format!("malformed PO string: {error}"),
577 span,
578 )
579 })?;
580 let Some((context, msgid, msgstr)) = current.as_mut() else {
581 return Err(OpyError::at(
582 "translations-invalid",
583 "PO continuation has no preceding field",
584 span,
585 ));
586 };
587 match field {
588 Some("msgid") => msgid.push_str(&value),
589 Some("msgstr") => msgstr.push_str(&value),
590 Some("msgctxt") => context.get_or_insert_default().push_str(&value),
591 _ => {
592 return Err(OpyError::at(
593 "translations-invalid",
594 "PO continuation has no recognized field",
595 span,
596 ));
597 }
598 }
599 if msgid.is_empty() {
600 for line in msgstr.lines() {
601 if let Some(value) = line.strip_prefix("Language:") {
602 header_language = Some(value.trim().to_string());
603 }
604 }
605 }
606 continue;
607 }
608 let (name, value) = trimmed
609 .split_once(' ')
610 .ok_or_else(|| OpyError::at("translations-invalid", "malformed PO entry", span))?;
611 let value = serde_json::from_str::<String>(value).map_err(|error| {
612 OpyError::at(
613 "translations-invalid",
614 format!("malformed PO string: {error}"),
615 span,
616 )
617 })?;
618 match name {
619 "msgctxt" => {
620 if let Some((context, _, _)) = current.as_mut() {
621 *context = Some(value);
622 } else {
623 current = Some((Some(value), String::new(), String::new()));
624 }
625 field = Some("msgctxt");
626 }
627 "msgid" => {
628 if let Some((_, msgid, _)) = current.as_mut() {
629 *msgid = value;
630 } else {
631 current = Some((None, value, String::new()));
632 }
633 field = Some("msgid");
634 }
635 "msgstr" => {
636 if let Some((_, _, msgstr)) = current.as_mut() {
637 *msgstr = value;
638 } else {
639 current = Some((None, String::new(), value));
640 }
641 field = Some("msgstr");
642 }
643 _ => {
644 return Err(OpyError::at(
645 "translations-invalid",
646 format!("unsupported PO field '{name}'"),
647 span,
648 ));
649 }
650 }
651 if name == "msgstr"
652 && current
653 .as_ref()
654 .is_some_and(|(_, msgid, _)| msgid.is_empty())
655 {
656 if let Some((_, value, _)) = current.as_ref() {
657 for line in value.lines() {
658 if let Some(value) = line.strip_prefix("Language:") {
659 header_language = Some(value.trim().to_string());
660 }
661 }
662 }
663 }
664 }
665 if header_language.as_deref() != Some(language) {
666 return Err(OpyError::at(
667 "translations-invalid",
668 format!("PO language header does not match '{language}'"),
669 span,
670 ));
671 }
672 Ok(entries)
673}
674
675#[cfg(test)]
676mod tests {
677 use super::*;
678
679 #[test]
680 fn object_define_expands_at_use_site() {
681 let (pre, _) = preprocess(
682 "#!define SIDE 1.5\nrule \"r\":\n x = SIDE\n",
683 "main.opy",
684 Path::new("."),
685 )
686 .unwrap();
687 assert_eq!(pre.defines.len(), 1);
688 assert_eq!(pre.defines[0].name, "SIDE");
689 assert!(!pre.defines[0].is_function);
690 assert!(!pre.defines[0].is_member);
691 let numbers: Vec<&str> = pre
692 .tokens
693 .iter()
694 .filter(|t| t.kind == TokenKind::Number)
695 .map(|t| t.text.as_str())
696 .collect();
697 assert_eq!(numbers, vec!["1.5"]);
698 }
699
700 #[test]
701 fn function_define_substitutes_params() {
702 let (pre, _) = preprocess(
703 "#!define double(x) x + x\nrule \"r\":\n y = double(3)\n",
704 "main.opy",
705 Path::new("."),
706 )
707 .unwrap();
708 let numbers: Vec<&str> = pre
709 .tokens
710 .iter()
711 .filter(|t| t.kind == TokenKind::Number)
712 .map(|t| t.text.as_str())
713 .collect();
714 assert_eq!(numbers, vec!["3", "3"]);
715 }
716
717 #[test]
718 fn function_define_keeps_commas_inside_nested_collections() {
719 let (pre, _) = preprocess(
720 "#!define first(xs, fallback) xs[0]\nrule \"r\":\n x = first([1, 2], 3)\n",
721 "main.opy",
722 Path::new("."),
723 )
724 .unwrap();
725 let numbers: Vec<&str> = pre
726 .tokens
727 .iter()
728 .filter(|token| token.kind == TokenKind::Number)
729 .map(|token| token.text.as_str())
730 .collect();
731 assert_eq!(numbers, vec!["1", "2", "0"]);
732 }
733
734 #[test]
735 fn zero_argument_function_define_accepts_empty_invocation() {
736 let (pre, _) = preprocess(
737 "#!define value() 3\nrule \"r\":\n x = value()\n",
738 "main.opy",
739 Path::new("."),
740 )
741 .unwrap();
742 let numbers: Vec<&str> = pre
743 .tokens
744 .iter()
745 .filter(|token| token.kind == TokenKind::Number)
746 .map(|token| token.text.as_str())
747 .collect();
748 assert_eq!(numbers, vec!["3"]);
749 }
750
751 #[test]
752 fn macro_expanded_string_can_concatenate_with_following_literal() {
753 let (pre, _) = preprocess(
754 "#!define PREFIX \"one\"\nrule \"r\":\n debug(PREFIX\n \"two\")\n",
755 "main.opy",
756 Path::new("."),
757 )
758 .unwrap();
759 let output = crate::parser::parse(&pre.tokens);
760 assert!(
761 output.errors.is_empty(),
762 "unexpected errors: {:?}",
763 output.errors
764 );
765 let program = output.program.expect("expanded source must parse");
766 let crate::cst::RuleEntry::Rule(rule) = &program.rules[0] else {
767 panic!("expected rule");
768 };
769 let crate::cst::Stmt::Expr { expr, .. } = &rule.actions[0] else {
770 panic!("expected expression statement");
771 };
772 let crate::cst::Expr::Call { args, .. } = expr else {
773 panic!("expected call");
774 };
775 assert!(matches!(
776 &args[0].value,
777 crate::cst::Expr::String { value, .. } if value == "onetwo"
778 ));
779 }
780
781 #[test]
782 fn recursive_defines_expand_transitively() {
783 let (pre, _) = preprocess(
784 "#!define A 2\n#!define B A + 1\nrule \"r\":\n x = B\n",
785 "main.opy",
786 Path::new("."),
787 )
788 .unwrap();
789 let numbers: Vec<&str> = pre
790 .tokens
791 .iter()
792 .filter(|t| t.kind == TokenKind::Number)
793 .map(|t| t.text.as_str())
794 .collect();
795 assert_eq!(numbers, vec!["2", "1"]);
796 }
797
798 #[test]
799 fn recursive_define_fails_structurally() {
800 let error = preprocess(
801 "#!define X X + 1\nrule \"r\":\n x = X\n",
802 "main.opy",
803 Path::new("."),
804 )
805 .unwrap_err();
806 assert_eq!(error.code, "macro-recursion");
807 }
808
809 #[test]
810 fn defines_are_only_visible_after_their_directive() {
811 let (pre, _) = preprocess(
812 "VALUE\n#!define VALUE 1\nVALUE\n#!allowMacroRedeclaration\n#!define VALUE 2\nVALUE\n",
813 "main.opy",
814 Path::new("."),
815 )
816 .expect("ordered define expansion");
817 let numbers: Vec<&str> = pre
818 .tokens
819 .iter()
820 .filter(|token| token.kind == TokenKind::Number)
821 .map(|token| token.text.as_str())
822 .collect();
823 assert_eq!(numbers, vec!["1", "2"]);
824 assert!(pre.tokens.iter().any(|token| {
825 token.kind == TokenKind::Ident && token.text == "VALUE" && token.span.start.line == 1
826 }));
827 }
828
829 #[test]
830 fn function_define_substitutes_inside_string_text() {
831 let (pre, _) = preprocess(
832 "#!define wrap(value) \"value\"\nwrap(foo + bar)\n",
833 "main.opy",
834 Path::new("."),
835 )
836 .expect("textual define expansion");
837 let strings: Vec<&str> = pre
838 .tokens
839 .iter()
840 .filter(|token| token.kind == TokenKind::String)
841 .map(|token| token.text.as_str())
842 .collect();
843 assert_eq!(strings, vec!["foo + bar"]);
844 }
845
846 #[test]
847 fn multiline_define_preserves_relative_expansion_spans() {
848 let (pre, _) = preprocess(
849 "#!define block() A = 1\\\n A = 2\nblock()\n",
850 "main.opy",
851 Path::new("."),
852 )
853 .expect("multiline define expansion");
854 let numbers: Vec<Span> = pre
855 .tokens
856 .iter()
857 .filter(|token| token.kind == TokenKind::Number)
858 .map(|token| token.span)
859 .collect();
860 assert_eq!(numbers.len(), 2);
861 assert_eq!(numbers[0].start.line, 3);
862 assert_eq!(numbers[1].start.line, 4);
863 assert_eq!(numbers[1].start.col, 9);
864 }
865
866 #[test]
867 fn function_define_requires_a_call_with_the_expected_arity() {
868 let error = preprocess(
869 "#!define value(argument) argument + 1\nvalue\n",
870 "main.opy",
871 Path::new("."),
872 )
873 .expect_err("a function-like define used without parentheses must fail arity checking");
874 assert_eq!(error.code, "macro-arity");
875 }
876
877 #[test]
878 fn exact_object_define_self_reference_is_rejected_at_definition() {
879 let error = preprocess("#!define VALUE VALUE\n", "main.opy", Path::new("."))
880 .expect_err("an exact object define self-reference must fail at definition");
881 assert_eq!(error.code, "macro-recursion");
882
883 let error = preprocess(
884 "#!define value(argument) value(argument)\n",
885 "main.opy",
886 Path::new("."),
887 )
888 .expect_err("an exact function define self-reference must fail at definition");
889 assert_eq!(error.code, "macro-recursion");
890 }
891
892 #[test]
893 fn missing_include_is_structured() {
894 let error = preprocess(
895 "#!include \"nope.opy\"\n",
896 "main.opy",
897 Path::new("/nonexistent-root"),
898 )
899 .unwrap_err();
900 assert_eq!(error.code, "include-not-found");
901 assert!(error.span.is_some());
902 }
903
904 #[test]
905 fn include_cycle_is_detected() {
906 let dir = std::env::temp_dir().join(format!("wright-opy-test-{}", std::process::id()));
907 std::fs::create_dir_all(&dir).unwrap();
908 std::fs::write(dir.join("a.opy"), "#!include \"b.opy\"\n").unwrap();
909 std::fs::write(dir.join("b.opy"), "#!include \"a.opy\"\n").unwrap();
910 let main = std::fs::read_to_string(dir.join("a.opy")).unwrap();
911 let error = preprocess(&main, "a.opy", &dir).unwrap_err();
912 assert_eq!(error.code, "include-cycle");
913 let _ = std::fs::remove_dir_all(&dir);
914 }
915
916 #[test]
917 fn unsupported_directive_is_structured() {
918 let error = preprocess("#!frobnicate\n", "main.opy", Path::new(".")).unwrap_err();
919 assert_eq!(error.code, "unsupported-directive");
920 }
921
922 #[test]
923 fn settings_block_is_extracted_before_lexing() {
924 let (pre, _) = preprocess(
925 "settings {\n \"gamemodes\": {}\n}\nrule \"r\":\n pass\n",
926 "main.opy",
927 Path::new("."),
928 )
929 .unwrap();
930 let block = pre.settings.expect("settings block extracted");
931 assert!(block.text.contains("gamemodes"));
932 assert!(
934 !pre.tokens.iter().any(|t| t.text.contains("gamemodes")),
935 "settings content must not be lexed"
936 );
937 }
938
939 #[test]
940 fn external_settings_file_is_loaded_before_lexing() {
941 let overlay = BTreeMap::from([(
942 "settings.opy.json".to_string(),
943 "{\n \"gamemodes\": {},\n \"lobby\": {\"maxPlayers\": 12}\n}\n".to_string(),
944 )]);
945 let (pre, _) = preprocess_with_overlay(
946 "settings \"settings.opy.json\"\nrule \"r\":\n pass\n",
947 "main.opy",
948 Path::new("."),
949 &overlay,
950 )
951 .expect("external settings should load");
952 let block = pre.settings.expect("external settings block");
953 assert_eq!(block.external_path.as_deref(), Some("settings.opy.json"));
954 assert!(block.text.contains("maxPlayers"));
955 let parsed = crate::settings::parse_block(&block).expect("external JSONC object");
956 assert_eq!(parsed.children.len(), 2);
957 }
958
959 #[test]
960 fn settings_in_include_is_extracted_with_source_provenance() {
961 let overlay = BTreeMap::from([(
962 "shared.opy".to_string(),
963 "settings {\n \"gamemodes\": {}\n}\n".to_string(),
964 )]);
965 let main = "#!include \"shared.opy\"\nrule \"r\":\n pass\n";
966 let (pre, files) = preprocess_with_overlay(main, "main.opy", Path::new("."), &overlay)
967 .expect("included settings must be extracted");
968 let block = pre.settings.expect("included settings block");
969 assert_eq!(block.keyword_span.file, 1);
970 assert_eq!(files[1].path, "shared.opy");
971 assert!(!pre.tokens.iter().any(|token| token.text == "gamemodes"));
972 }
973
974 #[test]
975 fn duplicate_include_is_skipped_without_redeclaring_macros() {
976 let overlay =
977 BTreeMap::from([("shared.opy".to_string(), "#!define VALUE 2\n".to_string())]);
978 let main =
979 "#!include \"shared.opy\"\n#!include \"shared.opy\"\nrule \"r\":\n x = VALUE\n";
980 let (pre, files) = preprocess_with_overlay(main, "main.opy", Path::new("."), &overlay)
981 .expect("duplicate includes must not redeclare macros");
982 assert_eq!(pre.defines.len(), 1);
983 assert_eq!(files.len(), 2);
984 assert_eq!(pre.warnings.len(), 1);
985 assert_eq!(pre.warnings[0].code, "w_already_imported");
986 assert_eq!(
987 pre.preprocessing
988 .directives
989 .iter()
990 .filter(|directive| directive.name == "include")
991 .count(),
992 2
993 );
994 }
995
996 #[test]
997 fn alias_include_paths_are_distinct_imports() {
998 let overlay = BTreeMap::from([
999 ("shared.opy".to_string(), "#!define FIRST 1\n".to_string()),
1000 (
1001 "dir/../shared.opy".to_string(),
1002 "#!define SECOND 2\n".to_string(),
1003 ),
1004 ]);
1005 let main = "#!include \"shared.opy\"\n#!include \"dir/../shared.opy\"\nrule \"r\":\n x = FIRST\n y = SECOND\n";
1006 let (pre, files) = preprocess_with_overlay(main, "main.opy", Path::new("."), &overlay)
1007 .expect("alias include paths must remain distinct imports");
1008 assert_eq!(files.len(), 3);
1009 assert_eq!(files[1].path, "shared.opy");
1010 assert_eq!(files[2].path, "dir/../shared.opy");
1011 assert_eq!(pre.defines.len(), 2);
1012 assert!(pre.warnings.is_empty());
1013 }
1014
1015 #[test]
1016 fn dict_literal_braces_reach_the_parser() {
1017 let (pre, _) = preprocess(
1019 "rule \"r\":\n money += {\n Mei.GENERIC: 10,\n }\n",
1020 "main.opy",
1021 Path::new("."),
1022 )
1023 .unwrap();
1024 assert!(
1025 pre.tokens
1026 .iter()
1027 .any(|token| token.kind == TokenKind::LBrace)
1028 );
1029 assert!(
1030 pre.tokens
1031 .iter()
1032 .any(|token| token.kind == TokenKind::RBrace)
1033 );
1034 }
1035
1036 #[test]
1037 fn advanced_directives_preserve_frontend_state_without_catalog_data() {
1038 let (pre, _) = preprocess(
1039 "#!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",
1040 "main.opy",
1041 Path::new("."),
1042 )
1043 .unwrap();
1044 assert!(pre.preprocessing.allow_macro_redeclaration);
1045 assert_eq!(
1046 pre.preprocessing
1047 .translations
1048 .as_ref()
1049 .map(|state| state.languages.as_slice()),
1050 Some(["en".to_string(), "fr".to_string()].as_slice())
1051 );
1052 assert_eq!(
1053 pre.preprocessing
1054 .rule_prefix
1055 .as_ref()
1056 .map(|value| value.value.as_str()),
1057 Some("Effects")
1058 );
1059 assert!(pre.preprocessing.optimization.for_size);
1060 assert!(pre.preprocessing.optimization.strict);
1061 assert_eq!(
1062 pre.preprocessing.replacements[0].value,
1063 "getCapturePercentage"
1064 );
1065 assert_eq!(pre.defines.len(), 1);
1066 }
1067
1068 #[test]
1069 fn backend_only_directives_are_validated_and_recorded() {
1070 let (pre, _) = preprocess(
1071 "#!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",
1072 "main.opy",
1073 Path::new("."),
1074 )
1075 .unwrap();
1076 let names: Vec<&str> = pre
1077 .preprocessing
1078 .directives
1079 .iter()
1080 .map(|directive| directive.name.as_str())
1081 .collect();
1082 assert_eq!(
1083 names,
1084 vec![
1085 "excludeVariablesInCompilation",
1086 "extension",
1087 "setupTags",
1088 "setupTx",
1089 "translateWithPlayerVar",
1090 "disableInspector",
1091 "writeToOutputFile",
1092 "disableTranslationSourceLines",
1093 "keepUnusedTranslations",
1094 "useVariableForCompressionAlphabet",
1095 "debugElementCount",
1096 "globalvarInitRuleName",
1097 "playervarInitRuleName",
1098 ]
1099 );
1100 assert_eq!(
1101 pre.preprocessing.directives[1].value.as_deref(),
1102 Some("projectiles")
1103 );
1104 assert_eq!(
1105 pre.preprocessing.directives[4].value.as_deref(),
1106 Some("noDetectionRule noTlErr")
1107 );
1108 }
1109
1110 #[test]
1111 fn extension_directive_rejects_unknown_schema_values() {
1112 let error = preprocess(
1113 "#!extension notAnExtension\nrule \"r\":\n pass\n",
1114 "main.opy",
1115 Path::new("."),
1116 )
1117 .unwrap_err();
1118 assert_eq!(error.code, "directive-invalid");
1119 assert_eq!(error.span.expect("directive provenance").start.line, 1);
1120 }
1121
1122 #[test]
1123 fn translations_follow_pinned_codes_without_local_deduplication() {
1124 let (pre, _) = preprocess(
1125 "#!translations EN zh-cn en\nrule \"r\":\n pass\n",
1126 "main.opy",
1127 Path::new("."),
1128 )
1129 .unwrap();
1130 assert_eq!(
1131 pre.preprocessing.translations.unwrap().languages,
1132 vec!["en", "zh_cn", "en"]
1133 );
1134 }
1135
1136 #[test]
1137 fn translations_reject_codes_outside_the_pinned_oracle_set() {
1138 let error = preprocess(
1139 "#!translations en_US\nrule \"r\":\n pass\n",
1140 "main.opy",
1141 Path::new("."),
1142 )
1143 .unwrap_err();
1144 assert_eq!(error.code, "translations-invalid");
1145 }
1146
1147 #[test]
1148 fn directive_records_expose_state_transitions_and_include_depth() {
1149 let root =
1150 std::env::temp_dir().join(format!("wright-opy-directive-scope-{}", std::process::id()));
1151 std::fs::create_dir_all(&root).unwrap();
1152 std::fs::write(
1153 root.join("child.opy"),
1154 "#!rulePrefix \"inner\"\n#!disableOptimizations\n",
1155 )
1156 .unwrap();
1157 let (pre, _) = preprocess(
1158 "#!rulePrefix \"outer\"\n#!include \"child.opy\"\n#!enableOptimizations\n",
1159 "main.opy",
1160 &root,
1161 )
1162 .unwrap();
1163 let records = &pre.preprocessing.directives;
1164 assert_eq!(records[0].state.rule_prefix.as_deref(), Some("outer"));
1165 assert_eq!(records[0].scope_depth, 0);
1166 assert_eq!(records[1].name, "rulePrefix");
1167 assert_eq!(records[1].state.rule_prefix.as_deref(), Some("inner"));
1168 assert!(!records[2].state.optimization.enabled);
1169 assert_eq!(records[2].scope_depth, 1);
1170 assert_eq!(records[3].name, "include");
1171 assert_eq!(records[3].state.rule_prefix.as_deref(), Some("outer"));
1172 assert_eq!(records[4].name, "enableOptimizations");
1173 assert!(records[4].state.optimization.enabled);
1174 let _ = std::fs::remove_dir_all(&root);
1175 }
1176
1177 #[test]
1178 fn malformed_translation_state_is_source_located() {
1179 let error = preprocess("#!translations\n", "main.opy", Path::new(".")).unwrap_err();
1180 assert_eq!(error.code, "translations-invalid");
1181 assert!(error.span.is_some());
1182 }
1183}