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