Skip to main content

cobble/validator/
mod.rs

1pub mod arg_parsers;
2pub mod command_tree;
3pub mod string_reader;
4
5use command_tree::CommandNode;
6use std::path::{Path, PathBuf};
7use string_reader::StringReader;
8use walkdir::WalkDir;
9
10#[derive(Debug)]
11pub struct ValidationError {
12    pub line_number: usize,
13    pub command: String,
14    pub message: String,
15    pub position: usize,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct CommandValidationError {
20    pub message: String,
21    pub position: usize,
22}
23
24#[derive(Debug)]
25pub struct ValidationReport {
26    pub files_checked: usize,
27    pub commands_checked: usize,
28    pub macro_commands_checked: usize,
29    pub commands_skipped: usize,
30    pub errors: Vec<(PathBuf, ValidationError)>,
31    pub source_map_errors: Vec<String>,
32}
33
34pub struct CommandValidator {
35    root: CommandNode,
36}
37
38impl CommandValidator {
39    pub fn from_file(path: &Path) -> Result<Self, String> {
40        let root = CommandNode::from_file(path)?;
41        Ok(Self { root })
42    }
43
44    pub fn from_json_str(content: &str) -> Result<Self, String> {
45        let root = CommandNode::from_json_str(content)?;
46        Ok(Self { root })
47    }
48
49    /// Validate a single command string.
50    /// Returns Ok(()) if the command is valid, Err with a message otherwise.
51    pub fn validate_command(&self, command: &str) -> Result<(), String> {
52        self.validate_command_detailed(command)
53            .map_err(|error| error.message)
54    }
55
56    /// Validate a single command string and retain the best-known error cursor.
57    pub fn validate_command_detailed(&self, command: &str) -> Result<(), CommandValidationError> {
58        let trimmed = command.trim();
59        if trimmed.is_empty() || trimmed.starts_with('#') {
60            return Ok(()); // comment or empty
61        }
62
63        let macro_offset = usize::from(trimmed.starts_with('$'));
64        if macro_offset == 1 {
65            Self::validate_macro_placeholders(trimmed)?;
66        }
67        let command = trimmed.strip_prefix('$').unwrap_or(trimmed);
68        let mut reader = StringReader::new(command);
69        self.walk_node(&self.root, &mut reader, 0)
70            .map_err(|mut error| {
71                error.position += macro_offset;
72                error
73            })
74    }
75
76    /// Validate an entire .mcfunction file.
77    /// Returns a list of errors found.
78    pub fn validate_mcfunction(&self, content: &str) -> Vec<ValidationError> {
79        let mut errors = Vec::new();
80        for (i, line) in content.lines().enumerate() {
81            let line_num = i + 1;
82            let trimmed = line.trim();
83            if trimmed.is_empty() || trimmed.starts_with('#') {
84                continue;
85            }
86            if let Err(error) = self.validate_command_detailed(trimmed) {
87                errors.push(ValidationError {
88                    line_number: line_num,
89                    command: trimmed.to_string(),
90                    message: error.message,
91                    position: error.position,
92                });
93            }
94        }
95        errors
96    }
97
98    /// Validate all .mcfunction files in a datapack directory.
99    pub fn validate_datapack(&self, dir: &Path) -> ValidationReport {
100        let mut report = ValidationReport {
101            files_checked: 0,
102            commands_checked: 0,
103            macro_commands_checked: 0,
104            commands_skipped: 0,
105            errors: Vec::new(),
106            source_map_errors: Vec::new(),
107        };
108
109        for entry in WalkDir::new(dir)
110            .follow_links(false)
111            .into_iter()
112            .filter_map(|e| e.ok())
113        {
114            let path = entry.path();
115            if entry.file_type().is_file() {
116                if let Some(ext) = path.extension() {
117                    if ext == "mcfunction" {
118                        report.files_checked += 1;
119                        if let Ok(content) = std::fs::read_to_string(path) {
120                            for (i, line) in content.lines().enumerate() {
121                                let trimmed = line.trim();
122                                if trimmed.is_empty() || trimmed.starts_with('#') {
123                                    continue;
124                                }
125                                report.commands_checked += 1;
126                                if trimmed.starts_with('$') {
127                                    report.macro_commands_checked += 1;
128                                }
129                                if let Err(error) = self.validate_command_detailed(trimmed) {
130                                    report.errors.push((
131                                        path.to_path_buf(),
132                                        ValidationError {
133                                            line_number: i + 1,
134                                            command: trimmed.to_string(),
135                                            message: error.message,
136                                            position: error.position,
137                                        },
138                                    ));
139                                }
140                            }
141                        }
142                    }
143                }
144            }
145        }
146
147        report
148    }
149
150    /// Tree-walking validation against the command tree.
151    /// Tries to match the input against the node's children using backtracking.
152    fn walk_node(
153        &self,
154        node: &CommandNode,
155        reader: &mut StringReader,
156        depth: usize,
157    ) -> Result<(), CommandValidationError> {
158        reader.skip_whitespace();
159
160        if !reader.can_read() {
161            if node.executable {
162                return Ok(());
163            }
164            return Err(CommandValidationError {
165                message: "Incomplete command".to_string(),
166                position: reader.cursor(),
167            });
168        }
169
170        // Safety: prevent infinite recursion from redirect loops
171        if depth > 100 {
172            return Err(CommandValidationError {
173                message: "Command too deeply nested (possible redirect loop)".to_string(),
174                position: reader.cursor(),
175            });
176        }
177
178        let mut best_error: Option<CommandValidationError> = None;
179        let mut best_error_pos: usize = 0;
180
181        // Try literal children first (they have priority over arguments)
182        for (name, child) in &node.children {
183            if child.node_type == "literal" {
184                let saved = reader.cursor();
185                if reader.try_read_literal(name) {
186                    if child.executable && Self::is_at_end(reader) {
187                        return Ok(());
188                    }
189
190                    let target = if let Some(ref redirect_path) = child.redirect {
191                        child
192                            .resolve_redirect(&self.root, redirect_path)
193                            .unwrap_or(&self.root)
194                    } else {
195                        child
196                    };
197
198                    match self.walk_node(target, reader, depth + 1) {
199                        Ok(()) => return Ok(()),
200                        Err(error) => {
201                            let pos = error.position;
202                            if pos > best_error_pos {
203                                best_error_pos = pos;
204                                best_error = Some(error);
205                            }
206                            reader.set_cursor(saved);
207                        }
208                    }
209                }
210            }
211        }
212
213        // Then try argument children
214        for child in node.children.values() {
215            if child.node_type == "argument" {
216                if let Some(ref parser_type) = child.parser {
217                    let saved = reader.cursor();
218                    if arg_parsers::parse_argument(reader, parser_type, child.properties.as_ref()) {
219                        if child.executable && Self::is_at_end(reader) {
220                            return Ok(());
221                        }
222
223                        let target = if let Some(ref redirect_path) = child.redirect {
224                            child
225                                .resolve_redirect(&self.root, redirect_path)
226                                .unwrap_or(&self.root)
227                        } else {
228                            child
229                        };
230
231                        match self.walk_node(target, reader, depth + 1) {
232                            Ok(()) => return Ok(()),
233                            Err(error) => {
234                                let pos = error.position;
235                                if pos > best_error_pos {
236                                    best_error_pos = pos;
237                                    best_error = Some(error);
238                                }
239                                reader.set_cursor(saved);
240                            }
241                        }
242                    }
243                }
244            }
245        }
246
247        Err(best_error.unwrap_or_else(|| {
248            let remaining = reader.remaining();
249            let preview = remaining.chars().take(40).collect::<String>();
250            CommandValidationError {
251                message: format!(
252                    "Unknown or invalid argument at position {}: '{}'",
253                    reader.cursor(),
254                    preview
255                ),
256                position: reader.cursor(),
257            }
258        }))
259    }
260
261    fn is_at_end(reader: &StringReader) -> bool {
262        let mut reader = reader.clone();
263        reader.skip_whitespace();
264        !reader.can_read()
265    }
266
267    fn validate_macro_placeholders(command: &str) -> Result<(), CommandValidationError> {
268        let chars = command.chars().collect::<Vec<_>>();
269        let mut index = 0;
270        while index + 1 < chars.len() {
271            if chars[index] == '$' && chars[index + 1] == '(' {
272                let start = index;
273                index += 2;
274                let name_start = index;
275                while index < chars.len() && chars[index] != ')' {
276                    index += 1;
277                }
278                if index >= chars.len() {
279                    return Err(CommandValidationError {
280                        message: format!("Unclosed macro placeholder at position {}", start),
281                        position: start,
282                    });
283                }
284                if index == name_start {
285                    return Err(CommandValidationError {
286                        message: format!("Empty macro placeholder at position {}", start),
287                        position: start,
288                    });
289                }
290            }
291            index += 1;
292        }
293        Ok(())
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300    use std::fs;
301
302    fn get_validator() -> Option<CommandValidator> {
303        let commands_json = Path::new("data/commands.json");
304        if !commands_json.exists() {
305            eprintln!("Skipping validator tests: data/commands.json not found");
306            return None;
307        }
308        Some(CommandValidator::from_file(commands_json).unwrap())
309    }
310
311    fn fixture_validator() -> CommandValidator {
312        CommandValidator::from_json_str(
313            r#"{
314                "type": "root",
315                "children": {
316                    "say": {
317                        "type": "literal",
318                        "children": {
319                            "message": {
320                                "type": "argument",
321                                "parser": "minecraft:message",
322                                "executable": true
323                            }
324                        }
325                    },
326                    "tellraw": {
327                        "type": "literal",
328                        "children": {
329                            "targets": {
330                                "type": "argument",
331                                "parser": "minecraft:entity",
332                                "children": {
333                                    "message": {
334                                        "type": "argument",
335                                        "parser": "minecraft:component",
336                                        "executable": true
337                                    }
338                                }
339                            }
340                        }
341                    },
342                    "execute": {
343                        "type": "literal",
344                        "children": {
345                            "run": {
346                                "type": "literal",
347                                "redirect": []
348                            }
349                        }
350                    },
351                    "return": {
352                        "type": "literal",
353                        "children": {
354                            "run": {
355                                "type": "literal",
356                                "redirect": []
357                            }
358                        }
359                    }
360                }
361            }"#,
362        )
363        .unwrap()
364    }
365
366    #[test]
367    fn fixture_validator_reports_error_positions() {
368        let validator = fixture_validator();
369
370        let error = validator
371            .validate_command_detailed("titel @a actionbar bad")
372            .unwrap_err();
373
374        assert_eq!(error.position, 0);
375        assert!(error.message.contains("Unknown or invalid argument"));
376    }
377
378    #[test]
379    fn fixture_validator_handles_redirects_and_macro_prefixes() {
380        let validator = fixture_validator();
381
382        assert!(validator.validate_command("execute run say ok").is_ok());
383        assert!(validator.validate_command("return run say ok").is_ok());
384        assert!(validator
385            .validate_command(r#"$tellraw $(player) {"text":"ok"}"#)
386            .is_ok());
387    }
388
389    #[test]
390    fn fixture_validator_rejects_malformed_macro_placeholders() {
391        let validator = fixture_validator();
392
393        for command in [
394            "$say $(message",
395            "$say $()",
396            r#"$tellraw $(player) {"text":"$(bad"}"#,
397        ] {
398            let error = validator.validate_command_detailed(command).unwrap_err();
399            assert!(
400                error.message.contains("macro placeholder"),
401                "unexpected error for {command}: {:?}",
402                error
403            );
404        }
405    }
406
407    #[test]
408    fn fixture_validator_allows_literal_macro_syntax_in_non_macro_commands() {
409        let validator = fixture_validator();
410
411        assert!(validator
412            .validate_command(r#"tellraw @a {"text":"$(price"}"#)
413            .is_ok());
414    }
415
416    #[test]
417    fn datapack_validation_counts_checked_macro_commands() {
418        let validator = fixture_validator();
419        let temp_dir = tempfile::TempDir::new().unwrap();
420        let function_dir = temp_dir.path().join("data/test/function");
421        fs::create_dir_all(&function_dir).unwrap();
422        fs::write(
423            function_dir.join("macro.mcfunction"),
424            "$tellraw $(player) {\"text\":\"ok\"}\n# comment\nsay done\n",
425        )
426        .unwrap();
427
428        let report = validator.validate_datapack(temp_dir.path());
429
430        assert_eq!(report.files_checked, 1);
431        assert_eq!(report.commands_checked, 2);
432        assert_eq!(report.macro_commands_checked, 1);
433        assert_eq!(report.commands_skipped, 0);
434        assert!(report.errors.is_empty());
435    }
436
437    #[test]
438    fn test_validate_say() {
439        let v = match get_validator() {
440            Some(v) => v,
441            None => return,
442        };
443        assert!(v.validate_command("say hello world").is_ok());
444        assert!(v.validate_command("say").is_err()); // missing message
445    }
446
447    #[test]
448    fn test_validate_tellraw() {
449        let v = match get_validator() {
450            Some(v) => v,
451            None => return,
452        };
453        assert!(v
454            .validate_command("tellraw @a {\"text\":\"Hello\",\"color\":\"green\"}")
455            .is_ok());
456        assert!(v
457            .validate_command("tellraw @a [{\"text\":\"Hello\"}]")
458            .is_ok());
459    }
460
461    #[test]
462    fn test_validate_scoreboard() {
463        let v = match get_validator() {
464            Some(v) => v,
465            None => return,
466        };
467        assert!(v
468            .validate_command("scoreboard objectives add test dummy")
469            .is_ok());
470        assert!(v
471            .validate_command("scoreboard players set x temp 10")
472            .is_ok());
473        assert!(v
474            .validate_command("scoreboard players operation x temp += y temp")
475            .is_ok());
476    }
477
478    #[test]
479    fn test_validate_execute() {
480        let v = match get_validator() {
481            Some(v) => v,
482            None => return,
483        };
484        assert!(v.validate_command("execute as @a run say hello").is_ok());
485        assert!(v
486            .validate_command("execute as @a at @s run particle flame ~ ~1 ~")
487            .is_ok());
488        assert!(v
489            .validate_command("execute if score x temp matches 1..5 run say match")
490            .is_ok());
491        assert!(v.validate_command("execute if entity @s").is_ok());
492        assert!(v
493            .validate_command("execute if block ~ ~ ~ minecraft:stone")
494            .is_ok());
495    }
496
497    #[test]
498    fn test_validate_function() {
499        let v = match get_validator() {
500            Some(v) => v,
501            None => return,
502        };
503        assert!(v.validate_command("function cobble:test").is_ok());
504    }
505
506    #[test]
507    fn test_validate_comments_and_macros() {
508        let v = match get_validator() {
509            Some(v) => v,
510            None => return,
511        };
512        assert!(v.validate_command("# comment").is_ok());
513        assert!(v.validate_command("").is_ok());
514        assert!(v.validate_command("$say $(message)").is_ok());
515        assert!(v
516            .validate_command("$give $(player) minecraft:$(item) $(count)")
517            .is_ok());
518        assert!(v.validate_command("$tp $(player) $(x) $(y) $(z)").is_ok());
519        assert!(v
520            .validate_command(
521                "$execute if entity $(player)[nbt={Inventory:[{id:\"minecraft:diamond\"}]}] run say found"
522            )
523            .is_ok());
524        assert!(v
525            .validate_command(
526                "$summon minecraft:armor_stand $(x) $(y) $(z) {Invisible:1b,CustomName:'{\"text\":\"Checkpoint_$(id)\"}'}"
527            )
528            .is_ok());
529        assert!(v
530            .validate_command("$particle minecraft:end_rod $(x) $(y) $(z) 0.5 1 0.5 0.01 20")
531            .is_ok());
532        assert!(v
533            .validate_command("$title $(player) actionbar {\"text\":\"Kit count: $(count)\"}")
534            .is_ok());
535        assert!(v.validate_command("$titel $(player) actionbar hi").is_err());
536        assert!(v.validate_command("$swing $(player) bogus").is_err());
537    }
538
539    #[test]
540    fn test_validate_particle() {
541        let v = match get_validator() {
542            Some(v) => v,
543            None => return,
544        };
545        assert!(v.validate_command("particle flame ~ ~1 ~").is_ok());
546    }
547
548    #[test]
549    fn test_validate_data_commands() {
550        let v = match get_validator() {
551            Some(v) => v,
552            None => return,
553        };
554        assert!(v.validate_command("data get entity @s Pos").is_ok());
555    }
556
557    #[test]
558    fn test_validate_setblock() {
559        let v = match get_validator() {
560            Some(v) => v,
561            None => return,
562        };
563        assert!(v.validate_command("setblock ~ ~ ~ minecraft:stone").is_ok());
564        assert!(v
565            .validate_command("setblock 0 64 0 minecraft:oak_stairs[facing=north]")
566            .is_ok());
567    }
568
569    #[test]
570    fn test_validate_kill() {
571        let v = match get_validator() {
572            Some(v) => v,
573            None => return,
574        };
575        assert!(v.validate_command("kill @e[type=zombie]").is_ok());
576        assert!(v.validate_command("kill").is_ok()); // kill is executable without args
577    }
578
579    #[test]
580    fn test_validate_gamemode() {
581        let v = match get_validator() {
582            Some(v) => v,
583            None => return,
584        };
585        assert!(v.validate_command("gamemode creative @s").is_ok());
586        assert!(v.validate_command("gamemode survival").is_ok());
587        assert!(v.validate_command("gamemode flying @s").is_err());
588        assert!(v
589            .validate_command("gamemode creative @e[type=zombie]")
590            .is_err());
591    }
592
593    #[test]
594    fn test_validate_tp() {
595        let v = match get_validator() {
596            Some(v) => v,
597            None => return,
598        };
599        assert!(v.validate_command("tp @s 0 64 0").is_ok());
600        assert!(v.validate_command("tp @s @p").is_ok());
601    }
602
603    #[test]
604    fn test_validate_give() {
605        let v = match get_validator() {
606            Some(v) => v,
607            None => return,
608        };
609        assert!(v.validate_command("give @s minecraft:diamond 64").is_ok());
610        assert!(v.validate_command("give @s diamond").is_ok());
611        assert!(v.validate_command("give @s minecraft:diamond -1").is_err());
612    }
613
614    #[test]
615    fn test_validate_schedule() {
616        let v = match get_validator() {
617            Some(v) => v,
618            None => return,
619        };
620        assert!(v
621            .validate_command("schedule function cobble:tick 1t")
622            .is_ok());
623        assert!(v
624            .validate_command("schedule function cobble:tick -1t")
625            .is_err());
626    }
627
628    #[test]
629    fn test_validate_scoreboard_operation_rejects_invalid_ops() {
630        let v = match get_validator() {
631            Some(v) => v,
632            None => return,
633        };
634        assert!(v
635            .validate_command("scoreboard players operation x temp >= y temp")
636            .is_err());
637        assert!(v
638            .validate_command("scoreboard players operation x temp <= y temp")
639            .is_err());
640    }
641
642    #[test]
643    fn test_validate_rejects_invalid_selector_shapes_and_enums() {
644        let v = match get_validator() {
645            Some(v) => v,
646            None => return,
647        };
648        assert!(v.validate_command("data get entity @a Pos").is_err());
649        assert!(v.validate_command("dialog clear @e[type=zombie]").is_err());
650        assert!(v.validate_command("kill @x").is_err());
651        assert!(v.validate_command("kill @e[foo=bar]").is_err());
652        assert!(v
653            .validate_command("team modify matrix color ultraviolet")
654            .is_err());
655        assert!(v
656            .validate_command("waypoint modify @s color hex nope")
657            .is_err());
658        assert!(v
659            .validate_command("execute anchored nose run say hi")
660            .is_err());
661        assert!(v
662            .validate_command("scoreboard objectives setdisplay sideways points")
663            .is_err());
664        assert!(v.validate_command("execute align xx run say hi").is_err());
665        assert!(v
666            .validate_command("item replace entity @s armor.tail with minecraft:stone")
667            .is_err());
668        assert!(v
669            .validate_command("give @s minecraft:diamond{Enchantments:[]}")
670            .is_err());
671    }
672
673    #[test]
674    fn test_validate_rejects_unbalanced_syntax_and_empty_ranges() {
675        let v = match get_validator() {
676            Some(v) => v,
677            None => return,
678        };
679        assert!(v.validate_command("tellraw @a {\"text\":\"hi\"").is_err());
680        assert!(v
681            .validate_command("data merge entity @s {Glowing:1b")
682            .is_err());
683        assert!(v.validate_command("kill @e[type=zombie").is_err());
684        assert!(v
685            .validate_command("execute if score score temp matches .. run say hi")
686            .is_err());
687        assert!(v.validate_command("random roll ..").is_err());
688    }
689
690    #[test]
691    fn test_validate_mcfunction_file() {
692        let v = match get_validator() {
693            Some(v) => v,
694            None => return,
695        };
696        let content = "\
697# This is a comment
698say hello
699scoreboard objectives add test dummy
700scoreboard players set x temp 10
701execute as @a run say hi
702
703# Another comment
704kill @e[type=zombie]
705";
706        let errors = v.validate_mcfunction(content);
707        assert!(
708            errors.is_empty(),
709            "Unexpected validation errors: {:?}",
710            errors
711                .iter()
712                .map(|e| format!("L{}: {} ({})", e.line_number, e.message, e.command))
713                .collect::<Vec<_>>()
714        );
715    }
716}