1use std::collections::BTreeMap;
43use std::path::{Path, PathBuf};
44
45use opy_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 span: Option<Span>,
61}
62
63#[derive(Debug, Clone, PartialEq)]
65pub struct ScriptMacro {
66 pub path: String,
69 pub source: String,
71}
72
73#[derive(Debug, Clone, PartialEq)]
79pub struct PostCompileHook {
80 pub path: String,
82 pub source: String,
84 pub span: Span,
86}
87
88#[derive(Debug, Clone)]
90pub struct Preprocessed {
91 pub tokens: Vec<Token>,
93 pub defines: Vec<DefineRecord>,
95 pub settings: Option<SettingsBlock>,
97 pub post_compile_hook: Option<PostCompileHook>,
99 pub preprocessing: PreprocessingState,
101}
102
103#[derive(Debug, Clone, PartialEq)]
105pub struct FileRecord {
106 pub id: u32,
107 pub path: String,
108}
109
110pub fn preprocess(
112 main_text: &str,
113 main_path: &str,
114 root: &Path,
115) -> OpyResult<(Preprocessed, Vec<FileRecord>)> {
116 preprocess_with_overlay(main_text, main_path, root, &BTreeMap::new())
117}
118
119pub fn preprocess_with_overlay(
124 main_text: &str,
125 main_path: &str,
126 root: &Path,
127 overlay: &BTreeMap<String, String>,
128) -> OpyResult<(Preprocessed, Vec<FileRecord>)> {
129 preprocess_with_overlay_outcome(main_text, main_path, root, overlay).result
130}
131
132pub struct PreprocessOutcome {
136 pub result: OpyResult<(Preprocessed, Vec<FileRecord>)>,
137 pub files: Vec<FileRecord>,
138}
139
140pub fn preprocess_with_overlay_outcome(
143 main_text: &str,
144 main_path: &str,
145 root: &Path,
146 overlay: &BTreeMap<String, String>,
147) -> PreprocessOutcome {
148 let mut pre = Preprocessor {
149 files: vec![FileRecord {
150 id: 0,
151 path: main_path.to_string(),
152 }],
153 next_file_id: 1,
154 root: root.to_path_buf(),
155 overlay: overlay.clone(),
156 include_stack: Vec::new(),
157 macros: Vec::new(),
158 defines: Vec::new(),
159 post_compile_hook: None,
160 preprocessing: PreprocessingState::default(),
161 };
162 let mut owned_main_text = None;
163 let mut source_file_id = 0;
164 let first_line = main_text.lines().next().unwrap_or_default();
165 if first_line.trim_start().starts_with("#!mainFile")
166 && first_main_file_directive(main_text).is_none()
167 {
168 let span = Span::new(
169 0,
170 crate::diag::Position::new(1, 1),
171 crate::diag::Position::new(1, first_line.chars().count() as u32 + 1),
172 );
173 return PreprocessOutcome {
174 result: Err(OpyError::at(
175 "main-file-invalid",
176 "`#!mainFile` expects one quoted path on the first line",
177 span,
178 )),
179 files: pre.files,
180 };
181 }
182 if let Some((main_file, span)) = first_main_file_directive(main_text) {
183 let candidate = root.join(&main_file);
184 let canonical = std::fs::canonicalize(&candidate).ok();
185 let overlay_text = overlay
186 .get(&main_file)
187 .or_else(|| {
188 canonical
189 .as_ref()
190 .and_then(|path| overlay.get(&path.to_string_lossy().into_owned()))
191 })
192 .cloned();
193 let (text, display_path, new_root) = match overlay_text {
194 Some(text) => {
195 let display_path = candidate.to_string_lossy().into_owned();
196 let new_root = candidate
197 .parent()
198 .map(Path::to_path_buf)
199 .unwrap_or_else(|| root.to_path_buf());
200 (text, display_path, new_root)
201 }
202 None => {
203 let Some(canonical) = canonical else {
204 return PreprocessOutcome {
205 result: Err(OpyError::at(
206 "main-file-not-found",
207 format!("cannot find main file '{main_file}'"),
208 span,
209 )),
210 files: pre.files,
211 };
212 };
213 let text = match std::fs::read_to_string(&canonical) {
214 Ok(text) => text,
215 Err(error) => {
216 return PreprocessOutcome {
217 result: Err(OpyError::at(
218 "main-file-not-found",
219 format!("cannot read main file '{main_file}': {error}"),
220 span,
221 )),
222 files: pre.files,
223 };
224 }
225 };
226 let new_root = canonical
227 .parent()
228 .map(Path::to_path_buf)
229 .unwrap_or_else(|| root.to_path_buf());
230 (text, canonical.to_string_lossy().into_owned(), new_root)
231 }
232 };
233 owned_main_text = Some(text);
234 source_file_id = 1;
235 pre.files.push(FileRecord {
236 id: source_file_id,
237 path: display_path,
238 });
239 pre.next_file_id = 2;
240 pre.root = new_root;
241 pre.preprocessing.main_file = Some(DirectiveValue {
242 value: main_file.clone(),
243 span: Some(span.into()),
244 });
245 pre.record("mainFile", Some(&main_file), span);
246 }
247 let source_text = owned_main_text.as_deref().unwrap_or(main_text);
248 let settings = match crate::settings::find_blocks(source_text, source_file_id) {
252 Ok(mut blocks) => blocks.pop(),
253 Err(error) => {
254 return PreprocessOutcome {
255 result: Err(error),
256 files: pre.files,
257 };
258 }
259 };
260 let tokens = match &settings {
261 Some(block) => {
262 let sanitized = crate::settings::sanitize_for_lex(source_text, block);
263 lex(LexInput {
264 file_id: source_file_id,
265 text: &sanitized,
266 })
267 }
268 None => lex(LexInput {
269 file_id: source_file_id,
270 text: source_text,
271 }),
272 };
273 let mut tokens = match tokens {
274 Ok(tokens) => tokens,
275 Err(error) => {
276 return PreprocessOutcome {
277 result: Err(error),
278 files: pre.files,
279 };
280 }
281 };
282 if let Err(error) = pre.process_directives(&mut tokens) {
283 return PreprocessOutcome {
284 result: Err(error),
285 files: pre.files,
286 };
287 }
288 match pre.expand(tokens) {
289 Ok(tokens) => {
290 let result = Ok((
291 Preprocessed {
292 tokens,
293 defines: pre.defines,
294 settings,
295 post_compile_hook: pre.post_compile_hook,
296 preprocessing: pre.preprocessing,
297 },
298 pre.files.clone(),
299 ));
300 PreprocessOutcome {
301 result,
302 files: pre.files,
303 }
304 }
305 Err(error) => PreprocessOutcome {
306 result: Err(error),
307 files: pre.files,
308 },
309 }
310}
311
312struct Preprocessor {
313 files: Vec<FileRecord>,
314 next_file_id: u32,
315 root: PathBuf,
316 overlay: BTreeMap<String, String>,
317 include_stack: Vec<PathBuf>,
318 macros: Vec<MacroDef>,
319 defines: Vec<DefineRecord>,
320 post_compile_hook: Option<PostCompileHook>,
321 preprocessing: PreprocessingState,
322}
323
324struct MacroDef {
326 name: String,
327 params: Vec<String>,
328 body: Vec<Token>,
329 is_function: bool,
331 script: Option<ScriptMacro>,
333}
334
335fn first_main_file_directive(text: &str) -> Option<(String, Span)> {
336 let line = text.lines().next()?.trim_end_matches('\r');
337 let rest = line.strip_prefix("#!mainFile")?;
338 let value = rest.trim();
339 let value = strip_quoted(value)?.to_string();
340 let end_col = line.chars().count() as u32 + 1;
341 Some((
342 value,
343 Span::new(
344 0,
345 crate::diag::Position::new(1, 1),
346 crate::diag::Position::new(1, end_col),
347 ),
348 ))
349}
350
351impl Preprocessor {
352 fn process_directives(&mut self, tokens: &mut Vec<Token>) -> OpyResult<()> {
355 let mut out: Vec<Token> = Vec::with_capacity(tokens.len());
356 for token in tokens.drain(..) {
357 if token.kind == TokenKind::Directive {
358 self.handle_directive(token, &mut out)?;
359 } else if token.kind == TokenKind::Ident
360 && matches!(token.text.as_str(), "rule" | "def")
361 && self.preprocessing.rule_prefix.is_some()
362 {
363 let prefix = self
364 .preprocessing
365 .rule_prefix
366 .as_ref()
367 .map(|value| value.value.clone())
368 .unwrap_or_default();
369 out.push(Token {
370 kind: TokenKind::RulePrefixMarker,
371 text: prefix,
372 raw: None,
373 span: token.span,
374 });
375 out.push(token);
376 } else {
377 out.push(token);
378 }
379 }
380 *tokens = out;
381 Ok(())
382 }
383
384 fn handle_directive(&mut self, token: Token, out: &mut Vec<Token>) -> OpyResult<()> {
385 let text = token.text.trim();
386 let span = token.span;
387 let (name, rest) = split_directive(text);
388 if name == "include" {
389 let rest = rest.trim();
390 let include = rest
391 .strip_prefix('"')
392 .and_then(|r| r.strip_suffix('"'))
393 .or_else(|| rest.strip_prefix('\'').and_then(|r| r.strip_suffix('\'')));
394 let Some(include) = include else {
395 return Err(OpyError::at(
396 "include-invalid",
397 format!(
398 "invalid include directive: `{text}` (expected `#!include \"file.opy\"`)"
399 ),
400 span,
401 ));
402 };
403 self.include(include, span, out)?;
404 return Ok(());
405 }
406 if name == "define" {
407 self.define(rest.trim(), span)?;
408 return Ok(());
409 }
410 if name == "undef" {
411 let name = rest.trim();
412 if name.is_empty() || name.chars().any(|ch| !is_identifier_char(ch)) {
413 return Err(OpyError::at(
414 "undef-invalid",
415 "malformed `#!undef` directive: expected one macro name",
416 span,
417 ));
418 }
419 self.macros.retain(|m| m.name != name);
420 self.defines.retain(|define| define.name != name);
421 self.record("undef", Some(name), span);
422 return Ok(());
423 }
424 if name == "postCompileHook" {
425 let rest = rest.trim();
426 let Some(path) = strip_quoted(rest) else {
427 return Err(OpyError::at(
428 "script-invalid",
429 format!(
430 "invalid postCompileHook directive: `{text}` (expected `#!postCompileHook \"hook.js\"`)"
431 ),
432 span,
433 ));
434 };
435 if self.post_compile_hook.is_some() {
436 return Err(OpyError::at(
437 "post-compile-hook-duplicate",
438 "post-compile hook is already defined".to_string(),
439 span,
440 ));
441 }
442 let hook = self.resolve_script(path, span)?;
443 self.post_compile_hook = Some(PostCompileHook {
444 path: hook.path,
445 source: hook.source,
446 span,
447 });
448 self.record("postCompileHook", Some(path), span);
449 return Ok(());
450 }
451 if name == "mainFile" {
452 return Err(OpyError::at(
453 "main-file-placement",
454 "`#!mainFile` must be the first directive in the main source",
455 span,
456 ));
457 }
458 if name == "allowMacroRedeclaration" {
459 self.preprocessing.allow_macro_redeclaration = true;
460 self.record(name, None, span);
461 return Ok(());
462 }
463 if name == "translations" {
464 let languages = parse_translations(rest.trim(), span)?;
465 self.preprocessing.translations = Some(TranslationState {
466 languages: languages.clone(),
467 span: Some(span.into()),
468 });
469 self.record(name, Some(&languages.join(" ")), span);
470 return Ok(());
471 }
472 if name == "suppressWarnings" {
473 let warnings = parse_words(rest, "suppressWarnings", span)?;
474 self.preprocessing
475 .suppressed_warnings
476 .extend(warnings.clone());
477 self.record(name, Some(&warnings.join(" ")), span);
478 return Ok(());
479 }
480 if name == "rulePrefix" {
481 let prefix = strip_quoted(rest.trim()).ok_or_else(|| {
482 OpyError::at(
483 "rule-prefix-invalid",
484 "`#!rulePrefix` expects one quoted string",
485 span,
486 )
487 })?;
488 self.preprocessing.rule_prefix = Some(DirectiveValue {
489 value: prefix.to_string(),
490 span: Some(span.into()),
491 });
492 self.record(name, Some(prefix), span);
493 return Ok(());
494 }
495 if name == "rulePrefixTemplate" {
496 if self.preprocessing.rule_prefix_template.is_some() {
497 return Err(OpyError::at(
498 "rule-prefix-template-duplicate",
499 "a rule prefix template is already defined",
500 span,
501 ));
502 }
503 let template = if rest.trim().is_empty() {
504 r#"f"[{$pathTitle.replace('_', ' ')}] {$rule}" if $rule and not $isDelimiter else $rule"#
505 } else {
506 rest.trim()
507 };
508 self.preprocessing.rule_prefix_template = Some(DirectiveValue {
509 value: template.to_string(),
510 span: Some(span.into()),
511 });
512 self.record(name, Some(template), span);
513 return Ok(());
514 }
515 if let Some((directive, control)) = optimization_directive(name) {
516 apply_optimization(&mut self.preprocessing.optimization, control);
517 self.record(directive, None, span);
518 return Ok(());
519 }
520 if let Some(replacement) = replacement_directive(name) {
521 let family = replacement_family(name).expect("replacement directive family");
522 if self
523 .preprocessing
524 .directives
525 .iter()
526 .filter_map(|item| replacement_family(&item.name))
527 .any(|item_family| item_family == family)
528 {
529 return Err(OpyError::at(
530 "replacement-duplicate",
531 format!("a replacement for `{family}` is already defined"),
532 span,
533 ));
534 }
535 self.preprocessing.replacements.push(DirectiveValue {
536 value: replacement.to_string(),
537 span: Some(span.into()),
538 });
539 self.record(name, Some(replacement), span);
540 return Ok(());
541 }
542 Err(OpyError::at(
543 "unsupported-directive",
544 format!("unsupported preprocessing directive `#!{text}`"),
545 span,
546 ))
547 }
548
549 fn record(&mut self, name: &str, value: Option<&str>, span: Span) {
550 let state = PreprocessingSnapshot {
551 allow_macro_redeclaration: self.preprocessing.allow_macro_redeclaration,
552 optimization: self.preprocessing.optimization.clone(),
553 rule_prefix: self
554 .preprocessing
555 .rule_prefix
556 .as_ref()
557 .map(|value| value.value.clone()),
558 rule_prefix_template: self
559 .preprocessing
560 .rule_prefix_template
561 .as_ref()
562 .map(|value| value.value.clone()),
563 translations: self
564 .preprocessing
565 .translations
566 .as_ref()
567 .map(|translations| translations.languages.clone()),
568 replacements: self
569 .preprocessing
570 .replacements
571 .iter()
572 .map(|value| value.value.clone())
573 .collect(),
574 };
575 self.preprocessing.directives.push(DirectiveRecord {
576 name: name.to_string(),
577 value: value.map(str::to_string),
578 scope_col: span.start.col,
579 scope_depth: self.include_stack.len() as u32,
580 state,
581 span: Some(span.into()),
582 });
583 }
584
585 fn resolve_script(&self, path: &str, span: Span) -> OpyResult<ScriptMacro> {
588 let canonical = self.root.join(path).canonicalize().map_err(|_| {
589 OpyError::at(
590 "script-not-found",
591 format!(
592 "cannot find script '{path}' under root '{}'",
593 self.root.display()
594 ),
595 span,
596 )
597 })?;
598 let source = std::fs::read_to_string(&canonical).map_err(|error| {
599 OpyError::at(
600 "script-not-found",
601 format!("cannot read script '{path}': {error}"),
602 span,
603 )
604 })?;
605 Ok(ScriptMacro {
606 path: path.to_string(),
607 source,
608 })
609 }
610
611 fn include(&mut self, include: &str, span: Span, out: &mut Vec<Token>) -> OpyResult<()> {
613 let candidate = self.root.join(include);
616 let canonical = std::fs::canonicalize(&candidate).ok();
617 let overlay_text = self
621 .overlay
622 .get(include)
623 .or_else(|| {
624 canonical
625 .as_ref()
626 .and_then(|path| self.overlay.get(&path.to_string_lossy().into_owned()))
627 })
628 .cloned();
629
630 let identity = canonical.clone().unwrap_or_else(|| candidate.clone());
633 if self.include_stack.contains(&identity) {
634 return Err(OpyError::at(
635 "include-cycle",
636 format!(
637 "include cycle detected: '{}' is already being included",
638 identity.display()
639 ),
640 span,
641 ));
642 }
643
644 let text = match overlay_text {
645 Some(text) => text,
646 None => {
647 let canonical = canonical.ok_or_else(|| {
648 OpyError::at(
649 "include-not-found",
650 format!(
651 "cannot find included file '{include}' under root '{}'",
652 self.root.display()
653 ),
654 span,
655 )
656 })?;
657 std::fs::read_to_string(&canonical).map_err(|error| {
658 OpyError::at(
659 "include-not-found",
660 format!("cannot read included file '{include}': {error}"),
661 span,
662 )
663 })?
664 }
665 };
666 let file_id = self.next_file_id;
668 self.next_file_id += 1;
669 self.files.push(FileRecord {
670 id: file_id,
671 path: include.to_string(),
672 });
673 self.include_stack.push(identity);
674 let saved_prefix = self.preprocessing.rule_prefix.clone();
675 let saved_optimization = self.preprocessing.optimization.clone();
676 match crate::settings::find_blocks(&text, file_id) {
680 Err(error) => return Err(error),
681 Ok(blocks) if !blocks.is_empty() => {
682 return Err(OpyError::at(
683 "settings-placement",
684 "settings blocks are only supported in the main file".to_string(),
685 blocks[0].keyword_span,
686 ));
687 }
688 Ok(_) => {}
689 }
690 let mut included = lex(LexInput {
691 file_id,
692 text: &text,
693 })?;
694 let processed = self.process_directives(&mut included);
695 self.preprocessing.rule_prefix = saved_prefix;
696 self.preprocessing.optimization = saved_optimization;
697 if let Err(error) = processed {
698 self.include_stack.pop();
699 return Err(error);
700 }
701 included.retain(|token| token.kind != TokenKind::Eof);
704 out.extend(included);
709 self.include_stack.pop();
710 self.record("include", Some(include), span);
711 Ok(())
712 }
713
714 fn define(&mut self, rest: &str, span: Span) -> OpyResult<()> {
720 let rest = rest.trim();
721 let first_open = rest.find('(').unwrap_or(usize::MAX);
722 let first_space = rest.find(char::is_whitespace).unwrap_or(usize::MAX);
723 let is_function_like = first_open < first_space;
724
725 let (name, params, body_text) = if is_function_like {
726 let name = rest[..first_open].trim();
727 let Some(close) = rest[first_open..].find(')') else {
728 return Err(OpyError::at(
729 "define-invalid",
730 format!("malformed function-like define `#!define {rest}`: missing `)`"),
731 span,
732 ));
733 };
734 let close = first_open + close;
735 let params: Vec<String> = rest[first_open + 1..close]
736 .split(',')
737 .map(|p| p.trim().to_string())
738 .filter(|p| !p.is_empty())
739 .collect();
740 let body = rest[close + 1..].trim();
741 (name.to_string(), params, body.to_string())
742 } else {
743 let name = rest[..first_space].trim();
744 let body = if first_space == usize::MAX {
745 String::new()
746 } else {
747 rest[first_space..].trim().to_string()
748 };
749 (name.to_string(), Vec::new(), body)
750 };
751 if name.is_empty() {
752 return Err(OpyError::at(
753 "define-invalid",
754 "malformed `#!define` directive: missing macro name",
755 span,
756 ));
757 }
758 if self.macros.iter().any(|macro_def| macro_def.name == name) {
759 if !self.preprocessing.allow_macro_redeclaration {
760 return Err(OpyError::at(
761 "macro-redeclaration",
762 format!("macro '{name}' is already defined"),
763 span,
764 ));
765 }
766 self.macros.retain(|macro_def| macro_def.name != name);
767 self.defines.retain(|define| define.name != name);
768 }
769 let script = if is_function_like && body_text.starts_with("__script__(") {
770 let inner = &body_text["__script__(".len()..];
775 let inner = inner.strip_suffix(')').ok_or_else(|| {
776 OpyError::at(
777 "script-invalid",
778 format!(
779 "malformed script macro `#!define {rest}`: expected `__script__(\"path.js\")`"
780 ),
781 span,
782 )
783 })?;
784 let Some(path) = strip_quoted(inner.trim()) else {
785 return Err(OpyError::at(
786 "script-invalid",
787 format!(
788 "malformed script macro `#!define {rest}`: expected a quoted script path"
789 ),
790 span,
791 ));
792 };
793 Some(self.resolve_script(path, span)?)
794 } else {
795 None
796 };
797 let body_tokens = lex(LexInput {
798 file_id: span.file,
799 text: &body_text,
800 })?;
801 let body_tokens: Vec<Token> = body_tokens
803 .into_iter()
804 .filter(|t| t.kind != TokenKind::Eof)
805 .collect();
806 let is_function = is_function_like;
807 self.defines.push(DefineRecord {
808 name: name.clone(),
809 is_function,
810 span: Some(span),
811 });
812 self.macros.push(MacroDef {
813 name,
814 params,
815 body: body_tokens,
816 is_function,
817 script,
818 });
819 Ok(())
820 }
821
822 fn expand(&self, tokens: Vec<Token>) -> OpyResult<Vec<Token>> {
824 let mut out = Vec::new();
825 let mut index = 0;
826 while index < tokens.len() {
827 let token = &tokens[index];
828 if token.kind == TokenKind::Ident {
829 let name = token.text.clone();
830 if let Some(mac) = self.macros.iter().find(|m| m.name == name) {
831 if mac.is_function {
832 let cursor = index + 1;
834 if cursor < tokens.len() && tokens[cursor].kind == TokenKind::LParen {
835 let (args, after) = self.collect_args(&tokens, cursor)?;
836 let mut expanded = self.expand_macro(mac, args, token.span)?;
837 self.expand_into(&mut expanded, &mut Vec::new(), 0)?;
838 out.append(&mut expanded);
839 index = after;
840 continue;
841 }
842 out.push(token.clone());
845 index += 1;
846 continue;
847 }
848 let mut expanded = self.expand_macro(mac, Vec::new(), token.span)?;
849 self.expand_into(&mut expanded, &mut Vec::new(), 0)?;
850 out.append(&mut expanded);
851 index += 1;
852 continue;
853 }
854 }
855 out.push(token.clone());
856 index += 1;
857 }
858 Ok(out)
859 }
860
861 fn collect_args(&self, tokens: &[Token], open: usize) -> OpyResult<(Vec<Vec<Token>>, usize)> {
864 let mut args: Vec<Vec<Token>> = Vec::new();
865 let mut current: Vec<Token> = Vec::new();
866 let mut depth = 0usize;
867 let mut cursor = open + 1;
868 while cursor < tokens.len() {
869 let kind = tokens[cursor].kind;
870 if kind == TokenKind::LParen {
871 depth += 1;
872 current.push(tokens[cursor].clone());
873 } else if kind == TokenKind::RParen {
874 if depth == 0 {
875 args.push(std::mem::take(&mut current));
876 return Ok((args, cursor + 1));
877 }
878 depth -= 1;
879 current.push(tokens[cursor].clone());
880 } else if kind == TokenKind::Comma && depth == 0 {
881 args.push(std::mem::take(&mut current));
882 } else {
883 current.push(tokens[cursor].clone());
884 }
885 cursor += 1;
886 }
887 Err(OpyError::new(
888 "macro-invalid",
889 "unterminated macro invocation: missing closing `)`",
890 ))
891 }
892
893 fn expand_macro(
900 &self,
901 mac: &MacroDef,
902 args: Vec<Vec<Token>>,
903 use_site: Span,
904 ) -> OpyResult<Vec<Token>> {
905 if mac.is_function && args.len() != mac.params.len() {
906 return Err(OpyError::at(
907 "macro-arity",
908 format!(
909 "macro '{}' expects {} argument(s) but got {}",
910 mac.name,
911 mac.params.len(),
912 args.len()
913 ),
914 use_site,
915 ));
916 }
917 if let Some(script) = &mac.script {
918 return self.expand_script(mac, script, args, use_site);
919 }
920 let mut out = Vec::new();
921 for token in &mac.body {
922 if mac.is_function
923 && token.kind == TokenKind::Ident
924 && mac.params.iter().any(|p| p == &token.text)
925 {
926 let param_index = mac
927 .params
928 .iter()
929 .position(|p| p == &token.text)
930 .expect("checked above");
931 let mut replacement = args.get(param_index).cloned().unwrap_or_default();
932 for replacement_token in &mut replacement {
933 replacement_token.span = use_site;
934 }
935 out.extend(replacement);
936 } else {
937 let mut token = token.clone();
938 token.span = use_site;
939 out.push(token);
940 }
941 }
942 Ok(out)
943 }
944
945 fn expand_script(
957 &self,
958 mac: &MacroDef,
959 script: &ScriptMacro,
960 args: Vec<Vec<Token>>,
961 use_site: Span,
962 ) -> OpyResult<Vec<Token>> {
963 let macro_args: Vec<MacroArg> = mac
964 .params
965 .iter()
966 .zip(args.iter())
967 .map(|(param, tokens)| MacroArg::new(param.clone(), raw_arg_text(tokens)))
968 .collect();
969 let runtime = MacroRuntime::new(Limits::default());
972 let result = runtime
973 .run_macro(&script.source, ¯o_args, &script.path)
974 .map_err(|error| map_macro_error(&error, &script.path, use_site))?;
975 let indent = " ".repeat(use_site.start.col.saturating_sub(1) as usize);
978 let indented = result.text.replace('\n', &format!("\n{indent}"));
979 let mut tokens = lex(LexInput {
980 file_id: use_site.file,
981 text: &indented,
982 })?;
983 tokens.retain(|token| token.kind != TokenKind::Eof);
984 for token in &mut tokens {
985 token.span = use_site;
986 }
987 Ok(tokens)
988 }
989
990 fn expand_into(
993 &self,
994 tokens: &mut Vec<Token>,
995 stack: &mut Vec<String>,
996 depth: usize,
997 ) -> OpyResult<()> {
998 if depth > 64 {
999 return Err(OpyError::new(
1000 "macro-recursion",
1001 "macro expansion exceeded the recursion limit (possible recursive define)",
1002 ));
1003 }
1004 let mut out: Vec<Token> = Vec::with_capacity(tokens.len());
1005 let mut index = 0;
1006 while index < tokens.len() {
1007 let token = &tokens[index];
1008 if token.kind == TokenKind::Ident {
1009 let name = token.text.clone();
1010 if let Some(mac) = self.macros.iter().find(|m| m.name == name) {
1011 if stack.iter().any(|s| s == &name) {
1012 return Err(OpyError::new(
1013 "macro-recursion",
1014 format!("recursive macro expansion detected for '{name}'"),
1015 ));
1016 }
1017 if mac.is_function {
1018 if index + 1 < tokens.len() && tokens[index + 1].kind == TokenKind::LParen {
1019 let (args, after) = self.collect_args(tokens, index)?;
1020 let mut expanded = self.expand_macro(mac, args, token.span)?;
1021 stack.push(name.clone());
1022 self.expand_into(&mut expanded, stack, depth + 1)?;
1023 stack.pop();
1024 out.append(&mut expanded);
1025 index = after;
1026 continue;
1027 }
1028 out.push(token.clone());
1029 index += 1;
1030 continue;
1031 }
1032 let mut expanded = self.expand_macro(mac, Vec::new(), token.span)?;
1033 stack.push(name.clone());
1034 self.expand_into(&mut expanded, stack, depth + 1)?;
1035 stack.pop();
1036 out.append(&mut expanded);
1037 index += 1;
1038 continue;
1039 }
1040 }
1041 out.push(token.clone());
1042 index += 1;
1043 }
1044 *tokens = out;
1045 Ok(())
1046 }
1047}
1048
1049fn split_directive(text: &str) -> (&str, &str) {
1050 text.split_once(char::is_whitespace)
1051 .map_or((text, ""), |(name, rest)| (name, rest))
1052}
1053
1054fn is_identifier_char(ch: char) -> bool {
1055 ch.is_ascii_alphanumeric() || ch == '_'
1056}
1057
1058fn parse_words(rest: &str, directive: &str, span: Span) -> OpyResult<Vec<String>> {
1059 let words: Vec<String> = rest.split_whitespace().map(str::to_string).collect();
1060 if words.is_empty() {
1061 return Err(OpyError::at(
1062 "directive-invalid",
1063 format!("`#!{directive}` expects at least one argument"),
1064 span,
1065 ));
1066 }
1067 if words
1068 .iter()
1069 .any(|word| word.chars().any(|ch| !is_identifier_char(ch)))
1070 {
1071 return Err(OpyError::at(
1072 "directive-invalid",
1073 format!("`#!{directive}` arguments must be identifiers"),
1074 span,
1075 ));
1076 }
1077 Ok(words)
1078}
1079
1080fn parse_translations(rest: &str, span: Span) -> OpyResult<Vec<String>> {
1081 let values: Vec<String> = rest
1082 .split_whitespace()
1083 .map(|language| language.replace('-', "_").to_lowercase())
1084 .collect();
1085 if values.is_empty() {
1086 return Err(OpyError::at(
1087 "translations-invalid",
1088 "`#!translations` expects at least one language",
1089 span,
1090 ));
1091 }
1092 const PINNED_LANGUAGES: &[&str] = &[
1093 "de", "en", "es", "es_es", "es_mx", "fr", "it", "ja", "ko", "pl", "pt", "ru", "th", "tr",
1094 "zh", "zh_cn", "zh_tw",
1095 ];
1096 if values
1097 .iter()
1098 .any(|language| !PINNED_LANGUAGES.contains(&language.as_str()))
1099 {
1100 return Err(OpyError::at(
1101 "translations-invalid",
1102 "invalid translation language; expected one of the pinned OverPy language codes",
1103 span,
1104 ));
1105 }
1106 if values.iter().any(|value| value == "es")
1107 && values
1108 .iter()
1109 .any(|value| value == "es_es" || value == "es_mx")
1110 {
1111 return Err(OpyError::at(
1112 "translations-invalid",
1113 "cannot combine `es` with `es_es` or `es_mx`",
1114 span,
1115 ));
1116 }
1117 if values.iter().any(|value| value == "zh")
1118 && values
1119 .iter()
1120 .any(|value| value == "zh_cn" || value == "zh_tw")
1121 {
1122 return Err(OpyError::at(
1123 "translations-invalid",
1124 "cannot combine `zh` with `zh_cn` or `zh_tw`",
1125 span,
1126 ));
1127 }
1128 Ok(values)
1129}
1130
1131#[derive(Clone, Copy)]
1132enum OptimizationControl {
1133 Enable,
1134 Disable,
1135 ForSize,
1136 DisableForSize,
1137 ForSizeAggressive,
1138 Strict,
1139 DisableStrict,
1140}
1141
1142fn optimization_directive(name: &str) -> Option<(&str, OptimizationControl)> {
1143 Some(match name {
1144 "disableOptimizations" => (name, OptimizationControl::Disable),
1145 "enableOptimizations" => (name, OptimizationControl::Enable),
1146 "optimizeForSize" => (name, OptimizationControl::ForSize),
1147 "disableOptimizeForSize" => (name, OptimizationControl::DisableForSize),
1148 "optimizeForSizeAggressive" => (name, OptimizationControl::ForSizeAggressive),
1149 "optimizeStrict" => (name, OptimizationControl::Strict),
1150 "disableOptimizeStrict" => (name, OptimizationControl::DisableStrict),
1151 _ => return None,
1152 })
1153}
1154
1155fn apply_optimization(state: &mut OptimizationState, control: OptimizationControl) {
1156 match control {
1157 OptimizationControl::Enable => state.enabled = true,
1158 OptimizationControl::Disable => state.enabled = false,
1159 OptimizationControl::ForSize => state.for_size = true,
1160 OptimizationControl::DisableForSize => state.for_size = false,
1161 OptimizationControl::ForSizeAggressive => state.for_size_aggressive = true,
1162 OptimizationControl::Strict => state.strict = true,
1163 OptimizationControl::DisableStrict => state.strict = false,
1164 }
1165}
1166
1167fn replacement_directive(name: &str) -> Option<&str> {
1168 Some(match name {
1169 "replace0ByCapturePercentage" => "getCapturePercentage",
1170 "replace0ByPayloadProgressPercentage" => "getPayloadProgressPercentage",
1171 "replace0ByIsMatchComplete" => "isMatchComplete",
1172 "replace1ByMatchRound" => "getMatchRound",
1173 "replaceTeam1ByControlScoringTeam" => "getControlScoringTeam",
1174 "replaceEmptyStringByEmptyArray" => "emptyArray",
1175 "replaceEmptyStringByVariable" => "variable",
1176 _ => return None,
1177 })
1178}
1179
1180fn replacement_family(name: &str) -> Option<&str> {
1181 Some(match name {
1182 "replace0ByCapturePercentage"
1183 | "replace0ByPayloadProgressPercentage"
1184 | "replace0ByIsMatchComplete" => "0",
1185 "replace1ByMatchRound" => "1",
1186 "replaceTeam1ByControlScoringTeam" => "team1",
1187 "replaceEmptyStringByEmptyArray" | "replaceEmptyStringByVariable" => "emptyString",
1188 _ => return None,
1189 })
1190}
1191
1192fn strip_quoted(text: &str) -> Option<&str> {
1194 text.strip_prefix('"')
1195 .and_then(|rest| rest.strip_suffix('"'))
1196 .or_else(|| {
1197 text.strip_prefix('\'')
1198 .and_then(|rest| rest.strip_suffix('\''))
1199 })
1200}
1201
1202fn raw_arg_text(tokens: &[Token]) -> String {
1211 let mut out = String::new();
1212 for token in tokens {
1213 match token.kind {
1214 TokenKind::String => out.push_str(&json_string_literal(&token.text)),
1215 TokenKind::Newline => out.push('\n'),
1216 _ => out.push_str(&token.text),
1217 }
1218 }
1219 out
1220}
1221
1222fn json_string_literal(value: &str) -> String {
1224 serde_json::to_string(value).expect("serializing a string is infallible")
1225}
1226
1227pub(crate) fn map_macro_error(error: &MacroError, script_path: &str, span: Span) -> OpyError {
1239 match error {
1240 MacroError::Script(script) => {
1241 let code = match script.message.as_str() {
1242 "interrupted" => "script-timeout",
1243 "out of memory" => "script-memory-limit",
1244 "Maximum call stack size exceeded" => "script-stack-limit",
1245 _ => "script-error",
1246 };
1247 let location = match (script.line, script.column) {
1248 (Some(line), Some(column)) => format!(" (line {line}, column {column})"),
1249 (Some(line), None) => format!(" (line {line})"),
1250 _ => String::new(),
1251 };
1252 OpyError::at(
1253 code,
1254 format!(
1255 "script '{}' failed: {}{}",
1256 script_path, script.message, location
1257 ),
1258 span,
1259 )
1260 }
1261 MacroError::InvalidResult { type_name } => OpyError::at(
1262 "script-result-not-string",
1263 format!(
1264 "JavaScript macro returned value with type of {type_name}, expected string. Try using .toString()"
1265 ),
1266 span,
1267 ),
1268 MacroError::Internal(message) => OpyError::at(
1269 "script-internal",
1270 format!("script '{}' runtime failure: {message}", script_path),
1271 span,
1272 ),
1273 }
1274}
1275
1276#[cfg(test)]
1277mod tests {
1278 use super::*;
1279
1280 #[test]
1281 fn object_define_expands_at_use_site() {
1282 let (pre, _) = preprocess(
1283 "#!define SIDE 1.5\nrule \"r\":\n x = SIDE\n",
1284 "main.opy",
1285 Path::new("."),
1286 )
1287 .unwrap();
1288 assert_eq!(pre.defines.len(), 1);
1289 assert_eq!(pre.defines[0].name, "SIDE");
1290 assert!(!pre.defines[0].is_function);
1291 let numbers: Vec<&str> = pre
1292 .tokens
1293 .iter()
1294 .filter(|t| t.kind == TokenKind::Number)
1295 .map(|t| t.text.as_str())
1296 .collect();
1297 assert_eq!(numbers, vec!["1.5"]);
1298 }
1299
1300 #[test]
1301 fn function_define_substitutes_params() {
1302 let (pre, _) = preprocess(
1303 "#!define double(x) x + x\nrule \"r\":\n y = double(3)\n",
1304 "main.opy",
1305 Path::new("."),
1306 )
1307 .unwrap();
1308 let numbers: Vec<&str> = pre
1309 .tokens
1310 .iter()
1311 .filter(|t| t.kind == TokenKind::Number)
1312 .map(|t| t.text.as_str())
1313 .collect();
1314 assert_eq!(numbers, vec!["3", "3"]);
1315 }
1316
1317 #[test]
1318 fn recursive_defines_expand_transitively() {
1319 let (pre, _) = preprocess(
1320 "#!define A 2\n#!define B A + 1\nrule \"r\":\n x = B\n",
1321 "main.opy",
1322 Path::new("."),
1323 )
1324 .unwrap();
1325 let numbers: Vec<&str> = pre
1326 .tokens
1327 .iter()
1328 .filter(|t| t.kind == TokenKind::Number)
1329 .map(|t| t.text.as_str())
1330 .collect();
1331 assert_eq!(numbers, vec!["2", "1"]);
1332 }
1333
1334 #[test]
1335 fn recursive_define_fails_structurally() {
1336 let error = preprocess(
1337 "#!define X X + 1\nrule \"r\":\n x = X\n",
1338 "main.opy",
1339 Path::new("."),
1340 )
1341 .unwrap_err();
1342 assert_eq!(error.code, "macro-recursion");
1343 }
1344
1345 #[test]
1346 fn missing_include_is_structured() {
1347 let error = preprocess(
1348 "#!include \"nope.opy\"\n",
1349 "main.opy",
1350 Path::new("/nonexistent-root"),
1351 )
1352 .unwrap_err();
1353 assert_eq!(error.code, "include-not-found");
1354 assert!(error.span.is_some());
1355 }
1356
1357 #[test]
1358 fn include_cycle_is_detected() {
1359 let dir = std::env::temp_dir().join(format!("wright-opy-test-{}", std::process::id()));
1360 std::fs::create_dir_all(&dir).unwrap();
1361 std::fs::write(dir.join("a.opy"), "#!include \"b.opy\"\n").unwrap();
1362 std::fs::write(dir.join("b.opy"), "#!include \"a.opy\"\n").unwrap();
1363 let main = std::fs::read_to_string(dir.join("a.opy")).unwrap();
1364 let error = preprocess(&main, "a.opy", &dir).unwrap_err();
1365 assert_eq!(error.code, "include-cycle");
1366 let _ = std::fs::remove_dir_all(&dir);
1367 }
1368
1369 #[test]
1370 fn unsupported_directive_is_structured() {
1371 let error = preprocess("#!frobnicate\n", "main.opy", Path::new(".")).unwrap_err();
1372 assert_eq!(error.code, "unsupported-directive");
1373 }
1374
1375 #[test]
1376 fn settings_block_is_extracted_before_lexing() {
1377 let (pre, _) = preprocess(
1378 "settings {\n \"gamemodes\": {}\n}\nrule \"r\":\n pass\n",
1379 "main.opy",
1380 Path::new("."),
1381 )
1382 .unwrap();
1383 let block = pre.settings.expect("settings block extracted");
1384 assert!(block.text.contains("gamemodes"));
1385 assert!(
1387 !pre.tokens.iter().any(|t| t.text.contains("gamemodes")),
1388 "settings content must not be lexed"
1389 );
1390 }
1391
1392 #[test]
1393 fn settings_in_include_is_rejected() {
1394 let dir =
1395 std::env::temp_dir().join(format!("wright-opy-settings-test-{}", std::process::id()));
1396 std::fs::create_dir_all(&dir).unwrap();
1397 std::fs::write(
1398 dir.join("shared.opy"),
1399 "settings {\n \"gamemodes\": {}\n}\n",
1400 )
1401 .unwrap();
1402 let main = "#!include \"shared.opy\"\nrule \"r\":\n pass\n";
1403 let error = preprocess(main, "main.opy", &dir).unwrap_err();
1404 assert_eq!(error.code, "settings-placement");
1405 assert_eq!(
1406 error.span.unwrap().file,
1407 1,
1408 "the span names the included file"
1409 );
1410 let _ = std::fs::remove_dir_all(&dir);
1411 }
1412
1413 #[test]
1414 fn dict_literal_braces_reach_the_parser() {
1415 let (pre, _) = preprocess(
1417 "rule \"r\":\n money += {\n Mei.GENERIC: 10,\n }\n",
1418 "main.opy",
1419 Path::new("."),
1420 )
1421 .unwrap();
1422 assert!(
1423 pre.tokens
1424 .iter()
1425 .any(|token| token.kind == TokenKind::LBrace)
1426 );
1427 assert!(
1428 pre.tokens
1429 .iter()
1430 .any(|token| token.kind == TokenKind::RBrace)
1431 );
1432 }
1433
1434 #[test]
1435 fn advanced_directives_preserve_frontend_state_without_catalog_data() {
1436 let (pre, _) = preprocess(
1437 "#!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",
1438 "main.opy",
1439 Path::new("."),
1440 )
1441 .unwrap();
1442 assert!(pre.preprocessing.allow_macro_redeclaration);
1443 assert_eq!(
1444 pre.preprocessing
1445 .translations
1446 .as_ref()
1447 .map(|state| state.languages.as_slice()),
1448 Some(["en".to_string(), "fr".to_string()].as_slice())
1449 );
1450 assert_eq!(
1451 pre.preprocessing
1452 .rule_prefix
1453 .as_ref()
1454 .map(|value| value.value.as_str()),
1455 Some("Effects")
1456 );
1457 assert!(pre.preprocessing.optimization.for_size);
1458 assert!(pre.preprocessing.optimization.strict);
1459 assert_eq!(
1460 pre.preprocessing.replacements[0].value,
1461 "getCapturePercentage"
1462 );
1463 assert_eq!(pre.defines.len(), 1);
1464 }
1465
1466 #[test]
1467 fn translations_follow_pinned_codes_without_local_deduplication() {
1468 let (pre, _) = preprocess(
1469 "#!translations EN zh-cn en\nrule \"r\":\n pass\n",
1470 "main.opy",
1471 Path::new("."),
1472 )
1473 .unwrap();
1474 assert_eq!(
1475 pre.preprocessing.translations.unwrap().languages,
1476 vec!["en", "zh_cn", "en"]
1477 );
1478 }
1479
1480 #[test]
1481 fn translations_reject_codes_outside_the_pinned_oracle_set() {
1482 let error = preprocess(
1483 "#!translations en_US\nrule \"r\":\n pass\n",
1484 "main.opy",
1485 Path::new("."),
1486 )
1487 .unwrap_err();
1488 assert_eq!(error.code, "translations-invalid");
1489 }
1490
1491 #[test]
1492 fn directive_records_expose_state_transitions_and_include_depth() {
1493 let root =
1494 std::env::temp_dir().join(format!("wright-opy-directive-scope-{}", std::process::id()));
1495 std::fs::create_dir_all(&root).unwrap();
1496 std::fs::write(
1497 root.join("child.opy"),
1498 "#!rulePrefix \"inner\"\n#!disableOptimizations\n",
1499 )
1500 .unwrap();
1501 let (pre, _) = preprocess(
1502 "#!rulePrefix \"outer\"\n#!include \"child.opy\"\n#!enableOptimizations\n",
1503 "main.opy",
1504 &root,
1505 )
1506 .unwrap();
1507 let records = &pre.preprocessing.directives;
1508 assert_eq!(records[0].state.rule_prefix.as_deref(), Some("outer"));
1509 assert_eq!(records[0].scope_depth, 0);
1510 assert_eq!(records[1].name, "rulePrefix");
1511 assert_eq!(records[1].state.rule_prefix.as_deref(), Some("inner"));
1512 assert!(!records[2].state.optimization.enabled);
1513 assert_eq!(records[2].scope_depth, 1);
1514 assert_eq!(records[3].name, "include");
1515 assert_eq!(records[3].state.rule_prefix.as_deref(), Some("outer"));
1516 assert_eq!(records[4].name, "enableOptimizations");
1517 assert!(records[4].state.optimization.enabled);
1518 let _ = std::fs::remove_dir_all(&root);
1519 }
1520
1521 #[test]
1522 fn malformed_translation_state_is_source_located() {
1523 let error = preprocess("#!translations\n", "main.opy", Path::new(".")).unwrap_err();
1524 assert_eq!(error.code, "translations-invalid");
1525 assert!(error.span.is_some());
1526 }
1527}