Initial Dever 0.0.1
This commit is contained in:
commit
81233d098c
1479 files changed
+163782
No files matched your search
@@ -0,0 +1,109 @@
|
||||
use super::{checked, package::packages};
|
||||
|
||||
#[test]
|
||||
fn api_snapshot_uses_declared_contracts_not_private_bodies_or_inferred_constants() {
|
||||
let snapshot = |body: &str| {
|
||||
checked(&format!("public value(input: Int) (answer: Int) {{ answer = helper(input) }}\nhelper(input: Int) (answer: Int) {{ {body} }}")).unwrap().api_snapshot()
|
||||
};
|
||||
let first = snapshot("answer = 1");
|
||||
assert_eq!(
|
||||
first,
|
||||
"dever-api 5\nfunction main.value(Int) (answer: Int []) pure=false recover=None effects=[] errors=[] recoveries={}\n"
|
||||
);
|
||||
assert_eq!(first, snapshot("answer = input + 2"));
|
||||
let constrained =
|
||||
checked("public value(input: Int) (answer: Int >= 0) { answer = 1 }").unwrap();
|
||||
assert!(
|
||||
constrained
|
||||
.api_snapshot()
|
||||
.contains("answer: Int [0..9223372036854775807]")
|
||||
);
|
||||
let difference = constrained.check_api(&first).unwrap_err();
|
||||
assert!(
|
||||
difference.contains("- function main.value")
|
||||
&& difference.contains("+ function main.value")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_snapshot_tracks_public_shape_construction_failure_and_transitive_recovery() {
|
||||
let snapshot = |key: &str| {
|
||||
checked(&format!(
|
||||
"public type Session {{ private key: {key}\nlabel: Text }}"
|
||||
))
|
||||
.unwrap()
|
||||
.api_snapshot()
|
||||
};
|
||||
let private = snapshot("Int");
|
||||
assert_eq!(private, snapshot("Text"));
|
||||
assert_eq!(
|
||||
private,
|
||||
"dever-api 5\ntype main.Session record construct=private {label: Text []}\n"
|
||||
);
|
||||
let exposed = checked("public type Session { key: Int\nlabel: Text }").unwrap();
|
||||
assert!(
|
||||
exposed
|
||||
.check_api(&private)
|
||||
.unwrap_err()
|
||||
.contains("construct=public")
|
||||
);
|
||||
let failing = checked("public type Problem { error Bad(message: Text) }\npublic value() () { fail(Problem.Bad(\"bad\")) }").unwrap();
|
||||
let baseline = failing.api_snapshot();
|
||||
assert!(baseline.contains("errors=[main.Problem.Bad]"));
|
||||
assert!(baseline.contains("type main.Problem error Bad(message: Text [])"));
|
||||
let snapshot = |reason: &str| {
|
||||
checked(&format!(
|
||||
"public value() () {{ helper() }}\nhelper() () recover(\"{reason}\") {{}}"
|
||||
))
|
||||
.unwrap()
|
||||
.api_snapshot()
|
||||
};
|
||||
assert_ne!(snapshot("use zero"), snapshot("use one"));
|
||||
assert!(snapshot("use zero").contains("recoveries={\"use zero\"}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_snapshot_is_stable_across_app_topics_and_binds_command_inputs() {
|
||||
let snapshot = |topic: &str, input: &str| {
|
||||
packages(&[
|
||||
(
|
||||
topic,
|
||||
&format!("value({input}: Int) (answer: Int) {{ answer = {input} }}"),
|
||||
),
|
||||
("shop.account.api", "cmd value = app.value"),
|
||||
])
|
||||
.unwrap()
|
||||
.api_snapshot()
|
||||
};
|
||||
assert_eq!(
|
||||
snapshot("shop.account.app", "input"),
|
||||
snapshot("shop.account.app.read", "input")
|
||||
);
|
||||
assert_ne!(
|
||||
snapshot("shop.account.app", "input"),
|
||||
snapshot("shop.account.app", "renamed")
|
||||
);
|
||||
assert!(
|
||||
snapshot("shop.account.app", "input")
|
||||
.contains("cmd shop.account.value = shop.account.value(input: Int [])")
|
||||
);
|
||||
let program = checked("public apply(action: handler(input: Int) (answer: Int), input: Int) (answer: Int) { answer = action(input) }").unwrap();
|
||||
assert!(
|
||||
program
|
||||
.api_snapshot()
|
||||
.contains("function main.apply(handler(Int [])(answer: Int []), Int)")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_handler_snapshot_keeps_constraints_and_ignores_specializations() {
|
||||
let source = "public apply(action: handler(input: Int >= 0) (answer: Int >= 0)) (answer: Int >= 0) { answer = action(1) }";
|
||||
let original = checked(source).unwrap().api_snapshot();
|
||||
assert!(
|
||||
original.contains(
|
||||
"handler(Int [0..9223372036854775807])(answer: Int [0..9223372036854775807])"
|
||||
)
|
||||
);
|
||||
let called = checked(&format!("{source}\nnonnegative(input: Int) (answer: Int >= 0) {{ answer = 0 }}\nmain() (answer: Int) {{ answer = apply(nonnegative) }}")).unwrap();
|
||||
assert_eq!(original, called.api_snapshot());
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
use super::package::packages;
|
||||
|
||||
#[test]
|
||||
fn fixed_wire_budget_proof_retains_dynamic_checks_at_schema_limits() {
|
||||
for (levels, fields, input_fixed, output_fixed) in [
|
||||
(63, 1, true, true),
|
||||
(64, 1, false, false),
|
||||
(14, 2, true, true),
|
||||
(15, 2, true, false),
|
||||
(16, 2, false, false),
|
||||
] {
|
||||
let mut app = String::new();
|
||||
let mut kind = "Int".to_owned();
|
||||
for level in 0..levels {
|
||||
let members = if fields == 1 {
|
||||
format!("value: {kind}")
|
||||
} else {
|
||||
format!("first: {kind}\nsecond: {kind}")
|
||||
};
|
||||
app.push_str(&format!("type Layer{level} {{ {members} }}\n"));
|
||||
kind = format!("Layer{level}");
|
||||
}
|
||||
app.push_str(&format!(
|
||||
"run(value: {kind}) (answer: {kind}) {{ answer = value }}"
|
||||
));
|
||||
let program = packages(&[
|
||||
("shop.account.app", &app),
|
||||
(
|
||||
"shop.account.api",
|
||||
"cmd run = app.run\npublic post run = app.run",
|
||||
),
|
||||
])
|
||||
.unwrap();
|
||||
let emitted = dever::compiler::emit::application::generate(&program);
|
||||
assert_eq!(
|
||||
emitted.contains("wire::decode_fixed(input)"),
|
||||
input_fixed,
|
||||
"{levels}/{fields}"
|
||||
);
|
||||
assert_eq!(
|
||||
emitted.contains("input.decode(dever_runtime::wire::decode_fixed)"),
|
||||
input_fixed,
|
||||
"HTTP {levels}/{fields}"
|
||||
);
|
||||
assert_eq!(
|
||||
emitted.matches("wire::response_fixed(&result)").count(),
|
||||
usize::from(output_fixed),
|
||||
"{levels}/{fields}"
|
||||
);
|
||||
assert_eq!(
|
||||
emitted.contains("wire::response_fixed_bytes(&result)"),
|
||||
output_fixed,
|
||||
"HTTP {levels}/{fields}"
|
||||
);
|
||||
}
|
||||
for (kind, decoder, encoder) in [
|
||||
("List<Int>", "decode_shaped", "response"),
|
||||
("Json", "decode_json", "response_with"),
|
||||
("List<Json>", "decode", "response_with"),
|
||||
("List<Text>?", "decode_shaped", "response"),
|
||||
] {
|
||||
let app = format!("run(value: {kind}) (answer: {kind}) {{ answer = value }}");
|
||||
let program = packages(&[
|
||||
("shop.account.app", &app),
|
||||
(
|
||||
"shop.account.api",
|
||||
"cmd run = app.run\npublic post run = app.run",
|
||||
),
|
||||
])
|
||||
.unwrap();
|
||||
let emitted = dever::compiler::emit::application::generate(&program);
|
||||
if decoder == "decode_json" {
|
||||
assert!(emitted.contains("let args: Args0 = decode_args0(input)?;"));
|
||||
assert!(emitted.contains("input.decode(decode_httpargs0)"));
|
||||
assert_eq!(emitted.matches("wire::decode_json(input,").count(), 2);
|
||||
assert_eq!(
|
||||
emitted.contains("wire::decode(input)"),
|
||||
kind == "List<Json>"
|
||||
);
|
||||
} else {
|
||||
assert!(!emitted.contains("wire::decode_json("));
|
||||
assert!(
|
||||
emitted.contains(&format!("wire::{decoder}(input)")),
|
||||
"{kind}"
|
||||
);
|
||||
assert!(
|
||||
emitted.contains(&format!("input.decode(dever_runtime::wire::{decoder})")),
|
||||
"{kind}"
|
||||
);
|
||||
}
|
||||
assert!(emitted.contains(&format!("wire::{encoder}(")), "{kind}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_input_budget_includes_fixed_records_and_optional_json() {
|
||||
let program = packages(&[
|
||||
(
|
||||
"shop.account.app",
|
||||
"type Row { payload: Json?\nnote: Text? }\nrun(row: Row?, other: Json, label: Text) (answer: Row?, extra: Json, name: Text) { answer = row\nextra = other\nname = label }",
|
||||
),
|
||||
(
|
||||
"shop.account.api",
|
||||
"cmd run = app.run\npublic post run = app.run",
|
||||
),
|
||||
])
|
||||
.unwrap();
|
||||
let generated = dever::compiler::emit::application::generate(&program);
|
||||
assert_eq!(generated.matches("wire::decode_json(input,").count(), 2);
|
||||
assert!(generated.contains("budget.open()?; budget.value(3)?;"));
|
||||
assert!(generated.contains("budget.open()?; budget.value(2)?;"));
|
||||
assert!(generated.contains("if let Some(item) = (&(args).field0).as_ref()"));
|
||||
assert!(generated.contains("budget.json(item)?;"));
|
||||
assert!(generated.contains("budget.json(&(args).field1)?;"));
|
||||
assert!(!generated.contains("Json::from_validated_raw"));
|
||||
assert!(!generated.contains("Json::deserialize_validated"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_input_with_any_typed_list_keeps_whole_input_validation() {
|
||||
for kind in ["List<Int>", "List<Json>", "List<Json?>?"] {
|
||||
for nested in [false, true] {
|
||||
let (definition, kind) = if nested {
|
||||
(format!("type Row {{ values: {kind} }}\n"), "Row?")
|
||||
} else {
|
||||
(String::new(), kind)
|
||||
};
|
||||
let app = format!(
|
||||
"{definition}run(payload: Json, other: {kind}) (answer: Json, extra: {kind}) {{ answer = payload\nextra = other }}"
|
||||
);
|
||||
let program = packages(&[
|
||||
("shop.account.app", &app),
|
||||
(
|
||||
"shop.account.api",
|
||||
"cmd run = app.run\npublic post run = app.run",
|
||||
),
|
||||
])
|
||||
.unwrap();
|
||||
let generated = dever::compiler::emit::application::generate(&program);
|
||||
assert!(generated.contains("let args: Args0 = dever_runtime::wire::decode(input)?;"));
|
||||
assert!(generated.contains("input.decode(dever_runtime::wire::decode)"));
|
||||
assert!(!generated.contains("wire::decode_json("));
|
||||
assert!(!generated.contains("budget."));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variable_wire_depth_is_proved_before_skipping_validation() {
|
||||
for (levels, decoder) in [(63, "decode_shaped"), (64, "decode")] {
|
||||
let kind = format!("{}Int{}", "List<".repeat(levels), ">".repeat(levels));
|
||||
let app = format!("run(value: {kind}) (answer: Int) {{ answer = length(value) }}");
|
||||
let program = packages(&[
|
||||
("shop.account.app", &app),
|
||||
(
|
||||
"shop.account.api",
|
||||
"cmd run = app.run\npublic post run = app.run",
|
||||
),
|
||||
])
|
||||
.unwrap();
|
||||
let emitted = dever::compiler::emit::application::generate(&program);
|
||||
assert!(
|
||||
emitted.contains(&format!("wire::{decoder}(input)")),
|
||||
"{levels}"
|
||||
);
|
||||
assert!(
|
||||
emitted.contains(&format!("input.decode(dever_runtime::wire::{decoder})")),
|
||||
"{levels}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_entries_bind_typed_apps_and_keep_protected_entries_closed() {
|
||||
assert!(packages(&[("main", "get(value: Int) (answer: Int) { answer = value }")]).is_ok());
|
||||
let app = "echo(value: Int) (answer: Int) { answer = value }";
|
||||
let program = packages(&[
|
||||
("shop.account.app", app),
|
||||
(
|
||||
"shop.account.api.admin.session",
|
||||
"public get status = app.echo",
|
||||
),
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(program.http[0].path, "/shop/account/admin/session/status");
|
||||
assert_eq!(program.http[0].topic, "admin/session");
|
||||
assert_eq!(program.http[0].method, "GET");
|
||||
for (declaration, code) in [
|
||||
("get status = app.echo", "C014"),
|
||||
("authenticated get status = app.echo", "C014"),
|
||||
(
|
||||
"public get status = app.echo\npublic get status = app.echo",
|
||||
"C002",
|
||||
),
|
||||
("public get status = other.echo", "C006"),
|
||||
] {
|
||||
assert_eq!(
|
||||
packages(&[("shop.account.app", app), ("shop.account.api", declaration)])
|
||||
.unwrap_err()
|
||||
.code,
|
||||
code
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
packages(&[
|
||||
(
|
||||
"shop.account.app",
|
||||
"echo(value: List<Int>) (answer: List<Int>) { answer = value }"
|
||||
),
|
||||
("shop.account.api", "public get status = app.echo")
|
||||
])
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C005"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_wire_rejects_nested_constrained_records() {
|
||||
for kind in ["Port", "List<Port?>", "Envelope"] {
|
||||
let app = format!(
|
||||
"type Port {{ value: Int >= 1 }}\ntype Envelope {{ ports: List<Port?> }}\nrun(value: {kind}) (answer: {kind}) {{ answer = value }}"
|
||||
);
|
||||
assert_eq!(
|
||||
packages(&[
|
||||
("shop.account.app", &app),
|
||||
("shop.account.api", "cmd run = app.run")
|
||||
])
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C011"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn application_topics_share_app_contracts_and_domain_dependencies() {
|
||||
let program = packages(&[
|
||||
("shop.account.app.read", "type User { name: Text }\nread(name: Text) (answer: User) { answer = User { name = domain.label(name) } }"),
|
||||
("shop.account.app.write", "echo(user: User) (answer: User) { answer = user }"),
|
||||
("shop.account.domain", "label(name: Text) (answer: Text) { answer = name + \"!\" }"),
|
||||
("shop.account.api", "cmd read = app.read\ncmd echo = app.echo"),
|
||||
]).unwrap();
|
||||
assert_eq!(program.commands.len(), 2);
|
||||
assert_eq!(program.commands[0].name, "shop.account.read");
|
||||
assert!(
|
||||
program
|
||||
.functions
|
||||
.iter()
|
||||
.any(|function| function.signature.name == "label")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_app_fields_share_topic_ownership_and_reject_other_domains() {
|
||||
let declaration = "type Session { private key: Text\nlabel: Text }";
|
||||
assert!(packages(&[
|
||||
("shop.account.app.types", declaration),
|
||||
("shop.account.app.write", "make(key: Text) (answer: Session) { answer = Session { key = key, label = \"name\" } }"),
|
||||
("shop.account.app.read", "read(value: Session) (answer: Text) { value.key = value.key + \"!\"\nanswer = value.key }"),
|
||||
]).is_ok());
|
||||
for operation in [
|
||||
"answer = value.key",
|
||||
"value.key = \"changed\"\nanswer = value.label",
|
||||
"value = shop.account.Session { key = \"hidden\", label = \"name\" }\nanswer = value.label",
|
||||
] {
|
||||
let caller = format!("read(value: shop.account.Session) (answer: Text) {{ {operation} }}");
|
||||
assert_eq!(
|
||||
packages(&[
|
||||
("shop.account.app", declaration),
|
||||
("shop.other.app", &caller)
|
||||
])
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C006"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_wire_rejects_nested_private_records_in_both_directions() {
|
||||
for kind in ["Session", "List<Session?>", "Envelope"] {
|
||||
for function in [
|
||||
format!("run(value: {kind}) () {{}}"),
|
||||
format!("run(value: {kind}) (answer: {kind}) {{ answer = value }}"),
|
||||
] {
|
||||
let app = format!(
|
||||
"type Session {{ private key: Text }}\ntype Envelope {{ sessions: List<Session?> }}\n{function}"
|
||||
);
|
||||
let error = packages(&[
|
||||
("shop.account.app", &app),
|
||||
("shop.account.api", "cmd run = app.run"),
|
||||
])
|
||||
.unwrap_err();
|
||||
assert_eq!(error.code, "C006", "{error:?}");
|
||||
assert!(error.message.contains("wire boundary"), "{error:?}");
|
||||
}
|
||||
}
|
||||
let app = "type Session { private key: Text }\nrun() (answer: Session) { answer = Session { key = \"hidden\" } }";
|
||||
assert_eq!(
|
||||
packages(&[
|
||||
("shop.account.app", app),
|
||||
("shop.account.api", "cmd run = app.run")
|
||||
])
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C006"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn application_role_access_cannot_be_bypassed_with_qualified_names() {
|
||||
for (source, target) in [
|
||||
("shop.first.domain", "shop.second.run"),
|
||||
("shop.first.domain", "app.run"),
|
||||
("shop.first.app", "shop.second.domain.hidden"),
|
||||
] {
|
||||
let caller = format!("entry() (answer: Int) {{ answer = {target}() }}");
|
||||
let result = packages(&[
|
||||
(source, &caller),
|
||||
("shop.first.app.extra", "run() (answer: Int) { answer = 1 }"),
|
||||
("shop.second.app", "run() (answer: Int) { answer = 2 }"),
|
||||
(
|
||||
"shop.second.domain",
|
||||
"hidden() (answer: Int) { answer = 3 }",
|
||||
),
|
||||
]);
|
||||
assert_eq!(result.unwrap_err().code, "C006", "{source}: {target}");
|
||||
}
|
||||
assert_eq!(
|
||||
packages(&[
|
||||
("shop.account.app", "type Visible { secret: domain.Hidden }"),
|
||||
("shop.account.domain", "type Hidden { value: Int }"),
|
||||
])
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C006"
|
||||
);
|
||||
assert_eq!(
|
||||
packages(&[("shop.account.app", "public run() () {}")])
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C006"
|
||||
);
|
||||
assert_eq!(
|
||||
packages(&[("shop.account.api", "run() () {}")])
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C006"
|
||||
);
|
||||
assert_eq!(
|
||||
packages(&[("main", "cmd run = app.run")]).unwrap_err().code,
|
||||
"C006"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_secret_inputs_use_the_existing_input_codec_without_output_access() {
|
||||
for kind in ["Secret", "Secret?", "List<Secret>"] {
|
||||
let app = format!("run(value: {kind}) (answer: Bool) {{ answer = true }}");
|
||||
assert!(
|
||||
packages(&[
|
||||
("shop.account.app", &app),
|
||||
("shop.account.api", "cmd run = app.run")
|
||||
])
|
||||
.is_ok()
|
||||
);
|
||||
let leaking = format!("run(value: {kind}) (answer: {kind}) {{ answer = value }}");
|
||||
assert!(
|
||||
packages(&[
|
||||
("shop.account.app", &leaking),
|
||||
("shop.account.api", "cmd run = app.run")
|
||||
])
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_reject_ambiguous_targets_and_invalid_wire_contracts() {
|
||||
for app in [
|
||||
"run() () {}\nrun(value: Int) () {}",
|
||||
"run(action: handler() ()) () { action() }",
|
||||
"type State { Ready(value: Int) }\nrun(value: State) (answer: State) { answer = value }",
|
||||
"type State { Ready\n error Failed }\nrun(value: State) (answer: State) { answer = value }",
|
||||
] {
|
||||
assert_eq!(
|
||||
packages(&[
|
||||
("shop.account.app", app),
|
||||
("shop.account.api", "cmd run = app.run")
|
||||
])
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C005"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
packages(&[
|
||||
("shop.account.app", "run() () {}"),
|
||||
("shop.account.api", "cmd run = app.run\ncmd run = app.run"),
|
||||
])
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C002"
|
||||
);
|
||||
assert_eq!(
|
||||
packages(&[
|
||||
("shop.account.app", "run() () {}"),
|
||||
("shop.account.api", "cmd run = shop.account.run"),
|
||||
])
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C006"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn application_entry_reachability_and_domain_cycles_are_checked() {
|
||||
assert_eq!(
|
||||
packages(&[
|
||||
("shop.account.app", "run() () {}\nunused() () {}"),
|
||||
("shop.account.api", "cmd run = app.run"),
|
||||
])
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C016"
|
||||
);
|
||||
assert_eq!(packages(&[
|
||||
("shop.first.app", "run() (answer: Int) { answer = second.read() }\nread() (answer: Int) { answer = 1 }"),
|
||||
("shop.second.app", "read() (answer: Int) { answer = first.read() }"),
|
||||
("shop.first.api", "cmd run = app.run"),
|
||||
]).unwrap_err().code, "C007");
|
||||
assert_eq!(
|
||||
packages(&[
|
||||
("shop.account.app.read", "run() () {}"),
|
||||
("shop.account.app.write", "run() () {}"),
|
||||
])
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C013"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
use super::{APP, ERROR, packages};
|
||||
|
||||
const CLOCK: &str = "public type ClockResult { Read(value: Int)\nerror Failed(message: Text) }\npublic unixMillis() (value: Int) { value = read(dever.system.timeUnixMillis()) }\nread(result: ClockResult.Read(number)) (value: Int) { value = number }\nread(result: ClockResult.Failed(message)) (value: Int) { fail(ClockResult.Failed(message)) }\npublic type SleepResult { Done\nerror Failed(message: Text) }\npublic sleep(millis: Int) () { slept(dever.system.timeSleep(millis)) }\nslept(result: SleepResult.Done) () {}\nslept(result: SleepResult.Failed(message)) () { fail(SleepResult.Failed(message)) }";
|
||||
const TASK: &str = "public sleep(millis: Int) () { slept(dever.system.taskSleep(millis)) }\nslept(result: dever.time.SleepResult.Done) () {}\nslept(result: dever.time.SleepResult.Failed(message)) () { fail(dever.time.SleepResult.Failed(message)) }";
|
||||
|
||||
#[test]
|
||||
fn provider_expiration_reads_clock_but_cannot_wait() {
|
||||
for (operation, error) in [
|
||||
("current = dever.time.unixMillis()", None),
|
||||
("dever.time.sleep(1)\ncurrent = 0", Some("C013")),
|
||||
("dever.task.sleep(1)\ncurrent = 0", Some("C014")),
|
||||
] {
|
||||
let app = APP
|
||||
.replace(
|
||||
"account = model.first",
|
||||
&format!("{operation}\naccount = model.first"),
|
||||
)
|
||||
.replace(
|
||||
"session == claims.session",
|
||||
"session == claims.session and expires_at > current",
|
||||
);
|
||||
let result = packages(&[
|
||||
("dever.database", ERROR),
|
||||
("dever.api", "public type Error { error Unauthorized }"),
|
||||
(
|
||||
"dever.auth",
|
||||
"public type Claims { subject: Text\nsession: Text\ntenant: Text?\nsite: Text }",
|
||||
),
|
||||
("dever.time", CLOCK),
|
||||
("dever.task", TASK),
|
||||
(
|
||||
"demo.account.model",
|
||||
"global type Account { subject: Text\nsession: Text\nexpires_at: Int }",
|
||||
),
|
||||
("demo.account.app", &app),
|
||||
("demo.account.api", "public get health = app.health"),
|
||||
]);
|
||||
match error {
|
||||
Some(code) => assert_eq!(result.unwrap_err().code, code, "{operation}"),
|
||||
None => {
|
||||
let program = result.unwrap();
|
||||
let effects = program.functions[program.verifiers[0].function]
|
||||
.signature
|
||||
.effects;
|
||||
assert!(effects.clock);
|
||||
assert!(!effects.timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shipped_session_expiration_contract_checks_without_dropping_the_deadline() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../skills/assets/examples/http-rest");
|
||||
let project = dever::compiler::project::Project::load(&root).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
assert!(
|
||||
program
|
||||
.verifiers
|
||||
.iter()
|
||||
.any(|verifier| { program.functions[verifier.function].signature.effects.clock })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_checks_every_model_loaded_by_a_relation() {
|
||||
let model =
|
||||
"global type Account { subject: Text\nsession: Text\nprofile_id: demo.profile.model.id }";
|
||||
let app = APP.replace(
|
||||
"session == claims.session })",
|
||||
"session == claims.session, with = [profile] })",
|
||||
);
|
||||
for global in [true, false] {
|
||||
let profile = format!(
|
||||
"{}type Profile {{ name: Text }}",
|
||||
if global { "global " } else { "" }
|
||||
);
|
||||
let result = packages(&[
|
||||
("dever.database", ERROR),
|
||||
("dever.api", "public type Error { error Unauthorized }"),
|
||||
(
|
||||
"dever.auth",
|
||||
"public type Claims { subject: Text\nsession: Text\ntenant: Text?\nsite: Text }",
|
||||
),
|
||||
("demo.account.model", model),
|
||||
("demo.profile.model", &profile),
|
||||
("demo.account.app", &app),
|
||||
("demo.account.api", "public get health = app.health"),
|
||||
]);
|
||||
if global {
|
||||
result.unwrap();
|
||||
} else {
|
||||
let error = result.unwrap_err();
|
||||
assert_eq!(error.code, "C014");
|
||||
assert!(
|
||||
error
|
||||
.message
|
||||
.contains("Provider verify cannot read a tenant Model")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
use super::{database::ERROR, package::packages};
|
||||
use dever::{compiler::model::Program, syntax::diagnostic::Diagnostic};
|
||||
|
||||
#[path = "../authorization/mod.rs"]
|
||||
mod capability;
|
||||
mod effects;
|
||||
mod request;
|
||||
mod rest;
|
||||
|
||||
const APP: &str = "type Identity { id: Text\nuser_id: model.id?\ntenant_id: model.id? }\nverify(claims: dever.auth.Claims) (identity: Identity) { account = model.first({ where = subject == claims.subject and session == claims.session })\nidentity = identity(account) }\nidentity(account: model.Account) (identity: Identity) { identity = Identity { id = account.subject, user_id = account.id, tenant_id = null } }\nidentity(account: null) (identity: Identity) { fail(dever.api.Error.Unauthorized) }\nhealth() (value: Bool) { value = true }";
|
||||
|
||||
fn checked(model: &str, app: &str) -> Result<Program, Diagnostic> {
|
||||
packages(&[
|
||||
("dever.database", ERROR),
|
||||
("dever.api", "public type Error { error Unauthorized }"),
|
||||
(
|
||||
"dever.auth",
|
||||
"public type Claims { subject: Text\nsession: Text\ntenant: Text?\nsite: Text }",
|
||||
),
|
||||
("demo.account.model", model),
|
||||
("demo.account.app", app),
|
||||
("demo.account.api", "public get health = app.health"),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_identity_is_a_compiled_app_entry_with_global_reads() {
|
||||
let program = checked("global type Account { subject: Text\nsession: Text }", APP).unwrap();
|
||||
let verifier = &program.verifiers[0];
|
||||
assert_eq!(verifier.name, "demo.account.verify");
|
||||
assert!(program.models[0].global);
|
||||
assert!(program.entries().any(|entry| entry == verifier.function));
|
||||
assert!(program.api_snapshot().contains("global=true"));
|
||||
assert_eq!(
|
||||
checked("type Account { subject: Text\nsession: Text }", APP)
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C014"
|
||||
);
|
||||
assert_eq!(
|
||||
packages(&[("main", "global type Value { number: Int }")])
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C006"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_cannot_write_or_replace_nominal_identity_with_permissions() {
|
||||
let model = "global type Account { subject: Text\nsession: Text }";
|
||||
let writing = APP.replace(
|
||||
"model.first({ where = subject == claims.subject and session == claims.session })",
|
||||
"model.create({ subject = claims.subject, session = claims.session })",
|
||||
);
|
||||
assert_eq!(checked(model, &writing).unwrap_err().code, "C014");
|
||||
let invalid = APP
|
||||
.replace("user_id: model.id?", "user_id: Int?")
|
||||
.replace("user_id = account.id", "user_id = 1");
|
||||
assert_eq!(checked(model, &invalid).unwrap_err().code, "C005");
|
||||
let invalid = APP
|
||||
.replace(
|
||||
"tenant_id: model.id?",
|
||||
"tenant_id: model.id?\npermissions: List<Text>",
|
||||
)
|
||||
.replace("tenant_id = null", "tenant_id = null, permissions = []");
|
||||
assert_eq!(checked(model, &invalid).unwrap_err().code, "C005");
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
use super::packages;
|
||||
|
||||
const ERROR: &str = "public type Error { error Invalid\nerror Unauthorized\nerror Forbidden }";
|
||||
const AUTH: &str = "public id() (value: Text) { value = dever.system.authId() }";
|
||||
|
||||
#[test]
|
||||
fn identity_cannot_escape_to_public_commands_domains_or_tasks() {
|
||||
for api in ["public get me = app.me", "cmd me = app.me"] {
|
||||
let error = packages(&[
|
||||
("dever.api", ERROR),
|
||||
("dever.auth", AUTH),
|
||||
(
|
||||
"demo.user.app",
|
||||
"me() (value: Text) { value = dever.auth.id() }",
|
||||
),
|
||||
("demo.user.api", api),
|
||||
])
|
||||
.unwrap_err();
|
||||
assert_eq!(error.code, "C014", "{api}: {error:?}");
|
||||
}
|
||||
for (package, source, code) in [
|
||||
(
|
||||
"demo.user.domain",
|
||||
"me() (value: Text) { value = dever.auth.id() }",
|
||||
"C014",
|
||||
),
|
||||
(
|
||||
"main",
|
||||
"main() (value: Text) { task = run(dever.auth.id())\nvalue = wait(task) }",
|
||||
"C013",
|
||||
),
|
||||
(
|
||||
"main",
|
||||
"main() (value: Text) { value = dever.system.authId() }",
|
||||
"C006",
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
packages(&[
|
||||
("dever.api", ERROR),
|
||||
("dever.auth", AUTH),
|
||||
(package, source)
|
||||
])
|
||||
.unwrap_err()
|
||||
.code,
|
||||
code
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_cannot_reenter_request_identity() {
|
||||
let error = packages(&[
|
||||
("dever.api", ERROR),
|
||||
("dever.auth", "public type Claims { subject: Text\nsession: Text\ntenant: Text?\nsite: Text }\npublic id() (value: Text) { value = dever.system.authId() }"),
|
||||
("demo.user.model", "global type User { name: Text }"),
|
||||
("demo.user.app", "type Identity { id: Text\nuser_id: model.id?\ntenant_id: model.id? }\nverify(claims: dever.auth.Claims) (value: Identity) { value = Identity { id = dever.auth.id(), user_id = null, tenant_id = null } }\nhealth() (value: Bool) { value = true }"),
|
||||
("demo.user.api", "public get health = app.health"),
|
||||
]).unwrap_err();
|
||||
assert_eq!(error.code, "C014");
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
use super::*;
|
||||
|
||||
fn rest(model: &str, domain: &str) -> Result<Program, Diagnostic> {
|
||||
packages(&[
|
||||
("dever.database", ERROR),
|
||||
("dever.api", "public type Error { error Unauthorized }"),
|
||||
(
|
||||
"dever.auth",
|
||||
"public type Claims { subject: Text\nsession: Text\ntenant: Text?\nsite: Text }",
|
||||
),
|
||||
(
|
||||
"text",
|
||||
"public trim(input: Text) (value: Text) pure { value = input }",
|
||||
),
|
||||
(
|
||||
"demo.account.model",
|
||||
"global type Account { subject: Text\nsession: Text }",
|
||||
),
|
||||
("demo.account.model.profile", model),
|
||||
("demo.account.domain", domain),
|
||||
("demo.account.app", APP),
|
||||
(
|
||||
"demo.account.api.admin.session",
|
||||
"public get health = app.health",
|
||||
),
|
||||
("demo.account.api.admin.profile", "rest model.profile"),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rest_wire_reuses_schema_proof_without_skipping_json_validation() {
|
||||
for (kind, decoder) in [
|
||||
("Text", "decode_fixed"),
|
||||
("Int?", "decode_fixed"),
|
||||
("Json", "decode"),
|
||||
("Json?", "decode"),
|
||||
] {
|
||||
let program = rest(&format!("type Profile {{ value: {kind} }}"), "").unwrap();
|
||||
let generated = dever::compiler::emit::application::generate(&program);
|
||||
assert!(!generated.contains("wire::decode_json("));
|
||||
for operation in ["Create", "Replace"] {
|
||||
assert!(
|
||||
generated.contains(&format!(
|
||||
"let args: Rest{operation}0 = input.decode(dever_runtime::wire::{decoder})"
|
||||
)),
|
||||
"{kind}/{operation}"
|
||||
);
|
||||
}
|
||||
if kind == "Json" {
|
||||
assert!(
|
||||
generated.contains("field0: Box<dever_runtime::wire::serde_json::value::RawValue>")
|
||||
);
|
||||
assert!(generated.contains("Json::from_validated_raw(decoded.field0)"));
|
||||
} else if kind == "Json?" {
|
||||
assert!(
|
||||
generated.contains(
|
||||
"field0: Option<Box<dever_runtime::wire::serde_json::value::RawValue>>"
|
||||
)
|
||||
);
|
||||
assert!(generated.contains(
|
||||
"decoded.field0.map(dever_runtime::wire::json::Json::from_validated_raw)"
|
||||
));
|
||||
} else {
|
||||
assert!(!generated.contains("Json::deserialize_validated"));
|
||||
assert!(!generated.contains("Json::deserialize_optional_validated"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rest_json_output_projects_only_public_fields() {
|
||||
let program = rest(
|
||||
"type Profile { value: Json\nprivate secret: Text create = \"hidden\"\nextra: Json? }",
|
||||
"",
|
||||
)
|
||||
.unwrap();
|
||||
let generated = dever::compiler::emit::application::generate(&program);
|
||||
let view = generated
|
||||
.split_once("impl RestView0<'_> {")
|
||||
.unwrap()
|
||||
.1
|
||||
.split_once("struct RestItems0")
|
||||
.unwrap()
|
||||
.0;
|
||||
assert!(view.contains("field2"));
|
||||
assert!(!view.contains("field3"));
|
||||
assert!(view.contains("field4"));
|
||||
assert!(!view.contains("secret"));
|
||||
assert!(!view.contains("serialize_field"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rest_json_defaults_keep_their_typed_decoder() {
|
||||
for (kind, value, decoder) in [
|
||||
("Json", r#""{}""#, "deserialize_validated"),
|
||||
("Json?", "null", "deserialize_optional_validated"),
|
||||
] {
|
||||
let program = rest(
|
||||
&format!("type Profile {{ value: {kind} default {value} }}"),
|
||||
"",
|
||||
)
|
||||
.unwrap();
|
||||
let generated = dever::compiler::emit::application::generate(&program);
|
||||
assert!(generated.contains(&format!("Json::{decoder}")));
|
||||
assert!(generated.contains("#[serde(default=\"rest_create_0_default0\")]"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rest_bindings_use_checked_functions_and_preserve_owner_identity() {
|
||||
let program = rest("type Profile { private owner user_id: model.id = dever.auth.userId()\nname: Text unique create = domain.normalize(input) replace = text.trim(input) search = input\nprivate source: Text create = \"source\" replace = \"replaced\" }","normalize(input: Text) (value: Text) pure { value = input }").unwrap();
|
||||
let entry = &program.rest[0];
|
||||
assert_eq!(entry.owner.as_ref().unwrap().position, 2);
|
||||
assert!(!entry.read.contains(&2));
|
||||
assert_eq!(
|
||||
entry.create.iter().filter(|binding| binding.input).count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(entry.search.as_ref().unwrap().position, 3);
|
||||
assert!(
|
||||
entry
|
||||
.functions()
|
||||
.all(|function| program.entries().any(|entry| entry == function))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rest_rejects_unsafe_field_sources_and_ambiguous_search() {
|
||||
for model in [
|
||||
"type Profile { private name: Text create = input }",
|
||||
"type Profile { owner id_value: Int? = dever.auth.userId()\nname: Text }",
|
||||
"type Profile { owner first_id: Int = dever.auth.userId()\nowner second_id: Int = dever.auth.tenantId()\nname: Text }",
|
||||
"type Profile { name: Text search = input }",
|
||||
"type Profile { name: Text index search = \"fixed\" }",
|
||||
"type Profile { name: Text index search = input\nother_name: Text index search = input }",
|
||||
"type Profile { private required: Text\nname: Text }",
|
||||
"type Profile { name: Text create = domain.normalize(input) }",
|
||||
] {
|
||||
let error = rest(
|
||||
model,
|
||||
if model.contains("domain.normalize") {
|
||||
"normalize(input: Text) (value: Text) { value = input }"
|
||||
} else {
|
||||
""
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(error.code, "C014", "{model}: {error:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rest_search_reserves_pagination_names_without_banning_storage_fields() {
|
||||
for name in ["page", "size"] {
|
||||
let model = format!("type Profile {{ {name}: Int index search = input }}");
|
||||
let error = rest(&model, "").unwrap_err();
|
||||
assert_eq!(error.code, "C014");
|
||||
assert_eq!(
|
||||
error.message,
|
||||
"REST search field cannot use reserved pagination names"
|
||||
);
|
||||
let model = format!("type Profile {{ {name}: Int index }}");
|
||||
assert!(
|
||||
rest(&model, "").is_ok(),
|
||||
"{name} remains a valid storage field"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rest_preserves_required_private_fields_with_only_a_create_source() {
|
||||
let program = rest(
|
||||
"type Profile { private created_by: Text create = \"creator\"\nname: Text }",
|
||||
"",
|
||||
)
|
||||
.unwrap();
|
||||
let entry = &program.rest[0];
|
||||
let source = entry
|
||||
.create
|
||||
.iter()
|
||||
.find(|binding| binding.position == 2)
|
||||
.unwrap();
|
||||
assert!(source.function.is_some());
|
||||
assert!(!source.input);
|
||||
assert!(!entry.read.contains(&2));
|
||||
assert!(entry.replace.iter().all(|binding| binding.position != 2));
|
||||
let error = rest(
|
||||
"type Profile { private created_by: Text replace = \"replacement\"\nname: Text }",
|
||||
"",
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(error.code, "C014");
|
||||
assert_eq!(
|
||||
error.message,
|
||||
"REST field is private and required without a source"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rest_field_metadata_does_not_escape_into_handler_contracts() {
|
||||
assert!(packages(&[("main","apply(callback: handler(value: Int create = input) (answer: Int)) (answer: Int) { answer = callback(1) }")]).is_err());
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
use super::{APP, ERROR};
|
||||
use dever::{
|
||||
compiler::{check, model::Program},
|
||||
syntax::{diagnostic::Diagnostic, source::Source},
|
||||
};
|
||||
|
||||
const API_ERROR: &str = "public type Error { error Invalid\nerror Unauthorized\nerror Forbidden\nerror NotFound\nerror Conflict\nerror TooManyRequests }";
|
||||
|
||||
fn checked(app: &str, api: &str) -> Result<Program, Diagnostic> {
|
||||
let app = format!("{APP}\n{app}");
|
||||
let api = format!("{api}\npublic get health = app.health");
|
||||
let inputs = [
|
||||
("dever.database", "database.dever", ERROR),
|
||||
("dever.api", "api.dever", API_ERROR),
|
||||
(
|
||||
"dever.auth",
|
||||
"values.dever",
|
||||
include_str!("../../../../lib/dever/auth/values.dever"),
|
||||
),
|
||||
(
|
||||
"dever.auth",
|
||||
"capability.dever",
|
||||
include_str!("../../../../lib/dever/auth/capability.dever"),
|
||||
),
|
||||
(
|
||||
"demo.account.model",
|
||||
"model.dever",
|
||||
"global type Account { subject: Text\nsession: Text }",
|
||||
),
|
||||
("demo.account.app", "app.dever", app.as_str()),
|
||||
("demo.account.api", "api.dever", api.as_str()),
|
||||
];
|
||||
let sources = inputs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, (_, path, text))| Source::new(index, *path, (*text).into()))
|
||||
.collect::<Vec<_>>();
|
||||
let modules = sources
|
||||
.iter()
|
||||
.map(dever::syntax::parse)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
check::check(
|
||||
modules,
|
||||
&inputs
|
||||
.iter()
|
||||
.map(|(name, _, _)| (*name).into())
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capability_operations_keep_original_types_and_write_effects() {
|
||||
let program = checked("", "").unwrap();
|
||||
for function in program.functions.iter().filter(|function| {
|
||||
matches!(
|
||||
function.signature.name.as_str(),
|
||||
"register_capability" | "remove_capability"
|
||||
)
|
||||
}) {
|
||||
let effects = function.signature.effects;
|
||||
assert!(effects.identity && effects.authorization_write && effects.database_write);
|
||||
}
|
||||
let error = checked(
|
||||
"register(id: Text) (key: Text) { key = dever.auth.registerCapability(id) }",
|
||||
"post register = app.register",
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(error.code, "C005");
|
||||
let error = checked(
|
||||
"register(id: Uuid) (value: Bool) { value = dever.auth.registerCapability(id) }",
|
||||
"post register = app.register",
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(error.code, "C005");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capability_writes_require_protected_http_and_keep_transaction_rules() {
|
||||
for api in [
|
||||
"public post register = app.register",
|
||||
"authenticated post register = app.register",
|
||||
"get register = app.register",
|
||||
"cmd register = app.register",
|
||||
"long post register = app.register",
|
||||
] {
|
||||
let error = checked(
|
||||
"register(id: Uuid) (key: Text) { key = dever.auth.registerCapability(id) }",
|
||||
api,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(error.code, "C014", "{api}: {error:?}");
|
||||
}
|
||||
let program = checked(
|
||||
"transaction register(id: Uuid) (key: Text) { key = dever.auth.registerCapability(id) }",
|
||||
"long post register = app.register",
|
||||
)
|
||||
.unwrap();
|
||||
assert_tenant_requirement(&program, true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protected_role_query_is_authenticated_read_only_and_not_a_tenant_selector() {
|
||||
let app =
|
||||
"protected(user_id: Int) (value: Bool) { value = dever.auth.hasProtectedRoles(user_id) }";
|
||||
for api in [
|
||||
"get protected = app.protected",
|
||||
"authenticated get protected = app.protected",
|
||||
] {
|
||||
let program = checked(app, api).unwrap();
|
||||
assert_tenant_requirement(&program, true);
|
||||
}
|
||||
for api in [
|
||||
"public get protected = app.protected",
|
||||
"cmd protected = app.protected",
|
||||
] {
|
||||
assert_eq!(checked(app, api).unwrap_err().code, "C014");
|
||||
}
|
||||
assert_eq!(checked(
|
||||
"protected(user_id: Int) (value: Bool) { value = dever.auth.hasProtectedRoles(user_id, 42) }",
|
||||
"get protected = app.protected",
|
||||
).unwrap_err().code, "C005");
|
||||
}
|
||||
|
||||
fn assert_tenant_requirement(program: &Program, required: bool) {
|
||||
let entry = program
|
||||
.http
|
||||
.iter()
|
||||
.find(|entry| entry.access != dever::compiler::model::auth::Access::Public)
|
||||
.unwrap();
|
||||
assert_eq!(program.tenant_access(entry.function).required, required);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protected_write_entries_cannot_replace_the_required_tenant() {
|
||||
for (app, api) in [
|
||||
(
|
||||
"register(id: Uuid) (key: Text) { key = dever.auth.registerCapability(id) }",
|
||||
"post register = app.register",
|
||||
),
|
||||
(
|
||||
"remove(id: Uuid) () { dever.auth.removeCapability(id) }",
|
||||
"delete remove = app.remove",
|
||||
),
|
||||
] {
|
||||
assert_tenant_requirement(&checked(app, api).unwrap(), true);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tenant_requirement_follows_reachable_calls_and_static_handlers() {
|
||||
for (app, required) in [
|
||||
(
|
||||
"register(id: Uuid) (key: Text) { key = helper(id) }\nhelper(id: Uuid) (key: Text) { key = dever.auth.registerCapability(id) }",
|
||||
true,
|
||||
),
|
||||
(
|
||||
"register(id: Uuid) (key: Text) { key = apply(dever.auth.registerCapability, id) }\napply(action: handler(id: Uuid) (key: Text), id: Uuid) (key: Text) { key = action(id) }",
|
||||
true,
|
||||
),
|
||||
] {
|
||||
assert_tenant_requirement(
|
||||
&checked(app, "post register = app.register").unwrap(),
|
||||
required,
|
||||
);
|
||||
}
|
||||
let error = checked(
|
||||
"register(id: Uuid) (key: Text) { key = \"unused\" }\nunused(id: Uuid) (key: Text) { key = dever.auth.registerCapability(id) }",
|
||||
"post register = app.register",
|
||||
).unwrap_err();
|
||||
assert_eq!(error.code, "C016");
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
use dever::compiler::{check, model::Program};
|
||||
use dever::syntax::{diagnostic::Diagnostic, source::Source};
|
||||
|
||||
fn linked(text: &str) -> Result<Program, Diagnostic> {
|
||||
let sources = sources(text);
|
||||
let modules = sources
|
||||
.iter()
|
||||
.map(dever::syntax::parse)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
check::check(
|
||||
modules,
|
||||
&["main".into(), "dever.bytes".into(), "text".into()],
|
||||
)
|
||||
}
|
||||
|
||||
fn sources(text: &str) -> [Source; 3] {
|
||||
[
|
||||
Source::new(0, "main.dever", text.into()),
|
||||
Source::new(
|
||||
1,
|
||||
"lib/dever/bytes.dever",
|
||||
include_str!("../../../lib/dever/bytes.dever").into(),
|
||||
),
|
||||
Source::new(
|
||||
2,
|
||||
"lib/text.dever",
|
||||
include_str!("../../../lib/text.dever").into(),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn generated_entry(text: &str) -> String {
|
||||
let mut program = linked(text).unwrap();
|
||||
let entry = program
|
||||
.functions
|
||||
.iter()
|
||||
.position(|function| function.signature.name == "main")
|
||||
.unwrap();
|
||||
// 改名不改变内建操作身份,防止优化依赖 concat/from_text 的函数名称。
|
||||
for (index, function) in program.functions.iter_mut().enumerate() {
|
||||
function.signature.name = format!("renamed_{index}");
|
||||
}
|
||||
let generated = dever::compiler::emit::generate(&program, &sources(text), &[entry].into());
|
||||
let start = generated.find(&format!("pub fn function{entry}(")).unwrap();
|
||||
generated[start..]
|
||||
.split("\npub ")
|
||||
.next()
|
||||
.unwrap()
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn borrowed_bytes_keep_their_shared_owner() {
|
||||
let generated = generated_entry("main(bytes: Bytes) (answer: Bytes) { answer = bytes }");
|
||||
assert!(
|
||||
generated.contains("arg0: &dever_runtime::Bytes"),
|
||||
"{generated}"
|
||||
);
|
||||
assert!(generated.contains(".to_owned()"), "{generated}");
|
||||
assert!(!generated.contains("copy_from_slice"), "{generated}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_byte_views_are_limited_to_non_escaping_iteration() {
|
||||
let generated = generated_entry(
|
||||
"add(item: Int, total: Int) (next: Int) { next = total + item }\nmain(text: Text) (answer: Int) { bytes = dever.bytes.fromText(text)\nanswer = reduce(add, bytes, 0) }",
|
||||
);
|
||||
assert!(generated.contains(".as_bytes()"), "{generated}");
|
||||
assert!(!generated.contains("copy_from_slice"), "{generated}");
|
||||
let generated = generated_entry(
|
||||
"add(item: Int, total: Int) (next: Int) { next = total + item }\nmain(text: Text) (answer: Int) { bytes = dever.bytes.fromText(text)\nanswer = reduce(add, bytes, 0) + length(bytes) }",
|
||||
);
|
||||
assert!(!generated.contains("copy_from_slice"), "{generated}");
|
||||
for body in [
|
||||
"main(text: Text) (answer: Bytes) { bytes = dever.bytes.fromText(text)\nanswer = bytes }",
|
||||
"identity(bytes: Bytes) (answer: Bytes) { answer = bytes }\nmain(text: Text) (answer: Bytes) { bytes = dever.bytes.fromText(text)\nanswer = identity(bytes) }",
|
||||
] {
|
||||
let generated = generated_entry(body);
|
||||
assert!(generated.contains("copy_from_slice"), "{generated}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_separator_parts_are_fused_only_when_fully_concatenated() {
|
||||
let join = "joinPart(item: Bytes, state: Bytes) (answer: Bytes) { answer = dever.bytes.concat(state, item) }\n";
|
||||
let generated = generated_entry(
|
||||
&(join.to_owned()
|
||||
+ "main(text: Text, seed: Bytes) (answer: Bytes) { parts = each(dever.bytes.fromText, text.split(text, \"\"))\nanswer = reduce(joinPart, parts, seed) }"),
|
||||
);
|
||||
assert!(
|
||||
generated.contains("state.extend_from_slice(sequence)"),
|
||||
"{generated}"
|
||||
);
|
||||
assert!(!generated.contains("copy_from_slice"), "{generated}");
|
||||
assert!(!generated.contains("collect::<Vec<_>>()"), "{generated}");
|
||||
for body in [
|
||||
"main(text: Text) (answer: List<Bytes>) { parts = each(dever.bytes.fromText, text.split(text, \"\"))\nanswer = parts }",
|
||||
"main(text: Text, seed: Bytes) (answer: Bytes, count: Int) { parts = each(dever.bytes.fromText, text.split(text, \"\"))\ncount = length(parts)\nanswer = reduce(joinPart, parts, seed) }",
|
||||
"main(text: Text, seed: Bytes) (answer: Bytes) { parts = each(dever.bytes.fromText, text.split(text, \",\"))\nanswer = reduce(joinPart, parts, seed) }",
|
||||
"main(text: Text, separator: Text, seed: Bytes) (answer: Bytes) { parts = each(dever.bytes.fromText, text.split(text, separator))\nanswer = reduce(joinPart, parts, seed) }",
|
||||
] {
|
||||
let generated = generated_entry(&(join.to_owned() + body));
|
||||
assert!(generated.contains("copy_from_slice"), "{generated}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_text_concat_allocates_only_the_final_buffer() {
|
||||
for literal in [r#""""#, r#""prefix""#, r#""雪\u{0}🙂""#] {
|
||||
let generated = generated_entry(&format!(
|
||||
"main(suffix: Bytes) (answer: Bytes) {{ answer = dever.bytes.concat(dever.bytes.fromText({literal}), suffix) }}"
|
||||
));
|
||||
assert!(
|
||||
generated.contains("value::bytes::concat(prefix, suffix)"),
|
||||
"{generated}"
|
||||
);
|
||||
assert!(!generated.contains(".to_vec()"), "{generated}");
|
||||
assert!(!generated.contains("extend_from_slice"), "{generated}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_text_and_owned_byte_accumulators_keep_their_existing_paths() {
|
||||
for (source, operation) in [
|
||||
(
|
||||
"main(text: Text, suffix: Bytes) (answer: Bytes) { answer = dever.bytes.concat(dever.bytes.fromText(text), suffix) }",
|
||||
"Bytes::copy_from_slice",
|
||||
),
|
||||
(
|
||||
"main(suffix: Bytes) (answer: Bytes) { text = \"owned\"\n answer = dever.bytes.concat(dever.bytes.fromText(text), suffix) }",
|
||||
".into_bytes()",
|
||||
),
|
||||
(
|
||||
"main(suffix: Bytes) (answer: Bytes) { bytes = dever.bytes.fromText(\"owned\")\n answer = dever.bytes.concat(bytes, suffix) }",
|
||||
"bytes.extend_from_slice(suffix)",
|
||||
),
|
||||
] {
|
||||
let generated = generated_entry(source);
|
||||
assert!(generated.contains(operation), "{generated}");
|
||||
assert!(
|
||||
!generated.contains("value::bytes::concat(prefix, suffix)"),
|
||||
"{generated}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bytes_keep_their_declared_collection_contract() {
|
||||
for source in [
|
||||
"identity(item: Int) (answer: Int) { answer = item }\nmain(bytes: Bytes) (answer: List<Int>) { answer = each(identity, bytes) }",
|
||||
"add(item: Int, state: Int) (next: Int) { next = item + state }\nmain(bytes: Bytes) (answer: Int) { answer = reduce(add, bytes, 0) }",
|
||||
"main(values: List<Text>) (answer: List<Bytes>) { answer = each(dever.bytes.fromText, values) }",
|
||||
"main(bytes: Bytes?) (answer: Bytes?) { answer = bytes }",
|
||||
] {
|
||||
let result = linked(source);
|
||||
assert!(result.is_ok(), "{source}: {result:?}");
|
||||
}
|
||||
for source in [
|
||||
"main(bytes: Bytes) (answer: Int) { answer = sum(bytes) }",
|
||||
"identity(item: Int) (answer: Int) { answer = item }\nmain(bytes: Bytes) (answer: Int) { answer = sum(identity, bytes) }",
|
||||
"yes(item: Int) (answer: Bool) { answer = true }\nmain(bytes: Bytes) (answer: List<Int>) { answer = filter(yes, bytes) }",
|
||||
"yes(item: Int) (answer: Bool) { answer = true }\nmain(bytes: Bytes) (answer: Int?) { answer = find(yes, bytes) }",
|
||||
"identity(item: Text) (answer: Text) { answer = item }\nmain(bytes: Bytes) (answer: List<Text>) { answer = each(identity, bytes) }",
|
||||
"main(bytes: Bytes) (answer: List<Int>) { answer = bytes }",
|
||||
"main(values: List<Int>) (answer: Bytes) { answer = values }",
|
||||
"main(values: Map<Bytes, Int>) () {}",
|
||||
] {
|
||||
assert_eq!(linked(source).unwrap_err().code, "C005", "{source}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buffer_reductions_append_without_copying_the_accumulator() {
|
||||
for (kind, body, method) in [
|
||||
("Text", "state + item", "push_str"),
|
||||
(
|
||||
"Bytes",
|
||||
"dever.bytes.concat(state, item)",
|
||||
"extend_from_slice",
|
||||
),
|
||||
] {
|
||||
let source = format!(
|
||||
"appendPiece(item: {kind}, state: {kind}) (answer: {kind}) {{ answer = {body} }}\nmain(values: List<{kind}>, initial: {kind}) (answer: {kind}) {{ answer = reduce(appendPiece, values, initial) }}"
|
||||
);
|
||||
let generated = generated_entry(&source);
|
||||
let iteration = generated.split("for item in sequence").nth(1).unwrap();
|
||||
assert!(
|
||||
iteration.contains(&format!("state.{method}(item)")),
|
||||
"{generated}"
|
||||
);
|
||||
for copy in [".clone()", ".to_owned()", ".to_vec()"] {
|
||||
assert!(!iteration.contains(copy), "{generated}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_buffer_seeds_do_not_roundtrip_through_immutable_bytes() {
|
||||
for literal in [r#""""#, r#""seed""#, r#""雪\u{0}🙂""#] {
|
||||
let generated = generated_entry(&format!(
|
||||
"appendPiece(item: Bytes, state: Bytes) (answer: Bytes) {{ answer = dever.bytes.concat(state, item) }}\nmain(values: List<Bytes>) (answer: Bytes) {{ answer = reduce(appendPiece, values, dever.bytes.fromText({literal})) }}"
|
||||
));
|
||||
assert!(generated.contains("let mut state: Vec<u8>"), "{generated}");
|
||||
assert!(!generated.contains("Bytes::from_static"), "{generated}");
|
||||
assert!(!generated.contains("Vec::<u8>::from"), "{generated}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_helpers_and_intrinsics_are_private() {
|
||||
for source in [
|
||||
"main() (answer: Bytes) { answer = dever.system.bytesFromText(\"x\") }",
|
||||
"main() (answer: Bytes) { answer = dever.bytes.ready(dever.bytes.BufferResult.Ready(dever.bytes.fromText(\"x\"))) }",
|
||||
"main() (answer: Text) { answer = dever.bytes.decoded(dever.bytes.DecodeResult.Decoded(\"x\")) }",
|
||||
] {
|
||||
assert_eq!(linked(source).unwrap_err().code, "C006", "{source}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn byte_errors_keep_nominal_identity_and_obligations() {
|
||||
assert_eq!(
|
||||
linked("main(bytes: Bytes) () { captured = result(dever.bytes.toText(bytes)) }")
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C012"
|
||||
);
|
||||
assert_eq!(linked("type Result { Decoded(text: Text)\n error Failed(message: Text) }\nmain(bytes: Bytes) (answer: Result) { answer = result(dever.bytes.toText(bytes)) }").unwrap_err().code, "C005");
|
||||
assert!(linked("main(bytes: Bytes) (answer: dever.bytes.DecodeResult) { answer = result(dever.bytes.toText(bytes)) }").is_ok());
|
||||
assert!(linked("type Result { Ready(text: Text)\n error Failed(message: Text) }\nidentity() (text: Text) { text = \"ok\" }\nmain() (answer: Result) { captured = result(identity())\n answer = captured }").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_names_resolve_without_exposing_private_types() {
|
||||
let library = Source::new(1, "sample.dever", "type Hidden { value: Int }\npublic type Visible { value: Int }\npublic read(value: Int) (answer: Int) { answer = value }".into());
|
||||
for (source, accepted) in [
|
||||
(
|
||||
"main() (answer: sample.Visible) { answer = sample.Visible { value = sample.read(1) } }",
|
||||
true,
|
||||
),
|
||||
("main(value: sample.Hidden) () {}", false),
|
||||
("main() () { value = sample.Hidden { value = 1 } }", false),
|
||||
(
|
||||
"main() (answer: Visible) { answer = sample.Visible { value = 1 } }",
|
||||
false,
|
||||
),
|
||||
] {
|
||||
let source = Source::new(0, "main.dever", source.into());
|
||||
let result = check::check(
|
||||
vec![
|
||||
dever::syntax::parse(&source).unwrap(),
|
||||
dever::syntax::parse(&library).unwrap(),
|
||||
],
|
||||
&["main".into(), "sample".into()],
|
||||
);
|
||||
assert_eq!(result.is_ok(), accepted, "{result:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
use dever::{
|
||||
compiler::{build, check, model::Program},
|
||||
syntax::{diagnostic::Diagnostic, source::Source},
|
||||
};
|
||||
|
||||
fn linked(text: &str) -> Result<Program, Diagnostic> {
|
||||
let inputs = [
|
||||
("main", text),
|
||||
("dever.time", include_str!("../../../lib/dever/time.dever")),
|
||||
(
|
||||
"dever.time",
|
||||
include_str!("../../../lib/dever/time/calendar.dever"),
|
||||
),
|
||||
(
|
||||
"dever.time",
|
||||
include_str!("../../../lib/dever/time/operations.dever"),
|
||||
),
|
||||
(
|
||||
"dever.time",
|
||||
include_str!("../../../lib/dever/time/clock.dever"),
|
||||
),
|
||||
];
|
||||
let modules = inputs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, (_, text))| {
|
||||
dever::syntax::parse(&Source::new(
|
||||
index,
|
||||
if index == 0 {
|
||||
"main.dever".into()
|
||||
} else {
|
||||
format!("library{index}.dever")
|
||||
},
|
||||
(*text).into(),
|
||||
))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
check::check(
|
||||
modules,
|
||||
&inputs
|
||||
.iter()
|
||||
.map(|(package, _)| (*package).into())
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calendar_types_are_distinct_and_only_clock_reads_have_effects() {
|
||||
let program = linked("main() (answer: Text) pure { instant = dever.time.parseDatetime(\"1970-01-01T00:00:00Z\")\n answer = dever.time.formatDatetime(dever.time.add(instant, dever.time.duration(1))) }").unwrap();
|
||||
let main = program
|
||||
.functions
|
||||
.iter()
|
||||
.position(|function| function.signature.name == "main")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
program.functions[main].signature.effects,
|
||||
Default::default()
|
||||
);
|
||||
assert_eq!(
|
||||
build::runtime_features(&program, &program.reachable([main])),
|
||||
["calendar"]
|
||||
);
|
||||
let clocks = linked("main() (value: Int) { value = dever.time.unixMillis() }").unwrap();
|
||||
let main = clocks
|
||||
.functions
|
||||
.iter()
|
||||
.position(|function| function.signature.name == "main")
|
||||
.unwrap();
|
||||
assert!(clocks.functions[main].signature.effects.clock);
|
||||
assert!(!clocks.functions[main].signature.effects.timer);
|
||||
assert!(!clocks.functions[main].signature.effects.suspend);
|
||||
assert!(build::runtime_features(&clocks, &clocks.reachable([main])).is_empty());
|
||||
for (source, code) in [
|
||||
("main() (value: DateTime) { value = 0 }", "C005"),
|
||||
(
|
||||
"main(value: DateTime) (answer: DateTime) { answer = dever.time.add(value, 1) }",
|
||||
"C005",
|
||||
),
|
||||
(
|
||||
"main(value: DateTime) (answer: Bool) { answer = value < value }",
|
||||
"C005",
|
||||
),
|
||||
(
|
||||
"main() (value: DateTime) pure { value = dever.time.now() }",
|
||||
"C013",
|
||||
),
|
||||
("main() () { value = dever.system.timeDuration(1) }", "C006"),
|
||||
(
|
||||
"main() () { lost = result(dever.time.parseDate(\"bad\")) }",
|
||||
"C012",
|
||||
),
|
||||
] {
|
||||
assert_eq!(linked(source).unwrap_err().code, code, "{source}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
use super::checked;
|
||||
|
||||
#[test]
|
||||
fn unconstrained_parameters_do_not_expand_clause_products() {
|
||||
let inputs = (0..40)
|
||||
.map(|position| format!("flag{position}: Bool"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let body = (0..40)
|
||||
.map(|position| format!("flag{position}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" or ");
|
||||
let ordinary = format!("choose({inputs}) (answer: Bool) {{ answer = {body} }}\n");
|
||||
assert!(checked(&ordinary).is_ok());
|
||||
let clause = |pattern: &str| {
|
||||
format!("choose(test: {pattern}, {inputs}) (answer: Bool) {{ answer = {body} }}\n")
|
||||
};
|
||||
assert!(checked(&(clause("true") + &clause("false"))).is_ok());
|
||||
assert_eq!(checked(&clause("true")).unwrap_err().code, "C009");
|
||||
assert_eq!(
|
||||
checked(&(clause("Bool") + &clause("false")))
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C009"
|
||||
);
|
||||
assert!(checked("type State { One\n Two }\nvalue(input: State, optional: Bool?) (answer: Bool) { answer = optional == null }").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choice_coverage_and_payload_contracts() {
|
||||
let prefix = "type State { Idle\n Working(value: Int) }\n";
|
||||
let valid = "read(value: State.Idle) (answer: Int) { answer = 0 }\nread(value: State.Working(number)) (answer: Int) { answer = number }";
|
||||
assert!(checked(&(prefix.to_owned() + valid)).is_ok());
|
||||
for (body, code) in [
|
||||
(
|
||||
"read(value: State.Idle) (answer: Int) { answer = 0 }",
|
||||
"C009",
|
||||
),
|
||||
(
|
||||
"read(value: State) (answer: Int) { answer = 0 }\nread(value: State.Idle) (answer: Int) { answer = 1 }",
|
||||
"C009",
|
||||
),
|
||||
(
|
||||
"read(value: State.Working) (answer: Int) { answer = 0 }",
|
||||
"C005",
|
||||
),
|
||||
(
|
||||
"read(value: State.Working(first, second)) (answer: Int) { answer = first }",
|
||||
"C005",
|
||||
),
|
||||
(
|
||||
"read(value: State.Working(value)) (answer: Int) { answer = 0 }",
|
||||
"C009",
|
||||
),
|
||||
(
|
||||
"main() (answer: State) { answer = State.Working() }",
|
||||
"C005",
|
||||
),
|
||||
(
|
||||
"main() (answer: State) { answer = State.Working(true) }",
|
||||
"C005",
|
||||
),
|
||||
(
|
||||
"read(value: State) (answer: Int) { answer = value.value }",
|
||||
"C005",
|
||||
),
|
||||
] {
|
||||
let error = checked(&(prefix.to_owned() + body)).unwrap_err();
|
||||
assert_eq!(error.code, code, "{body}: {error:?}");
|
||||
}
|
||||
let wildcard = "read(value: State.Working(_)) (answer: Int) { answer = 1 }\nread(value: other) (answer: Int) { answer = 0 }";
|
||||
assert!(checked(&(prefix.to_owned() + wildcard)).is_ok());
|
||||
assert_eq!(
|
||||
checked("type Cycle { Next(value: Cycle) }")
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C007"
|
||||
);
|
||||
assert_eq!(
|
||||
checked("type State { Item(value: Row) }\ntype Row { state: State }")
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C007"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nullable_domains_and_complements_are_order_independent() {
|
||||
for clauses in [
|
||||
"read(value: Text) (answer: Int) { answer = length(value) }\nread(value: null) (answer: Int) { answer = -1 }",
|
||||
"read(value: null) (answer: Int) { answer = -1 }\nread(value: Text) (answer: Int) { answer = length(value) }",
|
||||
"read(value: other) (answer: Int) { answer = length(value) }\nread(value: null) (answer: Int) { answer = -1 }\nread(value: \"x\") (answer: Int) { answer = 1 }",
|
||||
] {
|
||||
assert!(checked(clauses).is_ok(), "{clauses}");
|
||||
}
|
||||
for clauses in [
|
||||
"read(value: Text?) (answer: Int) { answer = 0 }\nread(value: null) (answer: Int) { answer = 1 }",
|
||||
"read(value: \"x\") (answer: Int) { answer = 0 }\nread(value: null) (answer: Int) { answer = 1 }",
|
||||
] {
|
||||
assert_eq!(checked(clauses).unwrap_err().code, "C009");
|
||||
}
|
||||
assert!(checked("equal(value: Int?) (answer: Bool) { answer = value == null or null == value or value == 1 or 1 == value }").is_ok());
|
||||
assert!(checked("number() (answer: Decimal?) { answer = 1 }").is_ok());
|
||||
assert!(checked("number() (answer: Float?) { answer = 1.5 + 2.5 }").is_ok());
|
||||
assert!(checked("values() (answer: List<Int>?) { answer = [] }").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payload_bindings_do_not_shadow_inputs_or_outputs() {
|
||||
for binding in ["value", "answer"] {
|
||||
let text = format!(
|
||||
"type State {{ Item(number: Int) }}\nread(value: State.Item({binding})) (answer: Int) {{ answer = 1 }}"
|
||||
);
|
||||
assert_eq!(checked(&text).unwrap_err().code, "C002");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
use super::checked;
|
||||
|
||||
const HANDLERS: &str = "double(value: Int) (answer: Int) { answer = value * 2 }\npositive(value: Int) (answer: Bool) { answer = value > 0 }\nadd(value: Int, state: Int) (next: Int) { next = value + state }\nstop(value: Int, state: Int) (next: Int, stop: Bool) { next = value + state\n stop = next >= 3 }\n";
|
||||
const ERRORS: &str = "type Result { Ready(value: Int)\n error Failed(message: Text) }\nidentity(value: Result) (answer: Result) { answer = value }\nhandled(value: Result) (answer: Bool) recover(\"optional probe failure is explicitly discarded\") { answer = true }\ncontext(value: Int, state: Result) () recover(\"optional context probe is discarded\") {}\nkeep(value: Int, state: Result) (next: Result) { next = state }\nconsume(value: Result, state: Int) (next: Int) recover(\"count failed probes\") { next = state + 1 }\npartial(value: Result, state: Int) (next: Int, stop: Bool) recover(\"count failed probes\") { next = state + 1\n stop = true }\n";
|
||||
|
||||
#[test]
|
||||
fn checks_collection_handler_types_and_cycles() {
|
||||
for body in [
|
||||
"main() (answer: List<Int>) { answer = each(double, []) }",
|
||||
"main() (answer: List<Int>) { answer = filter(positive, [1, -1]) }",
|
||||
"main() (answer: Int?) { answer = find(positive, []) }",
|
||||
"main() (answer: Int) { answer = sum(double, []) }",
|
||||
"main() (answer: Float) { answer = sum([]) }",
|
||||
"main() (answer: Decimal?) { answer = sum([]) }",
|
||||
"main() (answer: Int) { answer = reduce(add, [], 10) }",
|
||||
"main() (answer: Int) { answer = reduceUntil(stop, [1, 2, 3], 0) }",
|
||||
] {
|
||||
let result = checked(&(HANDLERS.to_owned() + body));
|
||||
assert!(result.is_ok(), "{body}: {result:?}");
|
||||
}
|
||||
for body in [
|
||||
"main() (answer: List<Int>) { answer = each(positive, [1]) }",
|
||||
"main() (answer: List<Int>) { answer = filter(double, [1]) }",
|
||||
"main() (answer: Int?) { answer = find(positive, [\"one\"]) }",
|
||||
"main() (answer: Int) { answer = sum(positive, [1]) }",
|
||||
"main() (answer: Int) { answer = reduce(add, [1], true) }",
|
||||
"main() (answer: Int) { answer = reduceUntil(add, [1], 0) }",
|
||||
"main() (answer: Int) { answer = reduce(add, [1], 0, 1) }",
|
||||
"main() (answer: List<Int>) { double = 2\n answer = each(double, [1]) }",
|
||||
"main() () { handler = double }",
|
||||
] {
|
||||
assert!(checked(&(HANDLERS.to_owned() + body)).is_err(), "{body}");
|
||||
}
|
||||
assert_eq!(
|
||||
checked("cycle(value: Int) (answer: Int) { answer = sum(cycle, [value]) }")
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C007"
|
||||
);
|
||||
assert_eq!(checked("first(value: Int) (answer: Int) { answer = sum(second, [value]) }\nsecond(value: Int) (answer: Int) { answer = first(value) }").unwrap_err().code, "C007");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collections_preserve_error_obligations() {
|
||||
for body in [
|
||||
"main(values: List<Result>) (answer: Int) { copies = each(identity, values)\n answer = length(copies) }",
|
||||
"main(values: List<Result>) (answer: Result?) { answer = find(handled, values) }",
|
||||
"main(values: List<Result>) (answer: Int) { answer = reduceUntil(partial, values, 0) }",
|
||||
"main() (answer: Int) { answer = reduceUntil(partial, [Result.Failed(\"one\"), Result.Failed(\"two\")], 0) }",
|
||||
"main(state: Result) () { each(context, [], state) }",
|
||||
"main(values: List<Int>, state: Result) () { each(context, values, state) }",
|
||||
"main(state: Result) (answer: Int) { kept = reduce(keep, [], state)\n answer = 1 }",
|
||||
"main() (answer: List<Result>) { lost = Result.Failed(\"lost\")\n answer = each(identity, []) }",
|
||||
] {
|
||||
let result = checked(&(ERRORS.to_owned() + body));
|
||||
assert_eq!(result.unwrap_err().code, "C012", "{body}");
|
||||
}
|
||||
for body in [
|
||||
"main(values: List<Result>) (answer: List<Result>) { answer = each(identity, values) }",
|
||||
"main(values: List<Result>) (answer: Int) { answer = reduce(consume, values, 0) }",
|
||||
"main(state: Result) () { each(context, [1], state) }",
|
||||
"main() (answer: Result?) { answer = find(handled, [Result.Failed(\"one\")]) }",
|
||||
"main() (answer: Int) { answer = reduceUntil(partial, [Result.Failed(\"one\")], 0) }",
|
||||
"main(state: Result) (answer: Result) { answer = reduce(keep, [], state) }",
|
||||
] {
|
||||
let result = checked(&(ERRORS.to_owned() + body));
|
||||
assert!(result.is_ok(), "{body}: {result:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collection_error_summaries_survive_calls_and_repeated_state() {
|
||||
let prefix = ERRORS.to_owned()
|
||||
+ "copy(values: List<Result>) (answer: List<Result>) { answer = each(identity, values) }\nreplace(value: Result, state: Result) (next: Result) recover(\"last probe replaces previous probe\") { next = value }\n";
|
||||
for body in [
|
||||
"main(values: List<Result>) (answer: Int) { copies = copy(values)\n answer = length(copies) }",
|
||||
"main(values: List<Result>, state: Result) (answer: Int) { last = reduce(replace, values, state)\n answer = 1 }",
|
||||
"main(state: Result) () { copies = copy([])\n count = length(copies) }",
|
||||
] {
|
||||
assert_eq!(
|
||||
checked(&(prefix.clone() + body)).unwrap_err().code,
|
||||
"C012",
|
||||
"{body}"
|
||||
);
|
||||
}
|
||||
let source = prefix
|
||||
+ "main(values: List<Result>, state: Result) (answer: Result) { answer = reduce(replace, values, state) }";
|
||||
assert!(checked(&source).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optional_traversal_cannot_transfer_unvisited_obligations() {
|
||||
let prefix = ERRORS.to_owned()
|
||||
+ "takeContext(value: Int, context: Result) (answer: Result) { answer = context }\nfirst(value: Result, state: Result) (next: Result, stop: Bool) recover(\"replace previous state\") { next = value\n stop = true }\n";
|
||||
for body in [
|
||||
"main(values: List<Int>, state: Result) (answer: List<Result>) { answer = each(takeContext, values, state) }",
|
||||
"main(values: List<Result>) (answer: Result) { answer = reduceUntil(first, values, Result.Ready(0)) }",
|
||||
] {
|
||||
assert_eq!(
|
||||
checked(&(prefix.clone() + body)).unwrap_err().code,
|
||||
"C012",
|
||||
"{body}"
|
||||
);
|
||||
}
|
||||
assert!(checked(&(prefix + "main(state: Result) (answer: List<Result>) { answer = each(takeContext, [1], state) }")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacing_record_fields_still_checks_discarded_failures() {
|
||||
let source = ERRORS.to_owned()
|
||||
+ "type Row { result: Result\n count: Int }\nreplace(value: Row) (answer: Row) { answer = value\n answer.result = Result.Ready(1)\n answer.count = 0 }";
|
||||
assert_eq!(checked(&source).unwrap_err().code, "C012");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_length_map_builds_reserve_once_and_update_in_place() {
|
||||
for (kind, item, value, sequence) in [
|
||||
("Bytes", "Int", "value", "values"),
|
||||
("List<Int>", "Int", "value", "values"),
|
||||
(
|
||||
"Map<Int, Int>",
|
||||
"MapEntry<Int, Int>",
|
||||
"value.value",
|
||||
"entries(values)",
|
||||
),
|
||||
] {
|
||||
let text = format!(
|
||||
"insert(value: {item}, state: Map<Int, Int>) (next: Map<Int, Int>) {{ next = put(state, length(state), {value}) }}\nmain(values: {kind}) (answer: Map<Int, Int>) {{ answer = reduce(insert, {sequence}, {{}}) }}"
|
||||
);
|
||||
let source = dever::syntax::source::Source::new(0, "case.dever", text);
|
||||
let program = checked(&source.text).unwrap();
|
||||
let generated = dever::compiler::emit::generate(&program, &[source], &[1].into());
|
||||
let main = generated.split("pub fn function1(").nth(1).unwrap();
|
||||
let main = main.split("pub fn ").next().unwrap();
|
||||
assert!(
|
||||
main.contains("Map::with_capacity(sequence.len())"),
|
||||
"{main}"
|
||||
);
|
||||
assert!(main.contains("state.insert("), "{main}");
|
||||
assert!(!main.contains("state.clone()"), "{main}");
|
||||
assert!(!main.contains(".to_owned()"), "{main}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_reductions_keep_a_single_growing_buffer() {
|
||||
let text = "type State { values: List<Int>\n count: Int }\nstep(value: Int, state: State) (next: State) { values = append(state.values, value)\n next = State { values = values\n count = state.count + 1 } }\nmain(input: Bytes) (answer: State) { answer = reduce(step, input, State { values = []\n count = 0 }) }";
|
||||
let source = dever::syntax::source::Source::new(0, "case.dever", text.into());
|
||||
let program = checked(text).unwrap();
|
||||
let generated = dever::compiler::emit::generate(&program, &[source], &[1].into());
|
||||
let main = generated.split("pub fn function1(").nth(1).unwrap();
|
||||
let main = main.split("\npub ").next().unwrap();
|
||||
assert!(!main.contains(".clone()"), "{main}");
|
||||
let iteration = main.split("for byte in sequence").nth(1).unwrap();
|
||||
let iteration = iteration.split("Type0 {").next().unwrap();
|
||||
assert!(!iteration.contains(".reserve("), "{main}");
|
||||
assert!(iteration.contains(".push(element)"), "{main}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_selection_copies_only_matching_entries() {
|
||||
for (operation, result) in [
|
||||
("filter", "List<MapEntry<Text, Text>>"),
|
||||
("find", "MapEntry<Text, Text>?"),
|
||||
] {
|
||||
let text = format!(
|
||||
"matches(entry: MapEntry<Text, Text>, target: Text) (answer: Bool) {{ answer = entry.value == target }}\nmain(values: Map<Text, Text>, target: Text) (answer: {result}) {{ answer = {operation}(matches, entries(values), target) }}"
|
||||
);
|
||||
let source = dever::syntax::source::Source::new(0, "case.dever", text.clone());
|
||||
let program = checked(&text).unwrap();
|
||||
let generated = dever::compiler::emit::generate(&program, &[source], &[1].into());
|
||||
let main = generated.split("pub fn function1(").nth(1).unwrap();
|
||||
let main = main.split("\npub ").next().unwrap();
|
||||
let iteration = main.split("for (key, value) in sequence").nth(1).unwrap();
|
||||
let (inspection, selected) = iteration.split_once("if ").unwrap();
|
||||
assert!(!inspection.contains(".clone()"), "{main}");
|
||||
assert!(selected.contains("(key.clone(), value.clone())"), "{main}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
use dever::{
|
||||
compiler::{check, model::Program},
|
||||
syntax::{diagnostic::Diagnostic, source::Source},
|
||||
};
|
||||
|
||||
fn linked(text: &str) -> Result<Program, Diagnostic> {
|
||||
let inputs = [
|
||||
("main", text),
|
||||
(
|
||||
"dever.crypto",
|
||||
include_str!("../../../lib/dever/crypto.dever"),
|
||||
),
|
||||
("dever.time", include_str!("../../../lib/dever/time.dever")),
|
||||
("dever.task", include_str!("../../../lib/dever/task.dever")),
|
||||
];
|
||||
let modules = inputs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, (_, text))| {
|
||||
dever::syntax::parse(&Source::new(
|
||||
index,
|
||||
if index == 0 {
|
||||
"main.dever".into()
|
||||
} else {
|
||||
format!("library{index}.dever")
|
||||
},
|
||||
(*text).into(),
|
||||
))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
check::check(
|
||||
modules,
|
||||
&inputs
|
||||
.iter()
|
||||
.map(|(package, _)| (*package).into())
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secrets_cannot_be_compared_converted_or_hidden_in_error_payloads() {
|
||||
for source in [
|
||||
"main(value: Secret) (answer: Bool) { answer = value == value }",
|
||||
"main(values: List<Secret>) (answer: Bool) { answer = values == values }",
|
||||
"main(value: Secret) (answer: Int) { answer = length(value) }",
|
||||
"main(value: Secret) (answer: Text) { answer = value }",
|
||||
"type Invalid { error Failed(value: Secret) }\nmain() () {}",
|
||||
"type Inner { key: Secret }\ntype Invalid { error Failed(values: List<Inner>) }\nmain() () {}",
|
||||
"main(values: Map<Secret, Int>) () {}",
|
||||
] {
|
||||
assert_eq!(linked(source).unwrap_err().code, "C005", "{source}");
|
||||
}
|
||||
let error = linked("main() () { value = secret(\"password\") }").unwrap_err();
|
||||
assert_eq!(error.code, "C006");
|
||||
assert!(error.message.contains("compiler-owned Test source"));
|
||||
assert!(linked("main(value: Secret) (answer: Secret?) { answer = value }").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crypto_randomness_and_password_work_keep_effect_boundaries() {
|
||||
for source in [
|
||||
"main() () pure { value = dever.crypto.token(16) }",
|
||||
"main(password: Secret) (hash: Text) { dever.task.sleep(0)\n hash = dever.crypto.passwordHash(password) }",
|
||||
"main(password: Secret, hash: Text) (valid: Bool) { dever.task.sleep(0)\n valid = dever.crypto.passwordVerify(password, hash) }",
|
||||
] {
|
||||
assert_eq!(linked(source).unwrap_err().code, "C013", "{source}");
|
||||
}
|
||||
assert!(linked("main(password: Secret) (hash: Text) { hash = blocking(dever.crypto.passwordHash(password)) }").is_ok());
|
||||
assert!(linked("main(key: Secret, bytes: Bytes) (hash: Bytes) pure { hash = dever.crypto.hmacSha256(key, bytes) }").is_ok());
|
||||
assert_eq!(
|
||||
linked("main() () { value = dever.system.cryptoToken(16) }")
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C006"
|
||||
);
|
||||
assert_eq!(
|
||||
linked("main() () { lost = result(dever.crypto.token(0)) }")
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C012"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_outputs_reject_nested_secret_types() {
|
||||
for source in [
|
||||
"read() (value: Secret?) { value = null }",
|
||||
"type Envelope { values: List<Secret> }\nread() (value: Envelope) { value = Envelope { values = [] } }",
|
||||
] {
|
||||
let module = dever::syntax::parse(&Source::new(0, "app.dever", source.into())).unwrap();
|
||||
let error = check::check(vec![module], &["shop.account.app".into()]).unwrap_err();
|
||||
assert_eq!(error.code, "C006");
|
||||
assert!(error.message.contains("Secret"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
use super::{ERROR, packages};
|
||||
|
||||
#[test]
|
||||
fn text_policy_is_a_single_model_modifier_and_defaults_use_the_normalized_length() {
|
||||
use dever::compiler::model::persistence::{TextMode, Value};
|
||||
let program = packages(&[("demo.account.model", "type Account { name: Text(1, 2) lower default \" Ä \"\ncode: Text(2) upper default \" ß \"\nbody: Text raw default \" a \"\nnote: Text? }")]).unwrap();
|
||||
let columns = &program.models[0].columns;
|
||||
for (column, mode, expected) in [
|
||||
(2, TextMode::Lower, "ä"),
|
||||
(3, TextMode::Upper, "SS"),
|
||||
(4, TextMode::Raw, " a "),
|
||||
] {
|
||||
assert_eq!(columns[column].text, mode);
|
||||
assert!(matches!(&columns[column].default, Some(Value::Text(value)) if value == expected));
|
||||
}
|
||||
assert_eq!(columns[5].text, TextMode::Trim);
|
||||
for field in [
|
||||
"name: Text raw lower",
|
||||
"name: Text lower upper",
|
||||
"name: Text raw raw",
|
||||
"name: Int lower",
|
||||
"name: List<Text> raw",
|
||||
"name: Text(1, 2) default \" \"",
|
||||
"name: Text(1) upper default \"ß\"",
|
||||
"name: Text trim",
|
||||
] {
|
||||
assert!(
|
||||
packages(&[("demo.account.model", &format!("type Account {{ {field} }}"))]).is_err(),
|
||||
"{field}"
|
||||
);
|
||||
}
|
||||
assert!(packages(&[("demo.account.domain", "type Input { name: Text raw }")]).is_err());
|
||||
let seeded = packages(&[(
|
||||
"demo.account.model",
|
||||
"type Account { name: Text(1, 2) lower unique }\nseed { { name = \" Ä \" } }",
|
||||
)])
|
||||
.unwrap();
|
||||
assert!(matches!(&seeded.models[0].seeds[0].values[0].1, Value::Text(value) if value == "ä"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_choice_options_preserve_labels_and_order() {
|
||||
let program = packages(&[
|
||||
("demo.account.model", "type Status { Active = \"启用\"\nDisabled = \"禁用\" }\ntype Account { status: Status }"),
|
||||
("demo.account.app", "options() (values: Map<model.Status, Text>) { values = model.Status.options }"),
|
||||
]).unwrap();
|
||||
assert!(!program.functions[0].signature.effects.database);
|
||||
assert!(packages(&[("main", "type Status { Active }\npublic main() (values: Map<Text, Text>) { values = Status.options }")]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checked_defaults_and_generated_columns_keep_static_types() {
|
||||
let program = packages(&[
|
||||
("dever.database", ERROR),
|
||||
("demo.account.model", "type Status { Active = \"启用\"\nDisabled = \"禁用\" }\ntype Account { uid: Uuid generated unique\nstatus: Status default Status.Active\namount: Decimal(5, 2) default -12.30\nnote: Text? default null\nname: Text(1, 8) default \"雪\" }"),
|
||||
("demo.account.app", "create() (id: model.id) { row = model.create({})\nid = row.id }"),
|
||||
("demo.account.api", "cmd create = app.create"),
|
||||
]).unwrap();
|
||||
let columns = &program.models[0].columns;
|
||||
assert!(columns[2].generated);
|
||||
assert_eq!(
|
||||
columns[3].choice.as_ref().unwrap(),
|
||||
&[
|
||||
("Active".into(), "启用".into()),
|
||||
("Disabled".into(), "禁用".into())
|
||||
]
|
||||
);
|
||||
assert!(
|
||||
matches!(&columns[4].default, Some(dever::compiler::model::persistence::Value::Text(value)) if value == "098770")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_defaults_and_generated_writes_fail_before_database_access() {
|
||||
for field in [
|
||||
"value: Decimal(5, 2) default 0.001",
|
||||
"value: Decimal(5, 2) default 1000",
|
||||
"value: Text(2) default \"三字符\"",
|
||||
"value: Int default 1.1",
|
||||
"value: Int default 9223372036854775808",
|
||||
"value: Float default 1e999",
|
||||
"value: Bool default null",
|
||||
"value: Uuid default \"broken\"",
|
||||
"value: Date default \"2025-02-29\"",
|
||||
"value: Time default \"24:00:00\"",
|
||||
"value: Json default \"{\\\"x\\\":1,\\\"x\\\":2}\"",
|
||||
"value: Int generated",
|
||||
"value: Uuid? generated",
|
||||
"value: Uuid generated default \"00112233-4455-6677-8899-aabbccddeeff\"",
|
||||
] {
|
||||
let model = format!("type Account {{ {field} }}");
|
||||
let error = packages(&[("demo.account.model", &model)]).unwrap_err();
|
||||
assert_eq!(error.code, "C014", "{field}: {error:?}");
|
||||
}
|
||||
for body in [
|
||||
"row = model.create({ uid = uid })",
|
||||
"row = model.update(id, { uid = uid })",
|
||||
"row = model.get(id)\nrow.uid = uid",
|
||||
] {
|
||||
let app = format!(
|
||||
"write(id: model.id, uid: Uuid) (value: model.id) {{ {body}\nvalue = row.id }}"
|
||||
);
|
||||
let error = packages(&[
|
||||
("dever.database", ERROR),
|
||||
("demo.account.model", "type Account { uid: Uuid generated }"),
|
||||
("demo.account.app", &app),
|
||||
])
|
||||
.unwrap_err();
|
||||
assert!(matches!(error.code, "C014" | "C005"), "{body}: {error:?}");
|
||||
assert!(error.message.contains("read-only"), "{error:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
use super::package::packages;
|
||||
use dever::{compiler::model::Program, syntax::diagnostic::Diagnostic};
|
||||
mod defaults;
|
||||
mod native;
|
||||
mod privacy;
|
||||
mod query;
|
||||
mod relation;
|
||||
mod scalar;
|
||||
mod schema;
|
||||
|
||||
pub(super) const ERROR: &str = "public type Error { error Pool(message: Text)\nerror PoolExhausted(message: Text)\nerror Connection(message: Text)\nerror Timeout(message: Text)\nerror Cancelled(message: Text)\nerror Database(message: Text)\nerror Constraint(message: Text)\nerror NotFound(message: Text)\nerror InvalidData(message: Text)\nerror Migration(message: Text) }";
|
||||
|
||||
fn checked(app: &str, api: &str) -> Result<Program, Diagnostic> {
|
||||
packages(&[
|
||||
("dever.database", ERROR),
|
||||
(
|
||||
"demo.account.model",
|
||||
"type Account { name: Text(1, 64) unique\nnote: Text? }",
|
||||
),
|
||||
("demo.account.app", app),
|
||||
("demo.account.api", api),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_ids_queries_and_failures_keep_static_identity() {
|
||||
let program = checked(
|
||||
"get(id: model.id) (account: model.Account) { account = model.get(id) }",
|
||||
"cmd get = app.get",
|
||||
)
|
||||
.unwrap();
|
||||
let function = &program.functions[0].signature;
|
||||
assert!(function.effects.database);
|
||||
assert!(!function.effects.database_write);
|
||||
assert_eq!(function.failures.len(), 10);
|
||||
assert_eq!(program.models.len(), 1);
|
||||
assert!(program.models[0].columns[2].unique);
|
||||
assert_eq!(program.models[0].columns[2].limits, Some((1, 64)));
|
||||
assert_eq!(
|
||||
checked(
|
||||
"get(id: Int) (account: model.Account) { account = model.get(id) }",
|
||||
"cmd get = app.get"
|
||||
)
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C005"
|
||||
);
|
||||
let app = "read(value: model.Account) (id: model.id) { value.id = value.id\nid = value.id }";
|
||||
assert_eq!(
|
||||
checked(app, "cmd read = app.read").unwrap_err().code,
|
||||
"C014"
|
||||
);
|
||||
assert_eq!(checked("create(name: Text) (account: model.Account) { account = model.create({ name = name }) }", "public get create = app.create").unwrap_err().code, "C014");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transactions_reject_unrelated_work_and_role_bypasses() {
|
||||
let crypto = "public type HashResult { Ready(value: Text)\nerror Failed(message: Text) }\npublic passwordHash(secret: Secret) (hash: Text) { hash = hashValue(dever.system.cryptoPasswordHash(secret)) }\nhashValue(result: HashResult.Ready(value)) (hash: Text) { hash = value }\nhashValue(result: HashResult.Failed(message)) (hash: Text) { fail(result) }";
|
||||
let blocked = packages(&[
|
||||
("dever.database", ERROR),
|
||||
("dever.crypto", crypto),
|
||||
("demo.account.model", "type Account { name: Text }"),
|
||||
(
|
||||
"demo.account.app",
|
||||
"transaction create(password: Secret) (account: model.Account) { hash = blocking(dever.crypto.passwordHash(password))\naccount = model.create({ name = hash }) }",
|
||||
),
|
||||
("demo.account.api", "cmd create = app.create"),
|
||||
]);
|
||||
assert_eq!(blocked.unwrap_err().code, "C014");
|
||||
let staged = packages(&[
|
||||
("dever.database", ERROR),
|
||||
("dever.crypto", crypto),
|
||||
("demo.account.model", "type Account { name: Text }"),
|
||||
("demo.account.app", "transaction persist(name: Text) (account: model.Account) { account = model.create({ name = name }) }\ncreate(password: Secret) (account: model.Account) { hash = blocking(dever.crypto.passwordHash(password))\naccount = persist(hash) }"),
|
||||
]).unwrap();
|
||||
let staged = &staged
|
||||
.functions
|
||||
.iter()
|
||||
.find(|function| function.signature.name == "create")
|
||||
.unwrap()
|
||||
.signature
|
||||
.effects;
|
||||
assert!(staged.password);
|
||||
assert!(!staged.unscoped_write);
|
||||
let app = "transaction create(name: Text) (account: model.Account) { account = model.create({ name = name }) }";
|
||||
assert!(checked(app, "long cmd create = app.create").is_ok());
|
||||
assert!(checked(app, "public long post create = app.create").is_ok());
|
||||
assert_eq!(checked("create(name: Text) (account: model.Account) { account = model.create({ name = name }) }", "long cmd create = app.create").unwrap_err().code, "C014");
|
||||
assert_eq!(checked("create(name: Text) (account: model.Account) { account = model.create({ name = name }) }", "public long post create = app.create").unwrap_err().code, "C014");
|
||||
assert!(
|
||||
checked(
|
||||
"read(value: model.Page) (page: model.Page) { value.page = 2\npage = value }",
|
||||
"cmd read = app.read"
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert_eq!(
|
||||
checked(
|
||||
"transaction empty(value: Int) (answer: Int) { answer = value }",
|
||||
"cmd empty = app.empty"
|
||||
)
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C014"
|
||||
);
|
||||
assert_eq!(checked("read() (total: Int) { total = model.count() }\ntransaction spawn() (total: Int) { work = run(read())\ntotal = wait(work) }", "cmd spawn = app.spawn").unwrap_err().code, "C014");
|
||||
assert_eq!(
|
||||
packages(&[
|
||||
("dever.database", ERROR),
|
||||
("demo.account.model", "type Account { name: Text }"),
|
||||
(
|
||||
"demo.account.domain",
|
||||
"read() (total: Int) { total = model.count() }"
|
||||
)
|
||||
])
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C006"
|
||||
);
|
||||
assert_eq!(
|
||||
packages(&[("main", "type Value { name: Text(64) }")])
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C014"
|
||||
);
|
||||
assert_eq!(
|
||||
packages(&[("main", "type Value { name: Text unique }")])
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C014"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_query_blocks_reject_dynamic_fields_and_invalid_options() {
|
||||
for query in [
|
||||
"{ where = missing == name }",
|
||||
"{ where = name }",
|
||||
"{ order = [missing.asc] }",
|
||||
"{ size = 1, size = 2 }",
|
||||
"{ size = name }",
|
||||
] {
|
||||
let app = format!("search(name: Text) (page: model.Page) {{ page = model.list({query}) }}");
|
||||
assert!(checked(&app, "cmd search = app.search").is_err(), "{query}");
|
||||
}
|
||||
assert_eq!(
|
||||
packages(&[(
|
||||
"demo.account.model",
|
||||
"database one\ndatabase two\ntype Account { name: Text }"
|
||||
)])
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C002"
|
||||
);
|
||||
assert_eq!(
|
||||
packages(&[("demo.account.model", "type Account { name: Text(65, 64) }")])
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C014"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
use super::{ERROR, packages};
|
||||
|
||||
fn checked(
|
||||
sqlite: &str,
|
||||
postgres: &str,
|
||||
entry: &str,
|
||||
) -> Result<dever::compiler::model::Program, dever::syntax::diagnostic::Diagnostic> {
|
||||
let model = format!(
|
||||
"type Account {{ name: Text }}\nsql lookup(name: Text) (row: app.Summary?) {{ sqlite = {sqlite:?}\npostgres = {postgres:?} }}"
|
||||
);
|
||||
packages(&[
|
||||
("dever.database", ERROR),
|
||||
("demo.account.model", &model),
|
||||
(
|
||||
"demo.account.app",
|
||||
"type Summary { name: Text }\nread(name: Text) (value: Text) { row = model.lookup(name)\nvalue = text(row) }\ntext(row: Summary?) (value: Text) { value = \"present\" }",
|
||||
),
|
||||
("demo.account.api", entry),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_sql_proves_only_owned_static_projections_readonly() {
|
||||
let sqlite = "SELECT name FROM demo_account_model WHERE name = ?1 ORDER BY id DESC";
|
||||
let postgres = sqlite.replace('?', "$");
|
||||
let program = checked(sqlite, &postgres, "public get read = app.read").unwrap();
|
||||
assert!(program.models[0].sql[0].readonly);
|
||||
let complex = "SELECT max(name) AS name FROM demo_account_model WHERE name = ?1";
|
||||
assert!(checked(complex, &complex.replace('?', "$"), "cmd read = app.read").is_ok());
|
||||
assert!(
|
||||
checked(
|
||||
complex,
|
||||
&complex.replace('?', "$"),
|
||||
"public get read = app.read"
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
checked(
|
||||
sqlite,
|
||||
"SELECT name FROM other_table WHERE name = $1",
|
||||
"public get read = app.read"
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_sql_accepts_readonly_alias_count_and_parameterized_paging() {
|
||||
for sql in [
|
||||
"SELECT name AS label FROM demo_account_model WHERE name = ?1",
|
||||
"SELECT COUNT(*) AS total FROM demo_account_model WHERE name = ?1",
|
||||
"SELECT name AS label FROM demo_account_model ORDER BY id DESC LIMIT ?1 OFFSET ?1",
|
||||
] {
|
||||
assert!(
|
||||
checked(sql, &sql.replace('?', "$"), "public get read = app.read")
|
||||
.unwrap()
|
||||
.models[0]
|
||||
.sql[0]
|
||||
.readonly
|
||||
);
|
||||
}
|
||||
for sql in [
|
||||
"SELECT name AS label, unknown() FROM demo_account_model WHERE name = ?1",
|
||||
"SELECT count(unknown()) FROM demo_account_model WHERE name = ?1",
|
||||
"SELECT name FROM demo_account_model LIMIT ?1 OFFSET unknown()",
|
||||
] {
|
||||
assert!(
|
||||
checked(sql, &sql.replace('?', "$"), "public get read = app.read").is_err(),
|
||||
"{sql}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_sql_rejects_binding_and_statement_escape() {
|
||||
for sql in [
|
||||
"SELECT name FROM demo_account_model WHERE name = ?2",
|
||||
"SELECT name FROM demo_account_model WHERE name = :name",
|
||||
"SELECT name FROM demo_account_model WHERE name = ?1; DELETE FROM demo_account_model",
|
||||
"SELECT name FROM _dever_role WHERE name = ?1",
|
||||
"SELECT name FROM \"_dever_role\" WHERE name = ?1",
|
||||
"SELECT name FROM demo_account_model WHERE name = '?1'",
|
||||
"SELECT name FROM demo_account_model WHERE name = ?1 /*",
|
||||
"SELECT name FROM demo_account_model, other_table WHERE name = ?1",
|
||||
"SELECT name FROM demo_account_model.other_table WHERE name = ?1",
|
||||
"SELECT name FROM demo_account_model JOIN other_table ON name = ?1",
|
||||
] {
|
||||
assert!(
|
||||
checked(sql, &sql.replace('?', "$"), "cmd read = app.read").is_err(),
|
||||
"{sql}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_sql_accepts_same_domain_app_view_and_rejects_constrained_results() {
|
||||
let model = "type Account { name: Text }\nsql lookup(name: Text) (row: app.Summary?) { sqlite = \"SELECT name FROM demo_account_model WHERE name = ?1\"\npostgres = \"SELECT name FROM demo_account_model WHERE name = $1\" }";
|
||||
let app = "type Summary { name: Text }\nread(name: Text) (row: Summary?) { row = model.lookup(name) }";
|
||||
assert!(
|
||||
packages(&[
|
||||
("dever.database", ERROR),
|
||||
("demo.account.model", model),
|
||||
("demo.account.app", app),
|
||||
("demo.account.api", "public get read = app.read")
|
||||
])
|
||||
.is_ok()
|
||||
);
|
||||
for record in [
|
||||
"type Extra { value: Int }",
|
||||
"type Extra { value: Secret }",
|
||||
"type Extra { value: List<Int> }",
|
||||
] {
|
||||
let model = format!("type Account {{ name: Text }}\n{record}");
|
||||
assert!(packages(&[("demo.account.model", &model)]).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_sql_records_stay_private_and_all_joined_models_are_dependencies() {
|
||||
let model = "type Account { name: Text }\ntype Summary { name: Text }\nsql joined() (row: Summary) { sqlite = \"SELECT demo_account_model.name FROM demo_account_model, demo_profile_model\"\npostgres = \"SELECT demo_account_model.name FROM demo_account_model JOIN demo_profile_model ON true\" }";
|
||||
let app = "read() (name: Text) { row = model.joined()\nname = row.name }";
|
||||
let mut program = packages(&[
|
||||
("dever.database", ERROR),
|
||||
("demo.account.model", model),
|
||||
("demo.profile.model", "type Profile { name: Text }"),
|
||||
("demo.account.app", app),
|
||||
("demo.account.api", "cmd read = app.read"),
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(program.models[0].sql[0].dependencies, [0, 1]);
|
||||
program.commands.clear();
|
||||
assert!(
|
||||
program
|
||||
.functions
|
||||
.iter()
|
||||
.all(|function| !function.signature.transaction)
|
||||
);
|
||||
let generated = dever::compiler::emit::generate(&program, &[], &Default::default());
|
||||
assert!(generated.contains("registry.check_transactions(&[&[0,1]])"));
|
||||
let leaked = "read() (row: model.Summary) { row = model.joined() }";
|
||||
assert!(
|
||||
packages(&[
|
||||
("dever.database", ERROR),
|
||||
("demo.account.model", model),
|
||||
("demo.profile.model", "type Profile { name: Text }"),
|
||||
("demo.account.app", leaked),
|
||||
("demo.account.api", "cmd read = app.read")
|
||||
])
|
||||
.unwrap_err()
|
||||
.message
|
||||
.contains("private type")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_sql_reuses_model_columns_but_rejects_unproved_projection_bounds() {
|
||||
let model = "type Account { name: Text(8) }\nsql snapshot() (row: Account) { sqlite = \"SELECT id, created_at, name FROM demo_account_model\"\npostgres = \"SELECT id, created_at, name FROM demo_account_model\" }";
|
||||
assert!(
|
||||
packages(&[
|
||||
("dever.database", ERROR),
|
||||
("demo.account.model", model),
|
||||
(
|
||||
"demo.account.app",
|
||||
"read() (name: Text) { row = model.snapshot()\nname = row.name }"
|
||||
),
|
||||
("demo.account.api", "cmd read = app.read")
|
||||
])
|
||||
.is_ok()
|
||||
);
|
||||
for field in ["name: Text(8)", "name: Text unique", "name: Text index"] {
|
||||
let model = format!(
|
||||
"type Account {{ name: Text }}\ntype Summary {{ {field} }}\nsql summary() (row: Summary) {{ sqlite = \"SELECT name FROM demo_account_model\"\npostgres = \"SELECT name FROM demo_account_model\" }}"
|
||||
);
|
||||
assert!(
|
||||
packages(&[("demo.account.model", &model)]).is_err(),
|
||||
"{field}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quoted_sql_fields_are_identifiers_and_do_not_create_fake_table_dependencies() {
|
||||
let sql = "SELECT \"from\" FROM \"demo_account_model\" WHERE \"from\" = ?1";
|
||||
let model = format!(
|
||||
"type Account {{ from: Text }}\nsql lookup(value: Text) (row: app.View) {{ sqlite = {sql:?}\npostgres = {:?} }}",
|
||||
sql.replace('?', "$")
|
||||
);
|
||||
let program = packages(&[
|
||||
("dever.database", ERROR),
|
||||
("demo.account.model", &model),
|
||||
(
|
||||
"demo.account.app",
|
||||
"type View { from: Text }\nread(value: Text) (row: View) { row = model.lookup(value) }",
|
||||
),
|
||||
("demo.account.api", "public get read = app.read"),
|
||||
])
|
||||
.unwrap();
|
||||
assert!(program.models[0].sql[0].readonly);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
use super::{ERROR, packages};
|
||||
|
||||
fn check(app: &str) -> Result<super::Program, super::Diagnostic> {
|
||||
packages(&[
|
||||
("dever.database", ERROR),
|
||||
(
|
||||
"demo.account.model",
|
||||
"type Account { name: Text\nprivate token: Text }",
|
||||
),
|
||||
("demo.account.app", app),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_model_snapshots_cannot_cross_app_signatures() {
|
||||
for kind in [
|
||||
"model.Account",
|
||||
"model.Account?",
|
||||
"List<model.Account>",
|
||||
"model.Page",
|
||||
"Map<Text, model.Account>",
|
||||
"MapEntry<Text, model.Account>",
|
||||
"AsyncStream<model.Account>",
|
||||
"Related<model.Account>",
|
||||
] {
|
||||
let body = format!("read(value: {kind}) (answer: {kind}) {{ answer = value }}");
|
||||
let error = check(&body).unwrap_err();
|
||||
assert_eq!(error.code, "C006", "{kind}: {error:?}");
|
||||
assert!(error.message.contains("private Model"), "{error:?}");
|
||||
}
|
||||
let error = check("read(id: model.id) (account: model.Account) { account = model.get(id) }")
|
||||
.unwrap_err();
|
||||
assert_eq!(error.code, "C006");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_models_cannot_hide_in_app_records_or_errors() {
|
||||
for body in [
|
||||
"type Wrapped { private value: model.Account }\nread(value: Wrapped) (answer: Wrapped) { answer = value }",
|
||||
"type Problem { error Denied(account: model.Account) }\nread(id: model.id) () { account = model.get(id)\nfail(Problem.Denied(account)) }",
|
||||
"read(action: handler(value: model.Account) ()) () {}",
|
||||
] {
|
||||
let error = check(body).unwrap_err();
|
||||
assert_eq!(error.code, "C006", "{error:?}");
|
||||
assert!(error.message.contains("private Model"), "{error:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opaque_public_records_remain_legal_but_private_fields_stay_off_wire() {
|
||||
let opaque = "type Session { private token: Text\nlabel: Text }\nread(value: Session) (answer: Session) { answer = value }";
|
||||
assert!(check(opaque).is_ok());
|
||||
let error = packages(&[
|
||||
("demo.account.app", opaque),
|
||||
("demo.account.api", "cmd read = app.read"),
|
||||
])
|
||||
.unwrap_err();
|
||||
assert_eq!(error.code, "C006");
|
||||
assert!(error.message.contains("private fields"), "{error:?}");
|
||||
assert!(check("type View { name: Text }\nread(id: model.id) (view: View) { account = model.get(id)\nview = View { name = account.name } }").is_ok());
|
||||
let hidden = [
|
||||
("demo.account.domain", "type State { value: Text }"),
|
||||
(
|
||||
"demo.account.app",
|
||||
"type Session { private state: domain.State\nlabel: Text }",
|
||||
),
|
||||
];
|
||||
let mut files = hidden.to_vec();
|
||||
files.push((
|
||||
"demo.other.app",
|
||||
"pass(value: demo.account.Session) (answer: demo.account.Session) { answer = value }",
|
||||
));
|
||||
assert!(packages(&files).is_ok());
|
||||
files.pop();
|
||||
files.push((
|
||||
"demo.other.app",
|
||||
"read(value: demo.account.Session) (answer: Text) { answer = value.state.value }",
|
||||
));
|
||||
assert_eq!(packages(&files).unwrap_err().code, "C006");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_model_data_propagates_through_related_cycles_without_blocking_ids() {
|
||||
let models = [
|
||||
("dever.database", ERROR),
|
||||
(
|
||||
"demo.people.model.team",
|
||||
"type Team { name: Text }\nrelation users = demo.people.model.user.team_id",
|
||||
),
|
||||
(
|
||||
"demo.people.model.user",
|
||||
"type User { private token: Text\nteam_id: model.team.id }",
|
||||
),
|
||||
];
|
||||
for kind in [
|
||||
"model.team.Team",
|
||||
"Related<model.team.Team>",
|
||||
"List<model.team.Team?>",
|
||||
] {
|
||||
let app = format!("pass(value: {kind}) (answer: {kind}) {{ answer = value }}");
|
||||
let mut files = models.to_vec();
|
||||
files.push(("demo.people.app", &app));
|
||||
let error = packages(&files).unwrap_err();
|
||||
assert_eq!(error.code, "C006", "{error:?}");
|
||||
assert!(error.message.contains("private Model"), "{error:?}");
|
||||
}
|
||||
let mut files = models.to_vec();
|
||||
files.push((
|
||||
"demo.people.app",
|
||||
"pass(value: model.user.id) (answer: model.user.id) { answer = value }",
|
||||
));
|
||||
assert!(packages(&files).is_ok());
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
use super::checked;
|
||||
|
||||
#[test]
|
||||
fn conditional_writes_require_static_predicates_and_write_entries() {
|
||||
let update = "change(name: Text) (count: Int) { count = model.update({ where = name == name }, { note = name }) }";
|
||||
assert!(checked(update, "cmd change = app.change").is_ok());
|
||||
assert_eq!(
|
||||
checked(update, "public get change = app.change")
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C014"
|
||||
);
|
||||
for expression in [
|
||||
"model.update({}, { note = name })",
|
||||
"model.update({ where = name == name, order = [id.asc] }, { note = name })",
|
||||
"model.update({ where = name == name }, {})",
|
||||
"model.update({ where = name == name }, { note = name, note = name })",
|
||||
"model.delete({})",
|
||||
"model.delete({ where = name == name, size = 1 })",
|
||||
] {
|
||||
let source = format!("change(name: Text) (count: Int) {{ count = {expression} }}");
|
||||
assert!(
|
||||
checked(&source, "cmd change = app.change").is_err(),
|
||||
"{expression}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_checks_snapshot_type_order_and_options() {
|
||||
for query in ["", "{ after = null, size = 2, order = [name.asc] }"] {
|
||||
let app = format!("search() (page: model.Cursor) {{ page = model.cursor({query}) }}");
|
||||
assert!(checked(&app, "cmd search = app.search").is_ok(), "{query}");
|
||||
}
|
||||
for query in [
|
||||
"{ after = 1 }",
|
||||
"{ size = \"two\" }",
|
||||
"{ order = [note.asc] }",
|
||||
"{ order = [name.asc, name.desc] }",
|
||||
"{ page = 1 }",
|
||||
] {
|
||||
let app = format!("search() (page: model.Cursor) {{ page = model.cursor({query}) }}");
|
||||
assert!(checked(&app, "cmd search = app.search").is_err(), "{query}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_predicates_require_static_fields_and_typed_bounded_values() {
|
||||
let api = "cmd search = app.search";
|
||||
for condition in [
|
||||
"not contains(name, value) and in(name, [value, \"other\"])",
|
||||
"between(name, value, \"雪\") or startsWith(note, value)",
|
||||
"endsWith(name, value) and note == null",
|
||||
] {
|
||||
let app = format!(
|
||||
"search(value: Text) (total: Int) {{ total = model.count({{ where = {condition} }}) }}"
|
||||
);
|
||||
assert!(checked(&app, api).is_ok(), "{condition}");
|
||||
}
|
||||
for condition in [
|
||||
"in(name, [])",
|
||||
"in(name, value)",
|
||||
"in(name, [1])",
|
||||
"contains(name)",
|
||||
"contains(id, value)",
|
||||
"contains(\"field\", value)",
|
||||
"contains(missing, value)",
|
||||
"between(name, value)",
|
||||
"startsWith(name, 1)",
|
||||
"unknown(name, value)",
|
||||
] {
|
||||
let app = format!(
|
||||
"search(value: Text) (total: Int) {{ total = model.count({{ where = {condition} }}) }}"
|
||||
);
|
||||
assert!(checked(&app, api).is_err(), "accepted {condition}");
|
||||
}
|
||||
let items = vec!["value"; 101].join(",");
|
||||
let app = format!(
|
||||
"search(value: Text) (total: Int) {{ total = model.count({{ where = in(name, [{items}]) }}) }}"
|
||||
);
|
||||
assert!(checked(&app, api).is_err());
|
||||
assert!(
|
||||
checked(
|
||||
"search(value: Text) (answer: Bool) { answer = contains(value, value) }",
|
||||
api
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bulk_and_upsert_keep_field_contracts_and_unique_keys() {
|
||||
for body in [
|
||||
"total = model.createMany([])",
|
||||
"total = model.createMany([{ name = \"one\" }, { name = \"two\", note = null }])",
|
||||
"row = model.upsert({ name = \"one\" }, {}, { note = \"updated\" })\ntotal = length(row.name)",
|
||||
] {
|
||||
let app = format!("write() (total: Int) {{ {body} }}");
|
||||
assert!(checked(&app, "cmd write = app.write").is_ok(), "{body}");
|
||||
assert!(checked(&app, "public get write = app.write").is_err());
|
||||
}
|
||||
for body in [
|
||||
"total = model.createMany([{}])",
|
||||
"total = model.createMany([{ name = \"one\", name = \"two\" }])",
|
||||
"total = model.createMany([{ id = 1, name = \"one\" }])",
|
||||
"total = model.createMany([{ name = 7 }])",
|
||||
"row = model.upsert({}, { name = \"one\" }, {})\ntotal = length(row.name)",
|
||||
"row = model.upsert({ note = \"key\" }, { name = \"one\" }, {})\ntotal = length(row.name)",
|
||||
"row = model.upsert({ name = \"key\" }, { name = \"one\" }, {})\ntotal = length(row.name)",
|
||||
"row = model.upsert({ name = \"key\" }, {}, { id = 2 })\ntotal = length(row.name)",
|
||||
] {
|
||||
let app = format!("write() (total: Int) {{ {body} }}");
|
||||
assert!(
|
||||
checked(&app, "cmd write = app.write").is_err(),
|
||||
"accepted {body}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
use super::{ERROR, packages};
|
||||
|
||||
fn check(
|
||||
body: &str,
|
||||
) -> Result<dever::compiler::model::Program, dever::syntax::diagnostic::Diagnostic> {
|
||||
packages(&[
|
||||
("dever.database", ERROR),
|
||||
(
|
||||
"demo.people.model.user",
|
||||
"type User { name: Text }\nrelation posts = demo.people.model.post.author_id",
|
||||
),
|
||||
(
|
||||
"demo.people.model.post",
|
||||
"type Post { author_id: model.user.id\ntitle: Text }",
|
||||
),
|
||||
("demo.people.app", body),
|
||||
("demo.people.api", "cmd read = app.read"),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relations_are_static_and_propagate_all_models() {
|
||||
let program = check(
|
||||
"read() (total: Int) { page = model.user.list({ with = [posts] })\ntotal = page.total }",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(program.models[0].relations.len(), 1);
|
||||
let operation = &program.functions[0].clauses[0].body[0];
|
||||
let dever::compiler::model::Statement::Assign { value, .. } = operation else {
|
||||
panic!()
|
||||
};
|
||||
let dever::compiler::model::Node::Database(query, _) = &value.kind else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(query.models().collect::<Vec<_>>(), vec![0, 1]);
|
||||
for option in [
|
||||
"with = [posts, posts]",
|
||||
"with = [posts.author]",
|
||||
"with = [missing]",
|
||||
] {
|
||||
assert!(
|
||||
check(&format!(
|
||||
"read() (page: model.user.Page) {{ page = model.user.list({{ {option} }}) }}"
|
||||
))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streams_cannot_be_used_inside_transactions_or_load_relations() {
|
||||
for body in [
|
||||
"transaction read() (total: Int) { rows = model.user.stream()\nclose(rows)\ntotal = 0 }",
|
||||
"read() (total: Int) { rows = model.user.stream({ with = [posts] })\nclose(rows)\ntotal = 0 }",
|
||||
] {
|
||||
assert!(check(body).is_err(), "{body}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
use super::{ERROR, packages};
|
||||
|
||||
#[test]
|
||||
fn scalar_columns_keep_distinct_precision_and_query_bindings() {
|
||||
let program = packages(&[
|
||||
("dever.database", ERROR),
|
||||
("demo.account.model", "type Account { amount: Decimal(34, 4)\nfee: Decimal(5, 2)?\nuid: Uuid unique\nday: Date\ntime: Time\nelapsed: Duration\ndocument: Json }"),
|
||||
("demo.account.app", "search(amount: Decimal, fee: Decimal?) (page: model.Page) { page = model.list({ where = amount >= amount and fee == fee, order = [amount.asc] }) }"),
|
||||
("demo.account.api", "cmd search = app.search"),
|
||||
]).unwrap();
|
||||
assert_eq!(program.models[0].columns[2].decimal, Some((34, 4)));
|
||||
assert_eq!(program.models[0].columns[3].decimal, Some((5, 2)));
|
||||
assert_eq!(program.models[0].columns[2].limits, None);
|
||||
assert!(program.models[0].columns[4].unique);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scalar_metadata_rejects_lossy_or_ambiguous_decimal_storage() {
|
||||
for kind in [
|
||||
"Decimal",
|
||||
"Decimal(5)",
|
||||
"Decimal(0, 0)",
|
||||
"Decimal(35, 4)",
|
||||
"Decimal(4, 5)",
|
||||
"Decimal(4, 2, 1)",
|
||||
] {
|
||||
let model = format!("type Account {{ amount: {kind} }}");
|
||||
let error = packages(&[("demo.account.model", &model)]).unwrap_err();
|
||||
assert_eq!(error.code, "C014", "{kind}: {error:?}");
|
||||
}
|
||||
for kind in ["Decimal(1, 0)", "Decimal(34, 34)", "Decimal(9, 2)?"] {
|
||||
let model = format!("type Account {{ amount: {kind} }}");
|
||||
packages(&[("demo.account.model", &model)]).unwrap();
|
||||
}
|
||||
for kind in ["List<Int>", "Id", "Secret"] {
|
||||
let model = format!("type Account {{ value: {kind} }}");
|
||||
assert!(
|
||||
packages(&[("demo.account.model", &model)]).is_err(),
|
||||
"{kind}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
use super::packages;
|
||||
|
||||
#[test]
|
||||
fn storage_declarations_do_not_reserve_existing_function_names() {
|
||||
for name in ["seed", "index", "unique", "migrate"] {
|
||||
let source = format!("public {name}() (answer: Int) {{ answer = 1 }}");
|
||||
packages(&[("main", &source)]).unwrap();
|
||||
let source = format!("public {name}(value: Int) (answer: Int) {{ answer = value }}");
|
||||
packages(&[("main", &source)]).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn model(extra: &str) -> String {
|
||||
format!(
|
||||
"type Account {{ email: Text(64) unique\nname: Text(16)\namount: Decimal(9, 2) default 0\nstatus: Int default 1 }}\n{extra}"
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_checks_seed_unique_keys_and_composite_indexes() {
|
||||
let source = model(
|
||||
"index(status, created_at)\nunique(email, name)\nseed { { email = \"admin@sample.test\", name = \"Admin\" } }",
|
||||
);
|
||||
let program = packages(&[("demo.account.model", &source)]).unwrap();
|
||||
assert_eq!(program.models[0].indexes.len(), 2);
|
||||
assert_eq!(program.models[0].seeds.len(), 1);
|
||||
assert_eq!(program.models[0].seeds[0].key, [2]);
|
||||
for extra in [
|
||||
"index(missing)",
|
||||
"index(name, name)",
|
||||
"index(name)\nindex(name)",
|
||||
"seed { { name = \"Admin\" } }",
|
||||
"seed { { email = \"a\" } }",
|
||||
"seed { { email = \"a\", name = \"a\", amount = 0.001 } }",
|
||||
"seed { { email = \"a\", name = \"a\", id = 1 } }",
|
||||
"seed {}\nseed {}",
|
||||
"migrate same { drop legacy }\nmigrate same { drop old }",
|
||||
"migrate bad { drop name }",
|
||||
"migrate bad { drop created_at }",
|
||||
"migrate empty {}",
|
||||
] {
|
||||
let source = model(extra);
|
||||
let error = packages(&[("demo.account.model", &source)]).unwrap_err();
|
||||
assert_eq!(error.code, "C014", "{extra}: {error:?}");
|
||||
}
|
||||
for source in [
|
||||
"type Account { first: Text from old\nsecond: Text from old }",
|
||||
"type Account { first: Text from second\nsecond: Text }",
|
||||
"type Account { first: Text from created_at }",
|
||||
] {
|
||||
assert_eq!(
|
||||
packages(&[("demo.account.model", source)])
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C014"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_sql_tracks_boundaries_and_rejects_unsafe_or_lossy_inputs() {
|
||||
let statement = |sqlite: &str, postgres: &str, values: &str| {
|
||||
let extra = format!(
|
||||
"migrate normalize {{ before {{ sqlite = {}\npostgres = {}\nparameters = {values} }} }}",
|
||||
serde_json::to_string(sqlite).unwrap(),
|
||||
serde_json::to_string(postgres).unwrap()
|
||||
);
|
||||
packages(&[("demo.account.model", &model(&extra))])
|
||||
};
|
||||
statement("/* prefix */ UPDATE demo_account_model SET name = ?1 WHERE name = ?2 AND ';?90' = ';?90'; -- end", "/* nested /* ok */ */ UPDATE demo_account_model SET name = $1 WHERE name = $2 AND $text$;$90$text$ = $text$;$90$text$; -- end", "[\"new\",\"old\"]").unwrap();
|
||||
for sql in [
|
||||
"COMMIT",
|
||||
"SELECT ?1, ?2",
|
||||
"DROP TABLE account",
|
||||
"UPDATE account SET name = ?1; DELETE FROM account WHERE name = ?2",
|
||||
"UPDATE account SET name = ?3 WHERE name = ?2",
|
||||
"UPDATE account SET name = $1 WHERE name = $2",
|
||||
"UPDATE _dever_history SET name = ?1 WHERE name = ?2",
|
||||
"UPDATE sqlite_master SET name = ?1 WHERE name = ?2",
|
||||
"DELETE FROM main.'_dever_history' WHERE name IN (?1,?2)",
|
||||
"UPDATE account SET name = '未闭合",
|
||||
"UPDATE [未闭合 SET name = ?1 WHERE name = ?2",
|
||||
] {
|
||||
let error = statement(
|
||||
sql,
|
||||
"UPDATE account SET name = $1 WHERE name = $2",
|
||||
"[\"new\",\"old\"]",
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(error.code, "C014", "{sql}: {error:?}");
|
||||
}
|
||||
for sql in [
|
||||
"UPDATE account SET name = $tag$未闭合",
|
||||
"UPDATE account SET name = $$未闭合",
|
||||
"UPDATE pg_catalog.pg_class SET name = $1",
|
||||
] {
|
||||
assert_eq!(
|
||||
statement("UPDATE account SET name = ?1", sql, "[\"new\"]")
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C014"
|
||||
);
|
||||
}
|
||||
for value in ["1.25", "1e999", "9223372036854775808", "name", "1 + 2"] {
|
||||
assert_eq!(
|
||||
statement(
|
||||
"UPDATE account SET name = ?1",
|
||||
"UPDATE account SET name = $1",
|
||||
&format!("[{value}]")
|
||||
)
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C014"
|
||||
);
|
||||
}
|
||||
statement(
|
||||
"UPDATE account SET name = ?1 WHERE status = ?2",
|
||||
"UPDATE account SET name = $1 WHERE status = $2",
|
||||
"[\"new\",1e0]",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
use super::checked;
|
||||
|
||||
const TYPES: &str = "type Result { Ready(value: Int)\n error Failed(message: Text) }\ntype Box { result: Result\n label: Text }\nfailValue(message: Text) (value: Int) { fail(Result.Failed(message)) }\nfailure(message: Text) (value: Result) { value = result(failValue(message)) }\n";
|
||||
|
||||
#[test]
|
||||
fn rejects_discard_overwrite_and_nested_failure_loss() {
|
||||
for body in [
|
||||
"main() () { value = failure(\"bad\") }",
|
||||
"main() (answer: Result) { answer = failure(\"bad\")\n answer = Result.Ready(1) }",
|
||||
"main() (answer: Text) { box = Box { result = failure(\"bad\")\n label = \"x\" }\n answer = box.label }",
|
||||
"main() (answer: Int) { values = [failure(\"bad\")]\n answer = length(values) }",
|
||||
"ignore(value: Result) () {}",
|
||||
"read(value: Result.Ready(number)) (answer: Int) { answer = number }\nread(value: Result.Failed(message)) (answer: Int) { answer = length(message) }",
|
||||
"main() (answer: Result?) { old = failure(\"bad\")\n answer = null }",
|
||||
"main(old: Result, next: Result?) (answer: Result?) { answer = next }",
|
||||
"main(old: Result, next: List<Result>) (answer: List<Result>) { answer = next }",
|
||||
] {
|
||||
let error = checked(&(TYPES.to_owned() + body)).unwrap_err();
|
||||
assert_eq!(error.code, "C012", "{body}: {error:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_arguments_keep_default_propagation() {
|
||||
let source = TYPES.to_owned()
|
||||
+ "identity(value: Int) (answer: Int) { answer = value }\nmain() (answer: Result) { answer = result(identity(failValue(\"bad\"))) }";
|
||||
let program = checked(&source).unwrap();
|
||||
let main = program
|
||||
.functions
|
||||
.iter()
|
||||
.find(|function| function.signature.name == "main")
|
||||
.unwrap();
|
||||
assert_eq!(main.signature.failures.len(), 1);
|
||||
assert!(main.signature.returns.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_return_propagation_translation_and_explicit_recovery() {
|
||||
for body in [
|
||||
"main() (answer: Result) { answer = failure(\"bad\") }",
|
||||
"main() (answer: Int) { answer = failValue(\"bad\") }",
|
||||
"main() (answer: Result) { old = failure(\"bad\")\n answer = Result.Failed(\"replacement\") }",
|
||||
"identity(value: Result) (answer: Result) { answer = value }\nmain() (answer: Result) { first = failure(\"bad\")\n second = identity(first)\n answer = first }",
|
||||
"read(value: Result.Ready(number)) (answer: Int) { answer = number }\nread(value: Result.Failed(message)) (answer: Int) { fail(Result.Failed(message)) }",
|
||||
"read(value: Result.Ready(number)) (answer: Int) recover(\"use zero when unavailable\") pure { answer = number }\nread(value: Result.Failed(message)) (answer: Int) pure recover(\"use zero when unavailable\") { answer = 0 }",
|
||||
] {
|
||||
let result = checked(&(TYPES.to_owned() + body));
|
||||
assert!(result.is_ok(), "{body}: {result:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_and_fail_are_not_generic_error_bypasses() {
|
||||
for body in [
|
||||
"main() (answer: Int) { fail(Result.Ready(1)) }",
|
||||
"main() (answer: Int) { fail(1) }",
|
||||
"main() (answer: Int) recover(\" \") { answer = 0 }",
|
||||
"read(value: Result.Ready(number)) (answer: Int) recover(\"fallback\") { answer = number }\nread(value: Result.Failed(message)) (answer: Int) { answer = 0 }",
|
||||
"main() (answer: Int) { fail(Result.Failed(\"bad\"))\n answer = 1 }",
|
||||
] {
|
||||
assert!(checked(&(TYPES.to_owned() + body)).is_err(), "{body}");
|
||||
}
|
||||
let recovery = "handled(value: Result) (answer: Bool) recover(\"discard unavailable optional probe\") { answer = true }\n";
|
||||
for expression in [
|
||||
"false and handled(value)",
|
||||
"true or handled(value)",
|
||||
"flag and handled(value)",
|
||||
] {
|
||||
let body = format!(
|
||||
"main(flag: Bool) (answer: Bool) {{ value = failure(\"bad\")\n answer = {expression} }}"
|
||||
);
|
||||
assert_eq!(
|
||||
checked(&(TYPES.to_owned() + recovery + &body))
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C012",
|
||||
"{body}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_requires_exact_nominal_error_identity() {
|
||||
let prefix = "type Problem { error Denied(code: Int)\n error Missing(message: Text) }\ntype Result { Ready(value: Int)\n error Failed(problem: Problem) }\nraise() (value: Int) { fail(Problem.Denied(7)) }\nmain() (answer: Result) { answer = result(raise()) }";
|
||||
assert!(checked(prefix).is_ok());
|
||||
let ambiguous = prefix.replace(
|
||||
"error Failed(problem: Problem)",
|
||||
"error Failed(problem: Problem)\n error Again(problem: Problem)",
|
||||
);
|
||||
assert!(checked(&ambiguous).is_err());
|
||||
let wrong = prefix.replace("Failed(problem: Problem)", "Failed(problem: Int)");
|
||||
assert!(checked(&wrong).is_err());
|
||||
let normal = prefix.replace("error Missing", "Missing");
|
||||
assert!(checked(&normal).is_ok());
|
||||
let unrelated = format!(
|
||||
"type Other {{ error Denied(code: Int) }}\n{}",
|
||||
prefix.replace("Failed(problem: Problem)", "Failed(problem: Other)")
|
||||
);
|
||||
assert!(checked(&unrelated).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_preserves_zero_and_multiple_success_outputs() {
|
||||
for source in [
|
||||
"type Done { Complete }\nwork() () {}\nmain() (value: Done) { value = result(work()) }",
|
||||
"type Pair { Complete(number: Int, text: Text) }\nwork() (number: Int, text: Text) { number = 1\n text = \"ok\" }\nmain() (value: Pair) { value = result(work()) }",
|
||||
] {
|
||||
checked(source).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
use dever::compiler::format;
|
||||
use dever::syntax::source::Source;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn format_keeps_literals_comments_groups_and_field_order() {
|
||||
let text = "# leading\nmain( ) (answer: Int) {\n answer=(1+2)*3 # same line\n}\n";
|
||||
let source = Source::new(0, "case.dever", text.into());
|
||||
let output = format::format(&source).unwrap();
|
||||
assert_eq!(
|
||||
output,
|
||||
"# leading\nmain() (answer: Int) {\n answer = (1 + 2) * 3 # same line\n}\n"
|
||||
);
|
||||
assert_eq!(
|
||||
format::format(&Source::new(0, "case.dever", output.clone())).unwrap(),
|
||||
output
|
||||
);
|
||||
let literals = Source::new(
|
||||
0,
|
||||
"case.dever",
|
||||
"main() (answer: Text) { answer=\"# \\u{41} \\\"\" }\n".into(),
|
||||
);
|
||||
assert!(
|
||||
format::format(&literals)
|
||||
.unwrap()
|
||||
.contains("\"# \\u{41} \\\"\"")
|
||||
);
|
||||
let fields = Source::new(
|
||||
0,
|
||||
"case.dever",
|
||||
"main() (answer: Item) { answer=Item { second=2, first=1 } }\n".into(),
|
||||
);
|
||||
assert!(
|
||||
format::format(&fields)
|
||||
.unwrap()
|
||||
.contains("second = 2, first = 1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_preserves_crlf() {
|
||||
let text = "main( ) (answer: Int) {\r\n answer=1\r\n}\r\n";
|
||||
let output = format::format(&Source::new(0, "case.dever", text.into())).unwrap();
|
||||
assert_eq!(
|
||||
output,
|
||||
text.replace("main( )", "main()")
|
||||
.replace(" answer=1", " answer = 1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_preserves_local_type_annotations() {
|
||||
let source = Source::new(
|
||||
0,
|
||||
"case.dever",
|
||||
"main() () { value:List<Int?>=[null,1] }".into(),
|
||||
);
|
||||
let formatted = format::format(&source).unwrap();
|
||||
assert!(
|
||||
formatted.contains("value: List<Int?> = [null, 1]"),
|
||||
"{formatted}"
|
||||
);
|
||||
assert_eq!(
|
||||
format::format(&Source::new(0, "case.dever", formatted.clone())).unwrap(),
|
||||
formatted
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_preserves_text_policy_and_camel_functions() {
|
||||
let input = "type Category { name: Text(1,80)\ncode: Text lower unique\nbody: Text raw\nnote: Text? upper }\nreadName(input_value: Text) (output_value: Text) { output_value = input_value }";
|
||||
let output = format::format(&Source::new(0, "model.dever", input.into())).unwrap();
|
||||
for preserved in [
|
||||
"code: Text lower unique",
|
||||
"body: Text raw",
|
||||
"note: Text? upper",
|
||||
"readName(input_value: Text)",
|
||||
] {
|
||||
assert!(output.contains(preserved), "{output}");
|
||||
}
|
||||
assert_eq!(
|
||||
format::format(&Source::new(0, "model.dever", output.clone())).unwrap(),
|
||||
output
|
||||
);
|
||||
}
|
||||
|
||||
struct Directory(PathBuf);
|
||||
|
||||
impl Directory {
|
||||
fn new() -> Self {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"dever-format-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Directory {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_prepares_module_and_tests_before_any_rewrite() {
|
||||
let directory = Directory::new();
|
||||
std::fs::create_dir(directory.0.join("module")).unwrap();
|
||||
std::fs::create_dir(directory.0.join("test")).unwrap();
|
||||
let module = directory.0.join("module/main.dever");
|
||||
let test = directory.0.join("test/main.dever");
|
||||
let input = "main( ) () {}\n";
|
||||
std::fs::write(&module, input).unwrap();
|
||||
std::fs::write(&test, "main( {").unwrap();
|
||||
assert!(format::project(&directory.0, false).is_err());
|
||||
assert_eq!(std::fs::read_to_string(&module).unwrap(), input);
|
||||
std::fs::write(&test, input).unwrap();
|
||||
let report = format::project(&directory.0, true).unwrap();
|
||||
assert_eq!(report.checked, 2);
|
||||
assert_eq!(report.changed.len(), 2);
|
||||
assert_eq!(std::fs::read_to_string(&module).unwrap(), input);
|
||||
format::project(&directory.0, false).unwrap();
|
||||
assert!(
|
||||
format::project(&directory.0, true)
|
||||
.unwrap()
|
||||
.changed
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn project_rejects_links_without_touching_their_target() {
|
||||
let directory = Directory::new();
|
||||
let target = directory.0.join("actual.dever");
|
||||
std::fs::write(&target, "main( ) () {}\n").unwrap();
|
||||
std::os::unix::fs::symlink(&target, directory.0.join("link.dever")).unwrap();
|
||||
assert!(
|
||||
format::project(&directory.0, false)
|
||||
.unwrap_err()
|
||||
.contains("links")
|
||||
);
|
||||
assert_eq!(std::fs::read_to_string(target).unwrap(), "main( ) () {}\n");
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
use super::checked;
|
||||
|
||||
const BASE: &str = "increment(value: Int) (answer: Int) pure { answer = value + 1 }\ndecrement(value: Int) (answer: Int) pure { answer = value - 1 }\napply(route: handler(value: Int) (answer: Int), value: Int) (answer: Int) { answer = route(value) }\nforward(route: handler(value: Int) (answer: Int), value: Int) (answer: Int) { answer = apply(route, value) }\n";
|
||||
|
||||
#[test]
|
||||
fn specializes_handlers_and_reuses_identical_bindings() {
|
||||
let source = BASE.to_owned()
|
||||
+ "main() (answer: Int) pure { answer = forward(increment, 1) + forward(increment, 2) + forward(decrement, 3) }";
|
||||
let program = checked(&source).unwrap();
|
||||
assert_eq!(program.functions.len(), 7);
|
||||
for name in ["apply", "forward"] {
|
||||
let instances = program
|
||||
.functions
|
||||
.iter()
|
||||
.filter(|function| function.signature.name == name)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(instances.len(), 2);
|
||||
assert!(
|
||||
instances
|
||||
.iter()
|
||||
.all(|function| function.signature.inputs.len() == 1
|
||||
&& function.signature.static_parameters == 1)
|
||||
);
|
||||
}
|
||||
let source =
|
||||
BASE.to_owned() + "main() (answer: List<Int>) { answer = each(increment, [1, 2]) }";
|
||||
let program = checked(&source).unwrap();
|
||||
assert_eq!(program.functions.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checks_unused_templates_and_handler_signatures() {
|
||||
for body in [
|
||||
"bad(route: handler(value: Int) (answer: Int)) (answer: Text) { answer = route(1) }",
|
||||
"bad(route: handler(value: Int) (answer: Int)) (answer: Int) {}",
|
||||
"bad(route: handler(value: List) ()) () {}",
|
||||
"bad(route: handler() (answer: List<Int, Text>)) () {}",
|
||||
"bad(route: handler(private value: Int) ()) () {}",
|
||||
"bad(route: handler(value: Int) (value: Int)) () {}",
|
||||
"bad(route: handler(value: Int) (answer: Int)) (route: Int) { route = 1 }",
|
||||
"wrong(value: Int) (other: Int) { other = value }\nmain() (answer: Int) { answer = apply(wrong, 1) }",
|
||||
"wrong(value: Text) (answer: Int) { answer = length(value) }\nmain() (answer: Int) { answer = apply(wrong, 1) }",
|
||||
"main() (answer: Int) { answer = apply(increment(1), 1) }",
|
||||
"main() (answer: Int) { increment = 1\n answer = apply(increment, 1) }",
|
||||
"bad(route: handler(value: Text) (answer: Int), value: Int) (answer: Int) { answer = forward(route, value) }",
|
||||
] {
|
||||
assert!(checked(&(BASE.to_owned() + body)).is_err(), "{body}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handlers_are_not_values_or_clause_axes() {
|
||||
for body in [
|
||||
"bad(route: handler(value: Int) (answer: Int)) () { value = route }",
|
||||
"bad(route: handler(value: Int) (answer: Int)) () { route = 1 }",
|
||||
"bad(route: handler(value: Int) (answer: Int)) () { values = [route] }",
|
||||
"bad(route: handler(value: Int) (answer: Int)) (answer: Bool) { answer = route == route }",
|
||||
"bad(route: handler(value: Int) (answer: Int), value: Int) (answer: Int) { answer = route(value) }\nbad(route: other, value: other) (answer: Int) { answer = 0 }",
|
||||
"bad(route: handler(value: Int) (answer: Int), value: Int) (answer: Int) { answer = route(value) }\nbad(route: handler(value: Int) (answer: Int), value: Int) (answer: Int) { answer = 0 }",
|
||||
] {
|
||||
assert!(checked(&(BASE.to_owned() + body)).is_err(), "{body}");
|
||||
}
|
||||
let source = BASE.to_owned()
|
||||
+ "choose(route: handler(value: Int) (answer: Int), value: Int < 0) (answer: Int) { answer = 0 }\nchoose(route: handler(value: Int) (answer: Int), value: other) (answer: Int) { answer = route(value) }\nmain() (answer: Int) { answer = choose(increment, 1) }";
|
||||
assert!(checked(&source).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handler_cycles_and_unrestricted_pure_effects_are_rejected() {
|
||||
for source in [
|
||||
BASE.to_owned() + "cycle(value: Int) (answer: Int) { answer = forward(cycle, value) }",
|
||||
BASE.to_owned()
|
||||
+ "unused(route: handler(value: Int) (answer: Int), value: Int) (answer: Int) { answer = unused(route, value) }",
|
||||
] {
|
||||
assert_eq!(checked(&source).unwrap_err().code, "C007");
|
||||
}
|
||||
for body in [
|
||||
"bad(route: handler(value: Int) (answer: Int), value: Int) (answer: Int) pure { answer = route(value) }",
|
||||
"bad(route: handler(value: Int) (answer: Int), value: Int) (answer: Int) pure { answer = forward(route, value) }",
|
||||
"bad(route: handler(value: Int) (answer: Int), values: List<Int>) (answers: List<Int>) pure { answers = each(route, values) }",
|
||||
] {
|
||||
assert_eq!(
|
||||
checked(&(BASE.to_owned() + body)).unwrap_err().code,
|
||||
"C013",
|
||||
"{body}"
|
||||
);
|
||||
}
|
||||
let source = BASE.to_owned()
|
||||
+ "ignore(route: handler(value: Int) (answer: Int), value: Int) (answer: Int) pure { answer = value }\npass(route: handler(value: Int) (answer: Int), value: Int) (answer: Int) pure { answer = ignore(route, value) }";
|
||||
assert!(checked(&source).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generic_error_outputs_keep_handling_obligations() {
|
||||
let prefix = "type Result { Ready(value: Int)\n error Failed(message: Text) }\n";
|
||||
for body in [
|
||||
"bad(route: handler(value: Int) (answer: Result)) () { lost = route(1) }",
|
||||
"bad(route: handler(value: Int) (answer: Result), values: List<Int>) (answer: Int) { lost = each(route, values)\n answer = length(lost) }",
|
||||
"bad(route: handler(value: Int) (answer: Result)) (answer: Result) { answer = route(1)\n answer = Result.Ready(0) }",
|
||||
] {
|
||||
assert_eq!(
|
||||
checked(&(prefix.to_owned() + body)).unwrap_err().code,
|
||||
"C012",
|
||||
"{body}"
|
||||
);
|
||||
}
|
||||
let source = prefix.to_owned()
|
||||
+ "valid(route: handler(value: Int) (answer: Result)) (answer: Result) { answer = route(1) }";
|
||||
assert!(checked(&source).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_binding_keeps_its_own_nominal_failure_set() {
|
||||
let source = BASE.to_owned()
|
||||
+ "type First { error Failed(code: Int) }\ntype Second { error Failed(code: Int) }\ntype FirstResult { Ready(value: Int)\n error Failed(problem: First) }\ntype SecondResult { Ready(value: Int)\n error Failed(problem: Second) }\nfirst(value: Int) (answer: Int) { fail(First.Failed(value)) }\nsecond(value: Int) (answer: Int) { fail(Second.Failed(value)) }\nfirstEntry() (answer: FirstResult) { answer = result(forward(first, 1)) }\nsecondEntry() (answer: SecondResult) { answer = result(forward(second, 2)) }";
|
||||
let program = checked(&source).unwrap();
|
||||
let instances = program
|
||||
.functions
|
||||
.iter()
|
||||
.filter(|function| function.signature.name == "apply")
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(instances.len(), 2);
|
||||
assert!(
|
||||
instances
|
||||
.iter()
|
||||
.all(|function| function.signature.failures.len() == 1)
|
||||
);
|
||||
assert_ne!(
|
||||
instances[0].signature.failures,
|
||||
instances[1].signature.failures
|
||||
);
|
||||
let wrong = source.replace(
|
||||
"firstEntry() (answer: FirstResult)",
|
||||
"firstEntry() (answer: SecondResult)",
|
||||
);
|
||||
assert_eq!(checked(&wrong).unwrap_err().code, "C005");
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
use dever::{
|
||||
compiler::{build, model::Program, project::Project},
|
||||
syntax::source::Source,
|
||||
};
|
||||
use std::path::Path;
|
||||
|
||||
#[path = "http/live.rs"]
|
||||
mod live;
|
||||
#[path = "http/streaming.rs"]
|
||||
mod streaming;
|
||||
#[path = "http/websocket.rs"]
|
||||
mod websocket;
|
||||
|
||||
fn linked(text: &str) -> Result<Program, String> {
|
||||
let mut project = Project::load(
|
||||
&Path::new(env!("CARGO_MANIFEST_DIR")).join("../test/rust/fixtures/http.dever"),
|
||||
)
|
||||
.unwrap();
|
||||
let source = project
|
||||
.sources
|
||||
.iter_mut()
|
||||
.find(|source| source.path.ends_with("http.dever"))
|
||||
.unwrap();
|
||||
*source = Source::new(source.id, "case.dever", text.into());
|
||||
project.check()
|
||||
}
|
||||
|
||||
const ROUTE: &str = "route(request: dever.http.Request) (response: dever.http.Response) { response = dever.http.Response { status = 200, headers = [], body = request.body } }";
|
||||
|
||||
#[test]
|
||||
fn leaf_http_handlers_do_not_convert_unused_request_fields() {
|
||||
let project = Project::load(
|
||||
&Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../test/performance/fixtures/http/concurrent.dever"),
|
||||
)
|
||||
.unwrap();
|
||||
let program = project.check().unwrap();
|
||||
let main = program
|
||||
.functions
|
||||
.iter()
|
||||
.position(|function| function.signature.name == "main")
|
||||
.unwrap();
|
||||
let source = dever::compiler::emit::generate(&program, &project.sources, &[main].into());
|
||||
assert!(source.contains("http::serve_context"), "{source}");
|
||||
assert!(source.contains("struct RequestView"), "{source}");
|
||||
assert!(!source.contains("parts.method.to_string()"), "{source}");
|
||||
assert!(
|
||||
!source.contains("received_headers(&parts.headers)"),
|
||||
"{source}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tls_pool_types_are_resources_and_configuration_failure_is_explicit() {
|
||||
for (source, code) in [
|
||||
(
|
||||
"main() () { lost = result(dever.tls.client(dever.bytes.fromText(\"invalid\"))) }",
|
||||
"C012",
|
||||
),
|
||||
(
|
||||
"main(config: dever.system.ClientTls) (same: Bool) { same = config == config }",
|
||||
"C005",
|
||||
),
|
||||
(
|
||||
"main() (config: dever.system.ClientTls) { config = dever.system.tlsSystem() }",
|
||||
"C006",
|
||||
),
|
||||
(
|
||||
"main() (client: dever.system.HttpClient) pure { client = dever.http.client(\"http://localhost\", dever.tls.system(), dever.http.defaultLimits(), dever.http.defaultPoolLimits()) }",
|
||||
"C013",
|
||||
),
|
||||
] {
|
||||
let error = linked(source).unwrap_err();
|
||||
assert!(error.contains(code), "{error}");
|
||||
}
|
||||
let program = linked("main() (client: dever.system.HttpClient) { client = dever.http.client(\"http://localhost\", dever.tls.system(), dever.http.defaultLimits(), dever.http.defaultPoolLimits()) }").unwrap();
|
||||
let main = program
|
||||
.functions
|
||||
.iter()
|
||||
.find(|function| function.signature.name == "main")
|
||||
.unwrap();
|
||||
assert!(main.signature.effects.schedule && main.signature.effects.suspend);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_effects_and_runtime_selection_follow_reachable_calls() {
|
||||
for (source, http, schedule) in [
|
||||
("main() (answer: Int) { answer = 1 }".into(), false, false),
|
||||
("main(request: dever.http.Request) (response: dever.http.Response) { response = dever.http.send(\"127.0.0.1\", 80, request, dever.http.defaultLimits()) }".into(), true, false),
|
||||
(format!("{ROUTE}\nmain(listener: dever.system.Listener) () {{ dever.http.serve(route, listener, dever.http.defaultLimits()) }}"), true, true),
|
||||
] {
|
||||
let program = linked(&source).unwrap();
|
||||
let main = program.functions.iter().position(|function| function.signature.name == "main").unwrap();
|
||||
let effects = program.functions[main].signature.effects;
|
||||
assert_eq!(effects.network, http);
|
||||
assert_eq!(effects.suspend, http);
|
||||
assert_eq!(effects.schedule, schedule);
|
||||
assert_eq!(build::runtime_features(&program, &program.reachable([main])).contains(&"http"), http);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_preserves_handler_types_effect_boundaries_and_failure_obligations() {
|
||||
for (source, code) in [
|
||||
(format!("{ROUTE}\nmain(listener: dever.system.Listener) () {{ dever.system.httpServe(route, listener, dever.http.defaultLimits()) }}"), "C006"),
|
||||
(format!("{ROUTE}\nmain(listener: dever.system.Listener) () pure {{ dever.http.serve(route, listener, dever.http.defaultLimits()) }}"), "C013"),
|
||||
("route(request: Int) (response: Int) { response = request }\nmain(listener: dever.system.Listener) () { dever.http.serve(route, listener, dever.http.defaultLimits()) }".into(), "C005"),
|
||||
("route(request: dever.http.Request) (response: dever.http.Response) { dever.io.println(\"blocking\")\nresponse = dever.http.Response { status = 200, headers = [], body = request.body } }\nmain(listener: dever.system.Listener) () { dever.http.serve(route, listener, dever.http.defaultLimits()) }".into(), "C013"),
|
||||
("main(request: dever.http.Request) () { lost = result(dever.http.send(\"127.0.0.1\", 80, request, dever.http.defaultLimits())) }".into(), "C012"),
|
||||
] {
|
||||
let error = linked(&source).unwrap_err();
|
||||
assert!(error.contains(code), "{source}\n{error}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
use super::*;
|
||||
|
||||
const LIVE: &str = "route(request: dever.http.Request, reply: dever.system.HttpReply) () { dever.sse.start(reply, [])\ndever.sse.send(reply, dever.sse.Event { event = \"tick\", data = request.target, id = null, retry_ms = 10 }) }\nmain(listener: dever.system.Listener) () { dever.http.serveLive(route, listener, dever.http.defaultLimits(), dever.http.defaultLiveLimits()) }";
|
||||
|
||||
#[test]
|
||||
fn live_static_handler_shapes_effects_and_metadata_are_checked() {
|
||||
let program = linked(LIVE).unwrap();
|
||||
let main = program
|
||||
.functions
|
||||
.iter()
|
||||
.find(|function| function.signature.name == "main")
|
||||
.unwrap();
|
||||
assert!(
|
||||
main.signature.effects.network
|
||||
&& main.signature.effects.schedule
|
||||
&& main.signature.effects.suspend
|
||||
);
|
||||
for (source, code) in [
|
||||
(LIVE.replace("reply: dever.system.HttpReply", "reply: dever.system.Socket"), "C005"),
|
||||
(LIVE.replace("dever.sse.start(reply, [])", "dever.io.println(\"blocked\")\ndever.sse.start(reply, [])"), "C013"),
|
||||
("main(reply: dever.system.HttpReply) (same: Bool) { same = reply == reply }".into(), "C005"),
|
||||
("main(reply: dever.system.HttpReply) () { lost = result(dever.http.write(reply, dever.bytes.fromText(\"x\"))) }".into(), "C012"),
|
||||
("main(reply: dever.system.HttpReply) () { dever.system.httpFinish(reply) }".into(), "C006"),
|
||||
] { let error = linked(&source).unwrap_err(); assert!(error.contains(code), "{source}\n{error}"); }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_context_keeps_concrete_type_and_rejects_nontransferable_values() {
|
||||
let source = "type Context { prefix: Text }\nroute(request: dever.http.Request, reply: dever.system.HttpReply, context: Context) () { dever.http.start(reply, 200, [])\ndever.http.write(reply, dever.bytes.fromText(context.prefix + request.target)) }\nmain(listener: dever.system.Listener, context: Context) () { dever.system.httpServeLive(route, listener, dever.http.defaultLimits(), dever.http.defaultLiveLimits(), context) }";
|
||||
linked(source).unwrap();
|
||||
let error = linked(&source.replace(
|
||||
"context: Context) () { dever.http.start",
|
||||
"context: Text) () { dever.http.start",
|
||||
))
|
||||
.unwrap_err();
|
||||
assert!(error.contains("C005"), "{error}");
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn streaming_original_source_contract_checks_and_selects_http_runtime() {
|
||||
let project = Project::load(
|
||||
&Path::new(env!("CARGO_MANIFEST_DIR")).join("../test/rust/fixtures/http-stream.dever"),
|
||||
)
|
||||
.unwrap();
|
||||
let program = project.check().unwrap();
|
||||
let entry = program
|
||||
.functions
|
||||
.iter()
|
||||
.position(|function| function.signature.name == "verify")
|
||||
.unwrap();
|
||||
let effects = program.functions[entry].signature.effects;
|
||||
assert!(effects.network && effects.suspend && effects.schedule);
|
||||
assert!(build::runtime_features(&program, &program.reachable([entry])).contains(&"http"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_intrinsics_preserve_types_effects_and_failure_obligations() {
|
||||
linked("main(client: dever.system.HttpClient, request: dever.http.Request) () { response = dever.http.open(client, request)\nclose(response.body) }").unwrap();
|
||||
for (source, code) in [
|
||||
(
|
||||
"main(client: dever.system.HttpClient, request: dever.http.Request) (response: dever.http.StreamResponse) pure { response = dever.http.open(client, request) }",
|
||||
"C013",
|
||||
),
|
||||
(
|
||||
"main(client: dever.system.HttpClient, request: dever.http.Request) (response: dever.http.StreamResponse) { response = dever.http.openStream(client, request) }",
|
||||
"C005",
|
||||
),
|
||||
(
|
||||
"main(client: dever.system.HttpClient, request: dever.http.Request) () { lost = result(dever.http.open(client, request)) }",
|
||||
"C012",
|
||||
),
|
||||
(
|
||||
"main(client: dever.system.HttpClient, request: dever.http.Request) () { dever.system.httpOpen(client, request) }",
|
||||
"C006",
|
||||
),
|
||||
] {
|
||||
let error = linked(source).unwrap_err();
|
||||
assert!(error.contains(code), "{source}\n{error}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn websocket_original_contract_effects_and_resource_rules() {
|
||||
let source = "route(request: dever.http.Request, reply: dever.system.HttpReply) () { socket = dever.websocket.accept(reply, dever.websocket.defaultLimits())\ndever.websocket.send(socket, dever.websocket.Message.Text(request.target))\ndever.websocket.close(socket, 1000, \"\") }\nmain(listener: dever.system.Listener) () { dever.http.serveLive(route, listener, dever.http.defaultLimits(), dever.http.defaultLiveLimits()) }";
|
||||
let program = linked(source).unwrap();
|
||||
let main = program
|
||||
.functions
|
||||
.iter()
|
||||
.find(|function| function.signature.name == "main")
|
||||
.unwrap();
|
||||
assert!(
|
||||
main.signature.effects.network
|
||||
&& main.signature.effects.suspend
|
||||
&& main.signature.effects.schedule
|
||||
);
|
||||
for (source, code) in [
|
||||
(
|
||||
"main(socket: dever.system.WebSocket) (same: Bool) { same = socket == socket }",
|
||||
"C005",
|
||||
),
|
||||
(
|
||||
"main(socket: dever.system.WebSocket) () { dever.system.wsReceive(socket) }",
|
||||
"C006",
|
||||
),
|
||||
(
|
||||
"main(socket: dever.system.WebSocket) () { lost = result(dever.websocket.receive(socket)) }",
|
||||
"C012",
|
||||
),
|
||||
(
|
||||
"main() () { lost = result(dever.websocket.connect(\"127.0.0.1\", 80, \"/\", dever.websocket.defaultLimits())) }",
|
||||
"C012",
|
||||
),
|
||||
(
|
||||
"main(socket: dever.system.WebSocket) (value: dever.websocket.Message) { value = first(dever.websocket.messages(socket)) }",
|
||||
"C005",
|
||||
),
|
||||
] {
|
||||
let error = linked(source).unwrap_err();
|
||||
assert!(error.contains(code), "{source}\n{error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_messages_are_lazy_and_keep_terminal_failure_obligations() {
|
||||
let source = "main(socket: dever.system.WebSocket) (stream: AsyncStream<dever.websocket.MessageEvent>) { stream = dever.websocket.messages(socket) }";
|
||||
let program = linked(source).unwrap();
|
||||
let main = program
|
||||
.functions
|
||||
.iter()
|
||||
.find(|function| function.signature.name == "main")
|
||||
.unwrap();
|
||||
assert!(!main.signature.effects.suspend);
|
||||
let source = "read(event: dever.websocket.MessageEvent.Read(message)) () {}\nread(event: dever.websocket.MessageEvent.Failed(message)) () { fail(dever.websocket.MessageEvent.Failed(message)) }\nmain(socket: dever.system.WebSocket) () { each(read, dever.websocket.messages(socket)) }";
|
||||
linked(source).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
use dever::compiler::{check, model::Program};
|
||||
use dever::syntax::{diagnostic::Diagnostic, source::Source};
|
||||
|
||||
fn linked(text: &str) -> Result<Program, Diagnostic> {
|
||||
let sources = [
|
||||
Source::new(0, "main.dever", text.into()),
|
||||
Source::new(
|
||||
1,
|
||||
"lib/dever/id.dever",
|
||||
include_str!("../../../lib/dever/id.dever").into(),
|
||||
),
|
||||
];
|
||||
let modules = sources
|
||||
.iter()
|
||||
.map(dever::syntax::parse)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
check::check(modules, &["main".into(), "dever.id".into()])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_are_opaque_and_distinct_from_text() {
|
||||
for source in [
|
||||
"main(text: Text) (answer: Id) { answer = text }",
|
||||
"main(id: Id) (answer: Text) { answer = id }",
|
||||
"main(id: Id) (answer: Int) { answer = length(id) }",
|
||||
"main(id: Id) (answer: Bool) { answer = id == \"x\" }",
|
||||
"main(id: Id) (answer: Bool) { answer = id < id }",
|
||||
"main(id: Id) (answer: Id) { answer = id + id }",
|
||||
"main(id: Id) (answer: Text) { answer = id.value }",
|
||||
"main() (answer: Id) { answer = Id { value = \"x\" } }",
|
||||
"main(id: Id) () { value = dever.id.fromText(id) }",
|
||||
"main() () { value = dever.id.toText(\"x\") }",
|
||||
] {
|
||||
assert!(linked(source).is_err(), "{source}");
|
||||
}
|
||||
assert_eq!(
|
||||
linked("main() (answer: Id) { answer = dever.system.idFromText(\"x\") }")
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C006"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_can_be_keys_and_nullable_values_without_implicit_conversion() {
|
||||
for source in [
|
||||
"main(id: Id) (answer: Map<Id, Int>) { answer = { id = 1 } }",
|
||||
"main(id: Id, values: Map<Id, Text>) (answer: Text?) { answer = get(values, id) }",
|
||||
"main(id: Id, values: Map<Id, Id?>) (answer: Id?) { answer = get(values, id) }",
|
||||
"main(values: List<Text>) (answer: List<Id>) { answer = each(dever.id.fromText, values) }",
|
||||
"main(id: Id?) (answer: Id?) { answer = id }",
|
||||
] {
|
||||
let result = linked(source);
|
||||
assert!(result.is_ok(), "{result:?}");
|
||||
}
|
||||
assert_eq!(
|
||||
linked("main(id: Id) () { alias = id\n value = { id = 1\n alias = 2 } }")
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C002"
|
||||
);
|
||||
assert!(linked("main(values: Map<Id, Int>) () { found = get(values, \"x\") }").is_err());
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
use dever::compiler::{check, model::Program};
|
||||
use dever::syntax::{diagnostic::Diagnostic, source::Source};
|
||||
|
||||
fn linked(text: &str, identity: &str) -> Result<Program, Diagnostic> {
|
||||
let sources = [
|
||||
Source::new(0, "main.dever", text.into()),
|
||||
Source::new(
|
||||
1,
|
||||
"lib/dever/io.dever",
|
||||
include_str!("../../../../lib/dever/io.dever").into(),
|
||||
),
|
||||
];
|
||||
let modules = sources
|
||||
.iter()
|
||||
.map(dever::syntax::parse)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
check::check(modules, &[identity.into(), "dever.io".into()])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_rejects_direct_transitive_captured_and_handler_io() {
|
||||
let prefix = "write(value: Text) (answer: Text) { dever.io.println(value)\n answer = value }\napply(route: handler(value: Text) (answer: Text), value: Text) (answer: Text) { answer = route(value) }\n";
|
||||
for source in [
|
||||
"main(path: Text) () pure { file = dever.io.open(path) }".into(),
|
||||
"main(path: Text) (answer: dever.io.OpenResult) pure { answer = result(dever.io.open(path)) }".into(),
|
||||
"main() () pure { dever.io.println(\"hello\") }".into(),
|
||||
prefix.to_owned() + "main() (answer: Text) pure { answer = write(\"hello\") }",
|
||||
prefix.to_owned() + "main() (answer: Text) pure { answer = apply(write, \"hello\") }",
|
||||
prefix.to_owned() + "main() (answer: List<Text>) pure { answer = each(write, [\"hello\"]) }",
|
||||
"unused(route: handler() ()) () pure { route() }".into(),
|
||||
] {
|
||||
let error = linked(&source, "main").unwrap_err();
|
||||
assert_eq!(error.code, "C013", "{source}: {error:?}");
|
||||
}
|
||||
let source = "identity(value: Text) (answer: Text) { answer = value }\napply(route: handler(value: Text) (answer: Text), value: Text) (answer: Text) { answer = route(value) }\nmain() (answer: Text) pure { answer = apply(identity, \"hello\") }";
|
||||
assert!(linked(source, "main").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_equality_cannot_hide_inside_value_containers() {
|
||||
for source in [
|
||||
"main(file: dever.system.File) (answer: Bool) { answer = file == file }",
|
||||
"main(file: dever.system.File) (answer: Bool) { answer = [file] == [file] }",
|
||||
"type Box { file: dever.system.File }\nmain(value: Box) (answer: Bool) { answer = value == value }",
|
||||
"type Box { Some(file: dever.system.File)\n None }\nmain(value: Box) (answer: Bool) { answer = value == value }",
|
||||
"main(file: dever.system.File?) (answer: Bool) { answer = file == null }",
|
||||
"main(files: Map<dever.system.File, Int>) () {}",
|
||||
] {
|
||||
assert_eq!(linked(source, "main").unwrap_err().code, "C005", "{source}");
|
||||
}
|
||||
assert!(linked("type Box { files: List<dever.system.File> }\nmain(value: Box) (answer: Box) { answer = value }", "main").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_errors_keep_nominal_identity_and_handling_obligations() {
|
||||
let source =
|
||||
"main(path: Text) (answer: dever.io.OpenResult) { answer = result(dever.io.open(path)) }";
|
||||
assert!(linked(source, "main").is_ok());
|
||||
assert_eq!(
|
||||
linked(
|
||||
"main(path: Text) () { lost = result(dever.io.open(path)) }",
|
||||
"main"
|
||||
)
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C012"
|
||||
);
|
||||
assert_eq!(linked("type OpenResult { Opened(file: dever.system.File)\n error Failed(message: Text) }\nmain(path: Text) (answer: OpenResult) { answer = result(dever.io.open(path)) }", "main").unwrap_err().code, "C005");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_file_intrinsics_and_unmediated_app_effects_are_rejected() {
|
||||
assert_eq!(
|
||||
linked(
|
||||
"main() () { value = dever.system.fileOpen(\"file\") }",
|
||||
"main"
|
||||
)
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C006"
|
||||
);
|
||||
for identity in ["app.docs.app.files", "app.docs.domain.files"] {
|
||||
let source = "helper(path: Text) (file: dever.system.File) { file = dever.io.open(path) }\nmain(path: Text) (file: dever.system.File) { file = helper(path) }";
|
||||
assert_eq!(linked(source, identity).unwrap_err().code, "C013");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_operations_remain_lazy_and_effectful() {
|
||||
let prefix = "count(item: Int, state: Int) (next: Int) { next = state + item }\n";
|
||||
for source in [
|
||||
"main(stream: Stream<Int>) () pure { close(stream) }".into(),
|
||||
prefix.to_owned()
|
||||
+ "main(stream: Stream<Int>) (answer: Int) pure { answer = reduce(count, stream, 0) }",
|
||||
] {
|
||||
assert_eq!(
|
||||
linked(&source, "main").unwrap_err().code,
|
||||
"C013",
|
||||
"{source}"
|
||||
);
|
||||
}
|
||||
for source in [
|
||||
"same(item: Int) (answer: Int) { answer = item }\nmain(stream: Stream<Int>) (answer: List<Int>) { answer = each(same, stream) }",
|
||||
"yes(item: Int) (answer: Bool) { answer = true }\nmain(stream: Stream<Int>) (answer: Int?) { answer = find(yes, stream) }",
|
||||
"main(stream: Stream<Int>) (answer: Int) { answer = length(stream) }",
|
||||
"main(stream: Stream<Int>) (answer: Bool) { answer = stream == stream }",
|
||||
"main(file: dever.system.File) () { close(file) }",
|
||||
] {
|
||||
assert_eq!(linked(source, "main").unwrap_err().code, "C005", "{source}");
|
||||
}
|
||||
assert!(linked("main(file: dever.system.File) () { stream = dever.io.chunks(file, 1)\n close(stream) }", "main").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_events_cannot_drop_failures_or_leak_private_types() {
|
||||
for source in [
|
||||
"ignore(event: dever.io.ReadEvent) () {}\nmain(stream: Stream<dever.io.ReadEvent>) () { each(ignore, stream) }",
|
||||
"ignore(event: dever.io.ReadEvent, state: Int) (next: Int) { next = state }\nmain(stream: Stream<dever.io.ReadEvent>) (answer: Int) { answer = reduce(ignore, stream, 0) }",
|
||||
"main(file: dever.system.File) () { lost = result(dever.io.chunks(file, 0)) }",
|
||||
] {
|
||||
assert_eq!(linked(source, "main").unwrap_err().code, "C012", "{source}");
|
||||
}
|
||||
assert_eq!(
|
||||
linked(
|
||||
"type Hidden { value: Int }\npublic type Visible { stream: Stream<Hidden> }",
|
||||
"main"
|
||||
)
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C006"
|
||||
);
|
||||
assert_eq!(
|
||||
linked("type Cycle { stream: Stream<Cycle> }", "main")
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C007"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
use super::{database::ERROR, package::packages};
|
||||
|
||||
mod tasks;
|
||||
|
||||
fn checked(
|
||||
app: &str,
|
||||
job: &str,
|
||||
api: &str,
|
||||
) -> Result<dever::compiler::model::Program, dever::syntax::diagnostic::Diagnostic> {
|
||||
packages(&[
|
||||
("dever.database", ERROR),
|
||||
("demo.queue.app", app),
|
||||
("demo.queue.job", job),
|
||||
("demo.queue.api", api),
|
||||
])
|
||||
}
|
||||
|
||||
const APP: &str = "type Payload { name: Text }\nsubmit(key: Text) (id: Id) { id = dever.job.enqueue(job.publish, Payload { name = key }, key) }\napply(input: Payload) () {}";
|
||||
const JOB: &str = "database default\njob publish(input: app.Payload) () retry(2) timeout(1000) { app.apply(input) }";
|
||||
|
||||
#[test]
|
||||
fn persistent_job_is_a_separate_entry_and_not_a_synchronous_call() {
|
||||
let program = checked(APP, JOB, "cmd submit = app.submit").unwrap();
|
||||
assert_eq!(program.jobs.len(), 1);
|
||||
let job = &program.jobs[0];
|
||||
assert_eq!(job.name, "demo.queue.job.publish");
|
||||
assert!(program.entries().any(|entry| entry == job.function));
|
||||
assert!(
|
||||
program.functions[program.commands[0].function]
|
||||
.signature
|
||||
.effects
|
||||
.enqueue
|
||||
);
|
||||
let called = APP.replace(
|
||||
"dever.job.enqueue(job.publish, Payload { name = key }, key)",
|
||||
"job.publish(Payload { name = key })",
|
||||
);
|
||||
assert!(checked(&called, JOB, "cmd submit = app.submit").is_err());
|
||||
assert!(checked(APP, JOB, "public post submit = app.submit").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_contract_rejects_bad_policy_payload_effects_and_schedules() {
|
||||
for job in [
|
||||
JOB.replace("retry(2)", "retry(0)"),
|
||||
JOB.replace("timeout(1000)", "timeout(0)"),
|
||||
JOB.replace("database default\n", ""),
|
||||
JOB.replace("app.apply(input)", "dever.sleep(1)"),
|
||||
format!("{JOB}\nschedule publish = \"* * * * *\""),
|
||||
] {
|
||||
assert!(
|
||||
checked(APP, &job, "cmd submit = app.submit").is_err(),
|
||||
"{job}"
|
||||
);
|
||||
}
|
||||
let secret = APP.replace("name: Text", "name: Secret");
|
||||
assert!(checked(&secret, JOB, "cmd submit = app.submit").is_err());
|
||||
let app = "run() () {}";
|
||||
let jobs = "database default\njob cleanup() () retry(1) timeout(100) { app.run() }\nschedule cleanup = \"*/5 * * * *\"";
|
||||
let program = checked(app, jobs, "").unwrap();
|
||||
assert_eq!(program.schedules.len(), 1);
|
||||
let features =
|
||||
dever::compiler::build::runtime_features(&program, &program.reachable(program.entries()));
|
||||
assert!(features.contains(&"database"));
|
||||
assert_eq!(features.contains(&"postgres"), cfg!(feature = "postgres"));
|
||||
assert!(checked(app, &jobs.replace("*/5", "*/0"), "").is_err());
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
use super::{APP, JOB, checked};
|
||||
|
||||
fn app(body: &str) -> String {
|
||||
format!(
|
||||
"{}\ntransaction send(key: Text) () {{ id = submit(key) }}\nlaunch() () {{ {body} }}",
|
||||
APP
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tasks_can_inherit_enqueue_identity_without_sharing_a_transaction() {
|
||||
for body in [
|
||||
"pending = run(send(\"one\"))\nwait(pending)\nsend(\"after\")",
|
||||
"pool = group(2)\nrun(pool, send(\"one\"))\nrun(pool, send(\"two\"))\nwait(pool)\nsend(\"after\")",
|
||||
"parallelEach(send, [\"one\", \"two\"], 2)\nsend(\"after\")",
|
||||
] {
|
||||
let source = app(body);
|
||||
let program = checked(&source, JOB, "long cmd launch = app.launch").unwrap();
|
||||
let signature = &program.functions[program.commands[0].function].signature;
|
||||
assert!(signature.effects.enqueue);
|
||||
assert!(!signature.effects.request);
|
||||
assert!(!signature.effects.unscoped_write);
|
||||
assert!(signature.effects.concurrent || signature.effects.parallel);
|
||||
|
||||
// 普通入口不能把自动事务变成多个独立提交;必须显式声明 long。
|
||||
let error = checked(&source, JOB, "cmd launch = app.launch").unwrap_err();
|
||||
assert_eq!(error.code, "C014", "{body}: {error:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_jobs_can_launch_children_only_through_explicit_transactions() {
|
||||
let source = app("parallelEach(send, [\"one\", \"two\"], 2)");
|
||||
let jobs = format!("{JOB}\nlong job fanout() () retry(1) timeout(1000) {{ app.launch() }}");
|
||||
assert!(checked(&source, &jobs, "").is_ok());
|
||||
assert_eq!(
|
||||
checked(&source, &jobs.replace("long job", "job"), "")
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C014"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enqueue_does_not_escape_transaction_or_synchronous_worker_restrictions() {
|
||||
for body in [
|
||||
"pending = run(send(\"one\"))\nwait(pending)",
|
||||
"pool = group(1)\nrun(pool, send(\"one\"))\nwait(pool)",
|
||||
"parallelEach(send, [\"one\"], 1)",
|
||||
] {
|
||||
let source = app(body).replace("launch() ()", "transaction launch() ()");
|
||||
assert_eq!(
|
||||
checked(&source, JOB, "long cmd launch = app.launch")
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C014",
|
||||
"{body}"
|
||||
);
|
||||
}
|
||||
for boundary in ["blocking", "parallel"] {
|
||||
let source = app(&format!("{boundary}(send(\"one\"))"));
|
||||
assert_eq!(
|
||||
checked(&source, JOB, "long cmd launch = app.launch")
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C013"
|
||||
);
|
||||
}
|
||||
let unscoped =
|
||||
app("pending = run(send(\"one\"))\nwait(pending)").replace("transaction send", "send");
|
||||
assert_eq!(
|
||||
checked(&unscoped, JOB, "long cmd launch = app.launch")
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C014"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_enqueue_remains_forbidden_through_tasks() {
|
||||
let source = app("pending = run(send(\"one\"))\nwait(pending)");
|
||||
assert_eq!(
|
||||
checked(&source, JOB, "public long post launch = app.launch")
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C014"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enqueue_capability_does_not_make_request_identity_transferable() {
|
||||
for body in [
|
||||
"pending = run(send(\"one\"))\nwait(pending)",
|
||||
"pool = group(1)\nrun(pool, send(\"one\"))\nwait(pool)",
|
||||
"parallelEach(send, [\"one\"], 1)",
|
||||
] {
|
||||
let source = app(body).replace(
|
||||
"id = submit(key)",
|
||||
"identity = dever.auth.id()\nid = submit(identity + key)",
|
||||
);
|
||||
let error = super::packages(&[
|
||||
("dever.database", super::ERROR),
|
||||
(
|
||||
"dever.api",
|
||||
"public type Error { error Invalid\nerror Unauthorized\nerror Forbidden }",
|
||||
),
|
||||
(
|
||||
"dever.auth",
|
||||
"public id() (value: Text) { value = dever.system.authId() }",
|
||||
),
|
||||
("demo.queue.app", &source),
|
||||
("demo.queue.job", JOB),
|
||||
("demo.queue.api", "long cmd launch = app.launch"),
|
||||
])
|
||||
.unwrap_err();
|
||||
assert_eq!(error.code, "C013", "{body}: {error:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
use dever::{
|
||||
compiler::{check, model::Program},
|
||||
syntax::{diagnostic::Diagnostic, source::Source},
|
||||
};
|
||||
|
||||
fn linked(text: &str) -> Result<Program, Diagnostic> {
|
||||
let inputs = [
|
||||
("main", text),
|
||||
("dever.json", include_str!("../../../lib/dever/json.dever")),
|
||||
(
|
||||
"dever.json.value",
|
||||
include_str!("../../../lib/dever/json/value.dever"),
|
||||
),
|
||||
];
|
||||
let modules = inputs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, (_, text))| {
|
||||
dever::syntax::parse(&Source::new(
|
||||
index,
|
||||
if index == 0 {
|
||||
"main.dever".into()
|
||||
} else {
|
||||
format!("library{index}.dever")
|
||||
},
|
||||
(*text).into(),
|
||||
))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
check::check(
|
||||
modules,
|
||||
&inputs
|
||||
.iter()
|
||||
.map(|(package, _)| (*package).into())
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_rejects_non_wire_types_and_untyped_empty_values() {
|
||||
for source in [
|
||||
"main(value: Secret) (answer: Json) { answer = dever.json.encode(value) }",
|
||||
"type Row { value: Secret }\nmain(value: Secret) (answer: Json) { row = Row { value = value } answer = dever.json.encode(row) }",
|
||||
"type Row { private values: List<Int> }\nmain(values: List<Int>) (answer: Json) { row = Row { values = values } answer = dever.json.encode(row) }",
|
||||
"type Row { private value: Int }\nmain(value: Row) (answer: Json) { answer = dever.json.encode(value) }",
|
||||
"type Row { value: Int > 0 }\nmain(values: List<Row?>) (answer: Json) { answer = dever.json.encode(values) }",
|
||||
"type State { Ready(value: Int) }\nmain(value: State) (answer: Json) { answer = dever.json.encode(value) }",
|
||||
"type State { Ready\n error Failed }\nmain(value: State) (answer: Json) { answer = dever.json.encode(value) }",
|
||||
"main(value: Map<Text, Int>) (answer: Json) { answer = dever.json.encode(value) }",
|
||||
"main(value: dever.system.File) (answer: Json) { answer = dever.json.encode(value) }",
|
||||
"main() (answer: Json) { answer = dever.json.encode([]) }",
|
||||
"main() (answer: Json) { answer = dever.json.encode(null) }",
|
||||
] {
|
||||
assert!(linked(source).is_err(), "{source}");
|
||||
}
|
||||
assert!(linked("type Row { amount: Decimal }\nmain(values: List<Row?>) (answer: Json) pure { answer = dever.json.encode(values) }").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payload_free_choices_use_the_existing_wire_codec() {
|
||||
assert!(linked("type State { Ready\n Busy }\nmain(value: State) (answer: Json) { answer = dever.json.encode(value) }").is_ok());
|
||||
assert!(linked("type State { Ready\n Busy }\ntype Row { state: State\n optional: State?\n states: List<State> }\nmain(value: Json) (row: Row) { row = dever.json.decode(value) }").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_json_decode_can_be_captured_without_an_intermediate_function() {
|
||||
let prefix = "type Row { name: Text }\ntype Result { Decoded(value: Row)\n error Invalid(cause: dever.json.JsonResult) }\n";
|
||||
let program = linked(&format!("{prefix}main(payload: Json) (output: Result) {{ output = result(dever.json.decode(payload)) }}")).unwrap();
|
||||
let main = program
|
||||
.functions
|
||||
.iter()
|
||||
.find(|function| function.signature.name == "main")
|
||||
.unwrap();
|
||||
assert!(main.signature.failures.is_empty());
|
||||
assert!(
|
||||
linked(&format!(
|
||||
"{prefix}main(payload: Json) () {{ lost: Result = result(dever.json.decode(payload)) }}"
|
||||
))
|
||||
.unwrap_err()
|
||||
.code
|
||||
== "C012"
|
||||
);
|
||||
let source = format!(
|
||||
"{prefix}type Failure {{ error Missing }}\nraise() (payload: Json) {{ fail(Failure.Missing) }}\nmain() (output: Result) {{ output = result(dever.json.decode(raise())) }}"
|
||||
);
|
||||
let program = linked(&source).unwrap();
|
||||
let main = program
|
||||
.functions
|
||||
.iter()
|
||||
.find(|function| function.signature.name == "main")
|
||||
.unwrap();
|
||||
assert_eq!(main.signature.failures.len(), 1);
|
||||
assert_eq!(
|
||||
program.definitions[main.signature.failures[0].definition].name,
|
||||
"Failure"
|
||||
);
|
||||
for source in [
|
||||
format!("{prefix}main(payload: Json) () {{ lost = result(dever.json.decode(payload)) }}"),
|
||||
"type Wrong { Done(value: Int)\n error Invalid(message: Text) }\nmain(payload: Json) (output: Wrong) { output = result(dever.json.decode(payload)) }".into(),
|
||||
"type Limited { Done(value: Int > 0)\n error Invalid(cause: dever.json.JsonResult) }\nmain(payload: Json) (output: Limited) { output = result(dever.json.decode(payload)) }".into(),
|
||||
] {
|
||||
assert!(linked(&source).is_err(), "{source}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_failures_preserve_capture_and_argument_propagation() {
|
||||
let source = "type Failure { error Missing }\nraise() (value: Int) { fail(Failure.Missing) }\nmain() (answer: dever.json.JsonResult) { answer = result(dever.json.encode(raise())) }";
|
||||
let program = linked(source).unwrap();
|
||||
let main = program
|
||||
.functions
|
||||
.iter()
|
||||
.find(|function| function.signature.name == "main")
|
||||
.unwrap();
|
||||
assert_eq!(main.signature.failures.len(), 1);
|
||||
assert_eq!(
|
||||
program.definitions[main.signature.failures[0].definition].name,
|
||||
"Failure"
|
||||
);
|
||||
for expression in [
|
||||
"dever.json.encode(1)",
|
||||
"dever.json.propertyValue(dever.json.fromText(\"42\"), \"a\")",
|
||||
] {
|
||||
let source = format!("main() () {{ lost = result({expression}) }}");
|
||||
assert_eq!(linked(&source).unwrap_err().code, "C012");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_decode_requires_an_explicit_wire_type_and_propagates_errors() {
|
||||
let source = "type Row { names: List<Text>\noptional: Int? }\nmain(value: Json) (row: Row) { row = dever.json.decode(value) }";
|
||||
let program = linked(source).unwrap();
|
||||
let main = program
|
||||
.functions
|
||||
.iter()
|
||||
.find(|function| function.signature.name == "main")
|
||||
.unwrap();
|
||||
assert_eq!(main.signature.failures.len(), 1);
|
||||
for source in [
|
||||
"main(value: Json) () { row = dever.json.decode(value) }",
|
||||
"main(value: Text) (row: Int) { row = dever.json.decode(value) }",
|
||||
"type Row { private secret: Text }\nmain(value: Json) (row: Row) { row = dever.json.decode(value) }",
|
||||
] {
|
||||
assert!(linked(source).is_err(), "{source}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_type_annotations_supply_context_without_wrapper_functions() {
|
||||
assert!(linked("type Row { names: List<Text> }\nmain(value: Json) (names: List<Text>) { row: Row = dever.json.decode(value)\n names = row.names }").is_ok());
|
||||
assert!(linked("main() (answer: Int) { values: List<Int> = []\n values = [1]\n answer = length(values) }").is_ok());
|
||||
for (source, code) in [
|
||||
("main(value: Int) () { value: Int = 1 }", "C002"),
|
||||
("main() () { value = 1\n value: Int = 2 }", "C002"),
|
||||
("main() () { value: Int = true }", "C005"),
|
||||
("main() () { value: Int = 1\n value = true }", "C005"),
|
||||
(
|
||||
"main() () { value: Channel<Int?> = channel(Int?, 1) }",
|
||||
"C013",
|
||||
),
|
||||
] {
|
||||
assert_eq!(linked(source).unwrap_err().code, code, "{source}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_is_a_nominal_source_type_and_intrinsics_remain_private() {
|
||||
assert!(
|
||||
linked("main(document: dever.json.value.Document) (value: Int) { value = document.root }")
|
||||
.is_ok()
|
||||
);
|
||||
assert!(linked("type Document { root: Int }\nmain(document: Document) (value: Text) { value = dever.json.stringify(document) }").is_err());
|
||||
for expression in [
|
||||
"dever.system.jsonFromText(\"{}\")",
|
||||
"dever.system.jsonObject({})",
|
||||
] {
|
||||
assert_eq!(
|
||||
linked(&format!("main() () {{ value = {expression} }}"))
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C006"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
use dever::{
|
||||
compiler::{build, check, model::Program},
|
||||
syntax::{diagnostic::Diagnostic, source::Source},
|
||||
};
|
||||
|
||||
fn linked(text: &str) -> Result<Program, Diagnostic> {
|
||||
let inputs = [
|
||||
("main", text),
|
||||
("dever.io", include_str!("../../../lib/dever/io.dever")),
|
||||
("dever.time", include_str!("../../../lib/dever/time.dever")),
|
||||
("dever.task", include_str!("../../../lib/dever/task.dever")),
|
||||
(
|
||||
"dever.net",
|
||||
include_str!("../../../lib/dever/net/socket.dever"),
|
||||
),
|
||||
(
|
||||
"dever.net",
|
||||
include_str!("../../../lib/dever/net/listener.dever"),
|
||||
),
|
||||
];
|
||||
let modules = inputs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, (_, text))| {
|
||||
dever::syntax::parse(&Source::new(
|
||||
index,
|
||||
if index == 0 {
|
||||
"main.dever".into()
|
||||
} else {
|
||||
format!("source{index}.dever")
|
||||
},
|
||||
(*text).into(),
|
||||
))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
check::check(
|
||||
modules,
|
||||
&inputs
|
||||
.iter()
|
||||
.map(|(package, _)| (*package).into())
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_effects_and_runtime_features_match_the_actual_entry() {
|
||||
for (source, suspends, schedules, net) in [
|
||||
("main() (answer: Int) { answer = 42 }", false, false, false),
|
||||
(
|
||||
"main() (socket: dever.system.Socket?) { socket = null }",
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
),
|
||||
(
|
||||
"main(socket: dever.system.Socket) () { dever.net.close(socket) }",
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
),
|
||||
(
|
||||
"main(listener: dever.system.Listener) (port: Int) { port = dever.net.port(listener) }",
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
),
|
||||
(
|
||||
"main() (socket: dever.system.Socket) { socket = dever.net.connectTimeout(\"127.0.0.1\", 80, 1000) }",
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
),
|
||||
(
|
||||
"main() (socket: dever.system.Socket) { socket = dever.net.connect(\"localhost\", 80) }",
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
),
|
||||
(
|
||||
"main() (listener: dever.system.Listener) { listener = dever.net.listen(\"127.0.0.1\", 0) }",
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
),
|
||||
] {
|
||||
let program = linked(source).unwrap();
|
||||
let main = program
|
||||
.functions
|
||||
.iter()
|
||||
.position(|function| function.signature.name == "main")
|
||||
.unwrap();
|
||||
let effects = program.functions[main].signature.effects;
|
||||
assert_eq!(effects.suspend, suspends, "{source}");
|
||||
assert_eq!(effects.schedule, schedules, "{source}");
|
||||
assert_eq!(
|
||||
build::runtime_features(&program, &program.reachable([main])).contains(&"net"),
|
||||
net,
|
||||
"{source}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_resources_obey_visibility_equality_and_effect_boundaries() {
|
||||
for (source, code) in [
|
||||
("main() () { value = dever.system.tcpPort(null) }", "C006"),
|
||||
(
|
||||
"main(socket: dever.system.Socket) (answer: Bool) { answer = socket == socket }",
|
||||
"C005",
|
||||
),
|
||||
(
|
||||
"main(values: List<dever.system.Listener?>) (answer: Bool) { answer = values == values }",
|
||||
"C005",
|
||||
),
|
||||
(
|
||||
"main(listener: dever.system.Listener) (port: Int) pure { port = dever.net.port(listener) }",
|
||||
"C013",
|
||||
),
|
||||
("main(value: dever.net.SystemReadResult) () {}", "C006"),
|
||||
(
|
||||
"main(socket: dever.system.Socket) () { value = dever.net.read(socket, true) }",
|
||||
"C005",
|
||||
),
|
||||
(
|
||||
"main(socket: dever.system.Socket) () { value = dever.net.read(socket, 1)\n dever.io.println(\"blocking\") }",
|
||||
"C013",
|
||||
),
|
||||
] {
|
||||
assert_eq!(linked(source).unwrap_err().code, code, "{source}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lazy_network_streams_do_not_claim_unread_errors_but_captured_failures_remain_obligations() {
|
||||
for source in [
|
||||
"main(socket: dever.system.Socket) () { close(dever.net.chunks(socket, 1)) }",
|
||||
"main(listener: dever.system.Listener) () { close(dever.net.connections(listener)) }",
|
||||
] {
|
||||
assert!(linked(source).is_ok(), "{source}");
|
||||
}
|
||||
for source in [
|
||||
"main(socket: dever.system.Socket) () { lost = result(dever.net.read(socket, 1)) }",
|
||||
"main(socket: dever.system.Socket) () { lost = result(dever.net.chunks(socket, 1)) }",
|
||||
"main(socket: dever.system.Socket) () { lost = result(dever.net.close(socket)) }",
|
||||
] {
|
||||
assert_eq!(linked(source).unwrap_err().code, "C012", "{source}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
use super::super::checked;
|
||||
|
||||
#[test]
|
||||
fn handler_ranges_check_unused_templates_inference_and_forwarding() {
|
||||
let apply = "apply(route: handler(value: Int >= 1) (answer: Int >= 2)) (answer: Int >= 2) { answer = route(1) }\n";
|
||||
checked(apply).unwrap();
|
||||
checked(&(apply.to_owned() + "twice(value: Int) (answer: Int) { answer = 2 }\nmake() (answer: Int >= 2) { answer = apply(twice) }")).unwrap();
|
||||
assert_eq!(checked(&(apply.to_owned() + "identity(value: Int) (answer: Int) { answer = value }\nmake() (answer: Int) { answer = apply(identity) }")).unwrap_err().code, "C011");
|
||||
assert_eq!(
|
||||
checked(
|
||||
"bad(route: handler(value: Int >= 1) (answer: Int)) (answer: Int) { answer = route(0) }"
|
||||
)
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C011"
|
||||
);
|
||||
checked(&(apply.to_owned() + "forward(route: handler(value: Int) (answer: Int >= 3)) (answer: Int >= 2) { answer = apply(route) }")).unwrap();
|
||||
for contract in [
|
||||
"handler(value: Int >= 2) (answer: Int >= 3)",
|
||||
"handler(value: Int) (answer: Int >= 1)",
|
||||
] {
|
||||
assert_eq!(
|
||||
checked(&format!(
|
||||
"{apply}forward(route: {contract}) (answer: Int) {{ answer = apply(route) }}"
|
||||
))
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C011"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prototype_cache_keeps_distinct_range_contracts() {
|
||||
let prefix = "first(route: handler(value: Int) (answer: Int >= 2)) (answer: Int >= 2) { answer = route(1) }\n";
|
||||
assert_eq!(checked(&(prefix.to_owned() + "second(route: handler(value: Int) (answer: Int >= 1)) (answer: Int >= 2) { answer = route(1) }")).unwrap_err().code, "C011");
|
||||
assert_eq!(checked("first(route: handler(value: Int >= 1) (answer: Int)) (answer: Int) { answer = route(1) }\nsecond(route: handler(value: Int >= 2) (answer: Int)) (answer: Int) { answer = route(1) }").unwrap_err().code, "C011");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collection_handlers_check_items_context_and_repeated_state() {
|
||||
checked("valid(route: handler(value: Int >= 1) (answer: Int >= 2)) (answer: List<Int>) { answer = each(route, [1, 2]) }").unwrap();
|
||||
for body in [
|
||||
"bad(route: handler(value: Int >= 1) (answer: Int), values: List<Int>) (answer: List<Int>) { answer = each(route, values) }",
|
||||
"bad(route: handler(value: Int >= 1) (answer: Int)) (answer: List<Int>) { answer = each(route, [0, 1]) }",
|
||||
"bad(route: handler(value: Int, state: Int >= 1) (answer: Int)) (answer: Int) { answer = reduce(route, [1, 2], 1) }",
|
||||
"bad(route: handler(value: Int, context: Int >= 1) (answer: Int)) (answer: List<Int>) { answer = each(route, [1], 0) }",
|
||||
] {
|
||||
assert_eq!(checked(body).unwrap_err().code, "C011", "{body}");
|
||||
}
|
||||
checked("valid(route: handler(value: Int, state: Int >= 1) (answer: Int >= 1)) (answer: Int >= 1) { answer = reduce(route, [1, 2], 1) }").unwrap();
|
||||
checked("valid(route: handler(value: Int >= 0 and <= 255) (answer: Int), values: Bytes) (answer: List<Int>) { answer = each(route, values) }").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failing_collection_inputs_do_not_produce_invalid_values() {
|
||||
let prefix =
|
||||
"type Problem { error Failed }\nabortOperation() (answer: Int) { fail(Problem.Failed) }\n";
|
||||
checked(&(prefix.to_owned() + "apply(route: handler(value: Int, context: Int >= 1) (answer: Int)) (answer: List<Int>) { answer = each(route, [1], abortOperation()) }")).unwrap();
|
||||
checked("type Problem { error Failed }\nabortOperation(value: Int, state: Int) (answer: Int) { fail(Problem.Failed) }\napply(route: handler(value: Int, state: Int >= 1) (answer: Int >= 1)) (answer: Int >= 1) { answer = reduce(route, [1], 1) }\nentry() (answer: Int >= 1) { answer = apply(abortOperation) }").unwrap();
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
use super::checked;
|
||||
|
||||
mod handler_range;
|
||||
mod range;
|
||||
|
||||
#[test]
|
||||
fn decimal_and_float_clauses_cover_their_actual_value_domains() {
|
||||
for text in [
|
||||
"pick(value: Decimal < 0.0) (answer: Int) { answer = -1 }\npick(value: Decimal >= 0.0) (answer: Int) { answer = 1 }",
|
||||
"pick(value: other) (answer: Int) { answer = 1 }\npick(value: -0.0) (answer: Int) { answer = 0 }",
|
||||
"pick(value: null) (answer: Int) { answer = 0 }\npick(value: Decimal < 0.0) (answer: Int) { answer = -1 }\npick(value: other) (answer: Int) { answer = 1 }",
|
||||
"pick(value: 1.0e0) (answer: Int) { answer = 1 }\npick(value: other) (answer: Int) { answer = 0 }",
|
||||
"pick(value: null) (answer: Int) { answer = 0 }\npick(value: 0.0e0) (answer: Int) { answer = 1 }\npick(value: other) (answer: Int) { answer = 2 }",
|
||||
"pick(value: Int <= 9223372036854775807) () {}",
|
||||
"pick(value: Int >= -9223372036854775808) () {}",
|
||||
] {
|
||||
checked(text).unwrap_or_else(|error| panic!("{text}\n{error:?}"));
|
||||
}
|
||||
for text in [
|
||||
"pick(value: Decimal < 0.0) () {}",
|
||||
"pick(value: Decimal <= 0.0) () {}\npick(value: Decimal >= 0.0) () {}",
|
||||
"pick(value: 1.0) () {}\npick(value: 1.00) () {}\npick(value: other) () {}",
|
||||
"pick(value: 0.0e0) () {}\npick(value: -0.0e0) () {}\npick(value: other) () {}",
|
||||
"pick(value: 1.0e0) () {}",
|
||||
"pick(value: Float) () {}\npick(value: other) () {}",
|
||||
"pick(value: Int > 9223372036854775807) () {}\npick(value: other) () {}",
|
||||
] {
|
||||
let error = checked(text).unwrap_err();
|
||||
assert_eq!(error.code, "C009", "{text}: {error:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimal_partitions_use_exact_successors_at_precision_and_exponent_limits() {
|
||||
for (left, right) in [
|
||||
("1.0", "1.000000000000000000000000000000001"),
|
||||
("0.0", "1e-6176"),
|
||||
("-1e-6176", "0.0"),
|
||||
(
|
||||
"9.999999999999999999999999999999998e6144",
|
||||
"9.999999999999999999999999999999999e6144",
|
||||
),
|
||||
] {
|
||||
let text = format!(
|
||||
"pick(value: Decimal <= {left}) () {{}}\npick(value: Decimal >= {right}) () {{}}"
|
||||
);
|
||||
checked(&text).unwrap_or_else(|error| panic!("{text}: {error:?}"));
|
||||
}
|
||||
for text in [
|
||||
"pick(value: Decimal <= 1.0) () {}\npick(value: Decimal >= 1.000000000000000000000000000000002) () {}",
|
||||
"pick(value: Decimal > 9.999999999999999999999999999999999e6144) () {}\npick(value: other) () {}",
|
||||
"pick(value: Decimal < -9.999999999999999999999999999999999e6144) () {}\npick(value: other) () {}",
|
||||
] {
|
||||
assert_eq!(checked(text).unwrap_err().code, "C009", "{text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_pattern_validation_rejects_invalid_bounds_and_mixed_types() {
|
||||
for (text, code) in [
|
||||
("pick(value: 9223372036854775808) () {}", "C008"),
|
||||
("pick(value: 1e9999) () {}", "C008"),
|
||||
(
|
||||
"pick(value: Decimal < 0.12345678901234567890123456789012345) () {}",
|
||||
"C008",
|
||||
),
|
||||
("pick(value: Int < 1.5) () {}", "C008"),
|
||||
("pick(value: Float < 1.0e0) () {}", "C005"),
|
||||
("pick(value: Decimal? < 1.0) () {}", "C005"),
|
||||
("pick(value: 1.0) () {}\npick(value: Float) () {}", "C005"),
|
||||
] {
|
||||
assert_eq!(checked(text).unwrap_err().code, code, "{text}");
|
||||
}
|
||||
assert!(checked("pick(value: Int >= 1 and) () {}").is_err());
|
||||
assert!(checked("pick(value: Decimal < 1.0 and) () {}").is_err());
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
use super::super::checked;
|
||||
|
||||
const PORT: &str = "type Port { value: Int >= 1 and <= 65535 }\n";
|
||||
|
||||
#[test]
|
||||
fn nonreturning_values_propagate_failure_before_range_boundaries() {
|
||||
let source = PORT.to_owned()
|
||||
+ "type Problem { error Failed }\nabortOperation() (answer: Int) { fail(Problem.Failed) }\nmake() (answer: Port) { answer = Port { value = abortOperation() } }\n";
|
||||
checked(&source).unwrap();
|
||||
checked(&(source + "apply(route: handler() (answer: Int >= 1)) (answer: Int >= 1) { answer = route() }\nentry() (answer: Int >= 1) { answer = apply(abortOperation) }")).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ranges_check_construction_outputs_and_every_write() {
|
||||
for body in [
|
||||
"make() (answer: Port) { answer = Port { value = 8080 } }",
|
||||
"make(value: Int >= 1 and <= 65535) (answer: Port) { answer = Port { value = value } }\nmake(value: other) (answer: Port) { answer = Port { value = 8080 } }",
|
||||
"make(value: Port) (answer: Int >= 1 and <= 65535) { answer = value.value }",
|
||||
"make(value: Int < 1) (answer: Int >= 1) { answer = 1 }\nmake(value: other) (answer: Int >= 1) { answer = value }",
|
||||
"make(value: Int < 1) (answer: Int >= 1 and <= 65535) { answer = 1 }\nmake(value: Int > 65535) (answer: Int >= 1 and <= 65535) { answer = 65535 }\nmake(value: other) (answer: Int >= 1 and <= 65535) { answer = value }",
|
||||
] {
|
||||
checked(&(PORT.to_owned() + body)).unwrap_or_else(|error| panic!("{body}: {error:?}"));
|
||||
}
|
||||
for body in [
|
||||
"make() (answer: Port) { answer = Port { value = 0 } }",
|
||||
"make(value: Int) (answer: Port) { answer = Port { value = value } }",
|
||||
"make(value: Port) (answer: Port) { value.value = 65536\nanswer = value }",
|
||||
"make(value: Int) (answer: Int >= 1) { answer = value }",
|
||||
"make() (answer: Int >= 1) { answer = 0\nanswer = 1 }",
|
||||
"type Outer { port: Port }\nmake(value: Outer) (answer: Outer) { value.port.value = 0\nanswer = value }",
|
||||
] {
|
||||
assert_eq!(
|
||||
checked(&(PORT.to_owned() + body)).unwrap_err().code,
|
||||
"C011",
|
||||
"{body}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshots_survive_mutation_without_retaining_stale_facts() {
|
||||
let prefix = "type Row { value: Int }\ntype Pair { left: Row\nright: Row }\n";
|
||||
let body = "row = Row { value = 3 }\npair = Pair { left = row, right = row }\nsaved = pair\npair.left.value = 0\nrow.value = -1\n";
|
||||
for value in ["saved.left.value", "saved.right.value", "pair.right.value"] {
|
||||
checked(&format!(
|
||||
"{prefix}make() (answer: Int >= 1) {{ {body}answer = {value} }}"
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
for value in ["pair.left.value", "row.value"] {
|
||||
assert_eq!(
|
||||
checked(&format!(
|
||||
"{prefix}make() (answer: Int >= 1) {{ {body}answer = {value} }}"
|
||||
))
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C011"
|
||||
);
|
||||
}
|
||||
assert_eq!(checked("make(value: Int >= 1) (answer: Int >= 1) { value = 0\nanswer = value }\nmake(value: other) (answer: Int >= 1) { answer = 1 }").unwrap_err().code, "C011");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choice_payloads_and_captured_outputs_preserve_contracts() {
|
||||
let prefix = "type Result { Ready(value: Int >= 1)\nerror Failed(message: Text) }\n";
|
||||
let consumer = "read(value: Result.Ready(number)) (answer: Int >= 1) recover(\"fallback\") { answer = number }\nread(value: Result.Failed(message)) (answer: Int >= 1) recover(\"fallback\") { answer = 1 }\n";
|
||||
checked(
|
||||
&(prefix.to_owned() + consumer + "make() (answer: Result) { answer = Result.Ready(1) }"),
|
||||
)
|
||||
.unwrap();
|
||||
for value in ["0", "unknown"] {
|
||||
assert_eq!(
|
||||
checked(&format!(
|
||||
"{prefix}make(unknown: Int) (answer: Result) {{ answer = Result.Ready({value}) }}"
|
||||
))
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C011"
|
||||
);
|
||||
}
|
||||
checked(&format!("{prefix}produce() (answer: Int) {{ answer = 3 }}\nmake() (answer: Result) {{ answer = result(produce()) }}")).unwrap();
|
||||
assert_eq!(checked(&format!("{prefix}produce(value: Int) (answer: Int) {{ answer = value }}\nmake(value: Int) (answer: Result) {{ answer = result(produce(value)) }}")).unwrap_err().code, "C011");
|
||||
checked("pair() (small: Int >= 1 and <= 3, large: Int >= 5) { small = 2\nlarge = 8 }\nread() (answer: Int >= 5) { saved = pair()\nanswer = saved.large }").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arithmetic_proof_uses_checked_int_and_decimal_semantics() {
|
||||
checked(
|
||||
&(PORT.to_owned()
|
||||
+ "step(value: Port) (answer: Int >= 2 and <= 65536) { answer = value.value + 1 }"),
|
||||
)
|
||||
.unwrap();
|
||||
checked("step(value: Int >= -8 and <= 8) (answer: Int >= -16 and <= 16) { answer = value * -2 }\nstep(value: other) (answer: Int >= -16 and <= 16) { answer = 0 }").unwrap();
|
||||
checked("type Rate { value: Decimal >= 0.0 and <= 1.0 }\nscale(value: Rate) (answer: Decimal >= 0.0 and <= 100.0) { answer = value.value * 100.0 }").unwrap();
|
||||
checked("type Tiny { value: Decimal > 0.0 and <= 1e-6176 }\ncopy(value: Tiny) (answer: Decimal > 0.0) { answer = value.value }").unwrap();
|
||||
checked("type Edge { value: Decimal > 1.0 and <= 1.000000000000000000000000000000001 }\ncopy(value: Edge) (answer: Decimal > 1.0) { answer = value.value }").unwrap();
|
||||
checked("convert(value: Int >= 1 and <= 8) (answer: Decimal >= 1.0 and <= 8.0) { answer = value }\nconvert(value: other) (answer: Decimal >= 1.0 and <= 8.0) { answer = 1.0 }").unwrap();
|
||||
for body in [
|
||||
"read(value: Int) (answer: Int >= 0) { answer = value + 1 }",
|
||||
"read(value: Int >= 1) (answer: Int >= 0) { answer = -value }\nread(value: other) (answer: Int >= 0) { answer = 0 }",
|
||||
"read(value: Int >= 0) (answer: Int >= 0) { answer = value + 1 }\nread(value: other) (answer: Int >= 0) { answer = 0 }",
|
||||
] {
|
||||
assert_eq!(checked(body).unwrap_err().code, "C011", "{body}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_empty_and_inconsistent_ranges_are_rejected() {
|
||||
for fields in [
|
||||
"value: Int > 9223372036854775807",
|
||||
"value: Int < -9223372036854775808",
|
||||
"value: Int > 1 and < 2",
|
||||
"value: Decimal > 0.0 and < 1e-6176",
|
||||
] {
|
||||
assert_eq!(
|
||||
checked(&format!("type Invalid {{ {fields} }}"))
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C011"
|
||||
);
|
||||
}
|
||||
for fields in [
|
||||
"value: Float >= 1.0e0",
|
||||
"value: Int? >= 1",
|
||||
"value: Text >= 1",
|
||||
] {
|
||||
assert_eq!(
|
||||
checked(&format!("type Invalid {{ {fields} }}"))
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C005"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
checked("type Invalid { value: Decimal >= 0.12345678901234567890123456789012345 }")
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C008"
|
||||
);
|
||||
assert_eq!(checked("read(value: Int < 0) (answer: Int >= 1) { answer = 1 }\nread(value: other) (answer: Int >= 2) { answer = 2 }").unwrap_err().code, "C013");
|
||||
checked("read(value: Int < 0) (answer: Int > 0) { answer = 1 }\nread(value: other) (answer: Int >= 1) { answer = 2 }").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_proofs_remove_only_redundant_arithmetic_checks() {
|
||||
let text = "type Port { value: Int >= 1 and <= 65535 }\nstep(value: Port) (answer: Int >= 2 and <= 65536) { answer = value.value + 1 }";
|
||||
let mut plain = text.to_owned();
|
||||
for bound in [">= 1 and <= 65535", ">= 2 and <= 65536"] {
|
||||
plain = plain.replace(bound, &" ".repeat(bound.len()));
|
||||
}
|
||||
let source = |text: &str| dever::syntax::source::Source::new(0, "case.dever", text.into());
|
||||
let emit = |text: &str| {
|
||||
dever::compiler::emit::generate(
|
||||
&checked(text).unwrap(),
|
||||
&[source(text)],
|
||||
&std::collections::BTreeSet::from([0]),
|
||||
)
|
||||
};
|
||||
let bounded = emit(text);
|
||||
let unbounded = emit(&plain);
|
||||
assert!(bounded.contains("wrapping_add"));
|
||||
assert!(!bounded.contains("checked_add"));
|
||||
assert!(unbounded.contains("checked_add"));
|
||||
assert!(!unbounded.contains("wrapping_add"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arithmetic_proofs_follow_field_updates_and_each_specialized_handler() {
|
||||
let text = "type Row { value: Int }\ncopy(value: Int) (answer: Int) { answer = value }\nread(value: Int) (answer: Int) { row = Row { value = 1 }\nsaved = row\nrow.value = value\nknown = saved.value + 1\nunknown = row.value + 1\nanswer = known + unknown }\napply(route: handler(value: Int) (answer: Int), value: Int) (answer: Int) { answer = route(value) + 1 }\nfixed(value: Int) (answer: Int) { answer = 1 }\nfixedCall(value: Int) (answer: Int) { answer = apply(fixed, value) }\nunknownCall(value: Int) (answer: Int) { answer = apply(copy, value) }";
|
||||
let program = checked(text).unwrap();
|
||||
let source = dever::syntax::source::Source::new(0, "case.dever", text.into());
|
||||
let emit = |name: &str| {
|
||||
let index = program
|
||||
.functions
|
||||
.iter()
|
||||
.position(|function| function.signature.name == name)
|
||||
.unwrap();
|
||||
dever::compiler::emit::generate(&program, std::slice::from_ref(&source), &[index].into())
|
||||
};
|
||||
let changed = emit("read");
|
||||
assert!(changed.contains("wrapping_add"));
|
||||
assert!(changed.contains("checked_add"));
|
||||
assert!(emit("fixedCall").contains("wrapping_add"));
|
||||
assert!(!emit("unknownCall").contains("wrapping_add"));
|
||||
assert!(emit("unknownCall").contains("checked_add"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_complement_proof_stops_at_its_work_budget() {
|
||||
let mut source = String::new();
|
||||
for value in 0..100 {
|
||||
source.push_str(&format!(
|
||||
"classify(value: {value}) (answer: Int) {{ answer = value }}\n"
|
||||
));
|
||||
}
|
||||
source.push_str("classify(value: other) (answer: Int) { answer = value }");
|
||||
checked(&source).unwrap();
|
||||
for value in 100..200 {
|
||||
source.push_str(&format!(
|
||||
"\nclassify(value: {value}) (answer: Int) {{ answer = value }}"
|
||||
));
|
||||
}
|
||||
let error = checked(&source).unwrap_err();
|
||||
assert_eq!(error.code, "C015");
|
||||
assert!(error.message.contains("range proof"), "{error:?}");
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
use super::checked;
|
||||
use dever::compiler::{check, model::Program};
|
||||
use dever::syntax::{diagnostic::Diagnostic, source::Source};
|
||||
|
||||
pub(super) fn packages(files: &[(&str, &str)]) -> Result<Program, Diagnostic> {
|
||||
let mut modules = Vec::new();
|
||||
let mut identities = Vec::new();
|
||||
for (index, (identity, text)) in files.iter().enumerate() {
|
||||
let source = Source::new(index, format!("{identity}.dever"), (*text).into());
|
||||
modules.push(dever::syntax::parse(&source)?);
|
||||
identities.push((*identity).into());
|
||||
}
|
||||
check::check(modules, &identities)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_signatures_cannot_publish_private_types() {
|
||||
for declaration in [
|
||||
"public echo(value: Hidden) (answer: Hidden) { answer = value }",
|
||||
"public echo(value: List<Hidden?>) (answer: List<Hidden?>) { answer = value }",
|
||||
"public echo(value: Map<Text, List<Hidden>>) (answer: Map<Text, List<Hidden>>) { answer = value }",
|
||||
"public echo(value: MapEntry<Text, Hidden>) (answer: MapEntry<Text, Hidden>) { answer = value }",
|
||||
"public apply(action: handler(value: Hidden) (answer: Hidden)) () {}",
|
||||
"public type Visible { nested: Hidden }",
|
||||
"public type Visible { Wrapped(value: Hidden) }",
|
||||
] {
|
||||
let text = format!("type Hidden {{ value: Int }}\n{declaration}");
|
||||
assert_eq!(checked(&text).unwrap_err().code, "C006", "{text}");
|
||||
}
|
||||
assert!(checked("public type Visible { value: Int }\npublic echo(value: List<Visible?>) (answer: List<Visible?>) { answer = value }").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_failures_cannot_leak_private_error_identity() {
|
||||
for call in ["fail(Hidden.Failed(\"private\"))", "hidden()"] {
|
||||
let text = format!(
|
||||
"type Hidden {{ error Failed(message: Text) }}\nhidden() () {{ fail(Hidden.Failed(\"private\")) }}\npublic exposed() () {{ {call} }}"
|
||||
);
|
||||
let error = checked(&text).unwrap_err();
|
||||
assert_eq!(error.code, "C006", "{error:?}");
|
||||
}
|
||||
assert!(checked("public type Problem { error Failed(message: Text) }\nprivateFailure() () { fail(Problem.Failed(\"public\")) }\npublic exposed() () { privateFailure() }").is_ok());
|
||||
assert_eq!(
|
||||
checked("public apply(action: handler(value: Map<Float, Int>) ()) () {}")
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C005"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_cycles_are_rejected_without_rejecting_static_callbacks() {
|
||||
for files in [
|
||||
[
|
||||
(
|
||||
"left",
|
||||
"public type A { value: Int }\npublic make() () { local: right.B? = null }",
|
||||
),
|
||||
(
|
||||
"right",
|
||||
"public type B { value: Int }\npublic make() () { local: left.A? = null }",
|
||||
),
|
||||
],
|
||||
[
|
||||
(
|
||||
"left",
|
||||
"public first() (answer: Int) { answer = right.second() }\npublic last() (answer: Int) { answer = 1 }",
|
||||
),
|
||||
(
|
||||
"right",
|
||||
"public second() (answer: Int) { answer = left.last() }",
|
||||
),
|
||||
],
|
||||
[
|
||||
(
|
||||
"left",
|
||||
"public type A { value: Int }\npublic fromRight(value: right.B) () {}",
|
||||
),
|
||||
(
|
||||
"right",
|
||||
"public type B { value: Int }\npublic fromLeft(value: left.A) () {}",
|
||||
),
|
||||
],
|
||||
[
|
||||
(
|
||||
"left",
|
||||
"public type A { value: Int }\npublic make() () { local = right.B { value = 1 } }",
|
||||
),
|
||||
(
|
||||
"right",
|
||||
"public type B { value: Int }\npublic make() () { local = left.A { value = 1 } }",
|
||||
),
|
||||
],
|
||||
] {
|
||||
assert_eq!(packages(&files).unwrap_err().code, "C007");
|
||||
}
|
||||
let files = [
|
||||
(
|
||||
"client",
|
||||
"privateDouble(value: Int) (answer: Int) { answer = value * 2 }\nmain() (answer: Int) { answer = helper.apply(privateDouble, 4) }",
|
||||
),
|
||||
(
|
||||
"helper",
|
||||
"public apply(action: handler(value: Int) (answer: Int), value: Int) (answer: Int) { answer = action(value) }",
|
||||
),
|
||||
];
|
||||
assert!(packages(&files).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_package_names_and_callbacks_obey_visibility() {
|
||||
let helper = "type Hidden { value: Int }\nprivateRead(value: Int) (answer: Int) { answer = value }\npublic type Visible { value: Int }\npublic read(value: Int) (answer: Int) { answer = privateRead(value) }";
|
||||
for caller in [
|
||||
"main() (answer: Int) { answer = helper.privateRead(1) }",
|
||||
"main() (answer: List<Int>) { answer = each(helper.privateRead, [1]) }",
|
||||
"main(value: helper.Hidden) () {}",
|
||||
"main() () { value = helper.Hidden { value = 1 } }",
|
||||
] {
|
||||
assert_eq!(
|
||||
packages(&[("main", caller), ("helper", helper)])
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C006"
|
||||
);
|
||||
}
|
||||
assert!(packages(&[("main", "main() (answer: helper.Visible) { answer = helper.Visible { value = helper.read(3) } }"), ("helper", helper)]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_fields_reject_foreign_reads_writes_and_construction() {
|
||||
let vault = "public type Session { private key: Text\nlabel: Text }\npublic type Envelope { value: Session }";
|
||||
for caller in [
|
||||
"read(value: vault.Session) (answer: Text) { answer = value.key }",
|
||||
"write(value: vault.Session) () { value.key = \"changed\" }",
|
||||
"make() (answer: vault.Session) { answer = vault.Session { key = \"hidden\", label = \"name\" } }",
|
||||
"make() (answer: vault.Session) { answer = vault.Session { label = \"name\" } }",
|
||||
"read(value: vault.Envelope) (answer: Text) { answer = value.value.key }",
|
||||
"write(value: vault.Envelope) () { value.value.key = \"changed\" }",
|
||||
] {
|
||||
let error = packages(&[("main", caller), ("vault", vault)]).unwrap_err();
|
||||
assert_eq!(error.code, "C006", "{caller}: {error:?}");
|
||||
assert!(error.message.contains("field is private"), "{error:?}");
|
||||
}
|
||||
assert!(packages(&[("main", "rename(value: vault.Session) (answer: vault.Session) { value.label = \"new\"\nanswer = value }"), ("vault", vault)]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_record_fields_can_hide_types_but_choice_payloads_cannot() {
|
||||
assert!(checked("type Hidden { value: Int }\npublic type Visible { private hidden: Hidden\nlabel: Text }\npublic make() (answer: Visible) { answer = Visible { hidden = Hidden { value = 7 }, label = \"name\" } }\npublic read(value: Visible) (answer: Int) { answer = value.hidden.value }").is_ok());
|
||||
assert_eq!(
|
||||
checked("public type Choice { Ready(private value: Int) }")
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C005"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
use super::{ADAPTER, APP, PORT, packages};
|
||||
|
||||
#[test]
|
||||
fn roles_cannot_call_around_the_port_boundary() {
|
||||
for call in [
|
||||
"app.read(value)",
|
||||
"domain.read(value)",
|
||||
"port.read(value)",
|
||||
"foreign.library.read(value)",
|
||||
"shop.account.adapter.other.helper(value)",
|
||||
] {
|
||||
let adapter = format!("port.read(value: Int) (answer: Int) {{ answer = {call} }}");
|
||||
let error = packages(&[
|
||||
("shop.account.app", APP),
|
||||
(
|
||||
"shop.account.domain",
|
||||
"read(value: Int) (answer: Int) { answer = value }",
|
||||
),
|
||||
("shop.account.port", PORT),
|
||||
("shop.account.adapter.local", &adapter),
|
||||
(
|
||||
"foreign.library",
|
||||
"public read(value: Int) (answer: Int) { answer = value }",
|
||||
),
|
||||
])
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(error.code, "C004" | "C005" | "C006"),
|
||||
"{call}: {error:?}"
|
||||
);
|
||||
}
|
||||
for caller in ["shop.account.domain", "shop.other.app", "foreign"] {
|
||||
let error = packages(&[
|
||||
("shop.account.app", APP),
|
||||
("shop.account.port", PORT),
|
||||
("shop.account.adapter", ADAPTER),
|
||||
(
|
||||
caller,
|
||||
"entry(value: Int) (answer: Int) { answer = shop.account.port.read(value) }",
|
||||
),
|
||||
])
|
||||
.unwrap_err();
|
||||
assert_eq!(error.code, "C006", "{caller}: {error:?}");
|
||||
}
|
||||
for call in [
|
||||
"adapter.helper(value)",
|
||||
"adapter.port.read(value)",
|
||||
"shop.account.adapter.port.read(value)",
|
||||
] {
|
||||
assert!(
|
||||
packages(&[
|
||||
("shop.account.app", &APP.replace("port.read(value)", call)),
|
||||
("shop.account.port", PORT),
|
||||
("shop.account.adapter", ADAPTER)
|
||||
])
|
||||
.is_err(),
|
||||
"{call}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_app_types_in_the_complete_contract_graph_are_visible() {
|
||||
let app = "type Problem { error Missing(message: Text) }\ntype Person { name: Text }\ntype Unrelated { number: Int }\nread(value: Person) (answer: Text) { answer = port.read(port.Row { person = value }) }";
|
||||
let port = "type Row { person: app.Person }\nread(value: Row) (answer: Text) fails app.Problem";
|
||||
let adapter = "port.read(value: port.Row) (answer: Text) { answer = label(value.person) }\nlabel(value: app.Person) (answer: Text) { answer = value.name }";
|
||||
assert!(
|
||||
packages(&[
|
||||
("shop.account.adapter", adapter),
|
||||
("shop.account.port", port),
|
||||
("shop.account.app", app)
|
||||
])
|
||||
.is_ok()
|
||||
);
|
||||
for bad in [
|
||||
format!("{adapter}\nunused(value: app.Unrelated) () {{}}"),
|
||||
adapter.replace(
|
||||
"answer = label(value.person)",
|
||||
"hidden = app.Unrelated { number = 1 }\nanswer = label(value.person)",
|
||||
),
|
||||
format!("{adapter}\ntype Hidden {{ value: List<app.Unrelated> }}"),
|
||||
] {
|
||||
assert_eq!(
|
||||
packages(&[
|
||||
("shop.account.adapter", &bad),
|
||||
("shop.account.port", port),
|
||||
("shop.account.app", app)
|
||||
])
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C006"
|
||||
);
|
||||
}
|
||||
let bad = format!("{port}\ntype Unused {{ value: app.Unrelated }}");
|
||||
assert_eq!(
|
||||
packages(&[
|
||||
("shop.account.app", app),
|
||||
("shop.account.port", &bad),
|
||||
("shop.account.adapter", adapter)
|
||||
])
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C006"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapter_cannot_read_another_ports_private_dto() {
|
||||
let error = packages(&[
|
||||
("shop.account.app", APP),
|
||||
("shop.account.port", PORT),
|
||||
(
|
||||
"shop.account.port.other",
|
||||
"type Hidden { value: Int }\nwrite(value: Int) () fails app.Problem",
|
||||
),
|
||||
(
|
||||
"shop.account.adapter.first",
|
||||
&format!("{ADAPTER}\nunused(value: port.other.Hidden) () {{}}"),
|
||||
),
|
||||
(
|
||||
"shop.account.adapter.second",
|
||||
"port.other.write(value: Int) () {}",
|
||||
),
|
||||
])
|
||||
.unwrap_err();
|
||||
assert_eq!(error.code, "C006");
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
use super::signature;
|
||||
use dever::compiler::{check, model::Program};
|
||||
use dever::syntax::{diagnostic::Diagnostic, source::Source};
|
||||
|
||||
fn packages(files: &[(&str, &str)]) -> Result<Program, Diagnostic> {
|
||||
let mut modules = Vec::new();
|
||||
let mut identities = Vec::new();
|
||||
for (index, (identity, text)) in files.iter().enumerate() {
|
||||
let path = if *identity == "dever.io" {
|
||||
"lib/dever/io.dever"
|
||||
} else {
|
||||
"case.dever"
|
||||
};
|
||||
modules.push(dever::syntax::parse(&Source::new(
|
||||
index,
|
||||
path,
|
||||
(*text).into(),
|
||||
))?);
|
||||
identities.push((*identity).into());
|
||||
}
|
||||
check::check(modules, &identities)
|
||||
}
|
||||
|
||||
const IO: &str = include_str!("../../../../lib/dever/io.dever");
|
||||
const PORT: &str = "open(path: Text) (file: dever.system.File) fails dever.io.OpenResult";
|
||||
const ADAPTER: &str = "port.open(path: Text) (file: dever.system.File) { file = open(path) }\nopen(path: Text) (file: dever.system.File) { file = dever.io.open(path) }";
|
||||
|
||||
#[test]
|
||||
fn boundary_retains_real_effects_without_permitting_unmediated_io() {
|
||||
let app = "open(path: Text) (file: dever.system.File) { file = port.open(path) }";
|
||||
let program = packages(&[
|
||||
("shop.files.app", app),
|
||||
("shop.files.port", PORT),
|
||||
("shop.files.adapter", ADAPTER),
|
||||
("dever.io", IO),
|
||||
])
|
||||
.unwrap();
|
||||
let effects = signature(&program, "open").effects;
|
||||
assert!(effects.file && effects.blocking && !effects.unmediated_io);
|
||||
for body in [
|
||||
"file = dever.io.open(path)",
|
||||
"old = port.open(path)\nfile = dever.io.open(path)",
|
||||
"files = each(dever.io.open, [path])\nfile = port.open(path)",
|
||||
"old = result(dever.io.open(path))\nfile = port.open(path)",
|
||||
] {
|
||||
let app = format!("open(path: Text) (file: dever.system.File) {{ {body} }}");
|
||||
assert!(
|
||||
packages(&[
|
||||
("shop.files.app", &app),
|
||||
("shop.files.port", PORT),
|
||||
("shop.files.adapter", ADAPTER),
|
||||
("dever.io", IO)
|
||||
])
|
||||
.is_err(),
|
||||
"{body}"
|
||||
);
|
||||
}
|
||||
let pure = app.replace(" {", " pure {");
|
||||
assert_eq!(
|
||||
packages(&[
|
||||
("shop.files.app", &pure),
|
||||
("shop.files.port", PORT),
|
||||
("shop.files.adapter", ADAPTER),
|
||||
("dever.io", IO)
|
||||
])
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C013"
|
||||
);
|
||||
let scheduled = app.replace("port.open(path)", "blocking(port.open(path))");
|
||||
assert!(
|
||||
packages(&[
|
||||
("shop.files.app", &scheduled),
|
||||
("shop.files.port", PORT),
|
||||
("shop.files.adapter", ADAPTER),
|
||||
("dever.io", IO)
|
||||
])
|
||||
.is_ok()
|
||||
);
|
||||
let asynchronous = app.replace(
|
||||
"file = port.open(path)",
|
||||
"other = blocking(port.open(path))\nfile = port.open(path)",
|
||||
);
|
||||
assert_eq!(
|
||||
packages(&[
|
||||
("shop.files.app", &asynchronous),
|
||||
("shop.files.port", PORT),
|
||||
("shop.files.adapter", ADAPTER),
|
||||
("dever.io", IO)
|
||||
])
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C013"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_cannot_hide_blocking_candidate_effects() {
|
||||
let app = "open(path: Text) (file: dever.system.File) { file = port.open(path) }";
|
||||
let worker =
|
||||
"port.open(path: Text) (file: dever.system.File) { file = blocking(dever.io.open(path)) }";
|
||||
assert_eq!(
|
||||
packages(&[
|
||||
("shop.files.app", app),
|
||||
("shop.files.port", PORT),
|
||||
("shop.files.adapter.inline", ADAPTER),
|
||||
("shop.files.adapter.worker", worker),
|
||||
("dever.io", IO),
|
||||
])
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C013"
|
||||
);
|
||||
let program = packages(&[
|
||||
("shop.files.app", app),
|
||||
("shop.files.port", PORT),
|
||||
("shop.files.adapter.first", worker),
|
||||
("shop.files.adapter.second", worker),
|
||||
("dever.io", IO),
|
||||
])
|
||||
.unwrap();
|
||||
let effects = signature(&program, "open").effects;
|
||||
assert!(effects.file && effects.suspend && effects.configuration);
|
||||
assert!(!effects.blocking && !effects.unmediated_io);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
use super::package::packages;
|
||||
use dever::compiler::model::{Node, Program, Signature, Statement};
|
||||
|
||||
mod boundary;
|
||||
mod effects;
|
||||
mod settings;
|
||||
|
||||
const APP: &str = "type Problem { error Missing(message: Text)\n error Denied(code: Int) }\nread(value: Int) (answer: Int) { answer = port.read(value) }";
|
||||
const PORT: &str = "read(value: Int) (answer: Int) fails app.Problem";
|
||||
const ADAPTER: &str = "port.read(value: Int) (answer: Int) { answer = helper(value) }\nhelper(value: Int) (answer: Int) { answer = value + 1 }";
|
||||
|
||||
fn signature<'program>(program: &'program Program, name: &str) -> &'program Signature {
|
||||
&program
|
||||
.functions
|
||||
.iter()
|
||||
.find(|function| function.signature.name == name)
|
||||
.unwrap()
|
||||
.signature
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_alias_binds_directly_and_preserves_the_declared_error_set() {
|
||||
let program = packages(&[
|
||||
("shop.account.app", APP),
|
||||
("shop.account.port", PORT),
|
||||
("shop.account.adapter", ADAPTER),
|
||||
("shop.account.api", "cmd read = app.read"),
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(program.functions.len(), 3);
|
||||
let app = &program.functions[program.commands[0].function];
|
||||
let Statement::Assign { value, .. } = &app.clauses[0].body[0] else {
|
||||
panic!()
|
||||
};
|
||||
let Node::Call(index, _) = value.kind else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(program.functions[index].signature.name, "port.read");
|
||||
assert_eq!(app.signature.failures.len(), 2);
|
||||
assert_eq!(
|
||||
app.signature.failures,
|
||||
program.functions[index].signature.failures
|
||||
);
|
||||
assert!(!app.signature.effects.unmediated_io);
|
||||
assert!(program.api_snapshot().contains("Problem.Denied"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topics_dtos_static_handlers_and_adapter_clauses_share_existing_type_checks() {
|
||||
let program = packages(&[
|
||||
(
|
||||
"shop.account.app",
|
||||
"type Problem { error Missing(message: Text) }\nread(value: Int) (answer: Int) { row = port.mail.Row { value = value }\nanswer = apply(port.mail.read, row) }\napply(action: handler(row: port.mail.Row) (answer: Int), row: port.mail.Row) (answer: Int) { answer = action(row) }",
|
||||
),
|
||||
(
|
||||
"shop.account.port.mail",
|
||||
"type Row { value: Int }\nread(row: Row) (answer: Int) fails app.Problem",
|
||||
),
|
||||
(
|
||||
"shop.account.adapter.local",
|
||||
"port.mail.read(row: port.mail.Row) (answer: Int) { answer = row.value }",
|
||||
),
|
||||
]);
|
||||
// App 的公开 handler 参数不能泄漏 Port DTO;在领域内经普通 App 函数也不能绕过。
|
||||
assert_eq!(program.unwrap_err().code, "C006");
|
||||
let program = packages(&[
|
||||
("shop.account.app", APP),
|
||||
("shop.account.port", PORT),
|
||||
("shop.account.adapter", "port.read(value: Int < 0) (answer: Int) { fail(app.Problem.Missing(\"negative\")) }\nport.read(value: other) (answer: Int) { answer = value }"),
|
||||
]).unwrap();
|
||||
assert_eq!(signature(&program, "read").failures.len(), 2);
|
||||
assert!(packages(&[
|
||||
("shop.account.app", "type Problem { error Missing(message: Text) }\nread(value: Int) (answer: Int) { row = port.mail.Row { value = value }\nanswer = port.mail.read(row) }"),
|
||||
("shop.account.port.mail", "type Row { value: Int }\nread(row: Row) (answer: Int) fails app.Problem"),
|
||||
("shop.account.adapter.local", "port.mail.read(row: port.mail.Row) (answer: Int) { answer = row.value }"),
|
||||
]).is_ok());
|
||||
assert!(
|
||||
packages(&[
|
||||
(
|
||||
"shop.account.app",
|
||||
&APP.replace("port.read(value)", "sum(each(port.read, [value]))")
|
||||
),
|
||||
("shop.account.port", PORT),
|
||||
("shop.account.adapter", ADAPTER),
|
||||
])
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ports_reject_missing_mismatched_and_partial_implementations() {
|
||||
for adapter in [
|
||||
"port.read(wrong: Int) (answer: Int) { answer = wrong }",
|
||||
"port.read(value: Text) (answer: Int) { answer = 1 }",
|
||||
"port.read(value: Int) (wrong: Int) { wrong = value }",
|
||||
"port.read(value: Int, action: handler() ()) (answer: Int) { answer = value }",
|
||||
"port.unknown(value: Int) (answer: Int) { answer = value }",
|
||||
"helper(value: Int) (answer: Int) { answer = value }",
|
||||
] {
|
||||
assert!(
|
||||
packages(&[
|
||||
("shop.account.app", APP),
|
||||
("shop.account.port", PORT),
|
||||
("shop.account.adapter", adapter)
|
||||
])
|
||||
.is_err(),
|
||||
"{adapter}"
|
||||
);
|
||||
}
|
||||
assert!(packages(&[("shop.account.app", APP), ("shop.account.port", PORT)]).is_err());
|
||||
assert!(
|
||||
packages(&[
|
||||
("shop.account.app", APP),
|
||||
(
|
||||
"shop.account.port",
|
||||
&format!("{PORT}\nwrite(value: Int) () fails app.Problem")
|
||||
),
|
||||
("shop.account.adapter.first", ADAPTER),
|
||||
(
|
||||
"shop.account.adapter.second",
|
||||
"port.write(value: Int) () {}"
|
||||
),
|
||||
])
|
||||
.unwrap_err()
|
||||
.message
|
||||
.contains("complete Port")
|
||||
);
|
||||
assert!(
|
||||
packages(&[
|
||||
("shop.account.app", APP),
|
||||
("shop.account.port", PORT),
|
||||
("shop.account.adapter.first", ADAPTER),
|
||||
("shop.account.adapter.second", ADAPTER),
|
||||
])
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_declarations_require_plain_types_and_public_explicit_errors() {
|
||||
for port in [
|
||||
"read(value: Int) (answer: Int) { answer = value }",
|
||||
"read(value: Int < 0) (answer: Int) fails app.Problem",
|
||||
"read(value: other) (answer: Int) fails app.Problem",
|
||||
"read(value: handler() ()) (answer: Int) fails app.Problem",
|
||||
"read(value: Int) (answer: Int > 0) fails app.Problem",
|
||||
"read(value: Int) (answer: Int) pure fails app.Problem",
|
||||
"read(value: Int) (answer: Int) recover(\"ignore\") fails app.Problem",
|
||||
"read(value: Int) (answer: Int) fails Int",
|
||||
"read(value: Int) (answer: Int) fails app.Problem?",
|
||||
"read(value: Int) (answer: Int) fails app.Record",
|
||||
"read(value: Int) (answer: Int) fails app.State",
|
||||
"type Local { error Failed }\nread(value: Int) (answer: Int) fails Local",
|
||||
"public read(value: Int) (answer: Int) fails app.Problem",
|
||||
] {
|
||||
let app = format!("{APP}\ntype Record {{ value: Int }}\ntype State {{ Ready }}");
|
||||
assert!(
|
||||
packages(&[
|
||||
("shop.account.app", &app),
|
||||
("shop.account.port", port),
|
||||
("shop.account.adapter", ADAPTER)
|
||||
])
|
||||
.is_err(),
|
||||
"{port}"
|
||||
);
|
||||
}
|
||||
assert!(packages(&[("main", "read(value: Int) (answer: Int) fails Problem")]).is_err());
|
||||
assert_eq!(
|
||||
packages(&[
|
||||
("shop.account.app", APP),
|
||||
("shop.account.port", &format!("{PORT}\n{PORT}")),
|
||||
("shop.account.adapter", ADAPTER)
|
||||
])
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C002"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn implementations_cannot_propagate_undeclared_errors() {
|
||||
let adapter = "type Private { error Other }\nport.read(value: Int) (answer: Int) { fail(Private.Other()) }";
|
||||
assert_eq!(
|
||||
packages(&[
|
||||
("shop.account.app", APP),
|
||||
("shop.account.port", PORT),
|
||||
("shop.account.adapter", adapter)
|
||||
])
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C012"
|
||||
);
|
||||
let app = "type Problem { error Missing(message: Text)\nerror Denied(code: Int) }\ntype ReadResult { Ready(value: Int)\nerror Failed(problem: Problem) }\nread(value: Int) (answer: ReadResult) { answer = result(port.read(value)) }";
|
||||
assert!(
|
||||
packages(&[
|
||||
("shop.account.app", app),
|
||||
("shop.account.port", PORT),
|
||||
("shop.account.adapter", ADAPTER)
|
||||
])
|
||||
.is_ok()
|
||||
);
|
||||
let app = app.replace(
|
||||
"answer = result(port.read(value))",
|
||||
"lost = result(port.read(value))\nanswer = ReadResult.Ready(value)",
|
||||
);
|
||||
assert_eq!(
|
||||
packages(&[
|
||||
("shop.account.app", &app),
|
||||
("shop.account.port", PORT),
|
||||
("shop.account.adapter", ADAPTER)
|
||||
])
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C012"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
use super::{APP, PORT, packages, signature};
|
||||
|
||||
#[test]
|
||||
fn external_worker_secrets_belong_only_in_settings() {
|
||||
let adapter = "setting { token: Secret }\nexternal exec \"worker\" {}";
|
||||
for kind in [
|
||||
"Text",
|
||||
"Secret",
|
||||
"Secret?",
|
||||
"List<Secret>",
|
||||
"app.Credentials",
|
||||
] {
|
||||
let app = format!(
|
||||
"type Problem {{ error Failed(message: Text) }}\ntype Credentials {{ tokens: List<Secret?> }}\nread(value: {kind}) (answer: Int) {{ answer = port.read(value) }}"
|
||||
);
|
||||
let port = format!("read(value: {kind}) (answer: Int) fails app.Problem");
|
||||
let result = packages(&[
|
||||
("shop.account.app", &app),
|
||||
("shop.account.port", &port),
|
||||
("shop.account.adapter", adapter),
|
||||
]);
|
||||
if kind == "Text" {
|
||||
assert!(result.is_ok(), "{result:?}");
|
||||
} else {
|
||||
let error = result.unwrap_err();
|
||||
assert!(
|
||||
error.message.contains("Secret belongs only in setting"),
|
||||
"{kind}: {error:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_are_private_readonly_typed_values() {
|
||||
let adapter = "setting { offset: Int\ntoken: Secret }\nport.read(value: Int) (answer: Int) { answer = helper(value) }\nhelper(value: Int) (answer: Int) pure { answer = value + setting.offset }";
|
||||
let program = packages(&[
|
||||
("shop.account.app", APP),
|
||||
("shop.account.port", PORT),
|
||||
("shop.account.adapter", adapter),
|
||||
])
|
||||
.unwrap();
|
||||
assert!(signature(&program, "read").effects.configuration);
|
||||
assert!(signature(&program, "helper").pure);
|
||||
assert_eq!(signature(&program, "read").failures.len(), 2);
|
||||
for body in [
|
||||
"setting.offset = value\nanswer = value",
|
||||
"setting = value\nanswer = value",
|
||||
"answer = setting.missing",
|
||||
"answer = setting.offset()",
|
||||
"answer = setting.token",
|
||||
] {
|
||||
let bad = format!(
|
||||
"setting {{ offset: Int\ntoken: Secret }}\nport.read(value: Int) (answer: Int) {{ {body} }}"
|
||||
);
|
||||
assert!(
|
||||
packages(&[
|
||||
("shop.account.app", APP),
|
||||
("shop.account.port", PORT),
|
||||
("shop.account.adapter", &bad)
|
||||
])
|
||||
.is_err(),
|
||||
"{body}"
|
||||
);
|
||||
}
|
||||
let empty =
|
||||
"setting {}\nport.read(value: Int) (answer: Int) { setting = value\nanswer = value }";
|
||||
assert!(
|
||||
packages(&[
|
||||
("shop.account.app", APP),
|
||||
("shop.account.port", PORT),
|
||||
("shop.account.adapter", empty)
|
||||
])
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_reject_wrong_roles_invalid_wire_fields_and_shadowing() {
|
||||
for owner in [
|
||||
"main",
|
||||
"shop.account.app",
|
||||
"shop.account.port",
|
||||
"shop.account.domain",
|
||||
] {
|
||||
assert!(
|
||||
packages(&[(owner, "setting { offset: Int }")]).is_err(),
|
||||
"{owner}"
|
||||
);
|
||||
}
|
||||
for setting in [
|
||||
"setting { Ready }",
|
||||
"setting { private offset: Int }",
|
||||
"setting { offset: Int > 0 }",
|
||||
"setting { file: dever.system.File }",
|
||||
"setting { values: Map<Text, Secret> }",
|
||||
"setting { offset: Int }\nsetting { token: Secret }",
|
||||
"setting { missing: Unknown }",
|
||||
] {
|
||||
let adapter =
|
||||
format!("{setting}\nport.read(value: Int) (answer: Int) {{ answer = value }}");
|
||||
assert!(
|
||||
packages(&[
|
||||
("shop.account.app", APP),
|
||||
("shop.account.port", PORT),
|
||||
("shop.account.adapter", &adapter)
|
||||
])
|
||||
.is_err(),
|
||||
"{setting}"
|
||||
);
|
||||
}
|
||||
for helper in [
|
||||
"helper(setting: Int) () {}",
|
||||
"helper() (setting: Int) { setting = 1 }",
|
||||
] {
|
||||
let adapter = format!(
|
||||
"setting {{ offset: Int }}\nport.read(value: Int) (answer: Int) {{ answer = value }}\n{helper}"
|
||||
);
|
||||
assert!(
|
||||
packages(&[
|
||||
("shop.account.app", APP),
|
||||
("shop.account.port", PORT),
|
||||
("shop.account.adapter", &adapter)
|
||||
])
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unused_adapter_candidates_still_enforce_failures_and_complete_contracts() {
|
||||
let valid = "port.read(value: Int) (answer: Int) { answer = value }";
|
||||
let invalid = "type Private { error Failed }\nport.read(value: Int) (answer: Int) { fail(Private.Failed()) }";
|
||||
assert_eq!(
|
||||
packages(&[
|
||||
("shop.account.app", APP),
|
||||
("shop.account.port", PORT),
|
||||
("shop.account.adapter.local", valid),
|
||||
("shop.account.adapter.remote", invalid)
|
||||
])
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C012"
|
||||
);
|
||||
let extra_port = format!("{PORT}\nwrite(value: Int) () fails app.Problem");
|
||||
let complete = format!("{valid}\nport.write(value: Int) () {{}}");
|
||||
assert!(
|
||||
packages(&[
|
||||
("shop.account.app", APP),
|
||||
("shop.account.port", &extra_port),
|
||||
("shop.account.adapter.local", &complete),
|
||||
("shop.account.adapter.remote", valid)
|
||||
])
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_keeps_all_candidate_effects_and_failure_identity() {
|
||||
let quiet = "setting { offset: Int }\nport.read(value: Int) (answer: Int) { answer = value + setting.offset }";
|
||||
let scheduled = "port.read(value: Int) (answer: Int) { answer = wait(run(helper(value))) }\nhelper(value: Int) (answer: Int) { answer = value }";
|
||||
let program = packages(&[
|
||||
("shop.account.app", APP),
|
||||
("shop.account.port", PORT),
|
||||
("shop.account.adapter.local", quiet),
|
||||
("shop.account.adapter.remote", scheduled),
|
||||
])
|
||||
.unwrap();
|
||||
let effects = signature(&program, "read").effects;
|
||||
assert!(effects.configuration && effects.suspend && effects.schedule);
|
||||
let pure = APP.replace("(answer: Int) {", "(answer: Int) pure {");
|
||||
assert_eq!(
|
||||
packages(&[
|
||||
("shop.account.app", &pure),
|
||||
("shop.account.port", PORT),
|
||||
("shop.account.adapter.local", quiet),
|
||||
("shop.account.adapter.remote", scheduled),
|
||||
])
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C013"
|
||||
);
|
||||
let error = "port.read(value: Int) (answer: Int) { fail(app.Problem.Missing(\"missing\")) }";
|
||||
let program = packages(&[
|
||||
("shop.account.app", APP),
|
||||
("shop.account.port", PORT),
|
||||
("shop.account.adapter.local", quiet),
|
||||
("shop.account.adapter.remote", error),
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(signature(&program, "read").failures.len(), 2);
|
||||
assert!(signature(&program, "read").effects.configuration);
|
||||
assert!(!program.api_snapshot().contains("setting.offset"));
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
use dever::{
|
||||
compiler::{check, model::Program},
|
||||
syntax::{diagnostic::Diagnostic, source::Source},
|
||||
};
|
||||
|
||||
fn linked(text: &str) -> Result<Program, Diagnostic> {
|
||||
let inputs = [
|
||||
("main", text),
|
||||
("text", include_str!("../../../lib/text.dever")),
|
||||
("int", include_str!("../../../lib/int.dever")),
|
||||
("decimal", include_str!("../../../lib/decimal.dever")),
|
||||
("float", include_str!("../../../lib/float.dever")),
|
||||
("math", include_str!("../../../lib/math.dever")),
|
||||
];
|
||||
let modules = inputs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, (_, text))| {
|
||||
dever::syntax::parse(&Source::new(
|
||||
index,
|
||||
if index == 0 {
|
||||
"main.dever".into()
|
||||
} else {
|
||||
format!("library{index}.dever")
|
||||
},
|
||||
(*text).into(),
|
||||
))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
check::check(
|
||||
modules,
|
||||
&inputs
|
||||
.iter()
|
||||
.map(|(package, _)| (*package).into())
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
}
|
||||
#[test]
|
||||
fn text_and_numeric_standard_calls_remain_typed_and_effect_free() {
|
||||
let program = linked("main() (answer: Bool) pure { text = text.join([int.toText(42), decimal.toText(decimal.round(1.25, 1))], \"/\")\n answer = text.contains(text, \"42\") and math.isNan(math.sqrt(-1e0)) }").unwrap();
|
||||
let main = program
|
||||
.functions
|
||||
.iter()
|
||||
.find(|function| function.signature.name == "main")
|
||||
.unwrap();
|
||||
assert_eq!(main.signature.effects, Default::default());
|
||||
for source in [
|
||||
"main() () { value = int.parse(1) }",
|
||||
"main() () { value = text.slice(\"x\", 1.0, 2) }",
|
||||
"main() () { value = math.sqrt(\"9\") }",
|
||||
"main() (answer: Int) { answer = int.parse(\"1\") }",
|
||||
] {
|
||||
assert_eq!(linked(source).unwrap_err().code, "C005", "{source}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_sources_cannot_call_standard_primitive_boundaries() {
|
||||
for name in [
|
||||
"textTrim",
|
||||
"textJoin",
|
||||
"intParse",
|
||||
"decimalRound",
|
||||
"floatFromInt",
|
||||
"floatSqrt",
|
||||
] {
|
||||
assert_eq!(
|
||||
linked(&format!("main() () {{ value = dever.system.{name}(1) }}"))
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C006",
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
use super::checked;
|
||||
|
||||
#[test]
|
||||
fn literal_collections_select_values_without_building_containers() {
|
||||
for text in [
|
||||
"main(value: Text) (answer: Text?) { answer = get({ \"left\" = value }, \"left\") }",
|
||||
"main(value: Text?) (answer: Text?) { answer = get({ \"雪\" = value }, \"雪\") }",
|
||||
"main(value: Text) (answer: Text?) { answer = get({ \"left\" = value }, \"missing\") }",
|
||||
"main(value: Text) (answer: Text?) { answer = first([value]) }",
|
||||
"main(value: Text?) (answer: Text?) { answer = first([value]) }",
|
||||
] {
|
||||
let source = dever::syntax::source::Source::new(0, "case.dever", text.into());
|
||||
let mut program = checked(text).unwrap();
|
||||
program.functions[0].signature.name = "renamed".into();
|
||||
let generated = dever::compiler::emit::generate(&program, &[source], &[0].into());
|
||||
assert!(!generated.contains("Map::with_capacity"), "{generated}");
|
||||
assert!(!generated.contains("vec!["), "{generated}");
|
||||
assert!(generated.contains(".to_owned()"), "{generated}");
|
||||
}
|
||||
for (text, constructor) in [
|
||||
(
|
||||
"main(value: Text, key: Text) (answer: Text?) { answer = get({ \"left\" = value }, key) }",
|
||||
"Map::with_capacity",
|
||||
),
|
||||
(
|
||||
"main(value: Text, key: Text) (answer: Text?) { answer = get({ key = value }, \"left\") }",
|
||||
"Map::with_capacity",
|
||||
),
|
||||
(
|
||||
"main(value: dever.system.File) (answer: dever.system.File?) { answer = get({ \"left\" = value }, \"left\") }",
|
||||
"Map::with_capacity",
|
||||
),
|
||||
(
|
||||
"main(value: dever.system.File) (answer: dever.system.File?) { answer = first([value]) }",
|
||||
"vec![",
|
||||
),
|
||||
] {
|
||||
let source = dever::syntax::source::Source::new(0, "case.dever", text.into());
|
||||
let program = checked(text).unwrap();
|
||||
let generated = dever::compiler::emit::generate(&program, &[source], &[0].into());
|
||||
assert!(generated.contains(constructor), "{generated}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contains_checks_comparable_items_without_consuming_error_obligations() {
|
||||
for source in [
|
||||
"main(values: List<Text>, target: Text) (found: Bool) { found = contains(values, target) }",
|
||||
"main() (found: Bool) { found = contains([], 1) }",
|
||||
"type Row { id: Int }\nmain(rows: List<Row>, row: Row) (found: Bool) { found = contains(rows, row) }",
|
||||
] {
|
||||
checked(source).unwrap();
|
||||
}
|
||||
for source in [
|
||||
"main() (found: Bool) { found = contains([1], true) }",
|
||||
"main() (found: Bool) { found = contains([], null) }",
|
||||
"type Outcome { Done\nerror Failed }\nmain() (found: Bool) { found = contains([Outcome.Failed], Outcome.Done) }",
|
||||
] {
|
||||
assert!(checked(source).is_err(), "{source}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_construction_borrows_before_moving_owned_values() {
|
||||
for fields in [
|
||||
"first = owned\n second = length(owned)",
|
||||
"second = length(owned)\n first = owned",
|
||||
] {
|
||||
let text = format!(
|
||||
"type Row {{ first: Text\n second: Int }}\nmain(input: Text) (answer: Row) {{ owned = input + \"!\"\n answer = Row {{ {fields} }} }}"
|
||||
);
|
||||
let source = dever::syntax::source::Source::new(0, "case.dever", text);
|
||||
let program = checked(&source.text).unwrap();
|
||||
let generated = dever::compiler::emit::generate(&program, &[source], &[0].into());
|
||||
assert_eq!(generated.matches(".clone()").count(), 0, "{generated}");
|
||||
assert!(!generated.contains(".to_owned()"), "{generated}");
|
||||
}
|
||||
let text = "type Row { first: Text\n second: Text }\nmain(input: Text) (answer: Row) { owned = input + \"!\"\n answer = Row { first = owned\n second = owned } }";
|
||||
let source = dever::syntax::source::Source::new(0, "case.dever", text.into());
|
||||
let program = checked(text).unwrap();
|
||||
let generated = dever::compiler::emit::generate(&program, &[source], &[0].into());
|
||||
assert_eq!(generated.matches(".clone()").count(), 1, "{generated}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_keys_and_literal_identity_are_checked() {
|
||||
for kind in [
|
||||
"Float",
|
||||
"Decimal",
|
||||
"Int?",
|
||||
"List<Int>",
|
||||
"Map<Int, Int>",
|
||||
"Row",
|
||||
"Payload",
|
||||
] {
|
||||
let source = format!(
|
||||
"type Row {{ count: Int }}\ntype Payload {{ Value(value: Int) }}\nunused(values: Map<{kind}, Int>) () {{}}"
|
||||
);
|
||||
assert_eq!(checked(&source).unwrap_err().code, "C005", "{kind}");
|
||||
}
|
||||
for body in [
|
||||
"value = { \"a\" = 1\n \"a\" = 2 }",
|
||||
"value = { 1 + 1 = 1\n 2 = 2 }",
|
||||
"value = { Key.First = 1\n Key.First = 2 }",
|
||||
"key = \"a\"\n alias = key\n value = { alias = 1\n \"a\" = 2 }",
|
||||
"value = { input = 1\n input = 2 }",
|
||||
] {
|
||||
let source = format!("type Key {{ First\n Second }}\ninvalid(input: Text) () {{ {body} }}");
|
||||
assert_eq!(checked(&source).unwrap_err().code, "C002", "{body}");
|
||||
}
|
||||
assert!(checked("valid(input: Text, other: Text) (values: Map<Text, Int>) { alias = input\n input = other\n values = { alias = 1\n input = 2 } }").is_ok());
|
||||
assert_eq!(
|
||||
checked("type Node { children: Map<Int, Node> }")
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C007"
|
||||
);
|
||||
assert_eq!(
|
||||
checked("type Bad { values: Map<Float, Int> }")
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C005"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn access_types_empty_contexts_and_nullable_results_are_checked() {
|
||||
for source in [
|
||||
"main() (values: List<Int>) { values = append([], 1) }",
|
||||
"main() (values: Map<Text, Int>) { values = put({}, \"a\", 1) }",
|
||||
"main() (value: Int?) { value = first([]) }",
|
||||
"main(values: Map<Text, Int?>) (value: Int?) { value = get(values, \"a\") }",
|
||||
"main(values: List<Int?>) (value: Int?) { value = first(values) }",
|
||||
"main() (values: List<MapEntry<Text, Int>>) { values = entries({}) }",
|
||||
"main(values: List<MapEntry<Text, Int>>) (result: List<MapEntry<Text, Int>>) { result = values }",
|
||||
"main(value: MapEntry<Text, Int>) (result: Text) { result = value.key }",
|
||||
] {
|
||||
assert!(checked(source).is_ok(), "{source}");
|
||||
}
|
||||
for source in [
|
||||
"main() () { values = {} }",
|
||||
"main() () { values = { 1 = 2\n \"a\" = 3 } }",
|
||||
"main() () { values = { 1 = 2\n 2 = true } }",
|
||||
"main() () { values = put({}, 1.0, 1) }",
|
||||
"main(values: Map<Text, Int>) () { value = get(values, 1) }",
|
||||
"main(values: List<Int>) () { value = append(values, true) }",
|
||||
"main(values: Bytes) () { value = append(values, 1) }",
|
||||
"main(values: Map<Text, Int>) () { value = first(values) }",
|
||||
"main(value: MapEntry<Text, Int>) () { field = value.missing }",
|
||||
] {
|
||||
assert!(checked(source).is_err(), "{source}");
|
||||
}
|
||||
}
|
||||
|
||||
const ERRORS: &str = "type Result { Ready(value: Int)\n error Failed(message: Text) }\nconsume(input: Result.Ready(value)) () recover(\"discard explicitly in test\") {}\nconsume(input: Result.Failed(message)) () recover(\"discard explicitly in test\") {}\n";
|
||||
|
||||
#[test]
|
||||
fn nested_collection_shapes_survive_error_free_records_and_calls() {
|
||||
let types = "type Row { values: List<Int> }\ntype Box { row: Row }\ntype Choice { Row(value: Row)\n Empty }\n";
|
||||
for body in [
|
||||
"update(row: Row) (answer: List<Int>) { answer = append(row.values, 1) }",
|
||||
"update(box: Box) (answer: List<Int>) { answer = append(box.row.values, 1) }",
|
||||
"update(entry: MapEntry<Int, Row>) (answer: List<Int>) { answer = append(entry.value.values, 1) }",
|
||||
"update(row: Row) (answer: List<Int>) { answer = append(row.values, 1) }\nupdate(row: null) (answer: List<Int>) { answer = [] }",
|
||||
"update(choice: Choice.Row(row)) (answer: List<Int>) { answer = append(row.values, 1) }\nupdate(choice: Choice.Empty) (answer: List<Int>) { answer = [] }",
|
||||
"choose(flag: true, row: Row) (answer: Row) { answer = row }\nchoose(flag: false, row: Row) (answer: Row) { answer = Row { values = [] } }\nupdate(flag: Bool, row: Row) (answer: List<Int>) { selected = choose(flag, row)\n answer = append(selected.values, 1) }",
|
||||
] {
|
||||
let result = checked(&(types.to_owned() + body));
|
||||
assert!(result.is_ok(), "{body}: {result:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_errors_cannot_be_discarded_by_lookup_or_mutation() {
|
||||
let types = "type Result { Ready(value: Int)\n error Failed(message: Text) }\n";
|
||||
for body in [
|
||||
"bad(values: Map<Text, Result>) (answer: Int) { answer = length(values) }",
|
||||
"bad(values: Map<Text, Result>) (answer: Result?) { answer = get(values, \"one\") }",
|
||||
"bad(values: Map<Text, Result>) (answer: Map<Text, Result>) { answer = remove(values, \"one\") }",
|
||||
"bad(values: Map<Text, Result>) (answer: Map<Text, Result>) { answer = put(values, \"one\", Result.Ready(1)) }",
|
||||
"bad(values: List<Result>) (answer: Result?) { answer = first(values) }",
|
||||
"bad() (answer: Result?) { answer = first([Result.Ready(1), Result.Failed(\"lost\")]) }",
|
||||
"bad(value: MapEntry<Text, Result>) (answer: Text) { answer = value.key }",
|
||||
] {
|
||||
assert_eq!(
|
||||
checked(&(types.to_owned() + body)).unwrap_err().code,
|
||||
"C012",
|
||||
"{body}"
|
||||
);
|
||||
}
|
||||
for body in [
|
||||
"valid(values: Map<Text, Result>) (answer: Map<Text, Result>) { answer = values }",
|
||||
"valid(values: Map<Text, Result>) (answer: List<MapEntry<Text, Result>>) { answer = entries(values) }",
|
||||
"valid(values: List<Result>) (answer: List<Result>) { answer = append(values, Result.Ready(1)) }",
|
||||
"valid() (answer: Result?) { answer = first([Result.Failed(\"returned\")]) }",
|
||||
"valid() (answer: Map<Text, Result>) { answer = put({}, \"one\", Result.Failed(\"returned\")) }",
|
||||
] {
|
||||
assert!(checked(&(types.to_owned() + body)).is_ok(), "{body}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_entries_traversal_handles_keys_values_and_nested_errors() {
|
||||
let source = ERRORS.to_owned();
|
||||
let source = source
|
||||
+ "consumeEntry(entry: MapEntry<Text, Result>) () { consume(entry.value) }\nvalid(values: Map<Text, Result>) () { each(consumeEntry, entries(values)) }";
|
||||
let result = checked(&source);
|
||||
assert!(result.is_ok(), "{result:?}");
|
||||
let source = "type Key { Fine\n error Failed }\nconsume(entry: MapEntry<Key, Int>) () recover(\"key error policy\") {}\nvalid(values: Map<Key, Int>) () { each(consume, entries(values)) }";
|
||||
assert!(checked(source).is_ok());
|
||||
assert_eq!(checked("type Key { Fine\n error Failed }\nbad(values: Map<Key, Int>) (answer: Int) { answer = length(values) }").unwrap_err().code, "C012");
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
use super::linked;
|
||||
|
||||
#[test]
|
||||
fn async_streams_support_typed_sequential_consumers_and_infer_suspension() {
|
||||
let source = "add(value: Int, state: Int) (next: Int) pure { next = value + state }\n main() (answer: Int) { answer = reduce(add, stream([1, 2]), 0) }";
|
||||
let program = linked(source).unwrap();
|
||||
let main = program
|
||||
.functions
|
||||
.iter()
|
||||
.find(|function| function.signature.name == "main")
|
||||
.unwrap();
|
||||
assert!(main.signature.effects.suspend);
|
||||
assert!(main.signature.effects.async_stream);
|
||||
for source in [
|
||||
"main() (values: AsyncStream<Int>) { values = stream([]) }",
|
||||
"main() (values: AsyncStream<Int>) { values = dever.task.ticks(1) }",
|
||||
"main(values: AsyncStream<Int>) () { close(values) }",
|
||||
] {
|
||||
let program = linked(source).unwrap();
|
||||
let main = program
|
||||
.functions
|
||||
.iter()
|
||||
.find(|function| function.signature.name == "main")
|
||||
.unwrap();
|
||||
assert!(!main.signature.effects.suspend);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn async_streams_reject_wrong_handlers_nontransferable_values_and_private_intrinsics() {
|
||||
for (source, code) in [
|
||||
(
|
||||
"main(values: List<Stream<Int>>) () { close(stream(values)) }",
|
||||
"C013",
|
||||
),
|
||||
(
|
||||
"main(values: AsyncStream<Stream<Int>>) () { close(values) }",
|
||||
"C013",
|
||||
),
|
||||
("main() () pure { close(stream([1])) }", "C013"),
|
||||
("main() () { close(dever.system.taskTicks(1)) }", "C006"),
|
||||
(
|
||||
"copy(value: Int) (next: Int) { next = value }\nmain() () { each(copy, stream([1])) }",
|
||||
"C005",
|
||||
),
|
||||
(
|
||||
"consume(value: Int, state: Stream<Int>) () { close(state) }\nmain(state: Stream<Int>) () { each(consume, stream([1]), state) }",
|
||||
"C013",
|
||||
),
|
||||
("main() () { close(stream(1)) }", "C005"),
|
||||
] {
|
||||
assert_eq!(linked(source).unwrap_err().code, code, "{source}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streams_preserve_existing_error_obligations() {
|
||||
let prefix = "type Result { Done\n error Failed(message: Text) }\nhandle(value: Result) () recover(\"handles stream errors\") {}\n";
|
||||
assert!(
|
||||
linked(&format!(
|
||||
"{prefix}main() () {{ each(handle, stream([Result.Failed(\"reason\")])) }}"
|
||||
))
|
||||
.is_ok()
|
||||
);
|
||||
for body in [
|
||||
"values = stream([Result.Failed(\"reason\")])\n close(values)",
|
||||
"values = stream([Result.Failed(\"reason\")])",
|
||||
] {
|
||||
assert_eq!(
|
||||
linked(&format!("{prefix}main() () {{ {body} }}"))
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C012"
|
||||
);
|
||||
}
|
||||
assert!(linked(&format!("{prefix}main() (values: AsyncStream<Result>) {{ values = stream([Result.Failed(\"reason\")]) }}")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_stream_aliases_cannot_discharge_unread_error_values() {
|
||||
let prefix = "type Result { Done\n error Failed(message: Text) }\nhandle(value: Result) () recover(\"handles errors\") {}\nforward(values: AsyncStream<Result>) (output: AsyncStream<Result>) { output = values }\ntype Box { values: AsyncStream<Result> }\n";
|
||||
for body in [
|
||||
"values = stream([Result.Failed(\"reason\")])\n alias = values\n close(alias)\n each(handle, values)",
|
||||
"values = stream([Result.Failed(\"reason\")])\n alias = forward(values)\n close(alias)\n each(handle, values)",
|
||||
"values = stream([Result.Failed(\"reason\")])\n box = Box { values = values }\n close(box.values)\n each(handle, values)",
|
||||
] {
|
||||
assert_eq!(
|
||||
linked(&format!("{prefix}main() () {{ {body} }}"))
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C012",
|
||||
"{body}"
|
||||
);
|
||||
}
|
||||
assert_eq!(linked(&format!("{prefix}main() (output: AsyncStream<Result>) {{ values = stream([Result.Failed(\"reason\")])\n close(values)\n output = values }}")).unwrap_err().code, "C012");
|
||||
assert!(linked(&format!("{prefix}main() () {{ original = [Result.Failed(\"reason\")]\n close(stream(original))\n each(handle, original) }}")).is_ok());
|
||||
assert!(linked(&format!("{prefix}main() () {{ original = [Result.Failed(\"reason\")]\n first = stream(original)\n second = stream(original)\n close(first)\n each(handle, second) }}")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forwarding_a_lazy_stream_keeps_future_item_error_obligations() {
|
||||
let prefix = "type Result { Done\n error Failed(message: Text) }\nforward(values: AsyncStream<Result>) (output: AsyncStream<Result>) { output = values }\ncollect(value: Result, state: List<Result>) (next: List<Result>) { next = append(state, value) }\n";
|
||||
for input in [
|
||||
"stream(values)",
|
||||
"forward(stream(values))",
|
||||
"forward(forward(stream(values)))",
|
||||
] {
|
||||
assert_eq!(
|
||||
linked(&format!(
|
||||
"{prefix}main(values: Channel<Result>) () {{ lost = reduce(collect, {input}, []) }}"
|
||||
))
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C012",
|
||||
"{input}"
|
||||
);
|
||||
assert!(linked(&format!("{prefix}main(values: Channel<Result>) (output: List<Result>) {{ output = reduce(collect, {input}, []) }}")).is_ok());
|
||||
}
|
||||
assert!(
|
||||
linked(&format!(
|
||||
"{prefix}main(values: Channel<Result>) () {{ close(forward(stream(values))) }}"
|
||||
))
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
use super::linked;
|
||||
use dever::compiler::build;
|
||||
|
||||
#[test]
|
||||
fn channel_types_require_transferable_elements_and_typed_values() {
|
||||
assert!(linked("main() () { values = channel(List<Int?>, 2)\n send(values, [null, 1])\n close(values) }").is_ok());
|
||||
assert!(linked("main() () { values = channel(Channel<Int>, 1)\n send(values, channel(Int, 1))\n close(values) }").is_ok());
|
||||
assert!(linked("type Message { Value(value: Int?) }\nmain() () { values = channel(Message, 1)\n send(values, Message.Value(null))\n close(values) }").is_ok());
|
||||
for (source, code) in [
|
||||
("main() () { values = channel(Int, true) }", "C005"),
|
||||
("main() () { values = channel(Int?, 1) }", "C013"),
|
||||
("main(values: Channel<Int?>) () { close(values) }", "C013"),
|
||||
(
|
||||
"type Unused { values: Channel<Int?> }\nmain() () {}",
|
||||
"C013",
|
||||
),
|
||||
(
|
||||
"main() () { values = channel(Int, 1)\n send(values, true) }",
|
||||
"C005",
|
||||
),
|
||||
("main() () { values = channel(Stream<Int>, 1) }", "C013"),
|
||||
(
|
||||
"main() () { values = channel(List<Stream<Int>>, 1) }",
|
||||
"C013",
|
||||
),
|
||||
(
|
||||
"main() () { values = channel(Map<Channel<Int>, Int>, 1) }",
|
||||
"C005",
|
||||
),
|
||||
("type Loop { next: Channel<Loop> }\nmain() () {}", "C007"),
|
||||
("main() () pure { values = channel(Int, 1) }", "C013"),
|
||||
(
|
||||
"main(values: Stream<Int>, channel: Channel<Int>) () { close(channel)\n close(values) }",
|
||||
"C013",
|
||||
),
|
||||
] {
|
||||
assert_eq!(linked(source).unwrap_err().code, code, "{source}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_transfers_errors_and_receive_requires_handling_them() {
|
||||
let prefix = "type Result { Done\n error Failed(message: Text) }\n";
|
||||
assert!(linked(&format!("{prefix}main() () {{ values = channel(Result, 1)\n send(values, Result.Failed(\"reason\"))\n close(values) }}")).is_ok());
|
||||
assert_eq!(
|
||||
linked(&format!(
|
||||
"{prefix}main(values: Channel<Result>) () {{ lost = receive(values)\n close(values) }}"
|
||||
))
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C012"
|
||||
);
|
||||
assert!(
|
||||
linked(&format!(
|
||||
"{prefix}main(values: Channel<Result>) (answer: Result?) {{ answer = receive(values) }}"
|
||||
))
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_creation_and_type_references_enable_dependency_without_suspending() {
|
||||
for (source, required) in [
|
||||
(
|
||||
"main() (value: Channel<Int>) { value = channel(Int, 1) }",
|
||||
true,
|
||||
),
|
||||
("main() (value: Channel<Int>?) { value = null }", true),
|
||||
("type Unused { value: Channel<Int> }\nmain() () {}", false),
|
||||
] {
|
||||
let program = linked(source).unwrap();
|
||||
let main = program
|
||||
.functions
|
||||
.iter()
|
||||
.position(|function| function.signature.name == "main")
|
||||
.unwrap();
|
||||
assert!(!program.functions[main].signature.effects.suspend);
|
||||
assert!(!program.functions[main].signature.effects.schedule);
|
||||
assert_eq!(
|
||||
build::runtime_features(&program, &program.reachable([main])).contains(&"task"),
|
||||
required
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
use super::linked;
|
||||
|
||||
#[test]
|
||||
fn groups_require_local_affine_handles_and_zero_output_tasks() {
|
||||
let prefix = "work() () {}\nvalue() (answer: Int) { answer = 1 }\n";
|
||||
for body in [
|
||||
"pool = group(2)\n run(pool, work())\n run(pool, work())\n wait(pool)",
|
||||
"wait(group(1))\n stop(group(2))",
|
||||
"pool = group(1)\n wait(pool)\n pool = group(2)\n stop(pool)",
|
||||
] {
|
||||
assert!(
|
||||
linked(&format!("{prefix}main() () {{ {body} }}")).is_ok(),
|
||||
"{body}"
|
||||
);
|
||||
}
|
||||
for body in [
|
||||
"pool = group(2)",
|
||||
"pool = group(2)\n alias = pool\n wait(pool)",
|
||||
"pool = group(2)\n values = [pool]\n wait(pool)",
|
||||
"pool = group(2)\n wait(pool)\n wait(pool)",
|
||||
"pool = group(2)\n wait(pool)\n run(pool, work())",
|
||||
"pool = group(2)\n pool = group(1)\n wait(pool)",
|
||||
] {
|
||||
assert_eq!(
|
||||
linked(&format!("{prefix}main() () {{ {body} }}"))
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C013",
|
||||
"{body}"
|
||||
);
|
||||
}
|
||||
for body in [
|
||||
"run(group(1), work())",
|
||||
"pool = group(1)\n run(pool, value())\n wait(pool)",
|
||||
] {
|
||||
assert_eq!(
|
||||
linked(&format!("{prefix}main() () {{ {body} }}"))
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C005"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_wait_discharges_inputs_but_stop_does_not() {
|
||||
let prefix = "type Result { Done\n error Failed(message: Text) }\nhandle(value: Result) () recover(\"handles input\") {}\n";
|
||||
let body = "pool = group(2)\n run(pool, handle(Result.Failed(\"first\")))\n run(pool, handle(Result.Failed(\"second\")))";
|
||||
assert!(linked(&format!("{prefix}main() () {{ {body}\n wait(pool) }}")).is_ok());
|
||||
assert_eq!(
|
||||
linked(&format!("{prefix}main() () {{ {body}\n stop(pool) }}"))
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C012"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_arguments_keep_ownership_and_transferability_rules() {
|
||||
let prefix =
|
||||
"work(value: Int) () {}\nduration() (value: Int) { dever.task.sleep(0)\n value = 1 }\n";
|
||||
assert!(
|
||||
linked(&format!(
|
||||
"{prefix}main() () {{ pool = group(1)\n run(pool, work(duration()))\n wait(pool) }}"
|
||||
))
|
||||
.is_ok()
|
||||
);
|
||||
assert_eq!(
|
||||
linked(&format!(
|
||||
"{prefix}main() () {{ pool = group(1)\n run(pool, work(length([wait(pool)]))) }}"
|
||||
))
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C013"
|
||||
);
|
||||
assert_eq!(linked("work(values: List<Stream<Int>>) () {}\nmain() () { pool = group(1)\n run(pool, work([]))\n wait(pool) }").unwrap_err().code, "C013");
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
use dever::compiler::{check, model::Program};
|
||||
use dever::syntax::{diagnostic::Diagnostic, source::Source};
|
||||
|
||||
mod async_stream;
|
||||
mod channel;
|
||||
mod group;
|
||||
mod parallel;
|
||||
mod select;
|
||||
|
||||
fn linked(text: &str) -> Result<Program, Diagnostic> {
|
||||
let inputs = [
|
||||
("main", text),
|
||||
("dever.io", include_str!("../../../../lib/dever/io.dever")),
|
||||
(
|
||||
"dever.time",
|
||||
include_str!("../../../../lib/dever/time.dever"),
|
||||
),
|
||||
(
|
||||
"dever.task",
|
||||
include_str!("../../../../lib/dever/task.dever"),
|
||||
),
|
||||
];
|
||||
let modules = inputs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, (package, text))| {
|
||||
dever::syntax::parse(&Source::new(
|
||||
index,
|
||||
if index == 0 {
|
||||
"main.dever".into()
|
||||
} else {
|
||||
format!("{package}.dever")
|
||||
},
|
||||
(*text).into(),
|
||||
))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
check::check(
|
||||
modules,
|
||||
&inputs
|
||||
.iter()
|
||||
.map(|(package, _)| (*package).into())
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_ownership_rejects_aliases_discard_overwrite_and_double_consumption() {
|
||||
let prefix = "work() (value: Int) { value = 42 }\n";
|
||||
for body in [
|
||||
"task = run(work())",
|
||||
"run(work())",
|
||||
"task = run(work())\n alias = task\n value = wait(task)",
|
||||
"task = run(work())\n tasks = [task]\n value = wait(task)",
|
||||
"task = run(work())\n task = run(work())\n value = wait(task)",
|
||||
"task = run(work())\n value = wait(task)\n again = wait(task)",
|
||||
] {
|
||||
let source = format!("{prefix}main() () {{ {body} }}");
|
||||
let error = linked(&source).unwrap_err();
|
||||
assert!(matches!(error.code, "C005" | "C013"), "{source}: {error:?}");
|
||||
}
|
||||
assert!(
|
||||
linked(&format!(
|
||||
"{prefix}main() (answer: Int) {{ task = run(work())\n answer = wait(task) }}"
|
||||
))
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
linked(&format!(
|
||||
"{prefix}main() (answer: Int) {{ answer = wait(run(work())) }}"
|
||||
))
|
||||
.is_ok()
|
||||
);
|
||||
assert_eq!(
|
||||
linked(&format!("{prefix}main() () {{ stop(run(work())) }}"))
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C005"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_circuit_cannot_conditionally_consume_tasks() {
|
||||
let prefix = "work() (value: Bool) { value = true }\n";
|
||||
assert_eq!(linked(&format!("{prefix}main(flag: Bool) (answer: Bool) {{ task = run(work())\n answer = flag and wait(task) }}")).unwrap_err().code, "C013");
|
||||
assert!(linked(&format!("{prefix}main() (answer: Bool) {{ task = run(work())\n unused = false and wait(task)\n answer = wait(task) }}")).is_ok());
|
||||
assert!(
|
||||
linked(&format!(
|
||||
"{prefix}main() (answer: Bool) {{ task = run(work())\n answer = true and wait(task) }}"
|
||||
))
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effects_distinguish_cpu_workers_blocking_calls_and_async_calls() {
|
||||
for source in [
|
||||
"main() () { dever.task.sleep(0)\n dever.io.println(\"bad\") }",
|
||||
"main() () { dever.task.sleep(0)\n dever.time.sleep(0) }",
|
||||
"work() () {}\nmain() () { parallel(work()) }",
|
||||
"work() () pure {}\nmain() () { blocking(work()) }",
|
||||
"main() () { blocking(dever.task.sleep(0)) }",
|
||||
"main() () pure { stop(run(dever.task.sleep(0))) }",
|
||||
"main() () pure { dever.time.sleep(0) }",
|
||||
] {
|
||||
assert_eq!(linked(source).unwrap_err().code, "C013", "{source}");
|
||||
}
|
||||
let program = linked("calc() (value: Int) pure { value = 42 }\nmain() (answer: Int) { answer = parallel(calc())\n blocking(dever.time.sleep(0))\n stop(run(dever.task.sleep(0))) }").unwrap();
|
||||
let main = program
|
||||
.functions
|
||||
.iter()
|
||||
.find(|function| function.signature.name == "main")
|
||||
.unwrap();
|
||||
assert!(main.signature.effects.suspend);
|
||||
let calc = program
|
||||
.functions
|
||||
.iter()
|
||||
.find(|function| function.signature.name == "calc")
|
||||
.unwrap();
|
||||
assert!(!calc.signature.effects.suspend);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nontransferable_values_cannot_escape_into_tasks_or_cross_suspension() {
|
||||
for source in [
|
||||
"work(stream: Stream<Int>) () { close(stream) }\nmain(stream: Stream<Int>) () { stop(run(work(stream))) }",
|
||||
"type Box { stream: Stream<Int> }\nwork(value: Box) () { close(value.stream) }\nmain(value: Box) () { stop(run(work(value))) }",
|
||||
"main(stream: Stream<Int>) () { dever.task.sleep(0)\n close(stream) }",
|
||||
"type Box { stream: Stream<Int> }\nmain(value: Box) () { dever.task.sleep(0)\n close(value.stream) }",
|
||||
"work(stream: Stream<Int>) () { close(stream)\n dever.task.sleep(0) }\nmain(streams: List<Stream<Int>>) () { each(work, streams) }",
|
||||
] {
|
||||
assert_eq!(linked(source).unwrap_err().code, "C013", "{source}");
|
||||
}
|
||||
assert!(linked("main(stream: Stream<Int>) () { close(stream)\n dever.task.sleep(0) }").is_ok());
|
||||
assert!(linked("main(file: dever.system.File) () { blocking(dever.io.close(file)) }").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_failure_values_cannot_be_silently_discarded() {
|
||||
let prefix = "type Result { Done(value: Int)\n error Failed(message: Text) }\nwork() (answer: Result) { answer = Result.Failed(\"reason\") }\n";
|
||||
assert_eq!(
|
||||
linked(&format!(
|
||||
"{prefix}main() () {{ task = run(work())\n lost = wait(task) }}"
|
||||
))
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C012"
|
||||
);
|
||||
assert!(
|
||||
linked(&format!(
|
||||
"{prefix}main() (answer: Result) {{ task = run(work())\n answer = wait(task) }}"
|
||||
))
|
||||
.is_ok()
|
||||
);
|
||||
assert_eq!(
|
||||
linked("main() () { lost = result(dever.task.sleep(-1)) }")
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C012"
|
||||
);
|
||||
assert!(
|
||||
linked("main() (answer: dever.time.SleepResult) { answer = result(dever.task.sleep(-1)) }")
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timer_intrinsics_cannot_be_called_by_user_packages() {
|
||||
for name in ["timeSleep", "taskSleep"] {
|
||||
assert_eq!(
|
||||
linked(&format!("main() () {{ value = dever.system.{name}(0) }}"))
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C006"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelling_a_task_does_not_claim_its_input_errors_were_handled() {
|
||||
let prefix = "type Result { Done\n error Failed(message: Text) }\nhandle(value: Result) () recover(\"worker handles an optional error\") {}\n";
|
||||
let cancelled = format!(
|
||||
"{prefix}main() () {{ pending = run(handle(Result.Failed(\"reason\")))\n stop(pending) }}"
|
||||
);
|
||||
assert_eq!(linked(&cancelled).unwrap_err().code, "C012");
|
||||
let waited = format!(
|
||||
"{prefix}main() () {{ pending = run(handle(Result.Failed(\"reason\")))\n wait(pending) }}"
|
||||
);
|
||||
assert!(linked(&waited).is_ok());
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
use super::linked;
|
||||
|
||||
#[test]
|
||||
fn parallel_consumers_select_threads_or_runtime_from_their_body() {
|
||||
let prefix = "work(value: Int) () pure {}\n";
|
||||
let program = linked(&format!(
|
||||
"{prefix}main() () {{ parallelEach(work, [1, 2], 2) }}"
|
||||
))
|
||||
.unwrap();
|
||||
let main = program
|
||||
.functions
|
||||
.iter()
|
||||
.find(|function| function.signature.name == "main")
|
||||
.unwrap();
|
||||
assert!(!main.signature.effects.suspend);
|
||||
assert!(main.signature.effects.blocking);
|
||||
for body in [
|
||||
"dever.task.sleep(0)\n parallelEach(work, [1, 2], 2)",
|
||||
"parallelEach(work, stream([1, 2]), 2)",
|
||||
] {
|
||||
let program = linked(&format!("{prefix}main() () {{ {body} }}")).unwrap();
|
||||
let main = program
|
||||
.functions
|
||||
.iter()
|
||||
.find(|function| function.signature.name == "main")
|
||||
.unwrap();
|
||||
assert!(main.signature.effects.suspend);
|
||||
assert!(main.signature.effects.schedule);
|
||||
assert!(!main.signature.effects.blocking);
|
||||
}
|
||||
assert!(linked("work(value: Int) () { dever.task.sleep(value) }\nmain() () { parallelEach(work, [0, 1], 2) }").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_handlers_keep_typed_outputs_context_and_transfer_boundaries() {
|
||||
for (source, code) in [
|
||||
(
|
||||
"work(value: Int) (answer: Int) { answer = value }\nmain() () { parallelEach(work, [1], 2) }",
|
||||
"C005",
|
||||
),
|
||||
(
|
||||
"work(value: Int) () {}\nmain() () { parallelEach(work, [1], true) }",
|
||||
"C005",
|
||||
),
|
||||
(
|
||||
"work(value: Int, context: Bool) () {}\nmain() () { parallelEach(work, [1], 2, 3) }",
|
||||
"C005",
|
||||
),
|
||||
(
|
||||
"work(value: Stream<Int>) () { close(value) }\nmain(values: List<Stream<Int>>) () { parallelEach(work, values, 2) }",
|
||||
"C013",
|
||||
),
|
||||
(
|
||||
"work(value: Int) () pure {}\nmain() () pure { parallelEach(work, [1], 2) }",
|
||||
"C013",
|
||||
),
|
||||
(
|
||||
"work(value: Int) () {}\nmain(values: Stream<Int>) () { dever.task.sleep(0)\n parallelEach(work, values, 2) }",
|
||||
"C013",
|
||||
),
|
||||
(
|
||||
"leaf(value: Int) () {}\nwork(value: Int) () { parallelEach(leaf, [value], 2) }\nmain() () { dever.task.sleep(0)\n parallelEach(work, [1], 2) }",
|
||||
"C013",
|
||||
),
|
||||
] {
|
||||
assert_eq!(linked(source).unwrap_err().code, code, "{source}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_each_transfers_errors_only_through_valid_handlers() {
|
||||
let prefix = "type Result { Done\n error Failed(message: Text) }\nhandle(value: Result) () recover(\"handles each error\") {}\n";
|
||||
assert!(
|
||||
linked(&format!(
|
||||
"{prefix}main() () {{ parallelEach(handle, [Result.Failed(\"reason\")], 2) }}"
|
||||
))
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
linked(&format!(
|
||||
"{prefix}main() () {{ parallelEach(handle, stream([Result.Failed(\"reason\")]), 2) }}"
|
||||
))
|
||||
.is_ok()
|
||||
);
|
||||
assert_eq!(linked("type Result { error Failed(values: List<Stream<Int>>) }\nwork(value: Int) () { fail(Result.Failed([])) }\nmain() () { parallelEach(work, [1], 2) }").unwrap_err().code, "C013");
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
use super::linked;
|
||||
|
||||
#[test]
|
||||
fn selection_checks_named_outputs_static_fallback_and_affine_ownership() {
|
||||
let prefix = "work() (value: Int) { value = 1 }\nfallback() (value: Int) { value = 2 }\n";
|
||||
for body in [
|
||||
"value = timeout(run(work()), 1, fallback)",
|
||||
"first = run(work())\n second = run(work())\n value = race(first, second)",
|
||||
] {
|
||||
assert!(linked(&format!("{prefix}main() (value: Int) {{ {body} }}")).is_ok());
|
||||
}
|
||||
for (extra, body, code) in [
|
||||
(
|
||||
"wrong() (answer: Int) { answer = 0 }",
|
||||
"value = timeout(run(work()), 1, wrong)",
|
||||
"C005",
|
||||
),
|
||||
(
|
||||
"wrong(input: Int) (value: Int) { value = input }",
|
||||
"value = timeout(run(work()), 1, wrong)",
|
||||
"C005",
|
||||
),
|
||||
(
|
||||
"wrong() (answer: Int) { answer = 0 }",
|
||||
"value = race(run(work()), run(wrong()))",
|
||||
"C005",
|
||||
),
|
||||
("", "value = race(run(work()))", "C005"),
|
||||
("", "value = race(run(work()), 1)", "C005"),
|
||||
(
|
||||
"",
|
||||
"pending = run(work())\n value = race(pending, pending)",
|
||||
"C013",
|
||||
),
|
||||
(
|
||||
"",
|
||||
"pending = run(work())\n value = timeout(pending, 1, fallback)\n again = wait(pending)",
|
||||
"C013",
|
||||
),
|
||||
("", "value = timeout(run(work()), 1.0e0, fallback)", "C005"),
|
||||
] {
|
||||
let source = format!("{prefix}{extra}\nmain() (value: Int) {{ {body} }}");
|
||||
assert_eq!(linked(&source).unwrap_err().code, code, "{source}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeout_retains_task_ownership_while_duration_is_awaited() {
|
||||
let source = "work() (value: Int) { value = 1 }\nfallback() (value: Int) { value = 2 }\nduration() (value: Int) { dever.task.sleep(0)\n value = 1 }\nmain() (value: Int) { value = timeout(run(work()), duration(), fallback) }";
|
||||
assert!(linked(source).is_ok());
|
||||
assert_eq!(linked("work() () {}\nfallback() () { dever.io.println(\"blocked\") }\nmain() () { timeout(run(work()), 1, fallback) }").unwrap_err().code, "C013");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_does_not_discharge_input_error_obligations() {
|
||||
let prefix = "type Result { Done\n error Failed(message: Text) }\nhandle(value: Result) () recover(\"handles input\") {}\nempty() () {}\n";
|
||||
for body in [
|
||||
"timeout(run(handle(Result.Failed(\"first\"))), 1, empty)",
|
||||
"race(run(handle(Result.Failed(\"first\"))), run(handle(Result.Failed(\"second\"))))",
|
||||
"problem = Result.Failed(\"shared\")\n race(run(handle(problem)), run(empty()))",
|
||||
] {
|
||||
assert_eq!(
|
||||
linked(&format!("{prefix}main() () {{ {body} }}"))
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C012"
|
||||
);
|
||||
}
|
||||
assert!(linked(&format!("{prefix}main() () {{ problem = Result.Failed(\"shared\")\n race(run(handle(problem)), run(handle(problem))) }}")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_error_values_must_be_returned_or_handled() {
|
||||
let prefix = "type Result { Done(value: Int)\n error Failed(message: Text) }\nwork() (value: Result) { value = Result.Failed(\"reason\") }\nfallback() (value: Result) { value = Result.Done(0) }\n";
|
||||
for operation in [
|
||||
"timeout(run(work()), 1, fallback)",
|
||||
"race(run(work()), run(work()))",
|
||||
] {
|
||||
assert_eq!(
|
||||
linked(&format!("{prefix}main() () {{ lost = {operation} }}"))
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C012"
|
||||
);
|
||||
assert!(
|
||||
linked(&format!(
|
||||
"{prefix}main() (value: Result) {{ value = {operation} }}"
|
||||
))
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_ranges_include_fallback_and_each_possible_winner() {
|
||||
let prefix = "one() (value: Int) { value = 1 }\ntwo() (value: Int) { value = 2 }\n";
|
||||
for operation in [
|
||||
"timeout(run(one()), 1, two)",
|
||||
"race(run(one()), run(two()))",
|
||||
] {
|
||||
assert_eq!(
|
||||
linked(&format!(
|
||||
"{prefix}main() (value: Int <= 1) {{ value = {operation} }}"
|
||||
))
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C011"
|
||||
);
|
||||
assert!(
|
||||
linked(&format!(
|
||||
"{prefix}main() (value: Int <= 2) {{ value = {operation} }}"
|
||||
))
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
use super::{database::ERROR, package::packages};
|
||||
|
||||
#[test]
|
||||
fn public_entry_checks_actual_storage_instead_of_component_membership() {
|
||||
let files = [
|
||||
("dever.database", ERROR),
|
||||
(
|
||||
"demo.account.model",
|
||||
"global type Account { subject: Text }",
|
||||
),
|
||||
(
|
||||
"demo.account.app",
|
||||
"count() (value: Int) { value = model.count() }",
|
||||
),
|
||||
("demo.account.api", "public get count = app.count"),
|
||||
("demo.note.model", "type Note { title: Text }"),
|
||||
(
|
||||
"demo.note.app",
|
||||
"count() (value: Int) { value = model.count() }\nhealth() (value: Bool) { value = true }",
|
||||
),
|
||||
(
|
||||
"demo.note.api",
|
||||
"cmd count = app.count\npublic get health = app.health",
|
||||
),
|
||||
];
|
||||
let program = packages(&files).unwrap();
|
||||
assert!(program.check_tenant_entries().is_ok());
|
||||
for entry in &program.http {
|
||||
let access = program.tenant_access(entry.function);
|
||||
assert!(!access.required);
|
||||
assert_eq!(access.components.into_iter().collect::<Vec<_>>(), ["demo"]);
|
||||
}
|
||||
let access = program.tenant_access(program.commands[0].function);
|
||||
assert!(access.required);
|
||||
assert_eq!(access.components.into_iter().collect::<Vec<_>>(), ["demo"]);
|
||||
let mut public = files;
|
||||
public[6].1 = "public get count = app.count\npublic get health = app.health";
|
||||
let error = packages(&public)
|
||||
.unwrap()
|
||||
.check_tenant_entries()
|
||||
.unwrap_err();
|
||||
assert!(error.contains("tenant storage"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tenant_storage_follows_cross_component_calls() {
|
||||
let program = packages(&[
|
||||
("dever.database", ERROR),
|
||||
("news.item.model", "type Item { title: Text }"),
|
||||
(
|
||||
"news.item.app",
|
||||
"count() (value: Int) { value = model.count() }",
|
||||
),
|
||||
("notes.item.model", "type Item { title: Text }"),
|
||||
(
|
||||
"notes.item.app",
|
||||
"count() (value: Int) { value = model.count() }",
|
||||
),
|
||||
(
|
||||
"report.items.app",
|
||||
"count() (value: Int) { value = news.item.count() + notes.item.count() }",
|
||||
),
|
||||
("report.items.api", "public get count = app.count"),
|
||||
])
|
||||
.unwrap();
|
||||
let access = program.tenant_access(program.http[0].function);
|
||||
assert!(access.required);
|
||||
assert_eq!(
|
||||
access.components.into_iter().collect::<Vec<_>>(),
|
||||
["news", "notes"]
|
||||
);
|
||||
let error = program.check_tenant_entries().unwrap_err();
|
||||
assert!(error.contains("report/items"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_free_jobs_require_tenants_but_global_only_jobs_do_not() {
|
||||
for global in [false, true] {
|
||||
let mut files = vec![
|
||||
("dever.database", ERROR),
|
||||
(
|
||||
"demo.queue.app",
|
||||
"submit(key: Text) (id: Id) { id = dever.job.enqueue(job.execute, key) }\napply() () {}",
|
||||
),
|
||||
(
|
||||
"demo.queue.job",
|
||||
"database default\njob execute() () retry(1) timeout(100) { app.apply() }",
|
||||
),
|
||||
("demo.queue.api", "cmd submit = app.submit"),
|
||||
];
|
||||
if global {
|
||||
files.push((
|
||||
"demo.account.model",
|
||||
"global type Account { subject: Text }",
|
||||
));
|
||||
}
|
||||
let program = packages(&files).unwrap();
|
||||
let entry = program.commands[0].function;
|
||||
let access = program.tenant_access(entry);
|
||||
assert_eq!(access.required, !global);
|
||||
assert_eq!(
|
||||
access.components.into_iter().collect::<Vec<_>>(),
|
||||
if global { vec![] } else { vec!["demo"] }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
use super::package::packages;
|
||||
|
||||
const STORAGE: &str = "public type PutResult { Stored(key: Uuid)\nerror Failed(message: Text) }\npublic put(upload: Upload) (key: Uuid) { key = stored(dever.system.uploadPut(upload)) }\nstored(result: PutResult.Stored(value)) (key: Uuid) { key = value }\nstored(result: PutResult.Failed(message)) (key: Uuid) { fail(PutResult.Failed(message)) }";
|
||||
const APP: &str = "accept(file: Upload, caption: Text) (size: Int, name: Text) { size = dever.api.uploadSize(file)\nname = caption + dever.api.uploadFilename(file)\ndever.api.closeUpload(file) }";
|
||||
|
||||
#[test]
|
||||
fn upload_metadata_then_close_preserves_a_single_owned_post_input() {
|
||||
let program = packages(&[
|
||||
("demo.media.app", APP),
|
||||
("demo.media.api", "public post accept = app.accept"),
|
||||
])
|
||||
.unwrap();
|
||||
assert!(
|
||||
dever::compiler::build::runtime_features(&program, &program.entries().collect())
|
||||
.contains(&"upload")
|
||||
);
|
||||
for api in ["public get accept = app.accept", "cmd accept = app.accept"] {
|
||||
assert!(
|
||||
packages(&[("demo.media.app", APP), ("demo.media.api", api)]).is_err(),
|
||||
"{api}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upload_rejects_duplication_nesting_output_and_helpers() {
|
||||
for app in [
|
||||
"accept(file: Upload) () {}",
|
||||
"accept(file: Upload) () { dever.api.closeUpload(file)\ndever.api.closeUpload(file) }",
|
||||
"accept(file: Upload) (size: Int) { dever.api.closeUpload(file)\nsize = dever.api.uploadSize(file) }",
|
||||
"accept(file: Upload) () { copy = file\ndever.api.closeUpload(copy) }",
|
||||
"accept(file: Upload) (out: Upload) { out = file }",
|
||||
"type Container { file: Upload }",
|
||||
"type Container { Wrapped(file: Upload) }",
|
||||
"accept(files: List<Upload>) () {}",
|
||||
"accept(file: Upload?) () {}",
|
||||
"accept(file: Upload, other: Upload) () { dever.api.closeUpload(file)\ndever.api.closeUpload(other) }",
|
||||
"accept(file: Upload) () { helper(file) }\nhelper(file: Upload) () { dever.api.closeUpload(file) }",
|
||||
] {
|
||||
let error = packages(&[("demo.media.app", app)]).unwrap_err();
|
||||
assert!(
|
||||
!error.code.starts_with('P'),
|
||||
"syntax did not reach ownership check: {error:?}"
|
||||
);
|
||||
}
|
||||
assert!(packages(&[("demo.media.domain", "ignore(file: Upload) () {}")]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upload_storage_requires_a_same_domain_port_and_never_escapes_to_tasks() {
|
||||
let app = "accept(file: Upload) (key: Uuid) { key = port.store(file) }";
|
||||
let port = "store(file: Upload) (key: Uuid) fails dever.storage.PutResult";
|
||||
let adapter = "port.store(file: Upload) (key: Uuid) { key = dever.storage.put(file) }";
|
||||
let checked = |app: &str| {
|
||||
packages(&[
|
||||
("dever.storage", STORAGE),
|
||||
("demo.media.app", app),
|
||||
("demo.media.port", port),
|
||||
("demo.media.adapter.disk", adapter),
|
||||
("demo.media.api", "public post accept = app.accept"),
|
||||
])
|
||||
};
|
||||
checked(app).unwrap();
|
||||
for app in [
|
||||
"accept(file: Upload) (key: Uuid) { key = dever.storage.put(file) }",
|
||||
"accept(file: Upload) (key: Uuid) { task = run(port.store(file))\nkey = wait(task) }",
|
||||
"accept(file: Upload) (key: Uuid) { key = blocking(port.store(file)) }",
|
||||
"accept(file: Upload) (key: Uuid) { key = parallel(port.store(file)) }",
|
||||
] {
|
||||
assert!(checked(app).is_err(), "{app}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upload_storage_remains_an_external_effect_for_database_transactions() {
|
||||
let check = |prefix: &str, api: &str| {
|
||||
packages(&[
|
||||
("dever.storage", STORAGE),
|
||||
("dever.database", super::database::ERROR),
|
||||
("demo.media.model", "type Entry { name: Text }"),
|
||||
(
|
||||
"demo.media.app",
|
||||
&format!(
|
||||
"{prefix}accept(file: Upload) (key: Uuid) {{ row = model.create({{ name = \"upload\" }})\nkey = port.store(file) }}"
|
||||
),
|
||||
),
|
||||
(
|
||||
"demo.media.port",
|
||||
"store(file: Upload) (key: Uuid) fails dever.storage.PutResult",
|
||||
),
|
||||
(
|
||||
"demo.media.adapter",
|
||||
"port.store(file: Upload) (key: Uuid) { key = dever.storage.put(file) }",
|
||||
),
|
||||
("demo.media.api", api),
|
||||
])
|
||||
};
|
||||
assert_eq!(
|
||||
check("", "public post accept = app.accept")
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C014"
|
||||
);
|
||||
assert_eq!(
|
||||
check("transaction ", "public post accept = app.accept")
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C014"
|
||||
);
|
||||
assert_eq!(
|
||||
check("", "public long post accept = app.accept")
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C014"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
use super::checked;
|
||||
use dever::{
|
||||
compiler::{build, emit},
|
||||
syntax::source::Source,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn emission_keeps_only_called_ownership_versions() {
|
||||
let text = "decorate(value: Text, loud: true) (answer: Text) { answer = value + \"!\" }\ndecorate(value: Text, loud: false) (answer: Text) { answer = value + \".\" }\nmain(value: Text, loud: Bool) (answer: Text) { answer = decorate(value + \"hello\", loud) }";
|
||||
let program = checked(text).unwrap();
|
||||
let source = Source::new(0, "case.dever", text.into());
|
||||
let main = program
|
||||
.functions
|
||||
.iter()
|
||||
.position(|function| function.signature.name == "main")
|
||||
.unwrap();
|
||||
let decorate = program
|
||||
.functions
|
||||
.iter()
|
||||
.position(|function| function.signature.name == "decorate")
|
||||
.unwrap();
|
||||
let generated = emit::generate(&program, &[source], &[main].into());
|
||||
assert!(generated.contains(&format!("fn function{decorate}_own0(")));
|
||||
assert!(!generated.contains(&format!("fn function{decorate}(")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn final_record_fields_keep_ownership_across_calls() {
|
||||
let text = "type Packet { path: Text\n values: List<Int> }\nselected(path: \"/echo\", values: List<Int>) (answer: List<Int>) { answer = values }\nselected(path: other, values: List<Int>) (answer: List<Int>) { answer = [] }\nroute(packet: Packet) (answer: List<Int>) { answer = selected(packet.path, packet.values) }\nmain() (answer: List<Int>) { packet = Packet { path = \"/echo\", values = [1, 2, 3] }\n answer = route(packet) }";
|
||||
let program = checked(text).unwrap();
|
||||
let source = Source::new(0, "case.dever", text.into());
|
||||
let index = |name| {
|
||||
program
|
||||
.functions
|
||||
.iter()
|
||||
.position(|function| function.signature.name == name)
|
||||
.unwrap()
|
||||
};
|
||||
let generated = emit::generate(&program, &[source], &[index("main")].into());
|
||||
let selected = index("selected");
|
||||
assert!(generated.contains(&format!("fn function{selected}_own0_own1(")));
|
||||
assert!(!generated.contains(&format!("fn function{selected}(")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emitted_types_follow_entry_variables_errors_and_nested_fields() {
|
||||
let text = "type Leaf { value: Channel<Int>? }\ntype Box { leaves: List<Leaf> }\ntype Unused { value: AsyncStream<Int> }\nmake() (value: Box) { value = Box { leaves = [] } }\nmain() () { value = make() }\nplain() (answer: Int) { answer = 42 }";
|
||||
let program = checked(text).unwrap();
|
||||
let source = Source::new(0, "case.dever", text.into());
|
||||
for (entry, required) in [("main", true), ("plain", false)] {
|
||||
let index = program
|
||||
.functions
|
||||
.iter()
|
||||
.position(|function| function.signature.name == entry)
|
||||
.unwrap();
|
||||
let functions = program.reachable([index]);
|
||||
assert_eq!(
|
||||
build::runtime_features(&program, &functions).contains(&"task"),
|
||||
required
|
||||
);
|
||||
let generated = emit::generate(&program, std::slice::from_ref(&source), &functions);
|
||||
for (index, definition) in program.definitions.iter().enumerate() {
|
||||
let wanted = required && matches!(definition.name.as_str(), "Leaf" | "Box");
|
||||
assert_eq!(
|
||||
generated.contains(&format!("pub struct Type{index} {{")),
|
||||
wanted,
|
||||
"{entry}: {}",
|
||||
definition.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unused_types_are_still_validated_before_emission() {
|
||||
assert_eq!(
|
||||
checked("type Unused { values: Channel<Stream<Int>> }\nmain() () {}")
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C013"
|
||||
);
|
||||
assert_eq!(
|
||||
checked("type Unused { next: Unused? }\nmain() () {}")
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C007"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
use dever::{
|
||||
compiler::{check, model::Program},
|
||||
syntax::{diagnostic::Diagnostic, source::Source},
|
||||
};
|
||||
|
||||
fn linked(text: &str) -> Result<Program, Diagnostic> {
|
||||
let main = dever::syntax::parse(&Source::new(0, "main.dever", text.into()))?;
|
||||
let library = dever::syntax::parse(&Source::new(
|
||||
1,
|
||||
"uuid.dever",
|
||||
include_str!("../../../lib/uuid.dever").into(),
|
||||
))?;
|
||||
check::check(vec![main, library], &["main".into(), "uuid".into()])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uuids_are_distinct_values_with_typed_nullable_parsing() {
|
||||
assert!(linked("main(value: Uuid) (answer: Int?) pure { values = { value = 42 }\n answer = get(values, value) }").is_ok());
|
||||
for (source, code) in [
|
||||
(
|
||||
"main() (value: Uuid) { value = \"550e8400-e29b-41d4-a716-446655440000\" }",
|
||||
"C005",
|
||||
),
|
||||
(
|
||||
"main(value: Uuid, other: Id) (answer: Bool) { answer = value == other }",
|
||||
"C005",
|
||||
),
|
||||
(
|
||||
"main(value: Uuid) (answer: Bool) { answer = value < value }",
|
||||
"C005",
|
||||
),
|
||||
("main() () { value = dever.system.uuidParse(\"\") }", "C006"),
|
||||
("main() (value: Uuid) { value = uuid.parse(\"\") }", "C005"),
|
||||
] {
|
||||
assert_eq!(linked(source).unwrap_err().code, code, "{source}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user