1use std::collections::BTreeMap;
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 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, false) {
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(
355 &mut self,
356 tokens: &mut Vec<Token>,
357 allow_leading_main_file: bool,
358 ) -> OpyResult<()> {
359 let mut out: Vec<Token> = Vec::with_capacity(tokens.len());
360 for token in tokens.drain(..) {
361 if token.kind == TokenKind::Directive {
362 let is_leading_main_file = allow_leading_main_file && token.span.start.line == 1;
363 self.handle_directive(token, &mut out, is_leading_main_file)?;
364 } else if token.kind == TokenKind::Ident
365 && matches!(token.text.as_str(), "rule" | "def")
366 && self.preprocessing.rule_prefix.is_some()
367 {
368 let prefix = self
369 .preprocessing
370 .rule_prefix
371 .as_ref()
372 .map(|value| value.value.clone())
373 .unwrap_or_default();
374 out.push(Token {
375 kind: TokenKind::RulePrefixMarker,
376 text: prefix,
377 raw: None,
378 span: token.span,
379 });
380 out.push(token);
381 } else {
382 out.push(token);
383 }
384 }
385 *tokens = out;
386 Ok(())
387 }
388
389 fn handle_directive(
390 &mut self,
391 token: Token,
392 out: &mut Vec<Token>,
393 allow_leading_main_file: bool,
394 ) -> OpyResult<()> {
395 let text = token.text.trim();
396 let span = token.span;
397 let (name, rest) = split_directive(text);
398 if name == "include" {
399 let rest = rest.trim();
400 let include = rest
401 .strip_prefix('"')
402 .and_then(|r| r.strip_suffix('"'))
403 .or_else(|| rest.strip_prefix('\'').and_then(|r| r.strip_suffix('\'')));
404 let Some(include) = include else {
405 return Err(OpyError::at(
406 "include-invalid",
407 format!(
408 "invalid include directive: `{text}` (expected `#!include \"file.opy\"`)"
409 ),
410 span,
411 ));
412 };
413 self.include(include, span, out)?;
414 return Ok(());
415 }
416 if name == "define" {
417 self.define(rest.trim(), span)?;
418 return Ok(());
419 }
420 if name == "undef" {
421 let name = rest.trim();
422 if name.is_empty() || name.chars().any(|ch| !is_identifier_char(ch)) {
423 return Err(OpyError::at(
424 "undef-invalid",
425 "malformed `#!undef` directive: expected one macro name",
426 span,
427 ));
428 }
429 self.macros.retain(|m| m.name != name);
430 self.defines.retain(|define| define.name != name);
431 self.record("undef", Some(name), span);
432 return Ok(());
433 }
434 if name == "postCompileHook" {
435 let rest = rest.trim();
436 let Some(path) = strip_quoted(rest) else {
437 return Err(OpyError::at(
438 "script-invalid",
439 format!(
440 "invalid postCompileHook directive: `{text}` (expected `#!postCompileHook \"hook.js\"`)"
441 ),
442 span,
443 ));
444 };
445 if self.post_compile_hook.is_some() {
446 return Err(OpyError::at(
447 "post-compile-hook-duplicate",
448 "post-compile hook is already defined".to_string(),
449 span,
450 ));
451 }
452 let hook = self.resolve_script(path, span)?;
453 self.post_compile_hook = Some(PostCompileHook {
454 path: hook.path,
455 source: hook.source,
456 span,
457 });
458 self.record("postCompileHook", Some(path), span);
459 return Ok(());
460 }
461 if name == "mainFile" {
462 if allow_leading_main_file {
463 let main_file = strip_quoted(rest.trim())
464 .filter(|main_file| !main_file.is_empty())
465 .ok_or_else(|| {
466 OpyError::at(
467 "main-file-invalid",
468 "`#!mainFile` expects one quoted path",
469 span,
470 )
471 })?;
472 self.record(name, Some(main_file), span);
473 return Ok(());
474 }
475 return Err(OpyError::at(
476 "main-file-placement",
477 "`#!mainFile` must be the first directive in the main source",
478 span,
479 ));
480 }
481 if name == "allowMacroRedeclaration" {
482 self.preprocessing.allow_macro_redeclaration = true;
483 self.record(name, None, span);
484 return Ok(());
485 }
486 if name == "translations" {
487 let languages = parse_translations(rest.trim(), span)?;
488 self.preprocessing.translations = Some(TranslationState {
489 languages: languages.clone(),
490 span: Some(span.into()),
491 });
492 self.record(name, Some(&languages.join(" ")), span);
493 return Ok(());
494 }
495 if name == "suppressWarnings" {
496 let warnings = parse_words(rest, "suppressWarnings", span)?;
497 self.preprocessing
498 .suppressed_warnings
499 .extend(warnings.clone());
500 self.record(name, Some(&warnings.join(" ")), span);
501 return Ok(());
502 }
503 if name == "rulePrefix" {
504 let prefix = strip_quoted(rest.trim()).ok_or_else(|| {
505 OpyError::at(
506 "rule-prefix-invalid",
507 "`#!rulePrefix` expects one quoted string",
508 span,
509 )
510 })?;
511 self.preprocessing.rule_prefix = Some(DirectiveValue {
512 value: prefix.to_string(),
513 span: Some(span.into()),
514 });
515 self.record(name, Some(prefix), span);
516 return Ok(());
517 }
518 if name == "rulePrefixTemplate" {
519 if self.preprocessing.rule_prefix_template.is_some() {
520 return Err(OpyError::at(
521 "rule-prefix-template-duplicate",
522 "a rule prefix template is already defined",
523 span,
524 ));
525 }
526 let template = if rest.trim().is_empty() {
527 r#"f"[{$pathTitle.replace('_', ' ')}] {$rule}" if $rule and not $isDelimiter else $rule"#
528 } else {
529 rest.trim()
530 };
531 self.preprocessing.rule_prefix_template = Some(DirectiveValue {
532 value: template.to_string(),
533 span: Some(span.into()),
534 });
535 self.record(name, Some(template), span);
536 return Ok(());
537 }
538 if let Some((directive, control)) = optimization_directive(name) {
539 apply_optimization(&mut self.preprocessing.optimization, control);
540 self.record(directive, None, span);
541 return Ok(());
542 }
543 if let Some(replacement) = replacement_directive(name) {
544 let family = replacement_family(name).expect("replacement directive family");
545 if self
546 .preprocessing
547 .directives
548 .iter()
549 .filter_map(|item| replacement_family(&item.name))
550 .any(|item_family| item_family == family)
551 {
552 return Err(OpyError::at(
553 "replacement-duplicate",
554 format!("a replacement for `{family}` is already defined"),
555 span,
556 ));
557 }
558 self.preprocessing.replacements.push(DirectiveValue {
559 value: replacement.to_string(),
560 span: Some(span.into()),
561 });
562 self.record(name, Some(replacement), span);
563 return Ok(());
564 }
565 Err(OpyError::at(
566 "unsupported-directive",
567 format!("unsupported preprocessing directive `#!{text}`"),
568 span,
569 ))
570 }
571
572 fn record(&mut self, name: &str, value: Option<&str>, span: Span) {
573 let state = PreprocessingSnapshot {
574 allow_macro_redeclaration: self.preprocessing.allow_macro_redeclaration,
575 optimization: self.preprocessing.optimization.clone(),
576 rule_prefix: self
577 .preprocessing
578 .rule_prefix
579 .as_ref()
580 .map(|value| value.value.clone()),
581 rule_prefix_template: self
582 .preprocessing
583 .rule_prefix_template
584 .as_ref()
585 .map(|value| value.value.clone()),
586 translations: self
587 .preprocessing
588 .translations
589 .as_ref()
590 .map(|translations| translations.languages.clone()),
591 replacements: self
592 .preprocessing
593 .replacements
594 .iter()
595 .map(|value| value.value.clone())
596 .collect(),
597 };
598 self.preprocessing.directives.push(DirectiveRecord {
599 name: name.to_string(),
600 value: value.map(str::to_string),
601 scope_col: span.start.col,
602 scope_depth: self.include_stack.len() as u32,
603 state,
604 span: Some(span.into()),
605 });
606 }
607
608 fn resolve_script(&self, path: &str, span: Span) -> OpyResult<ScriptMacro> {
611 let canonical = self.root.join(path).canonicalize().map_err(|_| {
612 OpyError::at(
613 "script-not-found",
614 format!(
615 "cannot find script '{path}' under root '{}'",
616 self.root.display()
617 ),
618 span,
619 )
620 })?;
621 let source = std::fs::read_to_string(&canonical).map_err(|error| {
622 OpyError::at(
623 "script-not-found",
624 format!("cannot read script '{path}': {error}"),
625 span,
626 )
627 })?;
628 Ok(ScriptMacro {
629 path: path.to_string(),
630 source,
631 })
632 }
633
634 fn include(&mut self, include: &str, span: Span, out: &mut Vec<Token>) -> OpyResult<()> {
636 let candidate = self.root.join(include);
639 let canonical = std::fs::canonicalize(&candidate).ok();
640 let overlay_text = self
644 .overlay
645 .get(include)
646 .or_else(|| {
647 canonical
648 .as_ref()
649 .and_then(|path| self.overlay.get(&path.to_string_lossy().into_owned()))
650 })
651 .cloned();
652
653 let identity = canonical.clone().unwrap_or_else(|| candidate.clone());
656 if self.include_stack.contains(&identity) {
657 return Err(OpyError::at(
658 "include-cycle",
659 format!(
660 "include cycle detected: '{}' is already being included",
661 identity.display()
662 ),
663 span,
664 ));
665 }
666
667 let text = match overlay_text {
668 Some(text) => text,
669 None => {
670 let canonical = canonical.ok_or_else(|| {
671 OpyError::at(
672 "include-not-found",
673 format!(
674 "cannot find included file '{include}' under root '{}'",
675 self.root.display()
676 ),
677 span,
678 )
679 })?;
680 std::fs::read_to_string(&canonical).map_err(|error| {
681 OpyError::at(
682 "include-not-found",
683 format!("cannot read included file '{include}': {error}"),
684 span,
685 )
686 })?
687 }
688 };
689 let file_id = self.next_file_id;
691 self.next_file_id += 1;
692 self.files.push(FileRecord {
693 id: file_id,
694 path: include.to_string(),
695 });
696 self.include_stack.push(identity);
697 let saved_prefix = self.preprocessing.rule_prefix.clone();
698 let saved_optimization = self.preprocessing.optimization.clone();
699 match crate::settings::find_blocks(&text, file_id) {
703 Err(error) => return Err(error),
704 Ok(blocks) if !blocks.is_empty() => {
705 return Err(OpyError::at(
706 "settings-placement",
707 "settings blocks are only supported in the main file".to_string(),
708 blocks[0].keyword_span,
709 ));
710 }
711 Ok(_) => {}
712 }
713 let mut included = lex(LexInput {
714 file_id,
715 text: &text,
716 })?;
717 let allow_leading_main_file = text
718 .lines()
719 .next()
720 .is_some_and(|line| line.trim_end_matches('\r').starts_with("#!mainFile"));
721 let processed = self.process_directives(&mut included, allow_leading_main_file);
722 self.preprocessing.rule_prefix = saved_prefix;
723 self.preprocessing.optimization = saved_optimization;
724 if let Err(error) = processed {
725 self.include_stack.pop();
726 return Err(error);
727 }
728 included.retain(|token| token.kind != TokenKind::Eof);
731 out.extend(included);
736 self.include_stack.pop();
737 self.record("include", Some(include), span);
738 Ok(())
739 }
740
741 fn define(&mut self, rest: &str, span: Span) -> OpyResult<()> {
747 let rest = rest.trim();
748 let first_open = rest.find('(').unwrap_or(usize::MAX);
749 let first_space = rest.find(char::is_whitespace).unwrap_or(usize::MAX);
750 let is_function_like = first_open < first_space;
751
752 let (name, params, body_text) = if is_function_like {
753 let name = rest[..first_open].trim();
754 let Some(close) = rest[first_open..].find(')') else {
755 return Err(OpyError::at(
756 "define-invalid",
757 format!("malformed function-like define `#!define {rest}`: missing `)`"),
758 span,
759 ));
760 };
761 let close = first_open + close;
762 let params: Vec<String> = rest[first_open + 1..close]
763 .split(',')
764 .map(|p| p.trim().to_string())
765 .filter(|p| !p.is_empty())
766 .collect();
767 let body = rest[close + 1..].trim();
768 (name.to_string(), params, body.to_string())
769 } else {
770 let name = rest[..first_space].trim();
771 let body = if first_space == usize::MAX {
772 String::new()
773 } else {
774 rest[first_space..].trim().to_string()
775 };
776 (name.to_string(), Vec::new(), body)
777 };
778 if name.is_empty() {
779 return Err(OpyError::at(
780 "define-invalid",
781 "malformed `#!define` directive: missing macro name",
782 span,
783 ));
784 }
785 if self.macros.iter().any(|macro_def| macro_def.name == name) {
786 if !self.preprocessing.allow_macro_redeclaration {
787 return Err(OpyError::at(
788 "macro-redeclaration",
789 format!("macro '{name}' is already defined"),
790 span,
791 ));
792 }
793 self.macros.retain(|macro_def| macro_def.name != name);
794 self.defines.retain(|define| define.name != name);
795 }
796 let script = if is_function_like && body_text.starts_with("__script__(") {
797 let inner = &body_text["__script__(".len()..];
802 let inner = inner.strip_suffix(')').ok_or_else(|| {
803 OpyError::at(
804 "script-invalid",
805 format!(
806 "malformed script macro `#!define {rest}`: expected `__script__(\"path.js\")`"
807 ),
808 span,
809 )
810 })?;
811 let Some(path) = strip_quoted(inner.trim()) else {
812 return Err(OpyError::at(
813 "script-invalid",
814 format!(
815 "malformed script macro `#!define {rest}`: expected a quoted script path"
816 ),
817 span,
818 ));
819 };
820 Some(self.resolve_script(path, span)?)
821 } else {
822 None
823 };
824 let body_tokens = lex(LexInput {
825 file_id: span.file,
826 text: &body_text,
827 })?;
828 let body_tokens: Vec<Token> = body_tokens
830 .into_iter()
831 .filter(|t| t.kind != TokenKind::Eof)
832 .collect();
833 let is_function = is_function_like;
834 self.defines.push(DefineRecord {
835 name: name.clone(),
836 is_function,
837 span: Some(span),
838 });
839 self.macros.push(MacroDef {
840 name,
841 params,
842 body: body_tokens,
843 is_function,
844 script,
845 });
846 Ok(())
847 }
848
849 fn expand(&self, tokens: Vec<Token>) -> OpyResult<Vec<Token>> {
851 let mut out = Vec::new();
852 let mut index = 0;
853 while index < tokens.len() {
854 let token = &tokens[index];
855 if token.kind == TokenKind::Ident {
856 let name = token.text.clone();
857 if let Some(mac) = self.macros.iter().find(|m| m.name == name) {
858 if mac.is_function {
859 let cursor = index + 1;
861 if cursor < tokens.len() && tokens[cursor].kind == TokenKind::LParen {
862 let (args, after) = self.collect_args(&tokens, cursor)?;
863 let mut expanded = self.expand_macro(mac, args, token.span)?;
864 self.expand_into(&mut expanded, &mut Vec::new(), 0)?;
865 out.append(&mut expanded);
866 index = after;
867 continue;
868 }
869 out.push(token.clone());
872 index += 1;
873 continue;
874 }
875 let mut expanded = self.expand_macro(mac, Vec::new(), token.span)?;
876 self.expand_into(&mut expanded, &mut Vec::new(), 0)?;
877 out.append(&mut expanded);
878 index += 1;
879 continue;
880 }
881 }
882 out.push(token.clone());
883 index += 1;
884 }
885 Ok(out)
886 }
887
888 fn collect_args(&self, tokens: &[Token], open: usize) -> OpyResult<(Vec<Vec<Token>>, usize)> {
891 let mut args: Vec<Vec<Token>> = Vec::new();
892 let mut current: Vec<Token> = Vec::new();
893 let mut depth = 0usize;
894 let mut cursor = open + 1;
895 while cursor < tokens.len() {
896 let kind = tokens[cursor].kind;
897 if kind == TokenKind::LParen {
898 depth += 1;
899 current.push(tokens[cursor].clone());
900 } else if kind == TokenKind::RParen {
901 if depth == 0 {
902 args.push(std::mem::take(&mut current));
903 return Ok((args, cursor + 1));
904 }
905 depth -= 1;
906 current.push(tokens[cursor].clone());
907 } else if kind == TokenKind::Comma && depth == 0 {
908 args.push(std::mem::take(&mut current));
909 } else {
910 current.push(tokens[cursor].clone());
911 }
912 cursor += 1;
913 }
914 Err(OpyError::new(
915 "macro-invalid",
916 "unterminated macro invocation: missing closing `)`",
917 ))
918 }
919
920 fn expand_macro(
927 &self,
928 mac: &MacroDef,
929 args: Vec<Vec<Token>>,
930 use_site: Span,
931 ) -> OpyResult<Vec<Token>> {
932 if mac.is_function && args.len() != mac.params.len() {
933 return Err(OpyError::at(
934 "macro-arity",
935 format!(
936 "macro '{}' expects {} argument(s) but got {}",
937 mac.name,
938 mac.params.len(),
939 args.len()
940 ),
941 use_site,
942 ));
943 }
944 if let Some(script) = &mac.script {
945 return self.expand_script(mac, script, args, use_site);
946 }
947 let mut out = Vec::new();
948 for token in &mac.body {
949 if mac.is_function
950 && token.kind == TokenKind::Ident
951 && mac.params.iter().any(|p| p == &token.text)
952 {
953 let param_index = mac
954 .params
955 .iter()
956 .position(|p| p == &token.text)
957 .expect("checked above");
958 let mut replacement = args.get(param_index).cloned().unwrap_or_default();
959 for replacement_token in &mut replacement {
960 replacement_token.span = use_site;
961 }
962 out.extend(replacement);
963 } else {
964 let mut token = token.clone();
965 token.span = use_site;
966 out.push(token);
967 }
968 }
969 Ok(out)
970 }
971
972 fn expand_script(
984 &self,
985 mac: &MacroDef,
986 script: &ScriptMacro,
987 args: Vec<Vec<Token>>,
988 use_site: Span,
989 ) -> OpyResult<Vec<Token>> {
990 let macro_args: Vec<MacroArg> = mac
991 .params
992 .iter()
993 .zip(args.iter())
994 .map(|(param, tokens)| MacroArg::new(param.clone(), raw_arg_text(tokens)))
995 .collect();
996 let runtime = MacroRuntime::new(Limits::default());
999 let result = runtime
1000 .run_macro(&script.source, ¯o_args, &script.path)
1001 .map_err(|error| map_macro_error(&error, &script.path, use_site))?;
1002 let indent = " ".repeat(use_site.start.col.saturating_sub(1) as usize);
1005 let indented = result.text.replace('\n', &format!("\n{indent}"));
1006 let mut tokens = lex(LexInput {
1007 file_id: use_site.file,
1008 text: &indented,
1009 })?;
1010 tokens.retain(|token| token.kind != TokenKind::Eof);
1011 for token in &mut tokens {
1012 token.span = use_site;
1013 }
1014 Ok(tokens)
1015 }
1016
1017 fn expand_into(
1020 &self,
1021 tokens: &mut Vec<Token>,
1022 stack: &mut Vec<String>,
1023 depth: usize,
1024 ) -> OpyResult<()> {
1025 if depth > 64 {
1026 return Err(OpyError::new(
1027 "macro-recursion",
1028 "macro expansion exceeded the recursion limit (possible recursive define)",
1029 ));
1030 }
1031 let mut out: Vec<Token> = Vec::with_capacity(tokens.len());
1032 let mut index = 0;
1033 while index < tokens.len() {
1034 let token = &tokens[index];
1035 if token.kind == TokenKind::Ident {
1036 let name = token.text.clone();
1037 if let Some(mac) = self.macros.iter().find(|m| m.name == name) {
1038 if stack.iter().any(|s| s == &name) {
1039 return Err(OpyError::new(
1040 "macro-recursion",
1041 format!("recursive macro expansion detected for '{name}'"),
1042 ));
1043 }
1044 if mac.is_function {
1045 if index + 1 < tokens.len() && tokens[index + 1].kind == TokenKind::LParen {
1046 let (args, after) = self.collect_args(tokens, index)?;
1047 let mut expanded = self.expand_macro(mac, args, token.span)?;
1048 stack.push(name.clone());
1049 self.expand_into(&mut expanded, stack, depth + 1)?;
1050 stack.pop();
1051 out.append(&mut expanded);
1052 index = after;
1053 continue;
1054 }
1055 out.push(token.clone());
1056 index += 1;
1057 continue;
1058 }
1059 let mut expanded = self.expand_macro(mac, Vec::new(), token.span)?;
1060 stack.push(name.clone());
1061 self.expand_into(&mut expanded, stack, depth + 1)?;
1062 stack.pop();
1063 out.append(&mut expanded);
1064 index += 1;
1065 continue;
1066 }
1067 }
1068 out.push(token.clone());
1069 index += 1;
1070 }
1071 *tokens = out;
1072 Ok(())
1073 }
1074}
1075
1076fn split_directive(text: &str) -> (&str, &str) {
1077 text.split_once(char::is_whitespace)
1078 .map_or((text, ""), |(name, rest)| (name, rest))
1079}
1080
1081fn is_identifier_char(ch: char) -> bool {
1082 ch.is_ascii_alphanumeric() || ch == '_'
1083}
1084
1085fn parse_words(rest: &str, directive: &str, span: Span) -> OpyResult<Vec<String>> {
1086 let words: Vec<String> = rest.split_whitespace().map(str::to_string).collect();
1087 if words.is_empty() {
1088 return Err(OpyError::at(
1089 "directive-invalid",
1090 format!("`#!{directive}` expects at least one argument"),
1091 span,
1092 ));
1093 }
1094 if words
1095 .iter()
1096 .any(|word| word.chars().any(|ch| !is_identifier_char(ch)))
1097 {
1098 return Err(OpyError::at(
1099 "directive-invalid",
1100 format!("`#!{directive}` arguments must be identifiers"),
1101 span,
1102 ));
1103 }
1104 Ok(words)
1105}
1106
1107fn parse_translations(rest: &str, span: Span) -> OpyResult<Vec<String>> {
1108 let values: Vec<String> = rest
1109 .split_whitespace()
1110 .map(|language| language.replace('-', "_").to_lowercase())
1111 .collect();
1112 if values.is_empty() {
1113 return Err(OpyError::at(
1114 "translations-invalid",
1115 "`#!translations` expects at least one language",
1116 span,
1117 ));
1118 }
1119 const PINNED_LANGUAGES: &[&str] = &[
1120 "de", "en", "es", "es_es", "es_mx", "fr", "it", "ja", "ko", "pl", "pt", "ru", "th", "tr",
1121 "zh", "zh_cn", "zh_tw",
1122 ];
1123 if values
1124 .iter()
1125 .any(|language| !PINNED_LANGUAGES.contains(&language.as_str()))
1126 {
1127 return Err(OpyError::at(
1128 "translations-invalid",
1129 "invalid translation language; expected one of the pinned OverPy language codes",
1130 span,
1131 ));
1132 }
1133 if values.iter().any(|value| value == "es")
1134 && values
1135 .iter()
1136 .any(|value| value == "es_es" || value == "es_mx")
1137 {
1138 return Err(OpyError::at(
1139 "translations-invalid",
1140 "cannot combine `es` with `es_es` or `es_mx`",
1141 span,
1142 ));
1143 }
1144 if values.iter().any(|value| value == "zh")
1145 && values
1146 .iter()
1147 .any(|value| value == "zh_cn" || value == "zh_tw")
1148 {
1149 return Err(OpyError::at(
1150 "translations-invalid",
1151 "cannot combine `zh` with `zh_cn` or `zh_tw`",
1152 span,
1153 ));
1154 }
1155 Ok(values)
1156}
1157
1158#[derive(Clone, Copy)]
1159enum OptimizationControl {
1160 Enable,
1161 Disable,
1162 ForSize,
1163 DisableForSize,
1164 ForSizeAggressive,
1165 Strict,
1166 DisableStrict,
1167}
1168
1169fn optimization_directive(name: &str) -> Option<(&str, OptimizationControl)> {
1170 Some(match name {
1171 "disableOptimizations" => (name, OptimizationControl::Disable),
1172 "enableOptimizations" => (name, OptimizationControl::Enable),
1173 "optimizeForSize" => (name, OptimizationControl::ForSize),
1174 "disableOptimizeForSize" => (name, OptimizationControl::DisableForSize),
1175 "optimizeForSizeAggressive" => (name, OptimizationControl::ForSizeAggressive),
1176 "optimizeStrict" => (name, OptimizationControl::Strict),
1177 "disableOptimizeStrict" => (name, OptimizationControl::DisableStrict),
1178 _ => return None,
1179 })
1180}
1181
1182fn apply_optimization(state: &mut OptimizationState, control: OptimizationControl) {
1183 match control {
1184 OptimizationControl::Enable => state.enabled = true,
1185 OptimizationControl::Disable => state.enabled = false,
1186 OptimizationControl::ForSize => state.for_size = true,
1187 OptimizationControl::DisableForSize => state.for_size = false,
1188 OptimizationControl::ForSizeAggressive => state.for_size_aggressive = true,
1189 OptimizationControl::Strict => state.strict = true,
1190 OptimizationControl::DisableStrict => state.strict = false,
1191 }
1192}
1193
1194fn replacement_directive(name: &str) -> Option<&str> {
1195 Some(match name {
1196 "replace0ByCapturePercentage" => "getCapturePercentage",
1197 "replace0ByPayloadProgressPercentage" => "getPayloadProgressPercentage",
1198 "replace0ByIsMatchComplete" => "isMatchComplete",
1199 "replace1ByMatchRound" => "getMatchRound",
1200 "replaceTeam1ByControlScoringTeam" => "getControlScoringTeam",
1201 "replaceEmptyStringByEmptyArray" => "emptyArray",
1202 "replaceEmptyStringByVariable" => "variable",
1203 _ => return None,
1204 })
1205}
1206
1207fn replacement_family(name: &str) -> Option<&str> {
1208 Some(match name {
1209 "replace0ByCapturePercentage"
1210 | "replace0ByPayloadProgressPercentage"
1211 | "replace0ByIsMatchComplete" => "0",
1212 "replace1ByMatchRound" => "1",
1213 "replaceTeam1ByControlScoringTeam" => "team1",
1214 "replaceEmptyStringByEmptyArray" | "replaceEmptyStringByVariable" => "emptyString",
1215 _ => return None,
1216 })
1217}
1218
1219fn strip_quoted(text: &str) -> Option<&str> {
1221 text.strip_prefix('"')
1222 .and_then(|rest| rest.strip_suffix('"'))
1223 .or_else(|| {
1224 text.strip_prefix('\'')
1225 .and_then(|rest| rest.strip_suffix('\''))
1226 })
1227}
1228
1229fn raw_arg_text(tokens: &[Token]) -> String {
1238 let mut out = String::new();
1239 for token in tokens {
1240 match token.kind {
1241 TokenKind::String => out.push_str(&json_string_literal(&token.text)),
1242 TokenKind::Newline => out.push('\n'),
1243 _ => out.push_str(&token.text),
1244 }
1245 }
1246 out
1247}
1248
1249fn json_string_literal(value: &str) -> String {
1251 serde_json::to_string(value).expect("serializing a string is infallible")
1252}
1253
1254pub(crate) fn map_macro_error(error: &MacroError, script_path: &str, span: Span) -> OpyError {
1266 match error {
1267 MacroError::Script(script) => {
1268 let code = match script.message.as_str() {
1269 "interrupted" => "script-timeout",
1270 "out of memory" => "script-memory-limit",
1271 "Maximum call stack size exceeded" => "script-stack-limit",
1272 _ => "script-error",
1273 };
1274 let location = match (script.line, script.column) {
1275 (Some(line), Some(column)) => format!(" (line {line}, column {column})"),
1276 (Some(line), None) => format!(" (line {line})"),
1277 _ => String::new(),
1278 };
1279 OpyError::at(
1280 code,
1281 format!(
1282 "script '{}' failed: {}{}",
1283 script_path, script.message, location
1284 ),
1285 span,
1286 )
1287 }
1288 MacroError::InvalidResult { type_name } => OpyError::at(
1289 "script-result-not-string",
1290 format!(
1291 "JavaScript macro returned value with type of {type_name}, expected string. Try using .toString()"
1292 ),
1293 span,
1294 ),
1295 MacroError::Internal(message) => OpyError::at(
1296 "script-internal",
1297 format!("script '{}' runtime failure: {message}", script_path),
1298 span,
1299 ),
1300 }
1301}
1302
1303#[cfg(test)]
1304mod tests {
1305 use super::*;
1306
1307 #[test]
1308 fn object_define_expands_at_use_site() {
1309 let (pre, _) = preprocess(
1310 "#!define SIDE 1.5\nrule \"r\":\n x = SIDE\n",
1311 "main.opy",
1312 Path::new("."),
1313 )
1314 .unwrap();
1315 assert_eq!(pre.defines.len(), 1);
1316 assert_eq!(pre.defines[0].name, "SIDE");
1317 assert!(!pre.defines[0].is_function);
1318 let numbers: Vec<&str> = pre
1319 .tokens
1320 .iter()
1321 .filter(|t| t.kind == TokenKind::Number)
1322 .map(|t| t.text.as_str())
1323 .collect();
1324 assert_eq!(numbers, vec!["1.5"]);
1325 }
1326
1327 #[test]
1328 fn function_define_substitutes_params() {
1329 let (pre, _) = preprocess(
1330 "#!define double(x) x + x\nrule \"r\":\n y = double(3)\n",
1331 "main.opy",
1332 Path::new("."),
1333 )
1334 .unwrap();
1335 let numbers: Vec<&str> = pre
1336 .tokens
1337 .iter()
1338 .filter(|t| t.kind == TokenKind::Number)
1339 .map(|t| t.text.as_str())
1340 .collect();
1341 assert_eq!(numbers, vec!["3", "3"]);
1342 }
1343
1344 #[test]
1345 fn macro_expanded_string_can_concatenate_with_following_literal() {
1346 let (pre, _) = preprocess(
1347 "#!define PREFIX \"one\"\nrule \"r\":\n debug(PREFIX\n \"two\")\n",
1348 "main.opy",
1349 Path::new("."),
1350 )
1351 .unwrap();
1352 let output = crate::parser::parse(&pre.tokens);
1353 assert!(
1354 output.errors.is_empty(),
1355 "unexpected errors: {:?}",
1356 output.errors
1357 );
1358 let program = output.program.expect("expanded source must parse");
1359 let crate::cst::RuleEntry::Rule(rule) = &program.rules[0] else {
1360 panic!("expected rule");
1361 };
1362 let crate::cst::Stmt::Expr { expr, .. } = &rule.actions[0] else {
1363 panic!("expected expression statement");
1364 };
1365 let crate::cst::Expr::Call { args, .. } = expr else {
1366 panic!("expected call");
1367 };
1368 assert!(matches!(
1369 &args[0].value,
1370 crate::cst::Expr::String { value, .. } if value == "onetwo"
1371 ));
1372 }
1373
1374 #[test]
1375 fn recursive_defines_expand_transitively() {
1376 let (pre, _) = preprocess(
1377 "#!define A 2\n#!define B A + 1\nrule \"r\":\n x = B\n",
1378 "main.opy",
1379 Path::new("."),
1380 )
1381 .unwrap();
1382 let numbers: Vec<&str> = pre
1383 .tokens
1384 .iter()
1385 .filter(|t| t.kind == TokenKind::Number)
1386 .map(|t| t.text.as_str())
1387 .collect();
1388 assert_eq!(numbers, vec!["2", "1"]);
1389 }
1390
1391 #[test]
1392 fn recursive_define_fails_structurally() {
1393 let error = preprocess(
1394 "#!define X X + 1\nrule \"r\":\n x = X\n",
1395 "main.opy",
1396 Path::new("."),
1397 )
1398 .unwrap_err();
1399 assert_eq!(error.code, "macro-recursion");
1400 }
1401
1402 #[test]
1403 fn missing_include_is_structured() {
1404 let error = preprocess(
1405 "#!include \"nope.opy\"\n",
1406 "main.opy",
1407 Path::new("/nonexistent-root"),
1408 )
1409 .unwrap_err();
1410 assert_eq!(error.code, "include-not-found");
1411 assert!(error.span.is_some());
1412 }
1413
1414 #[test]
1415 fn include_cycle_is_detected() {
1416 let dir = std::env::temp_dir().join(format!("wright-opy-test-{}", std::process::id()));
1417 std::fs::create_dir_all(&dir).unwrap();
1418 std::fs::write(dir.join("a.opy"), "#!include \"b.opy\"\n").unwrap();
1419 std::fs::write(dir.join("b.opy"), "#!include \"a.opy\"\n").unwrap();
1420 let main = std::fs::read_to_string(dir.join("a.opy")).unwrap();
1421 let error = preprocess(&main, "a.opy", &dir).unwrap_err();
1422 assert_eq!(error.code, "include-cycle");
1423 let _ = std::fs::remove_dir_all(&dir);
1424 }
1425
1426 #[test]
1427 fn unsupported_directive_is_structured() {
1428 let error = preprocess("#!frobnicate\n", "main.opy", Path::new(".")).unwrap_err();
1429 assert_eq!(error.code, "unsupported-directive");
1430 }
1431
1432 #[test]
1433 fn settings_block_is_extracted_before_lexing() {
1434 let (pre, _) = preprocess(
1435 "settings {\n \"gamemodes\": {}\n}\nrule \"r\":\n pass\n",
1436 "main.opy",
1437 Path::new("."),
1438 )
1439 .unwrap();
1440 let block = pre.settings.expect("settings block extracted");
1441 assert!(block.text.contains("gamemodes"));
1442 assert!(
1444 !pre.tokens.iter().any(|t| t.text.contains("gamemodes")),
1445 "settings content must not be lexed"
1446 );
1447 }
1448
1449 #[test]
1450 fn settings_in_include_is_rejected() {
1451 let dir =
1452 std::env::temp_dir().join(format!("wright-opy-settings-test-{}", std::process::id()));
1453 std::fs::create_dir_all(&dir).unwrap();
1454 std::fs::write(
1455 dir.join("shared.opy"),
1456 "settings {\n \"gamemodes\": {}\n}\n",
1457 )
1458 .unwrap();
1459 let main = "#!include \"shared.opy\"\nrule \"r\":\n pass\n";
1460 let error = preprocess(main, "main.opy", &dir).unwrap_err();
1461 assert_eq!(error.code, "settings-placement");
1462 assert_eq!(
1463 error.span.unwrap().file,
1464 1,
1465 "the span names the included file"
1466 );
1467 let _ = std::fs::remove_dir_all(&dir);
1468 }
1469
1470 #[test]
1471 fn dict_literal_braces_reach_the_parser() {
1472 let (pre, _) = preprocess(
1474 "rule \"r\":\n money += {\n Mei.GENERIC: 10,\n }\n",
1475 "main.opy",
1476 Path::new("."),
1477 )
1478 .unwrap();
1479 assert!(
1480 pre.tokens
1481 .iter()
1482 .any(|token| token.kind == TokenKind::LBrace)
1483 );
1484 assert!(
1485 pre.tokens
1486 .iter()
1487 .any(|token| token.kind == TokenKind::RBrace)
1488 );
1489 }
1490
1491 #[test]
1492 fn advanced_directives_preserve_frontend_state_without_catalog_data() {
1493 let (pre, _) = preprocess(
1494 "#!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",
1495 "main.opy",
1496 Path::new("."),
1497 )
1498 .unwrap();
1499 assert!(pre.preprocessing.allow_macro_redeclaration);
1500 assert_eq!(
1501 pre.preprocessing
1502 .translations
1503 .as_ref()
1504 .map(|state| state.languages.as_slice()),
1505 Some(["en".to_string(), "fr".to_string()].as_slice())
1506 );
1507 assert_eq!(
1508 pre.preprocessing
1509 .rule_prefix
1510 .as_ref()
1511 .map(|value| value.value.as_str()),
1512 Some("Effects")
1513 );
1514 assert!(pre.preprocessing.optimization.for_size);
1515 assert!(pre.preprocessing.optimization.strict);
1516 assert_eq!(
1517 pre.preprocessing.replacements[0].value,
1518 "getCapturePercentage"
1519 );
1520 assert_eq!(pre.defines.len(), 1);
1521 }
1522
1523 #[test]
1524 fn translations_follow_pinned_codes_without_local_deduplication() {
1525 let (pre, _) = preprocess(
1526 "#!translations EN zh-cn en\nrule \"r\":\n pass\n",
1527 "main.opy",
1528 Path::new("."),
1529 )
1530 .unwrap();
1531 assert_eq!(
1532 pre.preprocessing.translations.unwrap().languages,
1533 vec!["en", "zh_cn", "en"]
1534 );
1535 }
1536
1537 #[test]
1538 fn translations_reject_codes_outside_the_pinned_oracle_set() {
1539 let error = preprocess(
1540 "#!translations en_US\nrule \"r\":\n pass\n",
1541 "main.opy",
1542 Path::new("."),
1543 )
1544 .unwrap_err();
1545 assert_eq!(error.code, "translations-invalid");
1546 }
1547
1548 #[test]
1549 fn directive_records_expose_state_transitions_and_include_depth() {
1550 let root =
1551 std::env::temp_dir().join(format!("wright-opy-directive-scope-{}", std::process::id()));
1552 std::fs::create_dir_all(&root).unwrap();
1553 std::fs::write(
1554 root.join("child.opy"),
1555 "#!rulePrefix \"inner\"\n#!disableOptimizations\n",
1556 )
1557 .unwrap();
1558 let (pre, _) = preprocess(
1559 "#!rulePrefix \"outer\"\n#!include \"child.opy\"\n#!enableOptimizations\n",
1560 "main.opy",
1561 &root,
1562 )
1563 .unwrap();
1564 let records = &pre.preprocessing.directives;
1565 assert_eq!(records[0].state.rule_prefix.as_deref(), Some("outer"));
1566 assert_eq!(records[0].scope_depth, 0);
1567 assert_eq!(records[1].name, "rulePrefix");
1568 assert_eq!(records[1].state.rule_prefix.as_deref(), Some("inner"));
1569 assert!(!records[2].state.optimization.enabled);
1570 assert_eq!(records[2].scope_depth, 1);
1571 assert_eq!(records[3].name, "include");
1572 assert_eq!(records[3].state.rule_prefix.as_deref(), Some("outer"));
1573 assert_eq!(records[4].name, "enableOptimizations");
1574 assert!(records[4].state.optimization.enabled);
1575 let _ = std::fs::remove_dir_all(&root);
1576 }
1577
1578 #[test]
1579 fn malformed_translation_state_is_source_located() {
1580 let error = preprocess("#!translations\n", "main.opy", Path::new(".")).unwrap_err();
1581 assert_eq!(error.code, "translations-invalid");
1582 assert!(error.span.is_some());
1583 }
1584}