487 lines
14 KiB
Rust
487 lines
14 KiB
Rust
use std::{
|
|
ffi::OsStr,
|
|
fs,
|
|
path::{Path, PathBuf},
|
|
process::{Command, Output},
|
|
sync::atomic::{AtomicUsize, Ordering},
|
|
};
|
|
|
|
static NEXT_FIXTURE_ID: AtomicUsize = AtomicUsize::new(0);
|
|
|
|
#[test]
|
|
fn sexpr_diagnostics_keep_v1_schema_across_source_pipelines() {
|
|
let cases = [
|
|
SourceDiagnosticCase {
|
|
name: "parse",
|
|
args: &["check"],
|
|
source: "(module main",
|
|
code: "UnclosedList",
|
|
},
|
|
SourceDiagnosticCase {
|
|
name: "check",
|
|
args: &["check"],
|
|
source: r#"
|
|
(module main)
|
|
|
|
(fn id ((value i32)) -> i32
|
|
value)
|
|
|
|
(fn main () -> i32
|
|
(id true))
|
|
"#,
|
|
code: "TypeMismatch",
|
|
},
|
|
SourceDiagnosticCase {
|
|
name: "fmt",
|
|
args: &["fmt"],
|
|
source: r#"
|
|
(module main) ; comments stay outside formatter input
|
|
|
|
(fn main () -> i32
|
|
0)
|
|
"#,
|
|
code: "UnsupportedFormatterComment",
|
|
},
|
|
SourceDiagnosticCase {
|
|
name: "test",
|
|
args: &["test"],
|
|
source: r#"
|
|
(module main)
|
|
|
|
(test "false case"
|
|
false)
|
|
"#,
|
|
code: "TestFailed",
|
|
},
|
|
];
|
|
|
|
for case in cases {
|
|
let fixture = write_fixture(case.name, case.source);
|
|
let mut args = case.args.iter().map(OsStr::new).collect::<Vec<_>>();
|
|
args.push(fixture.as_os_str());
|
|
|
|
let output = run_glagol(args);
|
|
|
|
assert_exit_code(case.name, &output, 1);
|
|
assert_stdout_empty(case.name, &output);
|
|
assert_sexpr_diagnostic_schema(case.name, &output, case.code, 1);
|
|
}
|
|
|
|
let project = write_project(
|
|
"sexpr-project",
|
|
"(module main)\n\n(import missing (value))\n",
|
|
);
|
|
let output = run_glagol(["check".as_ref(), project.as_os_str()]);
|
|
|
|
assert_exit_code("project", &output, 1);
|
|
assert_stdout_empty("project", &output);
|
|
assert_sexpr_diagnostic_schema("project", &output, "MissingImport", 1);
|
|
}
|
|
|
|
#[test]
|
|
fn json_diagnostics_keep_v1_schema_across_policy_boundaries() {
|
|
let cases = [
|
|
SourceDiagnosticCase {
|
|
name: "json-parse",
|
|
args: &["--json-diagnostics", "check"],
|
|
source: "(module main",
|
|
code: "UnclosedList",
|
|
},
|
|
SourceDiagnosticCase {
|
|
name: "json-check",
|
|
args: &["--json-diagnostics", "check"],
|
|
source: r#"
|
|
(module main)
|
|
|
|
(fn id ((value i32)) -> i32
|
|
value)
|
|
|
|
(fn main () -> i32
|
|
(id true))
|
|
"#,
|
|
code: "TypeMismatch",
|
|
},
|
|
SourceDiagnosticCase {
|
|
name: "json-fmt",
|
|
args: &["--json-diagnostics", "fmt"],
|
|
source: r#"
|
|
(module main) ; comments stay outside formatter input
|
|
|
|
(fn main () -> i32
|
|
0)
|
|
"#,
|
|
code: "UnsupportedFormatterComment",
|
|
},
|
|
SourceDiagnosticCase {
|
|
name: "json-test",
|
|
args: &["--json-diagnostics", "test"],
|
|
source: r#"
|
|
(module main)
|
|
|
|
(test "false case"
|
|
false)
|
|
"#,
|
|
code: "TestFailed",
|
|
},
|
|
];
|
|
|
|
for case in cases {
|
|
let fixture = write_fixture(case.name, case.source);
|
|
let mut args = case.args.iter().map(OsStr::new).collect::<Vec<_>>();
|
|
args.push(fixture.as_os_str());
|
|
|
|
let output = run_glagol(args);
|
|
|
|
assert_exit_code(case.name, &output, 1);
|
|
assert_stdout_empty(case.name, &output);
|
|
assert_json_diagnostic_schema(case.name, &output, case.code, JsonSource::Source);
|
|
}
|
|
|
|
let project = write_project(
|
|
"json-project",
|
|
"(module main)\n\n(import missing (value))\n",
|
|
);
|
|
let output = run_glagol([
|
|
"--json-diagnostics".as_ref(),
|
|
"check".as_ref(),
|
|
project.as_os_str(),
|
|
]);
|
|
|
|
assert_exit_code("json project", &output, 1);
|
|
assert_stdout_empty("json project", &output);
|
|
assert_json_diagnostic_schema("json project", &output, "MissingImport", JsonSource::Source);
|
|
|
|
let usage_manifest = temp_path("json-usage", "manifest.slo");
|
|
let usage = run_glagol([
|
|
"--json-diagnostics".as_ref(),
|
|
"--manifest".as_ref(),
|
|
usage_manifest.as_os_str(),
|
|
]);
|
|
|
|
assert_exit_code("json usage", &usage, 2);
|
|
assert_stdout_empty("json usage", &usage);
|
|
assert_json_diagnostic_schema("json usage", &usage, "UsageError", JsonSource::SourceLess);
|
|
assert_manifest_schema_fields(&read_manifest(&usage_manifest), "json");
|
|
|
|
let toolchain_fixture = write_fixture(
|
|
"json-toolchain",
|
|
"(module main)\n\n(fn main () -> i32\n 0)\n",
|
|
);
|
|
let toolchain_manifest = temp_path("json-toolchain", "manifest.slo");
|
|
let output_path = temp_path("json-toolchain", "bin");
|
|
let missing_clang = temp_path("json-toolchain", "not-a-clang");
|
|
let toolchain = Command::new(compiler_path())
|
|
.arg("--json-diagnostics")
|
|
.arg("build")
|
|
.arg(&toolchain_fixture)
|
|
.arg("-o")
|
|
.arg(&output_path)
|
|
.arg("--manifest")
|
|
.arg(&toolchain_manifest)
|
|
.env("GLAGOL_CLANG", &missing_clang)
|
|
.env_remove("GLAGOL_RUNTIME_C")
|
|
.env_remove("SLOVO_RUNTIME_C")
|
|
.output()
|
|
.unwrap_or_else(|err| panic!("run glagol build: {}", err));
|
|
|
|
assert_exit_code("json toolchain", &toolchain, 3);
|
|
assert_stdout_empty("json toolchain", &toolchain);
|
|
assert_json_diagnostic_schema(
|
|
"json toolchain",
|
|
&toolchain,
|
|
"ToolchainUnavailable",
|
|
JsonSource::SourceLess,
|
|
);
|
|
assert_manifest_schema_fields(&read_manifest(&toolchain_manifest), "json");
|
|
}
|
|
|
|
#[test]
|
|
fn project_failure_manifests_record_schema_encoding_and_count_deterministically() {
|
|
let project = write_project(
|
|
"manifest-project",
|
|
"(module main)\n\n(import missing (value))\n",
|
|
);
|
|
|
|
let first_sexpr = run_project_failure_manifest(&project, "sexpr", false);
|
|
let second_sexpr = run_project_failure_manifest(&project, "sexpr-repeat", false);
|
|
assert_manifest_schema_fields(&first_sexpr, "sexpr");
|
|
assert_project_diagnostics_count(&first_sexpr, 1);
|
|
assert_eq!(
|
|
project_block(&first_sexpr),
|
|
project_block(&second_sexpr),
|
|
"S-expression failure manifest project block drifted"
|
|
);
|
|
|
|
let first_json = run_project_failure_manifest(&project, "json", true);
|
|
let second_json = run_project_failure_manifest(&project, "json-repeat", true);
|
|
assert_manifest_schema_fields(&first_json, "json");
|
|
assert_project_diagnostics_count(&first_json, 1);
|
|
assert_eq!(
|
|
project_block(&first_json),
|
|
project_block(&second_json),
|
|
"JSON failure manifest project block drifted"
|
|
);
|
|
}
|
|
|
|
struct SourceDiagnosticCase {
|
|
name: &'static str,
|
|
args: &'static [&'static str],
|
|
source: &'static str,
|
|
code: &'static str,
|
|
}
|
|
|
|
#[derive(Copy, Clone)]
|
|
enum JsonSource {
|
|
Source,
|
|
SourceLess,
|
|
}
|
|
|
|
fn run_project_failure_manifest(project: &Path, name: &str, json: bool) -> String {
|
|
let manifest = temp_path(name, "manifest.slo");
|
|
let output = if json {
|
|
run_glagol([
|
|
"--json-diagnostics".as_ref(),
|
|
"check".as_ref(),
|
|
"--manifest".as_ref(),
|
|
manifest.as_os_str(),
|
|
project.as_os_str(),
|
|
])
|
|
} else {
|
|
run_glagol([
|
|
"check".as_ref(),
|
|
"--manifest".as_ref(),
|
|
manifest.as_os_str(),
|
|
project.as_os_str(),
|
|
])
|
|
};
|
|
|
|
assert_exit_code(name, &output, 1);
|
|
assert_stdout_empty(name, &output);
|
|
read_manifest(&manifest)
|
|
}
|
|
|
|
fn assert_sexpr_diagnostic_schema(
|
|
context: &str,
|
|
output: &Output,
|
|
expected_code: &str,
|
|
expected_count: usize,
|
|
) {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(
|
|
!stderr.trim().is_empty(),
|
|
"{} did not emit diagnostics",
|
|
context
|
|
);
|
|
assert!(
|
|
!stderr
|
|
.lines()
|
|
.all(|line| line.starts_with('{') && line.ends_with('}')),
|
|
"{} unexpectedly emitted JSON diagnostics:\n{}",
|
|
context,
|
|
stderr
|
|
);
|
|
assert_eq!(
|
|
stderr.matches("(diagnostic\n").count(),
|
|
expected_count,
|
|
"{} diagnostic block count drifted:\n{}",
|
|
context,
|
|
stderr
|
|
);
|
|
assert_eq!(
|
|
stderr.matches(" (schema slovo.diagnostic)\n").count(),
|
|
expected_count,
|
|
"{} diagnostic schema name drifted:\n{}",
|
|
context,
|
|
stderr
|
|
);
|
|
assert_eq!(
|
|
stderr.matches(" (version 1)\n").count(),
|
|
expected_count,
|
|
"{} diagnostic schema version drifted:\n{}",
|
|
context,
|
|
stderr
|
|
);
|
|
assert!(
|
|
stderr.contains(" (severity error)\n")
|
|
&& stderr.contains(&format!(" (code {})\n", expected_code))
|
|
&& stderr.contains(" (file ")
|
|
&& stderr.contains(" (span\n"),
|
|
"{} S-expression diagnostic missed required structural fields:\n{}",
|
|
context,
|
|
stderr
|
|
);
|
|
}
|
|
|
|
fn assert_json_diagnostic_schema(
|
|
context: &str,
|
|
output: &Output,
|
|
expected_code: &str,
|
|
source: JsonSource,
|
|
) {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
let lines = stderr
|
|
.lines()
|
|
.filter(|line| !line.trim().is_empty())
|
|
.collect::<Vec<_>>();
|
|
assert!(!lines.is_empty(), "{} did not emit diagnostics", context);
|
|
|
|
for line in &lines {
|
|
assert!(
|
|
line.starts_with('{') && line.ends_with('}'),
|
|
"{} emitted non-JSON diagnostic text:\n{}",
|
|
context,
|
|
stderr
|
|
);
|
|
assert!(
|
|
line.contains(r#""schema":"slovo.diagnostic""#)
|
|
&& line.contains(r#""version":1"#)
|
|
&& line.contains(r#""severity":"error""#)
|
|
&& line.contains(r#""message":"#)
|
|
&& line.contains(r#""file":"#)
|
|
&& line.contains(r#""span":"#),
|
|
"{} JSON diagnostic missed required schema fields:\n{}",
|
|
context,
|
|
line
|
|
);
|
|
}
|
|
|
|
assert!(
|
|
lines
|
|
.iter()
|
|
.any(|line| line.contains(&format!(r#""code":"{}""#, expected_code))),
|
|
"{} JSON diagnostics did not include code `{}`:\n{}",
|
|
context,
|
|
expected_code,
|
|
stderr
|
|
);
|
|
|
|
match source {
|
|
JsonSource::Source => assert!(
|
|
lines
|
|
.iter()
|
|
.any(|line| line.contains(r#""span":{"byte_start":"#)),
|
|
"{} JSON diagnostics should include a concrete source span:\n{}",
|
|
context,
|
|
stderr
|
|
),
|
|
JsonSource::SourceLess => assert!(
|
|
lines
|
|
.iter()
|
|
.any(|line| line.contains(r#""file":null"#) && line.contains(r#""span":null"#)),
|
|
"{} JSON diagnostics should be source-less:\n{}",
|
|
context,
|
|
stderr
|
|
),
|
|
}
|
|
}
|
|
|
|
fn assert_manifest_schema_fields(manifest: &str, encoding: &str) {
|
|
assert!(
|
|
manifest.contains(" (schema slovo.artifact-manifest)\n")
|
|
&& manifest.contains(" (version 1)\n")
|
|
&& manifest.contains(" (diagnostics-schema-version 1)\n")
|
|
&& manifest.contains(&format!(" (diagnostics-encoding {})\n", encoding))
|
|
&& manifest.contains("slovo.diagnostic"),
|
|
"manifest diagnostic schema fields drifted:\n{}",
|
|
manifest
|
|
);
|
|
}
|
|
|
|
fn assert_project_diagnostics_count(manifest: &str, expected: usize) {
|
|
assert!(
|
|
manifest.contains(&format!(" (diagnostics_count {})\n", expected)),
|
|
"manifest diagnostics count drifted:\n{}",
|
|
manifest
|
|
);
|
|
}
|
|
|
|
fn project_block(manifest: &str) -> &str {
|
|
let start = manifest
|
|
.find(" (project\n")
|
|
.expect("manifest did not contain project block");
|
|
manifest[start..]
|
|
.strip_suffix("\n)\n")
|
|
.expect("manifest did not end with artifact-manifest close")
|
|
}
|
|
|
|
fn run_glagol<I, S>(args: I) -> Output
|
|
where
|
|
I: IntoIterator<Item = S>,
|
|
S: AsRef<OsStr>,
|
|
{
|
|
Command::new(compiler_path())
|
|
.args(args)
|
|
.output()
|
|
.unwrap_or_else(|err| panic!("run glagol: {}", err))
|
|
}
|
|
|
|
fn compiler_path() -> &'static str {
|
|
env!("CARGO_BIN_EXE_glagol")
|
|
}
|
|
|
|
fn write_fixture(name: &str, source: &str) -> PathBuf {
|
|
let path = temp_path(name, "slo");
|
|
fs::write(&path, source).unwrap_or_else(|err| panic!("write `{}`: {}", path.display(), err));
|
|
path
|
|
}
|
|
|
|
fn write_project(name: &str, main_source: &str) -> PathBuf {
|
|
let root = temp_dir(name);
|
|
let source_root = root.join("src");
|
|
fs::create_dir_all(&source_root)
|
|
.unwrap_or_else(|err| panic!("create `{}`: {}", source_root.display(), err));
|
|
fs::write(
|
|
root.join("slovo.toml"),
|
|
"[project]\nname = \"beta13\"\nsource_root = \"src\"\nentry = \"main\"\n",
|
|
)
|
|
.unwrap_or_else(|err| panic!("write project manifest: {}", err));
|
|
fs::write(source_root.join("main.slo"), main_source)
|
|
.unwrap_or_else(|err| panic!("write main source: {}", err));
|
|
root
|
|
}
|
|
|
|
fn read_manifest(path: &Path) -> String {
|
|
fs::read_to_string(path).unwrap_or_else(|err| panic!("read `{}`: {}", path.display(), err))
|
|
}
|
|
|
|
fn temp_path(name: &str, extension: &str) -> PathBuf {
|
|
let mut path = std::env::temp_dir();
|
|
let id = NEXT_FIXTURE_ID.fetch_add(1, Ordering::Relaxed);
|
|
path.push(format!(
|
|
"glagol-beta13-{}-{}-{}.{}",
|
|
std::process::id(),
|
|
id,
|
|
name,
|
|
extension,
|
|
));
|
|
path
|
|
}
|
|
|
|
fn temp_dir(name: &str) -> PathBuf {
|
|
let mut path = std::env::temp_dir();
|
|
let id = NEXT_FIXTURE_ID.fetch_add(1, Ordering::Relaxed);
|
|
path.push(format!(
|
|
"glagol-beta13-{}-{}-{}",
|
|
std::process::id(),
|
|
id,
|
|
name,
|
|
));
|
|
path
|
|
}
|
|
|
|
fn assert_exit_code(context: &str, output: &Output, expected: i32) {
|
|
assert_eq!(
|
|
output.status.code(),
|
|
Some(expected),
|
|
"{} exit code mismatch\nstdout:\n{}\nstderr:\n{}",
|
|
context,
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
}
|
|
|
|
fn assert_stdout_empty(context: &str, output: &Output) {
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
assert!(stdout.is_empty(), "{} wrote stdout:\n{}", context, stdout);
|
|
}
|