1use crate::cst;
18use crate::diag::{OpyError, OpyResult, Position, Span};
19
20#[derive(Debug, Clone)]
22pub struct SettingsBlock {
23 pub text: String,
25 pub span: Span,
27 pub keyword_span: Span,
29 pub start: usize,
31 pub end: usize,
33 pub text_start: Position,
35}
36
37pub fn find_blocks(text: &str, file_id: u32) -> OpyResult<Vec<SettingsBlock>> {
46 let chars: Vec<char> = text.chars().collect();
47 let mut scanner = Scanner {
48 chars: &chars,
49 pos: 0,
50 line: 1,
51 col: 1,
52 };
53 let mut blocks = Vec::new();
54 let mut in_block_comment = false;
55 let mut string_quote = None;
56 let mut escaped = false;
57 let mut seen_first_construct = false;
58 while scanner.pos < scanner.chars.len() {
59 let ch = scanner.chars[scanner.pos];
60 if let Some(quote) = string_quote {
61 if escaped {
62 escaped = false;
63 } else if ch == '\\' {
64 escaped = true;
65 } else if ch == quote {
66 string_quote = None;
67 }
68 scanner.advance(1);
69 continue;
70 }
71 if in_block_comment {
72 if ch == '*' && scanner.peek(1) == Some('/') {
73 in_block_comment = false;
74 scanner.advance(2);
75 } else {
76 scanner.advance(1);
77 }
78 continue;
79 }
80 if matches!(ch, '"' | '\'') {
81 string_quote = Some(ch);
82 scanner.advance(1);
83 continue;
84 }
85 if ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n' {
86 scanner.advance(1);
87 continue;
88 }
89 if ch == '#' {
90 scanner.skip_to_eol();
91 continue;
92 }
93 if ch == '/' && scanner.peek(1) == Some('*') {
94 scanner.advance(2);
95 in_block_comment = true;
96 continue;
97 }
98 if is_ident_start(ch) {
100 let keyword_start = scanner.here();
101 let keyword_offset = scanner.pos;
102 let word = scanner.read_word();
103 if word == "settings" && keyword_start.col == 1 {
104 let keyword_span = Span::new(file_id, keyword_start, scanner.here());
105 if seen_first_construct || !blocks.is_empty() {
106 return Err(OpyError::at(
107 "settings-placement",
108 "settings block must be the first construct in the file".to_string(),
109 keyword_span,
110 ));
111 }
112 let block = match_block(&mut scanner, keyword_start, keyword_offset, keyword_span)?;
113 blocks.push(block);
114 seen_first_construct = true;
115 continue;
116 }
117 seen_first_construct = true;
118 continue;
119 }
120 seen_first_construct = true;
121 scanner.advance(1);
122 }
123 Ok(blocks)
124}
125
126fn match_block(
129 scanner: &mut Scanner<'_>,
130 keyword_start: Position,
131 keyword_offset: usize,
132 keyword_span: Span,
133) -> OpyResult<SettingsBlock> {
134 scanner.skip_whitespace();
135 if scanner.chars.get(scanner.pos) != Some(&'{') {
136 return Err(OpyError::at(
137 "settings-invalid",
138 "settings block must be a `settings { ... }` block (the `settings \"file\"` form is not supported)"
139 .to_string(),
140 keyword_span,
141 ));
142 }
143 let mut depth = 0usize;
144 let mut string_quote: Option<char> = None;
145 let mut escaped = false;
146 let mut text_start_offset = None;
147 let mut text_start = None;
148 loop {
149 let Some(ch) = scanner.chars.get(scanner.pos).copied() else {
150 return Err(OpyError::at(
151 "settings-invalid",
152 "unterminated settings block (missing closing brace)".to_string(),
153 keyword_span,
154 ));
155 };
156 if let Some(quote) = string_quote {
157 if escaped {
158 escaped = false;
159 } else if ch == '\\' {
160 escaped = true;
161 } else if ch == quote {
162 string_quote = None;
163 }
164 scanner.advance(1);
165 continue;
166 }
167 match ch {
168 '"' | '\'' => string_quote = Some(ch),
169 '{' => {
170 if depth == 0 {
171 text_start_offset = Some(scanner.pos + 1);
172 text_start = Some(scanner.here_after(1));
173 }
174 depth += 1;
175 }
176 '}' => {
177 depth -= 1;
178 if depth == 0 {
179 let text = scanner
180 .chars
181 .get(text_start_offset.expect("text start offset set on '{'")..scanner.pos)
182 .map(|slice| slice.iter().collect::<String>())
183 .unwrap_or_default();
184 return Ok(SettingsBlock {
185 text,
186 span: Span::new(keyword_span.file, keyword_start, scanner.here_after(1)),
187 keyword_span,
188 start: keyword_offset,
189 end: scanner.pos + 1,
190 text_start: text_start.expect("text start set on '{'"),
191 });
192 }
193 }
194 _ => {}
195 }
196 scanner.advance(1);
197 }
198}
199
200pub fn sanitize_for_lex(text: &str, block: &SettingsBlock) -> String {
203 let mut out = String::with_capacity(text.len());
204 for (index, ch) in text.chars().enumerate() {
205 if index >= block.start && index < block.end {
206 out.push(if ch == '\n' { '\n' } else { ' ' });
207 } else {
208 out.push(ch);
209 }
210 }
211 out
212}
213
214pub fn parse_block(block: &SettingsBlock) -> OpyResult<cst::Settings> {
221 let mut parser = Jsonc {
222 text: &block.text,
223 pos: 0,
224 line: block.text_start.line,
225 col: block.text_start.col,
226 file: block.span.file,
227 };
228 parser.skip_whitespace();
229 let children = parser.parse_members(true)?;
232 parser.skip_whitespace();
233 if parser.pos < parser.text.len() {
234 return Err(parser.error(
235 "settings-invalid",
236 "unexpected content after the settings object".to_string(),
237 ));
238 }
239 if !children
240 .iter()
241 .any(|node| matches!(node, cst::SettingsNode::Group { name, .. } if name == "gamemodes"))
242 {
243 return Err(OpyError::at(
244 "settings-invalid",
245 "settings block must contain a gamemodes group".to_string(),
246 block.span,
247 ));
248 }
249 Ok(cst::Settings {
250 span: block.span,
251 children,
252 })
253}
254
255struct Scanner<'a> {
257 chars: &'a [char],
258 pos: usize,
259 line: u32,
260 col: u32,
261}
262
263impl Scanner<'_> {
264 fn peek(&self, ahead: usize) -> Option<char> {
265 self.chars.get(self.pos + ahead).copied()
266 }
267
268 fn here(&self) -> Position {
269 Position::new(self.line, self.col)
270 }
271
272 fn here_after(&self, n: usize) -> Position {
273 let mut line = self.line;
274 let mut col = self.col;
275 for i in 0..n {
276 if self.chars.get(self.pos + i) == Some(&'\n') {
277 line += 1;
278 col = 1;
279 } else {
280 col += 1;
281 }
282 }
283 Position::new(line, col)
284 }
285
286 fn advance(&mut self, n: usize) {
287 for _ in 0..n {
288 if self.pos >= self.chars.len() {
289 return;
290 }
291 if self.chars[self.pos] == '\n' {
292 self.line += 1;
293 self.col = 1;
294 } else {
295 self.col += 1;
296 }
297 self.pos += 1;
298 }
299 }
300
301 fn skip_to_eol(&mut self) {
302 while self.pos < self.chars.len() && self.chars[self.pos] != '\n' {
303 self.advance(1);
304 }
305 }
306
307 fn skip_whitespace(&mut self) {
308 while self.pos < self.chars.len() && matches!(self.chars[self.pos], ' ' | '\t' | '\r') {
309 self.advance(1);
310 }
311 }
312
313 fn read_word(&mut self) -> String {
314 let mut word = String::new();
315 while self.pos < self.chars.len() && is_ident_continue(self.chars[self.pos]) {
316 word.push(self.chars[self.pos]);
317 self.advance(1);
318 }
319 word
320 }
321}
322
323struct Jsonc<'a> {
325 text: &'a str,
326 pos: usize,
327 line: u32,
328 col: u32,
329 file: u32,
330}
331
332impl Jsonc<'_> {
333 fn here(&self) -> Position {
334 Position::new(self.line, self.col)
335 }
336
337 fn peek(&self) -> Option<char> {
338 self.text[self.pos..].chars().next()
339 }
340
341 fn advance(&mut self) -> Option<char> {
342 let ch = self.peek()?;
343 self.pos += ch.len_utf8();
344 if ch == '\n' {
345 self.line += 1;
346 self.col = 1;
347 } else {
348 self.col += 1;
349 }
350 Some(ch)
351 }
352
353 fn skip_whitespace(&mut self) {
354 loop {
355 while let Some(ch) = self.peek() {
356 if ch.is_whitespace() {
357 self.advance();
358 } else {
359 break;
360 }
361 }
362 if self.peek() == Some('#') {
363 while self.peek().is_some_and(|ch| ch != '\n') {
364 self.advance();
365 }
366 continue;
367 }
368 if self.text[self.pos..].starts_with("//") {
369 self.advance();
370 self.advance();
371 while self.peek().is_some_and(|ch| ch != '\n') {
372 self.advance();
373 }
374 continue;
375 }
376 if self.text[self.pos..].starts_with("/*") {
377 self.advance();
378 self.advance();
379 while self.peek().is_some() && !self.text[self.pos..].starts_with("*/") {
380 self.advance();
381 }
382 if self.text[self.pos..].starts_with("*/") {
383 self.advance();
384 self.advance();
385 }
386 continue;
387 }
388 break;
389 }
390 }
391
392 fn error(&self, code: &str, message: String) -> OpyError {
393 OpyError::at(
394 code,
395 message,
396 Span::new(self.file, self.here(), self.here()),
397 )
398 }
399
400 fn error_at(&self, code: &str, message: String, span: Span) -> OpyError {
401 OpyError::at(code, message, span)
402 }
403
404 fn parse_object(&mut self) -> OpyResult<(Vec<cst::SettingsNode>, Span)> {
405 let open = self.here();
406 if self.advance() != Some('{') {
407 return Err(self.error(
408 "settings-invalid",
409 "settings block must be a JSONC object".to_string(),
410 ));
411 }
412 let members = self.parse_members(false)?;
413 let span = Span::new(self.file, open, self.here());
414 Ok((members, span))
415 }
416
417 fn parse_members(&mut self, root: bool) -> OpyResult<Vec<cst::SettingsNode>> {
421 let mut nodes = Vec::new();
422 let mut names = Vec::new();
423 self.skip_whitespace();
424 if (!root && self.peek() == Some('}')) || (root && self.pos >= self.text.len()) {
425 if !root {
426 self.advance();
427 }
428 return Ok(nodes);
429 }
430 loop {
431 self.skip_whitespace();
432 let key_start = self.here();
433 let key = match self.parse_string_value() {
434 Some(value) => value,
435 None => {
436 return Err(self.error(
437 "settings-invalid",
438 "settings keys must be quoted strings".to_string(),
439 ));
440 }
441 };
442 let key_span = Span::new(self.file, key_start, self.here());
443 if names.contains(&key) {
444 return Err(self.error_at(
445 "settings-invalid",
446 format!("duplicate settings key '{key}'"),
447 key_span,
448 ));
449 }
450 names.push(key.clone());
451 self.skip_whitespace();
452 if self.advance() != Some(':') {
453 return Err(self.error_at(
454 "settings-invalid",
455 format!("expected ':' after settings key '{key}'"),
456 key_span,
457 ));
458 }
459 self.skip_whitespace();
460 let (node, value_end) = self.parse_value()?;
461 let node = build_node(key, node, value_end, key_start, self.file);
462 nodes.push(node);
463 self.skip_whitespace();
464 match self.peek() {
465 Some(',') => {
466 self.advance();
467 self.skip_whitespace();
468 if (!root && self.peek() == Some('}')) || (root && self.pos >= self.text.len())
469 {
470 if !root {
471 self.advance();
472 }
473 return Ok(nodes);
474 }
475 }
476 Some('}') if !root => {
477 self.advance();
478 return Ok(nodes);
479 }
480 None if root => return Ok(nodes),
481 _ => {
482 return Err(self.error(
483 "settings-invalid",
484 "expected ',' or '}' in settings object".to_string(),
485 ));
486 }
487 }
488 }
489 }
490
491 fn parse_value(&mut self) -> OpyResult<(cst::SettingsNode, Position)> {
494 let start = self.here();
495 let ch = self.peek();
496 let node = match ch {
497 Some('"') | Some('\'') => {
498 let value = self.parse_string_value().ok_or_else(|| {
499 self.error(
500 "settings-invalid",
501 "unterminated string in settings value".to_string(),
502 )
503 })?;
504 cst::SettingsNode::String {
505 name: String::new(),
506 value,
507 span: Span::new(self.file, start, self.here()),
508 }
509 }
510 Some('t') => {
511 self.expect_word("true")?;
512 cst::SettingsNode::Bool {
513 name: String::new(),
514 value: true,
515 span: Span::new(self.file, start, self.here()),
516 }
517 }
518 Some('f') => {
519 self.expect_word("false")?;
520 cst::SettingsNode::Bool {
521 name: String::new(),
522 value: false,
523 span: Span::new(self.file, start, self.here()),
524 }
525 }
526 Some(c) if c.is_ascii_digit() || c == '-' => {
527 let value = self.parse_number()?;
528 cst::SettingsNode::Number {
529 name: String::new(),
530 value,
531 span: Span::new(self.file, start, self.here()),
532 }
533 }
534 Some('[') => {
535 let elements = self.parse_list()?;
536 cst::SettingsNode::List {
537 name: String::new(),
538 elements,
539 span: Span::new(self.file, start, self.here()),
540 }
541 }
542 Some('{') => {
543 let (children, _) = self.parse_object()?;
544 cst::SettingsNode::Group {
545 name: String::new(),
546 children,
547 span: Span::new(self.file, start, self.here()),
548 }
549 }
550 _ => {
551 let value = self.parse_expression_value();
552 if value.trim().is_empty() {
553 return Err(self.error(
554 "settings-invalid",
555 "expected a value in settings block".to_string(),
556 ));
557 }
558 cst::SettingsNode::Raw {
559 name: String::new(),
560 value,
561 span: Span::new(self.file, start, self.here()),
562 }
563 }
564 };
565 let end = self.here();
566 Ok((node, end))
567 }
568
569 fn parse_expression_value(&mut self) -> String {
573 let mut value = String::new();
574 let mut depth = 0usize;
575 let mut quote = None;
576 while let Some(ch) = self.peek() {
577 if let Some(expected) = quote {
578 value.push(self.advance().expect("peeked character exists"));
579 if ch == expected {
580 quote = None;
581 }
582 continue;
583 }
584 match ch {
585 '"' | '\'' => {
586 quote = Some(ch);
587 value.push(self.advance().expect("peeked character exists"));
588 }
589 '(' | '[' | '{' => {
590 depth += 1;
591 value.push(self.advance().expect("peeked character exists"));
592 }
593 ']' if depth == 0 => break,
594 ')' | ']' => {
595 depth = depth.saturating_sub(1);
596 value.push(self.advance().expect("peeked character exists"));
597 }
598 ',' | '}' if depth == 0 => break,
599 _ => value.push(self.advance().expect("peeked character exists")),
600 }
601 }
602 value.trim().to_string()
603 }
604
605 fn expect_word(&mut self, word: &str) -> OpyResult<()> {
606 let start = self.here();
607 for expected in word.chars() {
608 if self.advance() != Some(expected) {
609 return Err(self.error_at(
610 "settings-invalid",
611 format!("expected '{word}' in settings block"),
612 Span::new(self.file, start, self.here()),
613 ));
614 }
615 }
616 Ok(())
617 }
618
619 fn parse_number(&mut self) -> OpyResult<f64> {
620 let start = self.here();
621 let mut text = String::new();
622 if self.peek() == Some('-') {
623 text.push(self.advance().unwrap());
624 }
625 while let Some(c) = self.peek() {
626 if c.is_ascii_digit() {
627 text.push(self.advance().unwrap());
628 } else {
629 break;
630 }
631 }
632 if self.peek() == Some('.') {
633 text.push(self.advance().unwrap());
634 while let Some(c) = self.peek() {
635 if c.is_ascii_digit() {
636 text.push(self.advance().unwrap());
637 } else {
638 break;
639 }
640 }
641 }
642 text.parse::<f64>().map_err(|_| {
643 self.error_at(
644 "settings-invalid",
645 format!("invalid number '{text}' in settings block"),
646 Span::new(self.file, start, self.here()),
647 )
648 })
649 }
650
651 fn parse_list(&mut self) -> OpyResult<Vec<cst::SettingsListElement>> {
652 self.advance(); let mut elements = Vec::new();
654 self.skip_whitespace();
655 if self.peek() == Some(']') {
656 self.advance();
657 return Ok(elements);
658 }
659 loop {
660 self.skip_whitespace();
661 let start = self.here();
662 let value = match self.peek() {
663 Some('"') | Some('\'') => self.parse_string_value().ok_or_else(|| {
664 self.error(
665 "settings-invalid",
666 "unterminated string in settings list".to_string(),
667 )
668 })?,
669 Some(']') | Some(',') | None => {
670 return Err(self.error(
671 "settings-invalid",
672 "settings list elements must not be empty".to_string(),
673 ));
674 }
675 _ => self.parse_expression_value(),
676 };
677 let span = Span::new(self.file, start, self.here());
678 elements.push(cst::SettingsListElement { value, span });
679 self.skip_whitespace();
680 match self.peek() {
681 Some(',') => {
682 self.advance();
683 self.skip_whitespace();
684 if self.peek() == Some(']') {
685 self.advance();
686 return Ok(elements);
687 }
688 }
689 Some(']') => {
690 self.advance();
691 return Ok(elements);
692 }
693 _ => {
694 return Err(self.error(
695 "settings-invalid",
696 "expected ',' or ']' in settings list".to_string(),
697 ));
698 }
699 }
700 }
701 }
702
703 fn parse_string_value(&mut self) -> Option<String> {
706 let quote = self.peek()?;
707 if quote != '"' && quote != '\'' {
708 return None;
709 }
710 self.advance();
711 let mut value = String::new();
712 loop {
713 let ch = self.advance()?;
714 if ch == '\n' {
715 return None;
716 }
717 if ch == quote {
718 return Some(value);
719 }
720 if ch == '\\' {
721 let escaped = self.advance()?;
722 match escaped {
723 'n' => value.push('\n'),
724 't' => value.push('\t'),
725 'r' => value.push('\r'),
726 other => value.push(other),
727 }
728 } else {
729 value.push(ch);
730 }
731 }
732 }
733}
734
735fn build_node(
737 key: String,
738 node: cst::SettingsNode,
739 value_end: Position,
740 key_start: Position,
741 file: u32,
742) -> cst::SettingsNode {
743 let span = Span::new(file, key_start, value_end);
744 match node {
745 cst::SettingsNode::Group { children, .. } => cst::SettingsNode::Group {
746 name: key,
747 children,
748 span,
749 },
750 cst::SettingsNode::Number { value, .. } => cst::SettingsNode::Number {
751 name: key,
752 value,
753 span,
754 },
755 cst::SettingsNode::Bool { value, .. } => cst::SettingsNode::Bool {
756 name: key,
757 value,
758 span,
759 },
760 cst::SettingsNode::String { value, .. } => cst::SettingsNode::String {
761 name: key,
762 value,
763 span,
764 },
765 cst::SettingsNode::Raw { value, .. } => cst::SettingsNode::Raw {
766 name: key,
767 value,
768 span,
769 },
770 cst::SettingsNode::List { elements, .. } => cst::SettingsNode::List {
771 name: key,
772 elements,
773 span,
774 },
775 }
776}
777
778fn is_ident_start(c: char) -> bool {
779 c.is_ascii_alphabetic() || c == '_'
780}
781
782fn is_ident_continue(c: char) -> bool {
783 c.is_ascii_alphanumeric() || c == '_'
784}
785
786#[cfg(test)]
787mod tests {
788 use super::*;
789
790 fn block(text: &str) -> SettingsBlock {
791 let mut blocks = find_blocks(text, 0).unwrap();
792 assert_eq!(blocks.len(), 1, "one block expected in: {text}");
793 blocks.pop().unwrap()
794 }
795
796 #[test]
797 fn finds_block_after_comments_and_blanks() {
798 let text = "/* header */\n# comment\n\nsettings {\n \"gamemodes\": {}\n}\nrule \"r\":\n";
799 let found = block(text);
800 assert!(found.text.contains("gamemodes"));
801 assert_eq!(found.keyword_span.start.line, 4);
802 assert_eq!(found.span.end.line, 6);
803 }
804
805 #[test]
806 fn no_blocks_for_plain_program() {
807 assert!(
808 find_blocks("rule \"r\":\n pass\n", 0)
809 .unwrap()
810 .is_empty()
811 );
812 }
813
814 #[test]
815 fn ignores_settings_words_in_strings_and_indented_code() {
816 let text = "rule \"settings\":\n x = \"settings\"\n";
817 assert!(find_blocks(text, 0).unwrap().is_empty());
818 }
819
820 #[test]
821 fn settings_not_first_construct_is_placement_error() {
822 let error = find_blocks("rule \"r\":\n pass\nsettings {\n}\n", 0).unwrap_err();
823 assert_eq!(error.code, "settings-placement");
824 assert_eq!(error.span.unwrap().start.line, 3);
825 }
826
827 #[test]
828 fn second_block_is_placement_error() {
829 let error =
830 find_blocks("settings {\n \"gamemodes\": {}\n}\nsettings {\n}\n", 0).unwrap_err();
831 assert_eq!(error.code, "settings-placement");
832 assert_eq!(error.span.unwrap().start.line, 4);
833 }
834
835 #[test]
836 fn settings_file_form_is_invalid() {
837 let error = find_blocks("settings \"file.opy\"\n", 0).unwrap_err();
838 assert_eq!(error.code, "settings-invalid");
839 }
840
841 #[test]
842 fn unterminated_block_is_invalid() {
843 let error = find_blocks("settings {\n \"gamemodes\": {\n", 0).unwrap_err();
844 assert_eq!(error.code, "settings-invalid");
845 }
846
847 #[test]
848 fn braces_inside_strings_do_not_unbalance() {
849 let found =
850 block("settings {\n \"description\": \"a { b }\",\n \"gamemodes\": {}\n}\n");
851 assert!(found.text.contains("a { b }"));
852 }
853
854 #[test]
855 fn sanitize_preserves_post_block_positions() {
856 let text = "settings {\n \"gamemodes\": {}\n}\nrule \"r\":\n pass\n";
857 let found = block(text);
858 let sanitized = sanitize_for_lex(text, &found);
859 let lines: Vec<&str> = sanitized.lines().collect();
860 assert_eq!(lines.len(), 5);
861 assert_eq!(lines[3], "rule \"r\":");
862 assert_eq!(lines[4], " pass");
863 assert_eq!(sanitized.find("rule"), text.find("rule"));
865 }
866
867 #[test]
868 fn non_object_after_settings_is_invalid() {
869 let error = find_blocks("settings [1, 2]\n", 0).unwrap_err();
871 assert_eq!(error.code, "settings-invalid");
872 }
873
874 #[test]
875 fn parse_block_rejects_missing_gamemodes() {
876 let found = block("settings {\n \"main\": { \"description\": \"x\" }\n}\n");
877 let error = parse_block(&found).unwrap_err();
878 assert_eq!(error.code, "settings-invalid");
879 assert!(error.message.contains("gamemodes"));
880 }
881
882 #[test]
883 fn parse_block_rejects_duplicate_keys() {
884 let found = block("settings {\n \"gamemodes\": {},\n \"gamemodes\": {}\n}\n");
885 let error = parse_block(&found).unwrap_err();
886 assert_eq!(error.code, "settings-invalid");
887 assert!(error.message.contains("duplicate"));
888 }
889
890 #[test]
891 fn parse_block_accepts_trailing_commas() {
892 let found = block(
893 "settings {\n \"gamemodes\": {\n \"general\": {\n \"heroLimit\": \"off\",\n },\n },\n}\n",
894 );
895 let parsed = parse_block(&found).unwrap();
896 assert_eq!(parsed.children.len(), 1);
897 }
898
899 #[test]
900 fn parse_block_handles_escapes_and_quotes() {
901 let found = block(
902 "settings {\n \"main\": { \"description\": \"line\\n\\t\\\"quoted\\\"\" },\n \"gamemodes\": {}\n}\n",
903 );
904 let parsed = parse_block(&found).unwrap();
905 let cst::SettingsNode::Group { children, .. } = &parsed.children[0] else {
906 panic!("main group");
907 };
908 let cst::SettingsNode::String { value, .. } = &children[0] else {
909 panic!("description");
910 };
911 assert_eq!(value, "line\n\t\"quoted\"");
912 }
913
914 #[test]
915 fn parse_block_accepts_comments_and_expression_values() {
916 let found = block(
917 "settings {\n # keep the source expression intact\n \"lobby\": {\n \"modeName\": GAMEMODE_NAME\" \"GAMEMODE_VERSION,\n },\n \"gamemodes\": {}\n}\n",
918 );
919 let parsed = parse_block(&found).unwrap();
920 let cst::SettingsNode::Group { children, .. } = &parsed.children[0] else {
921 panic!("lobby group");
922 };
923 let cst::SettingsNode::Raw { value, .. } = &children[0] else {
924 panic!("expression-valued setting");
925 };
926 assert_eq!(value, "GAMEMODE_NAME\" \"GAMEMODE_VERSION");
927 }
928
929 #[test]
930 fn parse_block_types_values() {
931 let found = block(
932 "settings {\n \"lobby\": { \"ffaSlots\": 6 },\n \"gamemodes\": { \"general\": { \"enableRandomHeroes\": true, \"respawnTime%\": 30, \"heroLimit\": \"off\" } },\n \"heroes\": { \"allTeams\": { \"enabledHeroes\": [\"mei\"] } }\n}\n",
933 );
934 let parsed = parse_block(&found).unwrap();
935 let lobby = match &parsed.children[0] {
936 cst::SettingsNode::Group { name, children, .. } => {
937 assert_eq!(name, "lobby");
938 children
939 }
940 other => panic!("{other:?}"),
941 };
942 assert!(matches!(
943 lobby[0],
944 cst::SettingsNode::Number { value: 6.0, .. }
945 ));
946 }
947
948 #[test]
949 fn spans_are_computed_from_block_base() {
950 let text = "settings {\n \"lobby\": {\n \"ffaSlots\": 6\n },\n \"gamemodes\": {}\n}\n";
951 let found = block(text);
952 let parsed = parse_block(&found).unwrap();
953 let cst::SettingsNode::Group { children, .. } = &parsed.children[0] else {
954 panic!("lobby");
955 };
956 let cst::SettingsNode::Number { span, .. } = &children[0] else {
957 panic!("ffaSlots");
958 };
959 assert_eq!(span.start.line, 3);
960 assert_eq!(span.start.col, 9);
961 }
962
963 #[test]
964 fn keyword_span_carries_the_file_id() {
965 let found = block("settings {\n \"gamemodes\": {}\n}\n");
966 assert_eq!(found.keyword_span.file, 0);
967 assert_eq!(found.keyword_span.start.col, 1);
968 assert_eq!(found.keyword_span.end.col, 9);
969 }
970}