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