Skip to main content

opy_rs/
settings.rs

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