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::>(); 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::>(); 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::>(); 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(args: I) -> Output where I: IntoIterator, S: AsRef, { 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); }