1use std::collections::{BTreeMap, BTreeSet};
43use std::path::{Path, PathBuf};
44
45use crate::macro_js::{Limits, MacroArg, MacroError, MacroRuntime};
46
47use crate::diag::{OpyError, OpyResult, Span};
48use crate::hir::types::{
49 DirectiveRecord, DirectiveValue, OptimizationState, PreprocessingSnapshot, PreprocessingState,
50 TranslationState,
51};
52use crate::lexer::{LexInput, Token, TokenKind, lex};
53use crate::settings::SettingsBlock;
54
55#[derive(Debug, Clone, PartialEq)]
57pub struct DefineRecord {
58 pub name: String,
59 pub is_function: bool,
60 pub is_member: bool,
61 pub span: Option<Span>,
62}
63
64#[derive(Debug, Clone, PartialEq)]
66pub struct ScriptMacro {
67 pub path: String,
70 pub source: String,
72}
73
74#[derive(Debug, Clone, PartialEq)]
80pub struct PostCompileHook {
81 pub path: String,
83 pub source: String,
85 pub span: Span,
87}
88
89#[derive(Debug, Clone)]
91pub struct Preprocessed {
92 pub tokens: Vec<Token>,
94 pub defines: Vec<DefineRecord>,
96 pub settings: Option<SettingsBlock>,
98 pub warnings: Vec<PreprocessWarning>,
100 pub post_compile_hook: Option<PostCompileHook>,
102 pub preprocessing: PreprocessingState,
104}
105
106#[derive(Debug, Clone, PartialEq)]
108pub struct FileRecord {
109 pub id: u32,
110 pub path: String,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct PreprocessWarning {
116 pub code: String,
117 pub message: String,
118 pub span: Span,
119}
120
121pub fn preprocess(
123 main_text: &str,
124 main_path: &str,
125 root: &Path,
126) -> OpyResult<(Preprocessed, Vec<FileRecord>)> {
127 preprocess_with_overlay(main_text, main_path, root, &BTreeMap::new())
128}
129
130pub fn preprocess_with_overlay(
135 main_text: &str,
136 main_path: &str,
137 root: &Path,
138 overlay: &BTreeMap<String, String>,
139) -> OpyResult<(Preprocessed, Vec<FileRecord>)> {
140 preprocess_with_overlay_outcome(main_text, main_path, root, overlay).result
141}
142
143pub struct PreprocessOutcome {
147 pub result: OpyResult<(Preprocessed, Vec<FileRecord>)>,
148 pub files: Vec<FileRecord>,
149 pub warnings: Vec<PreprocessWarning>,
150}
151
152pub fn preprocess_with_overlay_outcome(
155 main_text: &str,
156 main_path: &str,
157 root: &Path,
158 overlay: &BTreeMap<String, String>,
159) -> PreprocessOutcome {
160 let mut pre = Preprocessor {
161 files: vec![FileRecord {
162 id: 0,
163 path: main_path.to_string(),
164 }],
165 next_file_id: 1,
166 root: root.to_path_buf(),
167 overlay: overlay.clone(),
168 include_stack: Vec::new(),
169 imported_files: BTreeSet::new(),
170 macros: Vec::new(),
171 defines: Vec::new(),
172 post_compile_hook: None,
173 settings: None,
174 warnings: Vec::new(),
175 preprocessing: PreprocessingState::default(),
176 };
177 let mut owned_main_text = None;
178 let mut source_file_id = 0;
179 let first_line = main_text.lines().next().unwrap_or_default();
180 if first_line.trim_start().starts_with("#!mainFile")
181 && first_main_file_directive(main_text).is_none()
182 {
183 let span = Span::new(
184 0,
185 crate::diag::Position::new(1, 1),
186 crate::diag::Position::new(1, first_line.chars().count() as u32 + 1),
187 );
188 return PreprocessOutcome {
189 result: Err(OpyError::at(
190 "main-file-invalid",
191 "`#!mainFile` expects one quoted path on the first line",
192 span,
193 )),
194 files: pre.files,
195 warnings: pre.warnings,
196 };
197 }
198 if let Some((main_file, span)) = first_main_file_directive(main_text) {
199 let candidate = root.join(&main_file);
200 let canonical = std::fs::canonicalize(&candidate).ok();
201 let overlay_text = overlay
202 .get(&main_file)
203 .or_else(|| {
204 canonical
205 .as_ref()
206 .and_then(|path| overlay.get(&path.to_string_lossy().into_owned()))
207 })
208 .cloned();
209 let (text, display_path, new_root) = match overlay_text {
210 Some(text) => {
211 let display_path = candidate.to_string_lossy().into_owned();
212 let new_root = candidate
213 .parent()
214 .map(Path::to_path_buf)
215 .unwrap_or_else(|| root.to_path_buf());
216 (text, display_path, new_root)
217 }
218 None => {
219 let Some(canonical) = canonical else {
220 return PreprocessOutcome {
221 result: Err(OpyError::at(
222 "main-file-not-found",
223 format!("cannot find main file '{main_file}'"),
224 span,
225 )),
226 files: pre.files,
227 warnings: pre.warnings,
228 };
229 };
230 let text = match std::fs::read_to_string(&canonical) {
231 Ok(text) => text,
232 Err(error) => {
233 return PreprocessOutcome {
234 result: Err(OpyError::at(
235 "main-file-not-found",
236 format!("cannot read main file '{main_file}': {error}"),
237 span,
238 )),
239 files: pre.files,
240 warnings: pre.warnings,
241 };
242 }
243 };
244 let new_root = canonical
245 .parent()
246 .map(Path::to_path_buf)
247 .unwrap_or_else(|| root.to_path_buf());
248 (text, canonical.to_string_lossy().into_owned(), new_root)
249 }
250 };
251 owned_main_text = Some(text);
252 source_file_id = 1;
253 pre.files.push(FileRecord {
254 id: source_file_id,
255 path: display_path,
256 });
257 pre.next_file_id = 2;
258 pre.root = new_root;
259 pre.preprocessing.main_file = Some(DirectiveValue {
260 value: main_file.clone(),
261 span: Some(span.into()),
262 });
263 pre.record("mainFile", Some(&main_file), span);
264 }
265 let source_text = owned_main_text.as_deref().unwrap_or(main_text);
266 let settings = match crate::settings::find_blocks(source_text, source_file_id) {
269 Ok(mut blocks) => blocks.pop(),
270 Err(error) => {
271 return PreprocessOutcome {
272 result: Err(error),
273 files: pre.files,
274 warnings: pre.warnings,
275 };
276 }
277 };
278 pre.settings = settings.clone();
279 let tokens = match &settings {
280 Some(block) => {
281 let sanitized = crate::settings::sanitize_for_lex(source_text, block);
282 lex(LexInput {
283 file_id: source_file_id,
284 text: &sanitized,
285 })
286 }
287 None => lex(LexInput {
288 file_id: source_file_id,
289 text: source_text,
290 }),
291 };
292 let mut tokens = match tokens {
293 Ok(tokens) => tokens,
294 Err(error) => {
295 return PreprocessOutcome {
296 result: Err(error),
297 files: pre.files,
298 warnings: pre.warnings,
299 };
300 }
301 };
302 if let Err(error) = pre.process_directives(&mut tokens, false) {
303 return PreprocessOutcome {
304 result: Err(error),
305 files: pre.files,
306 warnings: pre.warnings,
307 };
308 }
309 match pre.expand(tokens) {
310 Ok(tokens) => {
311 let result = Ok((
312 Preprocessed {
313 tokens,
314 defines: pre.defines,
315 settings: pre.settings,
316 warnings: pre.warnings.clone(),
317 post_compile_hook: pre.post_compile_hook,
318 preprocessing: pre.preprocessing,
319 },
320 pre.files.clone(),
321 ));
322 PreprocessOutcome {
323 result,
324 files: pre.files,
325 warnings: pre.warnings,
326 }
327 }
328 Err(error) => PreprocessOutcome {
329 result: Err(error),
330 files: pre.files,
331 warnings: pre.warnings,
332 },
333 }
334}
335
336struct Preprocessor {
337 files: Vec<FileRecord>,
338 next_file_id: u32,
339 root: PathBuf,
340 overlay: BTreeMap<String, String>,
341 include_stack: Vec<PathBuf>,
342 imported_files: BTreeSet<PathBuf>,
343 macros: Vec<MacroDef>,
344 settings: Option<SettingsBlock>,
345 defines: Vec<DefineRecord>,
346 post_compile_hook: Option<PostCompileHook>,
347 warnings: Vec<PreprocessWarning>,
348 preprocessing: PreprocessingState,
349}
350
351struct MacroDef {
353 name: String,
354 params: Vec<String>,
355 body: Vec<Token>,
356 is_function: bool,
358 script: Option<ScriptMacro>,
360}
361
362fn first_main_file_directive(text: &str) -> Option<(String, Span)> {
363 let line = text.lines().next()?.trim_end_matches('\r');
364 let rest = line.strip_prefix("#!mainFile")?;
365 let value = rest.trim();
366 let value = strip_quoted(value)?.to_string();
367 let end_col = line.chars().count() as u32 + 1;
368 Some((
369 value,
370 Span::new(
371 0,
372 crate::diag::Position::new(1, 1),
373 crate::diag::Position::new(1, end_col),
374 ),
375 ))
376}
377
378impl Preprocessor {
379 fn process_directives(
382 &mut self,
383 tokens: &mut Vec<Token>,
384 allow_leading_main_file: bool,
385 ) -> OpyResult<()> {
386 let mut out: Vec<Token> = Vec::with_capacity(tokens.len());
387 for token in tokens.drain(..) {
388 if token.kind == TokenKind::Directive {
389 let is_leading_main_file = allow_leading_main_file && token.span.start.line == 1;
390 self.handle_directive(token, &mut out, is_leading_main_file)?;
391 } else if token.kind == TokenKind::Ident
392 && matches!(token.text.as_str(), "rule" | "def")
393 && self.preprocessing.rule_prefix.is_some()
394 {
395 let prefix = self
396 .preprocessing
397 .rule_prefix
398 .as_ref()
399 .map(|value| value.value.clone())
400 .unwrap_or_default();
401 out.push(Token {
402 kind: TokenKind::RulePrefixMarker,
403 text: prefix,
404 raw: None,
405 span: token.span,
406 });
407 out.push(token);
408 } else {
409 out.push(token);
410 }
411 }
412 *tokens = out;
413 Ok(())
414 }
415
416 fn handle_directive(
417 &mut self,
418 token: Token,
419 out: &mut Vec<Token>,
420 allow_leading_main_file: bool,
421 ) -> OpyResult<()> {
422 let text = token.text.trim();
423 let span = token.span;
424 let (name, rest) = split_directive(text);
425 if name == "include" {
426 let rest = rest.trim();
427 let include = rest
428 .strip_prefix('"')
429 .and_then(|r| r.strip_suffix('"'))
430 .or_else(|| rest.strip_prefix('\'').and_then(|r| r.strip_suffix('\'')));
431 let Some(include) = include else {
432 return Err(OpyError::at(
433 "include-invalid",
434 format!(
435 "invalid include directive: `{text}` (expected `#!include \"file.opy\"`)"
436 ),
437 span,
438 ));
439 };
440 self.include(include, span, out)?;
441 return Ok(());
442 }
443 if matches!(name, "define" | "defineMember") {
444 self.define(rest.trim(), span, name == "defineMember")?;
445 return Ok(());
446 }
447 if name == "undef" {
448 let name = rest.trim();
449 if name.is_empty() || name.chars().any(|ch| !is_identifier_char(ch)) {
450 return Err(OpyError::at(
451 "undef-invalid",
452 "malformed `#!undef` directive: expected one macro name",
453 span,
454 ));
455 }
456 self.macros.retain(|m| m.name != name);
457 self.defines.retain(|define| define.name != name);
458 self.record("undef", Some(name), span);
459 return Ok(());
460 }
461 if name == "postCompileHook" {
462 let rest = rest.trim();
463 let Some(path) = strip_quoted(rest) else {
464 return Err(OpyError::at(
465 "script-invalid",
466 format!(
467 "invalid postCompileHook directive: `{text}` (expected `#!postCompileHook \"hook.js\"`)"
468 ),
469 span,
470 ));
471 };
472 if self.post_compile_hook.is_some() {
473 return Err(OpyError::at(
474 "post-compile-hook-duplicate",
475 "post-compile hook is already defined".to_string(),
476 span,
477 ));
478 }
479 let hook = self.resolve_script(path, span)?;
480 self.post_compile_hook = Some(PostCompileHook {
481 path: hook.path,
482 source: hook.source,
483 span,
484 });
485 self.record("postCompileHook", Some(path), span);
486 return Ok(());
487 }
488 if matches!(name, "setupTags" | "setupTx") {
489 require_no_arguments(name, rest, span)?;
490 self.record(name, None, span);
491 return Ok(());
492 }
493 if name == "mainFile" {
494 if allow_leading_main_file {
495 let main_file = strip_quoted(rest.trim())
496 .filter(|main_file| !main_file.is_empty())
497 .ok_or_else(|| {
498 OpyError::at(
499 "main-file-invalid",
500 "`#!mainFile` expects one quoted path",
501 span,
502 )
503 })?;
504 self.record(name, Some(main_file), span);
505 return Ok(());
506 }
507 return Err(OpyError::at(
508 "main-file-placement",
509 "`#!mainFile` must be the first directive in the main source",
510 span,
511 ));
512 }
513 if name == "allowMacroRedeclaration" {
514 self.preprocessing.allow_macro_redeclaration = true;
515 self.record(name, None, span);
516 return Ok(());
517 }
518 if name == "excludeVariablesInCompilation" {
519 require_no_arguments(name, rest, span)?;
520 self.record(name, None, span);
521 return Ok(());
522 }
523 if name == "extension" {
524 let extension = parse_single_word(rest, name, span)?;
525 validate_extension_name(extension, span)?;
526 self.record(name, Some(extension), span);
527 return Ok(());
528 }
529 if name == "translateWithPlayerVar" {
530 let options = rest.split_whitespace().collect::<Vec<_>>();
531 if options
532 .iter()
533 .any(|option| !matches!(*option, "noDetectionRule" | "noTlErr"))
534 {
535 return Err(OpyError::at(
536 "directive-invalid",
537 "`#!translateWithPlayerVar` accepts only `noDetectionRule` and `noTlErr`",
538 span,
539 ));
540 }
541 let value = (!options.is_empty()).then(|| options.join(" "));
542 self.record(name, value.as_deref(), span);
543 return Ok(());
544 }
545 if matches!(
546 name,
547 "disableInspector"
548 | "writeToOutputFile"
549 | "disableTranslationSourceLines"
550 | "keepUnusedTranslations"
551 | "useVariableForCompressionAlphabet"
552 | "debugElementCount"
553 ) {
554 require_no_arguments(name, rest, span)?;
555 self.record(name, None, span);
556 return Ok(());
557 }
558 if matches!(name, "globalvarInitRuleName" | "playervarInitRuleName") {
559 let value = strip_quoted(rest.trim()).ok_or_else(|| {
560 OpyError::at(
561 "directive-invalid",
562 format!("`#!{name}` expects one quoted string"),
563 span,
564 )
565 })?;
566 self.record(name, Some(value), span);
567 return Ok(());
568 }
569 if name == "translations" {
570 let languages = parse_translations(rest.trim(), span)?;
571 self.preprocessing.translations = Some(TranslationState {
572 languages: languages.clone(),
573 span: Some(span.into()),
574 });
575 self.record(name, Some(&languages.join(" ")), span);
576 return Ok(());
577 }
578 if name == "suppressWarnings" {
579 let warnings = parse_words(rest, "suppressWarnings", span)?;
580 self.preprocessing
581 .suppressed_warnings
582 .extend(warnings.clone());
583 self.record(name, Some(&warnings.join(" ")), span);
584 return Ok(());
585 }
586 if name == "rulePrefix" {
587 let prefix = strip_quoted(rest.trim()).ok_or_else(|| {
588 OpyError::at(
589 "rule-prefix-invalid",
590 "`#!rulePrefix` expects one quoted string",
591 span,
592 )
593 })?;
594 self.preprocessing.rule_prefix = Some(DirectiveValue {
595 value: prefix.to_string(),
596 span: Some(span.into()),
597 });
598 self.record(name, Some(prefix), span);
599 return Ok(());
600 }
601 if name == "rulePrefixTemplate" {
602 if self.preprocessing.rule_prefix_template.is_some() {
603 return Err(OpyError::at(
604 "rule-prefix-template-duplicate",
605 "a rule prefix template is already defined",
606 span,
607 ));
608 }
609 let template = if rest.trim().is_empty() {
610 r#"f"[{$pathTitle.replace('_', ' ')}] {$rule}" if $rule and not $isDelimiter else $rule"#
611 } else {
612 rest.trim()
613 };
614 self.preprocessing.rule_prefix_template = Some(DirectiveValue {
615 value: template.to_string(),
616 span: Some(span.into()),
617 });
618 self.record(name, Some(template), span);
619 return Ok(());
620 }
621 if let Some((directive, control)) = optimization_directive(name) {
622 apply_optimization(&mut self.preprocessing.optimization, control);
623 self.record(directive, None, span);
624 return Ok(());
625 }
626 if let Some(replacement) = replacement_directive(name) {
627 let family = replacement_family(name).expect("replacement directive family");
628 if self
629 .preprocessing
630 .directives
631 .iter()
632 .filter_map(|item| replacement_family(&item.name))
633 .any(|item_family| item_family == family)
634 {
635 return Err(OpyError::at(
636 "replacement-duplicate",
637 format!("a replacement for `{family}` is already defined"),
638 span,
639 ));
640 }
641 self.preprocessing.replacements.push(DirectiveValue {
642 value: replacement.to_string(),
643 span: Some(span.into()),
644 });
645 self.record(name, Some(replacement), span);
646 return Ok(());
647 }
648 Err(OpyError::at(
649 "unsupported-directive",
650 format!("unsupported preprocessing directive `#!{text}`"),
651 span,
652 ))
653 }
654
655 fn record(&mut self, name: &str, value: Option<&str>, span: Span) {
656 let state = PreprocessingSnapshot {
657 allow_macro_redeclaration: self.preprocessing.allow_macro_redeclaration,
658 optimization: self.preprocessing.optimization.clone(),
659 rule_prefix: self
660 .preprocessing
661 .rule_prefix
662 .as_ref()
663 .map(|value| value.value.clone()),
664 rule_prefix_template: self
665 .preprocessing
666 .rule_prefix_template
667 .as_ref()
668 .map(|value| value.value.clone()),
669 translations: self
670 .preprocessing
671 .translations
672 .as_ref()
673 .map(|translations| translations.languages.clone()),
674 replacements: self
675 .preprocessing
676 .replacements
677 .iter()
678 .map(|value| value.value.clone())
679 .collect(),
680 };
681 self.preprocessing.directives.push(DirectiveRecord {
682 name: name.to_string(),
683 value: value.map(str::to_string),
684 scope_col: span.start.col,
685 scope_depth: self.include_stack.len() as u32,
686 state,
687 span: Some(span.into()),
688 });
689 }
690
691 fn resolve_script(&self, path: &str, span: Span) -> OpyResult<ScriptMacro> {
693 let canonical = self.root.join(path).canonicalize().map_err(|_| {
694 OpyError::at(
695 "script-not-found",
696 format!(
697 "cannot find script '{path}' under root '{}'",
698 self.root.display()
699 ),
700 span,
701 )
702 })?;
703 let source = std::fs::read_to_string(&canonical).map_err(|error| {
704 OpyError::at(
705 "script-not-found",
706 format!("cannot read script '{path}': {error}"),
707 span,
708 )
709 })?;
710 Ok(ScriptMacro {
711 path: path.to_string(),
712 source,
713 })
714 }
715
716 fn include(&mut self, include: &str, span: Span, out: &mut Vec<Token>) -> OpyResult<()> {
718 let candidate = self.include_base().join(include);
721 let canonical = std::fs::canonicalize(&candidate).ok();
722 let candidate_path = candidate.to_string_lossy().into_owned();
723 let candidate_without_dot = candidate_path
724 .strip_prefix("./")
725 .unwrap_or(&candidate_path)
726 .to_string();
727 let overlay_text = self
731 .overlay
732 .get(include)
733 .or_else(|| self.overlay.get(&candidate_path))
734 .or_else(|| self.overlay.get(&candidate_without_dot))
735 .or_else(|| {
736 canonical
737 .as_ref()
738 .and_then(|path| self.overlay.get(&path.to_string_lossy().into_owned()))
739 })
740 .cloned();
741
742 let identity = canonical.clone().unwrap_or_else(|| candidate.clone());
745 if self.include_stack.contains(&identity) {
746 return Err(OpyError::at(
747 "include-cycle",
748 format!(
749 "include cycle detected: '{}' is already being included",
750 identity.display()
751 ),
752 span,
753 ));
754 }
755 let import_identity = candidate.clone();
756 if self.imported_files.contains(&import_identity) {
757 self.warnings.push(PreprocessWarning {
758 code: "w_already_imported".to_string(),
759 message: format!(
760 "The file '{}' was already imported and will not be imported again.",
761 import_identity.display()
762 ),
763 span,
764 });
765 self.record("include", Some(include), span);
766 return Ok(());
767 }
768 self.imported_files.insert(import_identity);
769
770 let text = match overlay_text {
771 Some(text) => text,
772 None => {
773 let canonical = canonical.ok_or_else(|| {
774 OpyError::at(
775 "include-not-found",
776 format!(
777 "cannot find included file '{include}' under root '{}'",
778 self.root.display()
779 ),
780 span,
781 )
782 })?;
783 std::fs::read_to_string(&canonical).map_err(|error| {
784 OpyError::at(
785 "include-not-found",
786 format!("cannot read included file '{include}': {error}"),
787 span,
788 )
789 })?
790 }
791 };
792 let file_id = self.next_file_id;
794 self.next_file_id += 1;
795 let display_path = self.include_display_path(&candidate, include);
796 self.files.push(FileRecord {
797 id: file_id,
798 path: display_path,
799 });
800 self.include_stack.push(identity);
801 let saved_prefix = self.preprocessing.rule_prefix.clone();
802 let saved_optimization = self.preprocessing.optimization.clone();
803 let settings = match crate::settings::find_blocks(&text, file_id) {
804 Err(error) => {
805 self.include_stack.pop();
806 return Err(error);
807 }
808 Ok(mut blocks) => blocks.pop(),
809 };
810 if let Some(block) = settings {
811 if self.settings.is_some() {
812 self.include_stack.pop();
813 return Err(OpyError::at(
814 "settings-placement",
815 "only one settings block is supported in a project".to_string(),
816 block.keyword_span,
817 ));
818 }
819 self.settings = Some(block);
820 }
821 let sanitized = self
822 .settings
823 .as_ref()
824 .filter(|block| block.span.file == file_id)
825 .map(|block| crate::settings::sanitize_for_lex(&text, block));
826 let mut included = lex(LexInput {
827 file_id,
828 text: sanitized.as_deref().unwrap_or(&text),
829 })?;
830 let allow_leading_main_file = text
831 .lines()
832 .next()
833 .is_some_and(|line| line.trim_end_matches('\r').starts_with("#!mainFile"));
834 let processed = self.process_directives(&mut included, allow_leading_main_file);
835 self.preprocessing.rule_prefix = saved_prefix;
836 self.preprocessing.optimization = saved_optimization;
837 if let Err(error) = processed {
838 self.include_stack.pop();
839 return Err(error);
840 }
841 included.retain(|token| token.kind != TokenKind::Eof);
844 out.extend(included);
849 self.include_stack.pop();
850 self.record("include", Some(include), span);
851 Ok(())
852 }
853
854 fn include_base(&self) -> PathBuf {
855 self.include_stack
856 .last()
857 .and_then(|path| path.parent())
858 .map(Path::to_path_buf)
859 .unwrap_or_else(|| self.root.clone())
860 }
861
862 fn include_display_path(&self, candidate: &Path, fallback: &str) -> String {
863 candidate
864 .strip_prefix(&self.root)
865 .ok()
866 .and_then(|path| (!path.as_os_str().is_empty()).then_some(path))
867 .map(|path| path.to_string_lossy().into_owned())
868 .or_else(|| {
869 let path = candidate.to_string_lossy();
870 Some(path.strip_prefix("./").unwrap_or(&path).to_string())
871 })
872 .unwrap_or_else(|| fallback.to_string())
873 }
874
875 fn define(&mut self, rest: &str, span: Span, is_member: bool) -> OpyResult<()> {
881 let rest = rest.trim();
882 let first_open = rest.find('(').unwrap_or(usize::MAX);
883 let first_space = rest.find(char::is_whitespace).unwrap_or(usize::MAX);
884 let is_function_like = first_open < first_space;
885
886 let (name, params, body_text) = if is_function_like {
887 let name = rest[..first_open].trim();
888 let Some(close) = rest[first_open..].find(')') else {
889 return Err(OpyError::at(
890 "define-invalid",
891 format!("malformed function-like define `#!define {rest}`: missing `)`"),
892 span,
893 ));
894 };
895 let close = first_open + close;
896 let params: Vec<String> = rest[first_open + 1..close]
897 .split(',')
898 .map(|p| p.trim().to_string())
899 .filter(|p| !p.is_empty())
900 .collect();
901 let body = rest[close + 1..].trim();
902 (name.to_string(), params, body.to_string())
903 } else {
904 let name = rest[..first_space].trim();
905 let body = if first_space == usize::MAX {
906 String::new()
907 } else {
908 rest[first_space..].trim().to_string()
909 };
910 (name.to_string(), Vec::new(), body)
911 };
912 if name.is_empty() {
913 return Err(OpyError::at(
914 "define-invalid",
915 "malformed `#!define` directive: missing macro name",
916 span,
917 ));
918 }
919 if self.macros.iter().any(|macro_def| macro_def.name == name) {
920 if !self.preprocessing.allow_macro_redeclaration {
921 return Err(OpyError::at(
922 "macro-redeclaration",
923 format!("macro '{name}' is already defined"),
924 span,
925 ));
926 }
927 self.macros.retain(|macro_def| macro_def.name != name);
928 self.defines.retain(|define| define.name != name);
929 }
930 let script = if is_function_like && body_text.starts_with("__script__(") {
931 let inner = &body_text["__script__(".len()..];
936 let inner = inner.strip_suffix(')').ok_or_else(|| {
937 OpyError::at(
938 "script-invalid",
939 format!(
940 "malformed script macro `#!define {rest}`: expected `__script__(\"path.js\")`"
941 ),
942 span,
943 )
944 })?;
945 let Some(path) = strip_quoted(inner.trim()) else {
946 return Err(OpyError::at(
947 "script-invalid",
948 format!(
949 "malformed script macro `#!define {rest}`: expected a quoted script path"
950 ),
951 span,
952 ));
953 };
954 Some(self.resolve_script(path, span)?)
955 } else {
956 None
957 };
958 let body_tokens = lex(LexInput {
959 file_id: span.file,
960 text: &body_text,
961 })?;
962 let body_tokens: Vec<Token> = body_tokens
964 .into_iter()
965 .filter(|t| t.kind != TokenKind::Eof)
966 .collect();
967 let is_function = is_function_like;
968 self.defines.push(DefineRecord {
969 name: name.clone(),
970 is_function,
971 is_member,
972 span: Some(span),
973 });
974 self.macros.push(MacroDef {
975 name,
976 params,
977 body: body_tokens,
978 is_function,
979 script,
980 });
981 Ok(())
982 }
983
984 fn expand(&self, tokens: Vec<Token>) -> OpyResult<Vec<Token>> {
986 let mut out = Vec::new();
987 let mut index = 0;
988 while index < tokens.len() {
989 let token = &tokens[index];
990 if token.kind == TokenKind::Ident {
991 let name = token.text.clone();
992 if let Some(mac) = self.macros.iter().find(|m| m.name == name) {
993 if mac.is_function {
994 let cursor = index + 1;
996 if cursor < tokens.len() && tokens[cursor].kind == TokenKind::LParen {
997 let (args, after) = self.collect_args(&tokens, cursor)?;
998 let mut expanded = self.expand_macro(mac, args, token.span)?;
999 self.expand_into(&mut expanded, &mut Vec::new(), 0)?;
1000 out.append(&mut expanded);
1001 index = after;
1002 continue;
1003 }
1004 out.push(token.clone());
1007 index += 1;
1008 continue;
1009 }
1010 let mut expanded = self.expand_macro(mac, Vec::new(), token.span)?;
1011 self.expand_into(&mut expanded, &mut Vec::new(), 0)?;
1012 out.append(&mut expanded);
1013 index += 1;
1014 continue;
1015 }
1016 }
1017 out.push(token.clone());
1018 index += 1;
1019 }
1020 Ok(out)
1021 }
1022
1023 fn collect_args(&self, tokens: &[Token], open: usize) -> OpyResult<(Vec<Vec<Token>>, usize)> {
1026 let mut args: Vec<Vec<Token>> = Vec::new();
1027 let mut current: Vec<Token> = Vec::new();
1028 let mut depth = 0usize;
1029 let mut cursor = open + 1;
1030 while cursor < tokens.len() {
1031 let kind = tokens[cursor].kind;
1032 if kind == TokenKind::LParen {
1033 depth += 1;
1034 current.push(tokens[cursor].clone());
1035 } else if kind == TokenKind::RParen {
1036 if depth == 0 {
1037 if !current.is_empty() || !args.is_empty() {
1038 args.push(std::mem::take(&mut current));
1039 }
1040 return Ok((args, cursor + 1));
1041 }
1042 depth -= 1;
1043 current.push(tokens[cursor].clone());
1044 } else if kind == TokenKind::Comma && depth == 0 {
1045 args.push(std::mem::take(&mut current));
1046 } else {
1047 current.push(tokens[cursor].clone());
1048 }
1049 cursor += 1;
1050 }
1051 Err(OpyError::new(
1052 "macro-invalid",
1053 "unterminated macro invocation: missing closing `)`",
1054 ))
1055 }
1056
1057 fn expand_macro(
1064 &self,
1065 mac: &MacroDef,
1066 args: Vec<Vec<Token>>,
1067 use_site: Span,
1068 ) -> OpyResult<Vec<Token>> {
1069 if mac.is_function && args.len() != mac.params.len() {
1070 return Err(OpyError::at(
1071 "macro-arity",
1072 format!(
1073 "macro '{}' expects {} argument(s) but got {}",
1074 mac.name,
1075 mac.params.len(),
1076 args.len()
1077 ),
1078 use_site,
1079 ));
1080 }
1081 if let Some(script) = &mac.script {
1082 return self.expand_script(mac, script, args, use_site);
1083 }
1084 let mut out = Vec::new();
1085 for token in &mac.body {
1086 if mac.is_function
1087 && token.kind == TokenKind::Ident
1088 && mac.params.iter().any(|p| p == &token.text)
1089 {
1090 let param_index = mac
1091 .params
1092 .iter()
1093 .position(|p| p == &token.text)
1094 .expect("checked above");
1095 let mut replacement = args.get(param_index).cloned().unwrap_or_default();
1096 for replacement_token in &mut replacement {
1097 replacement_token.span = use_site;
1098 }
1099 out.extend(replacement);
1100 } else {
1101 let mut token = token.clone();
1102 token.span = use_site;
1103 out.push(token);
1104 }
1105 }
1106 Ok(out)
1107 }
1108
1109 fn expand_script(
1121 &self,
1122 mac: &MacroDef,
1123 script: &ScriptMacro,
1124 args: Vec<Vec<Token>>,
1125 use_site: Span,
1126 ) -> OpyResult<Vec<Token>> {
1127 let macro_args: Vec<MacroArg> = mac
1128 .params
1129 .iter()
1130 .zip(args.iter())
1131 .map(|(param, tokens)| MacroArg::new(param.clone(), raw_arg_text(tokens)))
1132 .collect();
1133 let runtime = MacroRuntime::new(Limits::default());
1136 let result = runtime
1137 .run_macro(&script.source, ¯o_args, &script.path)
1138 .map_err(|error| map_macro_error(&error, &script.path, use_site))?;
1139 let indent = " ".repeat(use_site.start.col.saturating_sub(1) as usize);
1142 let indented = result.text.replace('\n', &format!("\n{indent}"));
1143 let mut tokens = lex(LexInput {
1144 file_id: use_site.file,
1145 text: &indented,
1146 })?;
1147 tokens.retain(|token| token.kind != TokenKind::Eof);
1148 for token in &mut tokens {
1149 token.span = use_site;
1150 }
1151 Ok(tokens)
1152 }
1153
1154 fn expand_into(
1157 &self,
1158 tokens: &mut Vec<Token>,
1159 stack: &mut Vec<String>,
1160 depth: usize,
1161 ) -> OpyResult<()> {
1162 if depth > 64 {
1163 return Err(OpyError::new(
1164 "macro-recursion",
1165 "macro expansion exceeded the recursion limit (possible recursive define)",
1166 ));
1167 }
1168 let mut out: Vec<Token> = Vec::with_capacity(tokens.len());
1169 let mut index = 0;
1170 while index < tokens.len() {
1171 let token = &tokens[index];
1172 if token.kind == TokenKind::Ident {
1173 let name = token.text.clone();
1174 if let Some(mac) = self.macros.iter().find(|m| m.name == name) {
1175 if stack.iter().any(|s| s == &name) {
1176 return Err(OpyError::new(
1177 "macro-recursion",
1178 format!("recursive macro expansion detected for '{name}'"),
1179 ));
1180 }
1181 if mac.is_function {
1182 if index + 1 < tokens.len() && tokens[index + 1].kind == TokenKind::LParen {
1183 let (args, after) = self.collect_args(tokens, index)?;
1184 let mut expanded = self.expand_macro(mac, args, token.span)?;
1185 stack.push(name.clone());
1186 self.expand_into(&mut expanded, stack, depth + 1)?;
1187 stack.pop();
1188 out.append(&mut expanded);
1189 index = after;
1190 continue;
1191 }
1192 out.push(token.clone());
1193 index += 1;
1194 continue;
1195 }
1196 let mut expanded = self.expand_macro(mac, Vec::new(), token.span)?;
1197 stack.push(name.clone());
1198 self.expand_into(&mut expanded, stack, depth + 1)?;
1199 stack.pop();
1200 out.append(&mut expanded);
1201 index += 1;
1202 continue;
1203 }
1204 }
1205 out.push(token.clone());
1206 index += 1;
1207 }
1208 *tokens = out;
1209 Ok(())
1210 }
1211}
1212
1213fn split_directive(text: &str) -> (&str, &str) {
1214 text.split_once(char::is_whitespace)
1215 .map_or((text, ""), |(name, rest)| (name, rest))
1216}
1217
1218fn require_no_arguments(name: &str, rest: &str, span: Span) -> OpyResult<()> {
1219 if rest.trim().is_empty() {
1220 Ok(())
1221 } else {
1222 Err(OpyError::at(
1223 "directive-invalid",
1224 format!("`#!{name}` does not accept arguments"),
1225 span,
1226 ))
1227 }
1228}
1229
1230fn parse_single_word<'a>(rest: &'a str, name: &str, span: Span) -> OpyResult<&'a str> {
1231 let value = rest.trim();
1232 if value.is_empty() || value.chars().any(char::is_whitespace) {
1233 return Err(OpyError::at(
1234 "directive-invalid",
1235 format!("`#!{name}` expects one argument"),
1236 span,
1237 ));
1238 }
1239 Ok(value)
1240}
1241
1242fn validate_extension_name(extension: &str, span: Span) -> OpyResult<()> {
1243 let path = [
1244 workshop_rs::settings::table::PathPart::Part("extensions"),
1245 workshop_rs::settings::table::PathPart::Part(extension),
1246 ];
1247 if workshop_rs::settings::definition(&path).is_some() {
1248 Ok(())
1249 } else {
1250 Err(OpyError::at(
1251 "directive-invalid",
1252 format!("unknown Workshop extension `{extension}`"),
1253 span,
1254 ))
1255 }
1256}
1257
1258fn is_identifier_char(ch: char) -> bool {
1259 ch.is_ascii_alphanumeric() || ch == '_'
1260}
1261
1262fn parse_words(rest: &str, directive: &str, span: Span) -> OpyResult<Vec<String>> {
1263 let words: Vec<String> = rest.split_whitespace().map(str::to_string).collect();
1264 if words.is_empty() {
1265 return Err(OpyError::at(
1266 "directive-invalid",
1267 format!("`#!{directive}` expects at least one argument"),
1268 span,
1269 ));
1270 }
1271 if words
1272 .iter()
1273 .any(|word| word.chars().any(|ch| !is_identifier_char(ch)))
1274 {
1275 return Err(OpyError::at(
1276 "directive-invalid",
1277 format!("`#!{directive}` arguments must be identifiers"),
1278 span,
1279 ));
1280 }
1281 Ok(words)
1282}
1283
1284fn parse_translations(rest: &str, span: Span) -> OpyResult<Vec<String>> {
1285 let values: Vec<String> = rest
1286 .split_whitespace()
1287 .map(|language| language.replace('-', "_").to_lowercase())
1288 .collect();
1289 if values.is_empty() {
1290 return Err(OpyError::at(
1291 "translations-invalid",
1292 "`#!translations` expects at least one language",
1293 span,
1294 ));
1295 }
1296 const PINNED_LANGUAGES: &[&str] = &[
1297 "de", "en", "es", "es_es", "es_mx", "fr", "it", "ja", "ko", "pl", "pt", "ru", "th", "tr",
1298 "zh", "zh_cn", "zh_tw",
1299 ];
1300 if values
1301 .iter()
1302 .any(|language| !PINNED_LANGUAGES.contains(&language.as_str()))
1303 {
1304 return Err(OpyError::at(
1305 "translations-invalid",
1306 "invalid translation language; expected one of the pinned OverPy language codes",
1307 span,
1308 ));
1309 }
1310 if values.iter().any(|value| value == "es")
1311 && values
1312 .iter()
1313 .any(|value| value == "es_es" || value == "es_mx")
1314 {
1315 return Err(OpyError::at(
1316 "translations-invalid",
1317 "cannot combine `es` with `es_es` or `es_mx`",
1318 span,
1319 ));
1320 }
1321 if values.iter().any(|value| value == "zh")
1322 && values
1323 .iter()
1324 .any(|value| value == "zh_cn" || value == "zh_tw")
1325 {
1326 return Err(OpyError::at(
1327 "translations-invalid",
1328 "cannot combine `zh` with `zh_cn` or `zh_tw`",
1329 span,
1330 ));
1331 }
1332 Ok(values)
1333}
1334
1335#[derive(Clone, Copy)]
1336enum OptimizationControl {
1337 Enable,
1338 Disable,
1339 ForSize,
1340 DisableForSize,
1341 ForSizeAggressive,
1342 Strict,
1343 DisableStrict,
1344}
1345
1346fn optimization_directive(name: &str) -> Option<(&str, OptimizationControl)> {
1347 Some(match name {
1348 "disableOptimizations" => (name, OptimizationControl::Disable),
1349 "enableOptimizations" => (name, OptimizationControl::Enable),
1350 "optimizeForSize" => (name, OptimizationControl::ForSize),
1351 "disableOptimizeForSize" => (name, OptimizationControl::DisableForSize),
1352 "optimizeForSizeAggressive" => (name, OptimizationControl::ForSizeAggressive),
1353 "optimizeStrict" => (name, OptimizationControl::Strict),
1354 "disableOptimizeStrict" => (name, OptimizationControl::DisableStrict),
1355 _ => return None,
1356 })
1357}
1358
1359fn apply_optimization(state: &mut OptimizationState, control: OptimizationControl) {
1360 match control {
1361 OptimizationControl::Enable => state.enabled = true,
1362 OptimizationControl::Disable => state.enabled = false,
1363 OptimizationControl::ForSize => state.for_size = true,
1364 OptimizationControl::DisableForSize => state.for_size = false,
1365 OptimizationControl::ForSizeAggressive => state.for_size_aggressive = true,
1366 OptimizationControl::Strict => state.strict = true,
1367 OptimizationControl::DisableStrict => state.strict = false,
1368 }
1369}
1370
1371fn replacement_directive(name: &str) -> Option<&str> {
1372 Some(match name {
1373 "replace0ByCapturePercentage" => "getCapturePercentage",
1374 "replace0ByPayloadProgressPercentage" => "getPayloadProgressPercentage",
1375 "replace0ByIsMatchComplete" => "isMatchComplete",
1376 "replace1ByMatchRound" => "getMatchRound",
1377 "replaceTeam1ByControlScoringTeam" => "getControlScoringTeam",
1378 "replaceEmptyStringByEmptyArray" => "emptyArray",
1379 "replaceEmptyStringByVariable" => "variable",
1380 _ => return None,
1381 })
1382}
1383
1384fn replacement_family(name: &str) -> Option<&str> {
1385 Some(match name {
1386 "replace0ByCapturePercentage"
1387 | "replace0ByPayloadProgressPercentage"
1388 | "replace0ByIsMatchComplete" => "0",
1389 "replace1ByMatchRound" => "1",
1390 "replaceTeam1ByControlScoringTeam" => "team1",
1391 "replaceEmptyStringByEmptyArray" | "replaceEmptyStringByVariable" => "emptyString",
1392 _ => return None,
1393 })
1394}
1395
1396fn strip_quoted(text: &str) -> Option<&str> {
1398 text.strip_prefix('"')
1399 .and_then(|rest| rest.strip_suffix('"'))
1400 .or_else(|| {
1401 text.strip_prefix('\'')
1402 .and_then(|rest| rest.strip_suffix('\''))
1403 })
1404}
1405
1406fn raw_arg_text(tokens: &[Token]) -> String {
1415 let mut out = String::new();
1416 for token in tokens {
1417 match token.kind {
1418 TokenKind::String => out.push_str(&json_string_literal(&token.text)),
1419 TokenKind::Newline => out.push('\n'),
1420 _ => out.push_str(&token.text),
1421 }
1422 }
1423 out
1424}
1425
1426fn json_string_literal(value: &str) -> String {
1428 serde_json::to_string(value).expect("serializing a string is infallible")
1429}
1430
1431pub(crate) fn map_macro_error(error: &MacroError, script_path: &str, span: Span) -> OpyError {
1443 match error {
1444 MacroError::Script(script) => {
1445 let code = match script.message.as_str() {
1446 "interrupted" => "script-timeout",
1447 "out of memory" => "script-memory-limit",
1448 "Maximum call stack size exceeded" => "script-stack-limit",
1449 _ => "script-error",
1450 };
1451 let location = match (script.line, script.column) {
1452 (Some(line), Some(column)) => format!(" (line {line}, column {column})"),
1453 (Some(line), None) => format!(" (line {line})"),
1454 _ => String::new(),
1455 };
1456 OpyError::at(
1457 code,
1458 format!(
1459 "script '{}' failed: {}{}",
1460 script_path, script.message, location
1461 ),
1462 span,
1463 )
1464 }
1465 MacroError::InvalidResult { type_name } => OpyError::at(
1466 "script-result-not-string",
1467 format!(
1468 "JavaScript macro returned value with type of {type_name}, expected string. Try using .toString()"
1469 ),
1470 span,
1471 ),
1472 MacroError::Internal(message) => OpyError::at(
1473 "script-internal",
1474 format!("script '{}' runtime failure: {message}", script_path),
1475 span,
1476 ),
1477 }
1478}
1479
1480#[cfg(test)]
1481mod tests {
1482 use super::*;
1483
1484 #[test]
1485 fn object_define_expands_at_use_site() {
1486 let (pre, _) = preprocess(
1487 "#!define SIDE 1.5\nrule \"r\":\n x = SIDE\n",
1488 "main.opy",
1489 Path::new("."),
1490 )
1491 .unwrap();
1492 assert_eq!(pre.defines.len(), 1);
1493 assert_eq!(pre.defines[0].name, "SIDE");
1494 assert!(!pre.defines[0].is_function);
1495 assert!(!pre.defines[0].is_member);
1496 let numbers: Vec<&str> = pre
1497 .tokens
1498 .iter()
1499 .filter(|t| t.kind == TokenKind::Number)
1500 .map(|t| t.text.as_str())
1501 .collect();
1502 assert_eq!(numbers, vec!["1.5"]);
1503 }
1504
1505 #[test]
1506 fn function_define_substitutes_params() {
1507 let (pre, _) = preprocess(
1508 "#!define double(x) x + x\nrule \"r\":\n y = double(3)\n",
1509 "main.opy",
1510 Path::new("."),
1511 )
1512 .unwrap();
1513 let numbers: Vec<&str> = pre
1514 .tokens
1515 .iter()
1516 .filter(|t| t.kind == TokenKind::Number)
1517 .map(|t| t.text.as_str())
1518 .collect();
1519 assert_eq!(numbers, vec!["3", "3"]);
1520 }
1521
1522 #[test]
1523 fn zero_argument_function_define_accepts_empty_invocation() {
1524 let (pre, _) = preprocess(
1525 "#!define value() 3\nrule \"r\":\n x = value()\n",
1526 "main.opy",
1527 Path::new("."),
1528 )
1529 .unwrap();
1530 let numbers: Vec<&str> = pre
1531 .tokens
1532 .iter()
1533 .filter(|token| token.kind == TokenKind::Number)
1534 .map(|token| token.text.as_str())
1535 .collect();
1536 assert_eq!(numbers, vec!["3"]);
1537 }
1538
1539 #[test]
1540 fn macro_expanded_string_can_concatenate_with_following_literal() {
1541 let (pre, _) = preprocess(
1542 "#!define PREFIX \"one\"\nrule \"r\":\n debug(PREFIX\n \"two\")\n",
1543 "main.opy",
1544 Path::new("."),
1545 )
1546 .unwrap();
1547 let output = crate::parser::parse(&pre.tokens);
1548 assert!(
1549 output.errors.is_empty(),
1550 "unexpected errors: {:?}",
1551 output.errors
1552 );
1553 let program = output.program.expect("expanded source must parse");
1554 let crate::cst::RuleEntry::Rule(rule) = &program.rules[0] else {
1555 panic!("expected rule");
1556 };
1557 let crate::cst::Stmt::Expr { expr, .. } = &rule.actions[0] else {
1558 panic!("expected expression statement");
1559 };
1560 let crate::cst::Expr::Call { args, .. } = expr else {
1561 panic!("expected call");
1562 };
1563 assert!(matches!(
1564 &args[0].value,
1565 crate::cst::Expr::String { value, .. } if value == "onetwo"
1566 ));
1567 }
1568
1569 #[test]
1570 fn recursive_defines_expand_transitively() {
1571 let (pre, _) = preprocess(
1572 "#!define A 2\n#!define B A + 1\nrule \"r\":\n x = B\n",
1573 "main.opy",
1574 Path::new("."),
1575 )
1576 .unwrap();
1577 let numbers: Vec<&str> = pre
1578 .tokens
1579 .iter()
1580 .filter(|t| t.kind == TokenKind::Number)
1581 .map(|t| t.text.as_str())
1582 .collect();
1583 assert_eq!(numbers, vec!["2", "1"]);
1584 }
1585
1586 #[test]
1587 fn recursive_define_fails_structurally() {
1588 let error = preprocess(
1589 "#!define X X + 1\nrule \"r\":\n x = X\n",
1590 "main.opy",
1591 Path::new("."),
1592 )
1593 .unwrap_err();
1594 assert_eq!(error.code, "macro-recursion");
1595 }
1596
1597 #[test]
1598 fn missing_include_is_structured() {
1599 let error = preprocess(
1600 "#!include \"nope.opy\"\n",
1601 "main.opy",
1602 Path::new("/nonexistent-root"),
1603 )
1604 .unwrap_err();
1605 assert_eq!(error.code, "include-not-found");
1606 assert!(error.span.is_some());
1607 }
1608
1609 #[test]
1610 fn include_cycle_is_detected() {
1611 let dir = std::env::temp_dir().join(format!("wright-opy-test-{}", std::process::id()));
1612 std::fs::create_dir_all(&dir).unwrap();
1613 std::fs::write(dir.join("a.opy"), "#!include \"b.opy\"\n").unwrap();
1614 std::fs::write(dir.join("b.opy"), "#!include \"a.opy\"\n").unwrap();
1615 let main = std::fs::read_to_string(dir.join("a.opy")).unwrap();
1616 let error = preprocess(&main, "a.opy", &dir).unwrap_err();
1617 assert_eq!(error.code, "include-cycle");
1618 let _ = std::fs::remove_dir_all(&dir);
1619 }
1620
1621 #[test]
1622 fn unsupported_directive_is_structured() {
1623 let error = preprocess("#!frobnicate\n", "main.opy", Path::new(".")).unwrap_err();
1624 assert_eq!(error.code, "unsupported-directive");
1625 }
1626
1627 #[test]
1628 fn settings_block_is_extracted_before_lexing() {
1629 let (pre, _) = preprocess(
1630 "settings {\n \"gamemodes\": {}\n}\nrule \"r\":\n pass\n",
1631 "main.opy",
1632 Path::new("."),
1633 )
1634 .unwrap();
1635 let block = pre.settings.expect("settings block extracted");
1636 assert!(block.text.contains("gamemodes"));
1637 assert!(
1639 !pre.tokens.iter().any(|t| t.text.contains("gamemodes")),
1640 "settings content must not be lexed"
1641 );
1642 }
1643
1644 #[test]
1645 fn settings_in_include_is_extracted_with_source_provenance() {
1646 let overlay = BTreeMap::from([(
1647 "shared.opy".to_string(),
1648 "settings {\n \"gamemodes\": {}\n}\n".to_string(),
1649 )]);
1650 let main = "#!include \"shared.opy\"\nrule \"r\":\n pass\n";
1651 let (pre, files) = preprocess_with_overlay(main, "main.opy", Path::new("."), &overlay)
1652 .expect("included settings must be extracted");
1653 let block = pre.settings.expect("included settings block");
1654 assert_eq!(block.keyword_span.file, 1);
1655 assert_eq!(files[1].path, "shared.opy");
1656 assert!(!pre.tokens.iter().any(|token| token.text == "gamemodes"));
1657 }
1658
1659 #[test]
1660 fn duplicate_include_is_skipped_without_redeclaring_macros() {
1661 let overlay =
1662 BTreeMap::from([("shared.opy".to_string(), "#!define VALUE 2\n".to_string())]);
1663 let main =
1664 "#!include \"shared.opy\"\n#!include \"shared.opy\"\nrule \"r\":\n x = VALUE\n";
1665 let (pre, files) = preprocess_with_overlay(main, "main.opy", Path::new("."), &overlay)
1666 .expect("duplicate includes must not redeclare macros");
1667 assert_eq!(pre.defines.len(), 1);
1668 assert_eq!(files.len(), 2);
1669 assert_eq!(pre.warnings.len(), 1);
1670 assert_eq!(pre.warnings[0].code, "w_already_imported");
1671 assert_eq!(
1672 pre.preprocessing
1673 .directives
1674 .iter()
1675 .filter(|directive| directive.name == "include")
1676 .count(),
1677 2
1678 );
1679 }
1680
1681 #[test]
1682 fn alias_include_paths_are_distinct_imports() {
1683 let overlay = BTreeMap::from([
1684 ("shared.opy".to_string(), "#!define FIRST 1\n".to_string()),
1685 (
1686 "dir/../shared.opy".to_string(),
1687 "#!define SECOND 2\n".to_string(),
1688 ),
1689 ]);
1690 let main = "#!include \"shared.opy\"\n#!include \"dir/../shared.opy\"\nrule \"r\":\n x = FIRST\n y = SECOND\n";
1691 let (pre, files) = preprocess_with_overlay(main, "main.opy", Path::new("."), &overlay)
1692 .expect("alias include paths must remain distinct imports");
1693 assert_eq!(files.len(), 3);
1694 assert_eq!(pre.defines.len(), 2);
1695 assert!(pre.warnings.is_empty());
1696 }
1697
1698 #[test]
1699 fn dict_literal_braces_reach_the_parser() {
1700 let (pre, _) = preprocess(
1702 "rule \"r\":\n money += {\n Mei.GENERIC: 10,\n }\n",
1703 "main.opy",
1704 Path::new("."),
1705 )
1706 .unwrap();
1707 assert!(
1708 pre.tokens
1709 .iter()
1710 .any(|token| token.kind == TokenKind::LBrace)
1711 );
1712 assert!(
1713 pre.tokens
1714 .iter()
1715 .any(|token| token.kind == TokenKind::RBrace)
1716 );
1717 }
1718
1719 #[test]
1720 fn advanced_directives_preserve_frontend_state_without_catalog_data() {
1721 let (pre, _) = preprocess(
1722 "#!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",
1723 "main.opy",
1724 Path::new("."),
1725 )
1726 .unwrap();
1727 assert!(pre.preprocessing.allow_macro_redeclaration);
1728 assert_eq!(
1729 pre.preprocessing
1730 .translations
1731 .as_ref()
1732 .map(|state| state.languages.as_slice()),
1733 Some(["en".to_string(), "fr".to_string()].as_slice())
1734 );
1735 assert_eq!(
1736 pre.preprocessing
1737 .rule_prefix
1738 .as_ref()
1739 .map(|value| value.value.as_str()),
1740 Some("Effects")
1741 );
1742 assert!(pre.preprocessing.optimization.for_size);
1743 assert!(pre.preprocessing.optimization.strict);
1744 assert_eq!(
1745 pre.preprocessing.replacements[0].value,
1746 "getCapturePercentage"
1747 );
1748 assert_eq!(pre.defines.len(), 1);
1749 }
1750
1751 #[test]
1752 fn backend_only_directives_are_validated_and_recorded() {
1753 let (pre, _) = preprocess(
1754 "#!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",
1755 "main.opy",
1756 Path::new("."),
1757 )
1758 .unwrap();
1759 let names: Vec<&str> = pre
1760 .preprocessing
1761 .directives
1762 .iter()
1763 .map(|directive| directive.name.as_str())
1764 .collect();
1765 assert_eq!(
1766 names,
1767 vec![
1768 "excludeVariablesInCompilation",
1769 "extension",
1770 "setupTags",
1771 "setupTx",
1772 "translateWithPlayerVar",
1773 "disableInspector",
1774 "writeToOutputFile",
1775 "disableTranslationSourceLines",
1776 "keepUnusedTranslations",
1777 "useVariableForCompressionAlphabet",
1778 "debugElementCount",
1779 "globalvarInitRuleName",
1780 "playervarInitRuleName",
1781 ]
1782 );
1783 assert_eq!(
1784 pre.preprocessing.directives[1].value.as_deref(),
1785 Some("projectiles")
1786 );
1787 assert_eq!(
1788 pre.preprocessing.directives[4].value.as_deref(),
1789 Some("noDetectionRule noTlErr")
1790 );
1791 }
1792
1793 #[test]
1794 fn extension_directive_rejects_unknown_schema_values() {
1795 let error = preprocess(
1796 "#!extension notAnExtension\nrule \"r\":\n pass\n",
1797 "main.opy",
1798 Path::new("."),
1799 )
1800 .unwrap_err();
1801 assert_eq!(error.code, "directive-invalid");
1802 }
1803
1804 #[test]
1805 fn translations_follow_pinned_codes_without_local_deduplication() {
1806 let (pre, _) = preprocess(
1807 "#!translations EN zh-cn en\nrule \"r\":\n pass\n",
1808 "main.opy",
1809 Path::new("."),
1810 )
1811 .unwrap();
1812 assert_eq!(
1813 pre.preprocessing.translations.unwrap().languages,
1814 vec!["en", "zh_cn", "en"]
1815 );
1816 }
1817
1818 #[test]
1819 fn translations_reject_codes_outside_the_pinned_oracle_set() {
1820 let error = preprocess(
1821 "#!translations en_US\nrule \"r\":\n pass\n",
1822 "main.opy",
1823 Path::new("."),
1824 )
1825 .unwrap_err();
1826 assert_eq!(error.code, "translations-invalid");
1827 }
1828
1829 #[test]
1830 fn directive_records_expose_state_transitions_and_include_depth() {
1831 let root =
1832 std::env::temp_dir().join(format!("wright-opy-directive-scope-{}", std::process::id()));
1833 std::fs::create_dir_all(&root).unwrap();
1834 std::fs::write(
1835 root.join("child.opy"),
1836 "#!rulePrefix \"inner\"\n#!disableOptimizations\n",
1837 )
1838 .unwrap();
1839 let (pre, _) = preprocess(
1840 "#!rulePrefix \"outer\"\n#!include \"child.opy\"\n#!enableOptimizations\n",
1841 "main.opy",
1842 &root,
1843 )
1844 .unwrap();
1845 let records = &pre.preprocessing.directives;
1846 assert_eq!(records[0].state.rule_prefix.as_deref(), Some("outer"));
1847 assert_eq!(records[0].scope_depth, 0);
1848 assert_eq!(records[1].name, "rulePrefix");
1849 assert_eq!(records[1].state.rule_prefix.as_deref(), Some("inner"));
1850 assert!(!records[2].state.optimization.enabled);
1851 assert_eq!(records[2].scope_depth, 1);
1852 assert_eq!(records[3].name, "include");
1853 assert_eq!(records[3].state.rule_prefix.as_deref(), Some("outer"));
1854 assert_eq!(records[4].name, "enableOptimizations");
1855 assert!(records[4].state.optimization.enabled);
1856 let _ = std::fs::remove_dir_all(&root);
1857 }
1858
1859 #[test]
1860 fn malformed_translation_state_is_source_located() {
1861 let error = preprocess("#!translations\n", "main.opy", Path::new(".")).unwrap_err();
1862 assert_eq!(error.code, "translations-invalid");
1863 assert!(error.span.is_some());
1864 }
1865}