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 logical-line `settings`
6//! statement in each source file with a 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 std::collections::HashMap;
18
19use crate::cst;
20use crate::diag::{OpyError, OpyResult, Position, Span};
21use crate::hir;
22use crate::lexer::{decode_string_escape, is_ident_continue, is_ident_start};
23
24/// A project `settings { ... }` block.
25#[derive(Debug, Clone)]
26pub struct SettingsBlock {
27    /// The raw JSONC text between the braces (braces excluded).
28    pub text: String,
29    /// The whole block: the `settings` keyword through the closing brace.
30    pub span: Span,
31    /// The `settings` keyword token (diagnostic anchor).
32    pub keyword_span: Span,
33    /// The char offset of the `settings` keyword (for sanitization).
34    pub start: usize,
35    /// The char offset just past the closing brace (for sanitization).
36    pub end: usize,
37    /// The position of the first char of `text` (just past the opening brace).
38    pub text_start: Position,
39    /// The source file containing the extracted settings content.
40    pub content_file: u32,
41    /// The span covering the extracted settings content.
42    pub content_span: Span,
43    /// The external source path for `settings "settings.opy.json"`.
44    pub external_path: Option<String>,
45}
46
47/// Locate every `settings { ... }` block in a source text.
48///
49/// Rules: 0 blocks -> `Ok(vec![])`; `settings` is a statement when it opens a
50/// logical line — the reference checks `tokens[0]` of every parsed line with
51/// no indentation or ordering requirement — or the inline tail of a `:`-header
52/// line, which the reference parses as a nested line. A newline inside
53/// brackets continues the logical line, so `settings` there is an ordinary
54/// token. After `settings` a `{` or quoted external path is required;
55/// a second/later block is `settings-placement` at its keyword span; brace
56/// matching respects `"`/`'` strings, `\` escapes, and nesting; an
57/// unterminated block is `settings-invalid`.
58pub fn find_blocks(text: &str, file_id: u32) -> OpyResult<Vec<SettingsBlock>> {
59    let mut scanner = Cursor::new(text, file_id, Position::new(1, 1));
60    let mut blocks = Vec::new();
61    let mut in_block_comment = false;
62    let mut string_quote = None;
63    let mut escaped = false;
64    // `line_start`: the next token would open a logical line. `tail_start`:
65    // the next token would open the inline tail of a `:`-header line.
66    // `depth`: open `(`, `[`, `{` count, which keeps a newline from ending
67    // the logical line.
68    let mut line_start = true;
69    let mut tail_start = false;
70    let mut depth = 0usize;
71    while let Some(ch) = scanner.peek() {
72        if scanner.advance_quoted(&mut string_quote, &mut escaped, ch) {
73            continue;
74        }
75        if in_block_comment {
76            if ch == '*' && scanner.peek_at(1) == Some('/') {
77                in_block_comment = false;
78                scanner.advance_by(2);
79            } else {
80                scanner.advance_by(1);
81            }
82            continue;
83        }
84        if matches!(ch, '"' | '\'') {
85            string_quote = Some(ch);
86            line_start = false;
87            tail_start = false;
88            scanner.advance_by(1);
89            continue;
90        }
91        if ch == ' ' || ch == '\t' || ch == '\r' {
92            scanner.advance_by(1);
93            continue;
94        }
95        if ch == '\n' {
96            if depth == 0 {
97                line_start = true;
98                tail_start = false;
99            }
100            scanner.advance_by(1);
101            continue;
102        }
103        if ch == '#' {
104            scanner.skip_to_eol();
105            continue;
106        }
107        if ch == '/' && scanner.peek_at(1) == Some('*') {
108            scanner.advance_by(2);
109            in_block_comment = true;
110            continue;
111        }
112        match ch {
113            '(' | '[' | '{' => depth += 1,
114            ')' | ']' | '}' => depth = depth.saturating_sub(1),
115            ':' if depth == 0 => {
116                tail_start = true;
117                line_start = false;
118                scanner.advance_by(1);
119                continue;
120            }
121            _ => {}
122        }
123        if is_ident_start(ch) {
124            let statement_start = line_start || tail_start;
125            line_start = false;
126            tail_start = false;
127            let keyword_start = scanner.here();
128            let keyword_offset = scanner.char_pos;
129            let word = scanner.read_word();
130            if word == "settings" && statement_start {
131                let keyword_span = Span::new(file_id, keyword_start, scanner.here());
132                if !blocks.is_empty() {
133                    return Err(OpyError::at(
134                        "settings-placement",
135                        "only one settings block is supported in a file".to_string(),
136                        keyword_span,
137                    ));
138                }
139                let block = match_block(&mut scanner, keyword_start, keyword_offset, keyword_span)?;
140                blocks.push(block);
141            }
142            continue;
143        }
144        line_start = false;
145        tail_start = false;
146        scanner.advance_by(1);
147    }
148    Ok(blocks)
149}
150
151/// Match the braces of one `settings { ... }` block, returning the extracted
152/// block. `scanner` is positioned just past the `settings` keyword.
153fn match_block(
154    scanner: &mut Cursor<'_>,
155    keyword_start: Position,
156    keyword_offset: usize,
157    keyword_span: Span,
158) -> OpyResult<SettingsBlock> {
159    scanner.skip_horizontal_whitespace();
160    if scanner.peek() != Some('{') {
161        let Some(quote) = scanner.peek().filter(|ch| matches!(ch, '"' | '\'')) else {
162            return Err(OpyError::at(
163                "settings-invalid",
164                "settings block must be a `settings { ... }` block or `settings \"file\"`",
165                keyword_span,
166            ));
167        };
168        scanner.advance_by(1);
169        let mut path = String::new();
170        let mut escaped = false;
171        loop {
172            let Some(ch) = scanner.peek() else {
173                return Err(OpyError::at(
174                    "settings-invalid",
175                    "unterminated external settings path",
176                    keyword_span,
177                ));
178            };
179            scanner.advance_by(1);
180            if escaped {
181                path.push(decode_string_escape(ch));
182                escaped = false;
183            } else if ch == '\\' {
184                escaped = true;
185            } else if ch == quote {
186                break;
187            } else if ch == '\n' {
188                return Err(OpyError::at(
189                    "settings-invalid",
190                    "external settings path must be one quoted string",
191                    keyword_span,
192                ));
193            } else {
194                path.push(ch);
195            }
196        }
197        scanner.skip_horizontal_whitespace();
198        if scanner.peek() == Some('#') {
199            scanner.skip_to_eol();
200        }
201        if !matches!(scanner.peek(), None | Some('\n')) {
202            return Err(OpyError::at(
203                "settings-invalid",
204                "external settings declaration has unexpected trailing content",
205                keyword_span,
206            ));
207        }
208        return Ok(SettingsBlock {
209            text: String::new(),
210            span: Span::new(keyword_span.file, keyword_start, scanner.here()),
211            keyword_span,
212            start: keyword_offset,
213            end: scanner.char_pos,
214            text_start: keyword_start,
215            content_file: keyword_span.file,
216            content_span: keyword_span,
217            external_path: Some(path),
218        });
219    }
220    let mut depth = 0usize;
221    let mut string_quote: Option<char> = None;
222    let mut escaped = false;
223    let mut text_start_offset = None;
224    let mut text_start = None;
225    loop {
226        let Some(ch) = scanner.peek() else {
227            return Err(OpyError::at(
228                "settings-invalid",
229                "unterminated settings block (missing closing brace)".to_string(),
230                keyword_span,
231            ));
232        };
233        if scanner.advance_quoted(&mut string_quote, &mut escaped, ch) {
234            continue;
235        }
236        match ch {
237            '"' | '\'' => string_quote = Some(ch),
238            '{' => {
239                if depth == 0 {
240                    text_start_offset = Some(scanner.pos + ch.len_utf8());
241                    text_start = Some(scanner.here_after(1));
242                }
243                depth += 1;
244            }
245            '}' => {
246                depth -= 1;
247                if depth == 0 {
248                    let text = scanner
249                        .text
250                        .get(text_start_offset.expect("text start offset set on '{'")..scanner.pos)
251                        .unwrap_or_default()
252                        .to_string();
253                    return Ok(SettingsBlock {
254                        text,
255                        span: Span::new(keyword_span.file, keyword_start, scanner.here_after(1)),
256                        keyword_span,
257                        start: keyword_offset,
258                        end: scanner.char_pos + 1,
259                        text_start: text_start.expect("text start set on '{'"),
260                        content_file: keyword_span.file,
261                        content_span: Span::new(
262                            keyword_span.file,
263                            text_start.expect("text start set on '{'"),
264                            scanner.here(),
265                        ),
266                        external_path: None,
267                    });
268                }
269            }
270            _ => {}
271        }
272        scanner.advance_by(1);
273    }
274}
275
276/// Replace every char of the block region with a space, preserving newlines,
277/// so tokens after the block keep their exact original line/col.
278pub fn sanitize_for_lex(text: &str, block: &SettingsBlock) -> String {
279    let mut out = String::with_capacity(text.len());
280    for (index, ch) in text.chars().enumerate() {
281        if index >= block.start && index < block.end {
282            out.push(if ch == '\n' { '\n' } else { ' ' });
283        } else {
284            out.push(ch);
285        }
286    }
287    out
288}
289
290/// Parse a settings block's JSONC text into a typed CST settings tree.
291///
292/// Grammar: quoted keys, `"`/`'` strings with `\` escapes, int/float numbers
293/// (f64), `true`/`false`, arrays of strings, nested objects, trailing commas
294/// in objects and arrays. Rejections (`settings-invalid`): duplicate keys,
295/// non-object root, missing `gamemodes` group, malformed values.
296pub fn parse_block(block: &SettingsBlock) -> OpyResult<cst::Settings> {
297    let mut parser = Cursor::new(&block.text, block.content_file, block.text_start);
298    parser.skip_whitespace();
299    let children = if block.external_path.is_some() {
300        let (children, _) = parser.parse_object()?;
301        children
302    } else {
303        // The block's own braces delimit the root object; the text between
304        // them parses as its members.
305        parser.parse_members(true)?
306    };
307    parser.skip_whitespace();
308    if parser.pos < parser.text.len() {
309        return Err(parser.error(
310            "settings-invalid",
311            "unexpected content after the settings object".to_string(),
312        ));
313    }
314    if !children
315        .iter()
316        .any(|node| matches!(node, cst::SettingsNode::Group { name, .. } if name == "gamemodes"))
317    {
318        return Err(OpyError::at(
319            "settings-invalid",
320            "settings block must contain a gamemodes group".to_string(),
321            block.content_span,
322        ));
323    }
324    Ok(cst::Settings {
325        span: block.content_span,
326        children,
327    })
328}
329
330/// Resolve parsed settings values through the ordinary OPY HIR expression path.
331///
332/// Token-level `#!define` expansion is performed by [`crate::preprocess`]
333/// before this function runs. Source macros and constants are resolved from
334/// the same HIR program used by ordinary OPY lowering.
335pub(crate) fn resolve_hir_settings(
336    program: &mut hir::Program,
337    cst_program: &cst::Program,
338) -> OpyResult<()> {
339    let constants = program
340        .declarations
341        .iter()
342        .filter_map(|declaration| match declaration {
343            hir::Declaration::Constant { name, value, .. } => Some((name.clone(), value.as_ref())),
344            _ => None,
345        })
346        .collect::<HashMap<_, _>>();
347    let mut expander = crate::compiler::MacroExpander::from_declarations(&program.declarations);
348    let Some(settings) = program.settings.take() else {
349        return Ok(());
350    };
351    let mut lowerer = crate::lower::SettingsLowerer::new(cst_program)?;
352    let children = settings
353        .children
354        .into_iter()
355        .map(|node| resolve_hir_node(node, &constants, &mut expander, &mut lowerer))
356        .collect::<OpyResult<Vec<_>>>()?;
357    program.settings = Some(hir::Settings {
358        span: settings.span,
359        children,
360    });
361    Ok(())
362}
363
364fn resolve_hir_node(
365    node: hir::SettingsNode,
366    constants: &HashMap<String, &hir::Expr>,
367    expander: &mut crate::compiler::MacroExpander<'_>,
368    lowerer: &mut crate::lower::SettingsLowerer,
369) -> OpyResult<hir::SettingsNode> {
370    match node {
371        hir::SettingsNode::Group {
372            name,
373            children,
374            span,
375        } => Ok(hir::SettingsNode::Group {
376            name,
377            children: children
378                .into_iter()
379                .map(|child| resolve_hir_node(child, constants, expander, lowerer))
380                .collect::<OpyResult<Vec<_>>>()?,
381            span,
382        }),
383        hir::SettingsNode::Raw { name, value, span } => {
384            let Some(span) = span else {
385                return Err(OpyError::new(
386                    "settings-expression",
387                    "settings value has no source span",
388                ));
389            };
390            let diag_span = hir_span_to_diag_span(span);
391            let expression = lowerer
392                .lower(
393                    &value,
394                    span.file,
395                    Position::new(span.start.line, span.start.col),
396                )
397                .map_err(|error| settings_expression_error(diag_span, error.message))?;
398            let expression = expander
399                .expand_expr(&expression, &HashMap::new(), None)
400                .map_err(|error| {
401                    let error_span = error
402                        .diagnostic
403                        .span
404                        .map(hir_span_to_diag_span)
405                        .unwrap_or(diag_span);
406                    OpyError::at(error.diagnostic.code, error.diagnostic.message, error_span)
407                })?;
408            let mut stack = Vec::new();
409            let value =
410                crate::compile_time::evaluate(&expression, constants, &HashMap::new(), &mut stack)
411                    .ok_or_else(|| {
412                        settings_expression_error(
413                            diag_span,
414                            "expression is not a compile-time value".to_string(),
415                        )
416                    })?;
417            node_from_value(name, value, diag_span)
418        }
419        node => Ok(node),
420    }
421}
422
423fn hir_span_to_diag_span(span: hir::Span) -> Span {
424    Span::new(
425        span.file,
426        Position::new(span.start.line, span.start.col),
427        Position::new(span.end.line, span.end.col),
428    )
429}
430
431fn settings_expression_error(span: Span, message: String) -> OpyError {
432    OpyError::at(
433        "settings-expression",
434        format!("settings value is not a compile-time value: {message}"),
435        span,
436    )
437}
438
439fn node_from_value(
440    name: String,
441    value: crate::compile_time::Value,
442    span: Span,
443) -> OpyResult<hir::SettingsNode> {
444    let node = match value {
445        crate::compile_time::Value::Number(value) if value.is_finite() => {
446            hir::SettingsNode::Number {
447                name,
448                value,
449                span: Some(span.into()),
450            }
451        }
452        crate::compile_time::Value::Number(_) => {
453            return Err(settings_expression_error(
454                span,
455                "compile-time number is not finite".to_string(),
456            ));
457        }
458        crate::compile_time::Value::String(value) => hir::SettingsNode::String {
459            name,
460            value,
461            span: Some(span.into()),
462        },
463        crate::compile_time::Value::Bool(value) => hir::SettingsNode::Bool {
464            name,
465            value,
466            span: Some(span.into()),
467        },
468        crate::compile_time::Value::Array(values) => hir::SettingsNode::List {
469            name,
470            elements: values
471                .into_iter()
472                .map(|value| {
473                    Ok(hir::SettingsListElement {
474                        value: display_value(&value)?,
475                        span: Some(span.into()),
476                    })
477                })
478                .collect::<Result<Vec<_>, String>>()
479                .map_err(|message| settings_expression_error(span, message))?,
480            span: Some(span.into()),
481        },
482        crate::compile_time::Value::Object(entries) => hir::SettingsNode::Group {
483            name,
484            children: entries
485                .into_iter()
486                .map(|(key, value)| {
487                    let crate::compile_time::Value::String(name) = key else {
488                        return Err(settings_expression_error(
489                            span,
490                            "compile-time object keys must be strings".to_string(),
491                        ));
492                    };
493                    node_from_value(name, value, span)
494                })
495                .collect::<OpyResult<Vec<_>>>()?,
496            span: Some(span.into()),
497        },
498    };
499    Ok(node)
500}
501
502fn display_value(value: &crate::compile_time::Value) -> Result<String, String> {
503    match value {
504        crate::compile_time::Value::Number(value) if value.is_finite() => {
505            Ok(crate::compiler::number_format::javascript_text(*value))
506        }
507        crate::compile_time::Value::String(value) => Ok(value.clone()),
508        crate::compile_time::Value::Bool(value) => Ok(value.to_string()),
509        _ => Err("settings list can only contain primitive values".to_string()),
510    }
511}
512
513/// The number literal a settings value token spells as JavaScript's
514/// `Number` reads it: decimal and exponent forms plus `0x`/`0o`/`0b`
515/// radixes, with an optional leading sign. `None` for non-number tokens.
516fn js_number_literal(text: &str) -> Option<f64> {
517    // `f64::parse` also admits spellings like `inf` and `NaN` that the pinned
518    // tokenizer reads as names, not literals; only a digit, dot, or sign can
519    // start a JavaScript number here.
520    if !text
521        .trim_start_matches(['-', '+'])
522        .chars()
523        .next()
524        .is_some_and(|c| c.is_ascii_digit() || c == '.')
525    {
526        return None;
527    }
528    // The reference's number reader requires a fraction digit before an
529    // exponent marker (`1.e5`); Rust accepts it, so gate the spelling out —
530    // an unrecognized literal keeps its authored text.
531    if !text.contains(".e") && !text.contains(".E") {
532        if let Ok(value) = text.parse::<f64>() {
533            return Some(value);
534        }
535    }
536    let (negative, rest) = if let Some(rest) = text.strip_prefix('-') {
537        (true, rest)
538    } else {
539        (false, text.strip_prefix('+').unwrap_or(text))
540    };
541    let digits = rest
542        .strip_prefix("0x")
543        .or_else(|| rest.strip_prefix("0X"))
544        .map(|digits| (digits, 16))
545        .or_else(|| {
546            rest.strip_prefix("0o")
547                .or_else(|| rest.strip_prefix("0O"))
548                .map(|digits| (digits, 8))
549        })
550        .or_else(|| {
551            rest.strip_prefix("0b")
552                .or_else(|| rest.strip_prefix("0B"))
553                .map(|digits| (digits, 2))
554        });
555    let (digits, radix) = digits?;
556    if digits.is_empty() {
557        return None;
558    }
559    let value = digits.chars().try_fold(0f64, |value, c| {
560        c.to_digit(radix)
561            .map(|digit| value * radix as f64 + digit as f64)
562    })?;
563    Some(if negative { -value } else { value })
564}
565struct Cursor<'a> {
566    text: &'a str,
567    pos: usize,
568    char_pos: usize,
569    line: u32,
570    col: u32,
571    file: u32,
572}
573
574impl Cursor<'_> {
575    fn new(text: &str, file: u32, origin: Position) -> Cursor<'_> {
576        Cursor {
577            text,
578            pos: 0,
579            char_pos: 0,
580            line: origin.line,
581            col: origin.col,
582            file,
583        }
584    }
585
586    fn peek(&self) -> Option<char> {
587        self.peek_at(0)
588    }
589
590    fn peek_at(&self, ahead: usize) -> Option<char> {
591        self.text[self.pos..].chars().nth(ahead)
592    }
593
594    fn here(&self) -> Position {
595        Position::new(self.line, self.col)
596    }
597
598    fn here_after(&self, n: usize) -> Position {
599        let mut line = self.line;
600        let mut col = self.col;
601        for ch in self.text[self.pos..].chars().take(n) {
602            if ch == '\n' {
603                line += 1;
604                col = 1;
605            } else {
606                col += 1;
607            }
608        }
609        Position::new(line, col)
610    }
611
612    fn advance(&mut self) -> Option<char> {
613        let ch = self.peek()?;
614        if ch == '\n' {
615            self.line += 1;
616            self.col = 1;
617        } else {
618            self.col += 1;
619        }
620        self.pos += ch.len_utf8();
621        self.char_pos += 1;
622        Some(ch)
623    }
624
625    fn advance_by(&mut self, n: usize) {
626        for _ in 0..n {
627            if self.advance().is_none() {
628                return;
629            }
630        }
631    }
632
633    fn advance_quoted(&mut self, quote: &mut Option<char>, escaped: &mut bool, ch: char) -> bool {
634        let Some(quote_char) = *quote else {
635            return false;
636        };
637        if *escaped {
638            *escaped = false;
639        } else if ch == '\\' {
640            *escaped = true;
641        } else if ch == quote_char {
642            *quote = None;
643        }
644        self.advance_by(1);
645        true
646    }
647
648    fn skip_to_eol(&mut self) {
649        while self.peek().is_some_and(|ch| ch != '\n') {
650            self.advance_by(1);
651        }
652    }
653
654    fn skip_horizontal_whitespace(&mut self) {
655        while matches!(self.peek(), Some(' ' | '\t' | '\r')) {
656            self.advance_by(1);
657        }
658    }
659
660    fn read_word(&mut self) -> String {
661        let mut word = String::new();
662        while let Some(ch) = self.peek() {
663            if !is_ident_continue(ch) {
664                break;
665            }
666            word.push(ch);
667            self.advance_by(1);
668        }
669        word
670    }
671}
672
673/// Primitive JSONC values before classification as typed settings nodes.
674enum ScalarValue {
675    String(String),
676    Bool(bool),
677    Number(f64),
678}
679
680impl Cursor<'_> {
681    fn skip_whitespace(&mut self) {
682        loop {
683            while let Some(ch) = self.peek() {
684                if ch.is_whitespace() {
685                    self.advance();
686                } else {
687                    break;
688                }
689            }
690            if self.peek() == Some('#') {
691                while self.peek().is_some_and(|ch| ch != '\n') {
692                    self.advance();
693                }
694                continue;
695            }
696            if self.text[self.pos..].starts_with("//") {
697                self.advance();
698                self.advance();
699                while self.peek().is_some_and(|ch| ch != '\n') {
700                    self.advance();
701                }
702                continue;
703            }
704            if self.text[self.pos..].starts_with("/*") {
705                self.advance();
706                self.advance();
707                while self.peek().is_some() && !self.text[self.pos..].starts_with("*/") {
708                    self.advance();
709                }
710                if self.text[self.pos..].starts_with("*/") {
711                    self.advance();
712                    self.advance();
713                }
714                continue;
715            }
716            break;
717        }
718    }
719
720    fn error(&self, code: &str, message: String) -> OpyError {
721        OpyError::at(
722            code,
723            message,
724            Span::new(self.file, self.here(), self.here()),
725        )
726    }
727
728    fn error_at(&self, code: &str, message: String, span: Span) -> OpyError {
729        OpyError::at(code, message, span)
730    }
731
732    fn parse_object(&mut self) -> OpyResult<(Vec<cst::SettingsNode>, Span)> {
733        let open = self.here();
734        if self.advance() != Some('{') {
735            return Err(self.error(
736                "settings-invalid",
737                "settings block must be a JSONC object".to_string(),
738            ));
739        }
740        let members = self.parse_members(false)?;
741        let span = Span::new(self.file, open, self.here());
742        Ok((members, span))
743    }
744
745    /// Parse `key: value, ...` members. `root` is true when the enclosing
746    /// object's braces are the settings block's own braces (the text runs to
747    /// the end of the block, and a trailing comma before it is allowed).
748    fn parse_members(&mut self, root: bool) -> OpyResult<Vec<cst::SettingsNode>> {
749        let mut nodes = Vec::new();
750        let mut names = Vec::new();
751        self.skip_whitespace();
752        if (!root && self.peek() == Some('}')) || (root && self.pos >= self.text.len()) {
753            if !root {
754                self.advance();
755            }
756            return Ok(nodes);
757        }
758        loop {
759            self.skip_whitespace();
760            let key_start = self.here();
761            let key = match self.parse_string_value() {
762                Some(value) => value,
763                None => {
764                    return Err(self.error(
765                        "settings-invalid",
766                        "settings keys must be quoted strings".to_string(),
767                    ));
768                }
769            };
770            let key_span = Span::new(self.file, key_start, self.here());
771            if names.contains(&key) {
772                return Err(self.error_at(
773                    "settings-invalid",
774                    format!("duplicate settings key '{key}'"),
775                    key_span,
776                ));
777            }
778            names.push(key.clone());
779            self.skip_whitespace();
780            if self.advance() != Some(':') {
781                return Err(self.error_at(
782                    "settings-invalid",
783                    format!("expected ':' after settings key '{key}'"),
784                    key_span,
785                ));
786            }
787            self.skip_whitespace();
788            let (node, value_end) = self.parse_value()?;
789            let node = build_node(key, node, value_end, key_start, self.file);
790            nodes.push(node);
791            self.skip_whitespace();
792            match self.peek() {
793                Some(',') => {
794                    self.advance();
795                    self.skip_whitespace();
796                    if (!root && self.peek() == Some('}')) || (root && self.pos >= self.text.len())
797                    {
798                        if !root {
799                            self.advance();
800                        }
801                        return Ok(nodes);
802                    }
803                }
804                Some('}') if !root => {
805                    self.advance();
806                    return Ok(nodes);
807                }
808                None if root => return Ok(nodes),
809                _ => {
810                    return Err(self.error(
811                        "settings-invalid",
812                        "expected ',' or '}' in settings object".to_string(),
813                    ));
814                }
815            }
816        }
817    }
818
819    /// Parse one value; returns the built node (name placeholder) and the
820    /// position after it.
821    fn parse_value(&mut self) -> OpyResult<(cst::SettingsNode, Position)> {
822        let start = self.here();
823        let ch = self.peek();
824        let saved = (self.pos, self.char_pos, self.line, self.col);
825        let scalar = match ch {
826            Some('"') | Some('\'') => {
827                let value = self.parse_string_expression().ok_or_else(|| {
828                    self.error(
829                        "settings-invalid",
830                        "unterminated string in settings value".to_string(),
831                    )
832                })?;
833                Some(ScalarValue::String(value))
834            }
835            Some('t') => {
836                self.expect_word("true")?;
837                Some(ScalarValue::Bool(true))
838            }
839            Some('f') => {
840                self.expect_word("false")?;
841                Some(ScalarValue::Bool(false))
842            }
843            Some(c) if c.is_ascii_digit() || c == '-' => {
844                Some(ScalarValue::Number(self.parse_number()?))
845            }
846            _ => None,
847        };
848        let node = if let Some(scalar) = scalar {
849            self.skip_inline_whitespace();
850            if self.is_expression_continuation() {
851                self.pos = saved.0;
852                self.char_pos = saved.1;
853                self.line = saved.2;
854                self.col = saved.3;
855                cst::SettingsNode::Raw {
856                    name: String::new(),
857                    value: self.parse_expression_value(),
858                    span: Span::new(self.file, start, self.here()),
859                }
860            } else {
861                let span = Span::new(self.file, start, self.here());
862                match scalar {
863                    ScalarValue::String(value) => cst::SettingsNode::String {
864                        name: String::new(),
865                        value,
866                        span,
867                    },
868                    ScalarValue::Bool(value) => cst::SettingsNode::Bool {
869                        name: String::new(),
870                        value,
871                        span,
872                    },
873                    ScalarValue::Number(value) => cst::SettingsNode::Number {
874                        name: String::new(),
875                        value,
876                        span,
877                    },
878                }
879            }
880        } else {
881            match ch {
882                Some('[') => {
883                    let elements = self.parse_list()?;
884                    cst::SettingsNode::List {
885                        name: String::new(),
886                        elements,
887                        span: Span::new(self.file, start, self.here()),
888                    }
889                }
890                Some('{') => {
891                    let (children, _) = self.parse_object()?;
892                    cst::SettingsNode::Group {
893                        name: String::new(),
894                        children,
895                        span: Span::new(self.file, start, self.here()),
896                    }
897                }
898                _ => {
899                    let value = self.parse_expression_value();
900                    if value.trim().is_empty() {
901                        return Err(self.error(
902                            "settings-invalid",
903                            "expected a value in settings block".to_string(),
904                        ));
905                    }
906                    cst::SettingsNode::Raw {
907                        name: String::new(),
908                        value,
909                        span: Span::new(self.file, start, self.here()),
910                    }
911                }
912            }
913        };
914        let end = self.here();
915        Ok((node, end))
916    }
917
918    /// Preserve an unquoted setting expression as an opaque value. Commas and
919    /// closing braces delimit it only outside nested expression delimiters and
920    /// string literals.
921    fn parse_expression_value(&mut self) -> String {
922        let mut value = String::new();
923        let mut depth = 0usize;
924        let mut quote = None;
925        while let Some(ch) = self.peek() {
926            if let Some(expected) = quote {
927                value.push(self.advance().expect("peeked character exists"));
928                if ch == expected {
929                    quote = None;
930                }
931                continue;
932            }
933            match ch {
934                '"' | '\'' => {
935                    quote = Some(ch);
936                    value.push(self.advance().expect("peeked character exists"));
937                }
938                '(' | '[' | '{' => {
939                    depth += 1;
940                    value.push(self.advance().expect("peeked character exists"));
941                }
942                ']' | ',' | '}' if depth == 0 => break,
943                ')' | ']' => {
944                    depth = depth.saturating_sub(1);
945                    value.push(self.advance().expect("peeked character exists"));
946                }
947                _ => value.push(self.advance().expect("peeked character exists")),
948            }
949        }
950        value.trim().to_string()
951    }
952
953    fn expect_word(&mut self, word: &str) -> OpyResult<()> {
954        let start = self.here();
955        for expected in word.chars() {
956            if self.advance() != Some(expected) {
957                return Err(self.error_at(
958                    "settings-invalid",
959                    format!("expected '{word}' in settings block"),
960                    Span::new(self.file, start, self.here()),
961                ));
962            }
963        }
964        Ok(())
965    }
966
967    /// Read one settings number literal the way the pinned OverPy tokenizer
968    /// admits it: decimal with an optional unsigned exponent (`1e21`,
969    /// `0.5e3`), and `0x`/`0o`/`0b` integer radixes (`0x1F` is 31; #496).
970    /// An exponent marker needs a fraction digit before it (`1.e5` is the
971    /// reference's "Expected a number after '.'" error).
972    /// A signed exponent (`1e-7`, `1e+21`) is not read — the reference
973    /// tokenizer splits the sign out as an operator and errors, so leaving
974    /// the `e` behind reproduces its rejection through the object parser.
975    fn parse_number(&mut self) -> OpyResult<f64> {
976        let start = self.here();
977        let mut text = String::new();
978        if self.peek() == Some('-') {
979            text.push(self.advance().unwrap());
980        }
981        if self.peek() == Some('0')
982            && matches!(self.peek_at(1), Some('x' | 'X' | 'o' | 'O' | 'b' | 'B'))
983        {
984            text.push(self.advance().unwrap());
985            let letter = self.advance().expect("radix letter is peeked");
986            text.push(letter);
987            let radix = match letter {
988                'x' | 'X' => 16,
989                'o' | 'O' => 8,
990                _ => 2,
991            };
992            let mut value = 0f64;
993            let mut digits = 0usize;
994            while let Some(c) = self.peek() {
995                let Some(digit) = c.to_digit(radix) else {
996                    break;
997                };
998                value = value * radix as f64 + digit as f64;
999                digits += 1;
1000                text.push(self.advance().unwrap());
1001            }
1002            if digits == 0 || self.peek().is_some_and(|c| c.is_ascii_alphanumeric()) {
1003                return Err(self.error_at(
1004                    "settings-invalid",
1005                    format!("invalid number '{text}' in settings block"),
1006                    Span::new(self.file, start, self.here()),
1007                ));
1008            }
1009            return Ok(if text.starts_with('-') { -value } else { value });
1010        }
1011        while let Some(c) = self.peek() {
1012            if c.is_ascii_digit() {
1013                text.push(self.advance().unwrap());
1014            } else {
1015                break;
1016            }
1017        }
1018        if self.peek() == Some('.') {
1019            text.push(self.advance().unwrap());
1020            let mut fraction = 0usize;
1021            while let Some(c) = self.peek() {
1022                if c.is_ascii_digit() {
1023                    text.push(self.advance().unwrap());
1024                    fraction += 1;
1025                } else {
1026                    break;
1027                }
1028            }
1029            // The reference requires a fraction digit before an exponent
1030            // marker: `1.e5` is its "Expected a number after '.'" error.
1031            if fraction == 0 && matches!(self.peek(), Some('e' | 'E')) {
1032                return Err(self.error_at(
1033                    "settings-invalid",
1034                    format!("invalid number '{text}' in settings block"),
1035                    Span::new(self.file, start, self.here()),
1036                ));
1037            }
1038        }
1039        if matches!(self.peek(), Some('e' | 'E'))
1040            && self.peek_at(1).is_some_and(|c| c.is_ascii_digit())
1041        {
1042            text.push(self.advance().unwrap());
1043            while let Some(c) = self.peek() {
1044                if c.is_ascii_digit() {
1045                    text.push(self.advance().unwrap());
1046                } else {
1047                    break;
1048                }
1049            }
1050        }
1051        text.parse::<f64>().map_err(|_| {
1052            self.error_at(
1053                "settings-invalid",
1054                format!("invalid number '{text}' in settings block"),
1055                Span::new(self.file, start, self.here()),
1056            )
1057        })
1058    }
1059
1060    fn parse_list(&mut self) -> OpyResult<Vec<cst::SettingsListElement>> {
1061        self.advance(); // '['
1062        let mut elements = Vec::new();
1063        self.skip_whitespace();
1064        if self.peek() == Some(']') {
1065            self.advance();
1066            return Ok(elements);
1067        }
1068        loop {
1069            self.skip_whitespace();
1070            let start = self.here();
1071            let value = match self.peek() {
1072                Some('"') | Some('\'') => self.parse_string_expression().ok_or_else(|| {
1073                    self.error(
1074                        "settings-invalid",
1075                        "unterminated string in settings list".to_string(),
1076                    )
1077                })?,
1078                Some(']') | Some(',') | None => {
1079                    return Err(self.error(
1080                        "settings-invalid",
1081                        "settings list elements must not be empty".to_string(),
1082                    ));
1083                }
1084                _ => {
1085                    let value = self.parse_expression_value();
1086                    // A bare list element that is a number literal writes its
1087                    // `String(value)` form (`[1e21]` emits `1e+21`; #496).
1088                    js_number_literal(&value)
1089                        .map(crate::compiler::number_format::javascript_text)
1090                        .unwrap_or(value)
1091                }
1092            };
1093            let span = Span::new(self.file, start, self.here());
1094            elements.push(cst::SettingsListElement { value, span });
1095            self.skip_whitespace();
1096            match self.peek() {
1097                Some(',') => {
1098                    self.advance();
1099                    self.skip_whitespace();
1100                    if self.peek() == Some(']') {
1101                        self.advance();
1102                        return Ok(elements);
1103                    }
1104                }
1105                Some(']') => {
1106                    self.advance();
1107                    return Ok(elements);
1108                }
1109                _ => {
1110                    return Err(self.error(
1111                        "settings-invalid",
1112                        "expected ',' or ']' in settings list".to_string(),
1113                    ));
1114                }
1115            }
1116        }
1117    }
1118
1119    /// Parse a quoted string value; `None` when no string is here or the
1120    /// string is unterminated before end-of-line.
1121    fn parse_string_value(&mut self) -> Option<String> {
1122        let quote = self.peek()?;
1123        if quote != '"' && quote != '\'' {
1124            return None;
1125        }
1126        self.advance();
1127        let mut value = String::new();
1128        loop {
1129            let ch = self.advance()?;
1130            if ch == '\n' {
1131                return None;
1132            }
1133            if ch == quote {
1134                return Some(value);
1135            }
1136            if ch == '\\' {
1137                let escaped = self.advance()?;
1138                value.push(decode_string_escape(escaped));
1139            } else {
1140                value.push(ch);
1141            }
1142        }
1143    }
1144
1145    /// Parse adjacent quoted strings as one compile-time string value. This
1146    /// is the settings equivalent of OPY's implicit string concatenation and
1147    /// is used after macro expansion has turned identifiers into literals.
1148    fn parse_string_expression(&mut self) -> Option<String> {
1149        let mut value = self.parse_string_value()?;
1150        loop {
1151            let saved = (self.pos, self.char_pos, self.line, self.col);
1152            self.skip_whitespace();
1153            if self.peek() != Some('"') && self.peek() != Some('\'') {
1154                self.pos = saved.0;
1155                self.char_pos = saved.1;
1156                self.line = saved.2;
1157                self.col = saved.3;
1158                break;
1159            }
1160            let next = self.parse_string_value()?;
1161            value.push_str(&next);
1162        }
1163        Some(value)
1164    }
1165
1166    fn skip_inline_whitespace(&mut self) {
1167        while matches!(self.peek(), Some(' ' | '\t' | '\r')) {
1168            self.advance();
1169        }
1170    }
1171
1172    fn is_expression_continuation(&self) -> bool {
1173        if matches!(
1174            self.peek(),
1175            Some('+' | '-' | '*' | '/' | '%' | '<' | '>' | '=')
1176        ) {
1177            return true;
1178        }
1179        ["and", "or", "if"].iter().any(|word| {
1180            self.text[self.pos..]
1181                .strip_prefix(word)
1182                .is_some_and(|rest| {
1183                    rest.chars().next().is_none_or(|character| {
1184                        !character.is_ascii_alphanumeric() && character != '_'
1185                    })
1186                })
1187        })
1188    }
1189}
1190
1191/// Attach the key name and a key..value span to a parsed value node.
1192fn build_node(
1193    key: String,
1194    node: cst::SettingsNode,
1195    value_end: Position,
1196    key_start: Position,
1197    file: u32,
1198) -> cst::SettingsNode {
1199    let span = Span::new(file, key_start, value_end);
1200    match node {
1201        cst::SettingsNode::Group { children, .. } => cst::SettingsNode::Group {
1202            name: key,
1203            children,
1204            span,
1205        },
1206        cst::SettingsNode::Number { value, .. } => cst::SettingsNode::Number {
1207            name: key,
1208            value,
1209            span,
1210        },
1211        cst::SettingsNode::Bool { value, .. } => cst::SettingsNode::Bool {
1212            name: key,
1213            value,
1214            span,
1215        },
1216        cst::SettingsNode::String { value, .. } => cst::SettingsNode::String {
1217            name: key,
1218            value,
1219            span,
1220        },
1221        cst::SettingsNode::Raw { value, .. } => cst::SettingsNode::Raw {
1222            name: key,
1223            value,
1224            span,
1225        },
1226        cst::SettingsNode::List { elements, .. } => cst::SettingsNode::List {
1227            name: key,
1228            elements,
1229            span,
1230        },
1231    }
1232}
1233
1234#[cfg(test)]
1235mod tests {
1236    use super::*;
1237
1238    fn block(text: &str) -> SettingsBlock {
1239        let mut blocks = find_blocks(text, 0).unwrap();
1240        assert_eq!(blocks.len(), 1, "one block expected in: {text}");
1241        blocks.pop().unwrap()
1242    }
1243
1244    #[test]
1245    fn finds_block_after_comments_and_blanks() {
1246        let text = "/* header */\n# comment\n\nsettings {\n    \"gamemodes\": {}\n}\nrule \"r\":\n";
1247        let found = block(text);
1248        assert!(found.text.contains("gamemodes"));
1249        assert_eq!(found.keyword_span.start.line, 4);
1250        assert_eq!(found.span.end.line, 6);
1251    }
1252
1253    #[test]
1254    fn no_blocks_for_plain_program() {
1255        assert!(
1256            find_blocks("rule \"r\":\n    pass\n", 0)
1257                .unwrap()
1258                .is_empty()
1259        );
1260    }
1261
1262    #[test]
1263    fn ignores_settings_words_in_strings_and_indented_code() {
1264        let text = "rule \"settings\":\n    x = \"settings\"\n";
1265        assert!(find_blocks(text, 0).unwrap().is_empty());
1266    }
1267
1268    #[test]
1269    fn settings_after_constructs_is_extracted() {
1270        // The pinned reference accepts `settings` as the first token of any
1271        // logical line, with no ordering or indentation requirement (#513).
1272        let text = "playervar score\n\nrule \"r\":\n    pass\n\nsettings {\n}\n";
1273        let found = block(text);
1274        assert_eq!(found.keyword_span.start.line, 6);
1275
1276        // An indented `settings` inside a block body is still the statement.
1277        let found = block("rule \"r\":\n    pass\n    settings {\n    }\n");
1278        assert_eq!(found.keyword_span.start.line, 3);
1279    }
1280
1281    #[test]
1282    fn settings_inside_brackets_is_an_ordinary_token() {
1283        // Inside a bracketed expression a `settings` line start does not open
1284        // a block; the text reaches the lexer and the parser rejects it.
1285        let text = "globalvar g = [\n    1,\nsettings]\n";
1286        assert!(find_blocks(text, 0).unwrap().is_empty());
1287
1288        // A `settings` mid-line after a non-colon token is not a statement.
1289        assert!(
1290            find_blocks("globalvar g = settings {}\n", 0)
1291                .unwrap()
1292                .is_empty()
1293        );
1294    }
1295
1296    #[test]
1297    fn settings_in_an_inline_tail_is_extracted() {
1298        // The reference parses the `:` tail of a header line as a nested
1299        // logical line, so its first token is a statement position (#513).
1300        let found = block("rule \"r\":\n    pass\n\nrule \"t\": settings {\n}\n");
1301        assert_eq!(found.keyword_span.start.line, 4);
1302    }
1303
1304    #[test]
1305    fn settings_inside_an_enum_body_is_extracted() {
1306        // Recorded divergence: the reference absorbs a `settings` line inside
1307        // an enum body as a member named `settings`; we extract it as the
1308        // settings block (see docs/architecture/language-core.md).
1309        let found = block("enum E:\n    A\n    settings {\n    }\n");
1310        assert_eq!(found.keyword_span.start.line, 3);
1311    }
1312
1313    #[test]
1314    fn second_block_is_placement_error() {
1315        let error =
1316            find_blocks("settings {\n    \"gamemodes\": {}\n}\nsettings {\n}\n", 0).unwrap_err();
1317        assert_eq!(error.code, "settings-placement");
1318        assert_eq!(error.span.unwrap().start.line, 4);
1319    }
1320
1321    #[test]
1322    fn settings_file_form_is_extracted() {
1323        let found = block("settings \"file.opy.json\"\n");
1324        assert_eq!(found.external_path.as_deref(), Some("file.opy.json"));
1325    }
1326
1327    #[test]
1328    fn unterminated_block_is_invalid() {
1329        let error = find_blocks("settings {\n    \"gamemodes\": {\n", 0).unwrap_err();
1330        assert_eq!(error.code, "settings-invalid");
1331    }
1332
1333    #[test]
1334    fn braces_inside_strings_do_not_unbalance() {
1335        let found =
1336            block("settings {\n    \"description\": \"a { b }\",\n    \"gamemodes\": {}\n}\n");
1337        assert!(found.text.contains("a { b }"));
1338    }
1339
1340    #[test]
1341    fn sanitize_preserves_post_block_positions() {
1342        let text = "settings {\n    \"gamemodes\": {}\n}\nrule \"r\":\n    pass\n";
1343        let found = block(text);
1344        let sanitized = sanitize_for_lex(text, &found);
1345        let lines: Vec<&str> = sanitized.lines().collect();
1346        assert_eq!(lines.len(), 5);
1347        assert_eq!(lines[3], "rule \"r\":");
1348        assert_eq!(lines[4], "    pass");
1349        // The rule keyword is at the same char offset as in the original.
1350        assert_eq!(sanitized.find("rule"), text.find("rule"));
1351    }
1352
1353    #[test]
1354    fn unicode_preserves_offsets_and_sanitization() {
1355        let text = "# 前置🙂\nsettings {\n    \"main\": { \"description\": \"内🙂\" },\n    \"gamemodes\": {}\n}\nrule \"后🙂\":\n    pass\n";
1356        let found = block(text);
1357        let keyword_byte_offset = text.find("settings").unwrap();
1358        let closing_byte_offset = text.find("}\nrule").unwrap();
1359        assert_eq!(found.start, text[..keyword_byte_offset].chars().count());
1360        assert_eq!(
1361            found.end,
1362            text[..closing_byte_offset + '}'.len_utf8()].chars().count()
1363        );
1364        assert_eq!(found.keyword_span.start, Position::new(2, 1));
1365        assert_eq!(found.text_start, Position::new(2, 11));
1366        assert!(found.text.contains("内🙂"));
1367
1368        let sanitized = sanitize_for_lex(text, &found);
1369        assert_eq!(sanitized.chars().count(), text.chars().count());
1370        let rule_char_offset = |source: &str| {
1371            let byte_offset = source.find("rule").unwrap();
1372            source[..byte_offset].chars().count()
1373        };
1374        assert_eq!(rule_char_offset(&sanitized), rule_char_offset(text));
1375        assert!(sanitized.contains("rule \"后🙂\":"));
1376    }
1377
1378    #[test]
1379    fn non_object_after_settings_is_invalid() {
1380        // `settings [..]` is rejected at extraction (a `{` is required).
1381        let error = find_blocks("settings [1, 2]\n", 0).unwrap_err();
1382        assert_eq!(error.code, "settings-invalid");
1383    }
1384
1385    #[test]
1386    fn parse_block_rejects_missing_gamemodes() {
1387        let found = block("settings {\n    \"main\": { \"description\": \"x\" }\n}\n");
1388        let error = parse_block(&found).unwrap_err();
1389        assert_eq!(error.code, "settings-invalid");
1390        assert!(error.message.contains("gamemodes"));
1391    }
1392
1393    #[test]
1394    fn parse_block_rejects_duplicate_keys() {
1395        let found = block("settings {\n    \"gamemodes\": {},\n    \"gamemodes\": {}\n}\n");
1396        let error = parse_block(&found).unwrap_err();
1397        assert_eq!(error.code, "settings-invalid");
1398        assert!(error.message.contains("duplicate"));
1399    }
1400
1401    #[test]
1402    fn parse_block_accepts_trailing_commas() {
1403        let found = block(
1404            "settings {\n    \"gamemodes\": {\n        \"general\": {\n            \"heroLimit\": \"off\",\n        },\n    },\n}\n",
1405        );
1406        let parsed = parse_block(&found).unwrap();
1407        assert_eq!(parsed.children.len(), 1);
1408    }
1409
1410    #[test]
1411    fn parse_block_handles_escapes_and_quotes() {
1412        let found = block(
1413            "settings {\n    \"main\": { \"description\": \"line\\n\\t\\\"quoted\\\"\" },\n    \"gamemodes\": {}\n}\n",
1414        );
1415        let parsed = parse_block(&found).unwrap();
1416        let cst::SettingsNode::Group { children, .. } = &parsed.children[0] else {
1417            panic!("main group");
1418        };
1419        let cst::SettingsNode::String { value, .. } = &children[0] else {
1420            panic!("description");
1421        };
1422        assert_eq!(value, "line\n\t\"quoted\"");
1423    }
1424
1425    #[test]
1426    fn parse_block_accepts_comments_and_expression_values() {
1427        let found = block(
1428            "settings {\n    # keep the source expression intact\n    \"lobby\": {\n        \"modeName\": GAMEMODE_NAME\" \"GAMEMODE_VERSION,\n    },\n    \"gamemodes\": {}\n}\n",
1429        );
1430        let parsed = parse_block(&found).unwrap();
1431        let cst::SettingsNode::Group { children, .. } = &parsed.children[0] else {
1432            panic!("lobby group");
1433        };
1434        let cst::SettingsNode::Raw { value, .. } = &children[0] else {
1435            panic!("expression-valued setting");
1436        };
1437        assert_eq!(value, "GAMEMODE_NAME\" \"GAMEMODE_VERSION");
1438    }
1439
1440    #[test]
1441    fn parse_block_reads_javascript_number_literals() {
1442        // The pinned OverPy tokenizer reads exponent and `0x`/`0o`/`0b`
1443        // integer forms in settings (#496).
1444        let found = block(
1445            "settings {\n    \"lobby\": { \"a\": 0x1F, \"b\": 0b101, \"c\": 0o17, \"d\": 1.5e21, \"e\": 12e2, \"f\": -0.5, \"g\": 2.5e1 },\n    \"gamemodes\": {}\n}\n",
1446        );
1447        let parsed = parse_block(&found).unwrap();
1448        let cst::SettingsNode::Group { children, .. } = &parsed.children[0] else {
1449            panic!("lobby group");
1450        };
1451        let values: Vec<f64> = children
1452            .iter()
1453            .map(|node| match node {
1454                cst::SettingsNode::Number { value, .. } => *value,
1455                other => panic!("{other:?}"),
1456            })
1457            .collect();
1458        assert_eq!(values, [31.0, 5.0, 15.0, 1.5e21, 1200.0, -0.5, 25.0]);
1459    }
1460
1461    #[test]
1462    fn parse_block_rejects_signed_exponents_like_the_pinned_reference() {
1463        // The pinned settings tokenizer splits an exponent sign out as an
1464        // operator and errors (`1e-7`, `1e+21`, `-2.5e-3`; #496), and it
1465        // requires a fraction digit before an exponent marker (`1.e5`).
1466        for value in ["1e-7", "1e+21", "-2.5e-3", "0.5e+3", "1.e5", "0.e5"] {
1467            let found = block(&format!(
1468                "settings {{\n    \"lobby\": {{ \"k\": {value} }},\n    \"gamemodes\": {{}}\n}}\n"
1469            ));
1470            let error = parse_block(&found).unwrap_err();
1471            assert_eq!(error.code, "settings-invalid", "{value}");
1472        }
1473    }
1474
1475    #[test]
1476    fn parse_block_types_values() {
1477        let found = block(
1478            "settings {\n    \"lobby\": { \"ffaSlots\": 6 },\n    \"gamemodes\": { \"general\": { \"enableRandomHeroes\": true, \"respawnTime%\": 30, \"heroLimit\": \"off\" } },\n    \"heroes\": { \"allTeams\": { \"enabledHeroes\": [\"mei\"] } }\n}\n",
1479        );
1480        let parsed = parse_block(&found).unwrap();
1481        let lobby = match &parsed.children[0] {
1482            cst::SettingsNode::Group { name, children, .. } => {
1483                assert_eq!(name, "lobby");
1484                children
1485            }
1486            other => panic!("{other:?}"),
1487        };
1488        assert!(matches!(
1489            lobby[0],
1490            cst::SettingsNode::Number { value: 6.0, .. }
1491        ));
1492    }
1493
1494    #[test]
1495    fn spans_are_computed_from_block_base() {
1496        let text = "settings {\n    \"lobby\": {\n        \"ffaSlots\": 6\n    },\n    \"gamemodes\": {}\n}\n";
1497        let found = block(text);
1498        let parsed = parse_block(&found).unwrap();
1499        let cst::SettingsNode::Group { children, .. } = &parsed.children[0] else {
1500            panic!("lobby");
1501        };
1502        let cst::SettingsNode::Number { span, .. } = &children[0] else {
1503            panic!("ffaSlots");
1504        };
1505        assert_eq!(span.start.line, 3);
1506        assert_eq!(span.start.col, 9);
1507    }
1508
1509    #[test]
1510    fn keyword_span_carries_the_file_id() {
1511        let found = block("settings {\n    \"gamemodes\": {}\n}\n");
1512        assert_eq!(found.keyword_span.file, 0);
1513        assert_eq!(found.keyword_span.start.col, 1);
1514        assert_eq!(found.keyword_span.end.col, 9);
1515    }
1516}