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