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