Skip to main content

opy_rs/
settings.rs

1//! Scoped `settings { ... }` extraction and JSONC parsing (#86).
2//!
3//! The settings block is recognized and consumed *before* lexing, so the
4//! lexer never gains global `{`/`}` tokens (meipocalypse's dict literal keeps
5//! failing as a `lex-error`). [`find_blocks`] locates a top-of-file block in
6//! each source file with a logical-line keyword scan, [`sanitize_for_lex`]
7//! blanks the block region out of the text handed to the lexer (newlines
8//! preserved, so positions after the block are unchanged), and [`parse_block`]
9//! turns the JSONC text into a typed [`cst::Settings`] tree with source spans.
10//!
11//! The parse is value-driven: any JSONC object/leaf shape becomes a
12//! structurally generic [`cst::SettingsNode`]. Which keys exist and which
13//! leaf kinds/spellings are valid is Workshop-owned settings-schema content;
14//! that validation is `lowering-dependent` (issue #8) and lives at the
15//! Workshop integration boundary, never in a local allowlist here.
16
17use 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![])`; the first block must be the first
50/// non-comment construct (`settings-placement` otherwise); after `settings`
51/// a `{` or quoted external path is required;
52/// a second/later block is `settings-placement` at its keyword span; brace
53/// matching respects `"`/`'` strings, `\` escapes, and nesting; an
54/// unterminated block is `settings-invalid`.
55pub fn find_blocks(text: &str, file_id: u32) -> OpyResult<Vec<SettingsBlock>> {
56    let mut scanner = Cursor::new(text, file_id, Position::new(1, 1));
57    let mut blocks = Vec::new();
58    let mut in_block_comment = false;
59    let mut string_quote = None;
60    let mut escaped = false;
61    let mut seen_first_construct = false;
62    while let Some(ch) = scanner.peek() {
63        if scanner.advance_quoted(&mut string_quote, &mut escaped, ch) {
64            continue;
65        }
66        if in_block_comment {
67            if ch == '*' && scanner.peek_at(1) == Some('/') {
68                in_block_comment = false;
69                scanner.advance_by(2);
70            } else {
71                scanner.advance_by(1);
72            }
73            continue;
74        }
75        if matches!(ch, '"' | '\'') {
76            string_quote = Some(ch);
77            scanner.advance_by(1);
78            continue;
79        }
80        if ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n' {
81            scanner.advance_by(1);
82            continue;
83        }
84        if ch == '#' {
85            scanner.skip_to_eol();
86            continue;
87        }
88        if ch == '/' && scanner.peek_at(1) == Some('*') {
89            scanner.advance_by(2);
90            in_block_comment = true;
91            continue;
92        }
93        // A construct token: the first non-comment token of a logical line.
94        if is_ident_start(ch) {
95            let keyword_start = scanner.here();
96            let keyword_offset = scanner.char_pos;
97            let word = scanner.read_word();
98            if word == "settings" && keyword_start.col == 1 {
99                let keyword_span = Span::new(file_id, keyword_start, scanner.here());
100                if seen_first_construct || !blocks.is_empty() {
101                    return Err(OpyError::at(
102                        "settings-placement",
103                        "settings block must be the first construct in the file".to_string(),
104                        keyword_span,
105                    ));
106                }
107                let block = match_block(&mut scanner, keyword_start, keyword_offset, keyword_span)?;
108                blocks.push(block);
109                seen_first_construct = true;
110                continue;
111            }
112            seen_first_construct = true;
113            continue;
114        }
115        seen_first_construct = true;
116        scanner.advance_by(1);
117    }
118    Ok(blocks)
119}
120
121/// Match the braces of one `settings { ... }` block, returning the extracted
122/// block. `scanner` is positioned just past the `settings` keyword.
123fn match_block(
124    scanner: &mut Cursor<'_>,
125    keyword_start: Position,
126    keyword_offset: usize,
127    keyword_span: Span,
128) -> OpyResult<SettingsBlock> {
129    scanner.skip_horizontal_whitespace();
130    if scanner.peek() != Some('{') {
131        let Some(quote) = scanner.peek().filter(|ch| matches!(ch, '"' | '\'')) else {
132            return Err(OpyError::at(
133                "settings-invalid",
134                "settings block must be a `settings { ... }` block or `settings \"file\"`",
135                keyword_span,
136            ));
137        };
138        scanner.advance_by(1);
139        let mut path = String::new();
140        let mut escaped = false;
141        loop {
142            let Some(ch) = scanner.peek() else {
143                return Err(OpyError::at(
144                    "settings-invalid",
145                    "unterminated external settings path",
146                    keyword_span,
147                ));
148            };
149            scanner.advance_by(1);
150            if escaped {
151                path.push(decode_string_escape(ch));
152                escaped = false;
153            } else if ch == '\\' {
154                escaped = true;
155            } else if ch == quote {
156                break;
157            } else if ch == '\n' {
158                return Err(OpyError::at(
159                    "settings-invalid",
160                    "external settings path must be one quoted string",
161                    keyword_span,
162                ));
163            } else {
164                path.push(ch);
165            }
166        }
167        scanner.skip_horizontal_whitespace();
168        if scanner.peek() == Some('#') {
169            scanner.skip_to_eol();
170        }
171        if !matches!(scanner.peek(), None | Some('\n')) {
172            return Err(OpyError::at(
173                "settings-invalid",
174                "external settings declaration has unexpected trailing content",
175                keyword_span,
176            ));
177        }
178        return Ok(SettingsBlock {
179            text: String::new(),
180            span: Span::new(keyword_span.file, keyword_start, scanner.here()),
181            keyword_span,
182            start: keyword_offset,
183            end: scanner.char_pos,
184            text_start: keyword_start,
185            content_file: keyword_span.file,
186            content_span: keyword_span,
187            external_path: Some(path),
188        });
189    }
190    let mut depth = 0usize;
191    let mut string_quote: Option<char> = None;
192    let mut escaped = false;
193    let mut text_start_offset = None;
194    let mut text_start = None;
195    loop {
196        let Some(ch) = scanner.peek() else {
197            return Err(OpyError::at(
198                "settings-invalid",
199                "unterminated settings block (missing closing brace)".to_string(),
200                keyword_span,
201            ));
202        };
203        if scanner.advance_quoted(&mut string_quote, &mut escaped, ch) {
204            continue;
205        }
206        match ch {
207            '"' | '\'' => string_quote = Some(ch),
208            '{' => {
209                if depth == 0 {
210                    text_start_offset = Some(scanner.pos + ch.len_utf8());
211                    text_start = Some(scanner.here_after(1));
212                }
213                depth += 1;
214            }
215            '}' => {
216                depth -= 1;
217                if depth == 0 {
218                    let text = scanner
219                        .text
220                        .get(text_start_offset.expect("text start offset set on '{'")..scanner.pos)
221                        .unwrap_or_default()
222                        .to_string();
223                    return Ok(SettingsBlock {
224                        text,
225                        span: Span::new(keyword_span.file, keyword_start, scanner.here_after(1)),
226                        keyword_span,
227                        start: keyword_offset,
228                        end: scanner.char_pos + 1,
229                        text_start: text_start.expect("text start set on '{'"),
230                        content_file: keyword_span.file,
231                        content_span: Span::new(
232                            keyword_span.file,
233                            text_start.expect("text start set on '{'"),
234                            scanner.here(),
235                        ),
236                        external_path: None,
237                    });
238                }
239            }
240            _ => {}
241        }
242        scanner.advance_by(1);
243    }
244}
245
246/// Replace every char of the block region with a space, preserving newlines,
247/// so tokens after the block keep their exact original line/col.
248pub fn sanitize_for_lex(text: &str, block: &SettingsBlock) -> String {
249    let mut out = String::with_capacity(text.len());
250    for (index, ch) in text.chars().enumerate() {
251        if index >= block.start && index < block.end {
252            out.push(if ch == '\n' { '\n' } else { ' ' });
253        } else {
254            out.push(ch);
255        }
256    }
257    out
258}
259
260/// Parse a settings block's JSONC text into a typed CST settings tree.
261///
262/// Grammar: quoted keys, `"`/`'` strings with `\` escapes, int/float numbers
263/// (f64), `true`/`false`, arrays of strings, nested objects, trailing commas
264/// in objects and arrays. Rejections (`settings-invalid`): duplicate keys,
265/// non-object root, missing `gamemodes` group, malformed values.
266pub fn parse_block(block: &SettingsBlock) -> OpyResult<cst::Settings> {
267    let mut parser = Cursor::new(&block.text, block.content_file, block.text_start);
268    parser.skip_whitespace();
269    let children = if block.external_path.is_some() {
270        let (children, _) = parser.parse_object()?;
271        children
272    } else {
273        // The block's own braces delimit the root object; the text between
274        // them parses as its members.
275        parser.parse_members(true)?
276    };
277    parser.skip_whitespace();
278    if parser.pos < parser.text.len() {
279        return Err(parser.error(
280            "settings-invalid",
281            "unexpected content after the settings object".to_string(),
282        ));
283    }
284    if !children
285        .iter()
286        .any(|node| matches!(node, cst::SettingsNode::Group { name, .. } if name == "gamemodes"))
287    {
288        return Err(OpyError::at(
289            "settings-invalid",
290            "settings block must contain a gamemodes group".to_string(),
291            block.content_span,
292        ));
293    }
294    Ok(cst::Settings {
295        span: block.content_span,
296        children,
297    })
298}
299
300/// Resolve parsed settings values through the ordinary OPY HIR expression path.
301///
302/// Token-level `#!define` expansion is performed by [`crate::preprocess`]
303/// before this function runs. Source macros and constants are resolved from
304/// the same HIR program used by ordinary OPY lowering.
305pub(crate) fn resolve_hir_settings(
306    program: &mut hir::Program,
307    cst_program: &cst::Program,
308) -> OpyResult<()> {
309    let constants = program
310        .declarations
311        .iter()
312        .filter_map(|declaration| match declaration {
313            hir::Declaration::Constant { name, value, .. } => Some((name.clone(), value.as_ref())),
314            _ => None,
315        })
316        .collect::<HashMap<_, _>>();
317    let mut expander = crate::compiler::MacroExpander::from_declarations(&program.declarations);
318    let Some(settings) = program.settings.take() else {
319        return Ok(());
320    };
321    let children = settings
322        .children
323        .into_iter()
324        .map(|node| resolve_hir_node(node, cst_program, &constants, &mut expander))
325        .collect::<OpyResult<Vec<_>>>()?;
326    program.settings = Some(hir::Settings {
327        span: settings.span,
328        children,
329    });
330    Ok(())
331}
332
333fn resolve_hir_node(
334    node: hir::SettingsNode,
335    cst_program: &cst::Program,
336    constants: &HashMap<String, &hir::Expr>,
337    expander: &mut crate::compiler::MacroExpander<'_>,
338) -> OpyResult<hir::SettingsNode> {
339    match node {
340        hir::SettingsNode::Group {
341            name,
342            children,
343            span,
344        } => Ok(hir::SettingsNode::Group {
345            name,
346            children: children
347                .into_iter()
348                .map(|child| resolve_hir_node(child, cst_program, constants, expander))
349                .collect::<OpyResult<Vec<_>>>()?,
350            span,
351        }),
352        hir::SettingsNode::Raw { name, value, span } => {
353            let Some(span) = span else {
354                return Err(OpyError::new(
355                    "settings-expression",
356                    "settings value has no source span",
357                ));
358            };
359            let diag_span = hir_span_to_diag_span(span);
360            let expression = crate::lower::lower_settings_expression(
361                cst_program,
362                &value,
363                span.file,
364                Position::new(span.start.line, span.start.col),
365            )
366            .map_err(|error| settings_expression_error(diag_span, error.message))?;
367            let expression = expander
368                .expand_expr(&expression, &HashMap::new(), None)
369                .map_err(|error| {
370                    let error_span = error
371                        .diagnostic
372                        .span
373                        .map(hir_span_to_diag_span)
374                        .unwrap_or(diag_span);
375                    OpyError::at(error.diagnostic.code, error.diagnostic.message, error_span)
376                })?;
377            let mut stack = Vec::new();
378            let value =
379                crate::compile_time::evaluate(&expression, constants, &HashMap::new(), &mut stack)
380                    .ok_or_else(|| {
381                        settings_expression_error(
382                            diag_span,
383                            "expression is not a compile-time value".to_string(),
384                        )
385                    })?;
386            node_from_value(name, value, diag_span)
387        }
388        node => Ok(node),
389    }
390}
391
392fn hir_span_to_diag_span(span: hir::Span) -> Span {
393    Span::new(
394        span.file,
395        Position::new(span.start.line, span.start.col),
396        Position::new(span.end.line, span.end.col),
397    )
398}
399
400fn settings_expression_error(span: Span, message: String) -> OpyError {
401    OpyError::at(
402        "settings-expression",
403        format!("settings value is not a compile-time value: {message}"),
404        span,
405    )
406}
407
408fn node_from_value(
409    name: String,
410    value: crate::compile_time::Value,
411    span: Span,
412) -> OpyResult<hir::SettingsNode> {
413    let node = match value {
414        crate::compile_time::Value::Number(value) if value.is_finite() => {
415            hir::SettingsNode::Number {
416                name,
417                value,
418                span: Some(span.into()),
419            }
420        }
421        crate::compile_time::Value::Number(_) => {
422            return Err(settings_expression_error(
423                span,
424                "compile-time number is not finite".to_string(),
425            ));
426        }
427        crate::compile_time::Value::String(value) => hir::SettingsNode::String {
428            name,
429            value,
430            span: Some(span.into()),
431        },
432        crate::compile_time::Value::Bool(value) => hir::SettingsNode::Bool {
433            name,
434            value,
435            span: Some(span.into()),
436        },
437        crate::compile_time::Value::Array(values) => hir::SettingsNode::List {
438            name,
439            elements: values
440                .into_iter()
441                .map(|value| {
442                    Ok(hir::SettingsListElement {
443                        value: display_value(&value)?,
444                        span: Some(span.into()),
445                    })
446                })
447                .collect::<Result<Vec<_>, String>>()
448                .map_err(|message| settings_expression_error(span, message))?,
449            span: Some(span.into()),
450        },
451        crate::compile_time::Value::Object(entries) => hir::SettingsNode::Group {
452            name,
453            children: entries
454                .into_iter()
455                .map(|(key, value)| {
456                    let crate::compile_time::Value::String(name) = key else {
457                        return Err(settings_expression_error(
458                            span,
459                            "compile-time object keys must be strings".to_string(),
460                        ));
461                    };
462                    node_from_value(name, value, span)
463                })
464                .collect::<OpyResult<Vec<_>>>()?,
465            span: Some(span.into()),
466        },
467    };
468    Ok(node)
469}
470
471fn display_value(value: &crate::compile_time::Value) -> Result<String, String> {
472    match value {
473        crate::compile_time::Value::Number(value) if value.is_finite() => Ok(value.to_string()),
474        crate::compile_time::Value::String(value) => Ok(value.clone()),
475        crate::compile_time::Value::Bool(value) => Ok(value.to_string()),
476        _ => Err("settings list can only contain primitive values".to_string()),
477    }
478}
479struct Cursor<'a> {
480    text: &'a str,
481    pos: usize,
482    char_pos: usize,
483    line: u32,
484    col: u32,
485    file: u32,
486}
487
488impl Cursor<'_> {
489    fn new(text: &str, file: u32, origin: Position) -> Cursor<'_> {
490        Cursor {
491            text,
492            pos: 0,
493            char_pos: 0,
494            line: origin.line,
495            col: origin.col,
496            file,
497        }
498    }
499
500    fn peek(&self) -> Option<char> {
501        self.peek_at(0)
502    }
503
504    fn peek_at(&self, ahead: usize) -> Option<char> {
505        self.text[self.pos..].chars().nth(ahead)
506    }
507
508    fn here(&self) -> Position {
509        Position::new(self.line, self.col)
510    }
511
512    fn here_after(&self, n: usize) -> Position {
513        let mut line = self.line;
514        let mut col = self.col;
515        for ch in self.text[self.pos..].chars().take(n) {
516            if ch == '\n' {
517                line += 1;
518                col = 1;
519            } else {
520                col += 1;
521            }
522        }
523        Position::new(line, col)
524    }
525
526    fn advance(&mut self) -> Option<char> {
527        let ch = self.peek()?;
528        if ch == '\n' {
529            self.line += 1;
530            self.col = 1;
531        } else {
532            self.col += 1;
533        }
534        self.pos += ch.len_utf8();
535        self.char_pos += 1;
536        Some(ch)
537    }
538
539    fn advance_by(&mut self, n: usize) {
540        for _ in 0..n {
541            if self.advance().is_none() {
542                return;
543            }
544        }
545    }
546
547    fn advance_quoted(&mut self, quote: &mut Option<char>, escaped: &mut bool, ch: char) -> bool {
548        let Some(quote_char) = *quote else {
549            return false;
550        };
551        if *escaped {
552            *escaped = false;
553        } else if ch == '\\' {
554            *escaped = true;
555        } else if ch == quote_char {
556            *quote = None;
557        }
558        self.advance_by(1);
559        true
560    }
561
562    fn skip_to_eol(&mut self) {
563        while self.peek().is_some_and(|ch| ch != '\n') {
564            self.advance_by(1);
565        }
566    }
567
568    fn skip_horizontal_whitespace(&mut self) {
569        while matches!(self.peek(), Some(' ' | '\t' | '\r')) {
570            self.advance_by(1);
571        }
572    }
573
574    fn read_word(&mut self) -> String {
575        let mut word = String::new();
576        while let Some(ch) = self.peek() {
577            if !is_ident_continue(ch) {
578                break;
579            }
580            word.push(ch);
581            self.advance_by(1);
582        }
583        word
584    }
585}
586
587/// Primitive JSONC values before classification as typed settings nodes.
588enum ScalarValue {
589    String(String),
590    Bool(bool),
591    Number(f64),
592}
593
594impl Cursor<'_> {
595    fn skip_whitespace(&mut self) {
596        loop {
597            while let Some(ch) = self.peek() {
598                if ch.is_whitespace() {
599                    self.advance();
600                } else {
601                    break;
602                }
603            }
604            if self.peek() == Some('#') {
605                while self.peek().is_some_and(|ch| ch != '\n') {
606                    self.advance();
607                }
608                continue;
609            }
610            if self.text[self.pos..].starts_with("//") {
611                self.advance();
612                self.advance();
613                while self.peek().is_some_and(|ch| ch != '\n') {
614                    self.advance();
615                }
616                continue;
617            }
618            if self.text[self.pos..].starts_with("/*") {
619                self.advance();
620                self.advance();
621                while self.peek().is_some() && !self.text[self.pos..].starts_with("*/") {
622                    self.advance();
623                }
624                if self.text[self.pos..].starts_with("*/") {
625                    self.advance();
626                    self.advance();
627                }
628                continue;
629            }
630            break;
631        }
632    }
633
634    fn error(&self, code: &str, message: String) -> OpyError {
635        OpyError::at(
636            code,
637            message,
638            Span::new(self.file, self.here(), self.here()),
639        )
640    }
641
642    fn error_at(&self, code: &str, message: String, span: Span) -> OpyError {
643        OpyError::at(code, message, span)
644    }
645
646    fn parse_object(&mut self) -> OpyResult<(Vec<cst::SettingsNode>, Span)> {
647        let open = self.here();
648        if self.advance() != Some('{') {
649            return Err(self.error(
650                "settings-invalid",
651                "settings block must be a JSONC object".to_string(),
652            ));
653        }
654        let members = self.parse_members(false)?;
655        let span = Span::new(self.file, open, self.here());
656        Ok((members, span))
657    }
658
659    /// Parse `key: value, ...` members. `root` is true when the enclosing
660    /// object's braces are the settings block's own braces (the text runs to
661    /// the end of the block, and a trailing comma before it is allowed).
662    fn parse_members(&mut self, root: bool) -> OpyResult<Vec<cst::SettingsNode>> {
663        let mut nodes = Vec::new();
664        let mut names = Vec::new();
665        self.skip_whitespace();
666        if (!root && self.peek() == Some('}')) || (root && self.pos >= self.text.len()) {
667            if !root {
668                self.advance();
669            }
670            return Ok(nodes);
671        }
672        loop {
673            self.skip_whitespace();
674            let key_start = self.here();
675            let key = match self.parse_string_value() {
676                Some(value) => value,
677                None => {
678                    return Err(self.error(
679                        "settings-invalid",
680                        "settings keys must be quoted strings".to_string(),
681                    ));
682                }
683            };
684            let key_span = Span::new(self.file, key_start, self.here());
685            if names.contains(&key) {
686                return Err(self.error_at(
687                    "settings-invalid",
688                    format!("duplicate settings key '{key}'"),
689                    key_span,
690                ));
691            }
692            names.push(key.clone());
693            self.skip_whitespace();
694            if self.advance() != Some(':') {
695                return Err(self.error_at(
696                    "settings-invalid",
697                    format!("expected ':' after settings key '{key}'"),
698                    key_span,
699                ));
700            }
701            self.skip_whitespace();
702            let (node, value_end) = self.parse_value()?;
703            let node = build_node(key, node, value_end, key_start, self.file);
704            nodes.push(node);
705            self.skip_whitespace();
706            match self.peek() {
707                Some(',') => {
708                    self.advance();
709                    self.skip_whitespace();
710                    if (!root && self.peek() == Some('}')) || (root && self.pos >= self.text.len())
711                    {
712                        if !root {
713                            self.advance();
714                        }
715                        return Ok(nodes);
716                    }
717                }
718                Some('}') if !root => {
719                    self.advance();
720                    return Ok(nodes);
721                }
722                None if root => return Ok(nodes),
723                _ => {
724                    return Err(self.error(
725                        "settings-invalid",
726                        "expected ',' or '}' in settings object".to_string(),
727                    ));
728                }
729            }
730        }
731    }
732
733    /// Parse one value; returns the built node (name placeholder) and the
734    /// position after it.
735    fn parse_value(&mut self) -> OpyResult<(cst::SettingsNode, Position)> {
736        let start = self.here();
737        let ch = self.peek();
738        let saved = (self.pos, self.char_pos, self.line, self.col);
739        let scalar = match ch {
740            Some('"') | Some('\'') => {
741                let value = self.parse_string_expression().ok_or_else(|| {
742                    self.error(
743                        "settings-invalid",
744                        "unterminated string in settings value".to_string(),
745                    )
746                })?;
747                Some(ScalarValue::String(value))
748            }
749            Some('t') => {
750                self.expect_word("true")?;
751                Some(ScalarValue::Bool(true))
752            }
753            Some('f') => {
754                self.expect_word("false")?;
755                Some(ScalarValue::Bool(false))
756            }
757            Some(c) if c.is_ascii_digit() || c == '-' => {
758                Some(ScalarValue::Number(self.parse_number()?))
759            }
760            _ => None,
761        };
762        let node = if let Some(scalar) = scalar {
763            self.skip_inline_whitespace();
764            if self.is_expression_continuation() {
765                self.pos = saved.0;
766                self.char_pos = saved.1;
767                self.line = saved.2;
768                self.col = saved.3;
769                cst::SettingsNode::Raw {
770                    name: String::new(),
771                    value: self.parse_expression_value(),
772                    span: Span::new(self.file, start, self.here()),
773                }
774            } else {
775                let span = Span::new(self.file, start, self.here());
776                match scalar {
777                    ScalarValue::String(value) => cst::SettingsNode::String {
778                        name: String::new(),
779                        value,
780                        span,
781                    },
782                    ScalarValue::Bool(value) => cst::SettingsNode::Bool {
783                        name: String::new(),
784                        value,
785                        span,
786                    },
787                    ScalarValue::Number(value) => cst::SettingsNode::Number {
788                        name: String::new(),
789                        value,
790                        span,
791                    },
792                }
793            }
794        } else {
795            match ch {
796                Some('[') => {
797                    let elements = self.parse_list()?;
798                    cst::SettingsNode::List {
799                        name: String::new(),
800                        elements,
801                        span: Span::new(self.file, start, self.here()),
802                    }
803                }
804                Some('{') => {
805                    let (children, _) = self.parse_object()?;
806                    cst::SettingsNode::Group {
807                        name: String::new(),
808                        children,
809                        span: Span::new(self.file, start, self.here()),
810                    }
811                }
812                _ => {
813                    let value = self.parse_expression_value();
814                    if value.trim().is_empty() {
815                        return Err(self.error(
816                            "settings-invalid",
817                            "expected a value in settings block".to_string(),
818                        ));
819                    }
820                    cst::SettingsNode::Raw {
821                        name: String::new(),
822                        value,
823                        span: Span::new(self.file, start, self.here()),
824                    }
825                }
826            }
827        };
828        let end = self.here();
829        Ok((node, end))
830    }
831
832    /// Preserve an unquoted setting expression as an opaque value. Commas and
833    /// closing braces delimit it only outside nested expression delimiters and
834    /// string literals.
835    fn parse_expression_value(&mut self) -> String {
836        let mut value = String::new();
837        let mut depth = 0usize;
838        let mut quote = None;
839        while let Some(ch) = self.peek() {
840            if let Some(expected) = quote {
841                value.push(self.advance().expect("peeked character exists"));
842                if ch == expected {
843                    quote = None;
844                }
845                continue;
846            }
847            match ch {
848                '"' | '\'' => {
849                    quote = Some(ch);
850                    value.push(self.advance().expect("peeked character exists"));
851                }
852                '(' | '[' | '{' => {
853                    depth += 1;
854                    value.push(self.advance().expect("peeked character exists"));
855                }
856                ']' | ',' | '}' if depth == 0 => break,
857                ')' | ']' => {
858                    depth = depth.saturating_sub(1);
859                    value.push(self.advance().expect("peeked character exists"));
860                }
861                _ => value.push(self.advance().expect("peeked character exists")),
862            }
863        }
864        value.trim().to_string()
865    }
866
867    fn expect_word(&mut self, word: &str) -> OpyResult<()> {
868        let start = self.here();
869        for expected in word.chars() {
870            if self.advance() != Some(expected) {
871                return Err(self.error_at(
872                    "settings-invalid",
873                    format!("expected '{word}' in settings block"),
874                    Span::new(self.file, start, self.here()),
875                ));
876            }
877        }
878        Ok(())
879    }
880
881    fn parse_number(&mut self) -> OpyResult<f64> {
882        let start = self.here();
883        let mut text = String::new();
884        if self.peek() == Some('-') {
885            text.push(self.advance().unwrap());
886        }
887        while let Some(c) = self.peek() {
888            if c.is_ascii_digit() {
889                text.push(self.advance().unwrap());
890            } else {
891                break;
892            }
893        }
894        if self.peek() == Some('.') {
895            text.push(self.advance().unwrap());
896            while let Some(c) = self.peek() {
897                if c.is_ascii_digit() {
898                    text.push(self.advance().unwrap());
899                } else {
900                    break;
901                }
902            }
903        }
904        text.parse::<f64>().map_err(|_| {
905            self.error_at(
906                "settings-invalid",
907                format!("invalid number '{text}' in settings block"),
908                Span::new(self.file, start, self.here()),
909            )
910        })
911    }
912
913    fn parse_list(&mut self) -> OpyResult<Vec<cst::SettingsListElement>> {
914        self.advance(); // '['
915        let mut elements = Vec::new();
916        self.skip_whitespace();
917        if self.peek() == Some(']') {
918            self.advance();
919            return Ok(elements);
920        }
921        loop {
922            self.skip_whitespace();
923            let start = self.here();
924            let value = match self.peek() {
925                Some('"') | Some('\'') => self.parse_string_expression().ok_or_else(|| {
926                    self.error(
927                        "settings-invalid",
928                        "unterminated string in settings list".to_string(),
929                    )
930                })?,
931                Some(']') | Some(',') | None => {
932                    return Err(self.error(
933                        "settings-invalid",
934                        "settings list elements must not be empty".to_string(),
935                    ));
936                }
937                _ => self.parse_expression_value(),
938            };
939            let span = Span::new(self.file, start, self.here());
940            elements.push(cst::SettingsListElement { value, span });
941            self.skip_whitespace();
942            match self.peek() {
943                Some(',') => {
944                    self.advance();
945                    self.skip_whitespace();
946                    if self.peek() == Some(']') {
947                        self.advance();
948                        return Ok(elements);
949                    }
950                }
951                Some(']') => {
952                    self.advance();
953                    return Ok(elements);
954                }
955                _ => {
956                    return Err(self.error(
957                        "settings-invalid",
958                        "expected ',' or ']' in settings list".to_string(),
959                    ));
960                }
961            }
962        }
963    }
964
965    /// Parse a quoted string value; `None` when no string is here or the
966    /// string is unterminated before end-of-line.
967    fn parse_string_value(&mut self) -> Option<String> {
968        let quote = self.peek()?;
969        if quote != '"' && quote != '\'' {
970            return None;
971        }
972        self.advance();
973        let mut value = String::new();
974        loop {
975            let ch = self.advance()?;
976            if ch == '\n' {
977                return None;
978            }
979            if ch == quote {
980                return Some(value);
981            }
982            if ch == '\\' {
983                let escaped = self.advance()?;
984                value.push(decode_string_escape(escaped));
985            } else {
986                value.push(ch);
987            }
988        }
989    }
990
991    /// Parse adjacent quoted strings as one compile-time string value. This
992    /// is the settings equivalent of OPY's implicit string concatenation and
993    /// is used after macro expansion has turned identifiers into literals.
994    fn parse_string_expression(&mut self) -> Option<String> {
995        let mut value = self.parse_string_value()?;
996        loop {
997            let saved = (self.pos, self.char_pos, self.line, self.col);
998            self.skip_whitespace();
999            if self.peek() != Some('"') && self.peek() != Some('\'') {
1000                self.pos = saved.0;
1001                self.char_pos = saved.1;
1002                self.line = saved.2;
1003                self.col = saved.3;
1004                break;
1005            }
1006            let next = self.parse_string_value()?;
1007            value.push_str(&next);
1008        }
1009        Some(value)
1010    }
1011
1012    fn skip_inline_whitespace(&mut self) {
1013        while matches!(self.peek(), Some(' ' | '\t' | '\r')) {
1014            self.advance();
1015        }
1016    }
1017
1018    fn is_expression_continuation(&self) -> bool {
1019        if matches!(
1020            self.peek(),
1021            Some('+' | '-' | '*' | '/' | '%' | '<' | '>' | '=')
1022        ) {
1023            return true;
1024        }
1025        ["and", "or", "if"].iter().any(|word| {
1026            self.text[self.pos..]
1027                .strip_prefix(word)
1028                .is_some_and(|rest| {
1029                    rest.chars().next().is_none_or(|character| {
1030                        !character.is_ascii_alphanumeric() && character != '_'
1031                    })
1032                })
1033        })
1034    }
1035}
1036
1037/// Attach the key name and a key..value span to a parsed value node.
1038fn build_node(
1039    key: String,
1040    node: cst::SettingsNode,
1041    value_end: Position,
1042    key_start: Position,
1043    file: u32,
1044) -> cst::SettingsNode {
1045    let span = Span::new(file, key_start, value_end);
1046    match node {
1047        cst::SettingsNode::Group { children, .. } => cst::SettingsNode::Group {
1048            name: key,
1049            children,
1050            span,
1051        },
1052        cst::SettingsNode::Number { value, .. } => cst::SettingsNode::Number {
1053            name: key,
1054            value,
1055            span,
1056        },
1057        cst::SettingsNode::Bool { value, .. } => cst::SettingsNode::Bool {
1058            name: key,
1059            value,
1060            span,
1061        },
1062        cst::SettingsNode::String { value, .. } => cst::SettingsNode::String {
1063            name: key,
1064            value,
1065            span,
1066        },
1067        cst::SettingsNode::Raw { value, .. } => cst::SettingsNode::Raw {
1068            name: key,
1069            value,
1070            span,
1071        },
1072        cst::SettingsNode::List { elements, .. } => cst::SettingsNode::List {
1073            name: key,
1074            elements,
1075            span,
1076        },
1077    }
1078}
1079
1080#[cfg(test)]
1081mod tests {
1082    use super::*;
1083
1084    fn block(text: &str) -> SettingsBlock {
1085        let mut blocks = find_blocks(text, 0).unwrap();
1086        assert_eq!(blocks.len(), 1, "one block expected in: {text}");
1087        blocks.pop().unwrap()
1088    }
1089
1090    #[test]
1091    fn finds_block_after_comments_and_blanks() {
1092        let text = "/* header */\n# comment\n\nsettings {\n    \"gamemodes\": {}\n}\nrule \"r\":\n";
1093        let found = block(text);
1094        assert!(found.text.contains("gamemodes"));
1095        assert_eq!(found.keyword_span.start.line, 4);
1096        assert_eq!(found.span.end.line, 6);
1097    }
1098
1099    #[test]
1100    fn no_blocks_for_plain_program() {
1101        assert!(
1102            find_blocks("rule \"r\":\n    pass\n", 0)
1103                .unwrap()
1104                .is_empty()
1105        );
1106    }
1107
1108    #[test]
1109    fn ignores_settings_words_in_strings_and_indented_code() {
1110        let text = "rule \"settings\":\n    x = \"settings\"\n";
1111        assert!(find_blocks(text, 0).unwrap().is_empty());
1112    }
1113
1114    #[test]
1115    fn settings_not_first_construct_is_placement_error() {
1116        let error = find_blocks("rule \"r\":\n    pass\nsettings {\n}\n", 0).unwrap_err();
1117        assert_eq!(error.code, "settings-placement");
1118        assert_eq!(error.span.unwrap().start.line, 3);
1119    }
1120
1121    #[test]
1122    fn second_block_is_placement_error() {
1123        let error =
1124            find_blocks("settings {\n    \"gamemodes\": {}\n}\nsettings {\n}\n", 0).unwrap_err();
1125        assert_eq!(error.code, "settings-placement");
1126        assert_eq!(error.span.unwrap().start.line, 4);
1127    }
1128
1129    #[test]
1130    fn settings_file_form_is_extracted() {
1131        let found = block("settings \"file.opy.json\"\n");
1132        assert_eq!(found.external_path.as_deref(), Some("file.opy.json"));
1133    }
1134
1135    #[test]
1136    fn unterminated_block_is_invalid() {
1137        let error = find_blocks("settings {\n    \"gamemodes\": {\n", 0).unwrap_err();
1138        assert_eq!(error.code, "settings-invalid");
1139    }
1140
1141    #[test]
1142    fn braces_inside_strings_do_not_unbalance() {
1143        let found =
1144            block("settings {\n    \"description\": \"a { b }\",\n    \"gamemodes\": {}\n}\n");
1145        assert!(found.text.contains("a { b }"));
1146    }
1147
1148    #[test]
1149    fn sanitize_preserves_post_block_positions() {
1150        let text = "settings {\n    \"gamemodes\": {}\n}\nrule \"r\":\n    pass\n";
1151        let found = block(text);
1152        let sanitized = sanitize_for_lex(text, &found);
1153        let lines: Vec<&str> = sanitized.lines().collect();
1154        assert_eq!(lines.len(), 5);
1155        assert_eq!(lines[3], "rule \"r\":");
1156        assert_eq!(lines[4], "    pass");
1157        // The rule keyword is at the same char offset as in the original.
1158        assert_eq!(sanitized.find("rule"), text.find("rule"));
1159    }
1160
1161    #[test]
1162    fn unicode_preserves_offsets_and_sanitization() {
1163        let text = "# 前置🙂\nsettings {\n    \"main\": { \"description\": \"内🙂\" },\n    \"gamemodes\": {}\n}\nrule \"后🙂\":\n    pass\n";
1164        let found = block(text);
1165        let keyword_byte_offset = text.find("settings").unwrap();
1166        let closing_byte_offset = text.find("}\nrule").unwrap();
1167        assert_eq!(found.start, text[..keyword_byte_offset].chars().count());
1168        assert_eq!(
1169            found.end,
1170            text[..closing_byte_offset + '}'.len_utf8()].chars().count()
1171        );
1172        assert_eq!(found.keyword_span.start, Position::new(2, 1));
1173        assert_eq!(found.text_start, Position::new(2, 11));
1174        assert!(found.text.contains("内🙂"));
1175
1176        let sanitized = sanitize_for_lex(text, &found);
1177        assert_eq!(sanitized.chars().count(), text.chars().count());
1178        let rule_char_offset = |source: &str| {
1179            let byte_offset = source.find("rule").unwrap();
1180            source[..byte_offset].chars().count()
1181        };
1182        assert_eq!(rule_char_offset(&sanitized), rule_char_offset(text));
1183        assert!(sanitized.contains("rule \"后🙂\":"));
1184    }
1185
1186    #[test]
1187    fn non_object_after_settings_is_invalid() {
1188        // `settings [..]` is rejected at extraction (a `{` is required).
1189        let error = find_blocks("settings [1, 2]\n", 0).unwrap_err();
1190        assert_eq!(error.code, "settings-invalid");
1191    }
1192
1193    #[test]
1194    fn parse_block_rejects_missing_gamemodes() {
1195        let found = block("settings {\n    \"main\": { \"description\": \"x\" }\n}\n");
1196        let error = parse_block(&found).unwrap_err();
1197        assert_eq!(error.code, "settings-invalid");
1198        assert!(error.message.contains("gamemodes"));
1199    }
1200
1201    #[test]
1202    fn parse_block_rejects_duplicate_keys() {
1203        let found = block("settings {\n    \"gamemodes\": {},\n    \"gamemodes\": {}\n}\n");
1204        let error = parse_block(&found).unwrap_err();
1205        assert_eq!(error.code, "settings-invalid");
1206        assert!(error.message.contains("duplicate"));
1207    }
1208
1209    #[test]
1210    fn parse_block_accepts_trailing_commas() {
1211        let found = block(
1212            "settings {\n    \"gamemodes\": {\n        \"general\": {\n            \"heroLimit\": \"off\",\n        },\n    },\n}\n",
1213        );
1214        let parsed = parse_block(&found).unwrap();
1215        assert_eq!(parsed.children.len(), 1);
1216    }
1217
1218    #[test]
1219    fn parse_block_handles_escapes_and_quotes() {
1220        let found = block(
1221            "settings {\n    \"main\": { \"description\": \"line\\n\\t\\\"quoted\\\"\" },\n    \"gamemodes\": {}\n}\n",
1222        );
1223        let parsed = parse_block(&found).unwrap();
1224        let cst::SettingsNode::Group { children, .. } = &parsed.children[0] else {
1225            panic!("main group");
1226        };
1227        let cst::SettingsNode::String { value, .. } = &children[0] else {
1228            panic!("description");
1229        };
1230        assert_eq!(value, "line\n\t\"quoted\"");
1231    }
1232
1233    #[test]
1234    fn parse_block_accepts_comments_and_expression_values() {
1235        let found = block(
1236            "settings {\n    # keep the source expression intact\n    \"lobby\": {\n        \"modeName\": GAMEMODE_NAME\" \"GAMEMODE_VERSION,\n    },\n    \"gamemodes\": {}\n}\n",
1237        );
1238        let parsed = parse_block(&found).unwrap();
1239        let cst::SettingsNode::Group { children, .. } = &parsed.children[0] else {
1240            panic!("lobby group");
1241        };
1242        let cst::SettingsNode::Raw { value, .. } = &children[0] else {
1243            panic!("expression-valued setting");
1244        };
1245        assert_eq!(value, "GAMEMODE_NAME\" \"GAMEMODE_VERSION");
1246    }
1247
1248    #[test]
1249    fn parse_block_types_values() {
1250        let found = block(
1251            "settings {\n    \"lobby\": { \"ffaSlots\": 6 },\n    \"gamemodes\": { \"general\": { \"enableRandomHeroes\": true, \"respawnTime%\": 30, \"heroLimit\": \"off\" } },\n    \"heroes\": { \"allTeams\": { \"enabledHeroes\": [\"mei\"] } }\n}\n",
1252        );
1253        let parsed = parse_block(&found).unwrap();
1254        let lobby = match &parsed.children[0] {
1255            cst::SettingsNode::Group { name, children, .. } => {
1256                assert_eq!(name, "lobby");
1257                children
1258            }
1259            other => panic!("{other:?}"),
1260        };
1261        assert!(matches!(
1262            lobby[0],
1263            cst::SettingsNode::Number { value: 6.0, .. }
1264        ));
1265    }
1266
1267    #[test]
1268    fn spans_are_computed_from_block_base() {
1269        let text = "settings {\n    \"lobby\": {\n        \"ffaSlots\": 6\n    },\n    \"gamemodes\": {}\n}\n";
1270        let found = block(text);
1271        let parsed = parse_block(&found).unwrap();
1272        let cst::SettingsNode::Group { children, .. } = &parsed.children[0] else {
1273            panic!("lobby");
1274        };
1275        let cst::SettingsNode::Number { span, .. } = &children[0] else {
1276            panic!("ffaSlots");
1277        };
1278        assert_eq!(span.start.line, 3);
1279        assert_eq!(span.start.col, 9);
1280    }
1281
1282    #[test]
1283    fn keyword_span_carries_the_file_id() {
1284        let found = block("settings {\n    \"gamemodes\": {}\n}\n");
1285        assert_eq!(found.keyword_span.file, 0);
1286        assert_eq!(found.keyword_span.start.col, 1);
1287        assert_eq!(found.keyword_span.end.col, 9);
1288    }
1289}