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 pub fn validate_command(&self, command: &str) -> Result<(), String> {
52 self.validate_command_detailed(command)
53 .map_err(|error| error.message)
54 }
55
56 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(()); }
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 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 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 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 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 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 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()); }
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()); }
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}