1use crate::cst;
18use crate::diag::{OpyError, OpyResult, Position, Span};
19
20#[derive(Debug, Clone)]
22pub struct SettingsBlock {
23 pub text: String,
25 pub span: Span,
27 pub keyword_span: Span,
29 pub start: usize,
31 pub end: usize,
33 pub text_start: Position,
35}
36
37pub fn find_blocks(text: &str, file_id: u32) -> OpyResult<Vec<SettingsBlock>> {
46 let chars: Vec<char> = text.chars().collect();
47 let mut scanner = Scanner {
48 chars: &chars,
49 pos: 0,
50 line: 1,
51 col: 1,
52 };
53 let mut blocks = Vec::new();
54 let mut in_block_comment = false;
55 let mut seen_first_construct = false;
56 while scanner.pos < scanner.chars.len() {
57 let ch = scanner.chars[scanner.pos];
58 if in_block_comment {
59 if ch == '*' && scanner.peek(1) == Some('/') {
60 in_block_comment = false;
61 scanner.advance(2);
62 } else {
63 scanner.advance(1);
64 }
65 continue;
66 }
67 if ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n' {
68 scanner.advance(1);
69 continue;
70 }
71 if ch == '#' {
72 scanner.skip_to_eol();
73 continue;
74 }
75 if ch == '/' && scanner.peek(1) == Some('*') {
76 scanner.advance(2);
77 in_block_comment = true;
78 continue;
79 }
80 if is_ident_start(ch) {
82 let keyword_start = scanner.here();
83 let keyword_offset = scanner.pos;
84 let word = scanner.read_word();
85 if word == "settings" {
86 let keyword_span = Span::new(file_id, keyword_start, scanner.here());
87 if seen_first_construct || !blocks.is_empty() {
88 return Err(OpyError::at(
89 "settings-placement",
90 "settings block must be the first construct in the file".to_string(),
91 keyword_span,
92 ));
93 }
94 let block = match_block(&mut scanner, keyword_start, keyword_offset, keyword_span)?;
95 blocks.push(block);
96 seen_first_construct = true;
97 continue;
98 }
99 seen_first_construct = true;
100 continue;
101 }
102 seen_first_construct = true;
103 scanner.advance(1);
104 }
105 Ok(blocks)
106}
107
108fn match_block(
111 scanner: &mut Scanner<'_>,
112 keyword_start: Position,
113 keyword_offset: usize,
114 keyword_span: Span,
115) -> OpyResult<SettingsBlock> {
116 scanner.skip_whitespace();
117 if scanner.chars.get(scanner.pos) != Some(&'{') {
118 return Err(OpyError::at(
119 "settings-invalid",
120 "settings block must be a `settings { ... }` block (the `settings \"file\"` form is not supported)"
121 .to_string(),
122 keyword_span,
123 ));
124 }
125 let mut depth = 0usize;
126 let mut string_quote: Option<char> = None;
127 let mut escaped = false;
128 let mut text_start_offset = None;
129 let mut text_start = None;
130 loop {
131 let Some(ch) = scanner.chars.get(scanner.pos).copied() else {
132 return Err(OpyError::at(
133 "settings-invalid",
134 "unterminated settings block (missing closing brace)".to_string(),
135 keyword_span,
136 ));
137 };
138 if let Some(quote) = string_quote {
139 if escaped {
140 escaped = false;
141 } else if ch == '\\' {
142 escaped = true;
143 } else if ch == quote {
144 string_quote = None;
145 }
146 scanner.advance(1);
147 continue;
148 }
149 match ch {
150 '"' | '\'' => string_quote = Some(ch),
151 '{' => {
152 if depth == 0 {
153 text_start_offset = Some(scanner.pos + 1);
154 text_start = Some(scanner.here_after(1));
155 }
156 depth += 1;
157 }
158 '}' => {
159 depth -= 1;
160 if depth == 0 {
161 let text = scanner
162 .chars
163 .get(text_start_offset.expect("text start offset set on '{'")..scanner.pos)
164 .map(|slice| slice.iter().collect::<String>())
165 .unwrap_or_default();
166 return Ok(SettingsBlock {
167 text,
168 span: Span::new(keyword_span.file, keyword_start, scanner.here_after(1)),
169 keyword_span,
170 start: keyword_offset,
171 end: scanner.pos + 1,
172 text_start: text_start.expect("text start set on '{'"),
173 });
174 }
175 }
176 _ => {}
177 }
178 scanner.advance(1);
179 }
180}
181
182pub fn sanitize_for_lex(text: &str, block: &SettingsBlock) -> String {
185 let mut out = String::with_capacity(text.len());
186 for (index, ch) in text.chars().enumerate() {
187 if index >= block.start && index < block.end {
188 out.push(if ch == '\n' { '\n' } else { ' ' });
189 } else {
190 out.push(ch);
191 }
192 }
193 out
194}
195
196pub fn parse_block(block: &SettingsBlock) -> OpyResult<cst::Settings> {
203 let mut parser = Jsonc {
204 text: &block.text,
205 pos: 0,
206 line: block.text_start.line,
207 col: block.text_start.col,
208 file: block.span.file,
209 };
210 parser.skip_whitespace();
211 let children = parser.parse_members(true)?;
214 parser.skip_whitespace();
215 if parser.pos < parser.text.len() {
216 return Err(parser.error(
217 "settings-invalid",
218 "unexpected content after the settings object".to_string(),
219 ));
220 }
221 if !children
222 .iter()
223 .any(|node| matches!(node, cst::SettingsNode::Group { name, .. } if name == "gamemodes"))
224 {
225 return Err(OpyError::at(
226 "settings-invalid",
227 "settings block must contain a gamemodes group".to_string(),
228 block.span,
229 ));
230 }
231 Ok(cst::Settings {
232 span: block.span,
233 children,
234 })
235}
236
237struct Scanner<'a> {
239 chars: &'a [char],
240 pos: usize,
241 line: u32,
242 col: u32,
243}
244
245impl Scanner<'_> {
246 fn peek(&self, ahead: usize) -> Option<char> {
247 self.chars.get(self.pos + ahead).copied()
248 }
249
250 fn here(&self) -> Position {
251 Position::new(self.line, self.col)
252 }
253
254 fn here_after(&self, n: usize) -> Position {
255 let mut line = self.line;
256 let mut col = self.col;
257 for i in 0..n {
258 if self.chars.get(self.pos + i) == Some(&'\n') {
259 line += 1;
260 col = 1;
261 } else {
262 col += 1;
263 }
264 }
265 Position::new(line, col)
266 }
267
268 fn advance(&mut self, n: usize) {
269 for _ in 0..n {
270 if self.pos >= self.chars.len() {
271 return;
272 }
273 if self.chars[self.pos] == '\n' {
274 self.line += 1;
275 self.col = 1;
276 } else {
277 self.col += 1;
278 }
279 self.pos += 1;
280 }
281 }
282
283 fn skip_to_eol(&mut self) {
284 while self.pos < self.chars.len() && self.chars[self.pos] != '\n' {
285 self.advance(1);
286 }
287 }
288
289 fn skip_whitespace(&mut self) {
290 while self.pos < self.chars.len() && matches!(self.chars[self.pos], ' ' | '\t' | '\r') {
291 self.advance(1);
292 }
293 }
294
295 fn read_word(&mut self) -> String {
296 let mut word = String::new();
297 while self.pos < self.chars.len() && is_ident_continue(self.chars[self.pos]) {
298 word.push(self.chars[self.pos]);
299 self.advance(1);
300 }
301 word
302 }
303}
304
305struct Jsonc<'a> {
307 text: &'a str,
308 pos: usize,
309 line: u32,
310 col: u32,
311 file: u32,
312}
313
314impl Jsonc<'_> {
315 fn here(&self) -> Position {
316 Position::new(self.line, self.col)
317 }
318
319 fn peek(&self) -> Option<char> {
320 self.text[self.pos..].chars().next()
321 }
322
323 fn advance(&mut self) -> Option<char> {
324 let ch = self.peek()?;
325 self.pos += ch.len_utf8();
326 if ch == '\n' {
327 self.line += 1;
328 self.col = 1;
329 } else {
330 self.col += 1;
331 }
332 Some(ch)
333 }
334
335 fn skip_whitespace(&mut self) {
336 while let Some(ch) = self.peek() {
337 if ch.is_whitespace() {
338 self.advance();
339 } else {
340 break;
341 }
342 }
343 }
344
345 fn error(&self, code: &str, message: String) -> OpyError {
346 OpyError::at(
347 code,
348 message,
349 Span::new(self.file, self.here(), self.here()),
350 )
351 }
352
353 fn error_at(&self, code: &str, message: String, span: Span) -> OpyError {
354 OpyError::at(code, message, span)
355 }
356
357 fn parse_object(&mut self) -> OpyResult<(Vec<cst::SettingsNode>, Span)> {
358 let open = self.here();
359 if self.advance() != Some('{') {
360 return Err(self.error(
361 "settings-invalid",
362 "settings block must be a JSONC object".to_string(),
363 ));
364 }
365 let members = self.parse_members(false)?;
366 let span = Span::new(self.file, open, self.here());
367 Ok((members, span))
368 }
369
370 fn parse_members(&mut self, root: bool) -> OpyResult<Vec<cst::SettingsNode>> {
374 let mut nodes = Vec::new();
375 let mut names = Vec::new();
376 self.skip_whitespace();
377 if (!root && self.peek() == Some('}')) || (root && self.pos >= self.text.len()) {
378 if !root {
379 self.advance();
380 }
381 return Ok(nodes);
382 }
383 loop {
384 self.skip_whitespace();
385 let key_start = self.here();
386 let key = match self.parse_string_value() {
387 Some(value) => value,
388 None => {
389 return Err(self.error(
390 "settings-invalid",
391 "settings keys must be quoted strings".to_string(),
392 ));
393 }
394 };
395 let key_span = Span::new(self.file, key_start, self.here());
396 if names.contains(&key) {
397 return Err(self.error_at(
398 "settings-invalid",
399 format!("duplicate settings key '{key}'"),
400 key_span,
401 ));
402 }
403 names.push(key.clone());
404 self.skip_whitespace();
405 if self.advance() != Some(':') {
406 return Err(self.error_at(
407 "settings-invalid",
408 format!("expected ':' after settings key '{key}'"),
409 key_span,
410 ));
411 }
412 self.skip_whitespace();
413 let (node, value_end) = self.parse_value()?;
414 let node = build_node(key, node, value_end, key_start, self.file);
415 nodes.push(node);
416 self.skip_whitespace();
417 match self.peek() {
418 Some(',') => {
419 self.advance();
420 self.skip_whitespace();
421 if (!root && self.peek() == Some('}')) || (root && self.pos >= self.text.len())
422 {
423 if !root {
424 self.advance();
425 }
426 return Ok(nodes);
427 }
428 }
429 Some('}') if !root => {
430 self.advance();
431 return Ok(nodes);
432 }
433 None if root => return Ok(nodes),
434 _ => {
435 return Err(self.error(
436 "settings-invalid",
437 "expected ',' or '}' in settings object".to_string(),
438 ));
439 }
440 }
441 }
442 }
443
444 fn parse_value(&mut self) -> OpyResult<(cst::SettingsNode, Position)> {
447 let start = self.here();
448 let ch = self.peek();
449 let node = match ch {
450 Some('"') | Some('\'') => {
451 let value = self.parse_string_value().ok_or_else(|| {
452 self.error(
453 "settings-invalid",
454 "unterminated string in settings value".to_string(),
455 )
456 })?;
457 cst::SettingsNode::String {
458 name: String::new(),
459 value,
460 span: Span::new(self.file, start, self.here()),
461 }
462 }
463 Some('t') => {
464 self.expect_word("true")?;
465 cst::SettingsNode::Bool {
466 name: String::new(),
467 value: true,
468 span: Span::new(self.file, start, self.here()),
469 }
470 }
471 Some('f') => {
472 self.expect_word("false")?;
473 cst::SettingsNode::Bool {
474 name: String::new(),
475 value: false,
476 span: Span::new(self.file, start, self.here()),
477 }
478 }
479 Some(c) if c.is_ascii_digit() || c == '-' => {
480 let value = self.parse_number()?;
481 cst::SettingsNode::Number {
482 name: String::new(),
483 value,
484 span: Span::new(self.file, start, self.here()),
485 }
486 }
487 Some('[') => {
488 let elements = self.parse_list()?;
489 cst::SettingsNode::List {
490 name: String::new(),
491 elements,
492 span: Span::new(self.file, start, self.here()),
493 }
494 }
495 Some('{') => {
496 let (children, _) = self.parse_object()?;
497 cst::SettingsNode::Group {
498 name: String::new(),
499 children,
500 span: Span::new(self.file, start, self.here()),
501 }
502 }
503 _ => {
504 return Err(self.error(
505 "settings-invalid",
506 "expected a value in settings block".to_string(),
507 ));
508 }
509 };
510 let end = self.here();
511 Ok((node, end))
512 }
513
514 fn expect_word(&mut self, word: &str) -> OpyResult<()> {
515 let start = self.here();
516 for expected in word.chars() {
517 if self.advance() != Some(expected) {
518 return Err(self.error_at(
519 "settings-invalid",
520 format!("expected '{word}' in settings block"),
521 Span::new(self.file, start, self.here()),
522 ));
523 }
524 }
525 Ok(())
526 }
527
528 fn parse_number(&mut self) -> OpyResult<f64> {
529 let start = self.here();
530 let mut text = String::new();
531 if self.peek() == Some('-') {
532 text.push(self.advance().unwrap());
533 }
534 while let Some(c) = self.peek() {
535 if c.is_ascii_digit() {
536 text.push(self.advance().unwrap());
537 } else {
538 break;
539 }
540 }
541 if self.peek() == Some('.') {
542 text.push(self.advance().unwrap());
543 while let Some(c) = self.peek() {
544 if c.is_ascii_digit() {
545 text.push(self.advance().unwrap());
546 } else {
547 break;
548 }
549 }
550 }
551 text.parse::<f64>().map_err(|_| {
552 self.error_at(
553 "settings-invalid",
554 format!("invalid number '{text}' in settings block"),
555 Span::new(self.file, start, self.here()),
556 )
557 })
558 }
559
560 fn parse_list(&mut self) -> OpyResult<Vec<cst::SettingsListElement>> {
561 self.advance(); let mut elements = Vec::new();
563 self.skip_whitespace();
564 if self.peek() == Some(']') {
565 self.advance();
566 return Ok(elements);
567 }
568 loop {
569 self.skip_whitespace();
570 let start = self.here();
571 let value = match self.parse_string_value() {
572 Some(value) => value,
573 None => {
574 return Err(self.error(
575 "settings-invalid",
576 "settings list elements must be strings".to_string(),
577 ));
578 }
579 };
580 let span = Span::new(self.file, start, self.here());
581 elements.push(cst::SettingsListElement { value, span });
582 self.skip_whitespace();
583 match self.peek() {
584 Some(',') => {
585 self.advance();
586 self.skip_whitespace();
587 if self.peek() == Some(']') {
588 self.advance();
589 return Ok(elements);
590 }
591 }
592 Some(']') => {
593 self.advance();
594 return Ok(elements);
595 }
596 _ => {
597 return Err(self.error(
598 "settings-invalid",
599 "expected ',' or ']' in settings list".to_string(),
600 ));
601 }
602 }
603 }
604 }
605
606 fn parse_string_value(&mut self) -> Option<String> {
609 let quote = self.peek()?;
610 if quote != '"' && quote != '\'' {
611 return None;
612 }
613 self.advance();
614 let mut value = String::new();
615 loop {
616 let ch = self.advance()?;
617 if ch == '\n' {
618 return None;
619 }
620 if ch == quote {
621 return Some(value);
622 }
623 if ch == '\\' {
624 let escaped = self.advance()?;
625 match escaped {
626 'n' => value.push('\n'),
627 't' => value.push('\t'),
628 'r' => value.push('\r'),
629 other => value.push(other),
630 }
631 } else {
632 value.push(ch);
633 }
634 }
635 }
636}
637
638fn build_node(
640 key: String,
641 node: cst::SettingsNode,
642 value_end: Position,
643 key_start: Position,
644 file: u32,
645) -> cst::SettingsNode {
646 let span = Span::new(file, key_start, value_end);
647 match node {
648 cst::SettingsNode::Group { children, .. } => cst::SettingsNode::Group {
649 name: key,
650 children,
651 span,
652 },
653 cst::SettingsNode::Number { value, .. } => cst::SettingsNode::Number {
654 name: key,
655 value,
656 span,
657 },
658 cst::SettingsNode::Bool { value, .. } => cst::SettingsNode::Bool {
659 name: key,
660 value,
661 span,
662 },
663 cst::SettingsNode::String { value, .. } => cst::SettingsNode::String {
664 name: key,
665 value,
666 span,
667 },
668 cst::SettingsNode::List { elements, .. } => cst::SettingsNode::List {
669 name: key,
670 elements,
671 span,
672 },
673 }
674}
675
676fn is_ident_start(c: char) -> bool {
677 c.is_ascii_alphabetic() || c == '_'
678}
679
680fn is_ident_continue(c: char) -> bool {
681 c.is_ascii_alphanumeric() || c == '_'
682}
683
684#[cfg(test)]
685mod tests {
686 use super::*;
687
688 fn block(text: &str) -> SettingsBlock {
689 let mut blocks = find_blocks(text, 0).unwrap();
690 assert_eq!(blocks.len(), 1, "one block expected in: {text}");
691 blocks.pop().unwrap()
692 }
693
694 #[test]
695 fn finds_block_after_comments_and_blanks() {
696 let text = "/* header */\n# comment\n\nsettings {\n \"gamemodes\": {}\n}\nrule \"r\":\n";
697 let found = block(text);
698 assert!(found.text.contains("gamemodes"));
699 assert_eq!(found.keyword_span.start.line, 4);
700 assert_eq!(found.span.end.line, 6);
701 }
702
703 #[test]
704 fn no_blocks_for_plain_program() {
705 assert!(
706 find_blocks("rule \"r\":\n pass\n", 0)
707 .unwrap()
708 .is_empty()
709 );
710 }
711
712 #[test]
713 fn settings_not_first_construct_is_placement_error() {
714 let error = find_blocks("rule \"r\":\n pass\nsettings {\n}\n", 0).unwrap_err();
715 assert_eq!(error.code, "settings-placement");
716 assert_eq!(error.span.unwrap().start.line, 3);
717 }
718
719 #[test]
720 fn second_block_is_placement_error() {
721 let error =
722 find_blocks("settings {\n \"gamemodes\": {}\n}\nsettings {\n}\n", 0).unwrap_err();
723 assert_eq!(error.code, "settings-placement");
724 assert_eq!(error.span.unwrap().start.line, 4);
725 }
726
727 #[test]
728 fn settings_file_form_is_invalid() {
729 let error = find_blocks("settings \"file.opy\"\n", 0).unwrap_err();
730 assert_eq!(error.code, "settings-invalid");
731 }
732
733 #[test]
734 fn unterminated_block_is_invalid() {
735 let error = find_blocks("settings {\n \"gamemodes\": {\n", 0).unwrap_err();
736 assert_eq!(error.code, "settings-invalid");
737 }
738
739 #[test]
740 fn braces_inside_strings_do_not_unbalance() {
741 let found =
742 block("settings {\n \"description\": \"a { b }\",\n \"gamemodes\": {}\n}\n");
743 assert!(found.text.contains("a { b }"));
744 }
745
746 #[test]
747 fn sanitize_preserves_post_block_positions() {
748 let text = "settings {\n \"gamemodes\": {}\n}\nrule \"r\":\n pass\n";
749 let found = block(text);
750 let sanitized = sanitize_for_lex(text, &found);
751 let lines: Vec<&str> = sanitized.lines().collect();
752 assert_eq!(lines.len(), 5);
753 assert_eq!(lines[3], "rule \"r\":");
754 assert_eq!(lines[4], " pass");
755 assert_eq!(sanitized.find("rule"), text.find("rule"));
757 }
758
759 #[test]
760 fn non_object_after_settings_is_invalid() {
761 let error = find_blocks("settings [1, 2]\n", 0).unwrap_err();
763 assert_eq!(error.code, "settings-invalid");
764 }
765
766 #[test]
767 fn parse_block_rejects_missing_gamemodes() {
768 let found = block("settings {\n \"main\": { \"description\": \"x\" }\n}\n");
769 let error = parse_block(&found).unwrap_err();
770 assert_eq!(error.code, "settings-invalid");
771 assert!(error.message.contains("gamemodes"));
772 }
773
774 #[test]
775 fn parse_block_rejects_duplicate_keys() {
776 let found = block("settings {\n \"gamemodes\": {},\n \"gamemodes\": {}\n}\n");
777 let error = parse_block(&found).unwrap_err();
778 assert_eq!(error.code, "settings-invalid");
779 assert!(error.message.contains("duplicate"));
780 }
781
782 #[test]
783 fn parse_block_accepts_trailing_commas() {
784 let found = block(
785 "settings {\n \"gamemodes\": {\n \"general\": {\n \"heroLimit\": \"off\",\n },\n },\n}\n",
786 );
787 let parsed = parse_block(&found).unwrap();
788 assert_eq!(parsed.children.len(), 1);
789 }
790
791 #[test]
792 fn parse_block_handles_escapes_and_quotes() {
793 let found = block(
794 "settings {\n \"main\": { \"description\": \"line\\n\\t\\\"quoted\\\"\" },\n \"gamemodes\": {}\n}\n",
795 );
796 let parsed = parse_block(&found).unwrap();
797 let cst::SettingsNode::Group { children, .. } = &parsed.children[0] else {
798 panic!("main group");
799 };
800 let cst::SettingsNode::String { value, .. } = &children[0] else {
801 panic!("description");
802 };
803 assert_eq!(value, "line\n\t\"quoted\"");
804 }
805
806 #[test]
807 fn parse_block_types_values() {
808 let found = block(
809 "settings {\n \"lobby\": { \"ffaSlots\": 6 },\n \"gamemodes\": { \"general\": { \"enableRandomHeroes\": true, \"respawnTime%\": 30, \"heroLimit\": \"off\" } },\n \"heroes\": { \"allTeams\": { \"enabledHeroes\": [\"mei\"] } }\n}\n",
810 );
811 let parsed = parse_block(&found).unwrap();
812 let lobby = match &parsed.children[0] {
813 cst::SettingsNode::Group { name, children, .. } => {
814 assert_eq!(name, "lobby");
815 children
816 }
817 other => panic!("{other:?}"),
818 };
819 assert!(matches!(
820 lobby[0],
821 cst::SettingsNode::Number { value: 6.0, .. }
822 ));
823 }
824
825 #[test]
826 fn spans_are_computed_from_block_base() {
827 let text = "settings {\n \"lobby\": {\n \"ffaSlots\": 6\n },\n \"gamemodes\": {}\n}\n";
828 let found = block(text);
829 let parsed = parse_block(&found).unwrap();
830 let cst::SettingsNode::Group { children, .. } = &parsed.children[0] else {
831 panic!("lobby");
832 };
833 let cst::SettingsNode::Number { span, .. } = &children[0] else {
834 panic!("ffaSlots");
835 };
836 assert_eq!(span.start.line, 3);
837 assert_eq!(span.start.col, 9);
838 }
839
840 #[test]
841 fn keyword_span_carries_the_file_id() {
842 let found = block("settings {\n \"gamemodes\": {}\n}\n");
843 assert_eq!(found.keyword_span.file, 0);
844 assert_eq!(found.keyword_span.start.col, 1);
845 assert_eq!(found.keyword_span.end.col, 9);
846 }
847}