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 in
6//! each source file with a logical-line keyword scan, [`sanitize_for_lex`]
7//! blanks the block region out of the text handed to the lexer (newlines
8//! preserved, so positions after the block are unchanged), and [`parse_block`]
9//! turns the 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 project `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 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        // A construct token: the first non-comment token of a logical line.
99        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
126/// Match the braces of one `settings { ... }` block, returning the extracted
127/// block. `scanner` is positioned just past the `settings` keyword.
128fn 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
200/// Replace every char of the block region with a space, preserving newlines,
201/// so tokens after the block keep their exact original line/col.
202pub 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
214/// Parse a settings block's JSONC text into a typed CST settings tree.
215///
216/// Grammar: quoted keys, `"`/`'` strings with `\` escapes, int/float numbers
217/// (f64), `true`/`false`, arrays of strings, nested objects, trailing commas
218/// in objects and arrays. Rejections (`settings-invalid`): duplicate keys,
219/// non-object root, missing `gamemodes` group, malformed values.
220pub 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    // The block's own braces delimit the root object; the text between them
230    // parses as its members.
231    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
255/// A char scanner with 1-based line/col tracking.
256struct 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
323/// A JSONC parser over the block text.
324struct 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    /// Parse `key: value, ...` members. `root` is true when the enclosing
418    /// object's braces are the settings block's own braces (the text runs to
419    /// the end of the block, and a trailing comma before it is allowed).
420    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    /// Parse one value; returns the built node (name placeholder) and the
492    /// position after it.
493    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    /// Preserve an unquoted setting expression as an opaque value. Commas and
570    /// closing braces delimit it only outside nested expression delimiters and
571    /// string literals.
572    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(); // '['
653        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    /// Parse a quoted string value; `None` when no string is here or the
704    /// string is unterminated before end-of-line.
705    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
735/// Attach the key name and a key..value span to a parsed value node.
736fn 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        // The rule keyword is at the same char offset as in the original.
864        assert_eq!(sanitized.find("rule"), text.find("rule"));
865    }
866
867    #[test]
868    fn non_object_after_settings_is_invalid() {
869        // `settings [..]` is rejected at extraction (a `{` is required).
870        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}