1use std::process::Command;
2
3pub fn validate_structural(code: &str, backend_name: &str) -> Vec<String> {
6 match backend_name {
7 "python-psycopg3" | "python-asyncpg" | "python-aiomysql" | "python-aiosqlite" | "python-duckdb"
8 | "python-pyodbc" | "python-oracledb" | "python-snowflake" => validate_python(code),
9 "typescript-postgres"
10 | "typescript-pg"
11 | "typescript-mysql2"
12 | "typescript-better-sqlite3"
13 | "typescript-node-sqlite"
14 | "typescript-wasm-sqlite"
15 | "typescript-duckdb"
16 | "typescript-kysely"
17 | "typescript-mssql"
18 | "typescript-oracledb"
19 | "typescript-snowflake" => validate_typescript(code),
20 "javascript-pg" | "javascript-postgres" | "javascript-mysql2" | "javascript-better-sqlite3" => {
21 validate_javascript(code)
22 }
23 "go-pgx" | "go-database-sql" | "go-godror" | "go-gosnowflake" => validate_go(code),
24 "java-jdbc" => validate_java(code),
25 "java-r2dbc" => validate_java_r2dbc(code),
26 "kotlin-exposed" => validate_kotlin_exposed(code),
27 "kotlin-jdbc" => validate_kotlin(code),
28 "kotlin-r2dbc" => validate_kotlin_r2dbc(code),
29 "csharp-npgsql"
30 | "csharp-mysqlconnector"
31 | "csharp-microsoft-sqlite"
32 | "csharp-sqlclient"
33 | "csharp-oracle"
34 | "csharp-snowflake" => validate_csharp(code),
35 "elixir-postgrex" | "elixir-ecto" | "elixir-myxql" | "elixir-exqlite" | "elixir-tds" | "elixir-jamdb" => {
36 validate_elixir(code)
37 }
38 "ruby-pg" | "ruby-mysql2" | "ruby-sqlite3" | "ruby-trilogy" | "ruby-oci8" | "ruby-tiny-tds" => {
39 validate_ruby(code)
40 }
41 "php-pdo" | "php-amphp" => validate_php(code),
42 "rust-sqlx" | "rust-tokio-postgres" | "rust-tiberius" | "rust-sibyl" => vec![],
43 _ => vec![format!("unknown backend: {}", backend_name)],
44 }
45}
46
47fn validate_python(code: &str) -> Vec<String> {
48 let mut errors = Vec::new();
49
50 if code.contains("from __future__ import annotations") {
51 errors.push("unnecessary `from __future__ import annotations` — target is Python 3.10+".into());
52 }
53
54 let has_struct = code.contains("@dataclass")
55 || code.contains("(BaseModel)")
56 || code.contains("(msgspec.Struct)")
57 || code.contains("class ");
58 if !has_struct && !code.contains("async def ") && !code.contains("def ") {
59 errors.push("missing `@dataclass`/`class` and `def ` -- no meaningful output".into());
60 }
61
62 if !code.contains("async def ") && !code.contains("def ") {
63 errors.push("missing `async def ` or `def ` (for query functions)".into());
64 }
65
66 if code.contains("from typing import Union") {
67 errors.push("contains `from typing import Union` (pre-3.10 style)".into());
68 }
69
70 if code.contains("from typing import Optional") {
71 errors.push("contains `from typing import Optional` (pre-3.10 style)".into());
72 }
73
74 if code.contains("List[") {
75 errors.push("contains `List[` (use lowercase `list[`)".into());
76 }
77
78 if code.contains("Dict[") {
79 errors.push("contains `Dict[` (use lowercase `dict[`)".into());
80 }
81
82 for (i, line) in code.lines().enumerate() {
83 if line.starts_with('\t') {
84 errors.push(format!("line {} uses tab indentation (should use 4 spaces)", i + 1));
85 break;
86 }
87 }
88
89 errors
90}
91
92fn validate_javascript(code: &str) -> Vec<String> {
100 let mut errors = Vec::new();
101
102 let has_function = code.contains("export async function") || code.contains("export function");
103 let has_typedef = code.contains("@typedef");
104 if !has_typedef && !has_function {
105 errors.push("missing `@typedef` (for DTOs) and `export function`/`export async function`".into());
106 }
107 if !has_function {
108 errors.push("missing `export async function` or `export function`".into());
109 }
110 if code.contains("export interface") {
111 errors.push("contains `export interface` -- TypeScript-only syntax, invalid in plain .js".into());
112 }
113 if code.contains("export enum") {
114 errors.push("contains `export enum` -- TypeScript-only syntax, invalid in plain .js".into());
115 }
116
117 for line in code.lines() {
118 let trimmed = line.trim();
119 if find_disallowed_any_usage(trimmed).is_some() {
120 errors.push(format!(
121 "contains `any` type (should use `unknown` or specific): {}",
122 trimmed
123 ));
124 break;
125 }
126 }
127
128 errors
129}
130
131fn validate_typescript(code: &str) -> Vec<String> {
132 let mut errors = Vec::new();
133
134 let has_function = code.contains("export async function") || code.contains("export function");
135
136 let has_zod = code.contains("z.object(") || code.contains("z.infer<");
137 if !code.contains("export interface") && !code.contains("export type") && !has_zod && !has_function {
138 errors.push("missing `export interface` or `export type` (for DTOs)".into());
139 }
140
141 if !has_function {
142 errors.push("missing `export async function` or `export function`".into());
143 }
144
145 for line in code.lines() {
146 let trimmed = line.trim();
147 if find_disallowed_any_usage(trimmed).is_some() {
148 errors.push(format!(
149 "contains `any` type (should use `unknown` or specific): {}",
150 trimmed
151 ));
152 break;
153 }
154 }
155
156 errors
157}
158
159fn find_disallowed_any_usage(trimmed: &str) -> Option<&str> {
173 let mut offset = 0;
174 while let Some(rel) = trimmed[offset..].find("any") {
175 let start = offset + rel;
176 let end = start + "any".len();
177 let before_is_ident = start > 0 && is_ident_byte(trimmed.as_bytes()[start - 1]);
178 let after_is_ident = end < trimmed.len() && is_ident_byte(trimmed.as_bytes()[end]);
179 if !before_is_ident && !after_is_ident && !is_kysely_db_generic_default(trimmed, start, end) {
180 return Some(trimmed);
181 }
182 offset = end;
183 }
184 None
185}
186
187fn is_ident_byte(b: u8) -> bool {
188 b.is_ascii_alphanumeric() || b == b'_'
189}
190
191fn is_kysely_db_generic_default(line: &str, start: usize, end: usize) -> bool {
195 line[..start].ends_with("<DB = ") && line[end..].starts_with('>')
196}
197
198fn validate_go(code: &str) -> Vec<String> {
199 let mut errors = Vec::new();
200
201 let has_func = code.contains("func ");
202 let has_struct = code.contains("type ") && code.contains("struct {");
203
204 if !has_struct && !has_func {
205 errors.push("missing `type ... struct {` (for structs)".into());
206 }
207
208 if !has_func {
209 errors.push("missing `func ` (for functions)".into());
210 }
211
212 if !code.contains("context.Context") {
213 errors.push("missing `context.Context` as first param".into());
214 }
215
216 let has_indented_lines = code.lines().any(|l| l.starts_with('\t') || l.starts_with(" "));
217 if has_indented_lines {
218 let uses_spaces = code.lines().any(|l| l.starts_with(" ") && !l.trim().is_empty());
219 if uses_spaces {
220 errors.push("uses space indentation (Go standard is tabs)".into());
221 }
222 }
223
224 if has_struct && !code.contains("json:\"") {
225 errors.push("missing `json:\"` tags on struct fields".into());
226 }
227
228 errors
229}
230
231fn validate_java(code: &str) -> Vec<String> {
232 let mut errors = Vec::new();
233
234 let has_static = code.contains("public static ");
235
236 if !code.contains("public record ") && !has_static {
237 errors.push("missing `public record ` (for DTOs)".into());
238 }
239
240 if !has_static {
241 errors.push("missing `public static ` (for query methods)".into());
242 }
243
244 if !code.contains("throws SQLException") {
245 errors.push("missing `throws SQLException`".into());
246 }
247
248 if !code.contains("try (") {
249 errors.push("missing `try (` (try-with-resources)".into());
250 }
251
252 errors
253}
254
255fn validate_kotlin(code: &str) -> Vec<String> {
256 let mut errors = Vec::new();
257
258 let has_fun = code.contains("fun ");
259
260 if !code.contains("data class ") && !has_fun {
261 errors.push("missing `data class ` (for DTOs)".into());
262 }
263
264 if !has_fun {
265 errors.push("missing `fun ` (for functions)".into());
266 }
267
268 if !code.contains(".use {") {
269 errors.push("missing `.use {` (resource management)".into());
270 }
271
272 errors
273}
274
275fn validate_kotlin_exposed(code: &str) -> Vec<String> {
276 let mut errors = Vec::new();
277
278 let has_fun = code.contains("fun ");
279
280 if !code.contains("data class ") && !code.contains("object ") && !has_fun {
281 errors.push("missing `data class ` or `object ` (for DTOs/Tables)".into());
282 }
283
284 if !has_fun {
285 errors.push("missing `fun ` (for functions)".into());
286 }
287
288 if !code.contains("transaction {") {
289 errors.push("missing `transaction {` (Exposed transaction block)".into());
290 }
291
292 errors
293}
294
295fn validate_java_r2dbc(code: &str) -> Vec<String> {
296 let mut errors = Vec::new();
297
298 let has_static = code.contains("public static ");
299
300 if !code.contains("public record ") && !has_static {
301 errors.push("missing `public record ` (for DTOs)".into());
302 }
303
304 if !has_static {
305 errors.push("missing `public static ` (for query methods)".into());
306 }
307
308 if !code.contains("Mono<") && !code.contains("Flux<") {
309 errors.push("missing `Mono<` or `Flux<` (reactive types)".into());
310 }
311
312 if !code.contains("ConnectionFactory") {
313 errors.push("missing `ConnectionFactory` parameter".into());
314 }
315
316 errors
317}
318
319fn validate_kotlin_r2dbc(code: &str) -> Vec<String> {
320 let mut errors = Vec::new();
321
322 let has_fun = code.contains("fun ");
323
324 if !code.contains("data class ") && !has_fun {
325 errors.push("missing `data class ` (for DTOs)".into());
326 }
327
328 if !has_fun {
329 errors.push("missing `fun ` (for functions)".into());
330 }
331
332 if !code.contains("ConnectionFactory") {
333 errors.push("missing `ConnectionFactory` parameter".into());
334 }
335
336 if !code.contains("suspend fun") && !code.contains("Flow<") {
337 errors.push("missing `suspend fun` or `Flow<` (coroutine/reactive types)".into());
338 }
339
340 errors
341}
342
343fn validate_csharp(code: &str) -> Vec<String> {
344 let mut errors = Vec::new();
345
346 let has_async = code.contains("async Task<") || code.contains("async Task ");
347
348 if !code.contains("public record ") && !has_async {
349 errors.push("missing `public record ` (for DTOs)".into());
350 }
351
352 if !has_async {
353 errors.push("missing `async Task<` or `async Task` (for async methods)".into());
354 }
355
356 if !code.contains("await ") {
357 errors.push("missing `await `".into());
358 }
359
360 errors
361}
362
363fn validate_elixir(code: &str) -> Vec<String> {
364 let mut errors = Vec::new();
365
366 let has_def = code.contains("def ") || code.contains("defp ");
367
368 if !code.contains("defmodule ") && !has_def {
369 errors.push("missing `defmodule ` (for modules)".into());
370 }
371
372 if !code.contains("defstruct") && !has_def {
373 errors.push("missing `defstruct` (for structs)".into());
374 }
375
376 if !has_def {
377 errors.push("missing `def ` or `defp ` (for functions)".into());
378 }
379
380 if !code.contains("@type ") && !code.contains("@spec ") {
381 errors.push("missing `@type ` or `@spec ` (for typespecs)".into());
382 }
383
384 errors
385}
386
387fn validate_ruby(code: &str) -> Vec<String> {
388 let mut errors = Vec::new();
389
390 let has_method = code.contains("def self.");
391
392 if !code.contains("Data.define") && !has_method {
393 errors.push("missing `Data.define` (for DTOs)".into());
394 }
395
396 if !has_method {
397 errors.push("missing `def self.` (for module methods)".into());
398 }
399
400 if !code.contains("# frozen_string_literal: true") {
401 errors.push("missing `# frozen_string_literal: true`".into());
402 }
403
404 if !code.contains("module Queries") {
405 errors.push("missing `module Queries` wrapper".into());
406 }
407
408 errors
409}
410
411fn validate_php(code: &str) -> Vec<String> {
412 let mut errors = Vec::new();
413
414 let has_function = code.contains("function ");
415
416 if !code.contains("readonly class ") && !has_function {
417 errors.push("missing `readonly class ` (for DTOs)".into());
418 }
419
420 if !has_function {
421 errors.push("missing `function ` (for query functions)".into());
422 }
423
424 if !code.contains("declare(strict_types=1)") {
425 errors.push("missing `declare(strict_types=1)`".into());
426 }
427
428 if !code.contains("<?php") {
429 errors.push("missing `<?php`".into());
430 }
431
432 errors
433}
434
435pub const STRICT_ENV_VAR: &str = "SCYTHE_VALIDATE_STRICT";
441
442pub fn strict_mode_enabled() -> bool {
453 matches!(
454 std::env::var(STRICT_ENV_VAR).as_deref(),
455 Ok("1") | Ok("true") | Ok("TRUE")
456 )
457}
458
459#[derive(Debug, Clone, PartialEq, Eq)]
468pub enum ToolOutcome {
469 Ran {
472 tool: &'static str,
474 errors: Vec<String>,
476 },
477 Missing {
480 tool: &'static str,
482 },
483 Failed {
491 tool: &'static str,
493 reason: String,
495 },
496}
497
498#[derive(Debug, Clone, PartialEq, Eq)]
500pub enum ToolValidation {
501 Unsupported,
507 Attempted(Vec<ToolOutcome>),
509}
510
511impl ToolValidation {
512 pub fn errors(&self) -> Vec<&str> {
514 match self {
515 Self::Unsupported => vec![],
516 Self::Attempted(outcomes) => outcomes
517 .iter()
518 .flat_map(|outcome| match outcome {
519 ToolOutcome::Ran { errors, .. } => errors.iter().map(String::as_str).collect::<Vec<_>>(),
520 ToolOutcome::Missing { .. } => vec![],
521 ToolOutcome::Failed { reason, .. } => vec![reason.as_str()],
522 })
523 .collect(),
524 }
525 }
526
527 pub fn missing_tools(&self) -> Vec<&'static str> {
529 match self {
530 Self::Unsupported => vec![],
531 Self::Attempted(outcomes) => outcomes
532 .iter()
533 .filter_map(|outcome| match outcome {
534 ToolOutcome::Missing { tool } => Some(*tool),
535 _ => None,
536 })
537 .collect(),
538 }
539 }
540
541 pub fn tools_run(&self) -> Vec<&'static str> {
543 match self {
544 Self::Unsupported => vec![],
545 Self::Attempted(outcomes) => outcomes
546 .iter()
547 .filter_map(|outcome| match outcome {
548 ToolOutcome::Ran { tool, .. } => Some(*tool),
549 _ => None,
550 })
551 .collect(),
552 }
553 }
554
555 pub fn fully_checked(&self) -> bool {
560 match self {
561 Self::Unsupported => false,
562 Self::Attempted(outcomes) => {
563 !outcomes.is_empty()
564 && outcomes
565 .iter()
566 .all(|outcome| matches!(outcome, ToolOutcome::Ran { .. }))
567 }
568 }
569 }
570
571 pub fn into_result(self) -> Result<(), Vec<String>> {
577 self.into_result_with_strictness(strict_mode_enabled())
578 }
579
580 pub fn into_result_with_strictness(self, strict: bool) -> Result<(), Vec<String>> {
595 let mut failures: Vec<String> = self.errors().into_iter().map(str::to_string).collect();
596
597 if strict {
598 for tool in self.missing_tools() {
599 failures.push(format!(
600 "strict validation: `{tool}` is not installed, so nothing it checks was verified"
601 ));
602 }
603 }
604
605 if failures.is_empty() { Ok(()) } else { Err(failures) }
606 }
607}
608
609struct TempSource {
615 path: std::path::PathBuf,
616}
617
618impl TempSource {
619 fn new(tool: &'static str, code: &str, ext: &str) -> Result<Self, ToolOutcome> {
620 write_temp(code, ext)
621 .map(|path| Self { path })
622 .ok_or_else(|| ToolOutcome::Failed {
623 tool,
624 reason: format!("{tool}: could not write a temporary `{ext}` file"),
625 })
626 }
627
628 fn arg(&self, tool: &'static str) -> Result<&str, ToolOutcome> {
629 self.path.to_str().ok_or_else(|| ToolOutcome::Failed {
630 tool,
631 reason: format!("{tool}: temporary file path is not valid UTF-8"),
632 })
633 }
634}
635
636impl Drop for TempSource {
637 fn drop(&mut self) {
638 let _ = std::fs::remove_file(&self.path);
639 }
640}
641
642fn tool_present(tool: &str, probe_arg: &str) -> bool {
644 Command::new(tool).arg(probe_arg).output().is_ok()
645}
646
647#[derive(Clone, Copy)]
649enum Stream {
650 Stdout,
651 Stderr,
652 Both,
655}
656
657fn run_tool(tool: &'static str, args: &[&str], stream: Stream, max_lines: usize) -> Result<Vec<String>, ToolOutcome> {
662 let output = Command::new(tool)
663 .args(args)
664 .output()
665 .map_err(|error| ToolOutcome::Failed {
666 tool,
667 reason: format!("{tool}: could not be executed: {error}"),
668 })?;
669
670 if output.status.success() {
671 return Ok(vec![]);
672 }
673
674 let raw = match stream {
675 Stream::Stdout => String::from_utf8_lossy(&output.stdout),
676 Stream::Stderr => String::from_utf8_lossy(&output.stderr),
677 Stream::Both => std::borrow::Cow::Owned(format!(
678 "{}{}",
679 String::from_utf8_lossy(&output.stdout),
680 String::from_utf8_lossy(&output.stderr)
681 )),
682 };
683
684 Ok(raw
685 .lines()
686 .filter(|line| !line.trim().is_empty())
687 .take(max_lines)
688 .map(|line| format!("{tool}: {line}"))
689 .collect())
690}
691
692fn check_with(
694 tool: &'static str,
695 probe_arg: &str,
696 code: &str,
697 ext: &str,
698 build_args: impl Fn(&str) -> Vec<String>,
699 stream: Stream,
700 max_lines: usize,
701) -> ToolOutcome {
702 if !tool_present(tool, probe_arg) {
703 return ToolOutcome::Missing { tool };
704 }
705
706 let source = match TempSource::new(tool, code, ext) {
707 Ok(source) => source,
708 Err(outcome) => return outcome,
709 };
710 let path = match source.arg(tool) {
711 Ok(path) => path,
712 Err(outcome) => return outcome,
713 };
714
715 let args = build_args(path);
716 let args: Vec<&str> = args.iter().map(String::as_str).collect();
717
718 match run_tool(tool, &args, stream, max_lines) {
719 Ok(errors) => ToolOutcome::Ran { tool, errors },
720 Err(outcome) => outcome,
721 }
722}
723
724pub fn validate_with_tools(code: &str, backend_name: &str) -> ToolValidation {
733 let outcomes = match backend_name {
734 name if name.starts_with("python") => validate_python_tools(code),
735 name if name.starts_with("javascript") => validate_javascript_tools(code),
736 name if name.starts_with("typescript") => validate_typescript_tools(code),
737 name if name.starts_with("go") => validate_go_tools(code),
738 name if name.starts_with("ruby") => validate_ruby_tools(code),
739 name if name.starts_with("php") => validate_php_tools(code),
740 "java-jdbc" => validate_java_tools(code),
747 "java-r2dbc" => validate_java_r2dbc_tools(code),
748 "kotlin-jdbc" => validate_kotlin_tools(code),
757 name if name.starts_with("elixir") => validate_elixir_tools(code),
768 _ => return ToolValidation::Unsupported,
792 };
793
794 ToolValidation::Attempted(outcomes)
795}
796
797#[derive(Debug, Clone, PartialEq, Eq)]
808pub enum ValidationOutcome {
809 Unsupported,
813 Passed {
824 tools_run: Vec<&'static str>,
826 tools_missing: Vec<&'static str>,
828 },
829 Failed {
835 tools_run: Vec<&'static str>,
838 tools_missing: Vec<&'static str>,
840 errors: Vec<String>,
843 },
844}
845
846impl ValidationOutcome {
847 #[must_use]
857 pub fn is_failure(&self) -> bool {
858 matches!(self, Self::Failed { .. })
859 }
860}
861
862#[must_use]
897pub fn validate_generated_code(code: &str, backend_name: &str) -> ValidationOutcome {
898 let validation = validate_with_tools(code, backend_name);
899 if matches!(validation, ToolValidation::Unsupported) {
900 return ValidationOutcome::Unsupported;
901 }
902
903 let tools_run = validation.tools_run();
904 let tools_missing = validation.missing_tools();
905
906 match validation.into_result_with_strictness(false) {
907 Ok(()) => ValidationOutcome::Passed {
908 tools_run,
909 tools_missing,
910 },
911 Err(errors) => ValidationOutcome::Failed {
912 tools_run,
913 tools_missing,
914 errors,
915 },
916 }
917}
918
919fn write_temp(code: &str, ext: &str) -> Option<std::path::PathBuf> {
920 use std::sync::atomic::{AtomicU64, Ordering};
921 static COUNTER: AtomicU64 = AtomicU64::new(0);
922 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
923 let basename = if ext == ".kt" {
924 format!("ScytheValidate{n}")
925 } else {
926 format!("scythe_validate_{n}")
927 };
928 let path = std::env::temp_dir().join(format!("{basename}{ext}"));
929 let trimmed = format!("{}\n", code.trim_end());
930 std::fs::write(&path, trimmed).ok()?;
931 Some(path)
932}
933
934struct TempOutDir {
945 path: std::path::PathBuf,
946}
947
948impl TempOutDir {
949 fn new(tool: &'static str) -> Result<Self, ToolOutcome> {
950 use std::sync::atomic::{AtomicU64, Ordering};
951 static COUNTER: AtomicU64 = AtomicU64::new(0);
952 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
953 let path = std::env::temp_dir().join(format!("scythe_validate_out_{tool}_{n}"));
954 std::fs::create_dir_all(&path).map_err(|error| ToolOutcome::Failed {
955 tool,
956 reason: format!("{tool}: could not create output directory: {error}"),
957 })?;
958 Ok(Self { path })
959 }
960
961 fn arg(&self, tool: &'static str) -> Result<&str, ToolOutcome> {
962 self.path.to_str().ok_or_else(|| ToolOutcome::Failed {
963 tool,
964 reason: format!("{tool}: temporary output directory path is not valid UTF-8"),
965 })
966 }
967}
968
969impl Drop for TempOutDir {
970 fn drop(&mut self) {
971 let _ = std::fs::remove_dir_all(&self.path);
972 }
973}
974
975fn check_with_output(
979 tool: &'static str,
980 probe_arg: &str,
981 code: &str,
982 ext: &str,
983 build_args: impl Fn(&str, &str) -> Vec<String>,
984 stream: Stream,
985 max_lines: usize,
986) -> ToolOutcome {
987 if !tool_present(tool, probe_arg) {
988 return ToolOutcome::Missing { tool };
989 }
990
991 let source = match TempSource::new(tool, code, ext) {
992 Ok(source) => source,
993 Err(outcome) => return outcome,
994 };
995 let source_path = match source.arg(tool) {
996 Ok(path) => path,
997 Err(outcome) => return outcome,
998 };
999
1000 let out_dir = match TempOutDir::new(tool) {
1001 Ok(dir) => dir,
1002 Err(outcome) => return outcome,
1003 };
1004 let out_path = match out_dir.arg(tool) {
1005 Ok(path) => path,
1006 Err(outcome) => return outcome,
1007 };
1008
1009 let args = build_args(source_path, out_path);
1010 let args: Vec<&str> = args.iter().map(String::as_str).collect();
1011
1012 match run_tool(tool, &args, stream, max_lines) {
1013 Ok(errors) => ToolOutcome::Ran { tool, errors },
1014 Err(outcome) => outcome,
1015 }
1016}
1017
1018fn poly_check(code: &str, ext: &str) -> ToolOutcome {
1035 let config = generated_code_poly_config();
1036 check_with(
1037 "poly",
1038 "--version",
1039 code,
1040 ext,
1041 |path| {
1042 ["lint", "--no-workspace", "--config", &config.to_string_lossy(), path]
1043 .iter()
1044 .map(|arg| (*arg).to_string())
1045 .collect()
1046 },
1047 Stream::Both,
1048 6,
1049 )
1050}
1051
1052fn generated_code_poly_config() -> std::path::PathBuf {
1061 std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/generated-code-poly.toml")).to_path_buf()
1062}
1063
1064fn validate_python_tools(code: &str) -> Vec<ToolOutcome> {
1068 vec![poly_check(code, ".py")]
1069}
1070
1071fn validate_typescript_tools(code: &str) -> Vec<ToolOutcome> {
1084 vec![poly_check(code, ".ts")]
1085}
1086
1087fn js_mode_driver_stub_path() -> std::path::PathBuf {
1096 std::path::Path::new(concat!(
1097 env!("CARGO_MANIFEST_DIR"),
1098 "/tests/js_mode_stubs/driver-stubs.d.ts"
1099 ))
1100 .to_path_buf()
1101}
1102
1103fn validate_javascript_tools(code: &str) -> Vec<ToolOutcome> {
1120 let parse = check_with(
1124 "node",
1125 "--version",
1126 code,
1127 ".mjs",
1128 |path| ["--check", path].iter().map(|arg| (*arg).to_string()).collect(),
1129 Stream::Stderr,
1130 5,
1131 );
1132
1133 let stub = js_mode_driver_stub_path();
1134 let typecheck = check_with(
1135 "tsc",
1136 "--version",
1137 code,
1138 ".mjs",
1139 |path| {
1140 let mut args: Vec<String> = [
1141 "--checkJs",
1142 "--strict",
1143 "--allowJs",
1144 "--noEmit",
1145 "--module",
1146 "nodenext",
1147 "--moduleResolution",
1148 "nodenext",
1149 "--target",
1150 "es2022",
1151 ]
1152 .iter()
1153 .map(|arg| (*arg).to_string())
1154 .collect();
1155 args.push(path.to_string());
1156 args.push(stub.to_string_lossy().into_owned());
1157 args
1158 },
1159 Stream::Stdout,
1161 10,
1162 );
1163
1164 vec![parse, typecheck]
1165}
1166
1167fn validate_go_tools(code: &str) -> Vec<ToolOutcome> {
1168 vec![check_with(
1169 "gofmt",
1170 "-h",
1171 code,
1172 ".go",
1173 |path| ["-e", path].iter().map(|arg| (*arg).to_string()).collect(),
1174 Stream::Stderr,
1175 3,
1176 )]
1177}
1178
1179fn validate_ruby_tools(code: &str) -> Vec<ToolOutcome> {
1180 vec![check_with(
1181 "ruby",
1182 "--version",
1183 code,
1184 ".rb",
1185 |path| ["-c", path].iter().map(|arg| (*arg).to_string()).collect(),
1186 Stream::Stderr,
1187 1,
1188 )]
1189}
1190
1191fn validate_php_tools(code: &str) -> Vec<ToolOutcome> {
1194 vec![poly_check(code, ".php")]
1195}
1196
1197fn java_annotation_stub_paths() -> Result<(String, String), ToolOutcome> {
1204 const TOOL: &str = "javac";
1205 let dir = std::path::Path::new(concat!(
1206 env!("CARGO_MANIFEST_DIR"),
1207 "/tests/java_stubs/javax/annotation"
1208 ));
1209 let nonnull = dir.join("Nonnull.java");
1210 let nullable = dir.join("Nullable.java");
1211 match (nonnull.to_str(), nullable.to_str()) {
1212 (Some(a), Some(b)) => Ok((a.to_string(), b.to_string())),
1213 _ => Err(ToolOutcome::Failed {
1214 tool: TOOL,
1215 reason: format!("{TOOL}: stub annotation path is not valid UTF-8"),
1216 }),
1217 }
1218}
1219
1220struct JavaSource {
1232 dir: std::path::PathBuf,
1233}
1234
1235impl JavaSource {
1236 fn new(code: &str) -> Result<Self, ToolOutcome> {
1237 const TOOL: &str = "javac";
1238 use std::sync::atomic::{AtomicU64, Ordering};
1239 static COUNTER: AtomicU64 = AtomicU64::new(0);
1240 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
1241 let dir = std::env::temp_dir().join(format!("scythe_validate_java_{n}"));
1242 std::fs::create_dir_all(&dir).map_err(|error| ToolOutcome::Failed {
1243 tool: TOOL,
1244 reason: format!("{TOOL}: could not create source directory: {error}"),
1245 })?;
1246 let trimmed = format!("{}\n", code.trim_end());
1247 std::fs::write(dir.join("Queries.java"), trimmed).map_err(|error| ToolOutcome::Failed {
1248 tool: TOOL,
1249 reason: format!("{TOOL}: could not write Queries.java: {error}"),
1250 })?;
1251 Ok(Self { dir })
1252 }
1253
1254 fn source_arg(&self) -> Result<String, ToolOutcome> {
1255 self.dir
1256 .join("Queries.java")
1257 .to_str()
1258 .map(str::to_string)
1259 .ok_or_else(|| ToolOutcome::Failed {
1260 tool: "javac",
1261 reason: "javac: temporary source path is not valid UTF-8".to_string(),
1262 })
1263 }
1264}
1265
1266impl Drop for JavaSource {
1267 fn drop(&mut self) {
1268 let _ = std::fs::remove_dir_all(&self.dir);
1269 }
1270}
1271
1272fn validate_java_tools(code: &str) -> Vec<ToolOutcome> {
1280 javac_with_stubs(code, &[])
1281}
1282
1283fn java_r2dbc_stub_paths() -> Result<Vec<String>, ToolOutcome> {
1296 const TOOL: &str = "javac";
1297 const RELATIVE: [&str; 9] = [
1298 "org/reactivestreams/Publisher.java",
1299 "io/r2dbc/spi/ConnectionFactory.java",
1300 "io/r2dbc/spi/Connection.java",
1301 "io/r2dbc/spi/Statement.java",
1302 "io/r2dbc/spi/Result.java",
1303 "io/r2dbc/spi/Row.java",
1304 "io/r2dbc/spi/RowMetadata.java",
1305 "reactor/core/publisher/Mono.java",
1306 "reactor/core/publisher/Flux.java",
1307 ];
1308 let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/java_stubs"));
1309 RELATIVE
1310 .iter()
1311 .map(|relative| {
1312 dir.join(relative)
1313 .to_str()
1314 .map(str::to_string)
1315 .ok_or_else(|| ToolOutcome::Failed {
1316 tool: TOOL,
1317 reason: format!("{TOOL}: stub source path is not valid UTF-8: {relative}"),
1318 })
1319 })
1320 .collect()
1321}
1322
1323fn validate_java_r2dbc_tools(code: &str) -> Vec<ToolOutcome> {
1326 let stubs = match java_r2dbc_stub_paths() {
1327 Ok(paths) => paths,
1328 Err(outcome) => return vec![outcome],
1329 };
1330 javac_with_stubs(code, &stubs)
1331}
1332
1333fn javac_with_stubs(code: &str, extra_stubs: &[String]) -> Vec<ToolOutcome> {
1336 const TOOL: &str = "javac";
1337
1338 if !tool_present(TOOL, "-version") {
1339 return vec![ToolOutcome::Missing { tool: TOOL }];
1340 }
1341
1342 let source = match JavaSource::new(code) {
1343 Ok(source) => source,
1344 Err(outcome) => return vec![outcome],
1345 };
1346 let source_arg = match source.source_arg() {
1347 Ok(path) => path,
1348 Err(outcome) => return vec![outcome],
1349 };
1350 let (nonnull, nullable) = match java_annotation_stub_paths() {
1351 Ok(paths) => paths,
1352 Err(outcome) => return vec![outcome],
1353 };
1354 let out_dir = match TempOutDir::new(TOOL) {
1355 Ok(dir) => dir,
1356 Err(outcome) => return vec![outcome],
1357 };
1358 let out_arg = match out_dir.arg(TOOL) {
1359 Ok(path) => path,
1360 Err(outcome) => return vec![outcome],
1361 };
1362
1363 let mut args = vec![
1364 "-d".to_string(),
1365 out_arg.to_string(),
1366 "-nowarn".to_string(),
1367 source_arg,
1368 nonnull,
1369 nullable,
1370 ];
1371 args.extend(extra_stubs.iter().cloned());
1372 let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
1373
1374 vec![match run_tool(TOOL, &arg_refs, Stream::Both, 20) {
1375 Ok(errors) => ToolOutcome::Ran { tool: TOOL, errors },
1376 Err(outcome) => outcome,
1377 }]
1378}
1379
1380fn validate_kotlin_tools(code: &str) -> Vec<ToolOutcome> {
1391 vec![check_with_output(
1392 "kotlinc",
1393 "-version",
1394 code,
1395 ".kt",
1396 |source, out| ["-d", out, source].iter().map(|arg| (*arg).to_string()).collect(),
1397 Stream::Both,
1398 10,
1399 )]
1400}
1401
1402fn elixir_stub_paths() -> Result<Vec<String>, ToolOutcome> {
1414 const TOOL: &str = "elixirc";
1415 let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/elixir_stubs"));
1416 let entries = std::fs::read_dir(dir).map_err(|e| ToolOutcome::Failed {
1417 tool: TOOL,
1418 reason: format!("{TOOL}: cannot read stub directory {}: {e}", dir.display()),
1419 })?;
1420 let mut paths: Vec<String> = Vec::new();
1421 for entry in entries.flatten() {
1422 let path = entry.path();
1423 if path.extension().and_then(|e| e.to_str()) != Some("ex") {
1424 continue;
1425 }
1426 match path.to_str() {
1427 Some(p) => paths.push(p.to_string()),
1428 None => {
1429 return Err(ToolOutcome::Failed {
1430 tool: TOOL,
1431 reason: format!("{TOOL}: stub struct path is not valid UTF-8"),
1432 });
1433 }
1434 }
1435 }
1436 if paths.is_empty() {
1437 return Err(ToolOutcome::Failed {
1438 tool: TOOL,
1439 reason: format!("{TOOL}: no stub modules found in {}", dir.display()),
1440 });
1441 }
1442 paths.sort();
1444 Ok(paths)
1445}
1446
1447fn validate_elixir_tools(code: &str) -> Vec<ToolOutcome> {
1474 let stubs = match elixir_stub_paths() {
1475 Ok(paths) => paths,
1476 Err(outcome) => return vec![outcome],
1477 };
1478 vec![check_with_output(
1479 "elixirc",
1480 "--version",
1481 code,
1482 ".ex",
1483 move |source, out| {
1484 let mut args = vec!["-o".to_string(), out.to_string(), source.to_string()];
1485 args.extend(stubs.iter().cloned());
1486 args
1487 },
1488 Stream::Both,
1489 8,
1490 )]
1491}
1492
1493#[cfg(test)]
1494mod tests {
1495 use super::*;
1496
1497 #[test]
1498 fn test_unknown_backend() {
1499 let errors = validate_structural("some code", "unknown-backend");
1500 assert_eq!(errors.len(), 1);
1501 assert!(errors[0].contains("unknown backend"));
1502 }
1503
1504 #[test]
1505 fn test_rust_backends_skip() {
1506 assert!(validate_structural("anything", "rust-sqlx").is_empty());
1507 assert!(validate_structural("anything", "rust-tokio-postgres").is_empty());
1508 }
1509
1510 #[test]
1511 fn test_python_valid() {
1512 let code = r#"from dataclasses import dataclass
1513
1514@dataclass
1515class ListUsersRow:
1516 id: int
1517 name: str
1518
1519async def list_users(conn) -> list[ListUsersRow]:
1520 pass
1521"#;
1522 let errors = validate_structural(code, "python-psycopg3");
1523 assert!(errors.is_empty(), "expected no errors, got: {:?}", errors);
1524 }
1525
1526 #[test]
1527 fn test_python_invalid_typing() {
1528 let code = r#"from typing import Optional
1529
1530@dataclass
1531class Row:
1532 id: int
1533
1534def query() -> List[Row]:
1535 pass
1536"#;
1537 let errors = validate_structural(code, "python-asyncpg");
1538 assert!(errors.iter().any(|e| e.contains("Optional")));
1539 assert!(errors.iter().any(|e| e.contains("List[")));
1540 }
1541
1542 #[test]
1543 fn test_typescript_valid() {
1544 let code = r#"export interface ListUsersRow {
1545 id: number;
1546 name: string;
1547}
1548
1549export async function listUsers(): Promise<ListUsersRow[]> {
1550 // ...
1551}
1552"#;
1553 let errors = validate_structural(code, "typescript-postgres");
1554 assert!(errors.is_empty(), "expected no errors, got: {:?}", errors);
1555 }
1556
1557 #[test]
1558 fn test_typescript_allows_kysely_db_generic_default() {
1559 let code = r#"export async function listUsers<DB = any>(db: Kysely<DB>): Promise<ListUsersRow[]> {
1560 return [];
1561}
1562"#;
1563 let errors = validate_structural(code, "typescript-kysely");
1564 assert!(
1565 errors.is_empty(),
1566 "the `<DB = any>` generic default must be allowlisted, got: {:?}",
1567 errors
1568 );
1569 }
1570
1571 #[test]
1572 fn test_typescript_rejects_any_before_closing_angle_bracket() {
1573 let code = r#"export async function listUsers(): Promise<Array<any>> {
1578 return [];
1579}
1580"#;
1581 let errors = validate_structural(code, "typescript-postgres");
1582 assert!(
1583 errors.iter().any(|e| e.contains("any")),
1584 "expected an `any` violation, got: {:?}",
1585 errors
1586 );
1587 }
1588
1589 #[test]
1590 fn test_go_valid() {
1591 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";
1592 let errors = validate_structural(code, "go-pgx");
1593 assert!(errors.is_empty(), "expected no errors, got: {:?}", errors);
1594 }
1595
1596 #[test]
1597 fn test_php_valid() {
1598 let code = r#"<?php
1599
1600declare(strict_types=1);
1601
1602readonly class ListUsersRow {
1603 public function __construct(
1604 public int $id,
1605 public string $name,
1606 ) {}
1607}
1608
1609function listUsers($pdo): array {
1610 return [];
1611}
1612"#;
1613 let errors = validate_structural(code, "php-pdo");
1614 assert!(errors.is_empty(), "expected no errors, got: {:?}", errors);
1615 }
1616
1617 #[test]
1622 fn a_missing_tool_is_not_the_same_as_a_clean_run() {
1623 let clean = ToolValidation::Attempted(vec![ToolOutcome::Ran {
1624 tool: "ruff",
1625 errors: vec![],
1626 }]);
1627 let absent = ToolValidation::Attempted(vec![ToolOutcome::Missing { tool: "ruff" }]);
1628
1629 assert!(clean.errors().is_empty());
1632 assert!(absent.errors().is_empty());
1633
1634 assert!(clean.fully_checked(), "a tool that ran is a real check");
1636 assert!(!absent.fully_checked(), "a tool that never ran checked nothing");
1637 assert_eq!(absent.missing_tools(), vec!["ruff"]);
1638 assert!(clean.missing_tools().is_empty());
1639 }
1640
1641 #[test]
1642 fn strict_mode_turns_a_missing_tool_into_a_failure() {
1643 let absent = ToolValidation::Attempted(vec![ToolOutcome::Missing { tool: "poly" }]);
1644
1645 assert!(
1646 absent.clone().into_result_with_strictness(false).is_ok(),
1647 "outside strict mode a missing tool is tolerated"
1648 );
1649
1650 let failures = absent
1651 .into_result_with_strictness(true)
1652 .expect_err("strict mode must fail on a missing tool");
1653 assert_eq!(failures.len(), 1);
1654 assert!(
1655 failures[0].contains("poly") && failures[0].contains("not installed"),
1656 "the failure must name the tool and say it never ran: {failures:?}"
1657 );
1658 }
1659
1660 #[test]
1665 fn a_partially_run_validation_is_not_fully_checked() {
1666 let partial = ToolValidation::Attempted(vec![
1667 ToolOutcome::Ran {
1668 tool: "python3",
1669 errors: vec![],
1670 },
1671 ToolOutcome::Missing { tool: "ruff" },
1672 ]);
1673
1674 assert!(partial.errors().is_empty());
1675 assert!(!partial.fully_checked(), "one of two tools running is not a full check");
1676 assert_eq!(partial.tools_run(), vec!["python3"]);
1677 assert_eq!(partial.missing_tools(), vec!["ruff"]);
1678 assert!(partial.into_result_with_strictness(true).is_err());
1679 }
1680
1681 #[test]
1686 fn poly_backed_languages_report_exactly_one_tool() {
1687 for (label, outcomes) in [
1688 ("python", validate_python_tools("x = 1\n")),
1689 ("typescript", validate_typescript_tools("export const x = 1;\n")),
1690 ("php", validate_php_tools("<?php\ndeclare(strict_types=1);\n")),
1691 ] {
1692 assert_eq!(outcomes.len(), 1, "{label} must be checked by poly alone");
1693 let tool = match &outcomes[0] {
1694 ToolOutcome::Ran { tool, .. } | ToolOutcome::Missing { tool } | ToolOutcome::Failed { tool, .. } => {
1695 *tool
1696 }
1697 };
1698 assert_eq!(tool, "poly", "{label} must go through poly");
1699 }
1700 }
1701
1702 #[test]
1708 fn a_backend_with_no_validator_is_unsupported_not_clean() {
1709 let validation = validate_with_tools("public class Foo {}", "csharp-npgsql");
1710
1711 assert_eq!(validation, ToolValidation::Unsupported);
1712 assert!(validation.errors().is_empty());
1713 assert!(!validation.fully_checked(), "no validator means nothing was checked");
1714 assert!(validation.clone().into_result_with_strictness(false).is_ok());
1715 assert!(
1716 validation.into_result_with_strictness(true).is_ok(),
1717 "strict mode is for tools that can be installed; a language with no \
1718 validator is tracked by an inventory test instead -- see \
1719 into_result_with_strictness"
1720 );
1721 }
1722
1723 #[test]
1727 fn a_harness_failure_is_an_error_even_outside_strict_mode() {
1728 let broken = ToolValidation::Attempted(vec![ToolOutcome::Failed {
1729 tool: "gofmt",
1730 reason: "gofmt: could not be executed: permission denied".into(),
1731 }]);
1732
1733 assert!(!broken.fully_checked());
1734 let failures = broken
1735 .into_result_with_strictness(false)
1736 .expect_err("a broken harness is never a pass");
1737 assert!(failures[0].contains("gofmt"));
1738 }
1739
1740 #[test]
1741 fn strict_mode_reads_the_documented_environment_variable() {
1742 assert_eq!(STRICT_ENV_VAR, "SCYTHE_VALIDATE_STRICT");
1744 }
1745}