slovo/compiler/tests/diagnostics_schema_beta13.rs

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);
}