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