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