cobble-lang 0.7.0

A modern, Python-like language for creating Minecraft Data Packs
Documentation
use cobble::diagnostics::{analyze_source, parse_source};
use cobble::parser::parse;

#[test]
fn supported_language_surface_parses_representative_programs() {
    let cases = [
        (
            "literals and expressions",
            r#"
score = 10
offset = -2
power = 2 ^ 3 ^ 2
flag = not False or True and score >= 10
items = [1, 2, 3]
config = {"foo": 1, enabled: True}
"#,
        ),
        (
            "functions and control flow",
            r#"
def tick(player, amount):
    global total
    total = total + amount
    if total > 10:
        /say high
    elif total == 10:
        /say equal
    else:
        pass
    while total < 20:
        total = total + 1
    for i in range(3) by 1:
        /say loop
"#,
        ),
        (
            "match and execute blocks",
            r#"
def route(player):
    mode = 1
    match mode:
        case 0:
            /say zero
        case 1 to 3:
            /say range
        case _:
            /say other
    asat @s:
        /say asat
    as @a at @s if entity @s:
        /say execute
"#,
        ),
        (
            "imports selectors entities and resources",
            r#"
import stdlib
from stdlib import event

@Players = @a[type=player]

define @Marker = @e[type=marker]
create {"Tags": ["cobble"]}
end

create @Marker

datapack.function_tag("minecraft:load", ["cobble:setup"])
datapack.predicate("always", {"condition": "minecraft:random_chance", "chance": 1})

def setup():
    /say setup
"#,
        ),
    ];

    for (name, source) in cases {
        assert!(
            parse(source).is_ok(),
            "supported language surface should parse: {name}"
        );
    }
}

#[test]
fn unsupported_python_like_syntax_is_not_silently_accepted_by_parser() {
    let cases = [
        (
            "default parameters",
            r#"
def reward(player, amount=1):
    /say reward
"#,
        ),
        (
            "varargs",
            r#"
def reward(*players):
    /say reward
"#,
        ),
        (
            "compound assignment",
            r#"
def tick():
    score += 1
"#,
        ),
        (
            "list comprehension",
            r#"
def tick():
    values = [i for i in range(3)]
"#,
        ),
        (
            "class definition",
            r#"
class Game:
    pass
"#,
        ),
        (
            "try statement",
            r#"
try:
    /say risky
"#,
        ),
        (
            "dotted import",
            r#"
import foo.bar
"#,
        ),
    ];

    for (name, source) in cases {
        assert!(
            parse(source).is_err(),
            "unsupported Python-like syntax should not parse yet: {name}"
        );
    }
}

#[test]
fn unsupported_python_like_syntax_reports_actionable_diagnostics() {
    let source = r#"
@event.tick
def reward(player, amount=1, *extra):
    values = [i for i in range(3)]
    score += 1
    obj.field = 2
    break
    continue
    assert score
    raise score
    del score
import foo.bar
import helpers as h
from helpers import *
from helpers import setup as renamed
"#;

    let diagnostics = analyze_source(source);
    let messages = diagnostics
        .iter()
        .map(|diagnostic| diagnostic.message.as_str())
        .collect::<Vec<_>>();

    assert!(messages
        .iter()
        .any(|message| message.contains("Decorators")));
    assert!(messages
        .iter()
        .any(|message| message.contains("Default parameter values")));
    assert!(messages.iter().any(|message| message.contains("`*args`")));
    assert!(messages
        .iter()
        .any(|message| message.contains("comprehensions")));
    assert!(messages
        .iter()
        .any(|message| message.contains("Compound assignment")));
    assert!(messages
        .iter()
        .any(|message| message.contains("simple identifier assignment")));
    assert!(messages
        .iter()
        .any(|message| message.contains("Dotted imports")));
    assert!(messages
        .iter()
        .any(|message| message.contains("Import aliases are not supported")));
    assert!(messages
        .iter()
        .any(|message| message.contains("Wildcard imports are not supported")));
    assert!(messages.iter().any(|message| message.contains("`break`")));
    assert!(messages
        .iter()
        .any(|message| message.contains("`continue`")));
    assert!(messages.iter().any(|message| message.contains("`assert`")));
    assert!(messages.iter().any(|message| message.contains("`raise`")));
    assert!(messages.iter().any(|message| message.contains("`del`")));
}

#[test]
fn parse_source_rejects_for_else_with_location() {
    let source = r#"
def main():
    for i in range(3):
        pass
    else:
        pass
"#;

    let diagnostics = parse_source(source).expect_err("for-else should be rejected");

    assert_eq!(diagnostics.len(), 1);
    assert_eq!(diagnostics[0].kind, "unsupported-control-flow");
    assert_eq!(diagnostics[0].line, 5);
    assert_eq!(diagnostics[0].column, 5);
}

#[test]
fn parse_source_reports_structural_syntax_diagnostics() {
    let source = r#"
def main():
    value = (1 + 2
"#;

    let diagnostics = parse_source(source).expect_err("unclosed delimiter should be rejected");

    assert_eq!(diagnostics.len(), 1);
    assert_eq!(diagnostics[0].kind, "unclosed-delimiter");
    assert_eq!(diagnostics[0].line, 3);
    assert_eq!(diagnostics[0].column, 13);
}

#[test]
fn parse_source_reports_semantic_preflight_diagnostics() {
    let source = r#"
def helper():
    pass

def helper():
    return

def main():
    result = helper()
"#;

    let direct_diagnostics = analyze_source(source);
    assert!(
        !direct_diagnostics.is_empty(),
        "direct analyzer should reject source"
    );

    let diagnostics = parse_source(source).expect_err("semantic preflight should reject source");
    let kinds = diagnostics
        .iter()
        .map(|diagnostic| diagnostic.kind.as_str())
        .collect::<Vec<_>>();

    assert!(kinds.contains(&"duplicate-function"));
    assert!(kinds.contains(&"unsupported-return"));
    assert!(kinds.contains(&"unsupported-function-call-expression"));
    assert!(diagnostics.iter().any(|diagnostic| diagnostic
        .message
        .contains("Return statements are not supported")));
    assert!(diagnostics.iter().any(|diagnostic| diagnostic
        .message
        .contains("Function calls in expressions are not supported")));
}

#[test]
fn parse_source_reports_user_function_argument_count() {
    let source = r#"
def main():
    greet("@a")

def greet(player, message):
    /tellraw {player} {"text":"{message}"}
"#;

    let diagnostics = parse_source(source).expect_err("argument count should be rejected");

    assert_eq!(diagnostics.len(), 1);
    assert_eq!(diagnostics[0].kind, "function-argument-count");
    assert!(diagnostics[0]
        .message
        .contains("Function `greet` expects 2 argument(s), but 1 provided"));
}