1use crate::ast::{CobbleType, Import, Program};
2use crate::parser::parse;
3use std::collections::{BTreeMap, HashMap, HashSet};
4use std::fs;
5use std::path::{Path, PathBuf};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum DiagnosticSeverity {
9 Error,
10 Warning,
11}
12
13impl DiagnosticSeverity {
14 pub fn as_str(self) -> &'static str {
15 match self {
16 DiagnosticSeverity::Error => "error",
17 DiagnosticSeverity::Warning => "warning",
18 }
19 }
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct SourceDiagnostic {
24 pub severity: DiagnosticSeverity,
25 pub kind: String,
26 pub line: usize,
27 pub column: usize,
28 pub message: String,
29 pub help: Option<String>,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct FileSourceDiagnostics {
34 pub path: PathBuf,
35 pub source: String,
36 pub diagnostics: Vec<SourceDiagnostic>,
37}
38
39impl FileSourceDiagnostics {
40 pub fn new(
41 path: impl Into<PathBuf>,
42 source: impl Into<String>,
43 diagnostics: Vec<SourceDiagnostic>,
44 ) -> Self {
45 Self {
46 path: path.into(),
47 source: source.into(),
48 diagnostics,
49 }
50 }
51
52 pub fn format_compact(&self) -> String {
53 format_diagnostics_with_source(
54 &self.path.display().to_string(),
55 &self.source,
56 &self.diagnostics,
57 )
58 }
59}
60
61#[derive(Debug, Clone, PartialEq)]
62pub struct ParsedSourceFile {
63 pub path: PathBuf,
64 pub source: String,
65 pub program: Program,
66}
67
68impl SourceDiagnostic {
69 pub fn error(
70 kind: impl Into<String>,
71 line: usize,
72 column: usize,
73 message: impl Into<String>,
74 ) -> Self {
75 Self {
76 severity: DiagnosticSeverity::Error,
77 kind: kind.into(),
78 line: line.max(1),
79 column: column.max(1),
80 message: message.into(),
81 help: None,
82 }
83 }
84
85 pub fn with_help(mut self, help: impl Into<String>) -> Self {
86 self.help = Some(help.into());
87 self
88 }
89
90 pub fn format_compact(&self, filename: &str) -> String {
91 let mut output = format!(
92 "{}:{}:{}: {}[{}] {}",
93 filename,
94 self.line,
95 self.column,
96 self.severity.as_str(),
97 self.kind,
98 self.message
99 );
100 if let Some(help) = &self.help {
101 output.push_str("\n help: ");
102 output.push_str(help);
103 }
104 output
105 }
106
107 pub fn format_with_source(&self, filename: &str, source: &str) -> String {
108 let mut output = self.format_header(filename);
109 if let Some(snippet) = format_source_snippet(source, self.line, self.column) {
110 output.push('\n');
111 output.push_str(&snippet);
112 }
113 if let Some(help) = &self.help {
114 output.push('\n');
115 output.push_str(&format_help(help));
116 }
117 output
118 }
119
120 fn format_header(&self, filename: &str) -> String {
121 format!(
122 "{}:{}:{}: {}[{}] {}",
123 filename,
124 self.line,
125 self.column,
126 self.severity.as_str(),
127 self.kind,
128 self.message
129 )
130 }
131}
132
133pub fn format_diagnostics(filename: &str, diagnostics: &[SourceDiagnostic]) -> String {
134 diagnostics
135 .iter()
136 .map(|diagnostic| diagnostic.format_compact(filename))
137 .collect::<Vec<_>>()
138 .join("\n")
139}
140
141pub fn format_diagnostics_with_source(
142 filename: &str,
143 source: &str,
144 diagnostics: &[SourceDiagnostic],
145) -> String {
146 diagnostics
147 .iter()
148 .map(|diagnostic| diagnostic.format_with_source(filename, source))
149 .collect::<Vec<_>>()
150 .join("\n")
151}
152
153pub fn format_file_diagnostics(diagnostics: &[FileSourceDiagnostics]) -> String {
154 diagnostics
155 .iter()
156 .map(FileSourceDiagnostics::format_compact)
157 .collect::<Vec<_>>()
158 .join("\n")
159}
160
161fn format_source_snippet(source: &str, line: usize, column: usize) -> Option<String> {
162 let source_line = source.lines().nth(line.checked_sub(1)?)?;
163 let gutter = line.to_string();
164 let caret_padding = source_line
165 .chars()
166 .take(column.saturating_sub(1))
167 .map(|ch| if ch == '\t' { '\t' } else { ' ' })
168 .collect::<String>();
169 let gutter_padding = " ".repeat(gutter.len());
170
171 Some(format!(
172 "{gutter_padding} |\n{gutter} | {source_line}\n{gutter_padding} | {caret_padding}^"
173 ))
174}
175
176fn format_help(help: &str) -> String {
177 let mut output = String::new();
178 for (index, line) in help.lines().enumerate() {
179 if index == 0 {
180 output.push_str(" help: ");
181 } else {
182 output.push_str("\n ");
183 }
184 output.push_str(line);
185 }
186 output
187}
188
189pub fn parse_source(source: &str) -> Result<Program, Vec<SourceDiagnostic>> {
190 let diagnostics = analyze_source(source);
191 if !diagnostics.is_empty() {
192 return Err(diagnostics);
193 }
194
195 parse(source).map_err(|errors| {
196 errors
197 .into_iter()
198 .map(|error| SourceDiagnostic::error("parse", 1, 1, format!("Parse error: {error}")))
199 .collect()
200 })
201}
202
203pub fn analyze_in_memory_imports(source: &str, imports: &[Import]) -> Vec<SourceDiagnostic> {
204 let mut diagnostics = Vec::new();
205
206 for import in imports {
207 if import.module == "stdlib" {
208 continue;
209 }
210
211 let (line, column) = find_import_location(source, import).unwrap_or((1, 1));
212 diagnostics.push(
213 SourceDiagnostic::error(
214 "missing-import",
215 line,
216 column,
217 format!("Cannot import '{}': no import file is available", import.module),
218 )
219 .with_help(
220 "The browser compiler accepts one in-memory Cobble file. Use `stdlib` imports or compile multi-file projects with the CLI.",
221 ),
222 );
223 }
224
225 diagnostics
226}
227
228pub fn parse_source_file(path: &Path) -> Result<ParsedSourceFile, Vec<FileSourceDiagnostics>> {
229 let parsed_files = parse_source_file_tree(path)?;
230 let canonical_path = canonical_or_original(path);
231 parsed_files
232 .into_iter()
233 .find(|file| canonical_or_original(&file.path) == canonical_path)
234 .ok_or_else(|| {
235 vec![FileSourceDiagnostics::new(
236 path,
237 "",
238 vec![SourceDiagnostic::error(
239 "source-read",
240 1,
241 1,
242 "Parsed source file was not returned by the import tree",
243 )],
244 )]
245 })
246}
247
248pub fn parse_source_files(
249 paths: &[PathBuf],
250) -> Result<Vec<ParsedSourceFile>, Vec<FileSourceDiagnostics>> {
251 let mut roots = Vec::new();
252 let mut all_files = Vec::new();
253 let mut seen_files = HashSet::new();
254 let mut diagnostics = Vec::new();
255
256 for path in paths {
257 match parse_source_file_tree(path) {
258 Ok(parsed_tree) => {
259 let canonical_root = canonical_or_original(path);
260 if let Some(root) = parsed_tree
261 .iter()
262 .find(|file| canonical_or_original(&file.path) == canonical_root)
263 {
264 roots.push(root.clone());
265 }
266
267 for parsed_file in parsed_tree {
268 let canonical_path = canonical_or_original(&parsed_file.path);
269 if seen_files.insert(canonical_path) {
270 all_files.push(parsed_file);
271 }
272 }
273 }
274 Err(mut file_diagnostics) => diagnostics.append(&mut file_diagnostics),
275 }
276 }
277
278 if !diagnostics.is_empty() {
279 return Err(diagnostics);
280 }
281
282 let cross_file_diagnostics = analyze_cross_file_functions(&all_files);
283 if !cross_file_diagnostics.is_empty() {
284 return Err(cross_file_diagnostics);
285 }
286
287 Ok(roots)
288}
289
290fn parse_source_file_tree(
291 path: &Path,
292) -> Result<Vec<ParsedSourceFile>, Vec<FileSourceDiagnostics>> {
293 let source = fs::read_to_string(path).map_err(|error| {
294 vec![FileSourceDiagnostics::new(
295 path,
296 "",
297 vec![SourceDiagnostic::error(
298 "source-read",
299 1,
300 1,
301 format!("Failed to read source file: {error}"),
302 )],
303 )]
304 })?;
305
306 let program = parse_source(&source)
307 .map_err(|diagnostics| vec![FileSourceDiagnostics::new(path, &source, diagnostics)])?;
308
309 let canonical_path = canonical_or_original(path);
310 let mut visited = HashSet::from([canonical_path.clone()]);
311 let mut stack = vec![canonical_path];
312 let mut diagnostics = Vec::new();
313 let mut imported_files = Vec::new();
314 analyze_import_tree(
315 path,
316 &source,
317 &program.imports,
318 &mut visited,
319 &mut stack,
320 &mut diagnostics,
321 &mut imported_files,
322 );
323
324 if !diagnostics.is_empty() {
325 return Err(diagnostics);
326 }
327
328 let mut parsed_files = imported_files;
329 parsed_files.push(ParsedSourceFile {
330 path: path.to_path_buf(),
331 source: source.clone(),
332 program: program.clone(),
333 });
334
335 let cross_file_diagnostics = analyze_cross_file_functions(&parsed_files);
336 if !cross_file_diagnostics.is_empty() {
337 return Err(cross_file_diagnostics);
338 }
339
340 Ok(parsed_files)
341}
342
343fn analyze_import_tree(
344 current_path: &Path,
345 current_source: &str,
346 imports: &[Import],
347 visited: &mut HashSet<PathBuf>,
348 stack: &mut Vec<PathBuf>,
349 output: &mut Vec<FileSourceDiagnostics>,
350 parsed_imports: &mut Vec<ParsedSourceFile>,
351) {
352 let current_dir = current_path.parent().unwrap_or_else(|| Path::new("."));
353
354 for import in imports {
355 if import.module == "stdlib" {
356 continue;
357 }
358
359 let (line, column) = find_import_location(current_source, import).unwrap_or((1, 1));
360
361 if !is_simple_module_name(&import.module) {
362 output.push(FileSourceDiagnostics::new(
363 current_path,
364 current_source,
365 vec![SourceDiagnostic::error(
366 "unsupported-import",
367 line,
368 column,
369 format!("Invalid module name `{}`", import.module),
370 )
371 .with_help(
372 "Module names must be simple identifiers such as `helpers` or `utils`.",
373 )],
374 ));
375 continue;
376 }
377
378 let import_path = current_dir.join(format!("{}.cbl", import.module));
379 let canonical_path = canonical_or_original(&import_path);
380
381 if stack.contains(&canonical_path) {
382 output.push(FileSourceDiagnostics::new(
383 current_path,
384 current_source,
385 vec![
386 SourceDiagnostic::error(
387 "circular-import",
388 line,
389 column,
390 format!("Circular import detected while importing `{}`", import.module),
391 )
392 .with_help(format!(
393 "Import chain: {}. Import cycles are not supported because they make initialization order ambiguous.",
394 format_import_chain(stack, &canonical_path)
395 )),
396 ],
397 ));
398 continue;
399 }
400
401 if visited.contains(&canonical_path) {
402 if let Some(diagnostics) =
403 validate_import_items_from_path(current_path, current_source, import, &import_path)
404 {
405 output.push(diagnostics);
406 }
407 continue;
408 }
409
410 if !canonical_path.exists() && !import_path.exists() {
411 output.push(FileSourceDiagnostics::new(
412 current_path,
413 current_source,
414 vec![SourceDiagnostic::error(
415 "missing-import",
416 line,
417 column,
418 format!(
419 "Cannot import '{}': file '{}' was not found",
420 import.module,
421 import_path.display()
422 ),
423 )
424 .with_help(format!(
425 "Importing file: {}. Create the missing `.cbl` file or remove the import.",
426 current_path.display()
427 ))],
428 ));
429 continue;
430 }
431
432 let imported_source = match fs::read_to_string(&import_path) {
433 Ok(source) => source,
434 Err(error) => {
435 output.push(FileSourceDiagnostics::new(
436 current_path,
437 current_source,
438 vec![SourceDiagnostic::error(
439 "import-read",
440 line,
441 column,
442 format!(
443 "Failed to read imported module `{}` from `{}`: {error}",
444 import.module,
445 import_path.display()
446 ),
447 )
448 .with_help(format!("Importing file: {}", current_path.display()))],
449 ));
450 continue;
451 }
452 };
453
454 let imported_program = match parse_source(&imported_source) {
455 Ok(program) => program,
456 Err(mut diagnostics) => {
457 add_import_chain_help(&mut diagnostics, stack, &canonical_path);
458 output.push(FileSourceDiagnostics::new(
459 &import_path,
460 imported_source,
461 diagnostics,
462 ));
463 continue;
464 }
465 };
466
467 if let Some(diagnostics) =
468 validate_import_items(current_path, current_source, import, &imported_source)
469 {
470 output.push(diagnostics);
471 continue;
472 }
473
474 visited.insert(canonical_path.clone());
475 stack.push(canonical_path.clone());
476 analyze_import_tree(
477 &import_path,
478 &imported_source,
479 &imported_program.imports,
480 visited,
481 stack,
482 output,
483 parsed_imports,
484 );
485 stack.pop();
486
487 parsed_imports.push(ParsedSourceFile {
488 path: import_path,
489 source: imported_source,
490 program: imported_program,
491 });
492 }
493}
494
495fn analyze_cross_file_functions(files: &[ParsedSourceFile]) -> Vec<FileSourceDiagnostics> {
496 let mut signatures = HashMap::new();
497 let mut duplicate_diagnostics: BTreeMap<usize, Vec<SourceDiagnostic>> = BTreeMap::new();
498
499 for (file_index, file) in files.iter().enumerate() {
500 for signature in collect_file_function_signatures(&file.path, &file.source) {
501 if let Some(first) = signatures.get(&signature.name) {
502 duplicate_diagnostics.entry(file_index).or_default().push(
503 SourceDiagnostic::error(
504 "duplicate-function",
505 signature.line,
506 signature.column,
507 format!(
508 "Duplicate function definition `{}` across imported files",
509 signature.name
510 ),
511 )
512 .with_help(format!(
513 "First definition is at {}. Rename one function or split the generated function names.",
514 format_file_signature_location(first)
515 )),
516 );
517 } else {
518 signatures.insert(signature.name.clone(), signature);
519 }
520 }
521 }
522
523 if !duplicate_diagnostics.is_empty() {
524 return file_diagnostics_from_map(files, duplicate_diagnostics);
525 }
526
527 let duplicate_symbol_diagnostics = analyze_cross_file_named_symbols(files);
528 if !duplicate_symbol_diagnostics.is_empty() {
529 return duplicate_symbol_diagnostics;
530 }
531
532 let mut call_diagnostics: BTreeMap<usize, Vec<SourceDiagnostic>> = BTreeMap::new();
533 for (file_index, file) in files.iter().enumerate() {
534 collect_cross_file_call_diagnostics(file_index, file, &signatures, &mut call_diagnostics);
535 }
536
537 file_diagnostics_from_map(files, call_diagnostics)
538}
539
540fn analyze_cross_file_named_symbols(files: &[ParsedSourceFile]) -> Vec<FileSourceDiagnostics> {
541 let mut symbols = HashMap::new();
542 let mut duplicate_diagnostics: BTreeMap<usize, Vec<SourceDiagnostic>> = BTreeMap::new();
543
544 for (file_index, file) in files.iter().enumerate() {
545 for symbol in collect_file_named_symbols(&file.path, &file.source) {
546 let key = (symbol.kind, symbol.name.clone());
547 if let Some(first) = symbols.get(&key) {
548 duplicate_diagnostics.entry(file_index).or_default().push(
549 SourceDiagnostic::error(
550 "duplicate-symbol",
551 symbol.line,
552 symbol.column,
553 format!(
554 "Duplicate {} `{}` across imported files",
555 symbol.kind.label(),
556 symbol.display_name()
557 ),
558 )
559 .with_help(format!(
560 "First definition is at {}. Rename one definition so imports do not overwrite each other.",
561 format_file_symbol_location(first)
562 )),
563 );
564 } else {
565 symbols.insert(key, symbol);
566 }
567 }
568 }
569
570 file_diagnostics_from_map(files, duplicate_diagnostics)
571}
572
573fn validate_import_items_from_path(
574 current_path: &Path,
575 current_source: &str,
576 import: &Import,
577 import_path: &Path,
578) -> Option<FileSourceDiagnostics> {
579 if import.items.is_empty() || import.module == "stdlib" {
580 return None;
581 }
582
583 let imported_source = fs::read_to_string(import_path).ok()?;
584 validate_import_items(current_path, current_source, import, &imported_source)
585}
586
587fn validate_import_items(
588 current_path: &Path,
589 current_source: &str,
590 import: &Import,
591 imported_source: &str,
592) -> Option<FileSourceDiagnostics> {
593 if import.items.is_empty() || import.module == "stdlib" {
594 return None;
595 }
596
597 let exported_symbol_kinds = collect_exported_symbol_kinds(imported_source);
598 let exported_symbols = exported_symbol_kinds
599 .keys()
600 .cloned()
601 .collect::<HashSet<_>>();
602 let mut diagnostics = Vec::new();
603 for item in &import.items {
604 match exported_symbol_kinds.get(item) {
605 Some(kind) => {
606 if kind.is_command_placeholder_value() {
607 continue;
608 }
609
610 for (line, column) in raw_command_placeholder_locations(current_source, item) {
611 diagnostics.push(
612 SourceDiagnostic::error(
613 "unsupported-placeholder-symbol",
614 line,
615 column,
616 format!(
617 "Imported {} `{}` cannot be used as a command placeholder",
618 kind.label(),
619 item
620 ),
621 )
622 .with_help(
623 "Command placeholders such as `{score}` can only reference function parameters, loop variables, or value symbols backed by assignments, consts, or globals.",
624 ),
625 );
626 }
627 }
628 None => {
629 let (line, column) = find_import_item_location(current_source, import, item)
630 .unwrap_or_else(|| {
631 find_import_location(current_source, import).unwrap_or((1, 1))
632 });
633
634 diagnostics.push(
635 SourceDiagnostic::error(
636 "missing-import-item",
637 line,
638 column,
639 format!(
640 "Cannot import `{}` from `{}`: symbol was not found",
641 item, import.module
642 ),
643 )
644 .with_help(format_missing_import_item_help(
645 &import.module,
646 &exported_symbols,
647 )),
648 );
649 }
650 }
651 }
652
653 if diagnostics.is_empty() {
654 None
655 } else {
656 Some(FileSourceDiagnostics::new(
657 current_path,
658 current_source,
659 diagnostics,
660 ))
661 }
662}
663
664#[derive(Debug, Clone, Copy, PartialEq, Eq)]
665enum ExportedSymbolKind {
666 Value,
667 Function,
668 SelectorAlias,
669 EntityTemplate,
670}
671
672impl ExportedSymbolKind {
673 fn is_command_placeholder_value(self) -> bool {
674 matches!(self, Self::Value)
675 }
676
677 fn label(self) -> &'static str {
678 match self {
679 Self::Value => "value",
680 Self::Function => "function",
681 Self::SelectorAlias => "selector alias",
682 Self::EntityTemplate => "entity template",
683 }
684 }
685}
686
687fn collect_exported_symbol_kinds(source: &str) -> HashMap<String, ExportedSymbolKind> {
688 let mut symbols = HashSet::new();
689 let mut symbol_kinds = HashMap::new();
690 let mut active_docstring_quote = None;
691
692 for (line_index, line) in source.lines().enumerate() {
693 let line_number = line_index + 1;
694 let raw_trimmed = line.trim_start();
695 if should_skip_docstring_scan_line(line, raw_trimmed, &mut active_docstring_quote) {
696 continue;
697 }
698
699 let masked = mask_non_code(line);
700 let trimmed = masked.trim_start();
701 if trimmed.is_empty() || trimmed.starts_with('/') {
702 continue;
703 }
704
705 let indent = masked.len() - trimmed.len();
706 if let Some(signature) = parse_function_signature(line, trimmed, line_number, indent) {
707 symbol_kinds.insert(signature.name, ExportedSymbolKind::Function);
708 continue;
709 }
710
711 if let Some(name) = selector_alias_name(trimmed) {
712 symbol_kinds.insert(name.to_string(), ExportedSymbolKind::SelectorAlias);
713 continue;
714 }
715
716 if let Some(name) = entity_definition_name(trimmed) {
717 symbol_kinds.insert(name.to_string(), ExportedSymbolKind::EntityTemplate);
718 continue;
719 }
720
721 collect_assignment_export(trimmed, &mut symbols);
722 for name in symbols.drain() {
723 symbol_kinds.insert(name, ExportedSymbolKind::Value);
724 }
725 collect_global_export(trimmed, &mut symbols);
726 for name in symbols.drain() {
727 symbol_kinds.insert(name, ExportedSymbolKind::Value);
728 }
729 }
730
731 symbol_kinds
732}
733
734fn selector_alias_name(trimmed: &str) -> Option<&str> {
735 if !looks_like_selector_definition(trimmed) {
736 return None;
737 }
738 let left = trimmed.split_once('=')?.0.trim();
739 left.strip_prefix('@')
740 .filter(|name| is_simple_module_name(name))
741}
742
743fn entity_definition_name(trimmed: &str) -> Option<&str> {
744 let rest = trimmed.strip_prefix("define ")?.trim_start();
745 let name = rest.split_whitespace().next()?.strip_prefix('@')?;
746 if is_simple_module_name(name) {
747 Some(name)
748 } else {
749 None
750 }
751}
752
753fn collect_assignment_export(trimmed: &str, symbols: &mut HashSet<String>) {
754 if should_skip_assignment_symbol_scan(trimmed) {
755 return;
756 }
757
758 let Some(equals_index) = single_equals_index(trimmed) else {
759 return;
760 };
761 let raw_target = trimmed[..equals_index].trim();
762 let target = raw_target
763 .strip_prefix("const ")
764 .unwrap_or(raw_target)
765 .trim();
766 if is_simple_module_name(target) {
767 symbols.insert(target.to_string());
768 }
769}
770
771fn collect_global_export(trimmed: &str, symbols: &mut HashSet<String>) {
772 let Some(rest) = trimmed.strip_prefix("global ") else {
773 return;
774 };
775 for name in rest.trim_end_matches(':').split(',') {
776 let name = name.trim();
777 if is_simple_module_name(name) {
778 symbols.insert(name.to_string());
779 }
780 }
781}
782
783fn format_missing_import_item_help(module: &str, exported_symbols: &HashSet<String>) -> String {
784 if exported_symbols.is_empty() {
785 return format!("Module `{module}` does not define importable symbols.");
786 }
787
788 let mut symbols = exported_symbols.iter().cloned().collect::<Vec<_>>();
789 symbols.sort();
790 format!(
791 "Check the imported name or add it to `{module}.cbl`. Available symbols: {}",
792 symbols.join(", ")
793 )
794}
795
796fn collect_cross_file_call_diagnostics(
797 file_index: usize,
798 file: &ParsedSourceFile,
799 signatures: &HashMap<String, FileFunctionSignature>,
800 output: &mut BTreeMap<usize, Vec<SourceDiagnostic>>,
801) {
802 let mut active_docstring_quote = None;
803 for (line_index, line) in file.source.lines().enumerate() {
804 let line_number = line_index + 1;
805 let raw_trimmed = line.trim_start();
806 if should_skip_docstring_scan_line(line, raw_trimmed, &mut active_docstring_quote) {
807 continue;
808 }
809
810 let masked = mask_non_code(line);
811 let trimmed = masked.trim_start();
812 if should_skip_call_argument_scan(trimmed) {
813 continue;
814 }
815
816 let indent = masked.len() - trimmed.len();
817 for call in function_calls_in_expression(trimmed) {
818 let Some(signature) = signatures.get(&call.name) else {
819 if !is_user_function_call_statement(trimmed, &call) {
820 continue;
821 }
822
823 if let Some((module, method)) = split_module_call_name(&call.name) {
824 if is_value_only_module_call(module, method) {
825 output.entry(file_index).or_default().push(
826 SourceDiagnostic::error(
827 "unsupported-function-call-expression",
828 line_number,
829 column_from_byte(line, indent + call.offset),
830 format!(
831 "`{}` returns a value and cannot be used as a standalone statement",
832 call.name
833 ),
834 )
835 .with_help(
836 "Pass the returned value to another helper or JSON resource value instead.",
837 ),
838 );
839 continue;
840 }
841
842 if is_known_module_call(module, method) {
843 continue;
844 }
845
846 output.entry(file_index).or_default().push(
847 SourceDiagnostic::error(
848 "undefined-function",
849 line_number,
850 column_from_byte(line, indent + call.offset),
851 format!("Unknown helper function `{}`", call.name),
852 )
853 .with_help(
854 "Use a documented Cobble helper module/function, define a Cobble function, or use a raw Minecraft command for external behavior.",
855 ),
856 );
857 continue;
858 }
859
860 if !is_known_non_user_function_call(&call.name) {
861 output.entry(file_index).or_default().push(
862 SourceDiagnostic::error(
863 "undefined-function",
864 line_number,
865 column_from_byte(line, indent + call.offset),
866 format!("Undefined function `{}`", call.name),
867 )
868 .with_help(
869 "Define the Cobble function, import a file that defines it, or use a raw `/function namespace:path` command for external Minecraft functions.",
870 ),
871 );
872 }
873 continue;
874 };
875
876 if call.arg_count != signature.params.len() {
877 output.entry(file_index).or_default().push(
878 SourceDiagnostic::error(
879 "function-argument-count",
880 line_number,
881 column_from_byte(line, indent + call.offset),
882 format!(
883 "Function `{}` expects {} argument(s), but {} provided",
884 signature.name,
885 signature.params.len(),
886 call.arg_count
887 ),
888 )
889 .with_help(format!(
890 "Expected parameters: ({}). Definition is at {}.",
891 signature.params.join(", "),
892 format_file_signature_location(signature)
893 )),
894 );
895 continue;
896 }
897
898 for argument in &call.arguments {
899 let Some(nested_call) = function_calls_in_expression(&argument.text)
900 .into_iter()
901 .next()
902 else {
903 continue;
904 };
905
906 output.entry(file_index).or_default().push(
907 SourceDiagnostic::error(
908 "unsupported-function-call-argument",
909 line_number,
910 column_from_byte(line, indent + argument.offset + nested_call.offset),
911 format!(
912 "Function `{}` arguments cannot contain function call expressions",
913 signature.name
914 ),
915 )
916 .with_help(format!(
917 "Call Cobble functions as standalone statements, or pass a literal, variable, selector, or storage-backed value. Definition is at {}.",
918 format_file_signature_location(signature)
919 )),
920 );
921 }
922 }
923 }
924}
925
926fn collect_file_function_signatures(path: &Path, source: &str) -> Vec<FileFunctionSignature> {
927 let mut signatures = Vec::new();
928 let mut active_docstring_quote = None;
929
930 for (line_index, line) in source.lines().enumerate() {
931 let line_number = line_index + 1;
932 let raw_trimmed = line.trim_start();
933 if should_skip_docstring_scan_line(line, raw_trimmed, &mut active_docstring_quote) {
934 continue;
935 }
936
937 let masked = mask_non_code(line);
938 let trimmed = masked.trim_start();
939 if trimmed.is_empty() || trimmed.starts_with('/') {
940 continue;
941 }
942
943 let indent = masked.len() - trimmed.len();
944 if let Some(signature) = parse_function_signature(line, trimmed, line_number, indent) {
945 signatures.push(FileFunctionSignature {
946 path: path.to_path_buf(),
947 name: signature.name,
948 params: signature.params,
949 line: signature.line,
950 column: signature.column,
951 });
952 }
953 }
954
955 signatures
956}
957
958fn collect_file_named_symbols(path: &Path, source: &str) -> Vec<FileNamedSymbol> {
959 let mut symbols = Vec::new();
960 let mut active_docstring_quote = None;
961
962 for (line_index, line) in source.lines().enumerate() {
963 let line_number = line_index + 1;
964 let raw_trimmed = line.trim_start();
965 if should_skip_docstring_scan_line(line, raw_trimmed, &mut active_docstring_quote) {
966 continue;
967 }
968
969 let masked = mask_non_code(line);
970 let trimmed = masked.trim_start();
971 if trimmed.is_empty() || trimmed.starts_with('/') {
972 continue;
973 }
974
975 let indent = masked.len() - trimmed.len();
976 if let Some(name) = selector_alias_name(trimmed) {
977 symbols.push(FileNamedSymbol {
978 path: path.to_path_buf(),
979 name: name.to_string(),
980 kind: FileSymbolKind::SelectorAlias,
981 line: line_number,
982 column: column_from_byte(line, indent),
983 });
984 continue;
985 }
986
987 if let Some(name) = entity_definition_name(trimmed) {
988 let name_offset = trimmed.find(name).unwrap_or(0);
989 symbols.push(FileNamedSymbol {
990 path: path.to_path_buf(),
991 name: name.to_string(),
992 kind: FileSymbolKind::EntityTemplate,
993 line: line_number,
994 column: column_from_byte(line, indent + name_offset.saturating_sub(1)),
995 });
996 }
997 }
998
999 symbols
1000}
1001
1002fn file_diagnostics_from_map(
1003 files: &[ParsedSourceFile],
1004 diagnostics_by_file: BTreeMap<usize, Vec<SourceDiagnostic>>,
1005) -> Vec<FileSourceDiagnostics> {
1006 diagnostics_by_file
1007 .into_iter()
1008 .map(|(file_index, diagnostics)| {
1009 let file = &files[file_index];
1010 FileSourceDiagnostics::new(&file.path, &file.source, diagnostics)
1011 })
1012 .collect()
1013}
1014
1015fn format_file_signature_location(signature: &FileFunctionSignature) -> String {
1016 format!(
1017 "{}:{}:{}",
1018 signature.path.display(),
1019 signature.line,
1020 signature.column
1021 )
1022}
1023
1024fn format_file_symbol_location(symbol: &FileNamedSymbol) -> String {
1025 format!(
1026 "{}:{}:{}",
1027 symbol.path.display(),
1028 symbol.line,
1029 symbol.column
1030 )
1031}
1032
1033pub fn analyze_source(source: &str) -> Vec<SourceDiagnostic> {
1034 let mut diagnostics = Vec::new();
1035 check_structural_syntax(source, &mut diagnostics);
1036 if !diagnostics.is_empty() {
1037 return diagnostics;
1038 }
1039 check_indentation_syntax(source, &mut diagnostics);
1040 if !diagnostics.is_empty() {
1041 return diagnostics;
1042 }
1043
1044 let mut active_for_blocks: Vec<usize> = Vec::new();
1045 let mut active_docstring_quote: Option<char> = None;
1046 let mut multiline_expression_depth = 0usize;
1047 let mut function_defs: HashMap<String, FunctionSignature> = HashMap::new();
1048
1049 for (line_index, line) in source.lines().enumerate() {
1050 let line_number = line_index + 1;
1051 if let Some(quote) = active_docstring_quote {
1052 if find_triple_quote(line, quote, 0).is_some() {
1053 active_docstring_quote = None;
1054 }
1055 continue;
1056 }
1057
1058 let raw_trimmed = line.trim_start();
1059 if let Some(quote) = leading_triple_quote(raw_trimmed) {
1060 if find_triple_quote(raw_trimmed, quote, 3).is_none() {
1061 active_docstring_quote = Some(quote);
1062 }
1063 continue;
1064 }
1065
1066 let masked = mask_non_code(line);
1067 let trimmed = masked.trim_start();
1068 if trimmed.is_empty() {
1069 continue;
1070 }
1071
1072 if multiline_expression_depth > 0 {
1073 if !trimmed.starts_with('/') {
1074 update_delimiter_depth_for_indentation(&masked, &mut multiline_expression_depth);
1075 }
1076 continue;
1077 }
1078
1079 let indent = masked.len() - trimmed.len();
1080 let trimmed_column = column_from_byte(line, indent);
1081 let is_for_else = starts_with_keyword(trimmed, "else")
1082 && active_for_blocks.last().copied() == Some(indent);
1083
1084 while let Some(block_indent) = active_for_blocks.last().copied() {
1085 if indent < block_indent || (indent == block_indent && !is_for_else) {
1086 active_for_blocks.pop();
1087 } else {
1088 break;
1089 }
1090 }
1091
1092 if trimmed.starts_with('/') {
1093 continue;
1094 }
1095
1096 if is_for_else {
1097 diagnostics.push(
1098 SourceDiagnostic::error(
1099 "unsupported-control-flow",
1100 line_number,
1101 trimmed_column,
1102 "`for ... else` blocks are not supported",
1103 )
1104 .with_help("Use an explicit flag variable and a normal `if` after the loop."),
1105 );
1106 }
1107
1108 if starts_with_keyword(trimmed, "for") && trimmed.ends_with(':') {
1109 active_for_blocks.push(indent);
1110 }
1111
1112 check_missing_block_colon(line, trimmed, line_number, &mut diagnostics);
1113 check_decorator(line, trimmed, line_number, trimmed_column, &mut diagnostics);
1114 check_unsupported_keywords(line, trimmed, line_number, indent, &mut diagnostics);
1115 check_imports(line, trimmed, line_number, indent, &mut diagnostics);
1116 check_function_definition(
1117 line,
1118 trimmed,
1119 line_number,
1120 indent,
1121 &mut function_defs,
1122 &mut diagnostics,
1123 );
1124 check_function_parameters(line, trimmed, line_number, indent, &mut diagnostics);
1125 check_return_statement(line, trimmed, line_number, indent, &mut diagnostics);
1126 check_compound_assignment(line, &masked, line_number, &mut diagnostics);
1127 check_assignment_target(line, trimmed, line_number, indent, &mut diagnostics);
1128 check_assignment_function_calls(line, trimmed, line_number, indent, &mut diagnostics);
1129 check_comprehension(line, &masked, line_number, &mut diagnostics);
1130 check_datapack_helper_argument_shapes(
1131 line,
1132 trimmed,
1133 raw_trimmed,
1134 line_number,
1135 indent,
1136 &mut diagnostics,
1137 );
1138 check_noop_expression_statement(
1139 line,
1140 trimmed,
1141 raw_trimmed,
1142 line_number,
1143 indent,
1144 &mut diagnostics,
1145 );
1146
1147 if !trimmed.starts_with('/') {
1148 update_delimiter_depth_for_indentation(&masked, &mut multiline_expression_depth);
1149 }
1150 }
1151
1152 if diagnostics.is_empty() {
1153 check_multiline_datapack_helper_argument_shapes(source, &mut diagnostics);
1154 }
1155
1156 check_user_function_call_arguments(source, &function_defs, &mut diagnostics);
1157 if diagnostics.is_empty() && !has_non_stdlib_imports(source) {
1158 check_undefined_function_calls(source, &function_defs, &mut diagnostics);
1159 }
1160 if diagnostics.is_empty() {
1161 let interpolation_symbols = collect_interpolation_symbols(source);
1162 check_raw_command_placeholders(source, &interpolation_symbols, &mut diagnostics);
1163 }
1164 if diagnostics.is_empty() {
1165 check_unsupported_none_usage(source, &mut diagnostics);
1166 }
1167 if diagnostics.is_empty() {
1168 check_storage_backed_access(source, &mut diagnostics);
1169 check_type_mismatches(source, &mut diagnostics);
1170 }
1171 if diagnostics.is_empty() {
1172 let symbols = collect_defined_symbols(source, &function_defs);
1173 check_undefined_variable_references(source, &symbols, &mut diagnostics);
1174 }
1175
1176 diagnostics
1177}
1178
1179fn check_indentation_syntax(source: &str, diagnostics: &mut Vec<SourceDiagnostic>) {
1180 let mut indent_stack = vec![0usize];
1181 let mut delimiter_depth = 0usize;
1182 let mut previous_allows_indent = false;
1183 let mut active_docstring_quote: Option<char> = None;
1184
1185 for (line_index, line) in source.lines().enumerate() {
1186 let line_number = line_index + 1;
1187 let raw_trimmed = line.trim_start();
1188
1189 if let Some(quote) = active_docstring_quote {
1190 if find_triple_quote(line, quote, 0).is_some() {
1191 active_docstring_quote = None;
1192 }
1193 continue;
1194 }
1195
1196 if raw_trimmed.is_empty() || raw_trimmed.starts_with('#') {
1197 continue;
1198 }
1199
1200 let masked = mask_non_code(line);
1201 let trimmed = masked.trim_start();
1202 if trimmed.is_empty() {
1203 continue;
1204 }
1205
1206 if delimiter_depth == 0 {
1207 let indent = masked.len() - trimmed.len();
1208 let current_indent = *indent_stack.last().unwrap_or(&0);
1209
1210 if indent > current_indent {
1211 if !previous_allows_indent {
1212 diagnostics.push(
1213 SourceDiagnostic::error(
1214 "unexpected-indentation",
1215 line_number,
1216 column_from_byte(line, indent),
1217 "Unexpected indentation",
1218 )
1219 .with_help(
1220 "Only indent after a block header ending with `:` or inside a multi-line expression.",
1221 ),
1222 );
1223 }
1224 indent_stack.push(indent);
1225 } else if indent < current_indent {
1226 while indent_stack.len() > 1 && *indent_stack.last().unwrap() > indent {
1227 indent_stack.pop();
1228 }
1229
1230 if *indent_stack.last().unwrap_or(&0) != indent {
1231 diagnostics.push(
1232 SourceDiagnostic::error(
1233 "inconsistent-indentation",
1234 line_number,
1235 column_from_byte(line, indent),
1236 "Indentation does not match a previous block level",
1237 )
1238 .with_help(format!(
1239 "Use one of the active indentation levels: {}.",
1240 format_indent_levels(&indent_stack)
1241 )),
1242 );
1243 indent_stack.push(indent);
1244 }
1245 }
1246 }
1247
1248 if !trimmed.starts_with('/') {
1249 update_delimiter_depth_for_indentation(&masked, &mut delimiter_depth);
1250 }
1251
1252 previous_allows_indent =
1253 delimiter_depth == 0 && (trimmed.ends_with(':') || looks_like_block_header(trimmed));
1254
1255 if let Some(quote) = leading_triple_quote(raw_trimmed) {
1256 let after_open = 3;
1257 if find_triple_quote(raw_trimmed, quote, after_open).is_none() {
1258 active_docstring_quote = Some(quote);
1259 }
1260 }
1261 }
1262}
1263
1264fn update_delimiter_depth_for_indentation(masked_line: &str, depth: &mut usize) {
1265 for ch in masked_line.chars() {
1266 match ch {
1267 '(' | '[' | '{' => *depth += 1,
1268 ')' | ']' | '}' => {
1269 *depth = depth.saturating_sub(1);
1270 }
1271 _ => {}
1272 }
1273 }
1274}
1275
1276fn should_skip_docstring_scan_line(
1277 line: &str,
1278 raw_trimmed: &str,
1279 active_quote: &mut Option<char>,
1280) -> bool {
1281 if let Some(quote) = *active_quote {
1282 if find_triple_quote(line, quote, 0).is_some() {
1283 *active_quote = None;
1284 }
1285 return true;
1286 }
1287
1288 if let Some(quote) = leading_triple_quote(raw_trimmed) {
1289 if find_triple_quote(raw_trimmed, quote, 3).is_none() {
1290 *active_quote = Some(quote);
1291 }
1292 return true;
1293 }
1294
1295 false
1296}
1297
1298fn format_indent_levels(levels: &[usize]) -> String {
1299 levels
1300 .iter()
1301 .map(|level| level.to_string())
1302 .collect::<Vec<_>>()
1303 .join(", ")
1304}
1305
1306fn check_structural_syntax(source: &str, diagnostics: &mut Vec<SourceDiagnostic>) {
1307 let mut delimiters = Vec::new();
1308 let mut triple_quote: Option<QuoteFrame> = None;
1309
1310 for (line_index, line) in source.lines().enumerate() {
1311 let line_number = line_index + 1;
1312 let mut index = 0;
1313
1314 if let Some(active_quote) = triple_quote {
1315 let Some(close_index) = find_triple_quote(line, active_quote.quote, 0) else {
1316 continue;
1317 };
1318 index = close_index + 3;
1319 triple_quote = None;
1320 }
1321
1322 if line.trim_start().starts_with('/') {
1323 continue;
1324 }
1325
1326 while index < line.len() {
1327 let ch = line[index..].chars().next().unwrap();
1328 let next_index = index + ch.len_utf8();
1329
1330 match ch {
1331 '#' => break,
1332 '"' | '\'' => {
1333 if line[index..].starts_with(triple_quote_pattern(ch)) {
1334 let after_open = index + 3;
1335 if let Some(close_index) = find_triple_quote(line, ch, after_open) {
1336 index = close_index + 3;
1337 } else {
1338 triple_quote = Some(QuoteFrame {
1339 quote: ch,
1340 line: line_number,
1341 column: column_from_byte(line, index),
1342 });
1343 break;
1344 }
1345 } else if let Some(close_index) = find_string_end(line, ch, next_index) {
1346 index = close_index;
1347 } else {
1348 diagnostics.push(
1349 SourceDiagnostic::error(
1350 "unterminated-string",
1351 line_number,
1352 column_from_byte(line, index),
1353 "String literal is missing a closing quote",
1354 )
1355 .with_help("Close the string on the same line, or use a triple-quoted docstring when documenting a block."),
1356 );
1357 break;
1358 }
1359 }
1360 '(' | '[' | '{' => {
1361 delimiters.push(DelimiterFrame {
1362 delimiter: ch,
1363 line: line_number,
1364 column: column_from_byte(line, index),
1365 });
1366 index = next_index;
1367 }
1368 ')' | ']' | '}' => {
1369 let close_column = column_from_byte(line, index);
1370 match delimiters.last().copied() {
1371 Some(open) if matching_close_delimiter(open.delimiter) == ch => {
1372 delimiters.pop();
1373 }
1374 Some(open) => {
1375 diagnostics.push(
1376 SourceDiagnostic::error(
1377 "unmatched-delimiter",
1378 line_number,
1379 close_column,
1380 format!("Unexpected closing delimiter `{ch}`"),
1381 )
1382 .with_help(format!(
1383 "The delimiter `{}` opened at line {}, column {} must close with `{}` before `{ch}`.",
1384 open.delimiter,
1385 open.line,
1386 open.column,
1387 matching_close_delimiter(open.delimiter)
1388 )),
1389 );
1390 delimiters.pop();
1391 }
1392 None => diagnostics.push(
1393 SourceDiagnostic::error(
1394 "unmatched-delimiter",
1395 line_number,
1396 close_column,
1397 format!("Unexpected closing delimiter `{ch}`"),
1398 )
1399 .with_help(format!(
1400 "Remove `{ch}` or add a matching opening delimiter before it."
1401 )),
1402 ),
1403 }
1404 index = next_index;
1405 }
1406 _ => index = next_index,
1407 }
1408 }
1409 }
1410
1411 if let Some(quote) = triple_quote {
1412 diagnostics.push(
1413 SourceDiagnostic::error(
1414 "unterminated-string",
1415 quote.line,
1416 quote.column,
1417 "Triple-quoted string is missing a closing delimiter",
1418 )
1419 .with_help(format!(
1420 "Add the closing `{}` before the end of the file.",
1421 triple_quote_pattern(quote.quote)
1422 )),
1423 );
1424 }
1425
1426 for delimiter in delimiters {
1427 diagnostics.push(
1428 SourceDiagnostic::error(
1429 "unclosed-delimiter",
1430 delimiter.line,
1431 delimiter.column,
1432 format!("Opening delimiter `{}` is not closed", delimiter.delimiter),
1433 )
1434 .with_help(format!(
1435 "Add the matching `{}` before the expression ends.",
1436 matching_close_delimiter(delimiter.delimiter)
1437 )),
1438 );
1439 }
1440}
1441
1442pub fn byte_offset_for_line_column(source: &str, line: usize, column: usize) -> usize {
1443 let target_line = line.max(1);
1444 let target_column = column.max(1);
1445 let mut offset = 0;
1446
1447 for (index, source_line) in source.split_inclusive('\n').enumerate() {
1448 if index + 1 == target_line {
1449 let without_newline = source_line.strip_suffix('\n').unwrap_or(source_line);
1450 let without_line_ending = without_newline
1451 .strip_suffix('\r')
1452 .unwrap_or(without_newline);
1453 let column_offset = without_line_ending
1454 .char_indices()
1455 .nth(target_column.saturating_sub(1))
1456 .map(|(byte_index, _)| byte_index)
1457 .unwrap_or(without_line_ending.len());
1458 return offset + column_offset;
1459 }
1460 offset += source_line.len();
1461 }
1462
1463 source.len()
1464}
1465
1466fn check_decorator(
1467 line: &str,
1468 trimmed: &str,
1469 line_number: usize,
1470 column: usize,
1471 diagnostics: &mut Vec<SourceDiagnostic>,
1472) {
1473 if !trimmed.starts_with('@') || looks_like_selector_definition(trimmed) {
1474 return;
1475 }
1476
1477 diagnostics.push(
1478 SourceDiagnostic::error(
1479 "unsupported-decorator",
1480 line_number,
1481 column,
1482 "Decorators are not supported",
1483 )
1484 .with_help(
1485 "Use explicit stdlib registration calls such as `stdlib.addEventListener(...)`.",
1486 ),
1487 );
1488
1489 if trimmed.contains('=') && !line.contains(" = ") {
1490 diagnostics.push(
1491 SourceDiagnostic::error(
1492 "unsupported-assignment-target",
1493 line_number,
1494 column,
1495 "Selector aliases must use `@Name = @selector` syntax",
1496 )
1497 .with_help("Decorator arguments are not selector definitions."),
1498 );
1499 }
1500}
1501
1502fn check_unsupported_keywords(
1503 line: &str,
1504 trimmed: &str,
1505 line_number: usize,
1506 indent: usize,
1507 diagnostics: &mut Vec<SourceDiagnostic>,
1508) {
1509 for (keyword, help) in [
1510 (
1511 "class",
1512 "Cobble has functions and compile-time helpers, but no classes.",
1513 ),
1514 ("try", "Minecraft functions do not have exception handling."),
1515 (
1516 "except",
1517 "Minecraft functions do not have exception handling.",
1518 ),
1519 (
1520 "finally",
1521 "Minecraft functions do not have exception handling.",
1522 ),
1523 (
1524 "with",
1525 "Use explicit function calls or resource declarations instead.",
1526 ),
1527 (
1528 "break",
1529 "Cobble loops compile to Minecraft function commands and do not support early loop exits.",
1530 ),
1531 (
1532 "continue",
1533 "Cobble loops compile to Minecraft function commands and do not support early loop continuation.",
1534 ),
1535 (
1536 "raise",
1537 "Minecraft functions do not have exception handling.",
1538 ),
1539 (
1540 "assert",
1541 "Use explicit `if` blocks and commands to report failed conditions.",
1542 ),
1543 (
1544 "del",
1545 "Cobble variables are generated scoreboard or storage state; delete statements are not supported.",
1546 ),
1547 (
1548 "nonlocal",
1549 "Cobble does not have Python-style nested runtime scopes.",
1550 ),
1551 ] {
1552 if starts_with_keyword(trimmed, keyword) {
1553 diagnostics.push(
1554 SourceDiagnostic::error(
1555 "unsupported-python-syntax",
1556 line_number,
1557 column_from_byte(line, indent),
1558 format!("`{keyword}` is not supported in Cobble"),
1559 )
1560 .with_help(help),
1561 );
1562 }
1563 }
1564
1565 for (keyword, help) in [
1566 ("lambda", "Define a named Cobble function instead."),
1567 ("yield", "Cobble functions cannot yield runtime values."),
1568 ("await", "Cobble does not have async runtime semantics."),
1569 ] {
1570 if let Some(index) = find_word(trimmed, keyword) {
1571 diagnostics.push(
1572 SourceDiagnostic::error(
1573 "unsupported-python-syntax",
1574 line_number,
1575 column_from_byte(line, indent + index),
1576 format!("`{keyword}` is not supported in Cobble"),
1577 )
1578 .with_help(help),
1579 );
1580 }
1581 }
1582
1583 if starts_with_keyword(trimmed, "async") {
1584 diagnostics.push(
1585 SourceDiagnostic::error(
1586 "unsupported-python-syntax",
1587 line_number,
1588 column_from_byte(line, indent),
1589 "`async` is not supported in Cobble",
1590 )
1591 .with_help("Cobble compiles to Minecraft functions and has no async runtime."),
1592 );
1593 }
1594}
1595
1596fn check_missing_block_colon(
1597 line: &str,
1598 trimmed: &str,
1599 line_number: usize,
1600 diagnostics: &mut Vec<SourceDiagnostic>,
1601) {
1602 if trimmed.ends_with(':') || !looks_like_block_header(trimmed) {
1603 return;
1604 }
1605
1606 diagnostics.push(
1607 SourceDiagnostic::error(
1608 "missing-block-colon",
1609 line_number,
1610 column_from_byte(line, line.len()),
1611 "Block headers must end with `:`",
1612 )
1613 .with_help("Add `:` at the end of the line before the indented block."),
1614 );
1615}
1616
1617fn looks_like_block_header(trimmed: &str) -> bool {
1618 if starts_with_keyword(trimmed, "def") {
1619 return trimmed.contains('(') && trimmed.contains(')');
1620 }
1621
1622 for keyword in ["if", "elif", "else", "while", "for", "match", "case"] {
1623 if starts_with_keyword(trimmed, keyword) {
1624 return true;
1625 }
1626 }
1627
1628 false
1629}
1630
1631fn check_imports(
1632 line: &str,
1633 trimmed: &str,
1634 line_number: usize,
1635 indent: usize,
1636 diagnostics: &mut Vec<SourceDiagnostic>,
1637) {
1638 if let Some(rest) = trimmed.strip_prefix("import ") {
1639 let module = rest
1640 .split(|ch: char| ch.is_whitespace() || ch == ',')
1641 .next()
1642 .unwrap_or("");
1643
1644 if let Some(index) = rest.find(',') {
1645 diagnostics.push(
1646 SourceDiagnostic::error(
1647 "unsupported-import",
1648 line_number,
1649 column_from_byte(line, indent + "import ".len() + index),
1650 "Multiple modules in one import statement are not supported",
1651 )
1652 .with_help("Use one `import module` statement per line."),
1653 );
1654 }
1655
1656 if let Some(index) = find_import_alias_keyword(rest) {
1657 diagnostics.push(
1658 SourceDiagnostic::error(
1659 "unsupported-import",
1660 line_number,
1661 column_from_byte(line, indent + "import ".len() + index),
1662 "Import aliases are not supported",
1663 )
1664 .with_help(
1665 "Use the original module name, or rename the source file/module explicitly.",
1666 ),
1667 );
1668 }
1669
1670 if module.starts_with('.') {
1671 diagnostics.push(
1672 SourceDiagnostic::error(
1673 "unsupported-import",
1674 line_number,
1675 column_from_byte(line, indent + "import ".len()),
1676 "Relative imports are not supported",
1677 )
1678 .with_help(
1679 "Use simple module names such as `import helpers`; imports resolve relative to the importing file.",
1680 ),
1681 );
1682 } else if let Some(index) = module.find('.') {
1683 diagnostics.push(
1684 SourceDiagnostic::error(
1685 "unsupported-import",
1686 line_number,
1687 column_from_byte(line, indent + "import ".len() + index),
1688 "Dotted imports are not supported",
1689 )
1690 .with_help(
1691 "Use simple module names such as `import helpers`; imports resolve relative to the importing file.",
1692 ),
1693 );
1694 }
1695 return;
1696 }
1697
1698 if let Some(rest) = trimmed.strip_prefix("from ") {
1699 let Some((module, items)) = rest.split_once(" import ") else {
1700 return;
1701 };
1702
1703 let items_offset = indent + "from ".len() + module.len() + " import ".len();
1704
1705 if module.starts_with('.') {
1706 diagnostics.push(
1707 SourceDiagnostic::error(
1708 "unsupported-import",
1709 line_number,
1710 column_from_byte(line, indent + "from ".len()),
1711 "Relative imports are not supported",
1712 )
1713 .with_help(
1714 "Use simple module names such as `from helpers import setup`; imports resolve relative to the importing file.",
1715 ),
1716 );
1717 } else if let Some(index) = module.find('.') {
1718 diagnostics.push(
1719 SourceDiagnostic::error(
1720 "unsupported-import",
1721 line_number,
1722 column_from_byte(line, indent + "from ".len() + index),
1723 "Dotted imports are not supported",
1724 )
1725 .with_help(
1726 "Use simple module names such as `from helpers import setup`; imports resolve relative to the importing file.",
1727 ),
1728 );
1729 }
1730
1731 let leading = items.len() - items.trim_start().len();
1732 if items.trim_start().starts_with('*') {
1733 diagnostics.push(
1734 SourceDiagnostic::error(
1735 "unsupported-import",
1736 line_number,
1737 column_from_byte(line, items_offset + leading),
1738 "Wildcard imports are not supported",
1739 )
1740 .with_help("Import explicit names such as `from helpers import setup`."),
1741 );
1742 }
1743
1744 if let Some(index) = find_import_alias_keyword(items) {
1745 diagnostics.push(
1746 SourceDiagnostic::error(
1747 "unsupported-import",
1748 line_number,
1749 column_from_byte(line, items_offset + index),
1750 "Import aliases are not supported",
1751 )
1752 .with_help(
1753 "Use the exported Cobble name directly; alias binding is not part of the language.",
1754 ),
1755 );
1756 }
1757 }
1758}
1759
1760fn find_import_alias_keyword(text: &str) -> Option<usize> {
1761 let mut search_from = 0;
1762 while let Some(relative_index) = text[search_from..].find("as") {
1763 let index = search_from + relative_index;
1764 let before = text[..index].chars().next_back();
1765 let after = text[index + "as".len()..].chars().next();
1766
1767 if before.is_some_and(char::is_whitespace) && after.is_some_and(char::is_whitespace) {
1768 return Some(index);
1769 }
1770
1771 search_from = index + "as".len();
1772 }
1773
1774 None
1775}
1776
1777fn check_function_parameters(
1778 line: &str,
1779 trimmed: &str,
1780 line_number: usize,
1781 indent: usize,
1782 diagnostics: &mut Vec<SourceDiagnostic>,
1783) {
1784 if !starts_with_keyword(trimmed, "def") {
1785 return;
1786 }
1787
1788 let Some(open) = trimmed.find('(') else {
1789 return;
1790 };
1791 let Some(close) = trimmed[open + 1..]
1792 .find(')')
1793 .map(|offset| open + 1 + offset)
1794 else {
1795 return;
1796 };
1797
1798 let params = &trimmed[open + 1..close];
1799 if let Some(index) = params.find('*') {
1800 diagnostics.push(
1801 SourceDiagnostic::error(
1802 "unsupported-function-parameter",
1803 line_number,
1804 column_from_byte(line, indent + open + 1 + index),
1805 "`*args` and `**kwargs` parameters are not supported",
1806 )
1807 .with_help("List every Cobble function parameter explicitly."),
1808 );
1809 }
1810
1811 if let Some(index) = params.find('=') {
1812 diagnostics.push(
1813 SourceDiagnostic::error(
1814 "unsupported-function-parameter",
1815 line_number,
1816 column_from_byte(line, indent + open + 1 + index),
1817 "Default parameter values are not supported",
1818 )
1819 .with_help("Use explicit arguments at each call site."),
1820 );
1821 }
1822
1823 let mut seen_params: HashMap<String, usize> = HashMap::new();
1824 for param in split_top_level_arg_spans(params) {
1825 let name = param.text.trim();
1826 if !is_simple_module_name(name) {
1827 continue;
1828 }
1829
1830 let column = column_from_byte(line, indent + open + 1 + param.offset);
1831 if let Some(first_column) = seen_params.insert(name.to_string(), column) {
1832 diagnostics.push(
1833 SourceDiagnostic::error(
1834 "duplicate-function-parameter",
1835 line_number,
1836 column,
1837 format!("Duplicate function parameter `{name}`"),
1838 )
1839 .with_help(format!(
1840 "First `{name}` parameter is at line {line_number}, column {first_column}. Rename one parameter."
1841 )),
1842 );
1843 }
1844 }
1845}
1846
1847fn check_function_definition(
1848 line: &str,
1849 trimmed: &str,
1850 line_number: usize,
1851 indent: usize,
1852 function_defs: &mut HashMap<String, FunctionSignature>,
1853 diagnostics: &mut Vec<SourceDiagnostic>,
1854) {
1855 if !starts_with_keyword(trimmed, "def") {
1856 return;
1857 }
1858
1859 let Some(signature) = parse_function_signature(line, trimmed, line_number, indent) else {
1860 return;
1861 };
1862
1863 if let Some(first) = function_defs.insert(signature.name.clone(), signature.clone()) {
1864 let name = &signature.name;
1865 diagnostics.push(
1866 SourceDiagnostic::error(
1867 "duplicate-function",
1868 line_number,
1869 signature.column,
1870 format!("Duplicate function definition `{name}`"),
1871 )
1872 .with_help(format!(
1873 "First definition is at line {}, column {}. Rename one function or merge the implementations.",
1874 first.line, first.column
1875 )),
1876 );
1877 }
1878}
1879
1880fn parse_function_signature(
1881 line: &str,
1882 trimmed: &str,
1883 line_number: usize,
1884 indent: usize,
1885) -> Option<FunctionSignature> {
1886 let rest = trimmed.strip_prefix("def")?.trim_start();
1887 let name = rest
1888 .chars()
1889 .take_while(|ch| is_ident_char(*ch))
1890 .collect::<String>();
1891 if name.is_empty() {
1892 return None;
1893 }
1894
1895 let open = trimmed.find('(')?;
1896 let close = trimmed[open + 1..]
1897 .find(')')
1898 .map(|offset| open + 1 + offset)?;
1899 let params = split_top_level_args(&trimmed[open + 1..close])
1900 .into_iter()
1901 .filter(|param| !param.trim().is_empty())
1902 .map(|param| param.trim().to_string())
1903 .collect::<Vec<_>>();
1904
1905 Some(FunctionSignature {
1906 name: name.clone(),
1907 params,
1908 line: line_number,
1909 column: column_from_byte(line, indent + trimmed.find(&name).unwrap_or(0)),
1910 })
1911}
1912
1913fn check_return_statement(
1914 line: &str,
1915 trimmed: &str,
1916 line_number: usize,
1917 indent: usize,
1918 diagnostics: &mut Vec<SourceDiagnostic>,
1919) {
1920 if !starts_with_keyword(trimmed, "return") {
1921 return;
1922 }
1923
1924 diagnostics.push(
1925 SourceDiagnostic::error(
1926 "unsupported-return",
1927 line_number,
1928 column_from_byte(line, indent),
1929 "Return statements are not supported",
1930 )
1931 .with_help(
1932 "Minecraft functions cannot return early or return values. Use if/else blocks, scoreboard state, or separate functions to structure control flow.",
1933 ),
1934 );
1935}
1936
1937fn check_compound_assignment(
1938 line: &str,
1939 masked: &str,
1940 line_number: usize,
1941 diagnostics: &mut Vec<SourceDiagnostic>,
1942) {
1943 for operator in ["+=", "-=", "*=", "/=", "%=", "^="] {
1944 if let Some(index) = masked.find(operator) {
1945 diagnostics.push(
1946 SourceDiagnostic::error(
1947 "unsupported-assignment",
1948 line_number,
1949 column_from_byte(line, index),
1950 format!("Compound assignment `{operator}` is not supported"),
1951 )
1952 .with_help("Write the assignment explicitly, for example `x = x + value`."),
1953 );
1954 return;
1955 }
1956 }
1957}
1958
1959fn check_assignment_target(
1960 line: &str,
1961 trimmed: &str,
1962 line_number: usize,
1963 indent: usize,
1964 diagnostics: &mut Vec<SourceDiagnostic>,
1965) {
1966 if starts_with_keyword(trimmed, "def")
1967 || starts_with_keyword(trimmed, "if")
1968 || starts_with_keyword(trimmed, "elif")
1969 || starts_with_keyword(trimmed, "while")
1970 || starts_with_keyword(trimmed, "for")
1971 || starts_with_keyword(trimmed, "return")
1972 || starts_with_keyword(trimmed, "import")
1973 || starts_with_keyword(trimmed, "from")
1974 || starts_with_keyword(trimmed, "global")
1975 || starts_with_keyword(trimmed, "define")
1976 || trimmed.starts_with('@')
1977 {
1978 return;
1979 }
1980
1981 let Some(equals_index) = single_equals_index(trimmed) else {
1982 return;
1983 };
1984
1985 let raw_target = trimmed[..equals_index].trim();
1986 let target = raw_target
1987 .strip_prefix("const ")
1988 .unwrap_or(raw_target)
1989 .trim();
1990
1991 if target.contains('.') || target.contains('[') || target.contains(']') || target.contains(',')
1992 {
1993 diagnostics.push(
1994 SourceDiagnostic::error(
1995 "unsupported-assignment-target",
1996 line_number,
1997 column_from_byte(line, indent),
1998 "Only simple identifier assignment targets are supported",
1999 )
2000 .with_help("Assign to a named variable, then call storage helpers for nested data."),
2001 );
2002 }
2003}
2004
2005fn check_assignment_function_calls(
2006 line: &str,
2007 trimmed: &str,
2008 line_number: usize,
2009 indent: usize,
2010 diagnostics: &mut Vec<SourceDiagnostic>,
2011) {
2012 if starts_with_keyword(trimmed, "def")
2013 || starts_with_keyword(trimmed, "if")
2014 || starts_with_keyword(trimmed, "elif")
2015 || starts_with_keyword(trimmed, "while")
2016 || starts_with_keyword(trimmed, "for")
2017 || starts_with_keyword(trimmed, "return")
2018 || starts_with_keyword(trimmed, "import")
2019 || starts_with_keyword(trimmed, "from")
2020 || starts_with_keyword(trimmed, "global")
2021 || starts_with_keyword(trimmed, "define")
2022 || trimmed.starts_with('@')
2023 {
2024 return;
2025 }
2026
2027 let Some(equals_index) = single_equals_index(trimmed) else {
2028 return;
2029 };
2030 let rhs = &trimmed[equals_index + 1..];
2031
2032 for call in function_calls_in_expression(rhs) {
2033 if let Some(diagnostic) = invalid_math_value_function_call(&call) {
2034 diagnostics.push(
2035 SourceDiagnostic::error(
2036 diagnostic.kind,
2037 line_number,
2038 column_from_byte(line, indent + equals_index + 1 + call.offset),
2039 diagnostic.message,
2040 )
2041 .with_help(diagnostic.help),
2042 );
2043 return;
2044 }
2045
2046 if is_allowed_value_function_call(&call.name) {
2047 continue;
2048 }
2049
2050 diagnostics.push(
2051 SourceDiagnostic::error(
2052 "unsupported-function-call-expression",
2053 line_number,
2054 column_from_byte(line, indent + equals_index + 1 + call.offset),
2055 "Function calls in expressions are not supported (except math intrinsics).",
2056 )
2057 .with_help("Call Cobble functions as standalone statements, or store results through scoreboard/storage state explicitly."),
2058 );
2059 return;
2060 }
2061}
2062
2063fn check_user_function_call_arguments(
2064 source: &str,
2065 function_defs: &HashMap<String, FunctionSignature>,
2066 diagnostics: &mut Vec<SourceDiagnostic>,
2067) {
2068 if function_defs.is_empty() {
2069 return;
2070 }
2071
2072 let mut active_docstring_quote = None;
2073 for (line_index, line) in source.lines().enumerate() {
2074 let line_number = line_index + 1;
2075 let raw_trimmed = line.trim_start();
2076 if should_skip_docstring_scan_line(line, raw_trimmed, &mut active_docstring_quote) {
2077 continue;
2078 }
2079
2080 let masked = mask_non_code(line);
2081 let trimmed = masked.trim_start();
2082 if should_skip_call_argument_scan(trimmed) {
2083 continue;
2084 }
2085
2086 let indent = masked.len() - trimmed.len();
2087 for call in function_calls_in_expression(trimmed) {
2088 let Some(signature) = function_defs.get(&call.name) else {
2089 continue;
2090 };
2091 if call.arg_count == signature.params.len() {
2092 for argument in &call.arguments {
2093 let Some(nested_call) = function_calls_in_expression(&argument.text)
2094 .into_iter()
2095 .next()
2096 else {
2097 continue;
2098 };
2099
2100 diagnostics.push(
2101 SourceDiagnostic::error(
2102 "unsupported-function-call-argument",
2103 line_number,
2104 column_from_byte(
2105 line,
2106 indent + argument.offset + nested_call.offset,
2107 ),
2108 format!(
2109 "Function `{}` arguments cannot contain function call expressions",
2110 signature.name
2111 ),
2112 )
2113 .with_help(
2114 "Call Cobble functions as standalone statements, or pass a literal, variable, selector, or storage-backed value.",
2115 ),
2116 );
2117 }
2118 continue;
2119 }
2120
2121 diagnostics.push(
2122 SourceDiagnostic::error(
2123 "function-argument-count",
2124 line_number,
2125 column_from_byte(line, indent + call.offset),
2126 format!(
2127 "Function `{}` expects {} argument(s), but {} provided",
2128 signature.name,
2129 signature.params.len(),
2130 call.arg_count
2131 ),
2132 )
2133 .with_help(format!(
2134 "Expected parameters: ({})",
2135 signature.params.join(", ")
2136 )),
2137 );
2138 }
2139 }
2140}
2141
2142fn check_undefined_function_calls(
2143 source: &str,
2144 function_defs: &HashMap<String, FunctionSignature>,
2145 diagnostics: &mut Vec<SourceDiagnostic>,
2146) {
2147 let mut active_docstring_quote = None;
2148 for (line_index, line) in source.lines().enumerate() {
2149 let line_number = line_index + 1;
2150 let raw_trimmed = line.trim_start();
2151 if should_skip_docstring_scan_line(line, raw_trimmed, &mut active_docstring_quote) {
2152 continue;
2153 }
2154
2155 let masked = mask_non_code(line);
2156 let trimmed = masked.trim_start();
2157 if should_skip_call_argument_scan(trimmed) {
2158 continue;
2159 }
2160
2161 let indent = masked.len() - trimmed.len();
2162 for call in function_calls_in_expression(trimmed) {
2163 if !is_user_function_call_statement(trimmed, &call) {
2164 continue;
2165 }
2166
2167 if let Some((module, method)) = split_module_call_name(&call.name) {
2168 if is_value_only_module_call(module, method) {
2169 diagnostics.push(
2170 SourceDiagnostic::error(
2171 "unsupported-function-call-expression",
2172 line_number,
2173 column_from_byte(line, indent + call.offset),
2174 format!(
2175 "`{}` returns a value and cannot be used as a standalone statement",
2176 call.name
2177 ),
2178 )
2179 .with_help(
2180 "Pass the returned value to another helper or JSON resource value instead.",
2181 ),
2182 );
2183 continue;
2184 }
2185
2186 if is_known_module_call(module, method) {
2187 continue;
2188 }
2189
2190 diagnostics.push(
2191 SourceDiagnostic::error(
2192 "undefined-function",
2193 line_number,
2194 column_from_byte(line, indent + call.offset),
2195 format!("Unknown helper function `{}`", call.name),
2196 )
2197 .with_help(
2198 "Use a documented Cobble helper module/function, define a Cobble function, or use a raw Minecraft command for external behavior.",
2199 ),
2200 );
2201 continue;
2202 }
2203
2204 if function_defs.contains_key(&call.name) || is_allowed_value_function_call(&call.name)
2205 {
2206 continue;
2207 }
2208
2209 diagnostics.push(
2210 SourceDiagnostic::error(
2211 "undefined-function",
2212 line_number,
2213 column_from_byte(line, indent + call.offset),
2214 format!("Undefined function `{}`", call.name),
2215 )
2216 .with_help(
2217 "Define the Cobble function, import a file that defines it, or use a raw `/function namespace:path` command for external Minecraft functions.",
2218 ),
2219 );
2220 }
2221 }
2222}
2223
2224fn has_non_stdlib_imports(source: &str) -> bool {
2225 let mut active_docstring_quote = None;
2226 for line in source.lines() {
2227 let raw_trimmed = line.trim_start();
2228 if should_skip_docstring_scan_line(line, raw_trimmed, &mut active_docstring_quote) {
2229 continue;
2230 }
2231
2232 let masked = mask_non_code(line);
2233 let trimmed = masked.trim_start();
2234 if let Some(rest) = trimmed.strip_prefix("import ") {
2235 if rest.split(',').any(|module| {
2236 module
2237 .split_whitespace()
2238 .next()
2239 .unwrap_or("")
2240 .split('.')
2241 .next()
2242 .is_some_and(|module| module != "stdlib")
2243 }) {
2244 return true;
2245 }
2246 }
2247
2248 if let Some(rest) = trimmed.strip_prefix("from ") {
2249 let module = rest.split_once(" import ").map(|(module, _)| module.trim());
2250 if module.is_some_and(|module| module != "stdlib") {
2251 return true;
2252 }
2253 }
2254 }
2255
2256 false
2257}
2258
2259fn is_user_function_call_statement(trimmed: &str, call: &FunctionCallSpan) -> bool {
2260 if !trimmed[..call.offset].trim().is_empty() {
2261 return false;
2262 }
2263
2264 let Some(open_index) = trimmed[call.offset..]
2265 .find('(')
2266 .map(|offset| call.offset + offset)
2267 else {
2268 return false;
2269 };
2270 let Some(close_index) = matching_close_paren(trimmed, open_index) else {
2271 return false;
2272 };
2273
2274 trimmed[close_index + 1..].trim().is_empty()
2275}
2276
2277fn is_known_non_user_function_call(name: &str) -> bool {
2278 split_module_call_name(name)
2279 .is_some_and(|(module, method)| is_known_module_call(module, method))
2280 || is_allowed_value_function_call(name)
2281}
2282
2283fn split_module_call_name(name: &str) -> Option<(&str, &str)> {
2284 name.rsplit_once('.')
2285}
2286
2287fn is_known_module_call(module: &str, method: &str) -> bool {
2288 match module {
2289 "stdlib" => method == "addEventListener",
2290 "math" => matches!(method, "sqrt" | "abs" | "min" | "max"),
2291 "text" => matches!(
2292 method,
2293 "plain"
2294 | "colored"
2295 | "score"
2296 | "selector"
2297 | "tellraw"
2298 | "title"
2299 | "subtitle"
2300 | "actionbar"
2301 ),
2302 "score" => matches!(
2303 method,
2304 "set" | "add" | "remove" | "reset" | "copy" | "operation"
2305 ),
2306 "score.objective" => matches!(method, "add" | "remove" | "display"),
2307 "random" => matches!(method, "int" | "bool"),
2308 "timer" => matches!(method, "set" | "tick" | "done" | "reset"),
2309 "storage" => matches!(
2310 method,
2311 "set"
2312 | "merge"
2313 | "remove"
2314 | "copy"
2315 | "append"
2316 | "prepend"
2317 | "insert"
2318 | "get"
2319 | "read_score"
2320 | "copy_from"
2321 ),
2322 "schedule" => matches!(method, "once" | "clear"),
2323 "bossbar" => matches!(
2324 method,
2325 "add"
2326 | "remove"
2327 | "set_value"
2328 | "set_max"
2329 | "set_name"
2330 | "set_color"
2331 | "set_style"
2332 | "set_visible"
2333 | "set_players"
2334 ),
2335 "team" => matches!(method, "add" | "remove" | "join" | "leave" | "modify"),
2336 "entity" => matches!(
2337 method,
2338 "tag_add"
2339 | "tag_remove"
2340 | "effect_give"
2341 | "effect_clear"
2342 | "attribute_get"
2343 | "attribute_base_set"
2344 ),
2345 "datapack" => {
2346 datapack_json_resource_type(method).is_some() || datapack_tag_type(method).is_some()
2347 }
2348 _ => false,
2349 }
2350}
2351
2352fn is_value_only_module_call(module: &str, method: &str) -> bool {
2353 module == "text" && matches!(method, "plain" | "colored" | "score" | "selector")
2354}
2355
2356fn check_unsupported_none_usage(source: &str, diagnostics: &mut Vec<SourceDiagnostic>) {
2357 let mut active_docstring_quote = None;
2358 let mut active_datapack_json_expression: Option<MappedSourceExpression> = None;
2359 let mut datapack_json_depth = 0usize;
2360
2361 for (line_index, line) in source.lines().enumerate() {
2362 let line_number = line_index + 1;
2363 let raw_trimmed = line.trim_start();
2364 if should_skip_docstring_scan_line(line, raw_trimmed, &mut active_docstring_quote) {
2365 continue;
2366 }
2367
2368 let masked = mask_non_code(line);
2369 let trimmed = masked.trim_start();
2370 if trimmed.is_empty() || trimmed.starts_with('/') {
2371 continue;
2372 }
2373
2374 let indent = masked.len() - trimmed.len();
2375 if let Some(expression) = active_datapack_json_expression.as_mut() {
2376 expression.push_line_fragment(line_number, line, &masked, indent);
2377 update_delimiter_depth_for_indentation(trimmed, &mut datapack_json_depth);
2378 if datapack_json_depth == 0 {
2379 if let Some(expression) = active_datapack_json_expression.take() {
2380 check_none_usage_in_mapped_expression(&expression, diagnostics);
2381 }
2382 }
2383 continue;
2384 }
2385
2386 if starts_datapack_json_resource_call(trimmed) {
2387 let mut expression = MappedSourceExpression::default();
2388 expression.push_line_fragment(line_number, line, &masked, indent);
2389 update_delimiter_depth_for_indentation(trimmed, &mut datapack_json_depth);
2390 if datapack_json_depth == 0 {
2391 check_none_usage_in_mapped_expression(&expression, diagnostics);
2392 } else {
2393 active_datapack_json_expression = Some(expression);
2394 }
2395 continue;
2396 }
2397
2398 check_none_usage_in_line(line, trimmed, line_number, indent, diagnostics);
2399 }
2400}
2401
2402fn check_none_usage_in_mapped_expression(
2403 expression: &MappedSourceExpression,
2404 diagnostics: &mut Vec<SourceDiagnostic>,
2405) {
2406 let allowed_ranges = datapack_json_value_ranges(&expression.masked);
2407 for token in nullish_token_spans(&expression.masked) {
2408 if token.name == "None"
2409 && allowed_ranges
2410 .iter()
2411 .any(|range| token.offset >= range.start && token.offset < range.end)
2412 {
2413 continue;
2414 }
2415
2416 let location = expression.location_at(token.offset);
2417 push_unsupported_none_diagnostic(diagnostics, location.line, location.column, token.name);
2418 }
2419}
2420
2421fn check_none_usage_in_line(
2422 line: &str,
2423 trimmed: &str,
2424 line_number: usize,
2425 indent: usize,
2426 diagnostics: &mut Vec<SourceDiagnostic>,
2427) {
2428 for token in nullish_token_spans(trimmed) {
2429 diagnostics.push(
2430 SourceDiagnostic::error(
2431 "unsupported-none",
2432 line_number,
2433 column_from_byte(line, indent + token.offset),
2434 "None/null is only supported in data pack JSON resource helper values",
2435 )
2436 .with_help(
2437 "Minecraft SNBT/NBT storage has no null type. Use `None` only as a JSON null in datapack resource helpers, use `None` instead of lowercase `null`, or choose an explicit sentinel value.",
2438 ),
2439 );
2440 }
2441}
2442
2443fn push_unsupported_none_diagnostic(
2444 diagnostics: &mut Vec<SourceDiagnostic>,
2445 line: usize,
2446 column: usize,
2447 _token: &str,
2448) {
2449 diagnostics.push(
2450 SourceDiagnostic::error(
2451 "unsupported-none",
2452 line,
2453 column,
2454 "None/null is only supported in data pack JSON resource helper values",
2455 )
2456 .with_help(
2457 "Minecraft SNBT/NBT storage has no null type. Use `None` only as a JSON null in datapack resource helpers, use `None` instead of lowercase `null`, or choose an explicit sentinel value.",
2458 ),
2459 );
2460}
2461
2462#[derive(Debug, Clone, PartialEq, Eq)]
2463struct NullishTokenSpan {
2464 name: &'static str,
2465 offset: usize,
2466}
2467
2468fn nullish_token_spans(expression: &str) -> Vec<NullishTokenSpan> {
2469 let mut spans = Vec::new();
2470 for name in ["None", "null"] {
2471 let mut search_from = 0usize;
2472 while let Some(relative_offset) = expression[search_from..].find(name) {
2473 let offset = search_from + relative_offset;
2474 let before = expression[..offset].chars().next_back();
2475 let after = expression[offset + name.len()..].chars().next();
2476 if before.is_none_or(|ch| !is_ident_char(ch))
2477 && after.is_none_or(|ch| !is_ident_char(ch))
2478 {
2479 spans.push(NullishTokenSpan { name, offset });
2480 }
2481 search_from = offset + name.len();
2482 }
2483 }
2484 spans.sort_by_key(|span| span.offset);
2485 spans
2486}
2487
2488#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2489struct SourceRange {
2490 start: usize,
2491 end: usize,
2492}
2493
2494fn datapack_json_value_ranges(expression: &str) -> Vec<SourceRange> {
2495 function_calls_in_expression(expression)
2496 .into_iter()
2497 .filter_map(|call| {
2498 let helper = call.name.strip_prefix("datapack.")?;
2499 datapack_json_resource_type(helper)?;
2500 let json_arg = call.arguments.get(1)?;
2501 Some(SourceRange {
2502 start: json_arg.offset,
2503 end: json_arg.offset + json_arg.text.len(),
2504 })
2505 })
2506 .collect()
2507}
2508
2509fn starts_datapack_json_resource_call(trimmed: &str) -> bool {
2510 if function_calls_in_expression(trimmed)
2511 .into_iter()
2512 .any(|call| {
2513 call.name
2514 .strip_prefix("datapack.")
2515 .is_some_and(|helper| datapack_json_resource_type(helper).is_some())
2516 })
2517 {
2518 return true;
2519 }
2520
2521 let Some(rest) = trimmed.strip_prefix("datapack.") else {
2522 return false;
2523 };
2524 let helper = rest
2525 .split_once('(')
2526 .map(|(helper, _)| helper.trim())
2527 .unwrap_or(rest.trim());
2528 datapack_json_resource_type(helper).is_some()
2529}
2530
2531fn check_storage_backed_access(source: &str, diagnostics: &mut Vec<SourceDiagnostic>) {
2532 let mut module_types: HashMap<String, CobbleType> = HashMap::new();
2533 let mut module_constants: HashMap<String, f64> = HashMap::new();
2534 let mut current_function: Option<DiagnosticFunctionScope> = None;
2535 let mut active_docstring_quote = None;
2536
2537 for (line_index, line) in source.lines().enumerate() {
2538 let line_number = line_index + 1;
2539 let raw_trimmed = line.trim_start();
2540 if should_skip_docstring_scan_line(line, raw_trimmed, &mut active_docstring_quote) {
2541 continue;
2542 }
2543
2544 let masked = mask_non_code(line);
2545 let trimmed = masked.trim_start();
2546 if trimmed.is_empty() || trimmed.starts_with('/') {
2547 continue;
2548 }
2549
2550 let indent = masked.len() - trimmed.len();
2551 if current_function
2552 .as_ref()
2553 .is_some_and(|scope| indent <= scope.indent)
2554 {
2555 current_function = None;
2556 }
2557
2558 if starts_with_keyword(trimmed, "def") {
2559 current_function = Some(DiagnosticFunctionScope {
2560 indent,
2561 types: module_types.clone(),
2562 constants: module_constants.clone(),
2563 });
2564 continue;
2565 }
2566
2567 let Some(assignment) = assignment_span_for_type_check(line, trimmed, indent) else {
2568 continue;
2569 };
2570
2571 let (type_env, constant_env) = match current_function.as_mut() {
2572 Some(scope) => (&mut scope.types, &mut scope.constants),
2573 None => (&mut module_types, &mut module_constants),
2574 };
2575
2576 if let Some(diagnostic) =
2577 unsupported_storage_access_in_expression(&assignment.value, type_env, constant_env)
2578 {
2579 diagnostics.push(
2580 SourceDiagnostic::error(
2581 "unsupported-storage-access",
2582 line_number,
2583 column_from_byte(line, indent + assignment.value_offset + diagnostic.offset),
2584 diagnostic.message,
2585 )
2586 .with_help(diagnostic.help),
2587 );
2588 continue;
2589 }
2590
2591 if assignment.is_const {
2592 if let Some(value) =
2593 evaluate_numeric_const_for_diagnostics(&assignment.value, constant_env)
2594 {
2595 constant_env.insert(assignment.target.clone(), value);
2596 } else {
2597 constant_env.remove(&assignment.target);
2598 }
2599 } else {
2600 constant_env.remove(&assignment.target);
2601 }
2602
2603 if let Some(new_type) = infer_expression_type_for_diagnostics(&assignment.value, type_env) {
2604 match type_env.get(&assignment.target) {
2605 Some(existing_type) if *existing_type != new_type => {}
2606 _ => {
2607 type_env.insert(assignment.target, new_type);
2608 }
2609 }
2610 }
2611 }
2612}
2613
2614#[derive(Debug, Clone, PartialEq)]
2615struct DiagnosticFunctionScope {
2616 indent: usize,
2617 types: HashMap<String, CobbleType>,
2618 constants: HashMap<String, f64>,
2619}
2620
2621#[derive(Debug, Clone, PartialEq, Eq)]
2622struct StorageAccessDiagnostic {
2623 offset: usize,
2624 message: String,
2625 help: String,
2626}
2627
2628fn unsupported_storage_access_in_expression(
2629 expression: &str,
2630 type_env: &HashMap<String, CobbleType>,
2631 constant_env: &HashMap<String, f64>,
2632) -> Option<StorageAccessDiagnostic> {
2633 let masked = mask_non_code(expression);
2634
2635 if let Some(diagnostic) = unsupported_subscript_access(&masked, type_env, constant_env) {
2636 return Some(diagnostic);
2637 }
2638
2639 unsupported_attribute_access(&masked, type_env)
2640}
2641
2642fn unsupported_subscript_access(
2643 expression: &str,
2644 type_env: &HashMap<String, CobbleType>,
2645 constant_env: &HashMap<String, f64>,
2646) -> Option<StorageAccessDiagnostic> {
2647 let bytes = expression.as_bytes();
2648 let mut index = 0usize;
2649 while index < bytes.len() {
2650 if bytes[index] != b'[' {
2651 index += 1;
2652 continue;
2653 }
2654
2655 let Some((base, base_start)) = identifier_before(expression, index) else {
2656 index += 1;
2657 continue;
2658 };
2659 if is_builtin_symbol(base) {
2660 index += 1;
2661 continue;
2662 }
2663
2664 let Some(close_index) = matching_close_bracket(expression, index) else {
2665 index += 1;
2666 continue;
2667 };
2668 let index_expression = expression[index + 1..close_index].trim();
2669 let Some(base_type) = type_env.get(base) else {
2670 return Some(StorageAccessDiagnostic {
2671 offset: base_start,
2672 message: format!("Cannot resolve storage-backed subscript access `{base}[...]`"),
2673 help: "Assign a list, map, or string literal to the base variable before using storage-backed access.".to_string(),
2674 });
2675 };
2676
2677 if !matches!(
2678 base_type,
2679 CobbleType::List | CobbleType::Map | CobbleType::String
2680 ) {
2681 return Some(StorageAccessDiagnostic {
2682 offset: base_start,
2683 message: format!(
2684 "Variable `{base}` is type {}, which does not support subscript access",
2685 base_type.name()
2686 ),
2687 help: "Use subscript access only on storage-backed list, map, or string variables."
2688 .to_string(),
2689 });
2690 }
2691
2692 if !is_literal_storage_index(index_expression)
2693 && !is_constant_storage_index(index_expression, constant_env)
2694 {
2695 return Some(StorageAccessDiagnostic {
2696 offset: index + 1,
2697 message: "Dynamic storage-backed subscript indexes are not supported".to_string(),
2698 help: "Use a numeric/string literal index or a numeric compile-time constant such as `items[0]`, `config[\"chance\"]`, or `items[INDEX]`.".to_string(),
2699 });
2700 }
2701
2702 index = close_index + 1;
2703 }
2704
2705 None
2706}
2707
2708fn unsupported_attribute_access(
2709 expression: &str,
2710 type_env: &HashMap<String, CobbleType>,
2711) -> Option<StorageAccessDiagnostic> {
2712 let bytes = expression.as_bytes();
2713 let mut index = 0usize;
2714 while index < bytes.len() {
2715 if bytes[index] != b'.' {
2716 index += 1;
2717 continue;
2718 }
2719
2720 let Some((base, base_start)) = identifier_before(expression, index) else {
2721 index += 1;
2722 continue;
2723 };
2724 let Some((_field, _field_end)) = identifier_after(expression, index + 1) else {
2725 index += 1;
2726 continue;
2727 };
2728 if is_builtin_symbol(base) {
2729 index += 1;
2730 continue;
2731 }
2732
2733 let Some(base_type) = type_env.get(base) else {
2734 return Some(StorageAccessDiagnostic {
2735 offset: base_start,
2736 message: format!("Cannot resolve storage-backed attribute access `{base}.`"),
2737 help: "Assign a map literal to the base variable before using storage-backed attribute access.".to_string(),
2738 });
2739 };
2740
2741 if *base_type != CobbleType::Map {
2742 return Some(StorageAccessDiagnostic {
2743 offset: base_start,
2744 message: format!(
2745 "Variable `{base}` is type {}, which does not support attribute access",
2746 base_type.name()
2747 ),
2748 help: "Use attribute access only on storage-backed map variables.".to_string(),
2749 });
2750 }
2751
2752 index += 1;
2753 }
2754
2755 None
2756}
2757
2758fn identifier_before(text: &str, before: usize) -> Option<(&str, usize)> {
2759 let bytes = text.as_bytes();
2760 let mut end = before;
2761 while end > 0 && bytes[end - 1].is_ascii_whitespace() {
2762 end -= 1;
2763 }
2764 let mut start = end;
2765 while start > 0 && is_ident_continue_byte(bytes[start - 1]) {
2766 start -= 1;
2767 }
2768 if start == end || !is_ident_start_byte(bytes[start]) {
2769 return None;
2770 }
2771 Some((&text[start..end], start))
2772}
2773
2774fn identifier_after(text: &str, after: usize) -> Option<(&str, usize)> {
2775 let bytes = text.as_bytes();
2776 let mut start = after;
2777 while start < bytes.len() && bytes[start].is_ascii_whitespace() {
2778 start += 1;
2779 }
2780 if start >= bytes.len() || !is_ident_start_byte(bytes[start]) {
2781 return None;
2782 }
2783 let mut end = start + 1;
2784 while end < bytes.len() && is_ident_continue_byte(bytes[end]) {
2785 end += 1;
2786 }
2787 Some((&text[start..end], end))
2788}
2789
2790fn matching_close_bracket(expression: &str, open_index: usize) -> Option<usize> {
2791 let bytes = expression.as_bytes();
2792 let mut depth = 0usize;
2793
2794 for (index, byte) in bytes.iter().enumerate().skip(open_index) {
2795 match byte {
2796 b'[' => depth += 1,
2797 b']' => {
2798 depth = depth.saturating_sub(1);
2799 if depth == 0 {
2800 return Some(index);
2801 }
2802 }
2803 _ => {}
2804 }
2805 }
2806
2807 None
2808}
2809
2810fn is_literal_storage_index(index_expression: &str) -> bool {
2811 let index_expression = index_expression.trim();
2812 is_numeric_literal(index_expression)
2813 || (index_expression.len() >= 2
2814 && matches!(index_expression.as_bytes()[0], b'"' | b'\'')
2815 && index_expression
2816 .as_bytes()
2817 .last()
2818 .is_some_and(|last| *last == index_expression.as_bytes()[0]))
2819}
2820
2821fn is_constant_storage_index(index_expression: &str, constant_env: &HashMap<String, f64>) -> bool {
2822 let index_expression = index_expression.trim();
2823 is_simple_module_name(index_expression) && constant_env.contains_key(index_expression)
2824}
2825
2826fn evaluate_numeric_const_for_diagnostics(
2827 expression: &str,
2828 constant_env: &HashMap<String, f64>,
2829) -> Option<f64> {
2830 let expression = strip_balanced_outer_parens(expression.trim());
2831 if expression.is_empty() {
2832 return None;
2833 }
2834
2835 if let Ok(value) = expression.parse::<f64>() {
2836 return Some(value);
2837 }
2838 if is_simple_module_name(expression) {
2839 return constant_env.get(expression).copied();
2840 }
2841
2842 if let Some(rest) = expression.strip_prefix('+') {
2843 return evaluate_numeric_const_for_diagnostics(rest, constant_env);
2844 }
2845 if let Some(rest) = expression.strip_prefix('-') {
2846 return evaluate_numeric_const_for_diagnostics(rest, constant_env).map(|value| -value);
2847 }
2848
2849 let operator_groups: [&[&str]; 3] = [&["+", "-"], &["*", "/", "%"], &["^"]];
2850 for operators in operator_groups {
2851 if let Some((left, operator, right)) =
2852 split_top_level_binary_operator(expression, operators)
2853 {
2854 let left = evaluate_numeric_const_for_diagnostics(left, constant_env)?;
2855 let right = evaluate_numeric_const_for_diagnostics(right, constant_env)?;
2856 return match operator {
2857 "+" => Some(left + right),
2858 "-" => Some(left - right),
2859 "*" => Some(left * right),
2860 "/" if right != 0.0 => Some(left / right),
2861 "%" if right != 0.0 => Some(((left as i32) % (right as i32)) as f64),
2862 "^" => Some((left as i32).checked_pow(right as u32)? as f64),
2863 _ => None,
2864 };
2865 }
2866 }
2867
2868 None
2869}
2870
2871fn strip_balanced_outer_parens(expression: &str) -> &str {
2872 let mut expression = expression.trim();
2873 loop {
2874 if !expression.starts_with('(') || !expression.ends_with(')') {
2875 return expression;
2876 }
2877 let Some(close_index) = matching_close_paren(expression, 0) else {
2878 return expression;
2879 };
2880 if close_index != expression.len() - 1 {
2881 return expression;
2882 }
2883 expression = expression[1..expression.len() - 1].trim();
2884 }
2885}
2886
2887fn split_top_level_binary_operator<'a>(
2888 expression: &'a str,
2889 operators: &[&'static str],
2890) -> Option<(&'a str, &'static str, &'a str)> {
2891 let bytes = expression.as_bytes();
2892 let mut delimiter_depth = 0usize;
2893
2894 for index in (0..bytes.len()).rev() {
2895 match bytes[index] {
2896 b')' | b']' | b'}' => {
2897 delimiter_depth += 1;
2898 continue;
2899 }
2900 b'(' | b'[' | b'{' => {
2901 delimiter_depth = delimiter_depth.saturating_sub(1);
2902 continue;
2903 }
2904 _ => {}
2905 }
2906
2907 if delimiter_depth != 0 {
2908 continue;
2909 }
2910
2911 for operator in operators {
2912 if !expression[index..].starts_with(operator) {
2913 continue;
2914 }
2915 if matches!(*operator, "+" | "-") && is_unary_sign(expression, index) {
2916 continue;
2917 }
2918 let right_start = index + operator.len();
2919 if expression[..index].trim().is_empty() || expression[right_start..].trim().is_empty()
2920 {
2921 continue;
2922 }
2923 return Some((&expression[..index], *operator, &expression[right_start..]));
2924 }
2925 }
2926
2927 None
2928}
2929
2930fn is_unary_sign(expression: &str, index: usize) -> bool {
2931 expression[..index]
2932 .chars()
2933 .rev()
2934 .find(|ch| !ch.is_whitespace())
2935 .is_none_or(|ch| matches!(ch, '(' | '[' | '{' | '+' | '-' | '*' | '/' | '%' | '^'))
2936}
2937
2938fn check_type_mismatches(source: &str, diagnostics: &mut Vec<SourceDiagnostic>) {
2939 let mut module_types: HashMap<String, CobbleType> = HashMap::new();
2940 let mut current_function: Option<(usize, HashMap<String, CobbleType>)> = None;
2941 let mut active_docstring_quote = None;
2942
2943 for (line_index, line) in source.lines().enumerate() {
2944 let line_number = line_index + 1;
2945 let raw_trimmed = line.trim_start();
2946 if should_skip_docstring_scan_line(line, raw_trimmed, &mut active_docstring_quote) {
2947 continue;
2948 }
2949
2950 let masked = mask_non_code(line);
2951 let trimmed = masked.trim_start();
2952 if trimmed.is_empty() || trimmed.starts_with('/') {
2953 continue;
2954 }
2955
2956 let indent = masked.len() - trimmed.len();
2957 if current_function
2958 .as_ref()
2959 .is_some_and(|(function_indent, _)| indent <= *function_indent)
2960 {
2961 current_function = None;
2962 }
2963
2964 if starts_with_keyword(trimmed, "def") {
2965 current_function = Some((indent, module_types.clone()));
2966 continue;
2967 }
2968
2969 let Some(assignment) = assignment_span_for_type_check(line, trimmed, indent) else {
2970 continue;
2971 };
2972
2973 let type_env = current_function
2974 .as_mut()
2975 .map(|(_, types)| types)
2976 .unwrap_or(&mut module_types);
2977 let new_type = infer_expression_type_for_diagnostics(&assignment.value, type_env);
2978 let Some(new_type) = new_type else {
2979 continue;
2980 };
2981
2982 if let Some(existing_type) = type_env.get(&assignment.target) {
2983 if *existing_type != new_type {
2984 diagnostics.push(
2985 SourceDiagnostic::error(
2986 "type-mismatch",
2987 line_number,
2988 assignment.column,
2989 format!("Type mismatch for variable '{}'.", assignment.target),
2990 )
2991 .with_help(format!(
2992 "Variable was previously defined as type: {}\nCannot reassign to type: {}\nUse a different variable name or keep all assignments to '{}' the same type.",
2993 existing_type.name(),
2994 new_type.name(),
2995 assignment.target
2996 )),
2997 );
2998 continue;
2999 }
3000 }
3001
3002 type_env.insert(assignment.target, new_type);
3003 }
3004}
3005
3006#[derive(Debug, Clone, PartialEq, Eq)]
3007struct TypeAssignmentSpan {
3008 target: String,
3009 value: String,
3010 value_offset: usize,
3011 column: usize,
3012 is_const: bool,
3013}
3014
3015fn assignment_span_for_type_check(
3016 line: &str,
3017 masked_trimmed: &str,
3018 indent: usize,
3019) -> Option<TypeAssignmentSpan> {
3020 if should_skip_assignment_symbol_scan(masked_trimmed) {
3021 return None;
3022 }
3023
3024 let equals_index = single_equals_index(masked_trimmed)?;
3025 let raw_target = masked_trimmed[..equals_index].trim();
3026 let is_const = raw_target.starts_with("const ");
3027 let target = raw_target
3028 .strip_prefix("const ")
3029 .unwrap_or(raw_target)
3030 .trim();
3031 if !is_simple_module_name(target) {
3032 return None;
3033 }
3034
3035 let raw_trimmed = line.trim_start();
3036 let raw_rhs = raw_trimmed.get(equals_index + 1..)?;
3037 let stripped_rhs = strip_comment_preserving_strings(raw_rhs);
3038 let trim_start = stripped_rhs.len() - stripped_rhs.trim_start().len();
3039 let value = stripped_rhs.trim().to_string();
3040 if value.is_empty() {
3041 return None;
3042 }
3043
3044 Some(TypeAssignmentSpan {
3045 target: target.to_string(),
3046 value,
3047 value_offset: equals_index + 1 + trim_start,
3048 column: column_from_byte(line, indent + raw_target.find(target).unwrap_or(0)),
3049 is_const,
3050 })
3051}
3052
3053fn infer_expression_type_for_diagnostics(
3054 expression: &str,
3055 type_env: &HashMap<String, CobbleType>,
3056) -> Option<CobbleType> {
3057 let expression = expression.trim();
3058 if expression.is_empty() {
3059 return None;
3060 }
3061
3062 if expression.starts_with('"') || expression.starts_with('\'') {
3063 return Some(CobbleType::String);
3064 }
3065 if expression == "True" || expression == "False" {
3066 return Some(CobbleType::Boolean);
3067 }
3068 if expression.starts_with('[') {
3069 return Some(CobbleType::List);
3070 }
3071 if expression.starts_with('{') {
3072 return Some(CobbleType::Map);
3073 }
3074 let function_calls = function_calls_in_expression(expression);
3075 if expression.starts_with("math.") && function_calls.len() == 1 {
3076 let call = &function_calls[0];
3077 if math_value_function_arity(&call.name).is_some_and(|arity| arity == call.arg_count)
3078 && expression_references_are_known(expression, type_env)
3079 {
3080 return Some(CobbleType::Integer);
3081 }
3082 }
3083 if is_numeric_literal(expression) {
3084 return Some(CobbleType::Integer);
3085 }
3086 if is_simple_module_name(expression) {
3087 return type_env.get(expression).cloned();
3088 }
3089 if expression_contains_boolean_operator(expression)
3090 && expression_references_are_known(expression, type_env)
3091 {
3092 return Some(CobbleType::Boolean);
3093 }
3094 if expression_contains_arithmetic_operator(expression)
3095 && expression_references_are_known(expression, type_env)
3096 {
3097 return Some(CobbleType::Integer);
3098 }
3099
3100 None
3101}
3102
3103fn expression_references_are_known(
3104 expression: &str,
3105 type_env: &HashMap<String, CobbleType>,
3106) -> bool {
3107 identifier_references_in_expression(expression)
3108 .into_iter()
3109 .all(|reference| {
3110 is_builtin_symbol(&reference.name) || type_env.contains_key(&reference.name)
3111 })
3112}
3113
3114fn strip_comment_preserving_strings(text: &str) -> String {
3115 let mut output = String::with_capacity(text.len());
3116 let mut quote = None;
3117 let mut escaped = false;
3118
3119 for ch in text.chars() {
3120 if let Some(active_quote) = quote {
3121 output.push(ch);
3122 if escaped {
3123 escaped = false;
3124 } else if ch == '\\' {
3125 escaped = true;
3126 } else if ch == active_quote {
3127 quote = None;
3128 }
3129 continue;
3130 }
3131
3132 match ch {
3133 '"' | '\'' => {
3134 quote = Some(ch);
3135 output.push(ch);
3136 }
3137 '#' => break,
3138 _ => output.push(ch),
3139 }
3140 }
3141
3142 output
3143}
3144
3145fn is_numeric_literal(expression: &str) -> bool {
3146 let expression = expression.trim();
3147 if expression.is_empty() {
3148 return false;
3149 }
3150
3151 let digits = expression.strip_prefix('-').unwrap_or(expression);
3152 digits.parse::<f64>().is_ok()
3153}
3154
3155fn expression_contains_boolean_operator(expression: &str) -> bool {
3156 contains_top_level_operator(expression, &["==", "!=", "<=", ">=", "<", ">"])
3157 || find_word(expression, "and").is_some()
3158 || find_word(expression, "or").is_some()
3159 || expression
3160 .trim_start()
3161 .strip_prefix("not ")
3162 .is_some_and(|rest| !rest.trim().is_empty())
3163}
3164
3165fn expression_contains_arithmetic_operator(expression: &str) -> bool {
3166 contains_top_level_operator(expression, &["+", "-", "*", "/", "%", "^"])
3167 || expression
3168 .trim_start()
3169 .strip_prefix('-')
3170 .is_some_and(|rest| !rest.trim().is_empty())
3171 || expression
3172 .trim_start()
3173 .strip_prefix('+')
3174 .is_some_and(|rest| !rest.trim().is_empty())
3175}
3176
3177fn contains_top_level_operator(expression: &str, operators: &[&str]) -> bool {
3178 let bytes = expression.as_bytes();
3179 let mut delimiter_depth = 0usize;
3180 let mut index = 0;
3181
3182 while index < bytes.len() {
3183 match bytes[index] {
3184 b'(' | b'[' | b'{' => {
3185 delimiter_depth += 1;
3186 index += 1;
3187 continue;
3188 }
3189 b')' | b']' | b'}' => {
3190 delimiter_depth = delimiter_depth.saturating_sub(1);
3191 index += 1;
3192 continue;
3193 }
3194 _ => {}
3195 }
3196
3197 if delimiter_depth == 0 {
3198 for operator in operators {
3199 if expression[index..].starts_with(operator) {
3200 if *operator == "-" || *operator == "+" {
3201 let previous = previous_non_whitespace_byte(expression, index);
3202 if previous.is_none_or(|byte| {
3203 matches!(
3204 byte,
3205 b'=' | b'!'
3206 | b'<'
3207 | b'>'
3208 | b'+'
3209 | b'-'
3210 | b'*'
3211 | b'/'
3212 | b'%'
3213 | b'^'
3214 | b'('
3215 | b'['
3216 | b'{'
3217 )
3218 }) {
3219 continue;
3220 }
3221 }
3222 return true;
3223 }
3224 }
3225 }
3226
3227 index += 1;
3228 }
3229
3230 false
3231}
3232
3233#[derive(Debug, Clone, Default)]
3234struct DefinedSymbols {
3235 names: HashSet<String>,
3236}
3237
3238impl DefinedSymbols {
3239 fn contains(&self, name: &str) -> bool {
3240 self.names.contains(name) || is_builtin_symbol(name)
3241 }
3242
3243 fn insert(&mut self, name: impl Into<String>) {
3244 self.names.insert(name.into());
3245 }
3246}
3247
3248#[derive(Debug, Clone, PartialEq, Eq)]
3249struct ExpressionSpan {
3250 text: String,
3251 offset: usize,
3252}
3253
3254#[derive(Debug, Clone, PartialEq, Eq)]
3255struct IdentifierReference {
3256 name: String,
3257 offset: usize,
3258}
3259
3260fn collect_defined_symbols(
3261 source: &str,
3262 function_defs: &HashMap<String, FunctionSignature>,
3263) -> DefinedSymbols {
3264 let mut symbols = DefinedSymbols::default();
3265
3266 for signature in function_defs.values() {
3267 symbols.insert(signature.name.clone());
3268 }
3269
3270 let mut active_docstring_quote = None;
3271 for line in source.lines() {
3272 let raw_trimmed = line.trim_start();
3273 if should_skip_docstring_scan_line(line, raw_trimmed, &mut active_docstring_quote) {
3274 continue;
3275 }
3276
3277 let masked = mask_non_code(line);
3278 let trimmed = masked.trim_start();
3279 if trimmed.is_empty() || trimmed.starts_with('/') {
3280 continue;
3281 }
3282
3283 collect_import_symbols(trimmed, &mut symbols);
3284 collect_assignment_symbol(trimmed, &mut symbols);
3285 collect_global_symbols(trimmed, &mut symbols);
3286 collect_for_target_symbol(trimmed, &mut symbols);
3287 }
3288
3289 symbols
3290}
3291
3292fn collect_interpolation_symbols(source: &str) -> DefinedSymbols {
3293 let mut symbols = DefinedSymbols::default();
3294 let mut active_docstring_quote = None;
3295
3296 for line in source.lines() {
3297 let raw_trimmed = line.trim_start();
3298 if should_skip_docstring_scan_line(line, raw_trimmed, &mut active_docstring_quote) {
3299 continue;
3300 }
3301
3302 let masked = mask_non_code(line);
3303 let trimmed = masked.trim_start();
3304 if trimmed.is_empty() || trimmed.starts_with('/') {
3305 continue;
3306 }
3307
3308 collect_interpolation_import_symbols(trimmed, &mut symbols);
3309 }
3310
3311 symbols
3312}
3313
3314fn check_raw_command_placeholders(
3315 source: &str,
3316 symbols: &DefinedSymbols,
3317 diagnostics: &mut Vec<SourceDiagnostic>,
3318) {
3319 let mut available_symbols = symbols.clone();
3320 let mut current_function: Option<(usize, HashSet<String>)> = None;
3321 let mut loop_scopes: Vec<(usize, String)> = Vec::new();
3322 let mut active_docstring_quote = None;
3323
3324 for (line_index, line) in source.lines().enumerate() {
3325 let line_number = line_index + 1;
3326 let raw_trimmed = line.trim_start();
3327 if should_skip_docstring_scan_line(line, raw_trimmed, &mut active_docstring_quote) {
3328 continue;
3329 }
3330
3331 let masked = mask_non_code(line);
3332 let trimmed = masked.trim_start();
3333 if trimmed.is_empty() {
3334 continue;
3335 }
3336
3337 let indent = masked.len() - trimmed.len();
3338 if current_function
3339 .as_ref()
3340 .is_some_and(|(function_indent, _)| indent <= *function_indent)
3341 {
3342 current_function = None;
3343 }
3344
3345 while loop_scopes
3346 .last()
3347 .is_some_and(|(loop_indent, _)| indent <= *loop_indent)
3348 {
3349 loop_scopes.pop();
3350 }
3351
3352 if starts_with_keyword(trimmed, "def") {
3353 if let Some(signature) = parse_function_signature(line, trimmed, line_number, indent) {
3354 current_function =
3355 Some((indent, signature.params.into_iter().collect::<HashSet<_>>()));
3356 }
3357 continue;
3358 }
3359
3360 if starts_with_keyword(trimmed, "for") {
3361 if let Some(target) = for_loop_target(trimmed) {
3362 loop_scopes.push((indent, target));
3363 }
3364 }
3365
3366 if !trimmed.starts_with('/') {
3367 if let Some(name) = assignment_symbol_name(trimmed) {
3368 if let Some((_function_indent, local_names)) = current_function.as_mut() {
3369 local_names.insert(name);
3370 } else {
3371 available_symbols.insert(name);
3372 }
3373 }
3374
3375 for name in global_symbol_names(trimmed) {
3376 if let Some((_function_indent, local_names)) = current_function.as_mut() {
3377 local_names.insert(name);
3378 } else {
3379 available_symbols.insert(name);
3380 }
3381 }
3382 continue;
3383 }
3384
3385 let local_names = active_local_names(¤t_function, &loop_scopes);
3386 for placeholder in raw_command_placeholders(line, indent) {
3387 if placeholder.name.is_empty() {
3388 diagnostics.push(
3389 SourceDiagnostic::error(
3390 "unclosed-placeholder",
3391 line_number,
3392 placeholder.column,
3393 "Command placeholder or brace expression is not closed",
3394 )
3395 .with_help(
3396 "Close the brace expression, or write `{{name}}` for literal braces.",
3397 ),
3398 );
3399 continue;
3400 }
3401
3402 if !is_simple_module_name(&placeholder.name) {
3403 diagnostics.push(
3404 SourceDiagnostic::error(
3405 "invalid-placeholder",
3406 line_number,
3407 placeholder.column,
3408 format!("Invalid command placeholder `{}`", placeholder.name),
3409 )
3410 .with_help(
3411 "Use identifier placeholders such as `{player_name}`, or write doubled braces like `{{literal}}` for literal text.",
3412 ),
3413 );
3414 continue;
3415 }
3416
3417 if available_symbols.names.contains(&placeholder.name)
3418 || local_names.contains(&placeholder.name)
3419 {
3420 continue;
3421 }
3422
3423 diagnostics.push(
3424 SourceDiagnostic::error(
3425 "undefined-placeholder",
3426 line_number,
3427 placeholder.column,
3428 format!("Undefined command placeholder `{}`", placeholder.name),
3429 )
3430 .with_help(format!(
3431 "Define `{}` before using it, pass it as a function parameter, or write `{{{{{}}}}}` for literal braces.",
3432 placeholder.name, placeholder.name
3433 )),
3434 );
3435 }
3436 }
3437}
3438
3439fn raw_command_placeholder_locations(source: &str, name: &str) -> Vec<(usize, usize)> {
3440 let mut locations = Vec::new();
3441 let mut active_docstring_quote = None;
3442
3443 for (line_index, line) in source.lines().enumerate() {
3444 let line_number = line_index + 1;
3445 let raw_trimmed = line.trim_start();
3446 if should_skip_docstring_scan_line(line, raw_trimmed, &mut active_docstring_quote) {
3447 continue;
3448 }
3449
3450 let masked = mask_non_code(line);
3451 let trimmed = masked.trim_start();
3452 if !trimmed.starts_with('/') {
3453 continue;
3454 }
3455
3456 let indent = masked.len() - trimmed.len();
3457 for placeholder in raw_command_placeholders(line, indent) {
3458 if placeholder.name == name {
3459 locations.push((line_number, placeholder.column));
3460 }
3461 }
3462 }
3463
3464 locations
3465}
3466
3467#[derive(Debug, Clone, PartialEq, Eq)]
3468struct CommandPlaceholder {
3469 name: String,
3470 column: usize,
3471}
3472
3473fn raw_command_placeholders(line: &str, indent: usize) -> Vec<CommandPlaceholder> {
3474 let mut placeholders = Vec::new();
3475 let mut index = indent;
3476 let mut quote = None;
3477 let mut escaped = false;
3478
3479 while index < line.len() {
3480 let ch = line[index..].chars().next().unwrap();
3481 let next_index = index + ch.len_utf8();
3482
3483 if let Some(active_quote) = quote {
3484 if escaped {
3485 escaped = false;
3486 index = next_index;
3487 continue;
3488 }
3489 if ch == '\\' {
3490 escaped = true;
3491 index = next_index;
3492 continue;
3493 }
3494 if ch == '{' {
3495 if line[index..].starts_with("{{") {
3496 if let Some(close_index) = find_escaped_brace_end(line, index + 2) {
3497 index = close_index + 2;
3498 } else {
3499 index += 2;
3500 }
3501 continue;
3502 }
3503 if let Some(close_index) = placeholder_close_before_quote(line, index, active_quote)
3504 {
3505 let content = line[index + 1..close_index].trim();
3506 if is_potential_command_placeholder(content) {
3507 placeholders.push(CommandPlaceholder {
3508 name: content.to_string(),
3509 column: column_from_byte(line, index),
3510 });
3511 index = close_index + 1;
3512 continue;
3513 }
3514 }
3515 }
3516 if ch == active_quote {
3517 quote = None;
3518 }
3519 index = next_index;
3520 continue;
3521 }
3522
3523 if matches!(ch, '"' | '\'') {
3524 quote = Some(ch);
3525 escaped = false;
3526 index = next_index;
3527 continue;
3528 }
3529
3530 if ch != '{' {
3531 index = next_index;
3532 continue;
3533 }
3534
3535 if line[index..].starts_with("{{") {
3536 if let Some(close_index) = find_escaped_brace_end(line, index + 2) {
3537 index = close_index + 2;
3538 } else {
3539 index += 2;
3540 }
3541 continue;
3542 }
3543
3544 let Some(close_index) = matching_brace_quote_aware(line, index) else {
3545 placeholders.push(CommandPlaceholder {
3546 name: String::new(),
3547 column: column_from_byte(line, index),
3548 });
3549 index = next_index;
3550 continue;
3551 };
3552
3553 let content = line[index + 1..close_index].trim();
3554 if is_potential_command_placeholder(content) {
3555 placeholders.push(CommandPlaceholder {
3556 name: content.to_string(),
3557 column: column_from_byte(line, index),
3558 });
3559 index = close_index + 1;
3560 } else {
3561 index = next_index;
3562 }
3563 }
3564
3565 placeholders
3566}
3567
3568fn is_potential_command_placeholder(content: &str) -> bool {
3569 !content.is_empty()
3570 && !content.contains(':')
3571 && !content.contains(',')
3572 && !content.contains('{')
3573 && !content.contains('}')
3574 && !content.contains('"')
3575 && !content.contains('\'')
3576}
3577
3578fn matching_brace_quote_aware(text: &str, open_index: usize) -> Option<usize> {
3579 let mut depth = 0usize;
3580 let mut index = open_index;
3581 let mut quote = None;
3582 let mut escaped = false;
3583
3584 while index < text.len() {
3585 let ch = text[index..].chars().next().unwrap();
3586 let next_index = index + ch.len_utf8();
3587
3588 if let Some(active_quote) = quote {
3589 if escaped {
3590 escaped = false;
3591 } else if ch == '\\' {
3592 escaped = true;
3593 } else if ch == active_quote {
3594 quote = None;
3595 }
3596 index = next_index;
3597 continue;
3598 }
3599
3600 match ch {
3601 '"' | '\'' => quote = Some(ch),
3602 '{' => depth += 1,
3603 '}' => {
3604 depth = depth.saturating_sub(1);
3605 if depth == 0 {
3606 return Some(index);
3607 }
3608 }
3609 _ => {}
3610 }
3611 index = next_index;
3612 }
3613
3614 None
3615}
3616
3617fn placeholder_close_before_quote(text: &str, open_index: usize, quote: char) -> Option<usize> {
3618 let mut index = open_index + 1;
3619 let mut escaped = false;
3620
3621 while index < text.len() {
3622 let ch = text[index..].chars().next().unwrap();
3623 let next_index = index + ch.len_utf8();
3624 if escaped {
3625 escaped = false;
3626 } else if ch == '\\' {
3627 escaped = true;
3628 } else if ch == quote {
3629 return None;
3630 } else if ch == '}' {
3631 return Some(index);
3632 }
3633 index = next_index;
3634 }
3635
3636 None
3637}
3638
3639fn find_escaped_brace_end(text: &str, start: usize) -> Option<usize> {
3640 text.get(start..)?.find("}}").map(|offset| start + offset)
3641}
3642
3643fn collect_import_symbols(trimmed: &str, symbols: &mut DefinedSymbols) {
3644 if let Some(rest) = trimmed.strip_prefix("import ") {
3645 for module in rest.split(',') {
3646 let module = module
3647 .split_whitespace()
3648 .next()
3649 .unwrap_or("")
3650 .split('.')
3651 .next()
3652 .unwrap_or("");
3653 if is_simple_module_name(module) {
3654 symbols.insert(module.to_string());
3655 }
3656 }
3657 return;
3658 }
3659
3660 let Some(rest) = trimmed.strip_prefix("from ") else {
3661 return;
3662 };
3663 let Some((_module, items)) = rest.split_once(" import ") else {
3664 return;
3665 };
3666 for item in items.trim_end_matches(':').split(',') {
3667 let item = item
3668 .split_whitespace()
3669 .next()
3670 .unwrap_or("")
3671 .split('.')
3672 .next()
3673 .unwrap_or("");
3674 if is_simple_module_name(item) {
3675 symbols.insert(item.to_string());
3676 }
3677 }
3678}
3679
3680fn collect_interpolation_import_symbols(trimmed: &str, symbols: &mut DefinedSymbols) {
3681 let Some(rest) = trimmed.strip_prefix("from ") else {
3682 return;
3683 };
3684 let Some((_module, items)) = rest.split_once(" import ") else {
3685 return;
3686 };
3687 for item in items.trim_end_matches(':').split(',') {
3688 let item = item
3689 .split_whitespace()
3690 .next()
3691 .unwrap_or("")
3692 .split('.')
3693 .next()
3694 .unwrap_or("");
3695 if is_simple_module_name(item) {
3696 symbols.insert(item.to_string());
3697 }
3698 }
3699}
3700
3701fn collect_assignment_symbol(trimmed: &str, symbols: &mut DefinedSymbols) {
3702 if let Some(name) = assignment_symbol_name(trimmed) {
3703 symbols.insert(name);
3704 }
3705}
3706
3707fn assignment_symbol_name(trimmed: &str) -> Option<String> {
3708 if should_skip_assignment_symbol_scan(trimmed) {
3709 return None;
3710 }
3711
3712 let equals_index = single_equals_index(trimmed)?;
3713 let raw_target = trimmed[..equals_index].trim();
3714 let target = raw_target
3715 .strip_prefix("const ")
3716 .unwrap_or(raw_target)
3717 .trim();
3718 if is_simple_module_name(target) {
3719 Some(target.to_string())
3720 } else {
3721 None
3722 }
3723}
3724
3725fn should_skip_assignment_symbol_scan(trimmed: &str) -> bool {
3726 starts_with_keyword(trimmed, "def")
3727 || starts_with_keyword(trimmed, "if")
3728 || starts_with_keyword(trimmed, "elif")
3729 || starts_with_keyword(trimmed, "while")
3730 || starts_with_keyword(trimmed, "for")
3731 || starts_with_keyword(trimmed, "return")
3732 || starts_with_keyword(trimmed, "import")
3733 || starts_with_keyword(trimmed, "from")
3734 || starts_with_keyword(trimmed, "global")
3735 || starts_with_keyword(trimmed, "define")
3736 || starts_with_keyword(trimmed, "create")
3737 || starts_with_keyword(trimmed, "end")
3738 || trimmed.starts_with('@')
3739}
3740
3741fn collect_global_symbols(trimmed: &str, symbols: &mut DefinedSymbols) {
3742 for name in global_symbol_names(trimmed) {
3743 symbols.insert(name);
3744 }
3745}
3746
3747fn global_symbol_names(trimmed: &str) -> Vec<String> {
3748 let Some(rest) = trimmed.strip_prefix("global ") else {
3749 return Vec::new();
3750 };
3751 rest.trim_end_matches(':')
3752 .split(',')
3753 .map(str::trim)
3754 .filter(|name| is_simple_module_name(name))
3755 .map(ToOwned::to_owned)
3756 .collect()
3757}
3758
3759fn collect_for_target_symbol(trimmed: &str, symbols: &mut DefinedSymbols) {
3760 if !starts_with_keyword(trimmed, "for") {
3761 return;
3762 }
3763 let rest = trimmed["for".len()..].trim_start();
3764 let Some((target, _iter)) = rest.split_once(" in ") else {
3765 return;
3766 };
3767 let target = target.trim();
3768 if is_simple_module_name(target) {
3769 symbols.insert(target.to_string());
3770 }
3771}
3772
3773fn check_undefined_variable_references(
3774 source: &str,
3775 symbols: &DefinedSymbols,
3776 diagnostics: &mut Vec<SourceDiagnostic>,
3777) {
3778 let mut current_function: Option<(usize, HashSet<String>)> = None;
3779 let mut loop_scopes: Vec<(usize, String)> = Vec::new();
3780 let mut reported = HashSet::new();
3781 let mut active_docstring_quote = None;
3782
3783 for (line_index, line) in source.lines().enumerate() {
3784 let line_number = line_index + 1;
3785 let raw_trimmed = line.trim_start();
3786 if should_skip_docstring_scan_line(line, raw_trimmed, &mut active_docstring_quote) {
3787 continue;
3788 }
3789
3790 let masked = mask_non_code(line);
3791 let trimmed = masked.trim_start();
3792 if trimmed.is_empty() || trimmed.starts_with('/') {
3793 continue;
3794 }
3795
3796 let indent = masked.len() - trimmed.len();
3797 if current_function
3798 .as_ref()
3799 .is_some_and(|(function_indent, _)| indent <= *function_indent)
3800 {
3801 current_function = None;
3802 }
3803
3804 while loop_scopes
3805 .last()
3806 .is_some_and(|(loop_indent, _)| indent <= *loop_indent)
3807 {
3808 loop_scopes.pop();
3809 }
3810
3811 if starts_with_keyword(trimmed, "def") {
3812 if let Some(signature) = parse_function_signature(line, trimmed, line_number, indent) {
3813 current_function =
3814 Some((indent, signature.params.into_iter().collect::<HashSet<_>>()));
3815 }
3816 continue;
3817 }
3818
3819 if starts_with_keyword(trimmed, "for") {
3820 if let Some(target) = for_loop_target(trimmed) {
3821 loop_scopes.push((indent, target));
3822 }
3823 }
3824
3825 let local_names = active_local_names(¤t_function, &loop_scopes);
3826 for expression in expression_spans_for_undefined_scan(trimmed) {
3827 if expression_has_unsupported_reference_shape(&expression.text) {
3828 continue;
3829 }
3830
3831 for reference in identifier_references_in_expression(&expression.text) {
3832 if symbols.contains(&reference.name) || local_names.contains(&reference.name) {
3833 continue;
3834 }
3835
3836 if !reported.insert((line_number, reference.name.clone())) {
3837 continue;
3838 }
3839
3840 diagnostics.push(
3841 SourceDiagnostic::error(
3842 "undefined-variable",
3843 line_number,
3844 column_from_byte(line, indent + expression.offset + reference.offset),
3845 format!("Undefined variable `{}`", reference.name),
3846 )
3847 .with_help(format!(
3848 "Define or import `{}`, or pass it as a function parameter.",
3849 reference.name
3850 )),
3851 );
3852 }
3853 }
3854 }
3855}
3856
3857fn active_local_names(
3858 current_function: &Option<(usize, HashSet<String>)>,
3859 loop_scopes: &[(usize, String)],
3860) -> HashSet<String> {
3861 let mut names = current_function
3862 .as_ref()
3863 .map(|(_, params)| params.clone())
3864 .unwrap_or_default();
3865 for (_indent, target) in loop_scopes {
3866 names.insert(target.clone());
3867 }
3868 names
3869}
3870
3871fn expression_spans_for_undefined_scan(trimmed: &str) -> Vec<ExpressionSpan> {
3872 if should_skip_expression_reference_scan(trimmed) {
3873 return Vec::new();
3874 }
3875
3876 let mut expressions = Vec::new();
3877 if let Some(equals_index) = single_equals_index(trimmed) {
3878 let rhs = &trimmed[equals_index + 1..];
3879 let trim_start = rhs.len() - rhs.trim_start().len();
3880 let expression = rhs.trim();
3881 if !expression.is_empty() {
3882 expressions.push(ExpressionSpan {
3883 text: expression.to_string(),
3884 offset: equals_index + 1 + trim_start,
3885 });
3886 }
3887 return expressions;
3888 }
3889
3890 for keyword in ["if", "elif", "while", "match"] {
3891 if starts_with_keyword(trimmed, keyword) {
3892 let rest_offset = keyword.len();
3893 let rest = trimmed[rest_offset..].trim_start();
3894 let trim_start = trimmed[rest_offset..].len() - rest.len();
3895 let expression = rest.trim_end_matches(':').trim_end();
3896 if !expression.is_empty() {
3897 expressions.push(ExpressionSpan {
3898 text: expression.to_string(),
3899 offset: rest_offset + trim_start,
3900 });
3901 }
3902 return expressions;
3903 }
3904 }
3905
3906 if starts_with_keyword(trimmed, "for") {
3907 let rest_offset = "for".len();
3908 let rest = trimmed[rest_offset..].trim_start();
3909 let rest_trim_start = trimmed[rest_offset..].len() - rest.len();
3910 let Some((target, iter_and_step)) = rest.split_once(" in ") else {
3911 return expressions;
3912 };
3913 let iter_offset = rest_offset + rest_trim_start + target.len() + " in ".len();
3914 let iter_and_step = iter_and_step.trim_end_matches(':').trim_end();
3915 if let Some((iter, step)) = iter_and_step.split_once(" by ") {
3916 let iter = iter.trim();
3917 if !iter.is_empty() {
3918 expressions.push(ExpressionSpan {
3919 text: iter.to_string(),
3920 offset: iter_offset,
3921 });
3922 }
3923 let step_trim_start = step.len() - step.trim_start().len();
3924 let step = step.trim();
3925 if !step.is_empty() {
3926 expressions.push(ExpressionSpan {
3927 text: step.to_string(),
3928 offset: iter_offset + iter.len() + " by ".len() + step_trim_start,
3929 });
3930 }
3931 } else if !iter_and_step.is_empty() {
3932 expressions.push(ExpressionSpan {
3933 text: iter_and_step.to_string(),
3934 offset: iter_offset,
3935 });
3936 }
3937 }
3938
3939 if expressions.is_empty() {
3940 for call in function_calls_in_expression(trimmed) {
3941 for argument in call.arguments {
3942 if !should_scan_call_argument_references(&argument.text)
3943 || !function_calls_in_expression(&argument.text).is_empty()
3944 {
3945 continue;
3946 }
3947
3948 expressions.push(ExpressionSpan {
3949 text: argument.text,
3950 offset: argument.offset,
3951 });
3952 }
3953 }
3954 }
3955
3956 expressions
3957}
3958
3959fn should_scan_call_argument_references(argument: &str) -> bool {
3960 let trimmed = argument.trim();
3961 if trimmed.is_empty() {
3962 return false;
3963 }
3964
3965 !matches!(trimmed.chars().next(), Some('[' | '{'))
3966}
3967
3968fn should_skip_expression_reference_scan(trimmed: &str) -> bool {
3969 starts_with_keyword(trimmed, "def")
3970 || starts_with_keyword(trimmed, "import")
3971 || starts_with_keyword(trimmed, "from")
3972 || starts_with_keyword(trimmed, "global")
3973 || starts_with_keyword(trimmed, "return")
3974 || starts_with_keyword(trimmed, "define")
3975 || starts_with_keyword(trimmed, "create")
3976 || starts_with_keyword(trimmed, "end")
3977 || trimmed.starts_with('@')
3978}
3979
3980fn for_loop_target(trimmed: &str) -> Option<String> {
3981 if !starts_with_keyword(trimmed, "for") {
3982 return None;
3983 }
3984 let rest = trimmed["for".len()..].trim_start();
3985 let (target, _iter) = rest.split_once(" in ")?;
3986 let target = target.trim();
3987 if is_simple_module_name(target) {
3988 Some(target.to_string())
3989 } else {
3990 None
3991 }
3992}
3993
3994fn identifier_references_in_expression(expression: &str) -> Vec<IdentifierReference> {
3995 let bytes = expression.as_bytes();
3996 let mut refs = Vec::new();
3997 let mut index = 0;
3998
3999 while index < bytes.len() {
4000 if !is_ident_start_byte(bytes[index]) {
4001 index += 1;
4002 continue;
4003 }
4004
4005 let start = index;
4006 index += 1;
4007 while index < bytes.len() && is_ident_continue_byte(bytes[index]) {
4008 index += 1;
4009 }
4010
4011 let name = &expression[start..index];
4012 if should_skip_identifier_reference(expression, start, index, name) {
4013 continue;
4014 }
4015
4016 refs.push(IdentifierReference {
4017 name: name.to_string(),
4018 offset: start,
4019 });
4020 }
4021
4022 refs
4023}
4024
4025fn expression_has_unsupported_reference_shape(expression: &str) -> bool {
4026 if expression.contains('[') || expression.contains(']') {
4027 return true;
4028 }
4029
4030 let bytes = expression.as_bytes();
4031 let mut index = 0;
4032 while let Some(dot_offset) = expression[index..].find('.') {
4033 let dot = index + dot_offset;
4034 if identifier_around_dot(expression, dot).is_some_and(|base| base != "math") {
4035 return true;
4036 }
4037 index = dot + 1;
4038 if index >= bytes.len() {
4039 break;
4040 }
4041 }
4042
4043 false
4044}
4045
4046fn identifier_around_dot(expression: &str, dot: usize) -> Option<&str> {
4047 let bytes = expression.as_bytes();
4048 let mut left_start = dot;
4049 while left_start > 0 && is_ident_continue_byte(bytes[left_start - 1]) {
4050 left_start -= 1;
4051 }
4052 let mut right_end = dot + 1;
4053 while right_end < bytes.len() && is_ident_continue_byte(bytes[right_end]) {
4054 right_end += 1;
4055 }
4056
4057 if left_start == dot || right_end == dot + 1 {
4058 return None;
4059 }
4060 Some(&expression[left_start..dot])
4061}
4062
4063fn should_skip_identifier_reference(
4064 expression: &str,
4065 start: usize,
4066 end: usize,
4067 name: &str,
4068) -> bool {
4069 if name.chars().all(|ch| ch == '_') {
4070 return true;
4071 }
4072
4073 if is_builtin_symbol(name) || is_expression_keyword(name) {
4074 return true;
4075 }
4076
4077 let previous = previous_non_whitespace_byte(expression, start);
4078 let next = next_non_whitespace_byte(expression, end);
4079
4080 matches!(previous, Some(b'.' | b'@'))
4081 || matches!(next, Some(b'(' | b':'))
4082 || name.chars().next().is_some_and(|ch| ch.is_ascii_digit())
4083}
4084
4085fn previous_non_whitespace_byte(text: &str, before: usize) -> Option<u8> {
4086 text.as_bytes()
4087 .get(..before)?
4088 .iter()
4089 .rev()
4090 .copied()
4091 .find(|byte| !byte.is_ascii_whitespace())
4092}
4093
4094fn next_non_whitespace_byte(text: &str, after: usize) -> Option<u8> {
4095 text.as_bytes()
4096 .get(after..)?
4097 .iter()
4098 .copied()
4099 .find(|byte| !byte.is_ascii_whitespace())
4100}
4101
4102fn is_ident_start_byte(byte: u8) -> bool {
4103 byte.is_ascii_alphabetic() || byte == b'_'
4104}
4105
4106fn is_ident_continue_byte(byte: u8) -> bool {
4107 byte.is_ascii_alphanumeric() || byte == b'_'
4108}
4109
4110fn is_expression_keyword(name: &str) -> bool {
4111 matches!(name, "and" | "or" | "not" | "in" | "by" | "to")
4112}
4113
4114fn is_builtin_symbol(name: &str) -> bool {
4115 matches!(
4116 name,
4117 "True"
4118 | "False"
4119 | "None"
4120 | "range"
4121 | "math"
4122 | "datapack"
4123 | "stdlib"
4124 | "event"
4125 | "score"
4126 | "text"
4127 | "random"
4128 | "timer"
4129 | "storage"
4130 | "schedule"
4131 | "bossbar"
4132 | "team"
4133 | "entity"
4134 )
4135}
4136
4137fn should_skip_call_argument_scan(trimmed: &str) -> bool {
4138 trimmed.is_empty()
4139 || trimmed.starts_with('/')
4140 || trimmed.starts_with('@')
4141 || starts_with_keyword(trimmed, "def")
4142 || starts_with_keyword(trimmed, "import")
4143 || starts_with_keyword(trimmed, "from")
4144 || starts_with_keyword(trimmed, "return")
4145 || starts_with_keyword(trimmed, "define")
4146 || starts_with_keyword(trimmed, "create")
4147 || starts_with_keyword(trimmed, "end")
4148}
4149
4150fn check_comprehension(
4151 line: &str,
4152 masked: &str,
4153 line_number: usize,
4154 diagnostics: &mut Vec<SourceDiagnostic>,
4155) {
4156 for (open, close) in [('[', ']'), ('{', '}')] {
4157 if let Some(index) = comprehension_for_index(masked, open, close) {
4158 diagnostics.push(
4159 SourceDiagnostic::error(
4160 "unsupported-comprehension",
4161 line_number,
4162 column_from_byte(line, index),
4163 "List and dict comprehensions are not supported",
4164 )
4165 .with_help("Use explicit loops or storage helpers."),
4166 );
4167 return;
4168 }
4169 }
4170}
4171
4172fn comprehension_for_index(masked: &str, open: char, close: char) -> Option<usize> {
4173 let mut search_from = 0;
4174 while let Some(open_offset) = masked[search_from..].find(open) {
4175 let open_index = search_from + open_offset;
4176 let close_offset = masked[open_index + 1..].find(close)?;
4177 let close_index = open_index + 1 + close_offset;
4178 let body = &masked[open_index + 1..close_index];
4179 if let Some(for_index) = find_word(body, "for") {
4180 if find_word(&body[for_index + "for".len()..], "in").is_some() {
4181 return Some(open_index + 1 + for_index);
4182 }
4183 }
4184 search_from = close_index + 1;
4185 }
4186
4187 None
4188}
4189
4190fn check_datapack_helper_argument_shapes(
4191 line: &str,
4192 trimmed: &str,
4193 raw_trimmed: &str,
4194 line_number: usize,
4195 indent: usize,
4196 diagnostics: &mut Vec<SourceDiagnostic>,
4197) {
4198 if !trimmed.contains("datapack.") {
4199 return;
4200 }
4201
4202 for call in function_calls_in_expression(trimmed) {
4203 let Some(helper) = call.name.strip_prefix("datapack.") else {
4204 continue;
4205 };
4206
4207 if let Some(resource_type) = datapack_json_resource_type(helper) {
4208 if call.arg_count != 2 {
4209 diagnostics.push(
4210 SourceDiagnostic::error(
4211 "datapack-resource-argument",
4212 line_number,
4213 column_from_byte(line, indent + call.offset),
4214 format!("datapack.{resource_type}() takes 2 arguments"),
4215 )
4216 .with_help("Pass a resource name and an object JSON value."),
4217 );
4218 continue;
4219 }
4220
4221 if let Some(name_arg) = call.arguments.first() {
4222 check_datapack_resource_id_argument(
4223 line,
4224 raw_trimmed,
4225 name_arg,
4226 "resource name",
4227 line_number,
4228 indent,
4229 diagnostics,
4230 );
4231 }
4232
4233 let Some(json_arg) = call.arguments.get(1) else {
4234 continue;
4235 };
4236 if !json_arg.text.trim_start().starts_with('{') {
4237 diagnostics.push(
4238 SourceDiagnostic::error(
4239 "datapack-resource-argument",
4240 line_number,
4241 column_from_byte(line, indent + json_arg.offset),
4242 format!("datapack.{resource_type}() JSON value must be an object"),
4243 )
4244 .with_help("Use an object literal such as `{ \"condition\": \"...\" }`."),
4245 );
4246 }
4247 continue;
4248 }
4249
4250 if let Some(tag_type) = datapack_tag_type(helper) {
4251 if call.arg_count != 2 {
4252 diagnostics.push(
4253 SourceDiagnostic::error(
4254 "datapack-resource-argument",
4255 line_number,
4256 column_from_byte(line, indent + call.offset),
4257 format!("datapack.{tag_type}_tag() takes 2 arguments"),
4258 )
4259 .with_help("Pass a tag name and an array of resource IDs."),
4260 );
4261 continue;
4262 }
4263
4264 if let Some(name_arg) = call.arguments.first() {
4265 check_datapack_resource_id_argument(
4266 line,
4267 raw_trimmed,
4268 name_arg,
4269 "tag name",
4270 line_number,
4271 indent,
4272 diagnostics,
4273 );
4274 }
4275
4276 let Some(values_arg) = call.arguments.get(1) else {
4277 continue;
4278 };
4279 if !values_arg.text.trim_start().starts_with('[') {
4280 diagnostics.push(
4281 SourceDiagnostic::error(
4282 "datapack-resource-argument",
4283 line_number,
4284 column_from_byte(line, indent + values_arg.offset),
4285 "Tag values must be an array",
4286 )
4287 .with_help("Use an array literal such as `[\"minecraft:stone\"]`."),
4288 );
4289 } else {
4290 check_datapack_tag_value_resource_ids(
4291 line,
4292 raw_trimmed,
4293 values_arg,
4294 line_number,
4295 indent,
4296 diagnostics,
4297 );
4298 }
4299 }
4300 }
4301}
4302
4303fn check_datapack_tag_value_resource_ids(
4304 line: &str,
4305 raw_trimmed: &str,
4306 argument: &FunctionCallArgumentSpan,
4307 line_number: usize,
4308 indent: usize,
4309 diagnostics: &mut Vec<SourceDiagnostic>,
4310) {
4311 let raw_argument = raw_trimmed
4312 .get(argument.offset..argument.offset + argument.text.len())
4313 .unwrap_or(&argument.text);
4314
4315 for item in array_literal_item_spans(raw_argument) {
4316 if !item.text.starts_with('"') && !item.text.starts_with('\'') {
4317 diagnostics.push(
4318 SourceDiagnostic::error(
4319 "datapack-resource-argument",
4320 line_number,
4321 column_from_byte(line, indent + argument.offset + item.offset),
4322 "Tag values must be string resource IDs",
4323 )
4324 .with_help("Use string values such as `[\"minecraft:stone\"]`."),
4325 );
4326 continue;
4327 }
4328
4329 let Some(value) = quoted_string_literal_at(raw_argument, item.offset) else {
4330 continue;
4331 };
4332 let Some(diagnostic) = validate_datapack_resource_id("tag value", value) else {
4333 continue;
4334 };
4335 diagnostics.push(
4336 SourceDiagnostic::error(
4337 "datapack-resource-id",
4338 line_number,
4339 column_from_byte(
4340 line,
4341 indent + argument.offset + item.offset + 1 + diagnostic.offset,
4342 ),
4343 diagnostic.message,
4344 )
4345 .with_help(diagnostic.help),
4346 );
4347 }
4348}
4349
4350fn check_datapack_resource_id_argument(
4351 line: &str,
4352 raw_trimmed: &str,
4353 argument: &FunctionCallArgumentSpan,
4354 label: &str,
4355 line_number: usize,
4356 indent: usize,
4357 diagnostics: &mut Vec<SourceDiagnostic>,
4358) {
4359 let Some(value) = quoted_string_literal_at(raw_trimmed, argument.offset) else {
4360 return;
4361 };
4362 let Some(diagnostic) = validate_datapack_resource_id(label, value) else {
4363 return;
4364 };
4365
4366 diagnostics.push(
4367 SourceDiagnostic::error(
4368 "datapack-resource-id",
4369 line_number,
4370 column_from_byte(line, indent + argument.offset + 1 + diagnostic.offset),
4371 diagnostic.message,
4372 )
4373 .with_help(diagnostic.help),
4374 );
4375}
4376
4377struct ResourceIdDiagnostic {
4378 message: String,
4379 help: String,
4380 offset: usize,
4381}
4382
4383fn validate_datapack_resource_id(label: &str, id: &str) -> Option<ResourceIdDiagnostic> {
4384 let (namespace, path, path_offset) = if let Some((namespace, path)) = id.split_once(':') {
4385 if let Some(extra_colon) = path.find(':') {
4386 return Some(ResourceIdDiagnostic {
4387 message: format!(
4388 "Invalid {label} '{id}': resource IDs may contain at most one ':' separator"
4389 ),
4390 help: "Use one optional namespace separator, for example `minecraft:load`."
4391 .to_string(),
4392 offset: namespace.len() + 1 + extra_colon,
4393 });
4394 }
4395 (Some(namespace), path, namespace.len() + 1)
4396 } else {
4397 (None, id, 0)
4398 };
4399
4400 if let Some(namespace) = namespace {
4401 let invalid_namespace = namespace.is_empty()
4402 || namespace.chars().any(|c| {
4403 !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-' || c == '.')
4404 });
4405 if invalid_namespace {
4406 return Some(ResourceIdDiagnostic {
4407 message: format!(
4408 "Invalid {label} '{id}': use lowercase namespace:path resource IDs"
4409 ),
4410 help: "Namespaces may contain lowercase letters, digits, `_`, `-`, or `.`."
4411 .to_string(),
4412 offset: 0,
4413 });
4414 }
4415 } else if let Some((prefix, suffix)) = path.split_once('/') {
4416 if prefix == "minecraft" {
4417 return Some(ResourceIdDiagnostic {
4418 message: format!(
4419 "Invalid {label} '{id}': '{prefix}' looks like a namespace. Use '{prefix}:{suffix}' instead of a slash-separated namespace prefix."
4420 ),
4421 help: "Use `namespace:path` for explicit namespaces, not `namespace/path`."
4422 .to_string(),
4423 offset: prefix.len(),
4424 });
4425 }
4426 }
4427
4428 if let Some((index, c)) = path.char_indices().find(|(_, c)| c.is_ascii_uppercase()) {
4429 return Some(ResourceIdDiagnostic {
4430 message: format!(
4431 "Invalid {label} '{id}': uppercase character '{c}' at position {}. Use lowercase resource paths or namespace:path IDs.",
4432 index + 1
4433 ),
4434 help: "Use lowercase resource IDs such as `minecraft:load` or `checks/ready`."
4435 .to_string(),
4436 offset: path_offset + index,
4437 });
4438 }
4439
4440 if let Some(index) = path.find('\\') {
4441 return Some(ResourceIdDiagnostic {
4442 message: format!("Invalid {label} '{id}': use '/' path separators, not '\\'"),
4443 help: "Resource paths use forward slashes, for example `story/root`.".to_string(),
4444 offset: path_offset + index,
4445 });
4446 }
4447
4448 let invalid_segment = path
4449 .split('/')
4450 .any(|segment| segment.is_empty() || segment == "." || segment == "..");
4451 let invalid_char = path.chars().any(|c| {
4452 !(c.is_ascii_lowercase()
4453 || c.is_ascii_digit()
4454 || c == '_'
4455 || c == '-'
4456 || c == '.'
4457 || c == '/')
4458 });
4459 if path.is_empty() || invalid_segment || invalid_char {
4460 return Some(ResourceIdDiagnostic {
4461 message: format!(
4462 "Invalid {label} '{id}': use lowercase resource paths or namespace:path IDs with letters, digits, '/', '_', '-', or '.', and no empty, '.', or '..' segments"
4463 ),
4464 help: "Use a relative path such as `checks/ready` or an explicit ID such as `minecraft:load`."
4465 .to_string(),
4466 offset: path_offset,
4467 });
4468 }
4469
4470 None
4471}
4472
4473fn quoted_string_literal_at(text: &str, offset: usize) -> Option<&str> {
4474 let quote = text.get(offset..)?.chars().next()?;
4475 if !matches!(quote, '"' | '\'') {
4476 return None;
4477 }
4478
4479 let value_start = offset + quote.len_utf8();
4480 let end = find_string_end(text, quote, value_start)?;
4481 Some(&text[value_start..end - quote.len_utf8()])
4482}
4483
4484fn array_literal_item_spans(text: &str) -> Vec<ArgumentSpan> {
4485 let mut result = Vec::new();
4486 let mut start = None;
4487 let mut index = 0usize;
4488 let mut depth = 0usize;
4489 let mut quote = None;
4490 let mut escaped = false;
4491
4492 while index < text.len() {
4493 let ch = text[index..].chars().next().unwrap();
4494 let next_index = index + ch.len_utf8();
4495
4496 if let Some(active_quote) = quote {
4497 if escaped {
4498 escaped = false;
4499 } else if ch == '\\' {
4500 escaped = true;
4501 } else if ch == active_quote {
4502 quote = None;
4503 }
4504 index = next_index;
4505 continue;
4506 }
4507
4508 match ch {
4509 '"' | '\'' => {
4510 quote = Some(ch);
4511 if depth == 1 && start.is_none() {
4512 start = Some(index);
4513 }
4514 index = next_index;
4515 }
4516 '[' => {
4517 depth += 1;
4518 if depth == 1 {
4519 start = Some(next_index);
4520 }
4521 index = next_index;
4522 }
4523 ']' => {
4524 if depth == 1 {
4525 if let Some(start_index) = start {
4526 push_array_item_span(text, start_index, index, &mut result);
4527 }
4528 start = None;
4529 }
4530 depth = depth.saturating_sub(1);
4531 index = next_index;
4532 }
4533 ',' if depth == 1 => {
4534 if let Some(start_index) = start {
4535 push_array_item_span(text, start_index, index, &mut result);
4536 }
4537 start = Some(next_index);
4538 index = next_index;
4539 }
4540 '(' | '{' if depth >= 1 => {
4541 if depth == 1 && start.is_none() {
4542 start = Some(index);
4543 }
4544 depth += 1;
4545 index = next_index;
4546 }
4547 ')' | '}' if depth > 1 => {
4548 depth = depth.saturating_sub(1);
4549 index = next_index;
4550 }
4551 _ => {
4552 if depth == 1 && start.is_none() && !ch.is_whitespace() {
4553 start = Some(index);
4554 }
4555 index = next_index;
4556 }
4557 }
4558 }
4559
4560 result
4561}
4562
4563fn push_array_item_span(text: &str, start: usize, end: usize, result: &mut Vec<ArgumentSpan>) {
4564 if start > end || end > text.len() {
4565 return;
4566 }
4567 let span = argument_span(text, start, end);
4568 if !span.text.is_empty() {
4569 result.push(span);
4570 }
4571}
4572
4573fn check_noop_expression_statement(
4574 line: &str,
4575 trimmed: &str,
4576 raw_trimmed: &str,
4577 line_number: usize,
4578 indent: usize,
4579 diagnostics: &mut Vec<SourceDiagnostic>,
4580) {
4581 if should_skip_noop_expression_scan(trimmed, raw_trimmed) {
4582 return;
4583 }
4584
4585 if trimmed.contains('(') {
4586 return;
4587 }
4588
4589 if !function_calls_in_expression(trimmed).is_empty() {
4590 return;
4591 }
4592
4593 if !looks_like_noop_expression(trimmed) {
4594 return;
4595 }
4596
4597 diagnostics.push(
4598 SourceDiagnostic::error(
4599 "no-op-expression",
4600 line_number,
4601 column_from_byte(line, indent),
4602 "Standalone expression does not generate Minecraft commands",
4603 )
4604 .with_help(
4605 "Assign the value to a variable, call a function/helper, use a raw command, or write `pass` for an intentional no-op.",
4606 ),
4607 );
4608}
4609
4610fn should_skip_noop_expression_scan(trimmed: &str, raw_trimmed: &str) -> bool {
4611 trimmed.is_empty()
4612 || trimmed.starts_with('/')
4613 || trimmed.starts_with('@')
4614 || trimmed.ends_with(':')
4615 || raw_trimmed.starts_with('"')
4616 || raw_trimmed.starts_with('\'')
4617 || starts_with_keyword(trimmed, "def")
4618 || starts_with_keyword(trimmed, "if")
4619 || starts_with_keyword(trimmed, "elif")
4620 || starts_with_keyword(trimmed, "else")
4621 || starts_with_keyword(trimmed, "while")
4622 || starts_with_keyword(trimmed, "for")
4623 || starts_with_keyword(trimmed, "match")
4624 || starts_with_keyword(trimmed, "case")
4625 || starts_with_keyword(trimmed, "return")
4626 || starts_with_keyword(trimmed, "import")
4627 || starts_with_keyword(trimmed, "from")
4628 || starts_with_keyword(trimmed, "global")
4629 || starts_with_keyword(trimmed, "define")
4630 || starts_with_keyword(trimmed, "create")
4631 || starts_with_keyword(trimmed, "end")
4632 || starts_with_keyword(trimmed, "pass")
4633 || starts_with_keyword(trimmed, "const")
4634 || single_equals_index(trimmed).is_some()
4635}
4636
4637fn looks_like_noop_expression(trimmed: &str) -> bool {
4638 let first = trimmed.chars().next();
4639 matches!(first, Some('0'..='9' | '[' | '{' | '+' | '-'))
4640 || starts_with_keyword(trimmed, "True")
4641 || starts_with_keyword(trimmed, "False")
4642 || starts_with_keyword(trimmed, "None")
4643 || starts_with_keyword(trimmed, "not")
4644 || first.is_some_and(|ch| ch == '_' || ch.is_ascii_alphabetic())
4645}
4646
4647fn datapack_json_resource_type(helper: &str) -> Option<&str> {
4648 match helper {
4649 "predicate" | "advancement" | "loot_table" | "recipe" | "item_modifier" | "dialog" => {
4650 Some(helper)
4651 }
4652 _ => None,
4653 }
4654}
4655
4656fn datapack_tag_type(helper: &str) -> Option<&str> {
4657 match helper {
4658 "function_tag" => Some("function"),
4659 "block_tag" => Some("block"),
4660 "item_tag" => Some("item"),
4661 "entity_type_tag" => Some("entity_type"),
4662 _ => None,
4663 }
4664}
4665
4666#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4667struct SourceOffsetLocation {
4668 line: usize,
4669 column: usize,
4670}
4671
4672#[derive(Debug, Clone, Default)]
4673struct MappedSourceExpression {
4674 raw: String,
4675 masked: String,
4676 locations: Vec<SourceOffsetLocation>,
4677}
4678
4679impl MappedSourceExpression {
4680 fn push_line_fragment(
4681 &mut self,
4682 line_number: usize,
4683 raw_line: &str,
4684 masked_line: &str,
4685 start_byte: usize,
4686 ) {
4687 if !self.raw.is_empty() {
4688 let newline_location = SourceOffsetLocation {
4689 line: line_number.saturating_sub(1).max(1),
4690 column: 1,
4691 };
4692 self.raw.push('\n');
4693 self.masked.push('\n');
4694 self.locations.push(newline_location);
4695 }
4696
4697 let raw_fragment = raw_line.get(start_byte..).unwrap_or("");
4698 let masked_fragment = masked_line.get(start_byte..).unwrap_or("");
4699 for ((raw_offset, raw_char), masked_char) in
4700 raw_fragment.char_indices().zip(masked_fragment.chars())
4701 {
4702 let location = SourceOffsetLocation {
4703 line: line_number,
4704 column: column_from_byte(raw_line, start_byte + raw_offset),
4705 };
4706 self.raw.push(raw_char);
4707 self.masked.push(masked_char);
4708 for _ in 0..raw_char.len_utf8() {
4709 self.locations.push(location);
4710 }
4711 }
4712 }
4713
4714 fn location_at(&self, offset: usize) -> SourceOffsetLocation {
4715 self.locations
4716 .get(offset)
4717 .copied()
4718 .or_else(|| self.locations.last().copied())
4719 .unwrap_or(SourceOffsetLocation { line: 1, column: 1 })
4720 }
4721}
4722
4723fn check_multiline_datapack_helper_argument_shapes(
4724 source: &str,
4725 diagnostics: &mut Vec<SourceDiagnostic>,
4726) {
4727 let mut active_docstring_quote = None;
4728 let mut active_expression: Option<MappedSourceExpression> = None;
4729 let mut expression_depth = 0usize;
4730
4731 for (line_index, line) in source.lines().enumerate() {
4732 let line_number = line_index + 1;
4733 let raw_trimmed = line.trim_start();
4734 if should_skip_docstring_scan_line(line, raw_trimmed, &mut active_docstring_quote) {
4735 continue;
4736 }
4737
4738 let masked = mask_non_code(line);
4739 let trimmed = masked.trim_start();
4740 if trimmed.is_empty() || trimmed.starts_with('/') {
4741 continue;
4742 }
4743 let indent = masked.len() - trimmed.len();
4744
4745 if let Some(expression) = active_expression.as_mut() {
4746 expression.push_line_fragment(line_number, line, &masked, indent);
4747 update_delimiter_depth_for_indentation(trimmed, &mut expression_depth);
4748 if expression_depth == 0 {
4749 if let Some(expression) = active_expression.take() {
4750 check_mapped_datapack_helper_argument_shapes(&expression, diagnostics);
4751 }
4752 }
4753 continue;
4754 }
4755
4756 if !trimmed.contains("datapack.") {
4757 continue;
4758 }
4759
4760 let mut depth = 0usize;
4761 update_delimiter_depth_for_indentation(trimmed, &mut depth);
4762 if depth == 0 {
4763 continue;
4764 }
4765
4766 let mut expression = MappedSourceExpression::default();
4767 expression.push_line_fragment(line_number, line, &masked, indent);
4768 expression_depth = depth;
4769 active_expression = Some(expression);
4770 }
4771}
4772
4773fn check_mapped_datapack_helper_argument_shapes(
4774 expression: &MappedSourceExpression,
4775 diagnostics: &mut Vec<SourceDiagnostic>,
4776) {
4777 for call in function_calls_in_expression(&expression.masked) {
4778 let Some(helper) = call.name.strip_prefix("datapack.") else {
4779 continue;
4780 };
4781
4782 if let Some(resource_type) = datapack_json_resource_type(helper) {
4783 if call.arg_count != 2 {
4784 let location = expression.location_at(call.offset);
4785 diagnostics.push(
4786 SourceDiagnostic::error(
4787 "datapack-resource-argument",
4788 location.line,
4789 location.column,
4790 format!("datapack.{resource_type}() takes 2 arguments"),
4791 )
4792 .with_help("Pass a resource name and an object JSON value."),
4793 );
4794 continue;
4795 }
4796
4797 if let Some(name_arg) = call.arguments.first() {
4798 check_mapped_datapack_resource_id_argument(
4799 expression,
4800 name_arg,
4801 "resource name",
4802 diagnostics,
4803 );
4804 }
4805
4806 let Some(json_arg) = call.arguments.get(1) else {
4807 continue;
4808 };
4809 if !json_arg.text.trim_start().starts_with('{') {
4810 let location = expression.location_at(json_arg.offset);
4811 diagnostics.push(
4812 SourceDiagnostic::error(
4813 "datapack-resource-argument",
4814 location.line,
4815 location.column,
4816 format!("datapack.{resource_type}() JSON value must be an object"),
4817 )
4818 .with_help("Use an object literal such as `{ \"condition\": \"...\" }`."),
4819 );
4820 }
4821 continue;
4822 }
4823
4824 if let Some(tag_type) = datapack_tag_type(helper) {
4825 if call.arg_count != 2 {
4826 let location = expression.location_at(call.offset);
4827 diagnostics.push(
4828 SourceDiagnostic::error(
4829 "datapack-resource-argument",
4830 location.line,
4831 location.column,
4832 format!("datapack.{tag_type}_tag() takes 2 arguments"),
4833 )
4834 .with_help("Pass a tag name and an array of resource IDs."),
4835 );
4836 continue;
4837 }
4838
4839 if let Some(name_arg) = call.arguments.first() {
4840 check_mapped_datapack_resource_id_argument(
4841 expression,
4842 name_arg,
4843 "tag name",
4844 diagnostics,
4845 );
4846 }
4847
4848 let Some(values_arg) = call.arguments.get(1) else {
4849 continue;
4850 };
4851 if !values_arg.text.trim_start().starts_with('[') {
4852 let location = expression.location_at(values_arg.offset);
4853 diagnostics.push(
4854 SourceDiagnostic::error(
4855 "datapack-resource-argument",
4856 location.line,
4857 location.column,
4858 "Tag values must be an array",
4859 )
4860 .with_help("Use an array literal such as `[\"minecraft:stone\"]`."),
4861 );
4862 } else {
4863 check_mapped_datapack_tag_value_resource_ids(expression, values_arg, diagnostics);
4864 }
4865 }
4866 }
4867}
4868
4869fn check_mapped_datapack_resource_id_argument(
4870 expression: &MappedSourceExpression,
4871 argument: &FunctionCallArgumentSpan,
4872 label: &str,
4873 diagnostics: &mut Vec<SourceDiagnostic>,
4874) {
4875 let Some(value) = quoted_string_literal_at(&expression.raw, argument.offset) else {
4876 return;
4877 };
4878 let Some(diagnostic) = validate_datapack_resource_id(label, value) else {
4879 return;
4880 };
4881 let location = expression.location_at(argument.offset + 1 + diagnostic.offset);
4882 diagnostics.push(
4883 SourceDiagnostic::error(
4884 "datapack-resource-id",
4885 location.line,
4886 location.column,
4887 diagnostic.message,
4888 )
4889 .with_help(diagnostic.help),
4890 );
4891}
4892
4893fn check_mapped_datapack_tag_value_resource_ids(
4894 expression: &MappedSourceExpression,
4895 argument: &FunctionCallArgumentSpan,
4896 diagnostics: &mut Vec<SourceDiagnostic>,
4897) {
4898 let raw_argument = expression
4899 .raw
4900 .get(argument.offset..argument.offset + argument.text.len())
4901 .unwrap_or(&argument.text);
4902
4903 for item in array_literal_item_spans(raw_argument) {
4904 let item_offset = argument.offset + item.offset;
4905 if !item.text.starts_with('"') && !item.text.starts_with('\'') {
4906 let location = expression.location_at(item_offset);
4907 diagnostics.push(
4908 SourceDiagnostic::error(
4909 "datapack-resource-argument",
4910 location.line,
4911 location.column,
4912 "Tag values must be string resource IDs",
4913 )
4914 .with_help("Use string values such as `[\"minecraft:stone\"]`."),
4915 );
4916 continue;
4917 }
4918
4919 let Some(value) = quoted_string_literal_at(raw_argument, item.offset) else {
4920 continue;
4921 };
4922 let Some(diagnostic) = validate_datapack_resource_id("tag value", value) else {
4923 continue;
4924 };
4925 let location = expression.location_at(item_offset + 1 + diagnostic.offset);
4926 diagnostics.push(
4927 SourceDiagnostic::error(
4928 "datapack-resource-id",
4929 location.line,
4930 location.column,
4931 diagnostic.message,
4932 )
4933 .with_help(diagnostic.help),
4934 );
4935 }
4936}
4937
4938#[derive(Debug, Clone, PartialEq, Eq)]
4939struct FunctionCallSpan {
4940 name: String,
4941 offset: usize,
4942 arg_count: usize,
4943 arguments: Vec<FunctionCallArgumentSpan>,
4944}
4945
4946#[derive(Debug, Clone, PartialEq, Eq)]
4947struct FunctionCallArgumentSpan {
4948 text: String,
4949 offset: usize,
4950}
4951
4952#[derive(Debug, Clone, PartialEq, Eq)]
4953struct FunctionSignature {
4954 name: String,
4955 params: Vec<String>,
4956 line: usize,
4957 column: usize,
4958}
4959
4960#[derive(Debug, Clone, PartialEq, Eq)]
4961struct FileFunctionSignature {
4962 path: PathBuf,
4963 name: String,
4964 params: Vec<String>,
4965 line: usize,
4966 column: usize,
4967}
4968
4969#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
4970enum FileSymbolKind {
4971 SelectorAlias,
4972 EntityTemplate,
4973}
4974
4975impl FileSymbolKind {
4976 fn label(self) -> &'static str {
4977 match self {
4978 FileSymbolKind::SelectorAlias => "selector alias",
4979 FileSymbolKind::EntityTemplate => "entity template",
4980 }
4981 }
4982
4983 fn prefix(self) -> &'static str {
4984 match self {
4985 FileSymbolKind::SelectorAlias | FileSymbolKind::EntityTemplate => "@",
4986 }
4987 }
4988}
4989
4990#[derive(Debug, Clone, PartialEq, Eq)]
4991struct FileNamedSymbol {
4992 path: PathBuf,
4993 name: String,
4994 kind: FileSymbolKind,
4995 line: usize,
4996 column: usize,
4997}
4998
4999impl FileNamedSymbol {
5000 fn display_name(&self) -> String {
5001 format!("{}{}", self.kind.prefix(), self.name)
5002 }
5003}
5004
5005#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5006struct DelimiterFrame {
5007 delimiter: char,
5008 line: usize,
5009 column: usize,
5010}
5011
5012#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5013struct QuoteFrame {
5014 quote: char,
5015 line: usize,
5016 column: usize,
5017}
5018
5019fn function_calls_in_expression(expression: &str) -> Vec<FunctionCallSpan> {
5020 let mut calls = Vec::new();
5021 let bytes = expression.as_bytes();
5022 let mut index = 0;
5023
5024 while index < bytes.len() {
5025 if bytes[index] != b'(' {
5026 index += 1;
5027 continue;
5028 }
5029
5030 let Some((name, offset)) = function_name_before_open_paren(expression, index) else {
5031 index += 1;
5032 continue;
5033 };
5034 let Some(close_index) = matching_close_paren(expression, index) else {
5035 index += 1;
5036 continue;
5037 };
5038
5039 if !is_control_word(&name) {
5040 let arguments = split_top_level_arg_spans(&expression[index + 1..close_index])
5041 .into_iter()
5042 .filter(|argument| !argument.text.is_empty())
5043 .map(|argument| FunctionCallArgumentSpan {
5044 text: argument.text,
5045 offset: index + 1 + argument.offset,
5046 })
5047 .collect::<Vec<_>>();
5048 let arg_count = arguments.len();
5049 calls.push(FunctionCallSpan {
5050 name,
5051 offset,
5052 arg_count,
5053 arguments,
5054 });
5055 }
5056 index += 1;
5057 }
5058
5059 calls
5060}
5061
5062fn matching_close_paren(expression: &str, open_index: usize) -> Option<usize> {
5063 let bytes = expression.as_bytes();
5064 let mut depth = 0usize;
5065
5066 for (index, byte) in bytes.iter().enumerate().skip(open_index) {
5067 match byte {
5068 b'(' | b'[' | b'{' => depth += 1,
5069 b')' => {
5070 depth = depth.saturating_sub(1);
5071 if depth == 0 {
5072 return Some(index);
5073 }
5074 }
5075 b']' | b'}' => depth = depth.saturating_sub(1),
5076 _ => {}
5077 }
5078 }
5079
5080 None
5081}
5082
5083fn split_top_level_args(args: &str) -> Vec<&str> {
5084 let mut result = Vec::new();
5085 let mut start = 0;
5086 let mut depth = 0usize;
5087
5088 for (index, byte) in args.as_bytes().iter().enumerate() {
5089 match byte {
5090 b'(' | b'[' | b'{' => depth += 1,
5091 b')' | b']' | b'}' => depth = depth.saturating_sub(1),
5092 b',' if depth == 0 => {
5093 result.push(&args[start..index]);
5094 start = index + 1;
5095 }
5096 _ => {}
5097 }
5098 }
5099
5100 result.push(&args[start..]);
5101 result
5102}
5103
5104#[derive(Debug, Clone, PartialEq, Eq)]
5105struct ArgumentSpan {
5106 text: String,
5107 offset: usize,
5108}
5109
5110fn split_top_level_arg_spans(args: &str) -> Vec<ArgumentSpan> {
5111 let mut result = Vec::new();
5112 let mut start = 0;
5113 let mut depth = 0usize;
5114
5115 for (index, byte) in args.as_bytes().iter().enumerate() {
5116 match byte {
5117 b'(' | b'[' | b'{' => depth += 1,
5118 b')' | b']' | b'}' => depth = depth.saturating_sub(1),
5119 b',' if depth == 0 => {
5120 result.push(argument_span(args, start, index));
5121 start = index + 1;
5122 }
5123 _ => {}
5124 }
5125 }
5126
5127 result.push(argument_span(args, start, args.len()));
5128 result
5129}
5130
5131fn argument_span(args: &str, start: usize, end: usize) -> ArgumentSpan {
5132 let text = &args[start..end];
5133 let trim_start = text.len() - text.trim_start().len();
5134 let trimmed = text.trim();
5135
5136 ArgumentSpan {
5137 text: trimmed.to_string(),
5138 offset: start + trim_start,
5139 }
5140}
5141
5142fn function_name_before_open_paren(expression: &str, open_index: usize) -> Option<(String, usize)> {
5143 let bytes = expression.as_bytes();
5144 let mut end = open_index;
5145 while end > 0 && bytes[end - 1].is_ascii_whitespace() {
5146 end -= 1;
5147 }
5148 if end == 0 {
5149 return None;
5150 }
5151
5152 let mut start = end;
5153 while start > 0 {
5154 let byte = bytes[start - 1];
5155 if byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'.' {
5156 start -= 1;
5157 } else {
5158 break;
5159 }
5160 }
5161
5162 if start == end {
5163 return None;
5164 }
5165
5166 let name = expression[start..end].trim_matches('.').to_string();
5167 if name.is_empty() || name.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
5168 return None;
5169 }
5170
5171 Some((name, start))
5172}
5173
5174#[derive(Debug, Clone, PartialEq, Eq)]
5175struct MathValueFunctionDiagnostic {
5176 kind: &'static str,
5177 message: String,
5178 help: &'static str,
5179}
5180
5181fn invalid_math_value_function_call(
5182 call: &FunctionCallSpan,
5183) -> Option<MathValueFunctionDiagnostic> {
5184 if !call.name.starts_with("math.") {
5185 return None;
5186 }
5187
5188 let Some(arity) = math_value_function_arity(&call.name) else {
5189 return Some(MathValueFunctionDiagnostic {
5190 kind: "undefined-function",
5191 message: format!("Unknown math function `{}`", call.name),
5192 help: "Use one of the supported math intrinsics: math.sqrt, math.abs, math.min, or math.max.",
5193 });
5194 };
5195
5196 if call.arg_count != arity {
5197 return Some(MathValueFunctionDiagnostic {
5198 kind: "function-argument-count",
5199 message: format!(
5200 "{}() takes {} {}, but {} provided",
5201 call.name,
5202 arity,
5203 argument_word(arity),
5204 call.arg_count
5205 ),
5206 help: "Use math.sqrt(value), math.abs(value), math.min(left, right), or math.max(left, right).",
5207 });
5208 }
5209
5210 None
5211}
5212
5213fn math_value_function_arity(name: &str) -> Option<usize> {
5214 match name {
5215 "math.sqrt" | "math.abs" => Some(1),
5216 "math.min" | "math.max" => Some(2),
5217 _ => None,
5218 }
5219}
5220
5221fn argument_word(count: usize) -> &'static str {
5222 if count == 1 {
5223 "argument"
5224 } else {
5225 "arguments"
5226 }
5227}
5228
5229fn is_allowed_value_function_call(name: &str) -> bool {
5230 math_value_function_arity(name).is_some()
5231}
5232
5233fn is_control_word(name: &str) -> bool {
5234 matches!(name, "if" | "for" | "while" | "match" | "return")
5235}
5236
5237fn looks_like_selector_definition(trimmed: &str) -> bool {
5238 let Some(index) = single_equals_index(trimmed) else {
5239 return false;
5240 };
5241 let left = trimmed[..index].trim();
5242 let right = trimmed[index + 1..].trim();
5243 left.starts_with('@')
5244 && left[1..]
5245 .chars()
5246 .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
5247 && right.starts_with('@')
5248}
5249
5250fn single_equals_index(text: &str) -> Option<usize> {
5251 let bytes = text.as_bytes();
5252 let mut delimiter_depth = 0usize;
5253
5254 for (index, byte) in bytes.iter().enumerate() {
5255 match byte {
5256 b'(' | b'[' | b'{' => {
5257 delimiter_depth += 1;
5258 continue;
5259 }
5260 b')' | b']' | b'}' => {
5261 delimiter_depth = delimiter_depth.saturating_sub(1);
5262 continue;
5263 }
5264 _ => {}
5265 }
5266
5267 if delimiter_depth > 0 {
5268 continue;
5269 }
5270
5271 if *byte != b'=' {
5272 continue;
5273 }
5274 let previous = index.checked_sub(1).and_then(|i| bytes.get(i)).copied();
5275 let next = bytes.get(index + 1).copied();
5276 if matches!(
5277 previous,
5278 Some(b'=' | b'!' | b'<' | b'>' | b'+' | b'-' | b'*' | b'/' | b'%' | b'^')
5279 ) || matches!(next, Some(b'='))
5280 {
5281 continue;
5282 }
5283 return Some(index);
5284 }
5285 None
5286}
5287
5288fn starts_with_keyword(text: &str, keyword: &str) -> bool {
5289 let Some(rest) = text.strip_prefix(keyword) else {
5290 return false;
5291 };
5292 rest.is_empty() || rest.chars().next().is_some_and(|ch| !is_ident_char(ch))
5293}
5294
5295fn find_word(text: &str, word: &str) -> Option<usize> {
5296 let mut search_from = 0;
5297 while let Some(offset) = text[search_from..].find(word) {
5298 let index = search_from + offset;
5299 let before = text[..index].chars().next_back();
5300 let after = text[index + word.len()..].chars().next();
5301 if before.is_none_or(|ch| !is_ident_char(ch)) && after.is_none_or(|ch| !is_ident_char(ch)) {
5302 return Some(index);
5303 }
5304 search_from = index + word.len();
5305 }
5306 None
5307}
5308
5309fn is_ident_char(ch: char) -> bool {
5310 ch.is_ascii_alphanumeric() || ch == '_'
5311}
5312
5313fn column_from_byte(line: &str, byte_index: usize) -> usize {
5314 line[..byte_index.min(line.len())].chars().count() + 1
5315}
5316
5317fn matching_close_delimiter(open: char) -> char {
5318 match open {
5319 '(' => ')',
5320 '[' => ']',
5321 '{' => '}',
5322 _ => open,
5323 }
5324}
5325
5326fn triple_quote_pattern(quote: char) -> &'static str {
5327 match quote {
5328 '"' => "\"\"\"",
5329 '\'' => "'''",
5330 _ => "",
5331 }
5332}
5333
5334fn leading_triple_quote(text: &str) -> Option<char> {
5335 if text.starts_with("\"\"\"") {
5336 Some('"')
5337 } else if text.starts_with("'''") {
5338 Some('\'')
5339 } else {
5340 None
5341 }
5342}
5343
5344fn find_triple_quote(line: &str, quote: char, start: usize) -> Option<usize> {
5345 line.get(start..)?
5346 .find(triple_quote_pattern(quote))
5347 .map(|offset| start + offset)
5348}
5349
5350fn find_string_end(line: &str, quote: char, start: usize) -> Option<usize> {
5351 let mut index = start;
5352 let mut escaped = false;
5353
5354 while index < line.len() {
5355 let ch = line[index..].chars().next().unwrap();
5356 let next_index = index + ch.len_utf8();
5357 if escaped {
5358 escaped = false;
5359 } else if ch == '\\' {
5360 escaped = true;
5361 } else if ch == quote {
5362 return Some(next_index);
5363 }
5364 index = next_index;
5365 }
5366
5367 None
5368}
5369
5370fn mask_non_code(line: &str) -> String {
5371 let mut output = String::with_capacity(line.len());
5372 let mut chars = line.chars().peekable();
5373 let mut quote = None;
5374 let mut escaped = false;
5375
5376 while let Some(ch) = chars.next() {
5377 if let Some(active_quote) = quote {
5378 output.push(' ');
5379 if escaped {
5380 escaped = false;
5381 } else if ch == '\\' {
5382 escaped = true;
5383 } else if ch == active_quote {
5384 quote = None;
5385 }
5386 continue;
5387 }
5388
5389 match ch {
5390 '"' | '\'' => {
5391 quote = Some(ch);
5392 output.push('_');
5393 }
5394 '#' => {
5395 output.push(' ');
5396 for _ in chars {
5397 output.push(' ');
5398 }
5399 break;
5400 }
5401 _ => output.push(ch),
5402 }
5403 }
5404
5405 output
5406}
5407
5408fn canonical_or_original(path: &Path) -> PathBuf {
5409 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
5410}
5411
5412fn is_simple_module_name(module: &str) -> bool {
5413 let mut chars = module.chars();
5414 let Some(first) = chars.next() else {
5415 return false;
5416 };
5417
5418 (first.is_ascii_alphabetic() || first == '_') && chars.all(is_ident_char)
5419}
5420
5421fn find_import_location(source: &str, import: &Import) -> Option<(usize, usize)> {
5422 for (line_index, line) in source.lines().enumerate() {
5423 let masked = mask_non_code(line);
5424 let trimmed = masked.trim_start();
5425 let indent = masked.len() - trimmed.len();
5426
5427 if import.items.is_empty() {
5428 let Some(rest) = trimmed.strip_prefix("import ") else {
5429 continue;
5430 };
5431 let module = rest
5432 .split(|ch: char| ch.is_whitespace() || ch == ',')
5433 .next()
5434 .unwrap_or("");
5435 if module == import.module {
5436 return Some((
5437 line_index + 1,
5438 column_from_byte(line, indent + "import ".len()),
5439 ));
5440 }
5441 } else {
5442 let Some(rest) = trimmed.strip_prefix("from ") else {
5443 continue;
5444 };
5445 let Some((module, _items)) = rest.split_once(" import ") else {
5446 continue;
5447 };
5448 if module.trim() == import.module {
5449 return Some((
5450 line_index + 1,
5451 column_from_byte(line, indent + "from ".len()),
5452 ));
5453 }
5454 }
5455 }
5456
5457 None
5458}
5459
5460fn find_import_item_location(source: &str, import: &Import, item: &str) -> Option<(usize, usize)> {
5461 for (line_index, line) in source.lines().enumerate() {
5462 let masked = mask_non_code(line);
5463 let trimmed = masked.trim_start();
5464 let indent = masked.len() - trimmed.len();
5465
5466 let Some(rest) = trimmed.strip_prefix("from ") else {
5467 continue;
5468 };
5469 let Some((module, items)) = rest.split_once(" import ") else {
5470 continue;
5471 };
5472 if module.trim() != import.module {
5473 continue;
5474 }
5475
5476 let items_offset = indent + "from ".len() + module.len() + " import ".len();
5477 let mut search_from = 0;
5478 while search_from < items.len() {
5479 let item_text = items[search_from..]
5480 .split_once(',')
5481 .map(|(head, _)| head)
5482 .unwrap_or(&items[search_from..]);
5483 let leading = item_text.len() - item_text.trim_start().len();
5484 let candidate = item_text.trim().trim_end_matches(':');
5485 if candidate == item {
5486 return Some((
5487 line_index + 1,
5488 column_from_byte(line, items_offset + search_from + leading),
5489 ));
5490 }
5491 search_from += item_text.len() + 1;
5492 }
5493 }
5494
5495 None
5496}
5497
5498fn add_import_chain_help(
5499 diagnostics: &mut [SourceDiagnostic],
5500 stack: &[PathBuf],
5501 next_path: &Path,
5502) {
5503 let chain_help = format!("Import chain: {}", format_import_chain(stack, next_path));
5504 for diagnostic in diagnostics {
5505 diagnostic.help = Some(match diagnostic.help.take() {
5506 Some(help) => format!("{help}\n{chain_help}"),
5507 None => chain_help.clone(),
5508 });
5509 }
5510}
5511
5512fn format_import_chain(stack: &[PathBuf], next_path: &Path) -> String {
5513 let mut parts = stack
5514 .iter()
5515 .map(|path| path.display().to_string())
5516 .collect::<Vec<_>>();
5517 parts.push(next_path.display().to_string());
5518 parts.join(" -> ")
5519}
5520
5521#[cfg(test)]
5522mod tests {
5523 use super::*;
5524 use std::time::{SystemTime, UNIX_EPOCH};
5525
5526 struct TempDir {
5527 path: PathBuf,
5528 }
5529
5530 impl TempDir {
5531 fn new(name: &str) -> Self {
5532 let nanos = SystemTime::now()
5533 .duration_since(UNIX_EPOCH)
5534 .unwrap()
5535 .as_nanos();
5536 let path = std::env::temp_dir().join(format!(
5537 "cobble-diagnostics-{name}-{}-{nanos}",
5538 std::process::id()
5539 ));
5540 std::fs::create_dir_all(&path).unwrap();
5541 Self { path }
5542 }
5543
5544 fn path(&self) -> &Path {
5545 &self.path
5546 }
5547 }
5548
5549 impl Drop for TempDir {
5550 fn drop(&mut self) {
5551 let _ = std::fs::remove_dir_all(&self.path);
5552 }
5553 }
5554
5555 fn messages(source: &str) -> Vec<String> {
5556 analyze_source(source)
5557 .into_iter()
5558 .map(|diagnostic| diagnostic.message)
5559 .collect()
5560 }
5561
5562 #[test]
5563 fn file_diagnostics_format_includes_source_snippet() {
5564 let diagnostics = FileSourceDiagnostics::new(
5565 "main.cbl",
5566 "def main():\n value = (1 + 2\n",
5567 vec![SourceDiagnostic::error(
5568 "unclosed-delimiter",
5569 2,
5570 13,
5571 "Opening delimiter `(` is not closed",
5572 )
5573 .with_help("Add the matching `)` before the expression ends.")],
5574 );
5575
5576 let formatted = format_file_diagnostics(&[diagnostics]);
5577
5578 assert!(formatted.contains("main.cbl:2:13: error[unclosed-delimiter]"));
5579 assert!(formatted.contains("2 | value = (1 + 2"));
5580 assert!(formatted.contains(" | ^"));
5581 assert!(formatted.contains("help: Add the matching `)`"));
5582 }
5583
5584 #[test]
5585 fn byte_offsets_account_for_crlf_line_endings() {
5586 let source = "first\r\nsecond\r\nthird";
5587
5588 assert_eq!(byte_offset_for_line_column(source, 2, 1), "first\r\n".len());
5589 assert_eq!(
5590 byte_offset_for_line_column(source, 3, 3),
5591 "first\r\nsecond\r\nth".len()
5592 );
5593 }
5594
5595 #[test]
5596 fn masks_strings_and_comments_before_detection() {
5597 let diagnostics = analyze_source(
5598 r#"
5599def main():
5600 message = "class try lambda"
5601 /tellraw @a {"text":"i for i in values"}
5602 score = 1 # score += 1
5603"#,
5604 );
5605
5606 assert!(diagnostics.is_empty(), "{diagnostics:?}");
5607 }
5608
5609 #[test]
5610 fn reports_python_like_unsupported_syntax() {
5611 let source = r#"
5612@tick
5613def reward(player, amount=1, *extra):
5614 values = [i for i in range(3)]
5615 score += 1
5616 obj.field = 2
5617 break
5618 continue
5619 assert score
5620 raise score
5621 del score
5622import foo.bar
5623"#;
5624
5625 let diagnostics = messages(source);
5626 assert!(diagnostics.iter().any(|m| m.contains("Decorators")));
5627 assert!(diagnostics
5628 .iter()
5629 .any(|m| m.contains("Default parameter values")));
5630 assert!(diagnostics.iter().any(|m| m.contains("`*args`")));
5631 assert!(diagnostics.iter().any(|m| m.contains("comprehensions")));
5632 assert!(diagnostics
5633 .iter()
5634 .any(|m| m.contains("Compound assignment")));
5635 assert!(diagnostics
5636 .iter()
5637 .any(|m| m.contains("simple identifier assignment")));
5638 assert!(diagnostics.iter().any(|m| m.contains("Dotted imports")));
5639 assert!(diagnostics.iter().any(|m| m.contains("`break`")));
5640 assert!(diagnostics.iter().any(|m| m.contains("`continue`")));
5641 assert!(diagnostics.iter().any(|m| m.contains("`assert`")));
5642 assert!(diagnostics.iter().any(|m| m.contains("`raise`")));
5643 assert!(diagnostics.iter().any(|m| m.contains("`del`")));
5644 }
5645
5646 #[test]
5647 fn reports_each_named_unsupported_python_construct() {
5648 for (source, expected) in [
5649 ("class Reward:\n pass\n", "`class` is not supported"),
5650 ("try:\n pass\n", "`try` is not supported"),
5651 (
5652 "except ValueError:\n pass\n",
5653 "`except` is not supported",
5654 ),
5655 ("finally:\n pass\n", "`finally` is not supported"),
5656 ("with storage:\n pass\n", "`with` is not supported"),
5657 ("nonlocal score\n", "`nonlocal` is not supported"),
5658 ("value = lambda x: x\n", "`lambda` is not supported"),
5659 ("yield score\n", "`yield` is not supported"),
5660 ("value = await score\n", "`await` is not supported"),
5661 ("async def main():\n pass\n", "`async` is not supported"),
5662 ] {
5663 let diagnostics = analyze_source(source);
5664 assert!(
5665 diagnostics.iter().any(|diagnostic| {
5666 diagnostic.kind == "unsupported-python-syntax"
5667 && diagnostic.message.contains(expected)
5668 }),
5669 "expected {expected:?} in diagnostics for source {source:?}: {diagnostics:?}",
5670 );
5671 }
5672 }
5673
5674 #[test]
5675 fn reports_unsupported_import_forms() {
5676 let diagnostics = messages(
5677 r#"
5678import helpers as h
5679import foo, bar
5680from helpers import *
5681from helpers import setup as renamed
5682"#,
5683 );
5684
5685 assert!(diagnostics
5686 .iter()
5687 .any(|message| message.contains("Import aliases are not supported")));
5688 assert!(diagnostics
5689 .iter()
5690 .any(|message| message.contains("Multiple modules in one import")));
5691 assert!(diagnostics
5692 .iter()
5693 .any(|message| message.contains("Wildcard imports are not supported")));
5694 }
5695
5696 #[test]
5697 fn reports_duplicate_function_parameters_with_source_location() {
5698 let diagnostics = analyze_source(
5699 r#"
5700def greet(player, player):
5701 /say duplicate
5702"#,
5703 );
5704
5705 assert_eq!(diagnostics.len(), 1);
5706 assert_eq!(diagnostics[0].kind, "duplicate-function-parameter");
5707 assert_eq!(diagnostics[0].line, 2);
5708 assert_eq!(diagnostics[0].column, 19);
5709 assert!(diagnostics[0]
5710 .message
5711 .contains("Duplicate function parameter `player`"));
5712 }
5713
5714 #[test]
5715 fn reports_for_else_without_rejecting_if_else() {
5716 let diagnostics = analyze_source(
5717 r#"
5718def main():
5719 if True:
5720 pass
5721 else:
5722 pass
5723 for i in range(3):
5724 pass
5725 else:
5726 pass
5727"#,
5728 );
5729
5730 assert_eq!(diagnostics.len(), 1);
5731 assert_eq!(diagnostics[0].kind, "unsupported-control-flow");
5732 assert_eq!(diagnostics[0].line, 9);
5733 }
5734
5735 #[test]
5736 fn reports_missing_block_colon_with_source_location() {
5737 let diagnostics = parse_source(
5738 r#"
5739def main()
5740 /say missing colon
5741"#,
5742 )
5743 .expect_err("missing colon should fail before parser fallback");
5744
5745 assert_eq!(diagnostics.len(), 1);
5746 assert_eq!(diagnostics[0].kind, "missing-block-colon");
5747 assert_eq!(diagnostics[0].line, 2);
5748 assert!(diagnostics[0].column > 1);
5749 }
5750
5751 #[test]
5752 fn reports_unclosed_delimiter_with_source_location() {
5753 let diagnostics = parse_source(
5754 r#"
5755def main():
5756 value = (1 + 2
5757"#,
5758 )
5759 .expect_err("unclosed delimiter should fail before parser fallback");
5760
5761 assert_eq!(diagnostics.len(), 1);
5762 assert_eq!(diagnostics[0].kind, "unclosed-delimiter");
5763 assert_eq!(diagnostics[0].line, 3);
5764 assert_eq!(diagnostics[0].column, 13);
5765 assert!(diagnostics[0]
5766 .message
5767 .contains("Opening delimiter `(` is not closed"));
5768 }
5769
5770 #[test]
5771 fn reports_unmatched_closing_delimiter_with_source_location() {
5772 let diagnostics = parse_source(
5773 r#"
5774def main():
5775 value = 1]
5776"#,
5777 )
5778 .expect_err("unmatched closing delimiter should fail before parser fallback");
5779
5780 assert_eq!(diagnostics.len(), 1);
5781 assert_eq!(diagnostics[0].kind, "unmatched-delimiter");
5782 assert_eq!(diagnostics[0].line, 3);
5783 assert_eq!(diagnostics[0].column, 14);
5784 assert!(diagnostics[0]
5785 .message
5786 .contains("Unexpected closing delimiter `]`"));
5787 }
5788
5789 #[test]
5790 fn reports_unterminated_string_with_source_location() {
5791 let diagnostics = parse_source(
5792 r#"
5793def main():
5794 message = "oops
5795"#,
5796 )
5797 .expect_err("unterminated string should fail before parser fallback");
5798
5799 assert_eq!(diagnostics.len(), 1);
5800 assert_eq!(diagnostics[0].kind, "unterminated-string");
5801 assert_eq!(diagnostics[0].line, 3);
5802 assert_eq!(diagnostics[0].column, 15);
5803 }
5804
5805 #[test]
5806 fn reports_unexpected_indentation_with_source_location() {
5807 let diagnostics = parse_source(
5808 r#"
5809score = 1
5810 score = 2
5811"#,
5812 )
5813 .expect_err("unexpected indentation should fail before parser fallback");
5814
5815 assert_eq!(diagnostics.len(), 1);
5816 assert_eq!(diagnostics[0].kind, "unexpected-indentation");
5817 assert_eq!(diagnostics[0].line, 3);
5818 assert_eq!(diagnostics[0].column, 5);
5819 }
5820
5821 #[test]
5822 fn reports_inconsistent_indentation_with_source_location() {
5823 let diagnostics = parse_source(
5824 r#"
5825def main():
5826 if True:
5827 pass
5828 pass
5829"#,
5830 )
5831 .expect_err("inconsistent indentation should fail before parser fallback");
5832
5833 assert_eq!(diagnostics.len(), 1);
5834 assert_eq!(diagnostics[0].kind, "inconsistent-indentation");
5835 assert_eq!(diagnostics[0].line, 5);
5836 assert_eq!(diagnostics[0].column, 7);
5837 }
5838
5839 #[test]
5840 fn allows_multiline_expression_indentation() {
5841 let diagnostics = analyze_source(
5842 r#"
5843def main():
5844 value = (
5845 1 + 2
5846 )
5847 datapack.predicate(
5848 "always",
5849 {"condition": "minecraft:random_chance", "chance": 1}
5850 )
5851"#,
5852 );
5853
5854 assert!(diagnostics.is_empty(), "{diagnostics:?}");
5855 }
5856
5857 #[test]
5858 fn ignores_delimiters_inside_strings_comments_docstrings_and_raw_commands() {
5859 let diagnostics = analyze_source(
5860 r#"
5861def main():
5862 text = "literal ) ] }"
5863 value = 1 # (
5864 """Docstring can mention ( [ {
5865 across lines until it closes.
5866 """
5867 /say raw command can mention ]
5868"#,
5869 );
5870
5871 assert!(diagnostics.is_empty(), "{diagnostics:?}");
5872 }
5873
5874 #[test]
5875 fn ignores_selector_argument_equals_in_execute_modifiers() {
5876 let diagnostics = analyze_source(
5877 r#"
5878def main():
5879 as @Players at @s if entity @s[distance=..32]:
5880 /say nearby
5881"#,
5882 );
5883
5884 assert!(diagnostics.is_empty(), "{diagnostics:?}");
5885 }
5886
5887 #[test]
5888 fn reports_return_duplicate_function_and_call_assignment() {
5889 let diagnostics = analyze_source(
5890 r#"
5891def helper():
5892 pass
5893
5894def helper():
5895 return
5896
5897def main():
5898 value = helper()
5899"#,
5900 );
5901
5902 assert!(diagnostics
5903 .iter()
5904 .any(|diagnostic| diagnostic.kind == "duplicate-function"));
5905 assert!(diagnostics
5906 .iter()
5907 .any(|diagnostic| diagnostic.kind == "unsupported-return"));
5908 assert!(diagnostics
5909 .iter()
5910 .any(|diagnostic| diagnostic.kind == "unsupported-function-call-expression"));
5911 }
5912
5913 #[test]
5914 fn allows_math_intrinsics_in_assignment_values() {
5915 let diagnostics = analyze_source(
5916 r#"
5917def main():
5918 root = math.sqrt(64)
5919 lower = math.min(3, 4)
5920"#,
5921 );
5922
5923 assert!(diagnostics.is_empty(), "{diagnostics:?}");
5924 }
5925
5926 #[test]
5927 fn parse_source_returns_preflight_diagnostics() {
5928 let diagnostics = parse_source(
5929 r#"
5930def main():
5931 return
5932"#,
5933 )
5934 .expect_err("parse_source should reject unsupported return");
5935
5936 assert_eq!(diagnostics.len(), 1);
5937 assert_eq!(diagnostics[0].kind, "unsupported-return");
5938 }
5939
5940 #[test]
5941 fn reports_type_mismatch_for_known_reassignments() {
5942 let diagnostics = analyze_source(
5943 r#"
5944def main():
5945 items = ["sword"]
5946 items = 3
5947"#,
5948 );
5949
5950 assert_eq!(diagnostics.len(), 1);
5951 assert_eq!(diagnostics[0].kind, "type-mismatch");
5952 assert_eq!(diagnostics[0].line, 4);
5953 assert!(diagnostics[0]
5954 .message
5955 .contains("Type mismatch for variable 'items'"));
5956 assert!(diagnostics[0]
5957 .help
5958 .as_deref()
5959 .unwrap()
5960 .contains("previously defined as type: list"));
5961 }
5962
5963 #[test]
5964 fn reports_type_mismatch_for_module_variables_inside_functions() {
5965 let diagnostics = analyze_source(
5966 r#"
5967score = 1
5968
5969def main():
5970 score = "done"
5971"#,
5972 );
5973
5974 assert_eq!(diagnostics.len(), 1);
5975 assert_eq!(diagnostics[0].kind, "type-mismatch");
5976 assert_eq!(diagnostics[0].line, 5);
5977 assert!(diagnostics[0]
5978 .help
5979 .as_deref()
5980 .unwrap()
5981 .contains("Cannot reassign to type: string"));
5982 }
5983
5984 #[test]
5985 fn reports_undefined_before_type_mismatch_for_unknown_expression_inputs() {
5986 let diagnostics = analyze_source(
5987 r#"
5988flag = True
5989
5990def main():
5991 flag = missing_score + 1
5992"#,
5993 );
5994
5995 assert_eq!(diagnostics.len(), 1);
5996 assert_eq!(diagnostics[0].kind, "undefined-variable");
5997 assert!(diagnostics[0]
5998 .message
5999 .contains("Undefined variable `missing_score`"));
6000 }
6001
6002 #[test]
6003 fn reports_datapack_json_resource_argument_shape_diagnostics() {
6004 let diagnostics = analyze_source(
6005 r#"
6006datapack.predicate("bad", ["not", "an", "object"])
6007"#,
6008 );
6009
6010 assert_eq!(diagnostics.len(), 1);
6011 assert_eq!(diagnostics[0].kind, "datapack-resource-argument");
6012 assert!(diagnostics[0]
6013 .message
6014 .contains("datapack.predicate() JSON value must be an object"));
6015 }
6016
6017 #[test]
6018 fn reports_datapack_tag_argument_shape_diagnostics() {
6019 let diagnostics = analyze_source(
6020 r#"
6021datapack.block_tag("bad", {"values": ["minecraft:stone"]})
6022"#,
6023 );
6024
6025 assert_eq!(diagnostics.len(), 1);
6026 assert_eq!(diagnostics[0].kind, "datapack-resource-argument");
6027 assert!(diagnostics[0]
6028 .message
6029 .contains("Tag values must be an array"));
6030 }
6031
6032 #[test]
6033 fn reports_datapack_tag_non_string_values() {
6034 let diagnostics = analyze_source(
6035 r#"
6036datapack.item_tag("bad", [1, True])
6037"#,
6038 );
6039
6040 assert_eq!(diagnostics.len(), 2);
6041 assert!(diagnostics
6042 .iter()
6043 .all(|diagnostic| diagnostic.kind == "datapack-resource-argument"));
6044 assert!(diagnostics.iter().all(|diagnostic| diagnostic
6045 .message
6046 .contains("Tag values must be string resource IDs")));
6047 }
6048
6049 #[test]
6050 fn reports_datapack_resource_id_diagnostics() {
6051 let diagnostics = analyze_source(
6052 r#"
6053datapack.function_tag("minecraft/load", ["resources:setup"])
6054datapack.predicate("Checks/Ready", {"condition": "minecraft:random_chance", "chance": 1})
6055datapack.item_tag("rewards", ["minecraft/diamond"])
6056"#,
6057 );
6058
6059 assert_eq!(diagnostics.len(), 3);
6060 assert_eq!(diagnostics[0].kind, "datapack-resource-id");
6061 assert_eq!(diagnostics[0].line, 2);
6062 assert!(diagnostics[0]
6063 .message
6064 .contains("Use 'minecraft:load' instead"));
6065 assert_eq!(diagnostics[1].kind, "datapack-resource-id");
6066 assert_eq!(diagnostics[1].line, 3);
6067 assert!(diagnostics[1].message.contains("uppercase character 'C'"));
6068 assert_eq!(diagnostics[2].kind, "datapack-resource-id");
6069 assert_eq!(diagnostics[2].line, 4);
6070 assert!(diagnostics[2]
6071 .message
6072 .contains("Use 'minecraft:diamond' instead"));
6073 }
6074
6075 #[test]
6076 fn reports_multiline_datapack_resource_diagnostics() {
6077 let diagnostics = analyze_source(
6078 r#"
6079datapack.item_tag(
6080 "rewards",
6081 ["minecraft/diamond", 1],
6082)
6083"#,
6084 );
6085
6086 assert_eq!(diagnostics.len(), 2);
6087 assert_eq!(diagnostics[0].kind, "datapack-resource-id");
6088 assert_eq!(diagnostics[0].line, 4);
6089 assert!(diagnostics[0]
6090 .message
6091 .contains("Use 'minecraft:diamond' instead"));
6092 assert_eq!(diagnostics[1].kind, "datapack-resource-argument");
6093 assert_eq!(diagnostics[1].line, 4);
6094 assert!(diagnostics[1]
6095 .message
6096 .contains("Tag values must be string resource IDs"));
6097 }
6098
6099 #[test]
6100 fn reports_undefined_variables_in_variable_dependent_expressions() {
6101 let diagnostics = analyze_source(
6102 r#"
6103def main():
6104 total = missing_score + 1
6105"#,
6106 );
6107
6108 assert_eq!(diagnostics.len(), 1);
6109 assert_eq!(diagnostics[0].kind, "undefined-variable");
6110 assert_eq!(diagnostics[0].line, 3);
6111 assert!(diagnostics[0]
6112 .message
6113 .contains("Undefined variable `missing_score`"));
6114 }
6115
6116 #[test]
6117 fn reports_noop_standalone_expression_statements() {
6118 let diagnostics = analyze_source(
6119 r#"
6120def main():
6121 score + 1
6122"#,
6123 );
6124
6125 assert_eq!(diagnostics.len(), 1);
6126 assert_eq!(diagnostics[0].kind, "no-op-expression");
6127 assert_eq!(diagnostics[0].line, 3);
6128 assert_eq!(diagnostics[0].column, 5);
6129 assert!(diagnostics[0]
6130 .message
6131 .contains("Standalone expression does not generate Minecraft commands"));
6132 }
6133
6134 #[test]
6135 fn allows_pass_docstrings_and_call_statements() {
6136 let diagnostics = analyze_source(
6137 r#"
6138def main():
6139 pass
6140 """Single-line docstring with class and try words"""
6141 """
6142 Multi-line docstring with standalone words
6143 and arithmetic-looking text x + 1
6144 """
6145 helper()
6146 text.tellraw("@a", text.plain("ok"))
6147
6148def helper():
6149 /say helper
6150"#,
6151 );
6152
6153 assert!(diagnostics.is_empty(), "{diagnostics:?}");
6154 }
6155
6156 #[test]
6157 fn ignores_multiline_docstring_bodies_in_later_semantic_passes() {
6158 let diagnostics = analyze_source(
6159 r#"
6160def main():
6161 """
6162 missing = unknown_value + 1
6163 helper("@a")
6164 from fake import missing
6165 """
6166 pass
6167
6168def helper(player, message):
6169 pass
6170"#,
6171 );
6172
6173 assert!(diagnostics.is_empty(), "{diagnostics:?}");
6174 }
6175
6176 #[test]
6177 fn reports_undefined_raw_command_placeholders() {
6178 let diagnostics = analyze_source(
6179 r#"
6180def main(player):
6181 /tellraw {player} {"text":"{message}"}
6182"#,
6183 );
6184
6185 assert_eq!(diagnostics.len(), 1);
6186 assert_eq!(diagnostics[0].kind, "undefined-placeholder");
6187 assert_eq!(diagnostics[0].line, 3);
6188 assert!(diagnostics[0]
6189 .message
6190 .contains("Undefined command placeholder `message`"));
6191 assert!(diagnostics[0]
6192 .help
6193 .as_deref()
6194 .unwrap()
6195 .contains("`{{message}}`"));
6196 }
6197
6198 #[test]
6199 fn reports_forward_raw_command_placeholders() {
6200 let diagnostics = analyze_source(
6201 r#"
6202def main():
6203 /say {message}
6204 message = "hi"
6205"#,
6206 );
6207
6208 assert_eq!(diagnostics.len(), 1);
6209 assert_eq!(diagnostics[0].kind, "undefined-placeholder");
6210 assert_eq!(diagnostics[0].line, 3);
6211 assert!(diagnostics[0]
6212 .message
6213 .contains("Undefined command placeholder `message`"));
6214 }
6215
6216 #[test]
6217 fn reports_unclosed_raw_command_placeholders() {
6218 let diagnostics = analyze_source(
6219 r#"
6220def main(player):
6221 /say {player
6222"#,
6223 );
6224
6225 assert_eq!(diagnostics.len(), 1);
6226 assert_eq!(diagnostics[0].kind, "unclosed-placeholder");
6227 assert_eq!(diagnostics[0].line, 3);
6228 assert_eq!(diagnostics[0].column, 10);
6229 }
6230
6231 #[test]
6232 fn reports_invalid_raw_command_placeholders() {
6233 let diagnostics = analyze_source(
6234 r#"
6235def main():
6236 /say {bad-name}
6237"#,
6238 );
6239
6240 assert_eq!(diagnostics.len(), 1);
6241 assert_eq!(diagnostics[0].kind, "invalid-placeholder");
6242 assert_eq!(diagnostics[0].line, 3);
6243 assert!(diagnostics[0]
6244 .message
6245 .contains("Invalid command placeholder `bad-name`"));
6246 }
6247
6248 #[test]
6249 fn allows_defined_raw_command_placeholders() {
6250 let diagnostics = analyze_source(
6251 r#"
6252score = 1
6253
6254def main(player):
6255 message = "ready"
6256 for i in range(3):
6257 /tellraw {player} {"text":"Score {score} {message} {i}"}
6258"#,
6259 );
6260
6261 assert!(diagnostics.is_empty(), "{diagnostics:?}");
6262 }
6263
6264 #[test]
6265 fn allows_imported_raw_command_placeholders() {
6266 let diagnostics = analyze_source(
6267 r#"
6268from helper import imported_score
6269
6270def main():
6271 /say {imported_score}
6272"#,
6273 );
6274
6275 assert!(diagnostics.is_empty(), "{diagnostics:?}");
6276 }
6277
6278 #[test]
6279 fn does_not_treat_imported_modules_as_raw_command_placeholders() {
6280 let diagnostics = analyze_source(
6281 r#"
6282import helper
6283
6284def main():
6285 /say {helper}
6286"#,
6287 );
6288
6289 assert_eq!(diagnostics.len(), 1);
6290 assert_eq!(diagnostics[0].kind, "undefined-placeholder");
6291 assert!(diagnostics[0]
6292 .message
6293 .contains("Undefined command placeholder `helper`"));
6294 }
6295
6296 #[test]
6297 fn ignores_json_nbt_and_escaped_raw_command_braces() {
6298 let diagnostics = analyze_source(
6299 r#"
6300def main():
6301 /tellraw @a {"text":"literal {{name}}", "color":"gold"}
6302 /tellraw @a {"text":"literal {"}
6303 /data merge entity @s {}
6304 /data merge entity @s {Tags:["foo"]}
6305"#,
6306 );
6307
6308 assert!(diagnostics.is_empty(), "{diagnostics:?}");
6309 }
6310
6311 #[test]
6312 fn does_not_report_defined_imported_builtin_or_parameter_symbols() {
6313 let diagnostics = analyze_source(
6314 r#"
6315import stdlib
6316from stdlib import event
6317
6318counter = 1
6319
6320def tick(player):
6321 global counter
6322 counter = counter + 1
6323 root = math.sqrt(counter)
6324 ready = counter > 0 and True
6325 if ready:
6326 /tellraw {player} {"text":"ready"}
6327
6328stdlib.addEventListener(event.TICK, tick)
6329"#,
6330 );
6331
6332 assert!(diagnostics.is_empty(), "{diagnostics:?}");
6333 }
6334
6335 #[test]
6336 fn does_not_report_string_or_map_literal_tokens_as_undefined() {
6337 let diagnostics = analyze_source(
6338 r#"
6339status = "boot"
6340config = {enabled: True, label: "ready"}
6341"#,
6342 );
6343
6344 assert!(diagnostics.is_empty(), "{diagnostics:?}");
6345 }
6346
6347 #[test]
6348 fn does_not_replace_attribute_or_subscript_errors_with_undefined_variables() {
6349 let diagnostics = analyze_source(
6350 r#"
6351def main():
6352 field = obj.value
6353 item = arr[0]
6354"#,
6355 );
6356
6357 assert_eq!(diagnostics.len(), 2);
6358 assert!(diagnostics
6359 .iter()
6360 .all(|diagnostic| diagnostic.kind == "unsupported-storage-access"));
6361 assert!(diagnostics[0]
6362 .message
6363 .contains("Cannot resolve storage-backed attribute access `obj.`"));
6364 assert!(diagnostics[1]
6365 .message
6366 .contains("Cannot resolve storage-backed subscript access `arr[...]`"));
6367 }
6368
6369 #[test]
6370 fn reports_user_function_argument_count_for_forward_calls() {
6371 let diagnostics = analyze_source(
6372 r#"
6373def main():
6374 greet("@a")
6375
6376def greet(player, message):
6377 /tellraw {player} {"text":"{message}"}
6378"#,
6379 );
6380
6381 assert_eq!(diagnostics.len(), 1);
6382 assert_eq!(diagnostics[0].kind, "function-argument-count");
6383 assert!(diagnostics[0]
6384 .message
6385 .contains("Function `greet` expects 2 argument(s), but 1 provided"));
6386 assert_eq!(diagnostics[0].line, 3);
6387 }
6388
6389 #[test]
6390 fn reports_undefined_user_function_calls() {
6391 let diagnostics = analyze_source(
6392 r#"
6393def main():
6394 missing("x")
6395"#,
6396 );
6397
6398 assert_eq!(diagnostics.len(), 1);
6399 assert_eq!(diagnostics[0].kind, "undefined-function");
6400 assert_eq!(diagnostics[0].line, 3);
6401 assert!(diagnostics[0]
6402 .message
6403 .contains("Undefined function `missing`"));
6404 }
6405
6406 #[test]
6407 fn reports_unknown_dotted_helper_calls() {
6408 let diagnostics = analyze_source(
6409 r#"
6410def main():
6411 helper.do()
6412"#,
6413 );
6414
6415 assert_eq!(diagnostics.len(), 1);
6416 assert_eq!(diagnostics[0].kind, "undefined-function");
6417 assert!(diagnostics[0]
6418 .message
6419 .contains("Unknown helper function `helper.do`"));
6420
6421 let diagnostics = analyze_source(
6422 r#"
6423def main():
6424 storage.nope()
6425"#,
6426 );
6427
6428 assert_eq!(diagnostics.len(), 1);
6429 assert_eq!(diagnostics[0].kind, "undefined-function");
6430 assert!(diagnostics[0]
6431 .message
6432 .contains("Unknown helper function `storage.nope`"));
6433 }
6434
6435 #[test]
6436 fn reports_value_only_text_helpers_as_standalone_statements() {
6437 let diagnostics = analyze_source(
6438 r#"
6439def main():
6440 text.plain("hello")
6441"#,
6442 );
6443
6444 assert_eq!(diagnostics.len(), 1);
6445 assert_eq!(diagnostics[0].kind, "unsupported-function-call-expression");
6446 assert!(diagnostics[0]
6447 .message
6448 .contains("returns a value and cannot be used as a standalone statement"));
6449 }
6450
6451 #[test]
6452 fn reports_nested_function_call_arguments_for_user_functions() {
6453 let diagnostics = analyze_source(
6454 r#"
6455def main():
6456 greet(make_name())
6457
6458def make_name():
6459 pass
6460
6461def greet(name):
6462 /say {name}
6463"#,
6464 );
6465
6466 assert_eq!(diagnostics.len(), 1);
6467 assert_eq!(diagnostics[0].kind, "unsupported-function-call-argument");
6468 assert!(diagnostics[0]
6469 .message
6470 .contains("Function `greet` arguments cannot contain function call expressions"));
6471 assert_eq!(diagnostics[0].line, 3);
6472 }
6473
6474 #[test]
6475 fn does_not_report_module_call_argument_counts() {
6476 let diagnostics = analyze_source(
6477 r#"
6478def main():
6479 text.tellraw("@a", text.plain("ok"))
6480 score.set("points", 1)
6481"#,
6482 );
6483
6484 assert!(diagnostics.is_empty(), "{diagnostics:?}");
6485 }
6486
6487 #[test]
6488 fn reports_undefined_variables_in_standalone_call_arguments() {
6489 let diagnostics = analyze_source(
6490 r#"
6491def main():
6492 score.set("points", missing)
6493"#,
6494 );
6495
6496 assert_eq!(diagnostics.len(), 1);
6497 assert_eq!(diagnostics[0].kind, "undefined-variable");
6498 assert_eq!(diagnostics[0].line, 3);
6499 assert!(diagnostics[0]
6500 .message
6501 .contains("Undefined variable `missing`"));
6502 }
6503
6504 #[test]
6505 fn reports_unsupported_none_usage_but_allows_json_resource_null() {
6506 let diagnostics = analyze_source(
6507 r#"
6508def main():
6509 value = None
6510"#,
6511 );
6512
6513 assert_eq!(diagnostics.len(), 1);
6514 assert_eq!(diagnostics[0].kind, "unsupported-none");
6515 assert!(diagnostics[0]
6516 .message
6517 .contains("None/null is only supported in data pack JSON resource helper values"));
6518
6519 let diagnostics = analyze_source(
6520 r#"
6521datapack.predicate("maybe", {
6522 "condition": "minecraft:random_chance",
6523 "chance": 1,
6524 "comment": None
6525})
6526"#,
6527 );
6528
6529 assert!(diagnostics.is_empty(), "{diagnostics:?}");
6530 }
6531
6532 #[test]
6533 fn rejects_lowercase_null_and_none_outside_json_resource_values() {
6534 let diagnostics = analyze_source(
6535 r#"
6536datapack.predicate("maybe", {"condition": "minecraft:random_chance", "chance": null})
6537"#,
6538 );
6539
6540 assert_eq!(diagnostics.len(), 1);
6541 assert_eq!(diagnostics[0].kind, "unsupported-none");
6542 assert!(diagnostics[0]
6543 .message
6544 .contains("None/null is only supported"));
6545
6546 let diagnostics = analyze_source(
6547 r#"
6548datapack.predicate(None, {"condition": "minecraft:random_chance", "chance": 1})
6549"#,
6550 );
6551
6552 assert_eq!(diagnostics.len(), 1);
6553 assert_eq!(diagnostics[0].kind, "unsupported-none");
6554 }
6555
6556 #[test]
6557 fn reports_unsupported_storage_access_shapes() {
6558 let diagnostics = analyze_source(
6559 r#"
6560def main():
6561 items = [1, 2, 3]
6562 first = items[i]
6563"#,
6564 );
6565
6566 assert_eq!(diagnostics.len(), 1);
6567 assert_eq!(diagnostics[0].kind, "unsupported-storage-access");
6568 assert!(diagnostics[0]
6569 .message
6570 .contains("Dynamic storage-backed subscript indexes are not supported"));
6571
6572 let diagnostics = analyze_source(
6573 r#"
6574def main():
6575 x = 1
6576 y = x.foo
6577"#,
6578 );
6579
6580 assert_eq!(diagnostics.len(), 1);
6581 assert_eq!(diagnostics[0].kind, "unsupported-storage-access");
6582 assert!(diagnostics[0]
6583 .message
6584 .contains("does not support attribute access"));
6585 }
6586
6587 #[test]
6588 fn allows_storage_backed_subscript_with_numeric_const_index() {
6589 let diagnostics = analyze_source(
6590 r#"
6591const INDEX = 0
6592
6593def main():
6594 items = [1, 2, 3]
6595 first = items[INDEX]
6596"#,
6597 );
6598
6599 assert!(diagnostics.is_empty(), "{diagnostics:?}");
6600
6601 let diagnostics = analyze_source(
6602 r#"
6603def main():
6604 const INDEX = 1
6605 items = [1, 2, 3]
6606 second = items[INDEX]
6607"#,
6608 );
6609
6610 assert!(diagnostics.is_empty(), "{diagnostics:?}");
6611 }
6612
6613 #[test]
6614 fn reports_storage_access_and_later_type_mismatch_together() {
6615 let diagnostics = analyze_source(
6616 r#"
6617def main():
6618 items = [1, 2, 3]
6619 first = items[i]
6620 value = 1
6621 value = "one"
6622"#,
6623 );
6624
6625 assert_eq!(diagnostics.len(), 2);
6626 assert_eq!(diagnostics[0].kind, "unsupported-storage-access");
6627 assert_eq!(diagnostics[1].kind, "type-mismatch");
6628 }
6629
6630 #[test]
6631 fn reports_invalid_math_value_function_calls() {
6632 let diagnostics = analyze_source(
6633 r#"
6634def main():
6635 value = math.nope(1)
6636"#,
6637 );
6638
6639 assert_eq!(diagnostics.len(), 1);
6640 assert_eq!(diagnostics[0].kind, "undefined-function");
6641 assert!(diagnostics[0]
6642 .message
6643 .contains("Unknown math function `math.nope`"));
6644
6645 let diagnostics = analyze_source(
6646 r#"
6647def main():
6648 value = math.sqrt(1, 2)
6649"#,
6650 );
6651
6652 assert_eq!(diagnostics.len(), 1);
6653 assert_eq!(diagnostics[0].kind, "function-argument-count");
6654 assert!(diagnostics[0]
6655 .message
6656 .contains("math.sqrt() takes 1 argument, but 2 provided"));
6657 }
6658
6659 #[test]
6660 fn parse_source_file_reports_missing_import_at_import_site() {
6661 let temp_dir = TempDir::new("missing-import");
6662 let main = temp_dir.path().join("main.cbl");
6663 std::fs::write(&main, "import missing\n\ndef main():\n pass\n").unwrap();
6664
6665 let diagnostics = parse_source_file(&main).expect_err("missing import should fail");
6666
6667 assert_eq!(diagnostics.len(), 1);
6668 assert_eq!(diagnostics[0].path, main);
6669 assert_eq!(diagnostics[0].diagnostics[0].kind, "missing-import");
6670 assert_eq!(diagnostics[0].diagnostics[0].line, 1);
6671 assert_eq!(diagnostics[0].diagnostics[0].column, 8);
6672 assert!(diagnostics[0].diagnostics[0]
6673 .message
6674 .contains("Cannot import 'missing'"));
6675 }
6676
6677 #[test]
6678 fn parse_source_file_reports_import_cycle_with_chain() {
6679 let temp_dir = TempDir::new("import-cycle");
6680 let main = temp_dir.path().join("main.cbl");
6681 let helper = temp_dir.path().join("helper.cbl");
6682 std::fs::write(&main, "import helper\n\ndef main():\n pass\n").unwrap();
6683 std::fs::write(&helper, "import main\n\ndef helper():\n pass\n").unwrap();
6684
6685 let diagnostics = parse_source_file(&main).expect_err("import cycle should fail");
6686
6687 assert_eq!(diagnostics.len(), 1);
6688 assert_eq!(diagnostics[0].path, helper);
6689 assert_eq!(diagnostics[0].diagnostics[0].kind, "circular-import");
6690 let help = diagnostics[0].diagnostics[0].help.as_deref().unwrap();
6691 assert!(help.contains("Import chain:"));
6692 assert!(help.contains(main.to_string_lossy().as_ref()));
6693 assert!(help.contains(helper.to_string_lossy().as_ref()));
6694 }
6695
6696 #[test]
6697 fn parse_source_file_reports_imported_file_language_diagnostics() {
6698 let temp_dir = TempDir::new("imported-language-diagnostics");
6699 let main = temp_dir.path().join("main.cbl");
6700 let helper = temp_dir.path().join("helper.cbl");
6701 std::fs::write(&main, "import helper\n\ndef main():\n pass\n").unwrap();
6702 std::fs::write(&helper, "def helper(value=1):\n pass\n").unwrap();
6703
6704 let diagnostics = parse_source_file(&main).expect_err("imported diagnostic should fail");
6705
6706 assert_eq!(diagnostics.len(), 1);
6707 assert_eq!(diagnostics[0].path, helper);
6708 assert_eq!(
6709 diagnostics[0].diagnostics[0].kind,
6710 "unsupported-function-parameter"
6711 );
6712 assert!(diagnostics[0].diagnostics[0]
6713 .help
6714 .as_deref()
6715 .unwrap()
6716 .contains("Import chain:"));
6717 }
6718
6719 #[test]
6720 fn parse_source_file_reports_missing_from_import_item() {
6721 let temp_dir = TempDir::new("missing-from-import-item");
6722 let main = temp_dir.path().join("main.cbl");
6723 let helper = temp_dir.path().join("helper.cbl");
6724 std::fs::write(
6725 &main,
6726 "from helper import greet, missing\n\ndef main():\n pass\n",
6727 )
6728 .unwrap();
6729 std::fs::write(&helper, "def greet():\n /say hi\n").unwrap();
6730
6731 let diagnostics = parse_source_file(&main).expect_err("missing import item should fail");
6732
6733 assert_eq!(diagnostics.len(), 1);
6734 assert_eq!(diagnostics[0].path, main);
6735 assert_eq!(diagnostics[0].diagnostics[0].kind, "missing-import-item");
6736 assert_eq!(diagnostics[0].diagnostics[0].line, 1);
6737 assert_eq!(diagnostics[0].diagnostics[0].column, 27);
6738 assert!(diagnostics[0].diagnostics[0]
6739 .message
6740 .contains("Cannot import `missing` from `helper`"));
6741 assert!(diagnostics[0].diagnostics[0]
6742 .help
6743 .as_deref()
6744 .unwrap()
6745 .contains("Available symbols: greet"));
6746 }
6747
6748 #[test]
6749 fn parse_source_file_validates_items_for_already_visited_imports() {
6750 let temp_dir = TempDir::new("visited-missing-from-import-item");
6751 let main = temp_dir.path().join("main.cbl");
6752 let helper = temp_dir.path().join("helper.cbl");
6753 std::fs::write(
6754 &main,
6755 "import helper\nfrom helper import missing\n\ndef main():\n pass\n",
6756 )
6757 .unwrap();
6758 std::fs::write(&helper, "def greet():\n /say hi\n").unwrap();
6759
6760 let diagnostics = parse_source_file(&main).expect_err("visited import item should fail");
6761
6762 assert_eq!(diagnostics.len(), 1);
6763 assert_eq!(diagnostics[0].diagnostics[0].kind, "missing-import-item");
6764 assert_eq!(diagnostics[0].diagnostics[0].line, 2);
6765 }
6766
6767 #[test]
6768 fn parse_source_file_rejects_imported_function_raw_placeholder() {
6769 let temp_dir = TempDir::new("imported-function-placeholder");
6770 let main = temp_dir.path().join("main.cbl");
6771 let helper = temp_dir.path().join("helper.cbl");
6772 std::fs::write(
6773 &main,
6774 "from helper import greet\n\ndef main():\n /say {greet}\n",
6775 )
6776 .unwrap();
6777 std::fs::write(&helper, "def greet():\n /say hi\n").unwrap();
6778
6779 let diagnostics = parse_source_file(&main).expect_err("function placeholder should fail");
6780
6781 assert_eq!(diagnostics.len(), 1);
6782 assert_eq!(diagnostics[0].path, main);
6783 assert_eq!(
6784 diagnostics[0].diagnostics[0].kind,
6785 "unsupported-placeholder-symbol"
6786 );
6787 assert_eq!(diagnostics[0].diagnostics[0].line, 4);
6788 assert!(diagnostics[0].diagnostics[0]
6789 .message
6790 .contains("Imported function `greet` cannot be used as a command placeholder"));
6791 }
6792
6793 #[test]
6794 fn parse_source_file_reports_cross_file_duplicate_functions() {
6795 let temp_dir = TempDir::new("cross-file-duplicate-functions");
6796 let main = temp_dir.path().join("main.cbl");
6797 let helper = temp_dir.path().join("helper.cbl");
6798 std::fs::write(&main, "import helper\n\ndef greet():\n /say from main\n").unwrap();
6799 std::fs::write(&helper, "def greet():\n /say from helper\n").unwrap();
6800
6801 let diagnostics = parse_source_file(&main).expect_err("duplicate function should fail");
6802
6803 assert_eq!(diagnostics.len(), 1);
6804 assert_eq!(diagnostics[0].path, main);
6805 assert_eq!(diagnostics[0].diagnostics[0].kind, "duplicate-function");
6806 assert!(diagnostics[0].diagnostics[0]
6807 .message
6808 .contains("Duplicate function definition `greet` across imported files"));
6809 assert!(diagnostics[0].diagnostics[0]
6810 .help
6811 .as_deref()
6812 .unwrap()
6813 .contains(helper.to_string_lossy().as_ref()));
6814 }
6815
6816 #[test]
6817 fn parse_source_files_reports_directory_duplicate_functions() {
6818 let temp_dir = TempDir::new("directory-duplicate-functions");
6819 let first = temp_dir.path().join("first.cbl");
6820 let second = temp_dir.path().join("second.cbl");
6821 std::fs::write(&first, "def same():\n /say first\n").unwrap();
6822 std::fs::write(&second, "def same():\n /say second\n").unwrap();
6823
6824 let diagnostics = parse_source_files(&[first.clone(), second.clone()])
6825 .expect_err("duplicate should fail");
6826
6827 assert_eq!(diagnostics.len(), 1);
6828 assert_eq!(diagnostics[0].path, second);
6829 assert_eq!(diagnostics[0].diagnostics[0].kind, "duplicate-function");
6830 assert!(diagnostics[0].diagnostics[0]
6831 .message
6832 .contains("Duplicate function definition `same` across imported files"));
6833 assert!(diagnostics[0].diagnostics[0]
6834 .help
6835 .as_deref()
6836 .unwrap()
6837 .contains(first.to_string_lossy().as_ref()));
6838 }
6839
6840 #[test]
6841 fn parse_source_file_reports_cross_file_duplicate_selector_aliases() {
6842 let temp_dir = TempDir::new("cross-file-duplicate-selectors");
6843 let main = temp_dir.path().join("main.cbl");
6844 let helper = temp_dir.path().join("helper.cbl");
6845 std::fs::write(
6846 &main,
6847 "import helper\n\n@Players = @a\n\ndef main():\n pass\n",
6848 )
6849 .unwrap();
6850 std::fs::write(&helper, "@Players = @p\n\ndef helper():\n pass\n").unwrap();
6851
6852 let diagnostics = parse_source_file(&main).expect_err("duplicate selector should fail");
6853
6854 assert_eq!(diagnostics.len(), 1);
6855 assert_eq!(diagnostics[0].path, main);
6856 assert_eq!(diagnostics[0].diagnostics[0].kind, "duplicate-symbol");
6857 assert!(diagnostics[0].diagnostics[0]
6858 .message
6859 .contains("Duplicate selector alias `@Players` across imported files"));
6860 assert!(diagnostics[0].diagnostics[0]
6861 .help
6862 .as_deref()
6863 .unwrap()
6864 .contains(helper.to_string_lossy().as_ref()));
6865 }
6866
6867 #[test]
6868 fn parse_source_file_reports_cross_file_duplicate_entity_templates() {
6869 let temp_dir = TempDir::new("cross-file-duplicate-entities");
6870 let main = temp_dir.path().join("main.cbl");
6871 let helper = temp_dir.path().join("helper.cbl");
6872 let entity = "define @Marker = @e[type=marker]\ncreate {\"Tags\": [\"marker\"]}\nend\n";
6873 std::fs::write(
6874 &main,
6875 format!("import helper\n\n{entity}\ndef main():\n pass\n"),
6876 )
6877 .unwrap();
6878 std::fs::write(&helper, format!("{entity}\ndef helper():\n pass\n")).unwrap();
6879
6880 let diagnostics = parse_source_file(&main).expect_err("duplicate entity should fail");
6881
6882 assert_eq!(diagnostics.len(), 1);
6883 assert_eq!(diagnostics[0].path, main);
6884 assert_eq!(diagnostics[0].diagnostics[0].kind, "duplicate-symbol");
6885 assert!(diagnostics[0].diagnostics[0]
6886 .message
6887 .contains("Duplicate entity template `@Marker` across imported files"));
6888 assert!(diagnostics[0].diagnostics[0]
6889 .help
6890 .as_deref()
6891 .unwrap()
6892 .contains(helper.to_string_lossy().as_ref()));
6893 }
6894
6895 #[test]
6896 fn parse_source_file_reports_imported_function_argument_count() {
6897 let temp_dir = TempDir::new("imported-function-argument-count");
6898 let main = temp_dir.path().join("main.cbl");
6899 let helper = temp_dir.path().join("helper.cbl");
6900 std::fs::write(&main, "import helper\n\ndef main():\n greet(\"@a\")\n").unwrap();
6901 std::fs::write(
6902 &helper,
6903 "def greet(player, message):\n /tellraw {player} {\"text\":\"{message}\"}\n",
6904 )
6905 .unwrap();
6906
6907 let diagnostics = parse_source_file(&main).expect_err("argument count should fail");
6908
6909 assert_eq!(diagnostics.len(), 1);
6910 assert_eq!(diagnostics[0].path, main);
6911 assert_eq!(
6912 diagnostics[0].diagnostics[0].kind,
6913 "function-argument-count"
6914 );
6915 assert!(diagnostics[0].diagnostics[0]
6916 .message
6917 .contains("Function `greet` expects 2 argument(s), but 1 provided"));
6918 assert!(diagnostics[0].diagnostics[0]
6919 .help
6920 .as_deref()
6921 .unwrap()
6922 .contains(helper.to_string_lossy().as_ref()));
6923 }
6924
6925 #[test]
6926 fn parse_source_file_reports_unknown_cross_file_function_calls() {
6927 let temp_dir = TempDir::new("unknown-cross-file-function");
6928 let main = temp_dir.path().join("main.cbl");
6929 let helper = temp_dir.path().join("helper.cbl");
6930 std::fs::write(&main, "import helper\n\ndef main():\n missing(\"@a\")\n").unwrap();
6931 std::fs::write(&helper, "def greet(player):\n /say {player}\n").unwrap();
6932
6933 let diagnostics = parse_source_file(&main).expect_err("unknown function should fail");
6934
6935 assert_eq!(diagnostics.len(), 1);
6936 assert_eq!(diagnostics[0].path, main);
6937 assert_eq!(diagnostics[0].diagnostics[0].kind, "undefined-function");
6938 assert!(diagnostics[0].diagnostics[0]
6939 .message
6940 .contains("Undefined function `missing`"));
6941 }
6942
6943 #[test]
6944 fn parse_source_file_reports_unknown_cross_file_dotted_helper_calls() {
6945 let temp_dir = TempDir::new("unknown-cross-file-dotted-function");
6946 let main = temp_dir.path().join("main.cbl");
6947 let helper = temp_dir.path().join("helper.cbl");
6948 std::fs::write(&main, "import helper\n\ndef main():\n helper.do()\n").unwrap();
6949 std::fs::write(&helper, "def greet(player):\n /say {player}\n").unwrap();
6950
6951 let diagnostics = parse_source_file(&main).expect_err("unknown helper should fail");
6952
6953 assert_eq!(diagnostics.len(), 1);
6954 assert_eq!(diagnostics[0].path, main);
6955 assert_eq!(diagnostics[0].diagnostics[0].kind, "undefined-function");
6956 assert!(diagnostics[0].diagnostics[0]
6957 .message
6958 .contains("Unknown helper function `helper.do`"));
6959 }
6960}