feat(source): add bounded read-only mixed filesystem discovery
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
//! Actual filesystem discovery followed by the public borrowed complete checker.
|
||||
use oa_core::{analysis as a, language, source_files::*, source_project::*, *};
|
||||
use std::{collections::BTreeMap, fs};
|
||||
|
||||
fn id(n: u128) -> Id {
|
||||
Id::from_u128(n)
|
||||
}
|
||||
fn document(body: &str) -> String {
|
||||
format!(
|
||||
"arcadia 8;\nproject p \"Project\" id \"{}\" {{\n{body}\n}}\n",
|
||||
id(1)
|
||||
)
|
||||
}
|
||||
fn write(files: &[(&str, String)]) -> tempfile::TempDir {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
for (path, text) in files {
|
||||
fs::write(dir.path().join(path), text).unwrap();
|
||||
}
|
||||
dir
|
||||
}
|
||||
fn unchanged(dir: &tempfile::TempDir, files: &[(&str, String)]) {
|
||||
assert_eq!(fs::read_dir(dir.path()).unwrap().count(), files.len());
|
||||
for (path, text) in files {
|
||||
assert_eq!(fs::read(dir.path().join(path)).unwrap(), text.as_bytes());
|
||||
}
|
||||
}
|
||||
fn calculation(alias: &str, n: u128, body: a::Expr) -> a::Definition {
|
||||
a::Definition {
|
||||
id: id(n),
|
||||
alias: alias.into(),
|
||||
name: "Calculation".into(),
|
||||
description: String::new(),
|
||||
body: a::DefinitionBody::Calculation(a::CalculationDefinition {
|
||||
parameters: vec![a::Parameter {
|
||||
id: a::ParameterId(id(1)),
|
||||
name: "x".into(),
|
||||
value_type: a::AnalysisType::Scalar(a::ScalarType::Number),
|
||||
}],
|
||||
result: a::AnalysisType::Scalar(a::ScalarType::Number),
|
||||
body: a::Binding::Expression(body),
|
||||
}),
|
||||
}
|
||||
}
|
||||
fn calc(alias: &str, n: u128, body: &str) -> String {
|
||||
format!(
|
||||
"calculation {alias} \"Calculation\" id \"{}\" {{ input x id \"{}\" : Number; result Number; body {body}; }}",
|
||||
id(n),
|
||||
id(1)
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_files_complete_model_nested_ordinary_scoped_cross_file_and_original_bytes() {
|
||||
let files = [
|
||||
(
|
||||
"main.arcadia",
|
||||
document(
|
||||
"// include \"ignored.arcadia\"; 海\ninclude \"f.arcadia\"; include \"g.arcadia\";",
|
||||
),
|
||||
),
|
||||
(
|
||||
"f.arcadia",
|
||||
format!(
|
||||
"{}\noa_entity team \"Team\" id \"{}\" {{ oa_actor crew \"Crew\" id \"{}\" {{}} }}\r\n",
|
||||
calc("f", 2, "@g(x: $x)"),
|
||||
id(4),
|
||||
id(5)
|
||||
),
|
||||
),
|
||||
("g.arcadia", calc("g", 3, "$x")),
|
||||
];
|
||||
let dir = write(&files);
|
||||
let snapshot =
|
||||
load_mixed_source(&dir.path().join("main.arcadia"), ProjectLimits::default()).unwrap();
|
||||
assert!(
|
||||
snapshot.diagnostics().is_empty(),
|
||||
"{:?}",
|
||||
snapshot.diagnostics()
|
||||
);
|
||||
assert_eq!(snapshot.bundle().entry, "main.arcadia");
|
||||
assert_eq!(
|
||||
snapshot.bundle().files,
|
||||
files
|
||||
.iter()
|
||||
.map(|(p, t)| (p.to_string(), t.clone()))
|
||||
.collect()
|
||||
);
|
||||
let mut expected = Project::empty();
|
||||
expected.id = id(1);
|
||||
expected.alias = "p".into();
|
||||
expected.name = "Project".into();
|
||||
expected.definitions.insert(
|
||||
id(2),
|
||||
calculation(
|
||||
"f",
|
||||
2,
|
||||
a::Expr::Call {
|
||||
definition: a::DefinitionRef::Local(id(3)),
|
||||
arguments: BTreeMap::from([(
|
||||
a::ParameterId(id(1)),
|
||||
a::Expr::Parameter(a::ParameterId(id(1))),
|
||||
)]),
|
||||
},
|
||||
),
|
||||
);
|
||||
expected.definitions.insert(
|
||||
id(3),
|
||||
calculation("g", 3, a::Expr::Parameter(a::ParameterId(id(1)))),
|
||||
);
|
||||
for (n, alias, name, kind, parent) in [
|
||||
(4, "team", "Team", Kind::Entity, None),
|
||||
(5, "crew", "Crew", Kind::Actor, Some(id(4))),
|
||||
] {
|
||||
expected.elements.insert(
|
||||
id(n),
|
||||
Element {
|
||||
id: id(n),
|
||||
alias: alias.into(),
|
||||
name: name.into(),
|
||||
description: String::new(),
|
||||
parent,
|
||||
body: ElementBody::new(kind, Layer::Oa).unwrap(),
|
||||
},
|
||||
);
|
||||
}
|
||||
let checked =
|
||||
language::check_mixed_bundle(snapshot.bundle(), ProjectLimits::default()).unwrap();
|
||||
assert_eq!(checked.project(), &expected);
|
||||
assert!(checked.warnings().is_empty());
|
||||
drop(checked);
|
||||
drop(snapshot);
|
||||
unchanged(&dir, &files);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_files_terminal_syntax_preserves_first_path_and_still_reports_journal() {
|
||||
let files = [
|
||||
(
|
||||
"main.arcadia",
|
||||
document("include \"bad.arcadia\"; include \"later.arcadia\";"),
|
||||
),
|
||||
("bad.arcadia", "oa_entity missing \"Missing\" {}".into()),
|
||||
("later.arcadia", "must not parse".into()),
|
||||
];
|
||||
let dir = write(&files);
|
||||
fs::write(
|
||||
dir.path().join(".arcadia-save-main.arcadia.journal"),
|
||||
b"unrecovered",
|
||||
)
|
||||
.unwrap();
|
||||
let snapshot =
|
||||
load_mixed_source(&dir.path().join("main.arcadia"), ProjectLimits::default()).unwrap();
|
||||
assert_eq!(snapshot.diagnostics().len(), 2);
|
||||
assert_eq!(snapshot.diagnostics()[0].path, "bad.arcadia");
|
||||
assert_eq!(
|
||||
snapshot.diagnostics()[0].diagnostic.message,
|
||||
"Mixed source8 requires explicit UUIDs."
|
||||
);
|
||||
assert_eq!(snapshot.diagnostics()[1].diagnostic.code, "pending_save");
|
||||
assert!(!snapshot.bundle().files.contains_key("later.arcadia"));
|
||||
assert_eq!(
|
||||
fs::read(dir.path().join("later.arcadia")).unwrap(),
|
||||
b"must not parse"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(dir.path().join(".arcadia-save-main.arcadia.journal")).unwrap(),
|
||||
b"unrecovered"
|
||||
);
|
||||
for (path, text) in &files {
|
||||
assert_eq!(fs::read(dir.path().join(path)).unwrap(), text.as_bytes());
|
||||
}
|
||||
assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_files_identity_classes_and_malformed_units_are_discovery_errors() {
|
||||
let valid = calc("f", 2, "$x");
|
||||
let cases = [
|
||||
document("").replace(&format!(" id \"{}\"", id(1)), ""),
|
||||
document("oa_entity e \"E\" {}"),
|
||||
document(&valid.replace(&format!("id \"{}\"", id(2)), "")),
|
||||
document(&valid.replace(&format!("input x id \"{}\"", id(1)), "input x")),
|
||||
document("calculation malformed"),
|
||||
];
|
||||
for (i, text) in cases.into_iter().enumerate() {
|
||||
let files = [("main.arcadia", text)];
|
||||
let dir = write(&files);
|
||||
let snapshot =
|
||||
load_mixed_source(&dir.path().join("main.arcadia"), ProjectLimits::default()).unwrap();
|
||||
assert_eq!(snapshot.diagnostics().len(), 1);
|
||||
assert_eq!(snapshot.diagnostics()[0].path, "main.arcadia");
|
||||
if i < 4 {
|
||||
assert_eq!(
|
||||
snapshot.diagnostics()[0].diagnostic.message,
|
||||
"Mixed source8 requires explicit UUIDs."
|
||||
);
|
||||
} else {
|
||||
assert_eq!(snapshot.diagnostics()[0].diagnostic.code, "invalid_source");
|
||||
}
|
||||
unchanged(&dir, &files);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_files_include_failures_and_cycles_do_not_become_partial_admission() {
|
||||
for (include, code) in [
|
||||
("missing.arcadia", "missing_include"),
|
||||
("../escape.arcadia", "invalid_path"),
|
||||
] {
|
||||
let files = [("main.arcadia", document(&format!("include \"{include}\";")))];
|
||||
let dir = write(&files);
|
||||
let snapshot =
|
||||
load_mixed_source(&dir.path().join("main.arcadia"), ProjectLimits::default()).unwrap();
|
||||
assert!(
|
||||
snapshot
|
||||
.diagnostics()
|
||||
.iter()
|
||||
.any(|d| d.diagnostic.code == code)
|
||||
);
|
||||
unchanged(&dir, &files);
|
||||
}
|
||||
let files = [
|
||||
("main.arcadia", document("include \"part.arcadia\";")),
|
||||
("part.arcadia", "include \"part.arcadia\";".into()),
|
||||
];
|
||||
let dir = write(&files);
|
||||
let snapshot =
|
||||
load_mixed_source(&dir.path().join("main.arcadia"), ProjectLimits::default()).unwrap();
|
||||
assert!(snapshot.diagnostics().is_empty());
|
||||
let errors = match language::check_mixed_bundle(snapshot.bundle(), ProjectLimits::default()) {
|
||||
Ok(_) => panic!("cycle admitted"),
|
||||
Err(errors) => errors,
|
||||
};
|
||||
assert!(errors.iter().any(|d| d.diagnostic.code == "include_cycle"));
|
||||
unchanged(&dir, &files);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_files_limits_encoding_nonregular_and_physical_aliases() {
|
||||
let files = [
|
||||
("main.arcadia", document("include \"part.arcadia\";")),
|
||||
("part.arcadia", "include \"last.arcadia\";\r\n".into()),
|
||||
("last.arcadia", "//é\r\n".into()),
|
||||
];
|
||||
let dir = write(&files);
|
||||
let entry = dir.path().join("main.arcadia");
|
||||
for (kind, cap) in [("files", 3), ("depth", 2), ("edges", 2), ("bytes", 132)] {
|
||||
for allowed in [cap, cap - 1] {
|
||||
let mut limits = ProjectLimits::default();
|
||||
match kind {
|
||||
"files" => limits.max_files = allowed,
|
||||
"depth" => limits.max_include_depth = allowed,
|
||||
"edges" => limits.max_include_edges = allowed,
|
||||
_ => limits.model.max_source_bytes = allowed,
|
||||
};
|
||||
let result = load_mixed_source(&entry, limits);
|
||||
if allowed == cap {
|
||||
assert!(result.unwrap().diagnostics().is_empty(), "{kind}");
|
||||
} else {
|
||||
match result {
|
||||
Err(_) => {}
|
||||
Ok(snapshot) => assert!(!snapshot.diagnostics().is_empty(), "{kind}"),
|
||||
}
|
||||
}
|
||||
unchanged(&dir, &files);
|
||||
}
|
||||
}
|
||||
fs::write(dir.path().join("last.arcadia"), [0xff]).unwrap();
|
||||
let snapshot = load_mixed_source(&entry, ProjectLimits::default()).unwrap();
|
||||
assert!(
|
||||
snapshot
|
||||
.diagnostics()
|
||||
.iter()
|
||||
.any(|d| d.diagnostic.code == "source_encoding")
|
||||
);
|
||||
fs::remove_file(dir.path().join("last.arcadia")).unwrap();
|
||||
fs::create_dir(dir.path().join("last.arcadia")).unwrap();
|
||||
let snapshot = load_mixed_source(&entry, ProjectLimits::default()).unwrap();
|
||||
assert!(
|
||||
snapshot
|
||||
.diagnostics()
|
||||
.iter()
|
||||
.any(|d| d.diagnostic.code == "invalid_path")
|
||||
);
|
||||
let collision = write(&[
|
||||
(
|
||||
"main.arcadia",
|
||||
document("include \"part.arcadia\"; include \"PART.arcadia\";"),
|
||||
),
|
||||
("part.arcadia", "// case alias\n".into()),
|
||||
]);
|
||||
assert!(matches!(
|
||||
load_mixed_source(
|
||||
&collision.path().join("main.arcadia"),
|
||||
ProjectLimits::default()
|
||||
),
|
||||
Err(SourceFileError::Path(_))
|
||||
));
|
||||
let aliases = write(&[
|
||||
(
|
||||
"main.arcadia",
|
||||
document("include \"a.arcadia\"; include \"b.arcadia\";"),
|
||||
),
|
||||
("a.arcadia", "// same physical file\n".into()),
|
||||
]);
|
||||
fs::hard_link(
|
||||
aliases.path().join("a.arcadia"),
|
||||
aliases.path().join("b.arcadia"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
load_mixed_source(
|
||||
&aliases.path().join("main.arcadia"),
|
||||
ProjectLimits::default()
|
||||
),
|
||||
Err(SourceFileError::Path(_))
|
||||
));
|
||||
}
|
||||
Reference in New Issue
Block a user