use std::process::Command;
pub fn validate_structural(code: &str, backend_name: &str) -> Vec<String> {
match backend_name {
"python-psycopg3" | "python-asyncpg" | "python-aiomysql" | "python-aiosqlite" | "python-duckdb"
| "python-pyodbc" | "python-oracledb" | "python-snowflake" => validate_python(code),
"typescript-postgres"
| "typescript-pg"
| "typescript-mysql2"
| "typescript-better-sqlite3"
| "typescript-node-sqlite"
| "typescript-wasm-sqlite"
| "typescript-duckdb"
| "typescript-kysely"
| "typescript-mssql"
| "typescript-oracledb"
| "typescript-snowflake" => validate_typescript(code),
"javascript-pg"
| "javascript-postgres"
| "javascript-mysql2"
| "javascript-better-sqlite3"
| "javascript-node-sqlite"
| "javascript-wasm-sqlite"
| "javascript-snowflake"
| "javascript-duckdb"
| "javascript-oracledb"
| "javascript-mssql" => validate_javascript(code),
"go-pgx" | "go-database-sql" | "go-godror" | "go-gosnowflake" => validate_go(code),
"java-jdbc" => validate_java(code),
"java-r2dbc" => validate_java_r2dbc(code),
"kotlin-exposed" => validate_kotlin_exposed(code),
"kotlin-jdbc" => validate_kotlin(code),
"kotlin-r2dbc" => validate_kotlin_r2dbc(code),
"csharp-npgsql"
| "csharp-mysqlconnector"
| "csharp-microsoft-sqlite"
| "csharp-sqlclient"
| "csharp-oracle"
| "csharp-snowflake" => validate_csharp(code),
"elixir-postgrex" | "elixir-ecto" | "elixir-myxql" | "elixir-exqlite" | "elixir-tds" | "elixir-jamdb" => {
validate_elixir(code)
}
"ruby-pg" | "ruby-mysql2" | "ruby-sqlite3" | "ruby-trilogy" | "ruby-oci8" | "ruby-tiny-tds" => {
validate_ruby(code)
}
"php-pdo" | "php-amphp" => validate_php(code),
"rust-sqlx" | "rust-tokio-postgres" | "rust-tiberius" | "rust-sibyl" => vec![],
_ => vec![format!("unknown backend: {}", backend_name)],
}
}
fn validate_python(code: &str) -> Vec<String> {
let mut errors = Vec::new();
if code.contains("from __future__ import annotations") {
errors.push("unnecessary `from __future__ import annotations` — target is Python 3.10+".into());
}
let has_struct = code.contains("@dataclass")
|| code.contains("(BaseModel)")
|| code.contains("(msgspec.Struct)")
|| code.contains("class ");
if !has_struct && !code.contains("async def ") && !code.contains("def ") {
errors.push("missing `@dataclass`/`class` and `def ` -- no meaningful output".into());
}
if !code.contains("async def ") && !code.contains("def ") {
errors.push("missing `async def ` or `def ` (for query functions)".into());
}
if code.contains("from typing import Union") {
errors.push("contains `from typing import Union` (pre-3.10 style)".into());
}
if code.contains("from typing import Optional") {
errors.push("contains `from typing import Optional` (pre-3.10 style)".into());
}
if code.contains("List[") {
errors.push("contains `List[` (use lowercase `list[`)".into());
}
if code.contains("Dict[") {
errors.push("contains `Dict[` (use lowercase `dict[`)".into());
}
for (i, line) in code.lines().enumerate() {
if line.starts_with('\t') {
errors.push(format!("line {} uses tab indentation (should use 4 spaces)", i + 1));
break;
}
}
errors
}
fn validate_javascript(code: &str) -> Vec<String> {
let mut errors = Vec::new();
let has_function = code.contains("export async function") || code.contains("export function");
let has_typedef = code.contains("@typedef");
if !has_typedef && !has_function {
errors.push("missing `@typedef` (for DTOs) and `export function`/`export async function`".into());
}
if !has_function {
errors.push("missing `export async function` or `export function`".into());
}
if code.contains("export interface") {
errors.push("contains `export interface` -- TypeScript-only syntax, invalid in plain .js".into());
}
if code.contains("export enum") {
errors.push("contains `export enum` -- TypeScript-only syntax, invalid in plain .js".into());
}
for line in code.lines() {
let trimmed = line.trim();
if find_disallowed_any_usage(trimmed).is_some() {
errors.push(format!(
"contains `any` type (should use `unknown` or specific): {}",
trimmed
));
break;
}
}
errors
}
fn validate_typescript(code: &str) -> Vec<String> {
let mut errors = Vec::new();
let has_function = code.contains("export async function") || code.contains("export function");
let has_zod = code.contains("z.object(") || code.contains("z.infer<");
if !code.contains("export interface") && !code.contains("export type") && !has_zod && !has_function {
errors.push("missing `export interface` or `export type` (for DTOs)".into());
}
if !has_function {
errors.push("missing `export async function` or `export function`".into());
}
for line in code.lines() {
let trimmed = line.trim();
if find_disallowed_any_usage(trimmed).is_some() {
errors.push(format!(
"contains `any` type (should use `unknown` or specific): {}",
trimmed
));
break;
}
}
errors
}
fn find_disallowed_any_usage(trimmed: &str) -> Option<&str> {
let mut offset = 0;
while let Some(rel) = trimmed[offset..].find("any") {
let start = offset + rel;
let end = start + "any".len();
let before_is_ident = start > 0 && is_ident_byte(trimmed.as_bytes()[start - 1]);
let after_is_ident = end < trimmed.len() && is_ident_byte(trimmed.as_bytes()[end]);
if !before_is_ident && !after_is_ident && !is_kysely_db_generic_default(trimmed, start, end) {
return Some(trimmed);
}
offset = end;
}
None
}
fn is_ident_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_'
}
fn is_kysely_db_generic_default(line: &str, start: usize, end: usize) -> bool {
line[..start].ends_with("<DB = ") && line[end..].starts_with('>')
}
fn validate_go(code: &str) -> Vec<String> {
let mut errors = Vec::new();
let has_func = code.contains("func ");
let has_struct = code.contains("type ") && code.contains("struct {");
if !has_struct && !has_func {
errors.push("missing `type ... struct {` (for structs)".into());
}
if !has_func {
errors.push("missing `func ` (for functions)".into());
}
if !code.contains("context.Context") {
errors.push("missing `context.Context` as first param".into());
}
let has_indented_lines = code.lines().any(|l| l.starts_with('\t') || l.starts_with(" "));
if has_indented_lines {
let uses_spaces = code.lines().any(|l| l.starts_with(" ") && !l.trim().is_empty());
if uses_spaces {
errors.push("uses space indentation (Go standard is tabs)".into());
}
}
if has_struct && !code.contains("json:\"") {
errors.push("missing `json:\"` tags on struct fields".into());
}
errors
}
fn validate_java(code: &str) -> Vec<String> {
let mut errors = Vec::new();
let has_static = code.contains("public static ");
if !code.contains("public record ") && !has_static {
errors.push("missing `public record ` (for DTOs)".into());
}
if !has_static {
errors.push("missing `public static ` (for query methods)".into());
}
if !code.contains("throws SQLException") {
errors.push("missing `throws SQLException`".into());
}
if !code.contains("try (") {
errors.push("missing `try (` (try-with-resources)".into());
}
errors
}
fn validate_kotlin(code: &str) -> Vec<String> {
let mut errors = Vec::new();
let has_fun = code.contains("fun ");
if !code.contains("data class ") && !has_fun {
errors.push("missing `data class ` (for DTOs)".into());
}
if !has_fun {
errors.push("missing `fun ` (for functions)".into());
}
if !code.contains(".use {") {
errors.push("missing `.use {` (resource management)".into());
}
errors
}
fn validate_kotlin_exposed(code: &str) -> Vec<String> {
let mut errors = Vec::new();
let has_fun = code.contains("fun ");
if !code.contains("data class ") && !code.contains("object ") && !has_fun {
errors.push("missing `data class ` or `object ` (for DTOs/Tables)".into());
}
if !has_fun {
errors.push("missing `fun ` (for functions)".into());
}
if !code.contains("transaction {") {
errors.push("missing `transaction {` (Exposed transaction block)".into());
}
errors
}
fn validate_java_r2dbc(code: &str) -> Vec<String> {
let mut errors = Vec::new();
let has_static = code.contains("public static ");
if !code.contains("public record ") && !has_static {
errors.push("missing `public record ` (for DTOs)".into());
}
if !has_static {
errors.push("missing `public static ` (for query methods)".into());
}
if !code.contains("Mono<") && !code.contains("Flux<") {
errors.push("missing `Mono<` or `Flux<` (reactive types)".into());
}
if !code.contains("ConnectionFactory") {
errors.push("missing `ConnectionFactory` parameter".into());
}
errors
}
fn validate_kotlin_r2dbc(code: &str) -> Vec<String> {
let mut errors = Vec::new();
let has_fun = code.contains("fun ");
if !code.contains("data class ") && !has_fun {
errors.push("missing `data class ` (for DTOs)".into());
}
if !has_fun {
errors.push("missing `fun ` (for functions)".into());
}
if !code.contains("ConnectionFactory") {
errors.push("missing `ConnectionFactory` parameter".into());
}
if !code.contains("suspend fun") && !code.contains("Flow<") {
errors.push("missing `suspend fun` or `Flow<` (coroutine/reactive types)".into());
}
errors
}
fn validate_csharp(code: &str) -> Vec<String> {
let mut errors = Vec::new();
let has_async = code.contains("async Task<") || code.contains("async Task ");
if !code.contains("public record ") && !has_async {
errors.push("missing `public record ` (for DTOs)".into());
}
if !has_async {
errors.push("missing `async Task<` or `async Task` (for async methods)".into());
}
if !code.contains("await ") {
errors.push("missing `await `".into());
}
errors
}
fn validate_elixir(code: &str) -> Vec<String> {
let mut errors = Vec::new();
let has_def = code.contains("def ") || code.contains("defp ");
if !code.contains("defmodule ") && !has_def {
errors.push("missing `defmodule ` (for modules)".into());
}
if !code.contains("defstruct") && !has_def {
errors.push("missing `defstruct` (for structs)".into());
}
if !has_def {
errors.push("missing `def ` or `defp ` (for functions)".into());
}
if !code.contains("@type ") && !code.contains("@spec ") {
errors.push("missing `@type ` or `@spec ` (for typespecs)".into());
}
errors
}
fn validate_ruby(code: &str) -> Vec<String> {
let mut errors = Vec::new();
let has_method = code.contains("def self.");
if !code.contains("Data.define") && !has_method {
errors.push("missing `Data.define` (for DTOs)".into());
}
if !has_method {
errors.push("missing `def self.` (for module methods)".into());
}
if !code.contains("# frozen_string_literal: true") {
errors.push("missing `# frozen_string_literal: true`".into());
}
if !code.contains("module Queries") {
errors.push("missing `module Queries` wrapper".into());
}
errors
}
fn validate_php(code: &str) -> Vec<String> {
let mut errors = Vec::new();
let has_function = code.contains("function ");
if !code.contains("readonly class ") && !has_function {
errors.push("missing `readonly class ` (for DTOs)".into());
}
if !has_function {
errors.push("missing `function ` (for query functions)".into());
}
if !code.contains("declare(strict_types=1)") {
errors.push("missing `declare(strict_types=1)`".into());
}
if !code.contains("<?php") {
errors.push("missing `<?php`".into());
}
errors
}
pub const STRICT_ENV_VAR: &str = "SCYTHE_VALIDATE_STRICT";
pub fn strict_mode_enabled() -> bool {
matches!(
std::env::var(STRICT_ENV_VAR).as_deref(),
Ok("1") | Ok("true") | Ok("TRUE")
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ToolOutcome {
Ran {
tool: &'static str,
errors: Vec<String>,
},
Missing {
tool: &'static str,
},
Failed {
tool: &'static str,
reason: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ToolValidation {
Unsupported,
Attempted(Vec<ToolOutcome>),
}
impl ToolValidation {
pub fn errors(&self) -> Vec<&str> {
match self {
Self::Unsupported => vec![],
Self::Attempted(outcomes) => outcomes
.iter()
.flat_map(|outcome| match outcome {
ToolOutcome::Ran { errors, .. } => errors.iter().map(String::as_str).collect::<Vec<_>>(),
ToolOutcome::Missing { .. } => vec![],
ToolOutcome::Failed { reason, .. } => vec![reason.as_str()],
})
.collect(),
}
}
pub fn missing_tools(&self) -> Vec<&'static str> {
match self {
Self::Unsupported => vec![],
Self::Attempted(outcomes) => outcomes
.iter()
.filter_map(|outcome| match outcome {
ToolOutcome::Missing { tool } => Some(*tool),
_ => None,
})
.collect(),
}
}
pub fn tools_run(&self) -> Vec<&'static str> {
match self {
Self::Unsupported => vec![],
Self::Attempted(outcomes) => outcomes
.iter()
.filter_map(|outcome| match outcome {
ToolOutcome::Ran { tool, .. } => Some(*tool),
_ => None,
})
.collect(),
}
}
pub fn fully_checked(&self) -> bool {
match self {
Self::Unsupported => false,
Self::Attempted(outcomes) => {
!outcomes.is_empty()
&& outcomes
.iter()
.all(|outcome| matches!(outcome, ToolOutcome::Ran { .. }))
}
}
}
pub fn into_result(self) -> Result<(), Vec<String>> {
self.into_result_with_strictness(strict_mode_enabled())
}
pub fn into_result_with_strictness(self, strict: bool) -> Result<(), Vec<String>> {
let mut failures: Vec<String> = self.errors().into_iter().map(str::to_string).collect();
if strict {
for tool in self.missing_tools() {
failures.push(format!(
"strict validation: `{tool}` is not installed, so nothing it checks was verified"
));
}
}
if failures.is_empty() { Ok(()) } else { Err(failures) }
}
}
struct TempSource {
path: std::path::PathBuf,
}
impl TempSource {
fn new(tool: &'static str, code: &str, ext: &str) -> Result<Self, ToolOutcome> {
write_temp(code, ext)
.map(|path| Self { path })
.ok_or_else(|| ToolOutcome::Failed {
tool,
reason: format!("{tool}: could not write a temporary `{ext}` file"),
})
}
fn arg(&self, tool: &'static str) -> Result<&str, ToolOutcome> {
self.path.to_str().ok_or_else(|| ToolOutcome::Failed {
tool,
reason: format!("{tool}: temporary file path is not valid UTF-8"),
})
}
}
impl Drop for TempSource {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
fn tool_present(tool: &str, probe_arg: &str) -> bool {
Command::new(tool).arg(probe_arg).output().is_ok()
}
#[derive(Clone, Copy)]
enum Stream {
Stdout,
Stderr,
Both,
}
fn run_tool(tool: &'static str, args: &[&str], stream: Stream, max_lines: usize) -> Result<Vec<String>, ToolOutcome> {
let output = Command::new(tool)
.args(args)
.output()
.map_err(|error| ToolOutcome::Failed {
tool,
reason: format!("{tool}: could not be executed: {error}"),
})?;
if output.status.success() {
return Ok(vec![]);
}
let raw = match stream {
Stream::Stdout => String::from_utf8_lossy(&output.stdout),
Stream::Stderr => String::from_utf8_lossy(&output.stderr),
Stream::Both => std::borrow::Cow::Owned(format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
)),
};
Ok(raw
.lines()
.filter(|line| !line.trim().is_empty())
.take(max_lines)
.map(|line| format!("{tool}: {line}"))
.collect())
}
fn check_with(
tool: &'static str,
probe_arg: &str,
code: &str,
ext: &str,
build_args: impl Fn(&str) -> Vec<String>,
stream: Stream,
max_lines: usize,
) -> ToolOutcome {
if !tool_present(tool, probe_arg) {
return ToolOutcome::Missing { tool };
}
let source = match TempSource::new(tool, code, ext) {
Ok(source) => source,
Err(outcome) => return outcome,
};
let path = match source.arg(tool) {
Ok(path) => path,
Err(outcome) => return outcome,
};
let args = build_args(path);
let args: Vec<&str> = args.iter().map(String::as_str).collect();
match run_tool(tool, &args, stream, max_lines) {
Ok(errors) => ToolOutcome::Ran { tool, errors },
Err(outcome) => outcome,
}
}
pub fn validate_with_tools(code: &str, backend_name: &str) -> ToolValidation {
let outcomes = match backend_name {
name if name.starts_with("python") => validate_python_tools(code),
name if name.starts_with("javascript") => validate_javascript_tools(code),
name if name.starts_with("typescript") => validate_typescript_tools(code),
name if name.starts_with("go") => validate_go_tools(code),
name if name.starts_with("ruby") => validate_ruby_tools(code),
name if name.starts_with("php") => validate_php_tools(code),
"java-jdbc" => validate_java_tools(code),
"java-r2dbc" => validate_java_r2dbc_tools(code),
"kotlin-jdbc" => validate_kotlin_tools(code),
name if name.starts_with("elixir") => validate_elixir_tools(code),
_ => return ToolValidation::Unsupported,
};
ToolValidation::Attempted(outcomes)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ValidationOutcome {
Unsupported,
Passed {
tools_run: Vec<&'static str>,
tools_missing: Vec<&'static str>,
},
Failed {
tools_run: Vec<&'static str>,
tools_missing: Vec<&'static str>,
errors: Vec<String>,
},
}
impl ValidationOutcome {
#[must_use]
pub fn is_failure(&self) -> bool {
matches!(self, Self::Failed { .. })
}
}
#[must_use]
pub fn validate_generated_code(code: &str, backend_name: &str) -> ValidationOutcome {
let validation = validate_with_tools(code, backend_name);
if matches!(validation, ToolValidation::Unsupported) {
return ValidationOutcome::Unsupported;
}
let tools_run = validation.tools_run();
let tools_missing = validation.missing_tools();
match validation.into_result_with_strictness(false) {
Ok(()) => ValidationOutcome::Passed {
tools_run,
tools_missing,
},
Err(errors) => ValidationOutcome::Failed {
tools_run,
tools_missing,
errors,
},
}
}
fn write_temp(code: &str, ext: &str) -> Option<std::path::PathBuf> {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let basename = if ext == ".kt" {
format!("ScytheValidate{n}")
} else {
format!("scythe_validate_{n}")
};
let path = std::env::temp_dir().join(format!("{basename}{ext}"));
let trimmed = format!("{}\n", code.trim_end());
std::fs::write(&path, trimmed).ok()?;
Some(path)
}
struct TempOutDir {
path: std::path::PathBuf,
}
impl TempOutDir {
fn new(tool: &'static str) -> Result<Self, ToolOutcome> {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!("scythe_validate_out_{tool}_{n}"));
std::fs::create_dir_all(&path).map_err(|error| ToolOutcome::Failed {
tool,
reason: format!("{tool}: could not create output directory: {error}"),
})?;
Ok(Self { path })
}
fn arg(&self, tool: &'static str) -> Result<&str, ToolOutcome> {
self.path.to_str().ok_or_else(|| ToolOutcome::Failed {
tool,
reason: format!("{tool}: temporary output directory path is not valid UTF-8"),
})
}
}
impl Drop for TempOutDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
fn check_with_output(
tool: &'static str,
probe_arg: &str,
code: &str,
ext: &str,
build_args: impl Fn(&str, &str) -> Vec<String>,
stream: Stream,
max_lines: usize,
) -> ToolOutcome {
if !tool_present(tool, probe_arg) {
return ToolOutcome::Missing { tool };
}
let source = match TempSource::new(tool, code, ext) {
Ok(source) => source,
Err(outcome) => return outcome,
};
let source_path = match source.arg(tool) {
Ok(path) => path,
Err(outcome) => return outcome,
};
let out_dir = match TempOutDir::new(tool) {
Ok(dir) => dir,
Err(outcome) => return outcome,
};
let out_path = match out_dir.arg(tool) {
Ok(path) => path,
Err(outcome) => return outcome,
};
let args = build_args(source_path, out_path);
let args: Vec<&str> = args.iter().map(String::as_str).collect();
match run_tool(tool, &args, stream, max_lines) {
Ok(errors) => ToolOutcome::Ran { tool, errors },
Err(outcome) => outcome,
}
}
fn poly_check(code: &str, ext: &str) -> ToolOutcome {
let config = generated_code_poly_config();
check_with(
"poly",
"--version",
code,
ext,
|path| {
["lint", "--no-workspace", "--config", &config.to_string_lossy(), path]
.iter()
.map(|arg| (*arg).to_string())
.collect()
},
Stream::Both,
6,
)
}
fn generated_code_poly_config() -> std::path::PathBuf {
std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/generated-code-poly.toml")).to_path_buf()
}
fn validate_python_tools(code: &str) -> Vec<ToolOutcome> {
vec![poly_check(code, ".py")]
}
fn validate_typescript_tools(code: &str) -> Vec<ToolOutcome> {
vec![poly_check(code, ".ts")]
}
fn js_mode_driver_stub_path() -> std::path::PathBuf {
std::path::Path::new(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/js_mode_stubs/driver-stubs.d.ts"
))
.to_path_buf()
}
fn validate_javascript_tools(code: &str) -> Vec<ToolOutcome> {
let parse = check_with(
"node",
"--version",
code,
".mjs",
|path| ["--check", path].iter().map(|arg| (*arg).to_string()).collect(),
Stream::Stderr,
5,
);
let stub = js_mode_driver_stub_path();
let typecheck = check_with(
"tsc",
"--version",
code,
".mjs",
|path| {
let mut args: Vec<String> = [
"--checkJs",
"--strict",
"--allowJs",
"--noEmit",
"--module",
"nodenext",
"--moduleResolution",
"nodenext",
"--target",
"es2022",
]
.iter()
.map(|arg| (*arg).to_string())
.collect();
args.push(path.to_string());
args.push(stub.to_string_lossy().into_owned());
args
},
Stream::Stdout,
10,
);
vec![parse, typecheck]
}
fn validate_go_tools(code: &str) -> Vec<ToolOutcome> {
vec![check_with(
"gofmt",
"-h",
code,
".go",
|path| ["-e", path].iter().map(|arg| (*arg).to_string()).collect(),
Stream::Stderr,
3,
)]
}
fn validate_ruby_tools(code: &str) -> Vec<ToolOutcome> {
vec![check_with(
"ruby",
"--version",
code,
".rb",
|path| ["-c", path].iter().map(|arg| (*arg).to_string()).collect(),
Stream::Stderr,
1,
)]
}
fn validate_php_tools(code: &str) -> Vec<ToolOutcome> {
vec![poly_check(code, ".php")]
}
fn java_annotation_stub_paths() -> Result<(String, String), ToolOutcome> {
const TOOL: &str = "javac";
let dir = std::path::Path::new(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/java_stubs/javax/annotation"
));
let nonnull = dir.join("Nonnull.java");
let nullable = dir.join("Nullable.java");
match (nonnull.to_str(), nullable.to_str()) {
(Some(a), Some(b)) => Ok((a.to_string(), b.to_string())),
_ => Err(ToolOutcome::Failed {
tool: TOOL,
reason: format!("{TOOL}: stub annotation path is not valid UTF-8"),
}),
}
}
struct JavaSource {
dir: std::path::PathBuf,
}
impl JavaSource {
fn new(code: &str) -> Result<Self, ToolOutcome> {
const TOOL: &str = "javac";
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("scythe_validate_java_{n}"));
std::fs::create_dir_all(&dir).map_err(|error| ToolOutcome::Failed {
tool: TOOL,
reason: format!("{TOOL}: could not create source directory: {error}"),
})?;
let trimmed = format!("{}\n", code.trim_end());
std::fs::write(dir.join("Queries.java"), trimmed).map_err(|error| ToolOutcome::Failed {
tool: TOOL,
reason: format!("{TOOL}: could not write Queries.java: {error}"),
})?;
Ok(Self { dir })
}
fn source_arg(&self) -> Result<String, ToolOutcome> {
self.dir
.join("Queries.java")
.to_str()
.map(str::to_string)
.ok_or_else(|| ToolOutcome::Failed {
tool: "javac",
reason: "javac: temporary source path is not valid UTF-8".to_string(),
})
}
}
impl Drop for JavaSource {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
fn validate_java_tools(code: &str) -> Vec<ToolOutcome> {
javac_with_stubs(code, &[])
}
fn java_r2dbc_stub_paths() -> Result<Vec<String>, ToolOutcome> {
const TOOL: &str = "javac";
const RELATIVE: [&str; 9] = [
"org/reactivestreams/Publisher.java",
"io/r2dbc/spi/ConnectionFactory.java",
"io/r2dbc/spi/Connection.java",
"io/r2dbc/spi/Statement.java",
"io/r2dbc/spi/Result.java",
"io/r2dbc/spi/Row.java",
"io/r2dbc/spi/RowMetadata.java",
"reactor/core/publisher/Mono.java",
"reactor/core/publisher/Flux.java",
];
let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/java_stubs"));
RELATIVE
.iter()
.map(|relative| {
dir.join(relative)
.to_str()
.map(str::to_string)
.ok_or_else(|| ToolOutcome::Failed {
tool: TOOL,
reason: format!("{TOOL}: stub source path is not valid UTF-8: {relative}"),
})
})
.collect()
}
fn validate_java_r2dbc_tools(code: &str) -> Vec<ToolOutcome> {
let stubs = match java_r2dbc_stub_paths() {
Ok(paths) => paths,
Err(outcome) => return vec![outcome],
};
javac_with_stubs(code, &stubs)
}
fn javac_with_stubs(code: &str, extra_stubs: &[String]) -> Vec<ToolOutcome> {
const TOOL: &str = "javac";
if !tool_present(TOOL, "-version") {
return vec![ToolOutcome::Missing { tool: TOOL }];
}
let source = match JavaSource::new(code) {
Ok(source) => source,
Err(outcome) => return vec![outcome],
};
let source_arg = match source.source_arg() {
Ok(path) => path,
Err(outcome) => return vec![outcome],
};
let (nonnull, nullable) = match java_annotation_stub_paths() {
Ok(paths) => paths,
Err(outcome) => return vec![outcome],
};
let out_dir = match TempOutDir::new(TOOL) {
Ok(dir) => dir,
Err(outcome) => return vec![outcome],
};
let out_arg = match out_dir.arg(TOOL) {
Ok(path) => path,
Err(outcome) => return vec![outcome],
};
let mut args = vec![
"-d".to_string(),
out_arg.to_string(),
"-nowarn".to_string(),
source_arg,
nonnull,
nullable,
];
args.extend(extra_stubs.iter().cloned());
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
vec![match run_tool(TOOL, &arg_refs, Stream::Both, 20) {
Ok(errors) => ToolOutcome::Ran { tool: TOOL, errors },
Err(outcome) => outcome,
}]
}
fn validate_kotlin_tools(code: &str) -> Vec<ToolOutcome> {
vec![check_with_output(
"kotlinc",
"-version",
code,
".kt",
|source, out| ["-d", out, source].iter().map(|arg| (*arg).to_string()).collect(),
Stream::Both,
10,
)]
}
fn elixir_stub_paths() -> Result<Vec<String>, ToolOutcome> {
const TOOL: &str = "elixirc";
let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/elixir_stubs"));
let entries = std::fs::read_dir(dir).map_err(|e| ToolOutcome::Failed {
tool: TOOL,
reason: format!("{TOOL}: cannot read stub directory {}: {e}", dir.display()),
})?;
let mut paths: Vec<String> = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("ex") {
continue;
}
match path.to_str() {
Some(p) => paths.push(p.to_string()),
None => {
return Err(ToolOutcome::Failed {
tool: TOOL,
reason: format!("{TOOL}: stub struct path is not valid UTF-8"),
});
}
}
}
if paths.is_empty() {
return Err(ToolOutcome::Failed {
tool: TOOL,
reason: format!("{TOOL}: no stub modules found in {}", dir.display()),
});
}
paths.sort();
Ok(paths)
}
fn validate_elixir_tools(code: &str) -> Vec<ToolOutcome> {
let stubs = match elixir_stub_paths() {
Ok(paths) => paths,
Err(outcome) => return vec![outcome],
};
vec![check_with_output(
"elixirc",
"--version",
code,
".ex",
move |source, out| {
let mut args = vec!["-o".to_string(), out.to_string(), source.to_string()];
args.extend(stubs.iter().cloned());
args
},
Stream::Both,
8,
)]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_unknown_backend() {
let errors = validate_structural("some code", "unknown-backend");
assert_eq!(errors.len(), 1);
assert!(errors[0].contains("unknown backend"));
}
#[test]
fn test_rust_backends_skip() {
assert!(validate_structural("anything", "rust-sqlx").is_empty());
assert!(validate_structural("anything", "rust-tokio-postgres").is_empty());
}
#[test]
fn test_python_valid() {
let code = r#"from dataclasses import dataclass
@dataclass
class ListUsersRow:
id: int
name: str
async def list_users(conn) -> list[ListUsersRow]:
pass
"#;
let errors = validate_structural(code, "python-psycopg3");
assert!(errors.is_empty(), "expected no errors, got: {:?}", errors);
}
#[test]
fn test_python_invalid_typing() {
let code = r#"from typing import Optional
@dataclass
class Row:
id: int
def query() -> List[Row]:
pass
"#;
let errors = validate_structural(code, "python-asyncpg");
assert!(errors.iter().any(|e| e.contains("Optional")));
assert!(errors.iter().any(|e| e.contains("List[")));
}
#[test]
fn test_typescript_valid() {
let code = r#"export interface ListUsersRow {
id: number;
name: string;
}
export async function listUsers(): Promise<ListUsersRow[]> {
// ...
}
"#;
let errors = validate_structural(code, "typescript-postgres");
assert!(errors.is_empty(), "expected no errors, got: {:?}", errors);
}
#[test]
fn test_typescript_allows_kysely_db_generic_default() {
let code = r#"export async function listUsers<DB = any>(db: Kysely<DB>): Promise<ListUsersRow[]> {
return [];
}
"#;
let errors = validate_structural(code, "typescript-kysely");
assert!(
errors.is_empty(),
"the `<DB = any>` generic default must be allowlisted, got: {:?}",
errors
);
}
#[test]
fn test_typescript_rejects_any_before_closing_angle_bracket() {
let code = r#"export async function listUsers(): Promise<Array<any>> {
return [];
}
"#;
let errors = validate_structural(code, "typescript-postgres");
assert!(
errors.iter().any(|e| e.contains("any")),
"expected an `any` violation, got: {:?}",
errors
);
}
#[test]
fn test_go_valid() {
let code = "package db\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n)\n\ntype ListUsersRow struct {\n\tID int `json:\"id\"`\n\tName string `json:\"name\"`\n}\n\nfunc ListUsers(ctx context.Context) ([]ListUsersRow, error) {\n\treturn nil, nil\n}\n";
let errors = validate_structural(code, "go-pgx");
assert!(errors.is_empty(), "expected no errors, got: {:?}", errors);
}
#[test]
fn test_php_valid() {
let code = r#"<?php
declare(strict_types=1);
readonly class ListUsersRow {
public function __construct(
public int $id,
public string $name,
) {}
}
function listUsers($pdo): array {
return [];
}
"#;
let errors = validate_structural(code, "php-pdo");
assert!(errors.is_empty(), "expected no errors, got: {:?}", errors);
}
#[test]
fn a_missing_tool_is_not_the_same_as_a_clean_run() {
let clean = ToolValidation::Attempted(vec![ToolOutcome::Ran {
tool: "ruff",
errors: vec![],
}]);
let absent = ToolValidation::Attempted(vec![ToolOutcome::Missing { tool: "ruff" }]);
assert!(clean.errors().is_empty());
assert!(absent.errors().is_empty());
assert!(clean.fully_checked(), "a tool that ran is a real check");
assert!(!absent.fully_checked(), "a tool that never ran checked nothing");
assert_eq!(absent.missing_tools(), vec!["ruff"]);
assert!(clean.missing_tools().is_empty());
}
#[test]
fn strict_mode_turns_a_missing_tool_into_a_failure() {
let absent = ToolValidation::Attempted(vec![ToolOutcome::Missing { tool: "poly" }]);
assert!(
absent.clone().into_result_with_strictness(false).is_ok(),
"outside strict mode a missing tool is tolerated"
);
let failures = absent
.into_result_with_strictness(true)
.expect_err("strict mode must fail on a missing tool");
assert_eq!(failures.len(), 1);
assert!(
failures[0].contains("poly") && failures[0].contains("not installed"),
"the failure must name the tool and say it never ran: {failures:?}"
);
}
#[test]
fn a_partially_run_validation_is_not_fully_checked() {
let partial = ToolValidation::Attempted(vec![
ToolOutcome::Ran {
tool: "python3",
errors: vec![],
},
ToolOutcome::Missing { tool: "ruff" },
]);
assert!(partial.errors().is_empty());
assert!(!partial.fully_checked(), "one of two tools running is not a full check");
assert_eq!(partial.tools_run(), vec!["python3"]);
assert_eq!(partial.missing_tools(), vec!["ruff"]);
assert!(partial.into_result_with_strictness(true).is_err());
}
#[test]
fn poly_backed_languages_report_exactly_one_tool() {
for (label, outcomes) in [
("python", validate_python_tools("x = 1\n")),
("typescript", validate_typescript_tools("export const x = 1;\n")),
("php", validate_php_tools("<?php\ndeclare(strict_types=1);\n")),
] {
assert_eq!(outcomes.len(), 1, "{label} must be checked by poly alone");
let tool = match &outcomes[0] {
ToolOutcome::Ran { tool, .. } | ToolOutcome::Missing { tool } | ToolOutcome::Failed { tool, .. } => {
*tool
}
};
assert_eq!(tool, "poly", "{label} must go through poly");
}
}
#[test]
fn a_backend_with_no_validator_is_unsupported_not_clean() {
let validation = validate_with_tools("public class Foo {}", "csharp-npgsql");
assert_eq!(validation, ToolValidation::Unsupported);
assert!(validation.errors().is_empty());
assert!(!validation.fully_checked(), "no validator means nothing was checked");
assert!(validation.clone().into_result_with_strictness(false).is_ok());
assert!(
validation.into_result_with_strictness(true).is_ok(),
"strict mode is for tools that can be installed; a language with no \
validator is tracked by an inventory test instead -- see \
into_result_with_strictness"
);
}
#[test]
fn a_harness_failure_is_an_error_even_outside_strict_mode() {
let broken = ToolValidation::Attempted(vec![ToolOutcome::Failed {
tool: "gofmt",
reason: "gofmt: could not be executed: permission denied".into(),
}]);
assert!(!broken.fully_checked());
let failures = broken
.into_result_with_strictness(false)
.expect_err("a broken harness is never a pass");
assert!(failures[0].contains("gofmt"));
}
#[test]
fn strict_mode_reads_the_documented_environment_variable() {
assert_eq!(STRICT_ENV_VAR, "SCYTHE_VALIDATE_STRICT");
}
}