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