use std::path::Path;
use opy_rs::diag::{Position, Span};
use opy_rs::tooling::{self, SymbolKind, check};
const MULTI_MAIN: &str = concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/multi-file/main.opy"
);
#[test]
fn multi_file_project_checks_and_resolves_end_to_end() {
let source = std::fs::read_to_string(MULTI_MAIN).expect("fixture exists");
let root = Path::new(MULTI_MAIN).parent().expect("fixture parent");
let outcome = check(&source, MULTI_MAIN, root);
assert!(
outcome.is_clean(),
"multi-file fixture must check clean, got: {:?}",
outcome.diagnostics
);
let model = outcome.model.expect("a clean project resolves");
assert_eq!(outcome.files.len(), 2);
assert_eq!(outcome.files[0].id, 0);
assert_eq!(outcome.files[1].path, "shared/defs.opy");
assert_eq!(model.file(1), Some("shared/defs.opy"));
assert_eq!(model.declarations().len(), 5);
assert!(model
.declarations()
.iter()
.any(|decl| matches!(decl, opy_rs::hir::types::Declaration::GlobalVariable { name, .. } if name == "total")));
assert!(model
.declarations()
.iter()
.any(|decl| matches!(decl, opy_rs::hir::types::Declaration::PlayerVariable { name, .. } if name == "P")));
assert_eq!(model.rules().len(), 2);
assert!(model.rules().iter().any(|entry| matches!(
entry,
opy_rs::hir::types::RuleEntry::Rule(rule) if rule.name == "collect"
)));
assert!(model.rules().iter().any(|entry| matches!(
entry,
opy_rs::hir::types::RuleEntry::SubroutineDef { name, .. } if name == "finish"
)));
assert_eq!(model.defines().len(), 1);
assert_eq!(model.defines()[0].name, "SCALE");
assert_eq!(model.enums().len(), 1);
assert_eq!(model.enums()[0].name, "Direction");
assert_eq!(model.enums()[0].members.len(), 2);
let total = model.symbol("total").expect("globalvar from the include");
assert_eq!(total.kind, SymbolKind::Global);
assert_eq!(total.declaration.path, "shared/defs.opy");
assert_eq!(total.declaration.start.line, 1);
let p = model.symbol("P").expect("playervar from the main file");
assert_eq!(p.kind, SymbolKind::Player);
assert!(p.declaration.path.ends_with("main.opy"));
let reset = model
.symbol("resetScore")
.expect("subroutine from the include");
assert_eq!(reset.kind, SymbolKind::Subroutine);
assert_eq!(
model
.symbol("doubleIt")
.expect("macro from the include")
.kind,
SymbolKind::Macro
);
let finish_symbols: Vec<_> = model
.symbols()
.iter()
.filter(|symbol| symbol.name == "finish")
.collect();
assert_eq!(finish_symbols.len(), 2);
assert!(
finish_symbols
.iter()
.any(|symbol| symbol.kind == SymbolKind::Subroutine)
);
assert!(
finish_symbols
.iter()
.any(|symbol| symbol.kind == SymbolKind::Def)
);
assert_eq!(total.references.len(), 4);
let main_refs: Vec<_> = total
.references
.iter()
.filter(|reference| reference.file_id == 0)
.collect();
assert_eq!(main_refs.len(), 3);
assert!(
main_refs
.iter()
.all(|reference| reference.path.ends_with("main.opy"))
);
let defs_ref = total
.references
.iter()
.find(|reference| reference.file_id == 1)
.expect("the def body reference");
assert_eq!(defs_ref.path, "shared/defs.opy");
assert_eq!(defs_ref.start.line, 13);
assert_eq!(reset.references.len(), 1);
assert_eq!(reset.references[0].start.line, 11);
assert_eq!(model.symbol("doubleIt").expect("macro").references.len(), 1);
let main_reference = total
.references
.iter()
.find(|reference| reference.start.line == 9)
.expect("the augmented-assignment reference in the main file");
let at_reference = model
.provenance(main_reference.to_span())
.expect("reference provenance");
assert_eq!(at_reference.path, MULTI_MAIN);
assert_eq!(at_reference.start.line, 9);
assert_eq!(
model
.symbol_at(main_reference.to_span())
.expect("symbol at reference")
.name,
"total"
);
let json = serde_json::to_value(&model).expect("the model serializes");
assert_eq!(json["hir"]["protocol"]["name"], "wright/opy-hir");
assert!(json["symbols"].as_array().expect("symbols array").len() >= 5);
assert!(json["enums"].as_array().expect("enums array").len() == 1);
}
#[test]
fn tooling_exposes_define_member_identity() {
let outcome = check(
"#!defineMember VALUE 2\nglobalvar result\nrule \"member define\":\n @Event global\n result = VALUE\n",
"main.opy",
Path::new("."),
);
let model = outcome
.model
.expect("member define project must check clean");
assert_eq!(model.defines().len(), 1);
assert!(model.defines()[0].is_member);
}
#[rustfmt::skip]
const STABLE_DIAGNOSTICS: &[(&str, &str)] = &[
("globalvar money\nrule \"r\":\n @Event global\n money += {\n Mei.GENERIC: 10,\n }\n", "dict-access"),
("rule \"x\"\n @Event global\n", "parse-error"),
("rule \"r\":\n @Event global\n frobnicate()\n", "unknown-action"),
("globalvar x\nrule \"r\":\n @Event global\n x = frobnicate()\n", "unknown-value"),
("globalvar x\nrule \"r\":\n @Event global\n x = nope\n", "unknown-identifier"),
("rule \"r\":\n @Event eachPlayer\n eventPlayer.frobnicate()\n", "unknown-member"),
("globalvar g\nrule \"r\":\n @Event global\n chaseOverTime(g, 10, 3, 4, 5)\n", "invalid-arity"),
("rule \"r\":\n @Event global\n isGameInProgress()\n", "value-in-action-position"),
("#!define double(x) x + x\nrule \"r\":\n @Event global\n double(1, 2)\n", "macro-arity"),
("settings {\n \"gamemodes\": {}\n", "settings-invalid"),
];
#[test]
fn diagnostic_codes_are_stable_for_malformed_inputs() {
for (source, expected_code) in STABLE_DIAGNOSTICS {
let outcome = check(source, "main.opy", Path::new(""));
let diagnostic = outcome
.diagnostics
.first()
.unwrap_or_else(|| panic!("expected a '{expected_code}' diagnostic for:\n{source}"));
assert_eq!(
diagnostic.code, *expected_code,
"unexpected first diagnostic for:\n{source}\ngot: {:?}",
outcome.diagnostics
);
assert!(
diagnostic.span.is_some(),
"the '{expected_code}' diagnostic must be source-located:\n{source}"
);
assert!(outcome.model.is_none(), "a failing project has no model");
}
}
#[test]
fn workshop_script_source_reports_exactly_one_diagnostic() {
let outcome = check(
"rule \"workshop style\" {\n event {\n Ongoing - Global;\n }\n actions {\n Wait(1, Ignore Condition);\n }\n}\n",
"main.opy",
Path::new(""),
);
assert_eq!(
outcome.diagnostics.len(),
1,
"expected one diagnostic, got {:?}",
outcome.diagnostics
);
let diagnostic = &outcome.diagnostics[0];
assert_eq!(diagnostic.code, "workshop-source");
assert_eq!(diagnostic.span.as_ref().expect("span").path, "main.opy");
assert!(outcome.model.is_none(), "a failing project has no model");
}
#[test]
fn include_failures_are_stable_and_source_located() {
let outcome = check(
"#!include \"nope.opy\"\n",
"main.opy",
Path::new("/nonexistent-opy-root"),
);
let diagnostic = outcome.diagnostics.first().expect("include-not-found");
assert_eq!(diagnostic.code, "include-not-found");
assert_eq!(diagnostic.span.as_ref().expect("span").start.line, 1);
let dir = std::env::temp_dir().join(format!("opy-tooling-cycle-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.opy"), "#!include \"b.opy\"\n").unwrap();
std::fs::write(dir.join("b.opy"), "#!include \"a.opy\"\n").unwrap();
let main = std::fs::read_to_string(dir.join("a.opy")).unwrap();
let outcome = check(&main, "a.opy", &dir);
assert_eq!(
outcome.diagnostics.first().expect("include-cycle").code,
"include-cycle"
);
assert_eq!(outcome.files.len(), 3);
assert_eq!(outcome.files[1].path, "b.opy");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn all_parse_diagnostics_are_reported() {
let outcome = check(
"rule \"a\"\n @Event global\nrule \"b\"\n",
"main.opy",
Path::new(""),
);
assert_eq!(outcome.diagnostics.len(), 3);
assert!(
outcome
.diagnostics
.iter()
.all(|diagnostic| diagnostic.code == "parse-error")
);
assert!(outcome.model.is_none());
}
#[test]
fn semantic_errors_follow_the_compile_first_error_contract() {
let outcome = check(
"rule \"r\":\n @Event global\n x = frobnicate()\n",
"main.opy",
Path::new(""),
);
let diagnostic = outcome.diagnostics.first().expect("first semantic error");
assert_eq!(diagnostic.code, "unknown-identifier");
let compile_error = opy_rs::compile(
"rule \"r\":\n @Event global\n x = frobnicate()\n",
"main.opy",
Path::new(""),
)
.unwrap_err();
assert_eq!(compile_error.code, diagnostic.code);
}
#[test]
fn check_with_overlay_resolves_unsaved_includes() {
let mut overlay = std::collections::BTreeMap::new();
overlay.insert(
"shared/defs.opy".to_string(),
"globalvar total\n".to_string(),
);
let outcome = tooling::check_with_overlay(
"#!include \"shared/defs.opy\"\nrule \"r\":\n @Event global\n total = 1\n",
"main.opy",
Path::new(""),
&overlay,
);
assert!(
outcome.is_clean(),
"overlay include must resolve: {:?}",
outcome.diagnostics
);
let model = outcome.model.expect("model");
assert_eq!(
model.symbol("total").expect("symbol").kind,
SymbolKind::Global
);
}
#[test]
fn unknown_span_lookup_returns_none() {
let outcome = check("globalvar total\n", "main.opy", Path::new(""));
let model = outcome.model.expect("clean project");
let nowhere = Span::new(42, Position::new(1, 1), Position::new(1, 1));
assert!(model.symbol_at(nowhere).is_none());
assert!(model.provenance(nowhere).is_none());
assert_eq!(model.file(42), None);
}
#[test]
fn player_member_references_use_exact_member_span() {
let source = "playervar I\nrule \"r\":\n @Event global\n for hostPlayer.I in range(3):\n hostPlayer.I = 1\n";
let outcome = check(source, "main.opy", Path::new(""));
assert!(
outcome.is_clean(),
"player member source must check clean: {:?}",
outcome.diagnostics
);
let model = outcome.model.expect("clean project");
let symbol = model.symbol("I").expect("player variable symbol");
assert_eq!(symbol.references.len(), 2);
assert!(
symbol
.references
.iter()
.any(|reference| reference.start.line == 4 && reference.start.col == 20)
);
assert!(
symbol
.references
.iter()
.any(|reference| reference.start.line == 5 && reference.start.col == 20)
);
}
#[test]
fn settings_emission_agreement_between_check_and_compile() {
let unknown = concat!(
"settings {\n",
" \"main\": {\"description\": \"t\", \"emptyKey\": \"\"},\n",
" \"lobby\": {\"mapRotation\": \"sometimes\"},\n",
" \"gamemodes\": {\"ffa\": {\"notASetting\": 3, \"aList\": [\"x\", 1], \"anObject\": {\"k\": true}}},\n",
" \"heroes\": {\"allTeams\": {\"shion\": {\"ability2Duration\": \"500%\"}}}\n",
"}\n",
"rule \"a\":\n @Event global\n wait(1)\n",
);
let outcome = check(unknown, "main.opy", Path::new(""));
assert!(outcome.model.is_some(), "{:?}", outcome.diagnostics);
let warnings = outcome
.diagnostics
.iter()
.map(|diagnostic| (diagnostic.severity, diagnostic.code.as_str()))
.collect::<Vec<_>>();
assert_eq!(
warnings,
[(tooling::DiagnosticSeverity::Warning, "unknown-setting"); 6]
);
let lines = compiled_lines(unknown);
for line in [
"emptyKey:",
"Map Rotation: sometimes",
"notASetting: 3",
"aList {",
"x",
"1",
"anObject {",
"k: true",
"ability2Duration: 500%",
] {
assert!(lines.contains(&line.to_string()), "{line:?} in {lines:?}");
}
let invalid = concat!(
"settings {\n",
" \"main\": {\"description\": \"t\"},\n",
" \"gamemodes\": {}, \"heroes\": {\"allTeams\": {\"notAHero\": 1}}\n",
"}\n",
"rule \"a\":\n @Event global\n wait(1)\n",
);
let outcome = check(invalid, "main.opy", Path::new(""));
assert!(outcome.model.is_none());
let diagnostic = outcome
.diagnostics
.iter()
.find(|diagnostic| diagnostic.severity == tooling::DiagnosticSeverity::Error)
.expect("a team-level scalar must fail check");
assert_eq!(diagnostic.code, "workshop-emission");
assert_eq!(diagnostic.span.as_ref().expect("span").path, "main.opy");
let compile_error = opy_rs::compile(invalid, "main.opy", Path::new("")).unwrap_err();
assert_eq!(compile_error.code, "workshop-emission");
let valid = concat!(
"settings {\n",
" \"main\": {\"description\": \"t\"},\n",
" \"gamemodes\": {\"ffa\": {\"enabledMaps\": [\"workshopIsland\"], \"enableKillCam\": false}}\n",
"}\n",
"rule \"a\":\n @Event global\n wait(1)\n",
);
let outcome = check(valid, "main.opy", Path::new(""));
assert!(
outcome.is_clean(),
"inherited gamemode key must check clean: {:?}",
outcome.diagnostics
);
}
#[test]
fn misspelled_settings_key_warns_with_a_single_candidate_suffix() {
let source = concat!(
"settings {\n",
" \"main\": { \"descriptino\": \"x\" },\n",
" \"gamemodes\": {}\n",
"}\n",
"rule \"a\":\n @Event global\n wait(1)\n",
);
let expected = "unknown settings key 'descriptino' is passed through unchanged (did you mean 'description'?)";
let outcome = check(source, "main.opy", Path::new(""));
let diagnostic = outcome
.diagnostics
.iter()
.find(|diagnostic| diagnostic.code == "unknown-setting")
.expect("the misspelled key must warn");
assert_eq!(diagnostic.severity, tooling::DiagnosticSeverity::Warning);
assert_eq!(diagnostic.message, expected);
assert!(compiled_lines(source).contains(&"descriptino: x".to_string()));
}
fn compiled_lines(source: &str) -> Vec<String> {
opy_rs::Compiler::new()
.expect("compiler")
.compile_source(source, "main.opy", Path::new(""))
.expect("compiles")
.workshop
.lines()
.map(|line| line.trim().to_string())
.collect()
}