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 Ok(tokens) = lex(LexInput {
569 file_id: block.content_file,
570 text: &block.text,
571 }) else {
572 return Ok(block);
573 };
574 let mut tokens = tokens;
575 for token in &mut tokens {
576 token.span = shift_settings_span(token.span, block.text_start);
577 token.layout = token.span;
578 }
579 let tokens = self.expand(tokens)?;
580 Ok(SettingsBlock {
581 text: render_tokens(&tokens),
582 ..block
583 })
584 }
585}
586
587fn parse_po(text: &str, language: &str, span: Option<Span>) -> OpyResult<Vec<TranslationEntry>> {
588 let span = span.unwrap_or(Span::new(
589 0,
590 crate::diag::Position::new(1, 1),
591 crate::diag::Position::new(1, 1),
592 ));
593 let mut header_language = None;
594 let mut entries = Vec::new();
595 let mut current: Option<(Option<String>, String, String)> = None;
596 let mut field: Option<&str> = None;
597 for line in text.lines().chain(std::iter::once("")) {
598 let trimmed = line.trim();
599 if trimmed.is_empty() {
600 if let Some((context, msgid, msgstr)) = current.take()
601 && !msgid.is_empty()
602 {
603 let mut translations = BTreeMap::new();
604 translations.insert(language.to_string(), msgstr);
605 entries.push(TranslationEntry {
606 msgid,
607 context,
608 translations,
609 });
610 }
611 field = None;
612 continue;
613 }
614 if trimmed.starts_with('#') {
615 continue;
616 }
617 if trimmed.starts_with('"') {
618 let value = serde_json::from_str::<String>(trimmed).map_err(|error| {
619 OpyError::at(
620 "translations-invalid",
621 format!("malformed PO string: {error}"),
622 span,
623 )
624 })?;
625 let Some((context, msgid, msgstr)) = current.as_mut() else {
626 return Err(OpyError::at(
627 "translations-invalid",
628 "PO continuation has no preceding field",
629 span,
630 ));
631 };
632 match field {
633 Some("msgid") => msgid.push_str(&value),
634 Some("msgstr") => msgstr.push_str(&value),
635 Some("msgctxt") => context.get_or_insert_default().push_str(&value),
636 _ => {
637 return Err(OpyError::at(
638 "translations-invalid",
639 "PO continuation has no recognized field",
640 span,
641 ));
642 }
643 }
644 if msgid.is_empty() {
645 for line in msgstr.lines() {
646 if let Some(value) = line.strip_prefix("Language:") {
647 header_language = Some(value.trim().to_string());
648 }
649 }
650 }
651 continue;
652 }
653 let (name, value) = trimmed
654 .split_once(' ')
655 .ok_or_else(|| OpyError::at("translations-invalid", "malformed PO entry", span))?;
656 let value = serde_json::from_str::<String>(value).map_err(|error| {
657 OpyError::at(
658 "translations-invalid",
659 format!("malformed PO string: {error}"),
660 span,
661 )
662 })?;
663 match name {
664 "msgctxt" => {
665 if let Some((context, _, _)) = current.as_mut() {
666 *context = Some(value);
667 } else {
668 current = Some((Some(value), String::new(), String::new()));
669 }
670 field = Some("msgctxt");
671 }
672 "msgid" => {
673 if let Some((_, msgid, _)) = current.as_mut() {
674 *msgid = value;
675 } else {
676 current = Some((None, value, String::new()));
677 }
678 field = Some("msgid");
679 }
680 "msgstr" => {
681 if let Some((_, _, msgstr)) = current.as_mut() {
682 *msgstr = value;
683 } else {
684 current = Some((None, String::new(), value));
685 }
686 field = Some("msgstr");
687 }
688 _ => {
689 return Err(OpyError::at(
690 "translations-invalid",
691 format!("unsupported PO field '{name}'"),
692 span,
693 ));
694 }
695 }
696 if name == "msgstr"
697 && current
698 .as_ref()
699 .is_some_and(|(_, msgid, _)| msgid.is_empty())
700 {
701 if let Some((_, value, _)) = current.as_ref() {
702 for line in value.lines() {
703 if let Some(value) = line.strip_prefix("Language:") {
704 header_language = Some(value.trim().to_string());
705 }
706 }
707 }
708 }
709 }
710 if header_language.as_deref() != Some(language) {
711 return Err(OpyError::at(
712 "translations-invalid",
713 format!("PO language header does not match '{language}'"),
714 span,
715 ));
716 }
717 Ok(entries)
718}
719
720#[cfg(test)]
721mod tests {
722 use super::*;
723
724 #[test]
725 fn object_define_expands_at_use_site() {
726 let (pre, _) = preprocess(
727 "#!define SIDE 1.5\nrule \"r\":\n x = SIDE\n",
728 "main.opy",
729 Path::new("."),
730 )
731 .unwrap();
732 assert_eq!(pre.defines.len(), 1);
733 assert_eq!(pre.defines[0].name, "SIDE");
734 assert!(!pre.defines[0].is_function);
735 assert!(!pre.defines[0].is_member);
736 let numbers: Vec<&str> = pre
737 .tokens
738 .iter()
739 .filter(|t| t.kind == TokenKind::Number)
740 .map(|t| t.text.as_str())
741 .collect();
742 assert_eq!(numbers, vec!["1.5"]);
743 }
744
745 #[test]
746 fn expanded_tokens_carry_the_use_site_span() {
747 let (pre, _) = preprocess(
751 "#!define T a_much_wider_replacement + another_token\nrule \"r\":\n x = T\n",
752 "main.opy",
753 Path::new("."),
754 )
755 .unwrap();
756 let use_site = pre
757 .tokens
758 .iter()
759 .find(|token| token.text == "a_much_wider_replacement")
760 .expect("expanded token")
761 .span;
762 for token in pre.tokens.iter().filter(|token| {
763 matches!(
764 token.text.as_str(),
765 "a_much_wider_replacement" | "+" | "another_token"
766 )
767 }) {
768 assert_eq!(
769 token.span, use_site,
770 "{} must attribute to the use site",
771 token.text
772 );
773 }
774 assert_eq!(use_site.start, crate::diag::Position::new(3, 9));
777 assert_eq!(use_site.end, crate::diag::Position::new(3, 10));
778 }
779
780 #[test]
781 fn function_define_substitutes_params() {
782 let (pre, _) = preprocess(
783 "#!define double(x) x + x\nrule \"r\":\n y = double(3)\n",
784 "main.opy",
785 Path::new("."),
786 )
787 .unwrap();
788 let numbers: Vec<&str> = pre
789 .tokens
790 .iter()
791 .filter(|t| t.kind == TokenKind::Number)
792 .map(|t| t.text.as_str())
793 .collect();
794 assert_eq!(numbers, vec!["3", "3"]);
795 }
796
797 #[test]
798 fn function_define_keeps_commas_inside_nested_collections() {
799 let (pre, _) = preprocess(
800 "#!define first(xs, fallback) xs[0]\nrule \"r\":\n x = first([1, 2], 3)\n",
801 "main.opy",
802 Path::new("."),
803 )
804 .unwrap();
805 let numbers: Vec<&str> = pre
806 .tokens
807 .iter()
808 .filter(|token| token.kind == TokenKind::Number)
809 .map(|token| token.text.as_str())
810 .collect();
811 assert_eq!(numbers, vec!["1", "2", "0"]);
812 }
813
814 #[test]
815 fn zero_argument_function_define_accepts_empty_invocation() {
816 let (pre, _) = preprocess(
817 "#!define value() 3\nrule \"r\":\n x = value()\n",
818 "main.opy",
819 Path::new("."),
820 )
821 .unwrap();
822 let numbers: Vec<&str> = pre
823 .tokens
824 .iter()
825 .filter(|token| token.kind == TokenKind::Number)
826 .map(|token| token.text.as_str())
827 .collect();
828 assert_eq!(numbers, vec!["3"]);
829 }
830
831 #[test]
832 fn macro_expanded_string_can_concatenate_with_following_literal() {
833 let (pre, _) = preprocess(
834 "#!define PREFIX \"one\"\nrule \"r\":\n debug(PREFIX\n \"two\")\n",
835 "main.opy",
836 Path::new("."),
837 )
838 .unwrap();
839 let output = crate::parser::parse(&pre.tokens);
840 assert!(
841 output.errors.is_empty(),
842 "unexpected errors: {:?}",
843 output.errors
844 );
845 let program = output.program.expect("expanded source must parse");
846 let crate::cst::RuleEntry::Rule(rule) = &program.rules[0] else {
847 panic!("expected rule");
848 };
849 let crate::cst::Stmt::Expr { expr, .. } = &rule.actions[0] else {
850 panic!("expected expression statement");
851 };
852 let crate::cst::Expr::Call { args, .. } = expr else {
853 panic!("expected call");
854 };
855 assert!(matches!(
856 &args[0].value,
857 crate::cst::Expr::String { value, .. } if value == "onetwo"
858 ));
859 }
860
861 #[test]
862 fn recursive_defines_expand_transitively() {
863 let (pre, _) = preprocess(
864 "#!define A 2\n#!define B A + 1\nrule \"r\":\n x = B\n",
865 "main.opy",
866 Path::new("."),
867 )
868 .unwrap();
869 let numbers: Vec<&str> = pre
870 .tokens
871 .iter()
872 .filter(|t| t.kind == TokenKind::Number)
873 .map(|t| t.text.as_str())
874 .collect();
875 assert_eq!(numbers, vec!["2", "1"]);
876 }
877
878 #[test]
879 fn recursive_define_fails_structurally() {
880 let error = preprocess(
881 "#!define X X + 1\nrule \"r\":\n x = X\n",
882 "main.opy",
883 Path::new("."),
884 )
885 .unwrap_err();
886 assert_eq!(error.code, "macro-recursion");
887 }
888
889 #[test]
890 fn defines_are_only_visible_after_their_directive() {
891 let (pre, _) = preprocess(
892 "VALUE\n#!define VALUE 1\nVALUE\n#!allowMacroRedeclaration\n#!define VALUE 2\nVALUE\n",
893 "main.opy",
894 Path::new("."),
895 )
896 .expect("ordered define expansion");
897 let numbers: Vec<&str> = pre
898 .tokens
899 .iter()
900 .filter(|token| token.kind == TokenKind::Number)
901 .map(|token| token.text.as_str())
902 .collect();
903 assert_eq!(numbers, vec!["1", "2"]);
904 assert!(pre.tokens.iter().any(|token| {
905 token.kind == TokenKind::Ident && token.text == "VALUE" && token.span.start.line == 1
906 }));
907 }
908
909 #[test]
910 fn function_define_substitutes_inside_string_text() {
911 let (pre, _) = preprocess(
912 "#!define wrap(value) \"value\"\nwrap(foo + bar)\n",
913 "main.opy",
914 Path::new("."),
915 )
916 .expect("textual define expansion");
917 let strings: Vec<&str> = pre
918 .tokens
919 .iter()
920 .filter(|token| token.kind == TokenKind::String)
921 .map(|token| token.text.as_str())
922 .collect();
923 assert_eq!(strings, vec!["foo + bar"]);
924 }
925
926 #[test]
927 fn multiline_define_expanded_tokens_attribute_to_the_use_site() {
928 let (pre, _) = preprocess(
932 "#!define block() A = 1\\\n A = 2\nblock()\n",
933 "main.opy",
934 Path::new("."),
935 )
936 .expect("multiline define expansion");
937 let expected = Span::new(
938 0,
939 crate::diag::Position::new(3, 1),
940 crate::diag::Position::new(3, 6),
941 );
942 let numbers: Vec<Span> = pre
943 .tokens
944 .iter()
945 .filter(|token| token.kind == TokenKind::Number)
946 .map(|token| token.span)
947 .collect();
948 assert_eq!(numbers, vec![expected; 2]);
949 }
950
951 #[test]
952 fn function_define_requires_a_call_with_the_expected_arity() {
953 let error = preprocess(
954 "#!define value(argument) argument + 1\nvalue\n",
955 "main.opy",
956 Path::new("."),
957 )
958 .expect_err("a function-like define used without parentheses must fail arity checking");
959 assert_eq!(error.code, "macro-arity");
960 }
961
962 #[test]
963 fn exact_object_define_self_reference_is_rejected_at_definition() {
964 let error = preprocess("#!define VALUE VALUE\n", "main.opy", Path::new("."))
965 .expect_err("an exact object define self-reference must fail at definition");
966 assert_eq!(error.code, "macro-recursion");
967
968 let error = preprocess(
969 "#!define value(argument) value(argument)\n",
970 "main.opy",
971 Path::new("."),
972 )
973 .expect_err("an exact function define self-reference must fail at definition");
974 assert_eq!(error.code, "macro-recursion");
975 }
976
977 #[test]
978 fn missing_include_is_structured() {
979 let error = preprocess(
980 "#!include \"nope.opy\"\n",
981 "main.opy",
982 Path::new("/nonexistent-root"),
983 )
984 .unwrap_err();
985 assert_eq!(error.code, "include-not-found");
986 assert!(error.span.is_some());
987 }
988
989 #[test]
990 fn include_cycle_is_detected() {
991 let dir = std::env::temp_dir().join(format!("wright-opy-test-{}", std::process::id()));
992 std::fs::create_dir_all(&dir).unwrap();
993 std::fs::write(dir.join("a.opy"), "#!include \"b.opy\"\n").unwrap();
994 std::fs::write(dir.join("b.opy"), "#!include \"a.opy\"\n").unwrap();
995 let main = std::fs::read_to_string(dir.join("a.opy")).unwrap();
996 let error = preprocess(&main, "a.opy", &dir).unwrap_err();
997 assert_eq!(error.code, "include-cycle");
998 let _ = std::fs::remove_dir_all(&dir);
999 }
1000
1001 #[test]
1002 fn unsupported_directive_is_structured() {
1003 let error = preprocess("#!frobnicate\n", "main.opy", Path::new(".")).unwrap_err();
1004 assert_eq!(error.code, "unsupported-directive");
1005 }
1006
1007 #[test]
1008 fn settings_block_is_extracted_before_lexing() {
1009 let (pre, _) = preprocess(
1010 "settings {\n \"gamemodes\": {}\n}\nrule \"r\":\n pass\n",
1011 "main.opy",
1012 Path::new("."),
1013 )
1014 .unwrap();
1015 let block = pre.settings.expect("settings block extracted");
1016 assert!(block.text.contains("gamemodes"));
1017 assert!(
1019 !pre.tokens.iter().any(|t| t.text.contains("gamemodes")),
1020 "settings content must not be lexed"
1021 );
1022 }
1023
1024 #[test]
1025 fn external_settings_file_is_loaded_before_lexing() {
1026 let overlay = BTreeMap::from([(
1027 "settings.opy.json".to_string(),
1028 "{\n \"gamemodes\": {},\n \"lobby\": {\"maxPlayers\": 12}\n}\n".to_string(),
1029 )]);
1030 let (pre, _) = preprocess_with_overlay(
1031 "settings \"settings.opy.json\"\nrule \"r\":\n pass\n",
1032 "main.opy",
1033 Path::new("."),
1034 &overlay,
1035 )
1036 .expect("external settings should load");
1037 let block = pre.settings.expect("external settings block");
1038 assert_eq!(block.external_path.as_deref(), Some("settings.opy.json"));
1039 assert!(block.text.contains("maxPlayers"));
1040 let parsed = crate::settings::parse_block(&block).expect("external JSONC object");
1041 assert_eq!(parsed.children.len(), 2);
1042 }
1043
1044 #[test]
1045 fn settings_in_include_is_extracted_with_source_provenance() {
1046 let overlay = BTreeMap::from([(
1047 "shared.opy".to_string(),
1048 "settings {\n \"gamemodes\": {}\n}\n".to_string(),
1049 )]);
1050 let main = "#!include \"shared.opy\"\nrule \"r\":\n pass\n";
1051 let (pre, files) = preprocess_with_overlay(main, "main.opy", Path::new("."), &overlay)
1052 .expect("included settings must be extracted");
1053 let block = pre.settings.expect("included settings block");
1054 assert_eq!(block.keyword_span.file, 1);
1055 assert_eq!(files[1].path, "shared.opy");
1056 assert!(!pre.tokens.iter().any(|token| token.text == "gamemodes"));
1057 }
1058
1059 #[test]
1060 fn duplicate_include_is_skipped_without_redeclaring_macros() {
1061 let overlay =
1062 BTreeMap::from([("shared.opy".to_string(), "#!define VALUE 2\n".to_string())]);
1063 let main =
1064 "#!include \"shared.opy\"\n#!include \"shared.opy\"\nrule \"r\":\n x = VALUE\n";
1065 let (pre, files) = preprocess_with_overlay(main, "main.opy", Path::new("."), &overlay)
1066 .expect("duplicate includes must not redeclare macros");
1067 assert_eq!(pre.defines.len(), 1);
1068 assert_eq!(files.len(), 2);
1069 assert_eq!(pre.warnings.len(), 1);
1070 assert_eq!(pre.warnings[0].code, "w_already_imported");
1071 assert_eq!(
1072 pre.preprocessing
1073 .directives
1074 .iter()
1075 .filter(|directive| directive.name == "include")
1076 .count(),
1077 2
1078 );
1079 }
1080
1081 #[test]
1082 fn alias_include_paths_are_distinct_imports() {
1083 let overlay = BTreeMap::from([
1084 ("shared.opy".to_string(), "#!define FIRST 1\n".to_string()),
1085 (
1086 "dir/../shared.opy".to_string(),
1087 "#!define SECOND 2\n".to_string(),
1088 ),
1089 ]);
1090 let main = "#!include \"shared.opy\"\n#!include \"dir/../shared.opy\"\nrule \"r\":\n x = FIRST\n y = SECOND\n";
1091 let (pre, files) = preprocess_with_overlay(main, "main.opy", Path::new("."), &overlay)
1092 .expect("alias include paths must remain distinct imports");
1093 assert_eq!(files.len(), 3);
1094 assert_eq!(files[1].path, "shared.opy");
1095 assert_eq!(files[2].path, "dir/../shared.opy");
1096 assert_eq!(pre.defines.len(), 2);
1097 assert!(pre.warnings.is_empty());
1098 }
1099
1100 #[test]
1101 fn dict_literal_braces_reach_the_parser() {
1102 let (pre, _) = preprocess(
1104 "rule \"r\":\n money += {\n Mei.GENERIC: 10,\n }\n",
1105 "main.opy",
1106 Path::new("."),
1107 )
1108 .unwrap();
1109 assert!(
1110 pre.tokens
1111 .iter()
1112 .any(|token| token.kind == TokenKind::LBrace)
1113 );
1114 assert!(
1115 pre.tokens
1116 .iter()
1117 .any(|token| token.kind == TokenKind::RBrace)
1118 );
1119 }
1120
1121 #[test]
1122 fn advanced_directives_preserve_frontend_state_without_catalog_data() {
1123 let (pre, _) = preprocess(
1124 "#!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",
1125 "main.opy",
1126 Path::new("."),
1127 )
1128 .unwrap();
1129 assert!(pre.preprocessing.allow_macro_redeclaration);
1130 assert_eq!(
1131 pre.preprocessing
1132 .translations
1133 .as_ref()
1134 .map(|state| state.languages.as_slice()),
1135 Some(["en".to_string(), "fr".to_string()].as_slice())
1136 );
1137 assert_eq!(
1138 pre.preprocessing
1139 .rule_prefix
1140 .as_ref()
1141 .map(|value| value.value.as_str()),
1142 Some("Effects")
1143 );
1144 assert!(pre.preprocessing.optimization.for_size);
1145 assert!(pre.preprocessing.optimization.strict);
1146 assert_eq!(
1147 pre.preprocessing.replacements[0].value,
1148 "getCapturePercentage"
1149 );
1150 assert_eq!(pre.defines.len(), 1);
1151 }
1152
1153 #[test]
1154 fn backend_only_directives_are_validated_and_recorded() {
1155 let (pre, _) = preprocess(
1156 "#!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",
1157 "main.opy",
1158 Path::new("."),
1159 )
1160 .unwrap();
1161 let names: Vec<&str> = pre
1162 .preprocessing
1163 .directives
1164 .iter()
1165 .map(|directive| directive.name.as_str())
1166 .collect();
1167 assert_eq!(
1168 names,
1169 vec![
1170 "excludeVariablesInCompilation",
1171 "extension",
1172 "setupTags",
1173 "setupTx",
1174 "translateWithPlayerVar",
1175 "disableInspector",
1176 "writeToOutputFile",
1177 "disableTranslationSourceLines",
1178 "keepUnusedTranslations",
1179 "useVariableForCompressionAlphabet",
1180 "debugElementCount",
1181 "globalvarInitRuleName",
1182 "playervarInitRuleName",
1183 ]
1184 );
1185 assert_eq!(
1186 pre.preprocessing.directives[1].value.as_deref(),
1187 Some("projectiles")
1188 );
1189 assert_eq!(
1190 pre.preprocessing.directives[4].value.as_deref(),
1191 Some("noDetectionRule noTlErr")
1192 );
1193 }
1194
1195 #[test]
1196 fn extension_directive_rejects_unknown_schema_values() {
1197 let error = preprocess(
1198 "#!extension notAnExtension\nrule \"r\":\n pass\n",
1199 "main.opy",
1200 Path::new("."),
1201 )
1202 .unwrap_err();
1203 assert_eq!(error.code, "directive-invalid");
1204 assert_eq!(error.span.expect("directive provenance").start.line, 1);
1205 }
1206
1207 #[test]
1208 fn translations_follow_pinned_codes_without_local_deduplication() {
1209 let (pre, _) = preprocess(
1210 "#!translations EN zh-cn en\nrule \"r\":\n pass\n",
1211 "main.opy",
1212 Path::new("."),
1213 )
1214 .unwrap();
1215 assert_eq!(
1216 pre.preprocessing.translations.unwrap().languages,
1217 vec!["en", "zh_cn", "en"]
1218 );
1219 }
1220
1221 #[test]
1222 fn translations_reject_codes_outside_the_pinned_oracle_set() {
1223 let error = preprocess(
1224 "#!translations en_US\nrule \"r\":\n pass\n",
1225 "main.opy",
1226 Path::new("."),
1227 )
1228 .unwrap_err();
1229 assert_eq!(error.code, "translations-invalid");
1230 }
1231
1232 #[test]
1233 fn directive_records_expose_state_transitions_and_include_depth() {
1234 let root =
1235 std::env::temp_dir().join(format!("wright-opy-directive-scope-{}", std::process::id()));
1236 std::fs::create_dir_all(&root).unwrap();
1237 std::fs::write(
1238 root.join("child.opy"),
1239 "#!rulePrefix \"inner\"\n#!disableOptimizations\n",
1240 )
1241 .unwrap();
1242 let (pre, _) = preprocess(
1243 "#!rulePrefix \"outer\"\n#!include \"child.opy\"\n#!enableOptimizations\n",
1244 "main.opy",
1245 &root,
1246 )
1247 .unwrap();
1248 let records = &pre.preprocessing.directives;
1249 assert_eq!(records[0].state.rule_prefix.as_deref(), Some("outer"));
1250 assert_eq!(records[0].scope_depth, 0);
1251 assert_eq!(records[1].name, "rulePrefix");
1252 assert_eq!(records[1].state.rule_prefix.as_deref(), Some("inner"));
1253 assert!(!records[2].state.optimization.enabled);
1254 assert_eq!(records[2].scope_depth, 1);
1255 assert_eq!(records[3].name, "include");
1256 assert_eq!(records[3].state.rule_prefix.as_deref(), Some("outer"));
1257 assert_eq!(records[4].name, "enableOptimizations");
1258 assert!(records[4].state.optimization.enabled);
1259 let _ = std::fs::remove_dir_all(&root);
1260 }
1261
1262 #[test]
1263 fn malformed_translation_state_is_source_located() {
1264 let error = preprocess("#!translations\n", "main.opy", Path::new(".")).unwrap_err();
1265 assert_eq!(error.code, "translations-invalid");
1266 assert!(error.span.is_some());
1267 }
1268}