mod common;
use crate::common::{catalog, en};
use workshop_rs::source::{FileId, Position, Span};
use workshop_rs::{MappedText, Program, SourceMap, parser};
const WORKSHOP_TEXT: &str = r#"variables {
global:
0: counter
player:
1: score
}
subroutines {
0: tick
}
rule ("invocation") {
event { Ongoing - Global; }
conditions {
Global.counter > 0;
}
actions {
Set Global Variable(counter, Add(Global.counter, Event Player.score));
Call Subroutine(tick);
}
}
rule ("expansion") {
event {
Subroutine;
tick;
}
actions {
Wait(1);
}
}
"#;
const MAIN_OPY: &str = "// main.opy\nglobalvar 0: counter\nplayervar 1: score\nrule invocation {\n Global.counter > 0\n counter = Add(counter, EventPlayer.score)\n CallSubroutine(tick)\n}\n";
const HUD_OPY: &str =
"// hud.opy\nplayervar 1: score\n#!define SUM Add(counter, EventPlayer.score)\n";
const GENERATED_OPY: &str = "// generated.opy — macro expansion body\ndef tick\nrule expansion {\n event Subroutine tick\n Wait(1)\n}\n";
const LATE_OPY: &str = "// late.opy — the provider never attaches this file's text\ndef tick\nrule expansion {\n event Subroutine tick\n}\n";
fn wire(file: usize, line: u32, start: u32, end: u32) -> serde_json::Value {
serde_json::json!({
"file": file,
"start": {"line": line, "column": start},
"end": {"line": line, "column": end},
})
}
fn at(file: usize, line: u32, start: u32, end: u32) -> Span {
Span::new(
FileId::from_index(file),
Position::new(line, start),
Position::new(line, end),
)
}
fn provider_program(files: &[&str], spans: serde_json::Value) -> Program {
let artifact = serde_json::json!({
"format": "workshop-rs/mapped-text-v1",
"text": WORKSHOP_TEXT,
"files": files
.iter()
.map(|path| serde_json::json!({"path": path}))
.collect::<Vec<_>>(),
"shape": {
"global_variables": 1,
"player_variables": 1,
"subroutines": 1,
"rules": [
{"conditions": 1, "actions": 2},
{"conditions": 0, "actions": 1},
],
},
"spans": spans,
});
let decoded = MappedText::from_json(&artifact.to_string()).expect("decodes");
let mut program = parser::parse(&decoded.text, &catalog(), &en()).expect("parses");
decoded.map.apply(&mut program).expect("map applies");
program
}
#[test]
fn attaching_sources_in_any_order_keeps_every_files_provenance() {
const FILES: [&str; 3] = ["src/main.opy", "src/hud.opy", "src/generated.opy"];
const AUTHORED: [&str; 3] = [MAIN_OPY, HUD_OPY, GENERATED_OPY];
let spans = serde_json::json!([
{"node": "global_variable", "index": 0, "span": wire(0, 2, 1, 21), "name_span": wire(0, 2, 14, 21)},
{"node": "player_variable", "index": 0, "span": wire(1, 2, 1, 19), "name_span": wire(1, 2, 14, 19)},
{"node": "subroutine", "index": 0, "span": wire(2, 2, 1, 9), "name_span": wire(2, 2, 5, 9)},
{"node": "rule", "rule": 0, "span": wire(0, 4, 1, 18), "name_span": wire(0, 4, 6, 16)},
{"node": "condition", "rule": 0, "condition": 0, "span": wire(0, 5, 5, 23),
"children": [{"identifier_span": wire(0, 5, 12, 19)}, {"span": wire(0, 5, 22, 23)}]},
{"node": "action", "rule": 0, "action": 0, "span": wire(0, 6, 5, 46), "identifier_span": wire(0, 6, 5, 12)},
{"node": "action_argument", "rule": 0, "action": 0, "argument": 0, "span": wire(0, 6, 15, 46),
"children": [{"identifier_span": wire(1, 3, 18, 25)}, {"identifier_span": wire(1, 3, 38, 43)}]},
{"node": "action", "rule": 0, "action": 1, "span": wire(0, 7, 5, 25), "identifier_span": wire(2, 4, 22, 26)},
{"node": "rule", "rule": 1, "span": wire(2, 3, 1, 16), "name_span": wire(2, 3, 6, 15), "event_name_span": wire(2, 4, 22, 26)},
{"node": "action", "rule": 1, "action": 0, "span": wire(2, 5, 5, 12)},
]);
for order in [
[0usize, 1, 2],
[2, 1, 0],
[0, 2, 1],
[1, 0, 2],
[1, 2, 0],
[2, 0, 1],
] {
let mut program = provider_program(&FILES, spans.clone());
let mapped = SourceMap::extract(&program);
for &file in &order {
assert!(
program.set_file_source(FileId::from_index(file), AUTHORED[file]),
"order {order:?}: attaching {} must not fail",
FILES[file]
);
assert_eq!(
SourceMap::extract(&program),
mapped,
"order {order:?}: attaching {} dropped provenance",
FILES[file]
);
}
program
.validate()
.unwrap_or_else(|error| panic!("order {order:?}: {error}"));
}
}
#[test]
fn spans_outside_the_attached_text_survive_until_their_own_files_audit() {
const FILES: [&str; 3] = ["src/main.opy", "src/generated.opy", "src/late.opy"];
const AUTHORED: [&str; 3] = [MAIN_OPY, GENERATED_OPY, LATE_OPY];
const GENERATED: usize = 1;
let spans = |expansion_span: bool| {
let mut entries = serde_json::json!([
{"node": "global_variable", "index": 0, "span": wire(0, 2, 1, 21), "name_span": wire(0, 2, 14, 21)},
{"node": "player_variable", "index": 0, "span": wire(0, 3, 1, 19), "name_span": wire(0, 3, 14, 19)},
{"node": "subroutine", "index": 0, "span": wire(1, 2, 1, 9), "name_span": wire(1, 2, 5, 9)},
{"node": "rule", "rule": 0, "span": wire(0, 4, 1, 18), "name_span": wire(0, 4, 6, 16)},
{"node": "condition", "rule": 0, "condition": 0, "span": wire(0, 5, 5, 23),
"children": [{"identifier_span": wire(0, 5, 12, 19)}, {"span": wire(0, 5, 22, 23)}]},
{"node": "action", "rule": 0, "action": 0, "span": wire(0, 6, 5, 46), "identifier_span": wire(0, 6, 5, 12)},
{"node": "action", "rule": 0, "action": 1, "span": wire(0, 7, 5, 25), "identifier_span": wire(2, 2, 5, 9)},
{"node": "rule", "rule": 1, "span": wire(1, 3, 1, 16), "name_span": wire(1, 3, 6, 15), "event_name_span": wire(2, 4, 22, 26)},
{"node": "action", "rule": 1, "action": 0, "identifier_span": wire(0, 7, 19, 23)},
]);
if expansion_span {
entries[8]["span"] = wire(1, 40, 5, 40);
}
entries
};
let stale = at(GENERATED, 40, 5, 40);
for order in [[0usize, 1usize], [1, 0]] {
let mut program = provider_program(&FILES, spans(true));
let full_map = SourceMap::extract(&program);
let pruned_map = SourceMap::extract(&provider_program(&FILES, spans(false)));
assert_eq!(program.action_span(1, 0), Some(stale));
let mut generated_attached = false;
for &file in &order {
assert!(
program.set_file_source(FileId::from_index(file), AUTHORED[file]),
"order {order:?}: attaching {} must not fail on a span outside its text",
FILES[file]
);
if file == GENERATED {
generated_attached = true;
assert_eq!(program.action_span(1, 0), None);
assert_eq!(SourceMap::extract(&program), pruned_map);
} else {
if !generated_attached {
assert_eq!(program.action_span(1, 0), Some(stale));
}
let expected = if generated_attached {
&pruned_map
} else {
&full_map
};
assert_eq!(
SourceMap::extract(&program),
*expected,
"order {order:?}: attaching {} dropped provenance",
FILES[file]
);
}
assert_eq!(
program.action_identifier_span(0, 1),
Some(at(2, 2, 5, 9)),
"order {order:?}: late.opy callee span pruned"
);
assert_eq!(
program.rule_event_name_span(1),
Some(at(2, 4, 22, 26)),
"order {order:?}: late.opy event-name span pruned"
);
}
program.validate().unwrap_or_else(|error| {
panic!("order {order:?}: load fails on a pruned expansion span: {error}")
});
}
}