Skip to main content

opy_rs/
settings.rs

1//! Scoped `settings { ... }` extraction and JSONC parsing (#86).
2//!
3//! The settings block is recognized and consumed *before* lexing, so the
4//! lexer never gains global `{`/`}` tokens (meipocalypse's dict literal keeps
5//! failing as a `lex-error`). [`find_blocks`] locates a top-of-file block
6//! with a logical-line keyword scan, [`sanitize_for_lex`] blanks the block
7//! region out of the text handed to the lexer (newlines preserved, so
8//! positions after the block are unchanged), and [`parse_block`] turns the
9//! JSONC text into a typed [`cst::Settings`] tree with source spans.
10//!
11//! The parse is value-driven: any JSONC object/leaf shape becomes a
12//! structurally generic [`cst::SettingsNode`]. Which keys exist and which
13//! leaf kinds/spellings are valid is Workshop-owned settings-schema content;
14//! that validation is `lowering-dependent` (issue #8) and lives at the
15//! Workshop integration boundary, never in a local allowlist here.
16
17use crate::cst;
18use crate::diag::{OpyError, OpyResult, Position, Span};
19
20/// A top-of-file `settings { ... }` block.
21#[derive(Debug, Clone)]
22pub struct SettingsBlock {
23    /// The raw JSONC text between the braces (braces excluded).
24    pub text: String,
25    /// The whole block: the `settings` keyword through the closing brace.
26    pub span: Span,
27    /// The `settings` keyword token (diagnostic anchor).
28    pub keyword_span: Span,
29    /// The char offset of the `settings` keyword (for sanitization).
30    pub start: usize,
31    /// The char offset just past the closing brace (for sanitization).
32    pub end: usize,
33    /// The position of the first char of `text` (just past the opening brace).
34    pub text_start: Position,
35}
36
37/// Locate every `settings { ... }` block in a source text.
38///
39/// Rules: 0 blocks -> `Ok(vec![])`; the first block must be the first
40/// non-comment construct (`settings-placement` otherwise); after `settings`
41/// a `{` is required (`settings "file"` form -> `settings-invalid`);
42/// a second/later block is `settings-placement` at its keyword span; brace
43/// matching respects `"`/`'` strings, `\` escapes, and nesting; an
44/// unterminated block is `settings-invalid`.
45pub 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 seen_first_construct = false;
56    while scanner.pos < scanner.chars.len() {
57        let ch = scanner.chars[scanner.pos];
58        if in_block_comment {
59            if ch == '*' && scanner.peek(1) == Some('/') {
60                in_block_comment = false;
61                scanner.advance(2);
62            } else {
63                scanner.advance(1);
64            }
65            continue;
66        }
67        if ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n' {
68            scanner.advance(1);
69            continue;
70        }
71        if ch == '#' {
72            scanner.skip_to_eol();
73            continue;
74        }
75        if ch == '/' && scanner.peek(1) == Some('*') {
76            scanner.advance(2);
77            in_block_comment = true;
78            continue;
79        }
80        // A construct token: the first non-comment token of a logical line.
81        if is_ident_start(ch) {
82            let keyword_start = scanner.here();
83            let keyword_offset = scanner.pos;
84            let word = scanner.read_word();
85            if word == "settings" {
86                let keyword_span = Span::new(file_id, keyword_start, scanner.here());
87                if seen_first_construct || !blocks.is_empty() {
88                    return Err(OpyError::at(
89                        "settings-placement",
90                        "settings block must be the first construct in the file".to_string(),
91                        keyword_span,
92                    ));
93                }
94                let block = match_block(&mut scanner, keyword_start, keyword_offset, keyword_span)?;
95                blocks.push(block);
96                seen_first_construct = true;
97                continue;
98            }
99            seen_first_construct = true;
100            continue;
101        }
102        seen_first_construct = true;
103        scanner.advance(1);
104    }
105    Ok(blocks)
106}
107
108/// Match the braces of one `settings { ... }` block, returning the extracted
109/// block. `scanner` is positioned just past the `settings` keyword.
110fn match_block(
111    scanner: &mut Scanner<'_>,
112    keyword_start: Position,
113    keyword_offset: usize,
114    keyword_span: Span,
115) -> OpyResult<SettingsBlock> {
116    scanner.skip_whitespace();
117    if scanner.chars.get(scanner.pos) != Some(&'{') {
118        return Err(OpyError::at(
119            "settings-invalid",
120            "settings block must be a `settings { ... }` block (the `settings \"file\"` form is not supported)"
121                .to_string(),
122            keyword_span,
123        ));
124    }
125    let mut depth = 0usize;
126    let mut string_quote: Option<char> = None;
127    let mut escaped = false;
128    let mut text_start_offset = None;
129    let mut text_start = None;
130    loop {
131        let Some(ch) = scanner.chars.get(scanner.pos).copied() else {
132            return Err(OpyError::at(
133                "settings-invalid",
134                "unterminated settings block (missing closing brace)".to_string(),
135                keyword_span,
136            ));
137        };
138        if let Some(quote) = string_quote {
139            if escaped {
140                escaped = false;
141            } else if ch == '\\' {
142                escaped = true;
143            } else if ch == quote {
144                string_quote = None;
145            }
146            scanner.advance(1);
147            continue;
148        }
149        match ch {
150            '"' | '\'' => string_quote = Some(ch),
151            '{' => {
152                if depth == 0 {
153                    text_start_offset = Some(scanner.pos + 1);
154                    text_start = Some(scanner.here_after(1));
155                }
156                depth += 1;
157            }
158            '}' => {
159                depth -= 1;
160                if depth == 0 {
161                    let text = scanner
162                        .chars
163                        .get(text_start_offset.expect("text start offset set on '{'")..scanner.pos)
164                        .map(|slice| slice.iter().collect::<String>())
165                        .unwrap_or_default();
166                    return Ok(SettingsBlock {
167                        text,
168                        span: Span::new(keyword_span.file, keyword_start, scanner.here_after(1)),
169                        keyword_span,
170                        start: keyword_offset,
171                        end: scanner.pos + 1,
172                        text_start: text_start.expect("text start set on '{'"),
173                    });
174                }
175            }
176            _ => {}
177        }
178        scanner.advance(1);
179    }
180}
181
182/// Replace every char of the block region with a space, preserving newlines,
183/// so tokens after the block keep their exact original line/col.
184pub fn sanitize_for_lex(text: &str, block: &SettingsBlock) -> String {
185    let mut out = String::with_capacity(text.len());
186    for (index, ch) in text.chars().enumerate() {
187        if index >= block.start && index < block.end {
188            out.push(if ch == '\n' { '\n' } else { ' ' });
189        } else {
190            out.push(ch);
191        }
192    }
193    out
194}
195
196/// Parse a settings block's JSONC text into a typed CST settings tree.
197///
198/// Grammar: quoted keys, `"`/`'` strings with `\` escapes, int/float numbers
199/// (f64), `true`/`false`, arrays of strings, nested objects, trailing commas
200/// in objects and arrays. Rejections (`settings-invalid`): duplicate keys,
201/// non-object root, missing `gamemodes` group, malformed values.
202pub fn parse_block(block: &SettingsBlock) -> OpyResult<cst::Settings> {
203    let mut parser = Jsonc {
204        text: &block.text,
205        pos: 0,
206        line: block.text_start.line,
207        col: block.text_start.col,
208        file: block.span.file,
209    };
210    parser.skip_whitespace();
211    // The block's own braces delimit the root object; the text between them
212    // parses as its members.
213    let children = parser.parse_members(true)?;
214    parser.skip_whitespace();
215    if parser.pos < parser.text.len() {
216        return Err(parser.error(
217            "settings-invalid",
218            "unexpected content after the settings object".to_string(),
219        ));
220    }
221    if !children
222        .iter()
223        .any(|node| matches!(node, cst::SettingsNode::Group { name, .. } if name == "gamemodes"))
224    {
225        return Err(OpyError::at(
226            "settings-invalid",
227            "settings block must contain a gamemodes group".to_string(),
228            block.span,
229        ));
230    }
231    Ok(cst::Settings {
232        span: block.span,
233        children,
234    })
235}
236
237/// A char scanner with 1-based line/col tracking.
238struct Scanner<'a> {
239    chars: &'a [char],
240    pos: usize,
241    line: u32,
242    col: u32,
243}
244
245impl Scanner<'_> {
246    fn peek(&self, ahead: usize) -> Option<char> {
247        self.chars.get(self.pos + ahead).copied()
248    }
249
250    fn here(&self) -> Position {
251        Position::new(self.line, self.col)
252    }
253
254    fn here_after(&self, n: usize) -> Position {
255        let mut line = self.line;
256        let mut col = self.col;
257        for i in 0..n {
258            if self.chars.get(self.pos + i) == Some(&'\n') {
259                line += 1;
260                col = 1;
261            } else {
262                col += 1;
263            }
264        }
265        Position::new(line, col)
266    }
267
268    fn advance(&mut self, n: usize) {
269        for _ in 0..n {
270            if self.pos >= self.chars.len() {
271                return;
272            }
273            if self.chars[self.pos] == '\n' {
274                self.line += 1;
275                self.col = 1;
276            } else {
277                self.col += 1;
278            }
279            self.pos += 1;
280        }
281    }
282
283    fn skip_to_eol(&mut self) {
284        while self.pos < self.chars.len() && self.chars[self.pos] != '\n' {
285            self.advance(1);
286        }
287    }
288
289    fn skip_whitespace(&mut self) {
290        while self.pos < self.chars.len() && matches!(self.chars[self.pos], ' ' | '\t' | '\r') {
291            self.advance(1);
292        }
293    }
294
295    fn read_word(&mut self) -> String {
296        let mut word = String::new();
297        while self.pos < self.chars.len() && is_ident_continue(self.chars[self.pos]) {
298            word.push(self.chars[self.pos]);
299            self.advance(1);
300        }
301        word
302    }
303}
304
305/// A JSONC parser over the block text.
306struct Jsonc<'a> {
307    text: &'a str,
308    pos: usize,
309    line: u32,
310    col: u32,
311    file: u32,
312}
313
314impl Jsonc<'_> {
315    fn here(&self) -> Position {
316        Position::new(self.line, self.col)
317    }
318
319    fn peek(&self) -> Option<char> {
320        self.text[self.pos..].chars().next()
321    }
322
323    fn advance(&mut self) -> Option<char> {
324        let ch = self.peek()?;
325        self.pos += ch.len_utf8();
326        if ch == '\n' {
327            self.line += 1;
328            self.col = 1;
329        } else {
330            self.col += 1;
331        }
332        Some(ch)
333    }
334
335    fn skip_whitespace(&mut self) {
336        while let Some(ch) = self.peek() {
337            if ch.is_whitespace() {
338                self.advance();
339            } else {
340                break;
341            }
342        }
343    }
344
345    fn error(&self, code: &str, message: String) -> OpyError {
346        OpyError::at(
347            code,
348            message,
349            Span::new(self.file, self.here(), self.here()),
350        )
351    }
352
353    fn error_at(&self, code: &str, message: String, span: Span) -> OpyError {
354        OpyError::at(code, message, span)
355    }
356
357    fn parse_object(&mut self) -> OpyResult<(Vec<cst::SettingsNode>, Span)> {
358        let open = self.here();
359        if self.advance() != Some('{') {
360            return Err(self.error(
361                "settings-invalid",
362                "settings block must be a JSONC object".to_string(),
363            ));
364        }
365        let members = self.parse_members(false)?;
366        let span = Span::new(self.file, open, self.here());
367        Ok((members, span))
368    }
369
370    /// Parse `key: value, ...` members. `root` is true when the enclosing
371    /// object's braces are the settings block's own braces (the text runs to
372    /// the end of the block, and a trailing comma before it is allowed).
373    fn parse_members(&mut self, root: bool) -> OpyResult<Vec<cst::SettingsNode>> {
374        let mut nodes = Vec::new();
375        let mut names = Vec::new();
376        self.skip_whitespace();
377        if (!root && self.peek() == Some('}')) || (root && self.pos >= self.text.len()) {
378            if !root {
379                self.advance();
380            }
381            return Ok(nodes);
382        }
383        loop {
384            self.skip_whitespace();
385            let key_start = self.here();
386            let key = match self.parse_string_value() {
387                Some(value) => value,
388                None => {
389                    return Err(self.error(
390                        "settings-invalid",
391                        "settings keys must be quoted strings".to_string(),
392                    ));
393                }
394            };
395            let key_span = Span::new(self.file, key_start, self.here());
396            if names.contains(&key) {
397                return Err(self.error_at(
398                    "settings-invalid",
399                    format!("duplicate settings key '{key}'"),
400                    key_span,
401                ));
402            }
403            names.push(key.clone());
404            self.skip_whitespace();
405            if self.advance() != Some(':') {
406                return Err(self.error_at(
407                    "settings-invalid",
408                    format!("expected ':' after settings key '{key}'"),
409                    key_span,
410                ));
411            }
412            self.skip_whitespace();
413            let (node, value_end) = self.parse_value()?;
414            let node = build_node(key, node, value_end, key_start, self.file);
415            nodes.push(node);
416            self.skip_whitespace();
417            match self.peek() {
418                Some(',') => {
419                    self.advance();
420                    self.skip_whitespace();
421                    if (!root && self.peek() == Some('}')) || (root && self.pos >= self.text.len())
422                    {
423                        if !root {
424                            self.advance();
425                        }
426                        return Ok(nodes);
427                    }
428                }
429                Some('}') if !root => {
430                    self.advance();
431                    return Ok(nodes);
432                }
433                None if root => return Ok(nodes),
434                _ => {
435                    return Err(self.error(
436                        "settings-invalid",
437                        "expected ',' or '}' in settings object".to_string(),
438                    ));
439                }
440            }
441        }
442    }
443
444    /// Parse one value; returns the built node (name placeholder) and the
445    /// position after it.
446    fn parse_value(&mut self) -> OpyResult<(cst::SettingsNode, Position)> {
447        let start = self.here();
448        let ch = self.peek();
449        let node = match ch {
450            Some('"') | Some('\'') => {
451                let value = self.parse_string_value().ok_or_else(|| {
452                    self.error(
453                        "settings-invalid",
454                        "unterminated string in settings value".to_string(),
455                    )
456                })?;
457                cst::SettingsNode::String {
458                    name: String::new(),
459                    value,
460                    span: Span::new(self.file, start, self.here()),
461                }
462            }
463            Some('t') => {
464                self.expect_word("true")?;
465                cst::SettingsNode::Bool {
466                    name: String::new(),
467                    value: true,
468                    span: Span::new(self.file, start, self.here()),
469                }
470            }
471            Some('f') => {
472                self.expect_word("false")?;
473                cst::SettingsNode::Bool {
474                    name: String::new(),
475                    value: false,
476                    span: Span::new(self.file, start, self.here()),
477                }
478            }
479            Some(c) if c.is_ascii_digit() || c == '-' => {
480                let value = self.parse_number()?;
481                cst::SettingsNode::Number {
482                    name: String::new(),
483                    value,
484                    span: Span::new(self.file, start, self.here()),
485                }
486            }
487            Some('[') => {
488                let elements = self.parse_list()?;
489                cst::SettingsNode::List {
490                    name: String::new(),
491                    elements,
492                    span: Span::new(self.file, start, self.here()),
493                }
494            }
495            Some('{') => {
496                let (children, _) = self.parse_object()?;
497                cst::SettingsNode::Group {
498                    name: String::new(),
499                    children,
500                    span: Span::new(self.file, start, self.here()),
501                }
502            }
503            _ => {
504                return Err(self.error(
505                    "settings-invalid",
506                    "expected a value in settings block".to_string(),
507                ));
508            }
509        };
510        let end = self.here();
511        Ok((node, end))
512    }
513
514    fn expect_word(&mut self, word: &str) -> OpyResult<()> {
515        let start = self.here();
516        for expected in word.chars() {
517            if self.advance() != Some(expected) {
518                return Err(self.error_at(
519                    "settings-invalid",
520                    format!("expected '{word}' in settings block"),
521                    Span::new(self.file, start, self.here()),
522                ));
523            }
524        }
525        Ok(())
526    }
527
528    fn parse_number(&mut self) -> OpyResult<f64> {
529        let start = self.here();
530        let mut text = String::new();
531        if self.peek() == Some('-') {
532            text.push(self.advance().unwrap());
533        }
534        while let Some(c) = self.peek() {
535            if c.is_ascii_digit() {
536                text.push(self.advance().unwrap());
537            } else {
538                break;
539            }
540        }
541        if self.peek() == Some('.') {
542            text.push(self.advance().unwrap());
543            while let Some(c) = self.peek() {
544                if c.is_ascii_digit() {
545                    text.push(self.advance().unwrap());
546                } else {
547                    break;
548                }
549            }
550        }
551        text.parse::<f64>().map_err(|_| {
552            self.error_at(
553                "settings-invalid",
554                format!("invalid number '{text}' in settings block"),
555                Span::new(self.file, start, self.here()),
556            )
557        })
558    }
559
560    fn parse_list(&mut self) -> OpyResult<Vec<cst::SettingsListElement>> {
561        self.advance(); // '['
562        let mut elements = Vec::new();
563        self.skip_whitespace();
564        if self.peek() == Some(']') {
565            self.advance();
566            return Ok(elements);
567        }
568        loop {
569            self.skip_whitespace();
570            let start = self.here();
571            let value = match self.parse_string_value() {
572                Some(value) => value,
573                None => {
574                    return Err(self.error(
575                        "settings-invalid",
576                        "settings list elements must be strings".to_string(),
577                    ));
578                }
579            };
580            let span = Span::new(self.file, start, self.here());
581            elements.push(cst::SettingsListElement { value, span });
582            self.skip_whitespace();
583            match self.peek() {
584                Some(',') => {
585                    self.advance();
586                    self.skip_whitespace();
587                    if self.peek() == Some(']') {
588                        self.advance();
589                        return Ok(elements);
590                    }
591                }
592                Some(']') => {
593                    self.advance();
594                    return Ok(elements);
595                }
596                _ => {
597                    return Err(self.error(
598                        "settings-invalid",
599                        "expected ',' or ']' in settings list".to_string(),
600                    ));
601                }
602            }
603        }
604    }
605
606    /// Parse a quoted string value; `None` when no string is here or the
607    /// string is unterminated before end-of-line.
608    fn parse_string_value(&mut self) -> Option<String> {
609        let quote = self.peek()?;
610        if quote != '"' && quote != '\'' {
611            return None;
612        }
613        self.advance();
614        let mut value = String::new();
615        loop {
616            let ch = self.advance()?;
617            if ch == '\n' {
618                return None;
619            }
620            if ch == quote {
621                return Some(value);
622            }
623            if ch == '\\' {
624                let escaped = self.advance()?;
625                match escaped {
626                    'n' => value.push('\n'),
627                    't' => value.push('\t'),
628                    'r' => value.push('\r'),
629                    other => value.push(other),
630                }
631            } else {
632                value.push(ch);
633            }
634        }
635    }
636}
637
638/// Attach the key name and a key..value span to a parsed value node.
639fn build_node(
640    key: String,
641    node: cst::SettingsNode,
642    value_end: Position,
643    key_start: Position,
644    file: u32,
645) -> cst::SettingsNode {
646    let span = Span::new(file, key_start, value_end);
647    match node {
648        cst::SettingsNode::Group { children, .. } => cst::SettingsNode::Group {
649            name: key,
650            children,
651            span,
652        },
653        cst::SettingsNode::Number { value, .. } => cst::SettingsNode::Number {
654            name: key,
655            value,
656            span,
657        },
658        cst::SettingsNode::Bool { value, .. } => cst::SettingsNode::Bool {
659            name: key,
660            value,
661            span,
662        },
663        cst::SettingsNode::String { value, .. } => cst::SettingsNode::String {
664            name: key,
665            value,
666            span,
667        },
668        cst::SettingsNode::List { elements, .. } => cst::SettingsNode::List {
669            name: key,
670            elements,
671            span,
672        },
673    }
674}
675
676fn is_ident_start(c: char) -> bool {
677    c.is_ascii_alphabetic() || c == '_'
678}
679
680fn is_ident_continue(c: char) -> bool {
681    c.is_ascii_alphanumeric() || c == '_'
682}
683
684#[cfg(test)]
685mod tests {
686    use super::*;
687
688    fn block(text: &str) -> SettingsBlock {
689        let mut blocks = find_blocks(text, 0).unwrap();
690        assert_eq!(blocks.len(), 1, "one block expected in: {text}");
691        blocks.pop().unwrap()
692    }
693
694    #[test]
695    fn finds_block_after_comments_and_blanks() {
696        let text = "/* header */\n# comment\n\nsettings {\n    \"gamemodes\": {}\n}\nrule \"r\":\n";
697        let found = block(text);
698        assert!(found.text.contains("gamemodes"));
699        assert_eq!(found.keyword_span.start.line, 4);
700        assert_eq!(found.span.end.line, 6);
701    }
702
703    #[test]
704    fn no_blocks_for_plain_program() {
705        assert!(
706            find_blocks("rule \"r\":\n    pass\n", 0)
707                .unwrap()
708                .is_empty()
709        );
710    }
711
712    #[test]
713    fn settings_not_first_construct_is_placement_error() {
714        let error = find_blocks("rule \"r\":\n    pass\nsettings {\n}\n", 0).unwrap_err();
715        assert_eq!(error.code, "settings-placement");
716        assert_eq!(error.span.unwrap().start.line, 3);
717    }
718
719    #[test]
720    fn second_block_is_placement_error() {
721        let error =
722            find_blocks("settings {\n    \"gamemodes\": {}\n}\nsettings {\n}\n", 0).unwrap_err();
723        assert_eq!(error.code, "settings-placement");
724        assert_eq!(error.span.unwrap().start.line, 4);
725    }
726
727    #[test]
728    fn settings_file_form_is_invalid() {
729        let error = find_blocks("settings \"file.opy\"\n", 0).unwrap_err();
730        assert_eq!(error.code, "settings-invalid");
731    }
732
733    #[test]
734    fn unterminated_block_is_invalid() {
735        let error = find_blocks("settings {\n    \"gamemodes\": {\n", 0).unwrap_err();
736        assert_eq!(error.code, "settings-invalid");
737    }
738
739    #[test]
740    fn braces_inside_strings_do_not_unbalance() {
741        let found =
742            block("settings {\n    \"description\": \"a { b }\",\n    \"gamemodes\": {}\n}\n");
743        assert!(found.text.contains("a { b }"));
744    }
745
746    #[test]
747    fn sanitize_preserves_post_block_positions() {
748        let text = "settings {\n    \"gamemodes\": {}\n}\nrule \"r\":\n    pass\n";
749        let found = block(text);
750        let sanitized = sanitize_for_lex(text, &found);
751        let lines: Vec<&str> = sanitized.lines().collect();
752        assert_eq!(lines.len(), 5);
753        assert_eq!(lines[3], "rule \"r\":");
754        assert_eq!(lines[4], "    pass");
755        // The rule keyword is at the same char offset as in the original.
756        assert_eq!(sanitized.find("rule"), text.find("rule"));
757    }
758
759    #[test]
760    fn non_object_after_settings_is_invalid() {
761        // `settings [..]` is rejected at extraction (a `{` is required).
762        let error = find_blocks("settings [1, 2]\n", 0).unwrap_err();
763        assert_eq!(error.code, "settings-invalid");
764    }
765
766    #[test]
767    fn parse_block_rejects_missing_gamemodes() {
768        let found = block("settings {\n    \"main\": { \"description\": \"x\" }\n}\n");
769        let error = parse_block(&found).unwrap_err();
770        assert_eq!(error.code, "settings-invalid");
771        assert!(error.message.contains("gamemodes"));
772    }
773
774    #[test]
775    fn parse_block_rejects_duplicate_keys() {
776        let found = block("settings {\n    \"gamemodes\": {},\n    \"gamemodes\": {}\n}\n");
777        let error = parse_block(&found).unwrap_err();
778        assert_eq!(error.code, "settings-invalid");
779        assert!(error.message.contains("duplicate"));
780    }
781
782    #[test]
783    fn parse_block_accepts_trailing_commas() {
784        let found = block(
785            "settings {\n    \"gamemodes\": {\n        \"general\": {\n            \"heroLimit\": \"off\",\n        },\n    },\n}\n",
786        );
787        let parsed = parse_block(&found).unwrap();
788        assert_eq!(parsed.children.len(), 1);
789    }
790
791    #[test]
792    fn parse_block_handles_escapes_and_quotes() {
793        let found = block(
794            "settings {\n    \"main\": { \"description\": \"line\\n\\t\\\"quoted\\\"\" },\n    \"gamemodes\": {}\n}\n",
795        );
796        let parsed = parse_block(&found).unwrap();
797        let cst::SettingsNode::Group { children, .. } = &parsed.children[0] else {
798            panic!("main group");
799        };
800        let cst::SettingsNode::String { value, .. } = &children[0] else {
801            panic!("description");
802        };
803        assert_eq!(value, "line\n\t\"quoted\"");
804    }
805
806    #[test]
807    fn parse_block_types_values() {
808        let found = block(
809            "settings {\n    \"lobby\": { \"ffaSlots\": 6 },\n    \"gamemodes\": { \"general\": { \"enableRandomHeroes\": true, \"respawnTime%\": 30, \"heroLimit\": \"off\" } },\n    \"heroes\": { \"allTeams\": { \"enabledHeroes\": [\"mei\"] } }\n}\n",
810        );
811        let parsed = parse_block(&found).unwrap();
812        let lobby = match &parsed.children[0] {
813            cst::SettingsNode::Group { name, children, .. } => {
814                assert_eq!(name, "lobby");
815                children
816            }
817            other => panic!("{other:?}"),
818        };
819        assert!(matches!(
820            lobby[0],
821            cst::SettingsNode::Number { value: 6.0, .. }
822        ));
823    }
824
825    #[test]
826    fn spans_are_computed_from_block_base() {
827        let text = "settings {\n    \"lobby\": {\n        \"ffaSlots\": 6\n    },\n    \"gamemodes\": {}\n}\n";
828        let found = block(text);
829        let parsed = parse_block(&found).unwrap();
830        let cst::SettingsNode::Group { children, .. } = &parsed.children[0] else {
831            panic!("lobby");
832        };
833        let cst::SettingsNode::Number { span, .. } = &children[0] else {
834            panic!("ffaSlots");
835        };
836        assert_eq!(span.start.line, 3);
837        assert_eq!(span.start.col, 9);
838    }
839
840    #[test]
841    fn keyword_span_carries_the_file_id() {
842        let found = block("settings {\n    \"gamemodes\": {}\n}\n");
843        assert_eq!(found.keyword_span.file, 0);
844        assert_eq!(found.keyword_span.start.col, 1);
845        assert_eq!(found.keyword_span.end.col, 9);
846    }
847}