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