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