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