Skip to main content

cobble/
diagnostics.rs

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(&current_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(&current_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}