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 ')' | ']' => {
594 depth = depth.saturating_sub(1);
595 value.push(self.advance().expect("peeked character exists"));
596 }
597 ',' | '}' if depth == 0 => break,
598 _ => value.push(self.advance().expect("peeked character exists")),
599 }
600 }
601 value.trim().to_string()
602 }
603
604 fn expect_word(&mut self, word: &str) -> OpyResult<()> {
605 let start = self.here();
606 for expected in word.chars() {
607 if self.advance() != Some(expected) {
608 return Err(self.error_at(
609 "settings-invalid",
610 format!("expected '{word}' in settings block"),
611 Span::new(self.file, start, self.here()),
612 ));
613 }
614 }
615 Ok(())
616 }
617
618 fn parse_number(&mut self) -> OpyResult<f64> {
619 let start = self.here();
620 let mut text = String::new();
621 if self.peek() == Some('-') {
622 text.push(self.advance().unwrap());
623 }
624 while let Some(c) = self.peek() {
625 if c.is_ascii_digit() {
626 text.push(self.advance().unwrap());
627 } else {
628 break;
629 }
630 }
631 if self.peek() == Some('.') {
632 text.push(self.advance().unwrap());
633 while let Some(c) = self.peek() {
634 if c.is_ascii_digit() {
635 text.push(self.advance().unwrap());
636 } else {
637 break;
638 }
639 }
640 }
641 text.parse::<f64>().map_err(|_| {
642 self.error_at(
643 "settings-invalid",
644 format!("invalid number '{text}' in settings block"),
645 Span::new(self.file, start, self.here()),
646 )
647 })
648 }
649
650 fn parse_list(&mut self) -> OpyResult<Vec<cst::SettingsListElement>> {
651 self.advance(); let mut elements = Vec::new();
653 self.skip_whitespace();
654 if self.peek() == Some(']') {
655 self.advance();
656 return Ok(elements);
657 }
658 loop {
659 self.skip_whitespace();
660 let start = self.here();
661 let value = match self.parse_string_value() {
662 Some(value) => value,
663 None => {
664 return Err(self.error(
665 "settings-invalid",
666 "settings list elements must be strings".to_string(),
667 ));
668 }
669 };
670 let span = Span::new(self.file, start, self.here());
671 elements.push(cst::SettingsListElement { value, span });
672 self.skip_whitespace();
673 match self.peek() {
674 Some(',') => {
675 self.advance();
676 self.skip_whitespace();
677 if self.peek() == Some(']') {
678 self.advance();
679 return Ok(elements);
680 }
681 }
682 Some(']') => {
683 self.advance();
684 return Ok(elements);
685 }
686 _ => {
687 return Err(self.error(
688 "settings-invalid",
689 "expected ',' or ']' in settings list".to_string(),
690 ));
691 }
692 }
693 }
694 }
695
696 fn parse_string_value(&mut self) -> Option<String> {
699 let quote = self.peek()?;
700 if quote != '"' && quote != '\'' {
701 return None;
702 }
703 self.advance();
704 let mut value = String::new();
705 loop {
706 let ch = self.advance()?;
707 if ch == '\n' {
708 return None;
709 }
710 if ch == quote {
711 return Some(value);
712 }
713 if ch == '\\' {
714 let escaped = self.advance()?;
715 match escaped {
716 'n' => value.push('\n'),
717 't' => value.push('\t'),
718 'r' => value.push('\r'),
719 other => value.push(other),
720 }
721 } else {
722 value.push(ch);
723 }
724 }
725 }
726}
727
728fn build_node(
730 key: String,
731 node: cst::SettingsNode,
732 value_end: Position,
733 key_start: Position,
734 file: u32,
735) -> cst::SettingsNode {
736 let span = Span::new(file, key_start, value_end);
737 match node {
738 cst::SettingsNode::Group { children, .. } => cst::SettingsNode::Group {
739 name: key,
740 children,
741 span,
742 },
743 cst::SettingsNode::Number { value, .. } => cst::SettingsNode::Number {
744 name: key,
745 value,
746 span,
747 },
748 cst::SettingsNode::Bool { value, .. } => cst::SettingsNode::Bool {
749 name: key,
750 value,
751 span,
752 },
753 cst::SettingsNode::String { value, .. } => cst::SettingsNode::String {
754 name: key,
755 value,
756 span,
757 },
758 cst::SettingsNode::Raw { value, .. } => cst::SettingsNode::Raw {
759 name: key,
760 value,
761 span,
762 },
763 cst::SettingsNode::List { elements, .. } => cst::SettingsNode::List {
764 name: key,
765 elements,
766 span,
767 },
768 }
769}
770
771fn is_ident_start(c: char) -> bool {
772 c.is_ascii_alphabetic() || c == '_'
773}
774
775fn is_ident_continue(c: char) -> bool {
776 c.is_ascii_alphanumeric() || c == '_'
777}
778
779#[cfg(test)]
780mod tests {
781 use super::*;
782
783 fn block(text: &str) -> SettingsBlock {
784 let mut blocks = find_blocks(text, 0).unwrap();
785 assert_eq!(blocks.len(), 1, "one block expected in: {text}");
786 blocks.pop().unwrap()
787 }
788
789 #[test]
790 fn finds_block_after_comments_and_blanks() {
791 let text = "/* header */\n# comment\n\nsettings {\n \"gamemodes\": {}\n}\nrule \"r\":\n";
792 let found = block(text);
793 assert!(found.text.contains("gamemodes"));
794 assert_eq!(found.keyword_span.start.line, 4);
795 assert_eq!(found.span.end.line, 6);
796 }
797
798 #[test]
799 fn no_blocks_for_plain_program() {
800 assert!(
801 find_blocks("rule \"r\":\n pass\n", 0)
802 .unwrap()
803 .is_empty()
804 );
805 }
806
807 #[test]
808 fn ignores_settings_words_in_strings_and_indented_code() {
809 let text = "rule \"settings\":\n x = \"settings\"\n";
810 assert!(find_blocks(text, 0).unwrap().is_empty());
811 }
812
813 #[test]
814 fn settings_not_first_construct_is_placement_error() {
815 let error = find_blocks("rule \"r\":\n pass\nsettings {\n}\n", 0).unwrap_err();
816 assert_eq!(error.code, "settings-placement");
817 assert_eq!(error.span.unwrap().start.line, 3);
818 }
819
820 #[test]
821 fn second_block_is_placement_error() {
822 let error =
823 find_blocks("settings {\n \"gamemodes\": {}\n}\nsettings {\n}\n", 0).unwrap_err();
824 assert_eq!(error.code, "settings-placement");
825 assert_eq!(error.span.unwrap().start.line, 4);
826 }
827
828 #[test]
829 fn settings_file_form_is_invalid() {
830 let error = find_blocks("settings \"file.opy\"\n", 0).unwrap_err();
831 assert_eq!(error.code, "settings-invalid");
832 }
833
834 #[test]
835 fn unterminated_block_is_invalid() {
836 let error = find_blocks("settings {\n \"gamemodes\": {\n", 0).unwrap_err();
837 assert_eq!(error.code, "settings-invalid");
838 }
839
840 #[test]
841 fn braces_inside_strings_do_not_unbalance() {
842 let found =
843 block("settings {\n \"description\": \"a { b }\",\n \"gamemodes\": {}\n}\n");
844 assert!(found.text.contains("a { b }"));
845 }
846
847 #[test]
848 fn sanitize_preserves_post_block_positions() {
849 let text = "settings {\n \"gamemodes\": {}\n}\nrule \"r\":\n pass\n";
850 let found = block(text);
851 let sanitized = sanitize_for_lex(text, &found);
852 let lines: Vec<&str> = sanitized.lines().collect();
853 assert_eq!(lines.len(), 5);
854 assert_eq!(lines[3], "rule \"r\":");
855 assert_eq!(lines[4], " pass");
856 assert_eq!(sanitized.find("rule"), text.find("rule"));
858 }
859
860 #[test]
861 fn non_object_after_settings_is_invalid() {
862 let error = find_blocks("settings [1, 2]\n", 0).unwrap_err();
864 assert_eq!(error.code, "settings-invalid");
865 }
866
867 #[test]
868 fn parse_block_rejects_missing_gamemodes() {
869 let found = block("settings {\n \"main\": { \"description\": \"x\" }\n}\n");
870 let error = parse_block(&found).unwrap_err();
871 assert_eq!(error.code, "settings-invalid");
872 assert!(error.message.contains("gamemodes"));
873 }
874
875 #[test]
876 fn parse_block_rejects_duplicate_keys() {
877 let found = block("settings {\n \"gamemodes\": {},\n \"gamemodes\": {}\n}\n");
878 let error = parse_block(&found).unwrap_err();
879 assert_eq!(error.code, "settings-invalid");
880 assert!(error.message.contains("duplicate"));
881 }
882
883 #[test]
884 fn parse_block_accepts_trailing_commas() {
885 let found = block(
886 "settings {\n \"gamemodes\": {\n \"general\": {\n \"heroLimit\": \"off\",\n },\n },\n}\n",
887 );
888 let parsed = parse_block(&found).unwrap();
889 assert_eq!(parsed.children.len(), 1);
890 }
891
892 #[test]
893 fn parse_block_handles_escapes_and_quotes() {
894 let found = block(
895 "settings {\n \"main\": { \"description\": \"line\\n\\t\\\"quoted\\\"\" },\n \"gamemodes\": {}\n}\n",
896 );
897 let parsed = parse_block(&found).unwrap();
898 let cst::SettingsNode::Group { children, .. } = &parsed.children[0] else {
899 panic!("main group");
900 };
901 let cst::SettingsNode::String { value, .. } = &children[0] else {
902 panic!("description");
903 };
904 assert_eq!(value, "line\n\t\"quoted\"");
905 }
906
907 #[test]
908 fn parse_block_accepts_comments_and_expression_values() {
909 let found = block(
910 "settings {\n # keep the source expression intact\n \"lobby\": {\n \"modeName\": GAMEMODE_NAME\" \"GAMEMODE_VERSION,\n },\n \"gamemodes\": {}\n}\n",
911 );
912 let parsed = parse_block(&found).unwrap();
913 let cst::SettingsNode::Group { children, .. } = &parsed.children[0] else {
914 panic!("lobby group");
915 };
916 let cst::SettingsNode::Raw { value, .. } = &children[0] else {
917 panic!("expression-valued setting");
918 };
919 assert_eq!(value, "GAMEMODE_NAME\" \"GAMEMODE_VERSION");
920 }
921
922 #[test]
923 fn parse_block_types_values() {
924 let found = block(
925 "settings {\n \"lobby\": { \"ffaSlots\": 6 },\n \"gamemodes\": { \"general\": { \"enableRandomHeroes\": true, \"respawnTime%\": 30, \"heroLimit\": \"off\" } },\n \"heroes\": { \"allTeams\": { \"enabledHeroes\": [\"mei\"] } }\n}\n",
926 );
927 let parsed = parse_block(&found).unwrap();
928 let lobby = match &parsed.children[0] {
929 cst::SettingsNode::Group { name, children, .. } => {
930 assert_eq!(name, "lobby");
931 children
932 }
933 other => panic!("{other:?}"),
934 };
935 assert!(matches!(
936 lobby[0],
937 cst::SettingsNode::Number { value: 6.0, .. }
938 ));
939 }
940
941 #[test]
942 fn spans_are_computed_from_block_base() {
943 let text = "settings {\n \"lobby\": {\n \"ffaSlots\": 6\n },\n \"gamemodes\": {}\n}\n";
944 let found = block(text);
945 let parsed = parse_block(&found).unwrap();
946 let cst::SettingsNode::Group { children, .. } = &parsed.children[0] else {
947 panic!("lobby");
948 };
949 let cst::SettingsNode::Number { span, .. } = &children[0] else {
950 panic!("ffaSlots");
951 };
952 assert_eq!(span.start.line, 3);
953 assert_eq!(span.start.col, 9);
954 }
955
956 #[test]
957 fn keyword_span_carries_the_file_id() {
958 let found = block("settings {\n \"gamemodes\": {}\n}\n");
959 assert_eq!(found.keyword_span.file, 0);
960 assert_eq!(found.keyword_span.start.col, 1);
961 assert_eq!(found.keyword_span.end.col, 9);
962 }
963}