1use std::collections::HashMap;
18
19use crate::cst;
20use crate::diag::{OpyError, OpyResult, Position, Span};
21use crate::hir;
22
23#[derive(Debug, Clone)]
25pub struct SettingsBlock {
26 pub text: String,
28 pub span: Span,
30 pub keyword_span: Span,
32 pub start: usize,
34 pub end: usize,
36 pub text_start: Position,
38 pub content_file: u32,
40 pub content_span: Span,
42 pub external_path: Option<String>,
44}
45
46pub 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 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
134fn 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
272pub 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
286pub 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 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
332pub(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
590struct 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 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 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 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(); 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 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 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
1099fn 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 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 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}