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 value = eval_settings_text(&value, span, constants, expander, lowerer, true)?;
392 node_from_value(name, value, diag_span)
393 }
394 hir::SettingsNode::List {
400 name,
401 elements,
402 span,
403 } => Ok(hir::SettingsNode::List {
404 name,
405 elements: elements
406 .into_iter()
407 .map(|mut element| {
408 if element.evaluated.is_none()
409 && let Some(span) = element.span
410 {
411 element.evaluated = eval_settings_text(
412 &element.value,
413 span,
414 constants,
415 expander,
416 lowerer,
417 true,
418 )
419 .ok()
420 .and_then(|value| display_value(&value).ok());
421 }
422 element
423 })
424 .collect(),
425 span,
426 }),
427 hir::SettingsNode::String { name, value, span } => {
431 if crate::compile_time::has_unbound_formatters(&value) {
432 let Some(span) = span else {
433 return Err(OpyError::new(
434 "settings-expression",
435 "settings value has no source span",
436 ));
437 };
438 return Err(settings_expression_error(
439 hir_span_to_diag_span(span),
440 format!("settings string '{value}' has format arguments"),
441 ));
442 }
443 Ok(hir::SettingsNode::String { name, value, span })
444 }
445 node => Ok(node),
446 }
447}
448
449fn eval_settings_text(
454 text: &str,
455 span: hir::Span,
456 constants: &HashMap<String, &hir::Expr>,
457 expander: &mut crate::compiler::MacroExpander<'_>,
458 lowerer: &mut crate::lower::SettingsLowerer,
459 strict: bool,
460) -> OpyResult<crate::compile_time::Value> {
461 let diag_span = hir_span_to_diag_span(span);
462 let expression = lowerer
463 .lower(
464 text,
465 span.file,
466 Position::new(span.start.line, span.start.col),
467 )
468 .map_err(|error| settings_expression_error(diag_span, error.message))?;
469 let expression = expander
470 .expand_expr(&expression, &HashMap::new(), None)
471 .map_err(|error| {
472 let error_span = error
473 .diagnostic
474 .span
475 .map(hir_span_to_diag_span)
476 .unwrap_or(diag_span);
477 OpyError::at(error.diagnostic.code, error.diagnostic.message, error_span)
478 })?;
479 let mut stack = Vec::new();
480 let value = if strict {
481 crate::compile_time::evaluate_settings(&expression, constants, &HashMap::new(), &mut stack)
482 } else {
483 crate::compile_time::evaluate(&expression, constants, &HashMap::new(), &mut stack)
484 };
485 value.ok_or_else(|| {
486 settings_expression_error(
487 diag_span,
488 "expression is not a compile-time value".to_string(),
489 )
490 })
491}
492
493fn hir_span_to_diag_span(span: hir::Span) -> Span {
494 Span::new(
495 span.file,
496 Position::new(span.start.line, span.start.col),
497 Position::new(span.end.line, span.end.col),
498 )
499}
500
501fn settings_expression_error(span: Span, message: String) -> OpyError {
502 OpyError::at(
503 "settings-expression",
504 format!("settings value is not a compile-time value: {message}"),
505 span,
506 )
507}
508
509fn node_from_value(
510 name: String,
511 value: crate::compile_time::Value,
512 span: Span,
513) -> OpyResult<hir::SettingsNode> {
514 let node = match value {
515 crate::compile_time::Value::Number(value) => hir::SettingsNode::Number {
518 name,
519 value,
520 span: Some(span.into()),
521 },
522 crate::compile_time::Value::String(value) => {
523 if crate::compile_time::has_unbound_formatters(&value) {
524 return Err(settings_expression_error(
525 span,
526 format!("settings string '{value}' has format arguments"),
527 ));
528 }
529 hir::SettingsNode::String {
530 name,
531 value,
532 span: Some(span.into()),
533 }
534 }
535 crate::compile_time::Value::Bool(value) => hir::SettingsNode::Bool {
536 name,
537 value,
538 span: Some(span.into()),
539 },
540 crate::compile_time::Value::Array(values) => hir::SettingsNode::List {
541 name,
542 elements: values
543 .into_iter()
544 .map(|value| {
545 let value = display_value(&value)?;
546 Ok(hir::SettingsListElement {
547 evaluated: Some(value.clone()),
548 value,
549 span: Some(span.into()),
550 })
551 })
552 .collect::<Result<Vec<_>, String>>()
553 .map_err(|message| settings_expression_error(span, message))?,
554 span: Some(span.into()),
555 },
556 crate::compile_time::Value::Object(entries) => hir::SettingsNode::Group {
557 name,
558 children: entries
559 .into_iter()
560 .map(|(key, value)| {
561 let crate::compile_time::Value::String(name) = key else {
562 return Err(settings_expression_error(
563 span,
564 "compile-time object keys must be strings".to_string(),
565 ));
566 };
567 node_from_value(name, value, span)
568 })
569 .collect::<OpyResult<Vec<_>>>()?,
570 span: Some(span.into()),
571 },
572 crate::compile_time::Value::Vector(_) | crate::compile_time::Value::Null => {
575 return Err(settings_expression_error(
576 span,
577 "compile-time value is not a settings value".to_string(),
578 ));
579 }
580 };
581 Ok(node)
582}
583
584fn display_value(value: &crate::compile_time::Value) -> Result<String, String> {
585 match value {
586 crate::compile_time::Value::Number(value) => {
589 Ok(crate::compiler::number_format::javascript_text(*value))
590 }
591 crate::compile_time::Value::String(value) => {
592 if crate::compile_time::has_unbound_formatters(value) {
593 return Err(format!("settings string '{value}' has format arguments"));
594 }
595 Ok(value.clone())
596 }
597 crate::compile_time::Value::Bool(value) => Ok(value.to_string()),
598 crate::compile_time::Value::Array(values) => values
602 .iter()
603 .map(display_value)
604 .collect::<Result<Vec<_>, _>>()
605 .map(|text| text.join(",")),
606 crate::compile_time::Value::Object(_) => Ok("[object Object]".to_string()),
607 crate::compile_time::Value::Vector(_) | crate::compile_time::Value::Null => {
608 Err("compile-time value is not a settings value".to_string())
609 }
610 }
611}
612
613fn js_number_value(text: &str) -> Option<f64> {
626 if !text.starts_with(|c: char| c.is_ascii_digit())
627 || !text
628 .chars()
629 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '@' | '$'))
630 {
631 return None;
632 }
633 if let Some(digits) = text.strip_prefix("0x") {
635 let mut value = 0f64;
636 let mut seen = false;
637 for c in digits.chars() {
638 let Some(digit) = c.to_digit(16) else { break };
639 value = value * 16.0 + digit as f64;
640 seen = true;
641 }
642 return Some(if seen { value } else { f64::NAN });
643 }
644 let strict_radix = text
645 .strip_prefix("0X")
646 .map(|digits| (digits, 16))
647 .or_else(|| {
648 text.strip_prefix("0o")
649 .or_else(|| text.strip_prefix("0O"))
650 .map(|digits| (digits, 8))
651 })
652 .or_else(|| {
653 text.strip_prefix("0b")
654 .or_else(|| text.strip_prefix("0B"))
655 .map(|digits| (digits, 2))
656 });
657 if let Some((digits, radix)) = strict_radix {
658 return Some(
659 (!digits.is_empty())
660 .then(|| {
661 digits.chars().try_fold(0f64, |value, c| {
662 c.to_digit(radix)
663 .map(|digit| value * radix as f64 + digit as f64)
664 })
665 })
666 .flatten()
667 .unwrap_or(f64::NAN),
668 );
669 }
670 Some(text.parse::<f64>().unwrap_or(f64::NAN))
672}
673
674struct Cursor<'a> {
675 text: &'a str,
676 pos: usize,
677 char_pos: usize,
678 line: u32,
679 col: u32,
680 file: u32,
681}
682
683impl Cursor<'_> {
684 fn new(text: &str, file: u32, origin: Position) -> Cursor<'_> {
685 Cursor {
686 text,
687 pos: 0,
688 char_pos: 0,
689 line: origin.line,
690 col: origin.col,
691 file,
692 }
693 }
694
695 fn peek(&self) -> Option<char> {
696 self.peek_at(0)
697 }
698
699 fn peek_at(&self, ahead: usize) -> Option<char> {
700 self.text[self.pos..].chars().nth(ahead)
701 }
702
703 fn here(&self) -> Position {
704 Position::new(self.line, self.col)
705 }
706
707 fn here_after(&self, n: usize) -> Position {
708 let mut line = self.line;
709 let mut col = self.col;
710 for ch in self.text[self.pos..].chars().take(n) {
711 if ch == '\n' {
712 line += 1;
713 col = 1;
714 } else {
715 col += 1;
716 }
717 }
718 Position::new(line, col)
719 }
720
721 fn advance(&mut self) -> Option<char> {
722 let ch = self.peek()?;
723 if ch == '\n' {
724 self.line += 1;
725 self.col = 1;
726 } else {
727 self.col += 1;
728 }
729 self.pos += ch.len_utf8();
730 self.char_pos += 1;
731 Some(ch)
732 }
733
734 fn advance_by(&mut self, n: usize) {
735 for _ in 0..n {
736 if self.advance().is_none() {
737 return;
738 }
739 }
740 }
741
742 fn advance_quoted(&mut self, quote: &mut Option<char>, escaped: &mut bool, ch: char) -> bool {
743 let Some(quote_char) = *quote else {
744 return false;
745 };
746 if *escaped {
747 *escaped = false;
748 } else if ch == '\\' {
749 *escaped = true;
750 } else if ch == quote_char {
751 *quote = None;
752 }
753 self.advance_by(1);
754 true
755 }
756
757 fn skip_to_eol(&mut self) {
758 while self.peek().is_some_and(|ch| ch != '\n') {
759 self.advance_by(1);
760 }
761 }
762
763 fn skip_horizontal_whitespace(&mut self) {
764 while matches!(self.peek(), Some(' ' | '\t' | '\r')) {
765 self.advance_by(1);
766 }
767 }
768
769 fn read_word(&mut self) -> String {
770 let mut word = String::new();
771 while let Some(ch) = self.peek() {
772 if !is_ident_continue(ch) {
773 break;
774 }
775 word.push(ch);
776 self.advance_by(1);
777 }
778 word
779 }
780}
781
782enum ScalarValue {
784 String(String),
785 Bool(bool),
786 Number(f64),
787}
788
789impl Cursor<'_> {
790 fn skip_whitespace(&mut self) {
791 loop {
792 while let Some(ch) = self.peek() {
793 if ch.is_whitespace() {
794 self.advance();
795 } else {
796 break;
797 }
798 }
799 if self.peek() == Some('#') {
800 while self.peek().is_some_and(|ch| ch != '\n') {
801 self.advance();
802 }
803 continue;
804 }
805 if self.text[self.pos..].starts_with("//") {
806 self.advance();
807 self.advance();
808 while self.peek().is_some_and(|ch| ch != '\n') {
809 self.advance();
810 }
811 continue;
812 }
813 if self.text[self.pos..].starts_with("/*") {
814 self.advance();
815 self.advance();
816 while self.peek().is_some() && !self.text[self.pos..].starts_with("*/") {
817 self.advance();
818 }
819 if self.text[self.pos..].starts_with("*/") {
820 self.advance();
821 self.advance();
822 }
823 continue;
824 }
825 break;
826 }
827 }
828
829 fn error(&self, code: &str, message: String) -> OpyError {
830 OpyError::at(
831 code,
832 message,
833 Span::new(self.file, self.here(), self.here()),
834 )
835 }
836
837 fn error_at(&self, code: &str, message: String, span: Span) -> OpyError {
838 OpyError::at(code, message, span)
839 }
840
841 fn parse_object(&mut self) -> OpyResult<(Vec<cst::SettingsNode>, Span)> {
842 let open = self.here();
843 if self.advance() != Some('{') {
844 return Err(self.error(
845 "settings-invalid",
846 "settings block must be a JSONC object".to_string(),
847 ));
848 }
849 let members = self.parse_members(false)?;
850 let span = Span::new(self.file, open, self.here());
851 Ok((members, span))
852 }
853
854 fn parse_members(&mut self, root: bool) -> OpyResult<Vec<cst::SettingsNode>> {
858 let mut nodes = Vec::new();
859 let mut names = Vec::new();
860 self.skip_whitespace();
861 if (!root && self.peek() == Some('}')) || (root && self.pos >= self.text.len()) {
862 if !root {
863 self.advance();
864 }
865 return Ok(nodes);
866 }
867 loop {
868 self.skip_whitespace();
869 let key_start = self.here();
870 let key = match self.parse_string_value() {
871 Some(value) => value,
872 None => {
873 return Err(self.error(
874 "settings-invalid",
875 "settings keys must be quoted strings".to_string(),
876 ));
877 }
878 };
879 let key_span = Span::new(self.file, key_start, self.here());
880 if names.contains(&key) {
881 return Err(self.error_at(
882 "settings-invalid",
883 format!("duplicate settings key '{key}'"),
884 key_span,
885 ));
886 }
887 names.push(key.clone());
888 self.skip_whitespace();
889 if self.advance() != Some(':') {
890 return Err(self.error_at(
891 "settings-invalid",
892 format!("expected ':' after settings key '{key}'"),
893 key_span,
894 ));
895 }
896 self.skip_whitespace();
897 let (node, value_end) = self.parse_value()?;
898 let node = build_node(key, node, value_end, key_start, self.file);
899 nodes.push(node);
900 self.skip_whitespace();
901 match self.peek() {
902 Some(',') => {
903 self.advance();
904 self.skip_whitespace();
905 if (!root && self.peek() == Some('}')) || (root && self.pos >= self.text.len())
906 {
907 if !root {
908 self.advance();
909 }
910 return Ok(nodes);
911 }
912 }
913 Some('}') if !root => {
914 self.advance();
915 return Ok(nodes);
916 }
917 None if root => return Ok(nodes),
918 _ => {
919 return Err(self.error(
920 "settings-invalid",
921 "expected ',' or '}' in settings object".to_string(),
922 ));
923 }
924 }
925 }
926 }
927
928 fn parse_value(&mut self) -> OpyResult<(cst::SettingsNode, Position)> {
935 let start = self.here();
936 let ch = self.peek();
937 let saved = (self.pos, self.char_pos, self.line, self.col);
938 let scalar = match ch {
939 Some('"') | Some('\'') => {
940 let value = self.parse_string_expression().ok_or_else(|| {
941 self.error(
942 "settings-invalid",
943 "unterminated string in settings value".to_string(),
944 )
945 })?;
946 Some(ScalarValue::String(value))
947 }
948 Some('t') => {
949 self.expect_word("true")?;
950 Some(ScalarValue::Bool(true))
951 }
952 Some('f') => {
953 self.expect_word("false")?;
954 Some(ScalarValue::Bool(false))
955 }
956 Some(c) if c.is_ascii_digit() => Some(ScalarValue::Number(self.parse_number()?)),
957 Some('-') if self.peek_at(1).is_some_and(|c| c.is_ascii_digit()) => {
960 Some(ScalarValue::Number(self.parse_number()?))
961 }
962 _ => None,
963 };
964 let node = if let Some(scalar) = scalar {
965 if !self.is_expression_continuation() {
966 let span = Span::new(self.file, start, self.here());
967 match scalar {
968 ScalarValue::String(value) => cst::SettingsNode::String {
969 name: String::new(),
970 value,
971 span,
972 },
973 ScalarValue::Bool(value) => cst::SettingsNode::Bool {
974 name: String::new(),
975 value,
976 span,
977 },
978 ScalarValue::Number(value) => cst::SettingsNode::Number {
979 name: String::new(),
980 value,
981 span,
982 },
983 }
984 } else {
985 self.pos = saved.0;
986 self.char_pos = saved.1;
987 self.line = saved.2;
988 self.col = saved.3;
989 cst::SettingsNode::Raw {
990 name: String::new(),
991 value: self.parse_expression_value(),
992 span: Span::new(self.file, start, self.here()),
993 }
994 }
995 } else {
996 match ch {
997 Some('[') => {
998 let elements = self.parse_list()?;
999 if self.is_expression_continuation() {
1000 self.pos = saved.0;
1001 self.char_pos = saved.1;
1002 self.line = saved.2;
1003 self.col = saved.3;
1004 cst::SettingsNode::Raw {
1005 name: String::new(),
1006 value: self.parse_expression_value(),
1007 span: Span::new(self.file, start, self.here()),
1008 }
1009 } else {
1010 cst::SettingsNode::List {
1011 name: String::new(),
1012 elements,
1013 span: Span::new(self.file, start, self.here()),
1014 }
1015 }
1016 }
1017 Some('{') => {
1018 let (children, _) = self.parse_object()?;
1019 if self.is_expression_continuation() {
1020 self.pos = saved.0;
1021 self.char_pos = saved.1;
1022 self.line = saved.2;
1023 self.col = saved.3;
1024 cst::SettingsNode::Raw {
1025 name: String::new(),
1026 value: self.parse_expression_value(),
1027 span: Span::new(self.file, start, self.here()),
1028 }
1029 } else {
1030 cst::SettingsNode::Group {
1031 name: String::new(),
1032 children,
1033 span: Span::new(self.file, start, self.here()),
1034 }
1035 }
1036 }
1037 _ => {
1038 let value = self.parse_expression_value();
1039 if value.trim().is_empty() {
1040 return Err(self.error(
1041 "settings-invalid",
1042 "expected a value in settings block".to_string(),
1043 ));
1044 }
1045 cst::SettingsNode::Raw {
1046 name: String::new(),
1047 value,
1048 span: Span::new(self.file, start, self.here()),
1049 }
1050 }
1051 }
1052 };
1053 let end = self.here();
1054 Ok((node, end))
1055 }
1056
1057 fn parse_expression_value(&mut self) -> String {
1061 let mut value = String::new();
1062 let mut depth = 0usize;
1063 let mut quote = None;
1064 while let Some(ch) = self.peek() {
1065 if let Some(expected) = quote {
1066 value.push(self.advance().expect("peeked character exists"));
1067 if ch == expected {
1068 quote = None;
1069 }
1070 continue;
1071 }
1072 match ch {
1073 '"' | '\'' => {
1074 quote = Some(ch);
1075 value.push(self.advance().expect("peeked character exists"));
1076 }
1077 '(' | '[' | '{' => {
1078 depth += 1;
1079 value.push(self.advance().expect("peeked character exists"));
1080 }
1081 ']' | ',' | '}' if depth == 0 => break,
1082 ')' | ']' | '}' => {
1083 depth = depth.saturating_sub(1);
1084 value.push(self.advance().expect("peeked character exists"));
1085 }
1086 _ => value.push(self.advance().expect("peeked character exists")),
1087 }
1088 }
1089 value.trim().to_string()
1090 }
1091
1092 fn expect_word(&mut self, word: &str) -> OpyResult<()> {
1093 let start = self.here();
1094 for expected in word.chars() {
1095 if self.advance() != Some(expected) {
1096 return Err(self.error_at(
1097 "settings-invalid",
1098 format!("expected '{word}' in settings block"),
1099 Span::new(self.file, start, self.here()),
1100 ));
1101 }
1102 }
1103 Ok(())
1104 }
1105
1106 fn parse_number(&mut self) -> OpyResult<f64> {
1118 let start = self.here();
1119 let mut text = String::new();
1120 if self.peek() == Some('-') {
1121 text.push(self.advance().unwrap());
1122 }
1123 if self.peek() == Some('0')
1124 && matches!(self.peek_at(1), Some('x' | 'X' | 'o' | 'O' | 'b' | 'B'))
1125 {
1126 text.push(self.advance().unwrap());
1127 let letter = self.advance().expect("radix letter is peeked");
1128 text.push(letter);
1129 let radix = match letter {
1130 'x' | 'X' => 16,
1131 'o' | 'O' => 8,
1132 _ => 2,
1133 };
1134 let mut value = 0f64;
1135 let mut digits = 0usize;
1136 while let Some(c) = self.peek() {
1137 let Some(digit) = c.to_digit(radix) else {
1138 break;
1139 };
1140 value = value * radix as f64 + digit as f64;
1141 digits += 1;
1142 text.push(self.advance().unwrap());
1143 }
1144 let value = if digits == 0 { f64::NAN } else { value };
1148 return Ok(if text.starts_with('-') { -value } else { value });
1149 }
1150 while let Some(c) = self.peek() {
1151 if c.is_ascii_digit() {
1152 text.push(self.advance().unwrap());
1153 } else {
1154 break;
1155 }
1156 }
1157 if self.peek() == Some('.')
1160 && text.chars().any(|c| c.is_ascii_digit())
1161 && self.peek_at(1).is_some_and(|c| c.is_ascii_digit())
1162 {
1163 text.push(self.advance().unwrap());
1164 while let Some(c) = self.peek() {
1165 if c.is_ascii_digit() {
1166 text.push(self.advance().unwrap());
1167 } else {
1168 break;
1169 }
1170 }
1171 }
1172 if matches!(self.peek(), Some('e' | 'E'))
1173 && self.peek_at(1).is_some_and(|c| c.is_ascii_digit())
1174 {
1175 text.push(self.advance().unwrap());
1176 while let Some(c) = self.peek() {
1177 if c.is_ascii_digit() {
1178 text.push(self.advance().unwrap());
1179 } else {
1180 break;
1181 }
1182 }
1183 }
1184 text.parse::<f64>().map_err(|_| {
1185 self.error_at(
1186 "settings-invalid",
1187 format!("invalid number '{text}' in settings block"),
1188 Span::new(self.file, start, self.here()),
1189 )
1190 })
1191 }
1192
1193 fn parse_list(&mut self) -> OpyResult<Vec<cst::SettingsListElement>> {
1194 self.advance(); let mut elements = Vec::new();
1196 self.skip_whitespace();
1197 if self.peek() == Some(']') {
1198 self.advance();
1199 return Ok(elements);
1200 }
1201 loop {
1202 self.skip_whitespace();
1203 let start = self.here();
1204 let (value, expr) = match self.peek() {
1205 Some(']') | Some(',') | None => {
1206 return Err(self.error(
1207 "settings-invalid",
1208 "settings list elements must not be empty".to_string(),
1209 ));
1210 }
1211 _ => {
1212 let text = self.parse_expression_value();
1213 match js_number_value(&text) {
1214 Some(number) => (
1218 crate::compiler::number_format::javascript_text(number),
1219 false,
1220 ),
1221 None => (text, true),
1226 }
1227 }
1228 };
1229 let span = Span::new(self.file, start, self.here());
1230 elements.push(cst::SettingsListElement { value, expr, span });
1231 self.skip_whitespace();
1232 match self.peek() {
1233 Some(',') => {
1234 self.advance();
1235 self.skip_whitespace();
1236 if self.peek() == Some(']') {
1237 self.advance();
1238 return Ok(elements);
1239 }
1240 }
1241 Some(']') => {
1242 self.advance();
1243 return Ok(elements);
1244 }
1245 _ => {
1246 return Err(self.error(
1247 "settings-invalid",
1248 "expected ',' or ']' in settings list".to_string(),
1249 ));
1250 }
1251 }
1252 }
1253 }
1254
1255 fn parse_string_value(&mut self) -> Option<String> {
1258 let quote = self.peek()?;
1259 if quote != '"' && quote != '\'' {
1260 return None;
1261 }
1262 self.advance();
1263 let mut value = String::new();
1264 loop {
1265 let ch = self.advance()?;
1266 if ch == '\n' {
1267 return None;
1268 }
1269 if ch == quote {
1270 return Some(value);
1271 }
1272 if ch == '\\' {
1273 let escaped = self.advance()?;
1274 value.push(decode_string_escape(escaped));
1275 } else {
1276 value.push(ch);
1277 }
1278 }
1279 }
1280
1281 fn parse_string_expression(&mut self) -> Option<String> {
1285 let mut value = self.parse_string_value()?;
1286 loop {
1287 let saved = (self.pos, self.char_pos, self.line, self.col);
1288 self.skip_whitespace();
1289 if self.peek() != Some('"') && self.peek() != Some('\'') {
1290 self.pos = saved.0;
1291 self.char_pos = saved.1;
1292 self.line = saved.2;
1293 self.col = saved.3;
1294 break;
1295 }
1296 let next = self.parse_string_value()?;
1297 value.push_str(&next);
1298 }
1299 Some(value)
1300 }
1301
1302 fn is_expression_continuation(&self) -> bool {
1309 self.text[self.pos..]
1310 .trim_start()
1311 .chars()
1312 .next()
1313 .is_some_and(|c| c != ',' && c != '}')
1314 }
1315}
1316
1317fn build_node(
1319 key: String,
1320 node: cst::SettingsNode,
1321 value_end: Position,
1322 key_start: Position,
1323 file: u32,
1324) -> cst::SettingsNode {
1325 let span = Span::new(file, key_start, value_end);
1326 match node {
1327 cst::SettingsNode::Group { children, .. } => cst::SettingsNode::Group {
1328 name: key,
1329 children,
1330 span,
1331 },
1332 cst::SettingsNode::Number { value, .. } => cst::SettingsNode::Number {
1333 name: key,
1334 value,
1335 span,
1336 },
1337 cst::SettingsNode::Bool { value, .. } => cst::SettingsNode::Bool {
1338 name: key,
1339 value,
1340 span,
1341 },
1342 cst::SettingsNode::String { value, .. } => cst::SettingsNode::String {
1343 name: key,
1344 value,
1345 span,
1346 },
1347 cst::SettingsNode::Raw { value, .. } => cst::SettingsNode::Raw {
1348 name: key,
1349 value,
1350 span,
1351 },
1352 cst::SettingsNode::List { elements, .. } => cst::SettingsNode::List {
1353 name: key,
1354 elements,
1355 span,
1356 },
1357 }
1358}
1359
1360#[cfg(test)]
1361mod tests {
1362 use super::*;
1363
1364 fn block(text: &str) -> SettingsBlock {
1365 let mut blocks = find_blocks(text, 0).unwrap();
1366 assert_eq!(blocks.len(), 1, "one block expected in: {text}");
1367 blocks.pop().unwrap()
1368 }
1369
1370 #[test]
1371 fn finds_block_after_comments_and_blanks() {
1372 let text = "/* header */\n# comment\n\nsettings {\n \"gamemodes\": {}\n}\nrule \"r\":\n";
1373 let found = block(text);
1374 assert!(found.text.contains("gamemodes"));
1375 assert_eq!(found.keyword_span.start.line, 4);
1376 assert_eq!(found.span.end.line, 6);
1377 }
1378
1379 #[test]
1380 fn no_blocks_for_plain_program() {
1381 assert!(
1382 find_blocks("rule \"r\":\n pass\n", 0)
1383 .unwrap()
1384 .is_empty()
1385 );
1386 }
1387
1388 #[test]
1389 fn ignores_settings_words_in_strings_and_indented_code() {
1390 let text = "rule \"settings\":\n x = \"settings\"\n";
1391 assert!(find_blocks(text, 0).unwrap().is_empty());
1392 }
1393
1394 #[test]
1395 fn settings_after_constructs_is_extracted() {
1396 let text = "playervar score\n\nrule \"r\":\n pass\n\nsettings {\n}\n";
1399 let found = block(text);
1400 assert_eq!(found.keyword_span.start.line, 6);
1401
1402 let found = block("rule \"r\":\n pass\n settings {\n }\n");
1404 assert_eq!(found.keyword_span.start.line, 3);
1405 }
1406
1407 #[test]
1408 fn settings_inside_brackets_is_an_ordinary_token() {
1409 let text = "globalvar g = [\n 1,\nsettings]\n";
1412 assert!(find_blocks(text, 0).unwrap().is_empty());
1413
1414 assert!(
1416 find_blocks("globalvar g = settings {}\n", 0)
1417 .unwrap()
1418 .is_empty()
1419 );
1420 }
1421
1422 #[test]
1423 fn settings_in_an_inline_tail_is_extracted() {
1424 let found = block("rule \"r\":\n pass\n\nrule \"t\": settings {\n}\n");
1427 assert_eq!(found.keyword_span.start.line, 4);
1428 }
1429
1430 #[test]
1431 fn settings_inside_an_enum_body_is_extracted() {
1432 let found = block("enum E:\n A\n settings {\n }\n");
1436 assert_eq!(found.keyword_span.start.line, 3);
1437 }
1438
1439 #[test]
1440 fn second_block_is_placement_error() {
1441 let error =
1442 find_blocks("settings {\n \"gamemodes\": {}\n}\nsettings {\n}\n", 0).unwrap_err();
1443 assert_eq!(error.code, "settings-placement");
1444 assert_eq!(error.span.unwrap().start.line, 4);
1445 }
1446
1447 #[test]
1448 fn settings_file_form_is_extracted() {
1449 let found = block("settings \"file.opy.json\"\n");
1450 assert_eq!(found.external_path.as_deref(), Some("file.opy.json"));
1451 }
1452
1453 #[test]
1454 fn unterminated_block_is_invalid() {
1455 let error = find_blocks("settings {\n \"gamemodes\": {\n", 0).unwrap_err();
1456 assert_eq!(error.code, "settings-invalid");
1457 }
1458
1459 #[test]
1460 fn braces_inside_strings_do_not_unbalance() {
1461 let found =
1462 block("settings {\n \"description\": \"a { b }\",\n \"gamemodes\": {}\n}\n");
1463 assert!(found.text.contains("a { b }"));
1464 }
1465
1466 #[test]
1467 fn sanitize_preserves_post_block_positions() {
1468 let text = "settings {\n \"gamemodes\": {}\n}\nrule \"r\":\n pass\n";
1469 let found = block(text);
1470 let sanitized = sanitize_for_lex(text, &found);
1471 let lines: Vec<&str> = sanitized.lines().collect();
1472 assert_eq!(lines.len(), 5);
1473 assert_eq!(lines[3], "rule \"r\":");
1474 assert_eq!(lines[4], " pass");
1475 assert_eq!(sanitized.find("rule"), text.find("rule"));
1477 }
1478
1479 #[test]
1480 fn unicode_preserves_offsets_and_sanitization() {
1481 let text = "# 前置🙂\nsettings {\n \"main\": { \"description\": \"内🙂\" },\n \"gamemodes\": {}\n}\nrule \"后🙂\":\n pass\n";
1482 let found = block(text);
1483 let keyword_byte_offset = text.find("settings").unwrap();
1484 let closing_byte_offset = text.find("}\nrule").unwrap();
1485 assert_eq!(found.start, text[..keyword_byte_offset].chars().count());
1486 assert_eq!(
1487 found.end,
1488 text[..closing_byte_offset + '}'.len_utf8()].chars().count()
1489 );
1490 assert_eq!(found.keyword_span.start, Position::new(2, 1));
1491 assert_eq!(found.text_start, Position::new(2, 11));
1492 assert!(found.text.contains("内🙂"));
1493
1494 let sanitized = sanitize_for_lex(text, &found);
1495 assert_eq!(sanitized.chars().count(), text.chars().count());
1496 let rule_char_offset = |source: &str| {
1497 let byte_offset = source.find("rule").unwrap();
1498 source[..byte_offset].chars().count()
1499 };
1500 assert_eq!(rule_char_offset(&sanitized), rule_char_offset(text));
1501 assert!(sanitized.contains("rule \"后🙂\":"));
1502 }
1503
1504 #[test]
1505 fn non_object_after_settings_is_invalid() {
1506 let error = find_blocks("settings [1, 2]\n", 0).unwrap_err();
1508 assert_eq!(error.code, "settings-invalid");
1509 }
1510
1511 #[test]
1512 fn parse_block_rejects_missing_gamemodes() {
1513 let found = block("settings {\n \"main\": { \"description\": \"x\" }\n}\n");
1514 let error = parse_block(&found).unwrap_err();
1515 assert_eq!(error.code, "settings-invalid");
1516 assert!(error.message.contains("gamemodes"));
1517 }
1518
1519 #[test]
1520 fn parse_block_rejects_duplicate_keys() {
1521 let found = block("settings {\n \"gamemodes\": {},\n \"gamemodes\": {}\n}\n");
1522 let error = parse_block(&found).unwrap_err();
1523 assert_eq!(error.code, "settings-invalid");
1524 assert!(error.message.contains("duplicate"));
1525 }
1526
1527 #[test]
1528 fn parse_block_accepts_trailing_commas() {
1529 let found = block(
1530 "settings {\n \"gamemodes\": {\n \"general\": {\n \"heroLimit\": \"off\",\n },\n },\n}\n",
1531 );
1532 let parsed = parse_block(&found).unwrap();
1533 assert_eq!(parsed.children.len(), 1);
1534 }
1535
1536 #[test]
1537 fn parse_block_handles_escapes_and_quotes() {
1538 let found = block(
1539 "settings {\n \"main\": { \"description\": \"line\\n\\t\\\"quoted\\\"\" },\n \"gamemodes\": {}\n}\n",
1540 );
1541 let parsed = parse_block(&found).unwrap();
1542 let cst::SettingsNode::Group { children, .. } = &parsed.children[0] else {
1543 panic!("main group");
1544 };
1545 let cst::SettingsNode::String { value, .. } = &children[0] else {
1546 panic!("description");
1547 };
1548 assert_eq!(value, "line\n\t\"quoted\"");
1549 }
1550
1551 #[test]
1552 fn parse_block_accepts_comments_and_expression_values() {
1553 let found = block(
1554 "settings {\n # keep the source expression intact\n \"lobby\": {\n \"modeName\": GAMEMODE_NAME\" \"GAMEMODE_VERSION,\n },\n \"gamemodes\": {}\n}\n",
1555 );
1556 let parsed = parse_block(&found).unwrap();
1557 let cst::SettingsNode::Group { children, .. } = &parsed.children[0] else {
1558 panic!("lobby group");
1559 };
1560 let cst::SettingsNode::Raw { value, .. } = &children[0] else {
1561 panic!("expression-valued setting");
1562 };
1563 assert_eq!(value, "GAMEMODE_NAME\" \"GAMEMODE_VERSION");
1564 }
1565
1566 #[test]
1567 fn parse_block_reads_javascript_number_literals() {
1568 let found = block(
1571 "settings {\n \"lobby\": { \"a\": 0x1F, \"b\": 0b101, \"c\": 0o17, \"d\": 1.5e21, \"e\": 12e2, \"f\": -0.5, \"g\": 2.5e1 },\n \"gamemodes\": {}\n}\n",
1572 );
1573 let parsed = parse_block(&found).unwrap();
1574 let cst::SettingsNode::Group { children, .. } = &parsed.children[0] else {
1575 panic!("lobby group");
1576 };
1577 let values: Vec<f64> = children
1578 .iter()
1579 .map(|node| match node {
1580 cst::SettingsNode::Number { value, .. } => *value,
1581 other => panic!("{other:?}"),
1582 })
1583 .collect();
1584 assert_eq!(values, [31.0, 5.0, 15.0, 1.5e21, 1200.0, -0.5, 25.0]);
1585 }
1586
1587 #[test]
1588 fn parse_block_routes_signed_exponents_to_the_expression_path() {
1589 for value in ["1e-7", "1e+21", "-2.5e-3", "0.5e+3", "1.e5", "0.e5"] {
1594 let found = block(&format!(
1595 "settings {{\n \"lobby\": {{ \"k\": {value} }},\n \"gamemodes\": {{}}\n}}\n"
1596 ));
1597 let parsed = parse_block(&found).unwrap();
1598 let cst::SettingsNode::Group { children, .. } = &parsed.children[0] else {
1599 panic!("lobby group");
1600 };
1601 let cst::SettingsNode::Raw { value: raw, .. } = &children[0] else {
1602 panic!("{value} must parse as a raw expression: {children:?}");
1603 };
1604 assert_eq!(raw, value);
1605 }
1606 }
1607
1608 #[test]
1609 fn parse_block_types_values() {
1610 let found = block(
1611 "settings {\n \"lobby\": { \"ffaSlots\": 6 },\n \"gamemodes\": { \"general\": { \"enableRandomHeroes\": true, \"respawnTime%\": 30, \"heroLimit\": \"off\" } },\n \"heroes\": { \"allTeams\": { \"enabledHeroes\": [\"mei\"] } }\n}\n",
1612 );
1613 let parsed = parse_block(&found).unwrap();
1614 let lobby = match &parsed.children[0] {
1615 cst::SettingsNode::Group { name, children, .. } => {
1616 assert_eq!(name, "lobby");
1617 children
1618 }
1619 other => panic!("{other:?}"),
1620 };
1621 assert!(matches!(
1622 lobby[0],
1623 cst::SettingsNode::Number { value: 6.0, .. }
1624 ));
1625 }
1626
1627 #[test]
1628 fn spans_are_computed_from_block_base() {
1629 let text = "settings {\n \"lobby\": {\n \"ffaSlots\": 6\n },\n \"gamemodes\": {}\n}\n";
1630 let found = block(text);
1631 let parsed = parse_block(&found).unwrap();
1632 let cst::SettingsNode::Group { children, .. } = &parsed.children[0] else {
1633 panic!("lobby");
1634 };
1635 let cst::SettingsNode::Number { span, .. } = &children[0] else {
1636 panic!("ffaSlots");
1637 };
1638 assert_eq!(span.start.line, 3);
1639 assert_eq!(span.start.col, 9);
1640 }
1641
1642 #[test]
1643 fn keyword_span_carries_the_file_id() {
1644 let found = block("settings {\n \"gamemodes\": {}\n}\n");
1645 assert_eq!(found.keyword_span.file, 0);
1646 assert_eq!(found.keyword_span.start.col, 1);
1647 assert_eq!(found.keyword_span.end.col, 9);
1648 }
1649}