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 children = settings
322 .children
323 .into_iter()
324 .map(|node| resolve_hir_node(node, cst_program, &constants, &mut expander))
325 .collect::<OpyResult<Vec<_>>>()?;
326 program.settings = Some(hir::Settings {
327 span: settings.span,
328 children,
329 });
330 Ok(())
331}
332
333fn resolve_hir_node(
334 node: hir::SettingsNode,
335 cst_program: &cst::Program,
336 constants: &HashMap<String, &hir::Expr>,
337 expander: &mut crate::compiler::MacroExpander<'_>,
338) -> OpyResult<hir::SettingsNode> {
339 match node {
340 hir::SettingsNode::Group {
341 name,
342 children,
343 span,
344 } => Ok(hir::SettingsNode::Group {
345 name,
346 children: children
347 .into_iter()
348 .map(|child| resolve_hir_node(child, cst_program, constants, expander))
349 .collect::<OpyResult<Vec<_>>>()?,
350 span,
351 }),
352 hir::SettingsNode::Raw { name, value, span } => {
353 let Some(span) = span else {
354 return Err(OpyError::new(
355 "settings-expression",
356 "settings value has no source span",
357 ));
358 };
359 let diag_span = hir_span_to_diag_span(span);
360 let expression = crate::lower::lower_settings_expression(
361 cst_program,
362 &value,
363 span.file,
364 Position::new(span.start.line, span.start.col),
365 )
366 .map_err(|error| settings_expression_error(diag_span, error.message))?;
367 let expression = expander
368 .expand_expr(&expression, &HashMap::new(), None)
369 .map_err(|error| {
370 let error_span = error
371 .diagnostic
372 .span
373 .map(hir_span_to_diag_span)
374 .unwrap_or(diag_span);
375 OpyError::at(error.diagnostic.code, error.diagnostic.message, error_span)
376 })?;
377 let mut stack = Vec::new();
378 let value =
379 crate::compile_time::evaluate(&expression, constants, &HashMap::new(), &mut stack)
380 .ok_or_else(|| {
381 settings_expression_error(
382 diag_span,
383 "expression is not a compile-time value".to_string(),
384 )
385 })?;
386 node_from_value(name, value, diag_span)
387 }
388 node => Ok(node),
389 }
390}
391
392fn hir_span_to_diag_span(span: hir::Span) -> Span {
393 Span::new(
394 span.file,
395 Position::new(span.start.line, span.start.col),
396 Position::new(span.end.line, span.end.col),
397 )
398}
399
400fn settings_expression_error(span: Span, message: String) -> OpyError {
401 OpyError::at(
402 "settings-expression",
403 format!("settings value is not a compile-time value: {message}"),
404 span,
405 )
406}
407
408fn node_from_value(
409 name: String,
410 value: crate::compile_time::Value,
411 span: Span,
412) -> OpyResult<hir::SettingsNode> {
413 let node = match value {
414 crate::compile_time::Value::Number(value) if value.is_finite() => {
415 hir::SettingsNode::Number {
416 name,
417 value,
418 span: Some(span.into()),
419 }
420 }
421 crate::compile_time::Value::Number(_) => {
422 return Err(settings_expression_error(
423 span,
424 "compile-time number is not finite".to_string(),
425 ));
426 }
427 crate::compile_time::Value::String(value) => hir::SettingsNode::String {
428 name,
429 value,
430 span: Some(span.into()),
431 },
432 crate::compile_time::Value::Bool(value) => hir::SettingsNode::Bool {
433 name,
434 value,
435 span: Some(span.into()),
436 },
437 crate::compile_time::Value::Array(values) => hir::SettingsNode::List {
438 name,
439 elements: values
440 .into_iter()
441 .map(|value| {
442 Ok(hir::SettingsListElement {
443 value: display_value(&value)?,
444 span: Some(span.into()),
445 })
446 })
447 .collect::<Result<Vec<_>, String>>()
448 .map_err(|message| settings_expression_error(span, message))?,
449 span: Some(span.into()),
450 },
451 crate::compile_time::Value::Object(entries) => hir::SettingsNode::Group {
452 name,
453 children: entries
454 .into_iter()
455 .map(|(key, value)| {
456 let crate::compile_time::Value::String(name) = key else {
457 return Err(settings_expression_error(
458 span,
459 "compile-time object keys must be strings".to_string(),
460 ));
461 };
462 node_from_value(name, value, span)
463 })
464 .collect::<OpyResult<Vec<_>>>()?,
465 span: Some(span.into()),
466 },
467 };
468 Ok(node)
469}
470
471fn display_value(value: &crate::compile_time::Value) -> Result<String, String> {
472 match value {
473 crate::compile_time::Value::Number(value) if value.is_finite() => Ok(value.to_string()),
474 crate::compile_time::Value::String(value) => Ok(value.clone()),
475 crate::compile_time::Value::Bool(value) => Ok(value.to_string()),
476 _ => Err("settings list can only contain primitive values".to_string()),
477 }
478}
479struct Cursor<'a> {
480 text: &'a str,
481 pos: usize,
482 char_pos: usize,
483 line: u32,
484 col: u32,
485 file: u32,
486}
487
488impl Cursor<'_> {
489 fn new(text: &str, file: u32, origin: Position) -> Cursor<'_> {
490 Cursor {
491 text,
492 pos: 0,
493 char_pos: 0,
494 line: origin.line,
495 col: origin.col,
496 file,
497 }
498 }
499
500 fn peek(&self) -> Option<char> {
501 self.peek_at(0)
502 }
503
504 fn peek_at(&self, ahead: usize) -> Option<char> {
505 self.text[self.pos..].chars().nth(ahead)
506 }
507
508 fn here(&self) -> Position {
509 Position::new(self.line, self.col)
510 }
511
512 fn here_after(&self, n: usize) -> Position {
513 let mut line = self.line;
514 let mut col = self.col;
515 for ch in self.text[self.pos..].chars().take(n) {
516 if ch == '\n' {
517 line += 1;
518 col = 1;
519 } else {
520 col += 1;
521 }
522 }
523 Position::new(line, col)
524 }
525
526 fn advance(&mut self) -> Option<char> {
527 let ch = self.peek()?;
528 if ch == '\n' {
529 self.line += 1;
530 self.col = 1;
531 } else {
532 self.col += 1;
533 }
534 self.pos += ch.len_utf8();
535 self.char_pos += 1;
536 Some(ch)
537 }
538
539 fn advance_by(&mut self, n: usize) {
540 for _ in 0..n {
541 if self.advance().is_none() {
542 return;
543 }
544 }
545 }
546
547 fn advance_quoted(&mut self, quote: &mut Option<char>, escaped: &mut bool, ch: char) -> bool {
548 let Some(quote_char) = *quote else {
549 return false;
550 };
551 if *escaped {
552 *escaped = false;
553 } else if ch == '\\' {
554 *escaped = true;
555 } else if ch == quote_char {
556 *quote = None;
557 }
558 self.advance_by(1);
559 true
560 }
561
562 fn skip_to_eol(&mut self) {
563 while self.peek().is_some_and(|ch| ch != '\n') {
564 self.advance_by(1);
565 }
566 }
567
568 fn skip_horizontal_whitespace(&mut self) {
569 while matches!(self.peek(), Some(' ' | '\t' | '\r')) {
570 self.advance_by(1);
571 }
572 }
573
574 fn read_word(&mut self) -> String {
575 let mut word = String::new();
576 while let Some(ch) = self.peek() {
577 if !is_ident_continue(ch) {
578 break;
579 }
580 word.push(ch);
581 self.advance_by(1);
582 }
583 word
584 }
585}
586
587enum ScalarValue {
589 String(String),
590 Bool(bool),
591 Number(f64),
592}
593
594impl Cursor<'_> {
595 fn skip_whitespace(&mut self) {
596 loop {
597 while let Some(ch) = self.peek() {
598 if ch.is_whitespace() {
599 self.advance();
600 } else {
601 break;
602 }
603 }
604 if self.peek() == Some('#') {
605 while self.peek().is_some_and(|ch| ch != '\n') {
606 self.advance();
607 }
608 continue;
609 }
610 if self.text[self.pos..].starts_with("//") {
611 self.advance();
612 self.advance();
613 while self.peek().is_some_and(|ch| ch != '\n') {
614 self.advance();
615 }
616 continue;
617 }
618 if self.text[self.pos..].starts_with("/*") {
619 self.advance();
620 self.advance();
621 while self.peek().is_some() && !self.text[self.pos..].starts_with("*/") {
622 self.advance();
623 }
624 if self.text[self.pos..].starts_with("*/") {
625 self.advance();
626 self.advance();
627 }
628 continue;
629 }
630 break;
631 }
632 }
633
634 fn error(&self, code: &str, message: String) -> OpyError {
635 OpyError::at(
636 code,
637 message,
638 Span::new(self.file, self.here(), self.here()),
639 )
640 }
641
642 fn error_at(&self, code: &str, message: String, span: Span) -> OpyError {
643 OpyError::at(code, message, span)
644 }
645
646 fn parse_object(&mut self) -> OpyResult<(Vec<cst::SettingsNode>, Span)> {
647 let open = self.here();
648 if self.advance() != Some('{') {
649 return Err(self.error(
650 "settings-invalid",
651 "settings block must be a JSONC object".to_string(),
652 ));
653 }
654 let members = self.parse_members(false)?;
655 let span = Span::new(self.file, open, self.here());
656 Ok((members, span))
657 }
658
659 fn parse_members(&mut self, root: bool) -> OpyResult<Vec<cst::SettingsNode>> {
663 let mut nodes = Vec::new();
664 let mut names = Vec::new();
665 self.skip_whitespace();
666 if (!root && self.peek() == Some('}')) || (root && self.pos >= self.text.len()) {
667 if !root {
668 self.advance();
669 }
670 return Ok(nodes);
671 }
672 loop {
673 self.skip_whitespace();
674 let key_start = self.here();
675 let key = match self.parse_string_value() {
676 Some(value) => value,
677 None => {
678 return Err(self.error(
679 "settings-invalid",
680 "settings keys must be quoted strings".to_string(),
681 ));
682 }
683 };
684 let key_span = Span::new(self.file, key_start, self.here());
685 if names.contains(&key) {
686 return Err(self.error_at(
687 "settings-invalid",
688 format!("duplicate settings key '{key}'"),
689 key_span,
690 ));
691 }
692 names.push(key.clone());
693 self.skip_whitespace();
694 if self.advance() != Some(':') {
695 return Err(self.error_at(
696 "settings-invalid",
697 format!("expected ':' after settings key '{key}'"),
698 key_span,
699 ));
700 }
701 self.skip_whitespace();
702 let (node, value_end) = self.parse_value()?;
703 let node = build_node(key, node, value_end, key_start, self.file);
704 nodes.push(node);
705 self.skip_whitespace();
706 match self.peek() {
707 Some(',') => {
708 self.advance();
709 self.skip_whitespace();
710 if (!root && self.peek() == Some('}')) || (root && self.pos >= self.text.len())
711 {
712 if !root {
713 self.advance();
714 }
715 return Ok(nodes);
716 }
717 }
718 Some('}') if !root => {
719 self.advance();
720 return Ok(nodes);
721 }
722 None if root => return Ok(nodes),
723 _ => {
724 return Err(self.error(
725 "settings-invalid",
726 "expected ',' or '}' in settings object".to_string(),
727 ));
728 }
729 }
730 }
731 }
732
733 fn parse_value(&mut self) -> OpyResult<(cst::SettingsNode, Position)> {
736 let start = self.here();
737 let ch = self.peek();
738 let saved = (self.pos, self.char_pos, self.line, self.col);
739 let scalar = match ch {
740 Some('"') | Some('\'') => {
741 let value = self.parse_string_expression().ok_or_else(|| {
742 self.error(
743 "settings-invalid",
744 "unterminated string in settings value".to_string(),
745 )
746 })?;
747 Some(ScalarValue::String(value))
748 }
749 Some('t') => {
750 self.expect_word("true")?;
751 Some(ScalarValue::Bool(true))
752 }
753 Some('f') => {
754 self.expect_word("false")?;
755 Some(ScalarValue::Bool(false))
756 }
757 Some(c) if c.is_ascii_digit() || c == '-' => {
758 Some(ScalarValue::Number(self.parse_number()?))
759 }
760 _ => None,
761 };
762 let node = if let Some(scalar) = scalar {
763 self.skip_inline_whitespace();
764 if self.is_expression_continuation() {
765 self.pos = saved.0;
766 self.char_pos = saved.1;
767 self.line = saved.2;
768 self.col = saved.3;
769 cst::SettingsNode::Raw {
770 name: String::new(),
771 value: self.parse_expression_value(),
772 span: Span::new(self.file, start, self.here()),
773 }
774 } else {
775 let span = Span::new(self.file, start, self.here());
776 match scalar {
777 ScalarValue::String(value) => cst::SettingsNode::String {
778 name: String::new(),
779 value,
780 span,
781 },
782 ScalarValue::Bool(value) => cst::SettingsNode::Bool {
783 name: String::new(),
784 value,
785 span,
786 },
787 ScalarValue::Number(value) => cst::SettingsNode::Number {
788 name: String::new(),
789 value,
790 span,
791 },
792 }
793 }
794 } else {
795 match ch {
796 Some('[') => {
797 let elements = self.parse_list()?;
798 cst::SettingsNode::List {
799 name: String::new(),
800 elements,
801 span: Span::new(self.file, start, self.here()),
802 }
803 }
804 Some('{') => {
805 let (children, _) = self.parse_object()?;
806 cst::SettingsNode::Group {
807 name: String::new(),
808 children,
809 span: Span::new(self.file, start, self.here()),
810 }
811 }
812 _ => {
813 let value = self.parse_expression_value();
814 if value.trim().is_empty() {
815 return Err(self.error(
816 "settings-invalid",
817 "expected a value in settings block".to_string(),
818 ));
819 }
820 cst::SettingsNode::Raw {
821 name: String::new(),
822 value,
823 span: Span::new(self.file, start, self.here()),
824 }
825 }
826 }
827 };
828 let end = self.here();
829 Ok((node, end))
830 }
831
832 fn parse_expression_value(&mut self) -> String {
836 let mut value = String::new();
837 let mut depth = 0usize;
838 let mut quote = None;
839 while let Some(ch) = self.peek() {
840 if let Some(expected) = quote {
841 value.push(self.advance().expect("peeked character exists"));
842 if ch == expected {
843 quote = None;
844 }
845 continue;
846 }
847 match ch {
848 '"' | '\'' => {
849 quote = Some(ch);
850 value.push(self.advance().expect("peeked character exists"));
851 }
852 '(' | '[' | '{' => {
853 depth += 1;
854 value.push(self.advance().expect("peeked character exists"));
855 }
856 ']' | ',' | '}' if depth == 0 => break,
857 ')' | ']' => {
858 depth = depth.saturating_sub(1);
859 value.push(self.advance().expect("peeked character exists"));
860 }
861 _ => value.push(self.advance().expect("peeked character exists")),
862 }
863 }
864 value.trim().to_string()
865 }
866
867 fn expect_word(&mut self, word: &str) -> OpyResult<()> {
868 let start = self.here();
869 for expected in word.chars() {
870 if self.advance() != Some(expected) {
871 return Err(self.error_at(
872 "settings-invalid",
873 format!("expected '{word}' in settings block"),
874 Span::new(self.file, start, self.here()),
875 ));
876 }
877 }
878 Ok(())
879 }
880
881 fn parse_number(&mut self) -> OpyResult<f64> {
882 let start = self.here();
883 let mut text = String::new();
884 if self.peek() == Some('-') {
885 text.push(self.advance().unwrap());
886 }
887 while let Some(c) = self.peek() {
888 if c.is_ascii_digit() {
889 text.push(self.advance().unwrap());
890 } else {
891 break;
892 }
893 }
894 if self.peek() == Some('.') {
895 text.push(self.advance().unwrap());
896 while let Some(c) = self.peek() {
897 if c.is_ascii_digit() {
898 text.push(self.advance().unwrap());
899 } else {
900 break;
901 }
902 }
903 }
904 text.parse::<f64>().map_err(|_| {
905 self.error_at(
906 "settings-invalid",
907 format!("invalid number '{text}' in settings block"),
908 Span::new(self.file, start, self.here()),
909 )
910 })
911 }
912
913 fn parse_list(&mut self) -> OpyResult<Vec<cst::SettingsListElement>> {
914 self.advance(); let mut elements = Vec::new();
916 self.skip_whitespace();
917 if self.peek() == Some(']') {
918 self.advance();
919 return Ok(elements);
920 }
921 loop {
922 self.skip_whitespace();
923 let start = self.here();
924 let value = match self.peek() {
925 Some('"') | Some('\'') => self.parse_string_expression().ok_or_else(|| {
926 self.error(
927 "settings-invalid",
928 "unterminated string in settings list".to_string(),
929 )
930 })?,
931 Some(']') | Some(',') | None => {
932 return Err(self.error(
933 "settings-invalid",
934 "settings list elements must not be empty".to_string(),
935 ));
936 }
937 _ => self.parse_expression_value(),
938 };
939 let span = Span::new(self.file, start, self.here());
940 elements.push(cst::SettingsListElement { value, span });
941 self.skip_whitespace();
942 match self.peek() {
943 Some(',') => {
944 self.advance();
945 self.skip_whitespace();
946 if self.peek() == Some(']') {
947 self.advance();
948 return Ok(elements);
949 }
950 }
951 Some(']') => {
952 self.advance();
953 return Ok(elements);
954 }
955 _ => {
956 return Err(self.error(
957 "settings-invalid",
958 "expected ',' or ']' in settings list".to_string(),
959 ));
960 }
961 }
962 }
963 }
964
965 fn parse_string_value(&mut self) -> Option<String> {
968 let quote = self.peek()?;
969 if quote != '"' && quote != '\'' {
970 return None;
971 }
972 self.advance();
973 let mut value = String::new();
974 loop {
975 let ch = self.advance()?;
976 if ch == '\n' {
977 return None;
978 }
979 if ch == quote {
980 return Some(value);
981 }
982 if ch == '\\' {
983 let escaped = self.advance()?;
984 value.push(decode_string_escape(escaped));
985 } else {
986 value.push(ch);
987 }
988 }
989 }
990
991 fn parse_string_expression(&mut self) -> Option<String> {
995 let mut value = self.parse_string_value()?;
996 loop {
997 let saved = (self.pos, self.char_pos, self.line, self.col);
998 self.skip_whitespace();
999 if self.peek() != Some('"') && self.peek() != Some('\'') {
1000 self.pos = saved.0;
1001 self.char_pos = saved.1;
1002 self.line = saved.2;
1003 self.col = saved.3;
1004 break;
1005 }
1006 let next = self.parse_string_value()?;
1007 value.push_str(&next);
1008 }
1009 Some(value)
1010 }
1011
1012 fn skip_inline_whitespace(&mut self) {
1013 while matches!(self.peek(), Some(' ' | '\t' | '\r')) {
1014 self.advance();
1015 }
1016 }
1017
1018 fn is_expression_continuation(&self) -> bool {
1019 if matches!(
1020 self.peek(),
1021 Some('+' | '-' | '*' | '/' | '%' | '<' | '>' | '=')
1022 ) {
1023 return true;
1024 }
1025 ["and", "or", "if"].iter().any(|word| {
1026 self.text[self.pos..]
1027 .strip_prefix(word)
1028 .is_some_and(|rest| {
1029 rest.chars().next().is_none_or(|character| {
1030 !character.is_ascii_alphanumeric() && character != '_'
1031 })
1032 })
1033 })
1034 }
1035}
1036
1037fn build_node(
1039 key: String,
1040 node: cst::SettingsNode,
1041 value_end: Position,
1042 key_start: Position,
1043 file: u32,
1044) -> cst::SettingsNode {
1045 let span = Span::new(file, key_start, value_end);
1046 match node {
1047 cst::SettingsNode::Group { children, .. } => cst::SettingsNode::Group {
1048 name: key,
1049 children,
1050 span,
1051 },
1052 cst::SettingsNode::Number { value, .. } => cst::SettingsNode::Number {
1053 name: key,
1054 value,
1055 span,
1056 },
1057 cst::SettingsNode::Bool { value, .. } => cst::SettingsNode::Bool {
1058 name: key,
1059 value,
1060 span,
1061 },
1062 cst::SettingsNode::String { value, .. } => cst::SettingsNode::String {
1063 name: key,
1064 value,
1065 span,
1066 },
1067 cst::SettingsNode::Raw { value, .. } => cst::SettingsNode::Raw {
1068 name: key,
1069 value,
1070 span,
1071 },
1072 cst::SettingsNode::List { elements, .. } => cst::SettingsNode::List {
1073 name: key,
1074 elements,
1075 span,
1076 },
1077 }
1078}
1079
1080#[cfg(test)]
1081mod tests {
1082 use super::*;
1083
1084 fn block(text: &str) -> SettingsBlock {
1085 let mut blocks = find_blocks(text, 0).unwrap();
1086 assert_eq!(blocks.len(), 1, "one block expected in: {text}");
1087 blocks.pop().unwrap()
1088 }
1089
1090 #[test]
1091 fn finds_block_after_comments_and_blanks() {
1092 let text = "/* header */\n# comment\n\nsettings {\n \"gamemodes\": {}\n}\nrule \"r\":\n";
1093 let found = block(text);
1094 assert!(found.text.contains("gamemodes"));
1095 assert_eq!(found.keyword_span.start.line, 4);
1096 assert_eq!(found.span.end.line, 6);
1097 }
1098
1099 #[test]
1100 fn no_blocks_for_plain_program() {
1101 assert!(
1102 find_blocks("rule \"r\":\n pass\n", 0)
1103 .unwrap()
1104 .is_empty()
1105 );
1106 }
1107
1108 #[test]
1109 fn ignores_settings_words_in_strings_and_indented_code() {
1110 let text = "rule \"settings\":\n x = \"settings\"\n";
1111 assert!(find_blocks(text, 0).unwrap().is_empty());
1112 }
1113
1114 #[test]
1115 fn settings_not_first_construct_is_placement_error() {
1116 let error = find_blocks("rule \"r\":\n pass\nsettings {\n}\n", 0).unwrap_err();
1117 assert_eq!(error.code, "settings-placement");
1118 assert_eq!(error.span.unwrap().start.line, 3);
1119 }
1120
1121 #[test]
1122 fn second_block_is_placement_error() {
1123 let error =
1124 find_blocks("settings {\n \"gamemodes\": {}\n}\nsettings {\n}\n", 0).unwrap_err();
1125 assert_eq!(error.code, "settings-placement");
1126 assert_eq!(error.span.unwrap().start.line, 4);
1127 }
1128
1129 #[test]
1130 fn settings_file_form_is_extracted() {
1131 let found = block("settings \"file.opy.json\"\n");
1132 assert_eq!(found.external_path.as_deref(), Some("file.opy.json"));
1133 }
1134
1135 #[test]
1136 fn unterminated_block_is_invalid() {
1137 let error = find_blocks("settings {\n \"gamemodes\": {\n", 0).unwrap_err();
1138 assert_eq!(error.code, "settings-invalid");
1139 }
1140
1141 #[test]
1142 fn braces_inside_strings_do_not_unbalance() {
1143 let found =
1144 block("settings {\n \"description\": \"a { b }\",\n \"gamemodes\": {}\n}\n");
1145 assert!(found.text.contains("a { b }"));
1146 }
1147
1148 #[test]
1149 fn sanitize_preserves_post_block_positions() {
1150 let text = "settings {\n \"gamemodes\": {}\n}\nrule \"r\":\n pass\n";
1151 let found = block(text);
1152 let sanitized = sanitize_for_lex(text, &found);
1153 let lines: Vec<&str> = sanitized.lines().collect();
1154 assert_eq!(lines.len(), 5);
1155 assert_eq!(lines[3], "rule \"r\":");
1156 assert_eq!(lines[4], " pass");
1157 assert_eq!(sanitized.find("rule"), text.find("rule"));
1159 }
1160
1161 #[test]
1162 fn unicode_preserves_offsets_and_sanitization() {
1163 let text = "# 前置🙂\nsettings {\n \"main\": { \"description\": \"内🙂\" },\n \"gamemodes\": {}\n}\nrule \"后🙂\":\n pass\n";
1164 let found = block(text);
1165 let keyword_byte_offset = text.find("settings").unwrap();
1166 let closing_byte_offset = text.find("}\nrule").unwrap();
1167 assert_eq!(found.start, text[..keyword_byte_offset].chars().count());
1168 assert_eq!(
1169 found.end,
1170 text[..closing_byte_offset + '}'.len_utf8()].chars().count()
1171 );
1172 assert_eq!(found.keyword_span.start, Position::new(2, 1));
1173 assert_eq!(found.text_start, Position::new(2, 11));
1174 assert!(found.text.contains("内🙂"));
1175
1176 let sanitized = sanitize_for_lex(text, &found);
1177 assert_eq!(sanitized.chars().count(), text.chars().count());
1178 let rule_char_offset = |source: &str| {
1179 let byte_offset = source.find("rule").unwrap();
1180 source[..byte_offset].chars().count()
1181 };
1182 assert_eq!(rule_char_offset(&sanitized), rule_char_offset(text));
1183 assert!(sanitized.contains("rule \"后🙂\":"));
1184 }
1185
1186 #[test]
1187 fn non_object_after_settings_is_invalid() {
1188 let error = find_blocks("settings [1, 2]\n", 0).unwrap_err();
1190 assert_eq!(error.code, "settings-invalid");
1191 }
1192
1193 #[test]
1194 fn parse_block_rejects_missing_gamemodes() {
1195 let found = block("settings {\n \"main\": { \"description\": \"x\" }\n}\n");
1196 let error = parse_block(&found).unwrap_err();
1197 assert_eq!(error.code, "settings-invalid");
1198 assert!(error.message.contains("gamemodes"));
1199 }
1200
1201 #[test]
1202 fn parse_block_rejects_duplicate_keys() {
1203 let found = block("settings {\n \"gamemodes\": {},\n \"gamemodes\": {}\n}\n");
1204 let error = parse_block(&found).unwrap_err();
1205 assert_eq!(error.code, "settings-invalid");
1206 assert!(error.message.contains("duplicate"));
1207 }
1208
1209 #[test]
1210 fn parse_block_accepts_trailing_commas() {
1211 let found = block(
1212 "settings {\n \"gamemodes\": {\n \"general\": {\n \"heroLimit\": \"off\",\n },\n },\n}\n",
1213 );
1214 let parsed = parse_block(&found).unwrap();
1215 assert_eq!(parsed.children.len(), 1);
1216 }
1217
1218 #[test]
1219 fn parse_block_handles_escapes_and_quotes() {
1220 let found = block(
1221 "settings {\n \"main\": { \"description\": \"line\\n\\t\\\"quoted\\\"\" },\n \"gamemodes\": {}\n}\n",
1222 );
1223 let parsed = parse_block(&found).unwrap();
1224 let cst::SettingsNode::Group { children, .. } = &parsed.children[0] else {
1225 panic!("main group");
1226 };
1227 let cst::SettingsNode::String { value, .. } = &children[0] else {
1228 panic!("description");
1229 };
1230 assert_eq!(value, "line\n\t\"quoted\"");
1231 }
1232
1233 #[test]
1234 fn parse_block_accepts_comments_and_expression_values() {
1235 let found = block(
1236 "settings {\n # keep the source expression intact\n \"lobby\": {\n \"modeName\": GAMEMODE_NAME\" \"GAMEMODE_VERSION,\n },\n \"gamemodes\": {}\n}\n",
1237 );
1238 let parsed = parse_block(&found).unwrap();
1239 let cst::SettingsNode::Group { children, .. } = &parsed.children[0] else {
1240 panic!("lobby group");
1241 };
1242 let cst::SettingsNode::Raw { value, .. } = &children[0] else {
1243 panic!("expression-valued setting");
1244 };
1245 assert_eq!(value, "GAMEMODE_NAME\" \"GAMEMODE_VERSION");
1246 }
1247
1248 #[test]
1249 fn parse_block_types_values() {
1250 let found = block(
1251 "settings {\n \"lobby\": { \"ffaSlots\": 6 },\n \"gamemodes\": { \"general\": { \"enableRandomHeroes\": true, \"respawnTime%\": 30, \"heroLimit\": \"off\" } },\n \"heroes\": { \"allTeams\": { \"enabledHeroes\": [\"mei\"] } }\n}\n",
1252 );
1253 let parsed = parse_block(&found).unwrap();
1254 let lobby = match &parsed.children[0] {
1255 cst::SettingsNode::Group { name, children, .. } => {
1256 assert_eq!(name, "lobby");
1257 children
1258 }
1259 other => panic!("{other:?}"),
1260 };
1261 assert!(matches!(
1262 lobby[0],
1263 cst::SettingsNode::Number { value: 6.0, .. }
1264 ));
1265 }
1266
1267 #[test]
1268 fn spans_are_computed_from_block_base() {
1269 let text = "settings {\n \"lobby\": {\n \"ffaSlots\": 6\n },\n \"gamemodes\": {}\n}\n";
1270 let found = block(text);
1271 let parsed = parse_block(&found).unwrap();
1272 let cst::SettingsNode::Group { children, .. } = &parsed.children[0] else {
1273 panic!("lobby");
1274 };
1275 let cst::SettingsNode::Number { span, .. } = &children[0] else {
1276 panic!("ffaSlots");
1277 };
1278 assert_eq!(span.start.line, 3);
1279 assert_eq!(span.start.col, 9);
1280 }
1281
1282 #[test]
1283 fn keyword_span_carries_the_file_id() {
1284 let found = block("settings {\n \"gamemodes\": {}\n}\n");
1285 assert_eq!(found.keyword_span.file, 0);
1286 assert_eq!(found.keyword_span.start.col, 1);
1287 assert_eq!(found.keyword_span.end.col, 9);
1288 }
1289}