1use std::collections::HashMap;
18
19use crate::cst;
20use crate::diag::{OpyError, OpyResult, Position, Span};
21use crate::hir;
22use crate::lexer::{decode_string_escape, is_ident_continue, is_ident_start};
23
24#[derive(Debug, Clone)]
26pub struct SettingsBlock {
27 pub text: String,
29 pub span: Span,
31 pub keyword_span: Span,
33 pub start: usize,
35 pub end: usize,
37 pub text_start: Position,
39 pub content_file: u32,
41 pub content_span: Span,
43 pub external_path: Option<String>,
45}
46
47pub fn find_blocks(text: &str, file_id: u32) -> OpyResult<Vec<SettingsBlock>> {
59 let mut scanner = Cursor::new(text, file_id, Position::new(1, 1));
60 let mut blocks = Vec::new();
61 let mut in_block_comment = false;
62 let mut string_quote = None;
63 let mut escaped = false;
64 let mut line_start = true;
69 let mut tail_start = false;
70 let mut depth = 0usize;
71 while let Some(ch) = scanner.peek() {
72 if scanner.advance_quoted(&mut string_quote, &mut escaped, ch) {
73 continue;
74 }
75 if in_block_comment {
76 if ch == '*' && scanner.peek_at(1) == Some('/') {
77 in_block_comment = false;
78 scanner.advance_by(2);
79 } else {
80 scanner.advance_by(1);
81 }
82 continue;
83 }
84 if matches!(ch, '"' | '\'') {
85 string_quote = Some(ch);
86 line_start = false;
87 tail_start = false;
88 scanner.advance_by(1);
89 continue;
90 }
91 if ch == ' ' || ch == '\t' || ch == '\r' {
92 scanner.advance_by(1);
93 continue;
94 }
95 if ch == '\n' {
96 if depth == 0 {
97 line_start = true;
98 tail_start = false;
99 }
100 scanner.advance_by(1);
101 continue;
102 }
103 if ch == '#' {
104 scanner.skip_to_eol();
105 continue;
106 }
107 if ch == '/' && scanner.peek_at(1) == Some('*') {
108 scanner.advance_by(2);
109 in_block_comment = true;
110 continue;
111 }
112 match ch {
113 '(' | '[' | '{' => depth += 1,
114 ')' | ']' | '}' => depth = depth.saturating_sub(1),
115 ':' if depth == 0 => {
116 tail_start = true;
117 line_start = false;
118 scanner.advance_by(1);
119 continue;
120 }
121 _ => {}
122 }
123 if is_ident_start(ch) {
124 let statement_start = line_start || tail_start;
125 line_start = false;
126 tail_start = false;
127 let keyword_start = scanner.here();
128 let keyword_offset = scanner.char_pos;
129 let word = scanner.read_word();
130 if word == "settings" && statement_start {
131 let keyword_span = Span::new(file_id, keyword_start, scanner.here());
132 if !blocks.is_empty() {
133 return Err(OpyError::at(
134 "settings-placement",
135 "only one settings block is supported in a file".to_string(),
136 keyword_span,
137 ));
138 }
139 let block = match_block(&mut scanner, keyword_start, keyword_offset, keyword_span)?;
140 blocks.push(block);
141 }
142 continue;
143 }
144 line_start = false;
145 tail_start = false;
146 scanner.advance_by(1);
147 }
148 Ok(blocks)
149}
150
151fn match_block(
154 scanner: &mut Cursor<'_>,
155 keyword_start: Position,
156 keyword_offset: usize,
157 keyword_span: Span,
158) -> OpyResult<SettingsBlock> {
159 scanner.skip_horizontal_whitespace();
160 if scanner.peek() != Some('{') {
161 let Some(quote) = scanner.peek().filter(|ch| matches!(ch, '"' | '\'')) else {
162 return Err(OpyError::at(
163 "settings-invalid",
164 "settings block must be a `settings { ... }` block or `settings \"file\"`",
165 keyword_span,
166 ));
167 };
168 scanner.advance_by(1);
169 let mut path = String::new();
170 let mut escaped = false;
171 loop {
172 let Some(ch) = scanner.peek() else {
173 return Err(OpyError::at(
174 "settings-invalid",
175 "unterminated external settings path",
176 keyword_span,
177 ));
178 };
179 scanner.advance_by(1);
180 if escaped {
181 path.push(decode_string_escape(ch));
182 escaped = false;
183 } else if ch == '\\' {
184 escaped = true;
185 } else if ch == quote {
186 break;
187 } else if ch == '\n' {
188 return Err(OpyError::at(
189 "settings-invalid",
190 "external settings path must be one quoted string",
191 keyword_span,
192 ));
193 } else {
194 path.push(ch);
195 }
196 }
197 scanner.skip_horizontal_whitespace();
198 if scanner.peek() == Some('#') {
199 scanner.skip_to_eol();
200 }
201 if !matches!(scanner.peek(), None | Some('\n')) {
202 return Err(OpyError::at(
203 "settings-invalid",
204 "external settings declaration has unexpected trailing content",
205 keyword_span,
206 ));
207 }
208 return Ok(SettingsBlock {
209 text: String::new(),
210 span: Span::new(keyword_span.file, keyword_start, scanner.here()),
211 keyword_span,
212 start: keyword_offset,
213 end: scanner.char_pos,
214 text_start: keyword_start,
215 content_file: keyword_span.file,
216 content_span: keyword_span,
217 external_path: Some(path),
218 });
219 }
220 let mut depth = 0usize;
221 let mut string_quote: Option<char> = None;
222 let mut escaped = false;
223 let mut text_start_offset = None;
224 let mut text_start = None;
225 loop {
226 let Some(ch) = scanner.peek() else {
227 return Err(OpyError::at(
228 "settings-invalid",
229 "unterminated settings block (missing closing brace)".to_string(),
230 keyword_span,
231 ));
232 };
233 if scanner.advance_quoted(&mut string_quote, &mut escaped, ch) {
234 continue;
235 }
236 match ch {
237 '"' | '\'' => string_quote = Some(ch),
238 '{' => {
239 if depth == 0 {
240 text_start_offset = Some(scanner.pos + ch.len_utf8());
241 text_start = Some(scanner.here_after(1));
242 }
243 depth += 1;
244 }
245 '}' => {
246 depth -= 1;
247 if depth == 0 {
248 let text = scanner
249 .text
250 .get(text_start_offset.expect("text start offset set on '{'")..scanner.pos)
251 .unwrap_or_default()
252 .to_string();
253 return Ok(SettingsBlock {
254 text,
255 span: Span::new(keyword_span.file, keyword_start, scanner.here_after(1)),
256 keyword_span,
257 start: keyword_offset,
258 end: scanner.char_pos + 1,
259 text_start: text_start.expect("text start set on '{'"),
260 content_file: keyword_span.file,
261 content_span: Span::new(
262 keyword_span.file,
263 text_start.expect("text start set on '{'"),
264 scanner.here(),
265 ),
266 external_path: None,
267 });
268 }
269 }
270 _ => {}
271 }
272 scanner.advance_by(1);
273 }
274}
275
276pub fn sanitize_for_lex(text: &str, block: &SettingsBlock) -> String {
279 let mut out = String::with_capacity(text.len());
280 for (index, ch) in text.chars().enumerate() {
281 if index >= block.start && index < block.end {
282 out.push(if ch == '\n' { '\n' } else { ' ' });
283 } else {
284 out.push(ch);
285 }
286 }
287 out
288}
289
290pub fn parse_block(block: &SettingsBlock) -> OpyResult<cst::Settings> {
297 let mut parser = Cursor::new(&block.text, block.content_file, block.text_start);
298 parser.skip_whitespace();
299 let children = if block.external_path.is_some() {
300 let (children, _) = parser.parse_object()?;
301 children
302 } else {
303 parser.parse_members(true)?
306 };
307 parser.skip_whitespace();
308 if parser.pos < parser.text.len() {
309 return Err(parser.error(
310 "settings-invalid",
311 "unexpected content after the settings object".to_string(),
312 ));
313 }
314 if !children
315 .iter()
316 .any(|node| matches!(node, cst::SettingsNode::Group { name, .. } if name == "gamemodes"))
317 {
318 return Err(OpyError::at(
319 "settings-invalid",
320 "settings block must contain a gamemodes group".to_string(),
321 block.content_span,
322 ));
323 }
324 Ok(cst::Settings {
325 span: block.content_span,
326 children,
327 })
328}
329
330pub(crate) fn resolve_hir_settings(
336 program: &mut hir::Program,
337 cst_program: &cst::Program,
338) -> OpyResult<()> {
339 let constants = program
340 .declarations
341 .iter()
342 .filter_map(|declaration| match declaration {
343 hir::Declaration::Constant { name, value, .. } => Some((name.clone(), value.as_ref())),
344 _ => None,
345 })
346 .collect::<HashMap<_, _>>();
347 let mut expander = crate::compiler::MacroExpander::from_declarations(&program.declarations);
348 let Some(settings) = program.settings.take() else {
349 return Ok(());
350 };
351 let mut lowerer = crate::lower::SettingsLowerer::new(cst_program)?;
352 let children = settings
353 .children
354 .into_iter()
355 .map(|node| resolve_hir_node(node, &constants, &mut expander, &mut lowerer))
356 .collect::<OpyResult<Vec<_>>>()?;
357 program.settings = Some(hir::Settings {
358 span: settings.span,
359 children,
360 });
361 Ok(())
362}
363
364fn resolve_hir_node(
365 node: hir::SettingsNode,
366 constants: &HashMap<String, &hir::Expr>,
367 expander: &mut crate::compiler::MacroExpander<'_>,
368 lowerer: &mut crate::lower::SettingsLowerer,
369) -> OpyResult<hir::SettingsNode> {
370 match node {
371 hir::SettingsNode::Group {
372 name,
373 children,
374 span,
375 } => Ok(hir::SettingsNode::Group {
376 name,
377 children: children
378 .into_iter()
379 .map(|child| resolve_hir_node(child, constants, expander, lowerer))
380 .collect::<OpyResult<Vec<_>>>()?,
381 span,
382 }),
383 hir::SettingsNode::Raw { name, value, span } => {
384 let Some(span) = span else {
385 return Err(OpyError::new(
386 "settings-expression",
387 "settings value has no source span",
388 ));
389 };
390 let diag_span = hir_span_to_diag_span(span);
391 let expression = lowerer
392 .lower(
393 &value,
394 span.file,
395 Position::new(span.start.line, span.start.col),
396 )
397 .map_err(|error| settings_expression_error(diag_span, error.message))?;
398 let expression = expander
399 .expand_expr(&expression, &HashMap::new(), None)
400 .map_err(|error| {
401 let error_span = error
402 .diagnostic
403 .span
404 .map(hir_span_to_diag_span)
405 .unwrap_or(diag_span);
406 OpyError::at(error.diagnostic.code, error.diagnostic.message, error_span)
407 })?;
408 let mut stack = Vec::new();
409 let value =
410 crate::compile_time::evaluate(&expression, constants, &HashMap::new(), &mut stack)
411 .ok_or_else(|| {
412 settings_expression_error(
413 diag_span,
414 "expression is not a compile-time value".to_string(),
415 )
416 })?;
417 node_from_value(name, value, diag_span)
418 }
419 node => Ok(node),
420 }
421}
422
423fn hir_span_to_diag_span(span: hir::Span) -> Span {
424 Span::new(
425 span.file,
426 Position::new(span.start.line, span.start.col),
427 Position::new(span.end.line, span.end.col),
428 )
429}
430
431fn settings_expression_error(span: Span, message: String) -> OpyError {
432 OpyError::at(
433 "settings-expression",
434 format!("settings value is not a compile-time value: {message}"),
435 span,
436 )
437}
438
439fn node_from_value(
440 name: String,
441 value: crate::compile_time::Value,
442 span: Span,
443) -> OpyResult<hir::SettingsNode> {
444 let node = match value {
445 crate::compile_time::Value::Number(value) if value.is_finite() => {
446 hir::SettingsNode::Number {
447 name,
448 value,
449 span: Some(span.into()),
450 }
451 }
452 crate::compile_time::Value::Number(_) => {
453 return Err(settings_expression_error(
454 span,
455 "compile-time number is not finite".to_string(),
456 ));
457 }
458 crate::compile_time::Value::String(value) => hir::SettingsNode::String {
459 name,
460 value,
461 span: Some(span.into()),
462 },
463 crate::compile_time::Value::Bool(value) => hir::SettingsNode::Bool {
464 name,
465 value,
466 span: Some(span.into()),
467 },
468 crate::compile_time::Value::Array(values) => hir::SettingsNode::List {
469 name,
470 elements: values
471 .into_iter()
472 .map(|value| {
473 Ok(hir::SettingsListElement {
474 value: display_value(&value)?,
475 span: Some(span.into()),
476 })
477 })
478 .collect::<Result<Vec<_>, String>>()
479 .map_err(|message| settings_expression_error(span, message))?,
480 span: Some(span.into()),
481 },
482 crate::compile_time::Value::Object(entries) => hir::SettingsNode::Group {
483 name,
484 children: entries
485 .into_iter()
486 .map(|(key, value)| {
487 let crate::compile_time::Value::String(name) = key else {
488 return Err(settings_expression_error(
489 span,
490 "compile-time object keys must be strings".to_string(),
491 ));
492 };
493 node_from_value(name, value, span)
494 })
495 .collect::<OpyResult<Vec<_>>>()?,
496 span: Some(span.into()),
497 },
498 };
499 Ok(node)
500}
501
502fn display_value(value: &crate::compile_time::Value) -> Result<String, String> {
503 match value {
504 crate::compile_time::Value::Number(value) if value.is_finite() => {
505 Ok(crate::compiler::number_format::javascript_text(*value))
506 }
507 crate::compile_time::Value::String(value) => Ok(value.clone()),
508 crate::compile_time::Value::Bool(value) => Ok(value.to_string()),
509 _ => Err("settings list can only contain primitive values".to_string()),
510 }
511}
512
513fn js_number_literal(text: &str) -> Option<f64> {
517 if !text
521 .trim_start_matches(['-', '+'])
522 .chars()
523 .next()
524 .is_some_and(|c| c.is_ascii_digit() || c == '.')
525 {
526 return None;
527 }
528 if !text.contains(".e") && !text.contains(".E") {
532 if let Ok(value) = text.parse::<f64>() {
533 return Some(value);
534 }
535 }
536 let (negative, rest) = if let Some(rest) = text.strip_prefix('-') {
537 (true, rest)
538 } else {
539 (false, text.strip_prefix('+').unwrap_or(text))
540 };
541 let digits = rest
542 .strip_prefix("0x")
543 .or_else(|| rest.strip_prefix("0X"))
544 .map(|digits| (digits, 16))
545 .or_else(|| {
546 rest.strip_prefix("0o")
547 .or_else(|| rest.strip_prefix("0O"))
548 .map(|digits| (digits, 8))
549 })
550 .or_else(|| {
551 rest.strip_prefix("0b")
552 .or_else(|| rest.strip_prefix("0B"))
553 .map(|digits| (digits, 2))
554 });
555 let (digits, radix) = digits?;
556 if digits.is_empty() {
557 return None;
558 }
559 let value = digits.chars().try_fold(0f64, |value, c| {
560 c.to_digit(radix)
561 .map(|digit| value * radix as f64 + digit as f64)
562 })?;
563 Some(if negative { -value } else { value })
564}
565struct Cursor<'a> {
566 text: &'a str,
567 pos: usize,
568 char_pos: usize,
569 line: u32,
570 col: u32,
571 file: u32,
572}
573
574impl Cursor<'_> {
575 fn new(text: &str, file: u32, origin: Position) -> Cursor<'_> {
576 Cursor {
577 text,
578 pos: 0,
579 char_pos: 0,
580 line: origin.line,
581 col: origin.col,
582 file,
583 }
584 }
585
586 fn peek(&self) -> Option<char> {
587 self.peek_at(0)
588 }
589
590 fn peek_at(&self, ahead: usize) -> Option<char> {
591 self.text[self.pos..].chars().nth(ahead)
592 }
593
594 fn here(&self) -> Position {
595 Position::new(self.line, self.col)
596 }
597
598 fn here_after(&self, n: usize) -> Position {
599 let mut line = self.line;
600 let mut col = self.col;
601 for ch in self.text[self.pos..].chars().take(n) {
602 if ch == '\n' {
603 line += 1;
604 col = 1;
605 } else {
606 col += 1;
607 }
608 }
609 Position::new(line, col)
610 }
611
612 fn advance(&mut self) -> Option<char> {
613 let ch = self.peek()?;
614 if ch == '\n' {
615 self.line += 1;
616 self.col = 1;
617 } else {
618 self.col += 1;
619 }
620 self.pos += ch.len_utf8();
621 self.char_pos += 1;
622 Some(ch)
623 }
624
625 fn advance_by(&mut self, n: usize) {
626 for _ in 0..n {
627 if self.advance().is_none() {
628 return;
629 }
630 }
631 }
632
633 fn advance_quoted(&mut self, quote: &mut Option<char>, escaped: &mut bool, ch: char) -> bool {
634 let Some(quote_char) = *quote else {
635 return false;
636 };
637 if *escaped {
638 *escaped = false;
639 } else if ch == '\\' {
640 *escaped = true;
641 } else if ch == quote_char {
642 *quote = None;
643 }
644 self.advance_by(1);
645 true
646 }
647
648 fn skip_to_eol(&mut self) {
649 while self.peek().is_some_and(|ch| ch != '\n') {
650 self.advance_by(1);
651 }
652 }
653
654 fn skip_horizontal_whitespace(&mut self) {
655 while matches!(self.peek(), Some(' ' | '\t' | '\r')) {
656 self.advance_by(1);
657 }
658 }
659
660 fn read_word(&mut self) -> String {
661 let mut word = String::new();
662 while let Some(ch) = self.peek() {
663 if !is_ident_continue(ch) {
664 break;
665 }
666 word.push(ch);
667 self.advance_by(1);
668 }
669 word
670 }
671}
672
673enum ScalarValue {
675 String(String),
676 Bool(bool),
677 Number(f64),
678}
679
680impl Cursor<'_> {
681 fn skip_whitespace(&mut self) {
682 loop {
683 while let Some(ch) = self.peek() {
684 if ch.is_whitespace() {
685 self.advance();
686 } else {
687 break;
688 }
689 }
690 if self.peek() == Some('#') {
691 while self.peek().is_some_and(|ch| ch != '\n') {
692 self.advance();
693 }
694 continue;
695 }
696 if self.text[self.pos..].starts_with("//") {
697 self.advance();
698 self.advance();
699 while self.peek().is_some_and(|ch| ch != '\n') {
700 self.advance();
701 }
702 continue;
703 }
704 if self.text[self.pos..].starts_with("/*") {
705 self.advance();
706 self.advance();
707 while self.peek().is_some() && !self.text[self.pos..].starts_with("*/") {
708 self.advance();
709 }
710 if self.text[self.pos..].starts_with("*/") {
711 self.advance();
712 self.advance();
713 }
714 continue;
715 }
716 break;
717 }
718 }
719
720 fn error(&self, code: &str, message: String) -> OpyError {
721 OpyError::at(
722 code,
723 message,
724 Span::new(self.file, self.here(), self.here()),
725 )
726 }
727
728 fn error_at(&self, code: &str, message: String, span: Span) -> OpyError {
729 OpyError::at(code, message, span)
730 }
731
732 fn parse_object(&mut self) -> OpyResult<(Vec<cst::SettingsNode>, Span)> {
733 let open = self.here();
734 if self.advance() != Some('{') {
735 return Err(self.error(
736 "settings-invalid",
737 "settings block must be a JSONC object".to_string(),
738 ));
739 }
740 let members = self.parse_members(false)?;
741 let span = Span::new(self.file, open, self.here());
742 Ok((members, span))
743 }
744
745 fn parse_members(&mut self, root: bool) -> OpyResult<Vec<cst::SettingsNode>> {
749 let mut nodes = Vec::new();
750 let mut names = Vec::new();
751 self.skip_whitespace();
752 if (!root && self.peek() == Some('}')) || (root && self.pos >= self.text.len()) {
753 if !root {
754 self.advance();
755 }
756 return Ok(nodes);
757 }
758 loop {
759 self.skip_whitespace();
760 let key_start = self.here();
761 let key = match self.parse_string_value() {
762 Some(value) => value,
763 None => {
764 return Err(self.error(
765 "settings-invalid",
766 "settings keys must be quoted strings".to_string(),
767 ));
768 }
769 };
770 let key_span = Span::new(self.file, key_start, self.here());
771 if names.contains(&key) {
772 return Err(self.error_at(
773 "settings-invalid",
774 format!("duplicate settings key '{key}'"),
775 key_span,
776 ));
777 }
778 names.push(key.clone());
779 self.skip_whitespace();
780 if self.advance() != Some(':') {
781 return Err(self.error_at(
782 "settings-invalid",
783 format!("expected ':' after settings key '{key}'"),
784 key_span,
785 ));
786 }
787 self.skip_whitespace();
788 let (node, value_end) = self.parse_value()?;
789 let node = build_node(key, node, value_end, key_start, self.file);
790 nodes.push(node);
791 self.skip_whitespace();
792 match self.peek() {
793 Some(',') => {
794 self.advance();
795 self.skip_whitespace();
796 if (!root && self.peek() == Some('}')) || (root && self.pos >= self.text.len())
797 {
798 if !root {
799 self.advance();
800 }
801 return Ok(nodes);
802 }
803 }
804 Some('}') if !root => {
805 self.advance();
806 return Ok(nodes);
807 }
808 None if root => return Ok(nodes),
809 _ => {
810 return Err(self.error(
811 "settings-invalid",
812 "expected ',' or '}' in settings object".to_string(),
813 ));
814 }
815 }
816 }
817 }
818
819 fn parse_value(&mut self) -> OpyResult<(cst::SettingsNode, Position)> {
822 let start = self.here();
823 let ch = self.peek();
824 let saved = (self.pos, self.char_pos, self.line, self.col);
825 let scalar = match ch {
826 Some('"') | Some('\'') => {
827 let value = self.parse_string_expression().ok_or_else(|| {
828 self.error(
829 "settings-invalid",
830 "unterminated string in settings value".to_string(),
831 )
832 })?;
833 Some(ScalarValue::String(value))
834 }
835 Some('t') => {
836 self.expect_word("true")?;
837 Some(ScalarValue::Bool(true))
838 }
839 Some('f') => {
840 self.expect_word("false")?;
841 Some(ScalarValue::Bool(false))
842 }
843 Some(c) if c.is_ascii_digit() || c == '-' => {
844 Some(ScalarValue::Number(self.parse_number()?))
845 }
846 _ => None,
847 };
848 let node = if let Some(scalar) = scalar {
849 self.skip_inline_whitespace();
850 if self.is_expression_continuation() {
851 self.pos = saved.0;
852 self.char_pos = saved.1;
853 self.line = saved.2;
854 self.col = saved.3;
855 cst::SettingsNode::Raw {
856 name: String::new(),
857 value: self.parse_expression_value(),
858 span: Span::new(self.file, start, self.here()),
859 }
860 } else {
861 let span = Span::new(self.file, start, self.here());
862 match scalar {
863 ScalarValue::String(value) => cst::SettingsNode::String {
864 name: String::new(),
865 value,
866 span,
867 },
868 ScalarValue::Bool(value) => cst::SettingsNode::Bool {
869 name: String::new(),
870 value,
871 span,
872 },
873 ScalarValue::Number(value) => cst::SettingsNode::Number {
874 name: String::new(),
875 value,
876 span,
877 },
878 }
879 }
880 } else {
881 match ch {
882 Some('[') => {
883 let elements = self.parse_list()?;
884 cst::SettingsNode::List {
885 name: String::new(),
886 elements,
887 span: Span::new(self.file, start, self.here()),
888 }
889 }
890 Some('{') => {
891 let (children, _) = self.parse_object()?;
892 cst::SettingsNode::Group {
893 name: String::new(),
894 children,
895 span: Span::new(self.file, start, self.here()),
896 }
897 }
898 _ => {
899 let value = self.parse_expression_value();
900 if value.trim().is_empty() {
901 return Err(self.error(
902 "settings-invalid",
903 "expected a value in settings block".to_string(),
904 ));
905 }
906 cst::SettingsNode::Raw {
907 name: String::new(),
908 value,
909 span: Span::new(self.file, start, self.here()),
910 }
911 }
912 }
913 };
914 let end = self.here();
915 Ok((node, end))
916 }
917
918 fn parse_expression_value(&mut self) -> String {
922 let mut value = String::new();
923 let mut depth = 0usize;
924 let mut quote = None;
925 while let Some(ch) = self.peek() {
926 if let Some(expected) = quote {
927 value.push(self.advance().expect("peeked character exists"));
928 if ch == expected {
929 quote = None;
930 }
931 continue;
932 }
933 match ch {
934 '"' | '\'' => {
935 quote = Some(ch);
936 value.push(self.advance().expect("peeked character exists"));
937 }
938 '(' | '[' | '{' => {
939 depth += 1;
940 value.push(self.advance().expect("peeked character exists"));
941 }
942 ']' | ',' | '}' if depth == 0 => break,
943 ')' | ']' => {
944 depth = depth.saturating_sub(1);
945 value.push(self.advance().expect("peeked character exists"));
946 }
947 _ => value.push(self.advance().expect("peeked character exists")),
948 }
949 }
950 value.trim().to_string()
951 }
952
953 fn expect_word(&mut self, word: &str) -> OpyResult<()> {
954 let start = self.here();
955 for expected in word.chars() {
956 if self.advance() != Some(expected) {
957 return Err(self.error_at(
958 "settings-invalid",
959 format!("expected '{word}' in settings block"),
960 Span::new(self.file, start, self.here()),
961 ));
962 }
963 }
964 Ok(())
965 }
966
967 fn parse_number(&mut self) -> OpyResult<f64> {
976 let start = self.here();
977 let mut text = String::new();
978 if self.peek() == Some('-') {
979 text.push(self.advance().unwrap());
980 }
981 if self.peek() == Some('0')
982 && matches!(self.peek_at(1), Some('x' | 'X' | 'o' | 'O' | 'b' | 'B'))
983 {
984 text.push(self.advance().unwrap());
985 let letter = self.advance().expect("radix letter is peeked");
986 text.push(letter);
987 let radix = match letter {
988 'x' | 'X' => 16,
989 'o' | 'O' => 8,
990 _ => 2,
991 };
992 let mut value = 0f64;
993 let mut digits = 0usize;
994 while let Some(c) = self.peek() {
995 let Some(digit) = c.to_digit(radix) else {
996 break;
997 };
998 value = value * radix as f64 + digit as f64;
999 digits += 1;
1000 text.push(self.advance().unwrap());
1001 }
1002 if digits == 0 || self.peek().is_some_and(|c| c.is_ascii_alphanumeric()) {
1003 return Err(self.error_at(
1004 "settings-invalid",
1005 format!("invalid number '{text}' in settings block"),
1006 Span::new(self.file, start, self.here()),
1007 ));
1008 }
1009 return Ok(if text.starts_with('-') { -value } else { value });
1010 }
1011 while let Some(c) = self.peek() {
1012 if c.is_ascii_digit() {
1013 text.push(self.advance().unwrap());
1014 } else {
1015 break;
1016 }
1017 }
1018 if self.peek() == Some('.') {
1019 text.push(self.advance().unwrap());
1020 let mut fraction = 0usize;
1021 while let Some(c) = self.peek() {
1022 if c.is_ascii_digit() {
1023 text.push(self.advance().unwrap());
1024 fraction += 1;
1025 } else {
1026 break;
1027 }
1028 }
1029 if fraction == 0 && matches!(self.peek(), Some('e' | 'E')) {
1032 return Err(self.error_at(
1033 "settings-invalid",
1034 format!("invalid number '{text}' in settings block"),
1035 Span::new(self.file, start, self.here()),
1036 ));
1037 }
1038 }
1039 if matches!(self.peek(), Some('e' | 'E'))
1040 && self.peek_at(1).is_some_and(|c| c.is_ascii_digit())
1041 {
1042 text.push(self.advance().unwrap());
1043 while let Some(c) = self.peek() {
1044 if c.is_ascii_digit() {
1045 text.push(self.advance().unwrap());
1046 } else {
1047 break;
1048 }
1049 }
1050 }
1051 text.parse::<f64>().map_err(|_| {
1052 self.error_at(
1053 "settings-invalid",
1054 format!("invalid number '{text}' in settings block"),
1055 Span::new(self.file, start, self.here()),
1056 )
1057 })
1058 }
1059
1060 fn parse_list(&mut self) -> OpyResult<Vec<cst::SettingsListElement>> {
1061 self.advance(); let mut elements = Vec::new();
1063 self.skip_whitespace();
1064 if self.peek() == Some(']') {
1065 self.advance();
1066 return Ok(elements);
1067 }
1068 loop {
1069 self.skip_whitespace();
1070 let start = self.here();
1071 let value = match self.peek() {
1072 Some('"') | Some('\'') => self.parse_string_expression().ok_or_else(|| {
1073 self.error(
1074 "settings-invalid",
1075 "unterminated string in settings list".to_string(),
1076 )
1077 })?,
1078 Some(']') | Some(',') | None => {
1079 return Err(self.error(
1080 "settings-invalid",
1081 "settings list elements must not be empty".to_string(),
1082 ));
1083 }
1084 _ => {
1085 let value = self.parse_expression_value();
1086 js_number_literal(&value)
1089 .map(crate::compiler::number_format::javascript_text)
1090 .unwrap_or(value)
1091 }
1092 };
1093 let span = Span::new(self.file, start, self.here());
1094 elements.push(cst::SettingsListElement { value, span });
1095 self.skip_whitespace();
1096 match self.peek() {
1097 Some(',') => {
1098 self.advance();
1099 self.skip_whitespace();
1100 if self.peek() == Some(']') {
1101 self.advance();
1102 return Ok(elements);
1103 }
1104 }
1105 Some(']') => {
1106 self.advance();
1107 return Ok(elements);
1108 }
1109 _ => {
1110 return Err(self.error(
1111 "settings-invalid",
1112 "expected ',' or ']' in settings list".to_string(),
1113 ));
1114 }
1115 }
1116 }
1117 }
1118
1119 fn parse_string_value(&mut self) -> Option<String> {
1122 let quote = self.peek()?;
1123 if quote != '"' && quote != '\'' {
1124 return None;
1125 }
1126 self.advance();
1127 let mut value = String::new();
1128 loop {
1129 let ch = self.advance()?;
1130 if ch == '\n' {
1131 return None;
1132 }
1133 if ch == quote {
1134 return Some(value);
1135 }
1136 if ch == '\\' {
1137 let escaped = self.advance()?;
1138 value.push(decode_string_escape(escaped));
1139 } else {
1140 value.push(ch);
1141 }
1142 }
1143 }
1144
1145 fn parse_string_expression(&mut self) -> Option<String> {
1149 let mut value = self.parse_string_value()?;
1150 loop {
1151 let saved = (self.pos, self.char_pos, self.line, self.col);
1152 self.skip_whitespace();
1153 if self.peek() != Some('"') && self.peek() != Some('\'') {
1154 self.pos = saved.0;
1155 self.char_pos = saved.1;
1156 self.line = saved.2;
1157 self.col = saved.3;
1158 break;
1159 }
1160 let next = self.parse_string_value()?;
1161 value.push_str(&next);
1162 }
1163 Some(value)
1164 }
1165
1166 fn skip_inline_whitespace(&mut self) {
1167 while matches!(self.peek(), Some(' ' | '\t' | '\r')) {
1168 self.advance();
1169 }
1170 }
1171
1172 fn is_expression_continuation(&self) -> bool {
1173 if matches!(
1174 self.peek(),
1175 Some('+' | '-' | '*' | '/' | '%' | '<' | '>' | '=')
1176 ) {
1177 return true;
1178 }
1179 ["and", "or", "if"].iter().any(|word| {
1180 self.text[self.pos..]
1181 .strip_prefix(word)
1182 .is_some_and(|rest| {
1183 rest.chars().next().is_none_or(|character| {
1184 !character.is_ascii_alphanumeric() && character != '_'
1185 })
1186 })
1187 })
1188 }
1189}
1190
1191fn build_node(
1193 key: String,
1194 node: cst::SettingsNode,
1195 value_end: Position,
1196 key_start: Position,
1197 file: u32,
1198) -> cst::SettingsNode {
1199 let span = Span::new(file, key_start, value_end);
1200 match node {
1201 cst::SettingsNode::Group { children, .. } => cst::SettingsNode::Group {
1202 name: key,
1203 children,
1204 span,
1205 },
1206 cst::SettingsNode::Number { value, .. } => cst::SettingsNode::Number {
1207 name: key,
1208 value,
1209 span,
1210 },
1211 cst::SettingsNode::Bool { value, .. } => cst::SettingsNode::Bool {
1212 name: key,
1213 value,
1214 span,
1215 },
1216 cst::SettingsNode::String { value, .. } => cst::SettingsNode::String {
1217 name: key,
1218 value,
1219 span,
1220 },
1221 cst::SettingsNode::Raw { value, .. } => cst::SettingsNode::Raw {
1222 name: key,
1223 value,
1224 span,
1225 },
1226 cst::SettingsNode::List { elements, .. } => cst::SettingsNode::List {
1227 name: key,
1228 elements,
1229 span,
1230 },
1231 }
1232}
1233
1234#[cfg(test)]
1235mod tests {
1236 use super::*;
1237
1238 fn block(text: &str) -> SettingsBlock {
1239 let mut blocks = find_blocks(text, 0).unwrap();
1240 assert_eq!(blocks.len(), 1, "one block expected in: {text}");
1241 blocks.pop().unwrap()
1242 }
1243
1244 #[test]
1245 fn finds_block_after_comments_and_blanks() {
1246 let text = "/* header */\n# comment\n\nsettings {\n \"gamemodes\": {}\n}\nrule \"r\":\n";
1247 let found = block(text);
1248 assert!(found.text.contains("gamemodes"));
1249 assert_eq!(found.keyword_span.start.line, 4);
1250 assert_eq!(found.span.end.line, 6);
1251 }
1252
1253 #[test]
1254 fn no_blocks_for_plain_program() {
1255 assert!(
1256 find_blocks("rule \"r\":\n pass\n", 0)
1257 .unwrap()
1258 .is_empty()
1259 );
1260 }
1261
1262 #[test]
1263 fn ignores_settings_words_in_strings_and_indented_code() {
1264 let text = "rule \"settings\":\n x = \"settings\"\n";
1265 assert!(find_blocks(text, 0).unwrap().is_empty());
1266 }
1267
1268 #[test]
1269 fn settings_after_constructs_is_extracted() {
1270 let text = "playervar score\n\nrule \"r\":\n pass\n\nsettings {\n}\n";
1273 let found = block(text);
1274 assert_eq!(found.keyword_span.start.line, 6);
1275
1276 let found = block("rule \"r\":\n pass\n settings {\n }\n");
1278 assert_eq!(found.keyword_span.start.line, 3);
1279 }
1280
1281 #[test]
1282 fn settings_inside_brackets_is_an_ordinary_token() {
1283 let text = "globalvar g = [\n 1,\nsettings]\n";
1286 assert!(find_blocks(text, 0).unwrap().is_empty());
1287
1288 assert!(
1290 find_blocks("globalvar g = settings {}\n", 0)
1291 .unwrap()
1292 .is_empty()
1293 );
1294 }
1295
1296 #[test]
1297 fn settings_in_an_inline_tail_is_extracted() {
1298 let found = block("rule \"r\":\n pass\n\nrule \"t\": settings {\n}\n");
1301 assert_eq!(found.keyword_span.start.line, 4);
1302 }
1303
1304 #[test]
1305 fn settings_inside_an_enum_body_is_extracted() {
1306 let found = block("enum E:\n A\n settings {\n }\n");
1310 assert_eq!(found.keyword_span.start.line, 3);
1311 }
1312
1313 #[test]
1314 fn second_block_is_placement_error() {
1315 let error =
1316 find_blocks("settings {\n \"gamemodes\": {}\n}\nsettings {\n}\n", 0).unwrap_err();
1317 assert_eq!(error.code, "settings-placement");
1318 assert_eq!(error.span.unwrap().start.line, 4);
1319 }
1320
1321 #[test]
1322 fn settings_file_form_is_extracted() {
1323 let found = block("settings \"file.opy.json\"\n");
1324 assert_eq!(found.external_path.as_deref(), Some("file.opy.json"));
1325 }
1326
1327 #[test]
1328 fn unterminated_block_is_invalid() {
1329 let error = find_blocks("settings {\n \"gamemodes\": {\n", 0).unwrap_err();
1330 assert_eq!(error.code, "settings-invalid");
1331 }
1332
1333 #[test]
1334 fn braces_inside_strings_do_not_unbalance() {
1335 let found =
1336 block("settings {\n \"description\": \"a { b }\",\n \"gamemodes\": {}\n}\n");
1337 assert!(found.text.contains("a { b }"));
1338 }
1339
1340 #[test]
1341 fn sanitize_preserves_post_block_positions() {
1342 let text = "settings {\n \"gamemodes\": {}\n}\nrule \"r\":\n pass\n";
1343 let found = block(text);
1344 let sanitized = sanitize_for_lex(text, &found);
1345 let lines: Vec<&str> = sanitized.lines().collect();
1346 assert_eq!(lines.len(), 5);
1347 assert_eq!(lines[3], "rule \"r\":");
1348 assert_eq!(lines[4], " pass");
1349 assert_eq!(sanitized.find("rule"), text.find("rule"));
1351 }
1352
1353 #[test]
1354 fn unicode_preserves_offsets_and_sanitization() {
1355 let text = "# 前置🙂\nsettings {\n \"main\": { \"description\": \"内🙂\" },\n \"gamemodes\": {}\n}\nrule \"后🙂\":\n pass\n";
1356 let found = block(text);
1357 let keyword_byte_offset = text.find("settings").unwrap();
1358 let closing_byte_offset = text.find("}\nrule").unwrap();
1359 assert_eq!(found.start, text[..keyword_byte_offset].chars().count());
1360 assert_eq!(
1361 found.end,
1362 text[..closing_byte_offset + '}'.len_utf8()].chars().count()
1363 );
1364 assert_eq!(found.keyword_span.start, Position::new(2, 1));
1365 assert_eq!(found.text_start, Position::new(2, 11));
1366 assert!(found.text.contains("内🙂"));
1367
1368 let sanitized = sanitize_for_lex(text, &found);
1369 assert_eq!(sanitized.chars().count(), text.chars().count());
1370 let rule_char_offset = |source: &str| {
1371 let byte_offset = source.find("rule").unwrap();
1372 source[..byte_offset].chars().count()
1373 };
1374 assert_eq!(rule_char_offset(&sanitized), rule_char_offset(text));
1375 assert!(sanitized.contains("rule \"后🙂\":"));
1376 }
1377
1378 #[test]
1379 fn non_object_after_settings_is_invalid() {
1380 let error = find_blocks("settings [1, 2]\n", 0).unwrap_err();
1382 assert_eq!(error.code, "settings-invalid");
1383 }
1384
1385 #[test]
1386 fn parse_block_rejects_missing_gamemodes() {
1387 let found = block("settings {\n \"main\": { \"description\": \"x\" }\n}\n");
1388 let error = parse_block(&found).unwrap_err();
1389 assert_eq!(error.code, "settings-invalid");
1390 assert!(error.message.contains("gamemodes"));
1391 }
1392
1393 #[test]
1394 fn parse_block_rejects_duplicate_keys() {
1395 let found = block("settings {\n \"gamemodes\": {},\n \"gamemodes\": {}\n}\n");
1396 let error = parse_block(&found).unwrap_err();
1397 assert_eq!(error.code, "settings-invalid");
1398 assert!(error.message.contains("duplicate"));
1399 }
1400
1401 #[test]
1402 fn parse_block_accepts_trailing_commas() {
1403 let found = block(
1404 "settings {\n \"gamemodes\": {\n \"general\": {\n \"heroLimit\": \"off\",\n },\n },\n}\n",
1405 );
1406 let parsed = parse_block(&found).unwrap();
1407 assert_eq!(parsed.children.len(), 1);
1408 }
1409
1410 #[test]
1411 fn parse_block_handles_escapes_and_quotes() {
1412 let found = block(
1413 "settings {\n \"main\": { \"description\": \"line\\n\\t\\\"quoted\\\"\" },\n \"gamemodes\": {}\n}\n",
1414 );
1415 let parsed = parse_block(&found).unwrap();
1416 let cst::SettingsNode::Group { children, .. } = &parsed.children[0] else {
1417 panic!("main group");
1418 };
1419 let cst::SettingsNode::String { value, .. } = &children[0] else {
1420 panic!("description");
1421 };
1422 assert_eq!(value, "line\n\t\"quoted\"");
1423 }
1424
1425 #[test]
1426 fn parse_block_accepts_comments_and_expression_values() {
1427 let found = block(
1428 "settings {\n # keep the source expression intact\n \"lobby\": {\n \"modeName\": GAMEMODE_NAME\" \"GAMEMODE_VERSION,\n },\n \"gamemodes\": {}\n}\n",
1429 );
1430 let parsed = parse_block(&found).unwrap();
1431 let cst::SettingsNode::Group { children, .. } = &parsed.children[0] else {
1432 panic!("lobby group");
1433 };
1434 let cst::SettingsNode::Raw { value, .. } = &children[0] else {
1435 panic!("expression-valued setting");
1436 };
1437 assert_eq!(value, "GAMEMODE_NAME\" \"GAMEMODE_VERSION");
1438 }
1439
1440 #[test]
1441 fn parse_block_reads_javascript_number_literals() {
1442 let found = block(
1445 "settings {\n \"lobby\": { \"a\": 0x1F, \"b\": 0b101, \"c\": 0o17, \"d\": 1.5e21, \"e\": 12e2, \"f\": -0.5, \"g\": 2.5e1 },\n \"gamemodes\": {}\n}\n",
1446 );
1447 let parsed = parse_block(&found).unwrap();
1448 let cst::SettingsNode::Group { children, .. } = &parsed.children[0] else {
1449 panic!("lobby group");
1450 };
1451 let values: Vec<f64> = children
1452 .iter()
1453 .map(|node| match node {
1454 cst::SettingsNode::Number { value, .. } => *value,
1455 other => panic!("{other:?}"),
1456 })
1457 .collect();
1458 assert_eq!(values, [31.0, 5.0, 15.0, 1.5e21, 1200.0, -0.5, 25.0]);
1459 }
1460
1461 #[test]
1462 fn parse_block_rejects_signed_exponents_like_the_pinned_reference() {
1463 for value in ["1e-7", "1e+21", "-2.5e-3", "0.5e+3", "1.e5", "0.e5"] {
1467 let found = block(&format!(
1468 "settings {{\n \"lobby\": {{ \"k\": {value} }},\n \"gamemodes\": {{}}\n}}\n"
1469 ));
1470 let error = parse_block(&found).unwrap_err();
1471 assert_eq!(error.code, "settings-invalid", "{value}");
1472 }
1473 }
1474
1475 #[test]
1476 fn parse_block_types_values() {
1477 let found = block(
1478 "settings {\n \"lobby\": { \"ffaSlots\": 6 },\n \"gamemodes\": { \"general\": { \"enableRandomHeroes\": true, \"respawnTime%\": 30, \"heroLimit\": \"off\" } },\n \"heroes\": { \"allTeams\": { \"enabledHeroes\": [\"mei\"] } }\n}\n",
1479 );
1480 let parsed = parse_block(&found).unwrap();
1481 let lobby = match &parsed.children[0] {
1482 cst::SettingsNode::Group { name, children, .. } => {
1483 assert_eq!(name, "lobby");
1484 children
1485 }
1486 other => panic!("{other:?}"),
1487 };
1488 assert!(matches!(
1489 lobby[0],
1490 cst::SettingsNode::Number { value: 6.0, .. }
1491 ));
1492 }
1493
1494 #[test]
1495 fn spans_are_computed_from_block_base() {
1496 let text = "settings {\n \"lobby\": {\n \"ffaSlots\": 6\n },\n \"gamemodes\": {}\n}\n";
1497 let found = block(text);
1498 let parsed = parse_block(&found).unwrap();
1499 let cst::SettingsNode::Group { children, .. } = &parsed.children[0] else {
1500 panic!("lobby");
1501 };
1502 let cst::SettingsNode::Number { span, .. } = &children[0] else {
1503 panic!("ffaSlots");
1504 };
1505 assert_eq!(span.start.line, 3);
1506 assert_eq!(span.start.col, 9);
1507 }
1508
1509 #[test]
1510 fn keyword_span_carries_the_file_id() {
1511 let found = block("settings {\n \"gamemodes\": {}\n}\n");
1512 assert_eq!(found.keyword_span.file, 0);
1513 assert_eq!(found.keyword_span.start.col, 1);
1514 assert_eq!(found.keyword_span.end.col, 9);
1515 }
1516}