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