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