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