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 value = eval_settings_text(&value, span, constants, expander, lowerer, true)?;
392            node_from_value(name, value, diag_span)
393        }
394        // The pinned OverPy evaluates each settings list element through its
395        // expression path and errors on what it cannot resolve (#512).
396        // Evaluation is recorded on the element rather than emitted eagerly:
397        // elements under catalogued enum keys and hero lists are member
398        // names, not expressions, and only the downstream pass knows which.
399        hir::SettingsNode::List {
400            name,
401            elements,
402            span,
403        } => Ok(hir::SettingsNode::List {
404            name,
405            elements: elements
406                .into_iter()
407                .map(|mut element| {
408                    if element.evaluated.is_none()
409                        && let Some(span) = element.span
410                    {
411                        element.evaluated = eval_settings_text(
412                            &element.value,
413                            span,
414                            constants,
415                            expander,
416                            lowerer,
417                            true,
418                        )
419                        .ok()
420                        .and_then(|value| display_value(&value).ok());
421                    }
422                    element
423                })
424                .collect(),
425            span,
426        }),
427        // A literal string still passes the pinned `__customString__`
428        // check upstream — `{n}`/`{}` formatters without arguments are an
429        // error even outside `.format(...)` calls (#512).
430        hir::SettingsNode::String { name, value, span } => {
431            if crate::compile_time::has_unbound_formatters(&value) {
432                let Some(span) = span else {
433                    return Err(OpyError::new(
434                        "settings-expression",
435                        "settings value has no source span",
436                    ));
437                };
438                return Err(settings_expression_error(
439                    hir_span_to_diag_span(span),
440                    format!("settings string '{value}' has format arguments"),
441                ));
442            }
443            Ok(hir::SettingsNode::String { name, value, span })
444        }
445        node => Ok(node),
446    }
447}
448
449/// Compile an authored settings expression to a constant value: parse it as
450/// OPY, expand macros, then fold. `strict` selects the pinned settings
451/// evaluator — scalars and list elements both evaluate through it upstream
452/// (#512).
453fn eval_settings_text(
454    text: &str,
455    span: hir::Span,
456    constants: &HashMap<String, &hir::Expr>,
457    expander: &mut crate::compiler::MacroExpander<'_>,
458    lowerer: &mut crate::lower::SettingsLowerer,
459    strict: bool,
460) -> OpyResult<crate::compile_time::Value> {
461    let diag_span = hir_span_to_diag_span(span);
462    let expression = lowerer
463        .lower(
464            text,
465            span.file,
466            Position::new(span.start.line, span.start.col),
467        )
468        .map_err(|error| settings_expression_error(diag_span, error.message))?;
469    let expression = expander
470        .expand_expr(&expression, &HashMap::new(), None)
471        .map_err(|error| {
472            let error_span = error
473                .diagnostic
474                .span
475                .map(hir_span_to_diag_span)
476                .unwrap_or(diag_span);
477            OpyError::at(error.diagnostic.code, error.diagnostic.message, error_span)
478        })?;
479    let mut stack = Vec::new();
480    let value = if strict {
481        crate::compile_time::evaluate_settings(&expression, constants, &HashMap::new(), &mut stack)
482    } else {
483        crate::compile_time::evaluate(&expression, constants, &HashMap::new(), &mut stack)
484    };
485    value.ok_or_else(|| {
486        settings_expression_error(
487            diag_span,
488            "expression is not a compile-time value".to_string(),
489        )
490    })
491}
492
493fn hir_span_to_diag_span(span: hir::Span) -> Span {
494    Span::new(
495        span.file,
496        Position::new(span.start.line, span.start.col),
497        Position::new(span.end.line, span.end.col),
498    )
499}
500
501fn settings_expression_error(span: Span, message: String) -> OpyError {
502    OpyError::at(
503        "settings-expression",
504        format!("settings value is not a compile-time value: {message}"),
505        span,
506    )
507}
508
509fn node_from_value(
510    name: String,
511    value: crate::compile_time::Value,
512    span: Span,
513) -> OpyResult<hir::SettingsNode> {
514    let node = match value {
515        // `String(numValue)` writes `NaN`/`Infinity` literally upstream —
516        // `x: 5%0` is `x: NaN` and `x: Infinity` is `x: Infinity` (#512).
517        crate::compile_time::Value::Number(value) => hir::SettingsNode::Number {
518            name,
519            value,
520            span: Some(span.into()),
521        },
522        crate::compile_time::Value::String(value) => {
523            if crate::compile_time::has_unbound_formatters(&value) {
524                return Err(settings_expression_error(
525                    span,
526                    format!("settings string '{value}' has format arguments"),
527                ));
528            }
529            hir::SettingsNode::String {
530                name,
531                value,
532                span: Some(span.into()),
533            }
534        }
535        crate::compile_time::Value::Bool(value) => hir::SettingsNode::Bool {
536            name,
537            value,
538            span: Some(span.into()),
539        },
540        crate::compile_time::Value::Array(values) => hir::SettingsNode::List {
541            name,
542            elements: values
543                .into_iter()
544                .map(|value| {
545                    let value = display_value(&value)?;
546                    Ok(hir::SettingsListElement {
547                        evaluated: Some(value.clone()),
548                        value,
549                        span: Some(span.into()),
550                    })
551                })
552                .collect::<Result<Vec<_>, String>>()
553                .map_err(|message| settings_expression_error(span, message))?,
554            span: Some(span.into()),
555        },
556        crate::compile_time::Value::Object(entries) => hir::SettingsNode::Group {
557            name,
558            children: entries
559                .into_iter()
560                .map(|(key, value)| {
561                    let crate::compile_time::Value::String(name) = key else {
562                        return Err(settings_expression_error(
563                            span,
564                            "compile-time object keys must be strings".to_string(),
565                        ));
566                    };
567                    node_from_value(name, value, span)
568                })
569                .collect::<OpyResult<Vec<_>>>()?,
570            span: Some(span.into()),
571        },
572        // A `vect` AST has no settings object form upstream — the
573        // reference reports it unhandled (#512).
574        crate::compile_time::Value::Vector(_) | crate::compile_time::Value::Null => {
575            return Err(settings_expression_error(
576                span,
577                "compile-time value is not a settings value".to_string(),
578            ));
579        }
580    };
581    Ok(node)
582}
583
584fn display_value(value: &crate::compile_time::Value) -> Result<String, String> {
585    match value {
586        // `String(value)` writes `NaN`/`Infinity` literally, as the pinned
587        // evaluator does for `%` results and unparsed number tokens (#512).
588        crate::compile_time::Value::Number(value) => {
589            Ok(crate::compiler::number_format::javascript_text(*value))
590        }
591        crate::compile_time::Value::String(value) => {
592            if crate::compile_time::has_unbound_formatters(value) {
593                return Err(format!("settings string '{value}' has format arguments"));
594            }
595            Ok(value.clone())
596        }
597        crate::compile_time::Value::Bool(value) => Ok(value.to_string()),
598        // JavaScript `String(value)` on containers, as the pinned evaluator
599        // writes them: arrays join their element text with commas, objects
600        // string-ify to the literal `[object Object]` (#512).
601        crate::compile_time::Value::Array(values) => values
602            .iter()
603            .map(display_value)
604            .collect::<Result<Vec<_>, _>>()
605            .map(|text| text.join(",")),
606        crate::compile_time::Value::Object(_) => Ok("[object Object]".to_string()),
607        crate::compile_time::Value::Vector(_) | crate::compile_time::Value::Null => {
608            Err("compile-time value is not a settings value".to_string())
609        }
610    }
611}
612
613/// The number a settings list element spells when the pinned tokenizer
614/// reads it as one number token. The reference's word characters are
615/// `[A-Za-z0-9_@$]` (no `.` — `1.5` is `1 . 5` merged by the parser, not a
616/// token), so a digit-led word like `1_000`, `12abc`, `0o18`, or `5e` is
617/// one number token that `Number` converts to `NaN`. `0x` is the one
618/// lenient prefix: the reference rewrites it through `parseInt(name, 16)`
619/// which reads a hex *prefix*, so `0x1FZ` is 31 while `0x`/`0xZZ` are
620/// `NaN`. `0X`/`0o`/`0b` go through `Number` — all digits must be valid
621/// (`0o17` is 15, `0o18` and `0X1FZ` are `NaN`; #512).
622/// `None` when the element is not such a token (a dot, a sign, or a signed
623/// exponent like `1e-7` are separate tokens upstream and take the
624/// expression path).
625fn js_number_value(text: &str) -> Option<f64> {
626    if !text.starts_with(|c: char| c.is_ascii_digit())
627        || !text
628            .chars()
629            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '@' | '$'))
630    {
631        return None;
632    }
633    // `parseInt(name, 16)`: reads the hex prefix; `NaN` without digits.
634    if let Some(digits) = text.strip_prefix("0x") {
635        let mut value = 0f64;
636        let mut seen = false;
637        for c in digits.chars() {
638            let Some(digit) = c.to_digit(16) else { break };
639            value = value * 16.0 + digit as f64;
640            seen = true;
641        }
642        return Some(if seen { value } else { f64::NAN });
643    }
644    let strict_radix = text
645        .strip_prefix("0X")
646        .map(|digits| (digits, 16))
647        .or_else(|| {
648            text.strip_prefix("0o")
649                .or_else(|| text.strip_prefix("0O"))
650                .map(|digits| (digits, 8))
651        })
652        .or_else(|| {
653            text.strip_prefix("0b")
654                .or_else(|| text.strip_prefix("0B"))
655                .map(|digits| (digits, 2))
656        });
657    if let Some((digits, radix)) = strict_radix {
658        return Some(
659            (!digits.is_empty())
660                .then(|| {
661                    digits.chars().try_fold(0f64, |value, c| {
662                        c.to_digit(radix)
663                            .map(|digit| value * radix as f64 + digit as f64)
664                    })
665                })
666                .flatten()
667                .unwrap_or(f64::NAN),
668        );
669    }
670    // Whole-token `Number(text)`: unparseable tails yield `NaN`.
671    Some(text.parse::<f64>().unwrap_or(f64::NAN))
672}
673
674struct Cursor<'a> {
675    text: &'a str,
676    pos: usize,
677    char_pos: usize,
678    line: u32,
679    col: u32,
680    file: u32,
681}
682
683impl Cursor<'_> {
684    fn new(text: &str, file: u32, origin: Position) -> Cursor<'_> {
685        Cursor {
686            text,
687            pos: 0,
688            char_pos: 0,
689            line: origin.line,
690            col: origin.col,
691            file,
692        }
693    }
694
695    fn peek(&self) -> Option<char> {
696        self.peek_at(0)
697    }
698
699    fn peek_at(&self, ahead: usize) -> Option<char> {
700        self.text[self.pos..].chars().nth(ahead)
701    }
702
703    fn here(&self) -> Position {
704        Position::new(self.line, self.col)
705    }
706
707    fn here_after(&self, n: usize) -> Position {
708        let mut line = self.line;
709        let mut col = self.col;
710        for ch in self.text[self.pos..].chars().take(n) {
711            if ch == '\n' {
712                line += 1;
713                col = 1;
714            } else {
715                col += 1;
716            }
717        }
718        Position::new(line, col)
719    }
720
721    fn advance(&mut self) -> Option<char> {
722        let ch = self.peek()?;
723        if ch == '\n' {
724            self.line += 1;
725            self.col = 1;
726        } else {
727            self.col += 1;
728        }
729        self.pos += ch.len_utf8();
730        self.char_pos += 1;
731        Some(ch)
732    }
733
734    fn advance_by(&mut self, n: usize) {
735        for _ in 0..n {
736            if self.advance().is_none() {
737                return;
738            }
739        }
740    }
741
742    fn advance_quoted(&mut self, quote: &mut Option<char>, escaped: &mut bool, ch: char) -> bool {
743        let Some(quote_char) = *quote else {
744            return false;
745        };
746        if *escaped {
747            *escaped = false;
748        } else if ch == '\\' {
749            *escaped = true;
750        } else if ch == quote_char {
751            *quote = None;
752        }
753        self.advance_by(1);
754        true
755    }
756
757    fn skip_to_eol(&mut self) {
758        while self.peek().is_some_and(|ch| ch != '\n') {
759            self.advance_by(1);
760        }
761    }
762
763    fn skip_horizontal_whitespace(&mut self) {
764        while matches!(self.peek(), Some(' ' | '\t' | '\r')) {
765            self.advance_by(1);
766        }
767    }
768
769    fn read_word(&mut self) -> String {
770        let mut word = String::new();
771        while let Some(ch) = self.peek() {
772            if !is_ident_continue(ch) {
773                break;
774            }
775            word.push(ch);
776            self.advance_by(1);
777        }
778        word
779    }
780}
781
782/// Primitive JSONC values before classification as typed settings nodes.
783enum ScalarValue {
784    String(String),
785    Bool(bool),
786    Number(f64),
787}
788
789impl Cursor<'_> {
790    fn skip_whitespace(&mut self) {
791        loop {
792            while let Some(ch) = self.peek() {
793                if ch.is_whitespace() {
794                    self.advance();
795                } else {
796                    break;
797                }
798            }
799            if self.peek() == Some('#') {
800                while self.peek().is_some_and(|ch| ch != '\n') {
801                    self.advance();
802                }
803                continue;
804            }
805            if self.text[self.pos..].starts_with("//") {
806                self.advance();
807                self.advance();
808                while self.peek().is_some_and(|ch| ch != '\n') {
809                    self.advance();
810                }
811                continue;
812            }
813            if self.text[self.pos..].starts_with("/*") {
814                self.advance();
815                self.advance();
816                while self.peek().is_some() && !self.text[self.pos..].starts_with("*/") {
817                    self.advance();
818                }
819                if self.text[self.pos..].starts_with("*/") {
820                    self.advance();
821                    self.advance();
822                }
823                continue;
824            }
825            break;
826        }
827    }
828
829    fn error(&self, code: &str, message: String) -> OpyError {
830        OpyError::at(
831            code,
832            message,
833            Span::new(self.file, self.here(), self.here()),
834        )
835    }
836
837    fn error_at(&self, code: &str, message: String, span: Span) -> OpyError {
838        OpyError::at(code, message, span)
839    }
840
841    fn parse_object(&mut self) -> OpyResult<(Vec<cst::SettingsNode>, Span)> {
842        let open = self.here();
843        if self.advance() != Some('{') {
844            return Err(self.error(
845                "settings-invalid",
846                "settings block must be a JSONC object".to_string(),
847            ));
848        }
849        let members = self.parse_members(false)?;
850        let span = Span::new(self.file, open, self.here());
851        Ok((members, span))
852    }
853
854    /// Parse `key: value, ...` members. `root` is true when the enclosing
855    /// object's braces are the settings block's own braces (the text runs to
856    /// the end of the block, and a trailing comma before it is allowed).
857    fn parse_members(&mut self, root: bool) -> OpyResult<Vec<cst::SettingsNode>> {
858        let mut nodes = Vec::new();
859        let mut names = Vec::new();
860        self.skip_whitespace();
861        if (!root && self.peek() == Some('}')) || (root && self.pos >= self.text.len()) {
862            if !root {
863                self.advance();
864            }
865            return Ok(nodes);
866        }
867        loop {
868            self.skip_whitespace();
869            let key_start = self.here();
870            let key = match self.parse_string_value() {
871                Some(value) => value,
872                None => {
873                    return Err(self.error(
874                        "settings-invalid",
875                        "settings keys must be quoted strings".to_string(),
876                    ));
877                }
878            };
879            let key_span = Span::new(self.file, key_start, self.here());
880            if names.contains(&key) {
881                return Err(self.error_at(
882                    "settings-invalid",
883                    format!("duplicate settings key '{key}'"),
884                    key_span,
885                ));
886            }
887            names.push(key.clone());
888            self.skip_whitespace();
889            if self.advance() != Some(':') {
890                return Err(self.error_at(
891                    "settings-invalid",
892                    format!("expected ':' after settings key '{key}'"),
893                    key_span,
894                ));
895            }
896            self.skip_whitespace();
897            let (node, value_end) = self.parse_value()?;
898            let node = build_node(key, node, value_end, key_start, self.file);
899            nodes.push(node);
900            self.skip_whitespace();
901            match self.peek() {
902                Some(',') => {
903                    self.advance();
904                    self.skip_whitespace();
905                    if (!root && self.peek() == Some('}')) || (root && self.pos >= self.text.len())
906                    {
907                        if !root {
908                            self.advance();
909                        }
910                        return Ok(nodes);
911                    }
912                }
913                Some('}') if !root => {
914                    self.advance();
915                    return Ok(nodes);
916                }
917                None if root => return Ok(nodes),
918                _ => {
919                    return Err(self.error(
920                        "settings-invalid",
921                        "expected ',' or '}' in settings object".to_string(),
922                    ));
923                }
924            }
925        }
926    }
927
928    /// Parse one value; returns the built node (name placeholder) and the
929    /// position after it. A scalar literal, list, or group that is followed
930    /// by anything but a member terminator is re-read as one expression —
931    /// the pinned evaluator compiles each value as a single expression, so
932    /// `"a".charAt(0)`, `[1,2].last()`, `1 in [3]`, and word tails like
933    /// `1_000` all evaluate there rather than splitting (#512).
934    fn parse_value(&mut self) -> OpyResult<(cst::SettingsNode, Position)> {
935        let start = self.here();
936        let ch = self.peek();
937        let saved = (self.pos, self.char_pos, self.line, self.col);
938        let scalar = match ch {
939            Some('"') | Some('\'') => {
940                let value = self.parse_string_expression().ok_or_else(|| {
941                    self.error(
942                        "settings-invalid",
943                        "unterminated string in settings value".to_string(),
944                    )
945                })?;
946                Some(ScalarValue::String(value))
947            }
948            Some('t') => {
949                self.expect_word("true")?;
950                Some(ScalarValue::Bool(true))
951            }
952            Some('f') => {
953                self.expect_word("false")?;
954                Some(ScalarValue::Bool(false))
955            }
956            Some(c) if c.is_ascii_digit() => Some(ScalarValue::Number(self.parse_number()?)),
957            // `-` starts a number only before a digit; `-(1+2)` and
958            // `-Infinity` are expressions upstream.
959            Some('-') if self.peek_at(1).is_some_and(|c| c.is_ascii_digit()) => {
960                Some(ScalarValue::Number(self.parse_number()?))
961            }
962            _ => None,
963        };
964        let node = if let Some(scalar) = scalar {
965            if !self.is_expression_continuation() {
966                let span = Span::new(self.file, start, self.here());
967                match scalar {
968                    ScalarValue::String(value) => cst::SettingsNode::String {
969                        name: String::new(),
970                        value,
971                        span,
972                    },
973                    ScalarValue::Bool(value) => cst::SettingsNode::Bool {
974                        name: String::new(),
975                        value,
976                        span,
977                    },
978                    ScalarValue::Number(value) => cst::SettingsNode::Number {
979                        name: String::new(),
980                        value,
981                        span,
982                    },
983                }
984            } else {
985                self.pos = saved.0;
986                self.char_pos = saved.1;
987                self.line = saved.2;
988                self.col = saved.3;
989                cst::SettingsNode::Raw {
990                    name: String::new(),
991                    value: self.parse_expression_value(),
992                    span: Span::new(self.file, start, self.here()),
993                }
994            }
995        } else {
996            match ch {
997                Some('[') => {
998                    let elements = self.parse_list()?;
999                    if self.is_expression_continuation() {
1000                        self.pos = saved.0;
1001                        self.char_pos = saved.1;
1002                        self.line = saved.2;
1003                        self.col = saved.3;
1004                        cst::SettingsNode::Raw {
1005                            name: String::new(),
1006                            value: self.parse_expression_value(),
1007                            span: Span::new(self.file, start, self.here()),
1008                        }
1009                    } else {
1010                        cst::SettingsNode::List {
1011                            name: String::new(),
1012                            elements,
1013                            span: Span::new(self.file, start, self.here()),
1014                        }
1015                    }
1016                }
1017                Some('{') => {
1018                    let (children, _) = self.parse_object()?;
1019                    if self.is_expression_continuation() {
1020                        self.pos = saved.0;
1021                        self.char_pos = saved.1;
1022                        self.line = saved.2;
1023                        self.col = saved.3;
1024                        cst::SettingsNode::Raw {
1025                            name: String::new(),
1026                            value: self.parse_expression_value(),
1027                            span: Span::new(self.file, start, self.here()),
1028                        }
1029                    } else {
1030                        cst::SettingsNode::Group {
1031                            name: String::new(),
1032                            children,
1033                            span: Span::new(self.file, start, self.here()),
1034                        }
1035                    }
1036                }
1037                _ => {
1038                    let value = self.parse_expression_value();
1039                    if value.trim().is_empty() {
1040                        return Err(self.error(
1041                            "settings-invalid",
1042                            "expected a value in settings block".to_string(),
1043                        ));
1044                    }
1045                    cst::SettingsNode::Raw {
1046                        name: String::new(),
1047                        value,
1048                        span: Span::new(self.file, start, self.here()),
1049                    }
1050                }
1051            }
1052        };
1053        let end = self.here();
1054        Ok((node, end))
1055    }
1056
1057    /// Preserve an unquoted setting expression as an opaque value. Commas and
1058    /// closing braces delimit it only outside nested expression delimiters and
1059    /// string literals.
1060    fn parse_expression_value(&mut self) -> String {
1061        let mut value = String::new();
1062        let mut depth = 0usize;
1063        let mut quote = None;
1064        while let Some(ch) = self.peek() {
1065            if let Some(expected) = quote {
1066                value.push(self.advance().expect("peeked character exists"));
1067                if ch == expected {
1068                    quote = None;
1069                }
1070                continue;
1071            }
1072            match ch {
1073                '"' | '\'' => {
1074                    quote = Some(ch);
1075                    value.push(self.advance().expect("peeked character exists"));
1076                }
1077                '(' | '[' | '{' => {
1078                    depth += 1;
1079                    value.push(self.advance().expect("peeked character exists"));
1080                }
1081                ']' | ',' | '}' if depth == 0 => break,
1082                ')' | ']' | '}' => {
1083                    depth = depth.saturating_sub(1);
1084                    value.push(self.advance().expect("peeked character exists"));
1085                }
1086                _ => value.push(self.advance().expect("peeked character exists")),
1087            }
1088        }
1089        value.trim().to_string()
1090    }
1091
1092    fn expect_word(&mut self, word: &str) -> OpyResult<()> {
1093        let start = self.here();
1094        for expected in word.chars() {
1095            if self.advance() != Some(expected) {
1096                return Err(self.error_at(
1097                    "settings-invalid",
1098                    format!("expected '{word}' in settings block"),
1099                    Span::new(self.file, start, self.here()),
1100                ));
1101            }
1102        }
1103        Ok(())
1104    }
1105
1106    /// Read one settings number literal the way the pinned OverPy tokenizer
1107    /// admits it: decimal with an optional unsigned exponent (`1e21`,
1108    /// `0.5e3`), and `0x`/`0o`/`0b` integer radixes (`0x1F` is 31; #496).
1109    /// The read stops where the reference's word token would split off an
1110    /// operator: a `.` needs digits on both sides (`1.5` joins, `2.`/`1.e5`
1111    /// split at the dot), and an exponent marker needs a fraction digit
1112    /// before it plus an unsigned digit run after it (`1e-7` keeps its `e`
1113    /// for the expression path). A radix prefix without digits reads as
1114    /// `NaN` (`0x`, like `parseInt("0x", 16)`), and any leftover word tail
1115    /// (`0x1FZ`, `1_000`) makes the whole value an expression through the
1116    /// continuation check in `parse_value` (#512).
1117    fn parse_number(&mut self) -> OpyResult<f64> {
1118        let start = self.here();
1119        let mut text = String::new();
1120        if self.peek() == Some('-') {
1121            text.push(self.advance().unwrap());
1122        }
1123        if self.peek() == Some('0')
1124            && matches!(self.peek_at(1), Some('x' | 'X' | 'o' | 'O' | 'b' | 'B'))
1125        {
1126            text.push(self.advance().unwrap());
1127            let letter = self.advance().expect("radix letter is peeked");
1128            text.push(letter);
1129            let radix = match letter {
1130                'x' | 'X' => 16,
1131                'o' | 'O' => 8,
1132                _ => 2,
1133            };
1134            let mut value = 0f64;
1135            let mut digits = 0usize;
1136            while let Some(c) = self.peek() {
1137                let Some(digit) = c.to_digit(radix) else {
1138                    break;
1139                };
1140                value = value * radix as f64 + digit as f64;
1141                digits += 1;
1142                text.push(self.advance().unwrap());
1143            }
1144            // `Number("0x")`/`parseInt("0x", 16)` is `NaN`; a word tail is
1145            // left for `is_expression_continuation` to send to the
1146            // expression path (`0x1FZ` is 31 there, `0xZZ` is `NaN`; #512).
1147            let value = if digits == 0 { f64::NAN } else { value };
1148            return Ok(if text.starts_with('-') { -value } else { value });
1149        }
1150        while let Some(c) = self.peek() {
1151            if c.is_ascii_digit() {
1152                text.push(self.advance().unwrap());
1153            } else {
1154                break;
1155            }
1156        }
1157        // `.` joins the literal only between digits — `1.5` is one number
1158        // upstream while `2.` and `-.5` split the dot into member access.
1159        if self.peek() == Some('.')
1160            && text.chars().any(|c| c.is_ascii_digit())
1161            && self.peek_at(1).is_some_and(|c| c.is_ascii_digit())
1162        {
1163            text.push(self.advance().unwrap());
1164            while let Some(c) = self.peek() {
1165                if c.is_ascii_digit() {
1166                    text.push(self.advance().unwrap());
1167                } else {
1168                    break;
1169                }
1170            }
1171        }
1172        if matches!(self.peek(), Some('e' | 'E'))
1173            && self.peek_at(1).is_some_and(|c| c.is_ascii_digit())
1174        {
1175            text.push(self.advance().unwrap());
1176            while let Some(c) = self.peek() {
1177                if c.is_ascii_digit() {
1178                    text.push(self.advance().unwrap());
1179                } else {
1180                    break;
1181                }
1182            }
1183        }
1184        text.parse::<f64>().map_err(|_| {
1185            self.error_at(
1186                "settings-invalid",
1187                format!("invalid number '{text}' in settings block"),
1188                Span::new(self.file, start, self.here()),
1189            )
1190        })
1191    }
1192
1193    fn parse_list(&mut self) -> OpyResult<Vec<cst::SettingsListElement>> {
1194        self.advance(); // '['
1195        let mut elements = Vec::new();
1196        self.skip_whitespace();
1197        if self.peek() == Some(']') {
1198            self.advance();
1199            return Ok(elements);
1200        }
1201        loop {
1202            self.skip_whitespace();
1203            let start = self.here();
1204            let (value, expr) = match self.peek() {
1205                Some(']') | Some(',') | None => {
1206                    return Err(self.error(
1207                        "settings-invalid",
1208                        "settings list elements must not be empty".to_string(),
1209                    ));
1210                }
1211                _ => {
1212                    let text = self.parse_expression_value();
1213                    match js_number_value(&text) {
1214                        // A bare list element that is a number token writes
1215                        // its `String(value)` form (`[1e21]` emits `1e+21`,
1216                        // `[1_000]` emits `NaN`; #496, #512).
1217                        Some(number) => (
1218                            crate::compiler::number_format::javascript_text(number),
1219                            false,
1220                        ),
1221                        // Every other element — quoted strings included —
1222                        // is an expression the pinned settings evaluator
1223                        // resolves or rejects (`"a"=="a"` emits `true`;
1224                        // #512).
1225                        None => (text, true),
1226                    }
1227                }
1228            };
1229            let span = Span::new(self.file, start, self.here());
1230            elements.push(cst::SettingsListElement { value, expr, span });
1231            self.skip_whitespace();
1232            match self.peek() {
1233                Some(',') => {
1234                    self.advance();
1235                    self.skip_whitespace();
1236                    if self.peek() == Some(']') {
1237                        self.advance();
1238                        return Ok(elements);
1239                    }
1240                }
1241                Some(']') => {
1242                    self.advance();
1243                    return Ok(elements);
1244                }
1245                _ => {
1246                    return Err(self.error(
1247                        "settings-invalid",
1248                        "expected ',' or ']' in settings list".to_string(),
1249                    ));
1250                }
1251            }
1252        }
1253    }
1254
1255    /// Parse a quoted string value; `None` when no string is here or the
1256    /// string is unterminated before end-of-line.
1257    fn parse_string_value(&mut self) -> Option<String> {
1258        let quote = self.peek()?;
1259        if quote != '"' && quote != '\'' {
1260            return None;
1261        }
1262        self.advance();
1263        let mut value = String::new();
1264        loop {
1265            let ch = self.advance()?;
1266            if ch == '\n' {
1267                return None;
1268            }
1269            if ch == quote {
1270                return Some(value);
1271            }
1272            if ch == '\\' {
1273                let escaped = self.advance()?;
1274                value.push(decode_string_escape(escaped));
1275            } else {
1276                value.push(ch);
1277            }
1278        }
1279    }
1280
1281    /// Parse adjacent quoted strings as one compile-time string value. This
1282    /// is the settings equivalent of OPY's implicit string concatenation and
1283    /// is used after macro expansion has turned identifiers into literals.
1284    fn parse_string_expression(&mut self) -> Option<String> {
1285        let mut value = self.parse_string_value()?;
1286        loop {
1287            let saved = (self.pos, self.char_pos, self.line, self.col);
1288            self.skip_whitespace();
1289            if self.peek() != Some('"') && self.peek() != Some('\'') {
1290                self.pos = saved.0;
1291                self.char_pos = saved.1;
1292                self.line = saved.2;
1293                self.col = saved.3;
1294                break;
1295            }
1296            let next = self.parse_string_value()?;
1297            value.push_str(&next);
1298        }
1299        Some(value)
1300    }
1301
1302    /// Whether anything but a member terminator (`,`, `}`, or the end of
1303    /// the block) follows the value just parsed. The pinned evaluator
1304    /// compiles each settings value as one expression, so a suffix like
1305    /// `.charAt(0)`, `[0]`, `in [3]`, `and 2`, or a word tail (`1_000`,
1306    /// `0x1FZ`) means the value is an expression, not the parsed literal
1307    /// (#512). Lookahead only; does not move the cursor.
1308    fn is_expression_continuation(&self) -> bool {
1309        self.text[self.pos..]
1310            .trim_start()
1311            .chars()
1312            .next()
1313            .is_some_and(|c| c != ',' && c != '}')
1314    }
1315}
1316
1317/// Attach the key name and a key..value span to a parsed value node.
1318fn build_node(
1319    key: String,
1320    node: cst::SettingsNode,
1321    value_end: Position,
1322    key_start: Position,
1323    file: u32,
1324) -> cst::SettingsNode {
1325    let span = Span::new(file, key_start, value_end);
1326    match node {
1327        cst::SettingsNode::Group { children, .. } => cst::SettingsNode::Group {
1328            name: key,
1329            children,
1330            span,
1331        },
1332        cst::SettingsNode::Number { value, .. } => cst::SettingsNode::Number {
1333            name: key,
1334            value,
1335            span,
1336        },
1337        cst::SettingsNode::Bool { value, .. } => cst::SettingsNode::Bool {
1338            name: key,
1339            value,
1340            span,
1341        },
1342        cst::SettingsNode::String { value, .. } => cst::SettingsNode::String {
1343            name: key,
1344            value,
1345            span,
1346        },
1347        cst::SettingsNode::Raw { value, .. } => cst::SettingsNode::Raw {
1348            name: key,
1349            value,
1350            span,
1351        },
1352        cst::SettingsNode::List { elements, .. } => cst::SettingsNode::List {
1353            name: key,
1354            elements,
1355            span,
1356        },
1357    }
1358}
1359
1360#[cfg(test)]
1361mod tests {
1362    use super::*;
1363
1364    fn block(text: &str) -> SettingsBlock {
1365        let mut blocks = find_blocks(text, 0).unwrap();
1366        assert_eq!(blocks.len(), 1, "one block expected in: {text}");
1367        blocks.pop().unwrap()
1368    }
1369
1370    #[test]
1371    fn finds_block_after_comments_and_blanks() {
1372        let text = "/* header */\n# comment\n\nsettings {\n    \"gamemodes\": {}\n}\nrule \"r\":\n";
1373        let found = block(text);
1374        assert!(found.text.contains("gamemodes"));
1375        assert_eq!(found.keyword_span.start.line, 4);
1376        assert_eq!(found.span.end.line, 6);
1377    }
1378
1379    #[test]
1380    fn no_blocks_for_plain_program() {
1381        assert!(
1382            find_blocks("rule \"r\":\n    pass\n", 0)
1383                .unwrap()
1384                .is_empty()
1385        );
1386    }
1387
1388    #[test]
1389    fn ignores_settings_words_in_strings_and_indented_code() {
1390        let text = "rule \"settings\":\n    x = \"settings\"\n";
1391        assert!(find_blocks(text, 0).unwrap().is_empty());
1392    }
1393
1394    #[test]
1395    fn settings_after_constructs_is_extracted() {
1396        // The pinned reference accepts `settings` as the first token of any
1397        // logical line, with no ordering or indentation requirement (#513).
1398        let text = "playervar score\n\nrule \"r\":\n    pass\n\nsettings {\n}\n";
1399        let found = block(text);
1400        assert_eq!(found.keyword_span.start.line, 6);
1401
1402        // An indented `settings` inside a block body is still the statement.
1403        let found = block("rule \"r\":\n    pass\n    settings {\n    }\n");
1404        assert_eq!(found.keyword_span.start.line, 3);
1405    }
1406
1407    #[test]
1408    fn settings_inside_brackets_is_an_ordinary_token() {
1409        // Inside a bracketed expression a `settings` line start does not open
1410        // a block; the text reaches the lexer and the parser rejects it.
1411        let text = "globalvar g = [\n    1,\nsettings]\n";
1412        assert!(find_blocks(text, 0).unwrap().is_empty());
1413
1414        // A `settings` mid-line after a non-colon token is not a statement.
1415        assert!(
1416            find_blocks("globalvar g = settings {}\n", 0)
1417                .unwrap()
1418                .is_empty()
1419        );
1420    }
1421
1422    #[test]
1423    fn settings_in_an_inline_tail_is_extracted() {
1424        // The reference parses the `:` tail of a header line as a nested
1425        // logical line, so its first token is a statement position (#513).
1426        let found = block("rule \"r\":\n    pass\n\nrule \"t\": settings {\n}\n");
1427        assert_eq!(found.keyword_span.start.line, 4);
1428    }
1429
1430    #[test]
1431    fn settings_inside_an_enum_body_is_extracted() {
1432        // Recorded divergence: the reference absorbs a `settings` line inside
1433        // an enum body as a member named `settings`; we extract it as the
1434        // settings block (see docs/architecture/language-core.md).
1435        let found = block("enum E:\n    A\n    settings {\n    }\n");
1436        assert_eq!(found.keyword_span.start.line, 3);
1437    }
1438
1439    #[test]
1440    fn second_block_is_placement_error() {
1441        let error =
1442            find_blocks("settings {\n    \"gamemodes\": {}\n}\nsettings {\n}\n", 0).unwrap_err();
1443        assert_eq!(error.code, "settings-placement");
1444        assert_eq!(error.span.unwrap().start.line, 4);
1445    }
1446
1447    #[test]
1448    fn settings_file_form_is_extracted() {
1449        let found = block("settings \"file.opy.json\"\n");
1450        assert_eq!(found.external_path.as_deref(), Some("file.opy.json"));
1451    }
1452
1453    #[test]
1454    fn unterminated_block_is_invalid() {
1455        let error = find_blocks("settings {\n    \"gamemodes\": {\n", 0).unwrap_err();
1456        assert_eq!(error.code, "settings-invalid");
1457    }
1458
1459    #[test]
1460    fn braces_inside_strings_do_not_unbalance() {
1461        let found =
1462            block("settings {\n    \"description\": \"a { b }\",\n    \"gamemodes\": {}\n}\n");
1463        assert!(found.text.contains("a { b }"));
1464    }
1465
1466    #[test]
1467    fn sanitize_preserves_post_block_positions() {
1468        let text = "settings {\n    \"gamemodes\": {}\n}\nrule \"r\":\n    pass\n";
1469        let found = block(text);
1470        let sanitized = sanitize_for_lex(text, &found);
1471        let lines: Vec<&str> = sanitized.lines().collect();
1472        assert_eq!(lines.len(), 5);
1473        assert_eq!(lines[3], "rule \"r\":");
1474        assert_eq!(lines[4], "    pass");
1475        // The rule keyword is at the same char offset as in the original.
1476        assert_eq!(sanitized.find("rule"), text.find("rule"));
1477    }
1478
1479    #[test]
1480    fn unicode_preserves_offsets_and_sanitization() {
1481        let text = "# 前置🙂\nsettings {\n    \"main\": { \"description\": \"内🙂\" },\n    \"gamemodes\": {}\n}\nrule \"后🙂\":\n    pass\n";
1482        let found = block(text);
1483        let keyword_byte_offset = text.find("settings").unwrap();
1484        let closing_byte_offset = text.find("}\nrule").unwrap();
1485        assert_eq!(found.start, text[..keyword_byte_offset].chars().count());
1486        assert_eq!(
1487            found.end,
1488            text[..closing_byte_offset + '}'.len_utf8()].chars().count()
1489        );
1490        assert_eq!(found.keyword_span.start, Position::new(2, 1));
1491        assert_eq!(found.text_start, Position::new(2, 11));
1492        assert!(found.text.contains("内🙂"));
1493
1494        let sanitized = sanitize_for_lex(text, &found);
1495        assert_eq!(sanitized.chars().count(), text.chars().count());
1496        let rule_char_offset = |source: &str| {
1497            let byte_offset = source.find("rule").unwrap();
1498            source[..byte_offset].chars().count()
1499        };
1500        assert_eq!(rule_char_offset(&sanitized), rule_char_offset(text));
1501        assert!(sanitized.contains("rule \"后🙂\":"));
1502    }
1503
1504    #[test]
1505    fn non_object_after_settings_is_invalid() {
1506        // `settings [..]` is rejected at extraction (a `{` is required).
1507        let error = find_blocks("settings [1, 2]\n", 0).unwrap_err();
1508        assert_eq!(error.code, "settings-invalid");
1509    }
1510
1511    #[test]
1512    fn parse_block_rejects_missing_gamemodes() {
1513        let found = block("settings {\n    \"main\": { \"description\": \"x\" }\n}\n");
1514        let error = parse_block(&found).unwrap_err();
1515        assert_eq!(error.code, "settings-invalid");
1516        assert!(error.message.contains("gamemodes"));
1517    }
1518
1519    #[test]
1520    fn parse_block_rejects_duplicate_keys() {
1521        let found = block("settings {\n    \"gamemodes\": {},\n    \"gamemodes\": {}\n}\n");
1522        let error = parse_block(&found).unwrap_err();
1523        assert_eq!(error.code, "settings-invalid");
1524        assert!(error.message.contains("duplicate"));
1525    }
1526
1527    #[test]
1528    fn parse_block_accepts_trailing_commas() {
1529        let found = block(
1530            "settings {\n    \"gamemodes\": {\n        \"general\": {\n            \"heroLimit\": \"off\",\n        },\n    },\n}\n",
1531        );
1532        let parsed = parse_block(&found).unwrap();
1533        assert_eq!(parsed.children.len(), 1);
1534    }
1535
1536    #[test]
1537    fn parse_block_handles_escapes_and_quotes() {
1538        let found = block(
1539            "settings {\n    \"main\": { \"description\": \"line\\n\\t\\\"quoted\\\"\" },\n    \"gamemodes\": {}\n}\n",
1540        );
1541        let parsed = parse_block(&found).unwrap();
1542        let cst::SettingsNode::Group { children, .. } = &parsed.children[0] else {
1543            panic!("main group");
1544        };
1545        let cst::SettingsNode::String { value, .. } = &children[0] else {
1546            panic!("description");
1547        };
1548        assert_eq!(value, "line\n\t\"quoted\"");
1549    }
1550
1551    #[test]
1552    fn parse_block_accepts_comments_and_expression_values() {
1553        let found = block(
1554            "settings {\n    # keep the source expression intact\n    \"lobby\": {\n        \"modeName\": GAMEMODE_NAME\" \"GAMEMODE_VERSION,\n    },\n    \"gamemodes\": {}\n}\n",
1555        );
1556        let parsed = parse_block(&found).unwrap();
1557        let cst::SettingsNode::Group { children, .. } = &parsed.children[0] else {
1558            panic!("lobby group");
1559        };
1560        let cst::SettingsNode::Raw { value, .. } = &children[0] else {
1561            panic!("expression-valued setting");
1562        };
1563        assert_eq!(value, "GAMEMODE_NAME\" \"GAMEMODE_VERSION");
1564    }
1565
1566    #[test]
1567    fn parse_block_reads_javascript_number_literals() {
1568        // The pinned OverPy tokenizer reads exponent and `0x`/`0o`/`0b`
1569        // integer forms in settings (#496).
1570        let found = block(
1571            "settings {\n    \"lobby\": { \"a\": 0x1F, \"b\": 0b101, \"c\": 0o17, \"d\": 1.5e21, \"e\": 12e2, \"f\": -0.5, \"g\": 2.5e1 },\n    \"gamemodes\": {}\n}\n",
1572        );
1573        let parsed = parse_block(&found).unwrap();
1574        let cst::SettingsNode::Group { children, .. } = &parsed.children[0] else {
1575            panic!("lobby group");
1576        };
1577        let values: Vec<f64> = children
1578            .iter()
1579            .map(|node| match node {
1580                cst::SettingsNode::Number { value, .. } => *value,
1581                other => panic!("{other:?}"),
1582            })
1583            .collect();
1584        assert_eq!(values, [31.0, 5.0, 15.0, 1.5e21, 1200.0, -0.5, 25.0]);
1585    }
1586
1587    #[test]
1588    fn parse_block_routes_signed_exponents_to_the_expression_path() {
1589        // The pinned settings tokenizer splits an exponent sign or a
1590        // fraction-less dot out as an operator, so `1e-7`, `1e+21`,
1591        // `-2.5e-3`, and `1.e5` are expressions — the parser keeps them
1592        // raw and the settings evaluator rejects them downstream (#512).
1593        for value in ["1e-7", "1e+21", "-2.5e-3", "0.5e+3", "1.e5", "0.e5"] {
1594            let found = block(&format!(
1595                "settings {{\n    \"lobby\": {{ \"k\": {value} }},\n    \"gamemodes\": {{}}\n}}\n"
1596            ));
1597            let parsed = parse_block(&found).unwrap();
1598            let cst::SettingsNode::Group { children, .. } = &parsed.children[0] else {
1599                panic!("lobby group");
1600            };
1601            let cst::SettingsNode::Raw { value: raw, .. } = &children[0] else {
1602                panic!("{value} must parse as a raw expression: {children:?}");
1603            };
1604            assert_eq!(raw, value);
1605        }
1606    }
1607
1608    #[test]
1609    fn parse_block_types_values() {
1610        let found = block(
1611            "settings {\n    \"lobby\": { \"ffaSlots\": 6 },\n    \"gamemodes\": { \"general\": { \"enableRandomHeroes\": true, \"respawnTime%\": 30, \"heroLimit\": \"off\" } },\n    \"heroes\": { \"allTeams\": { \"enabledHeroes\": [\"mei\"] } }\n}\n",
1612        );
1613        let parsed = parse_block(&found).unwrap();
1614        let lobby = match &parsed.children[0] {
1615            cst::SettingsNode::Group { name, children, .. } => {
1616                assert_eq!(name, "lobby");
1617                children
1618            }
1619            other => panic!("{other:?}"),
1620        };
1621        assert!(matches!(
1622            lobby[0],
1623            cst::SettingsNode::Number { value: 6.0, .. }
1624        ));
1625    }
1626
1627    #[test]
1628    fn spans_are_computed_from_block_base() {
1629        let text = "settings {\n    \"lobby\": {\n        \"ffaSlots\": 6\n    },\n    \"gamemodes\": {}\n}\n";
1630        let found = block(text);
1631        let parsed = parse_block(&found).unwrap();
1632        let cst::SettingsNode::Group { children, .. } = &parsed.children[0] else {
1633            panic!("lobby");
1634        };
1635        let cst::SettingsNode::Number { span, .. } = &children[0] else {
1636            panic!("ffaSlots");
1637        };
1638        assert_eq!(span.start.line, 3);
1639        assert_eq!(span.start.col, 9);
1640    }
1641
1642    #[test]
1643    fn keyword_span_carries_the_file_id() {
1644        let found = block("settings {\n    \"gamemodes\": {}\n}\n");
1645        assert_eq!(found.keyword_span.file, 0);
1646        assert_eq!(found.keyword_span.start.col, 1);
1647        assert_eq!(found.keyword_span.end.col, 9);
1648    }
1649}