Skip to main content

scythe_codegen/
validation.rs

1use std::process::Command;
2
3/// Validate generated code structurally for a given backend.
4/// Returns a list of errors (empty = passed).
5pub 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            validate_python(code)
9        }
10        "typescript-postgres"
11        | "typescript-pg"
12        | "typescript-mysql2"
13        | "typescript-better-sqlite3"
14        | "typescript-duckdb" => validate_typescript(code),
15        "go-pgx" | "go-database-sql" => validate_go(code),
16        "java-jdbc" => validate_java(code),
17        "java-r2dbc" => validate_java_r2dbc(code),
18        "kotlin-exposed" => validate_kotlin_exposed(code),
19        "kotlin-jdbc" => validate_kotlin(code),
20        "kotlin-r2dbc" => validate_kotlin_r2dbc(code),
21        "csharp-npgsql" => validate_csharp(code),
22        "elixir-postgrex" | "elixir-ecto" => validate_elixir(code),
23        "ruby-pg" | "ruby-mysql2" | "ruby-sqlite3" | "ruby-trilogy" => validate_ruby(code),
24        "php-pdo" | "php-amphp" => validate_php(code),
25        // Rust backends are validated by syn, not here.
26        "rust-sqlx" | "rust-tokio-postgres" => vec![],
27        _ => vec![format!("unknown backend: {}", backend_name)],
28    }
29}
30
31fn validate_python(code: &str) -> Vec<String> {
32    let mut errors = Vec::new();
33
34    // Check for pre-3.10 typing imports (should NOT be used)
35    if code.contains("from __future__ import annotations") {
36        errors.push("unnecessary `from __future__ import annotations` — target is Python 3.10+".into());
37    }
38
39    let has_struct = code.contains("@dataclass")
40        || code.contains("(BaseModel)")
41        || code.contains("(msgspec.Struct)")
42        || code.contains("class ");
43    if !has_struct {
44        // No struct -- at least a function must be present.
45        if !code.contains("async def ") && !code.contains("def ") {
46            errors.push("missing `@dataclass`/`class` and `def ` -- no meaningful output".into());
47        }
48    }
49
50    if !code.contains("async def ") && !code.contains("def ") {
51        errors.push("missing `async def ` or `def ` (for query functions)".into());
52    }
53
54    if code.contains("from typing import Union") {
55        errors.push("contains `from typing import Union` (pre-3.10 style)".into());
56    }
57
58    if code.contains("from typing import Optional") {
59        errors.push("contains `from typing import Optional` (pre-3.10 style)".into());
60    }
61
62    if code.contains("List[") {
63        errors.push("contains `List[` (use lowercase `list[`)".into());
64    }
65
66    if code.contains("Dict[") {
67        errors.push("contains `Dict[` (use lowercase `dict[`)".into());
68    }
69
70    // Check for proper indentation: 4 spaces, no tabs
71    for (i, line) in code.lines().enumerate() {
72        if line.starts_with('\t') {
73            errors.push(format!("line {} uses tab indentation (should use 4 spaces)", i + 1));
74            break; // one error is enough
75        }
76    }
77
78    errors
79}
80
81fn validate_typescript(code: &str) -> Vec<String> {
82    let mut errors = Vec::new();
83
84    let has_function = code.contains("export async function") || code.contains("export function");
85
86    // Structs are only required when the code is NOT exec-only (i.e. when
87    // there is something beyond a bare function).
88    let has_zod = code.contains("z.object(") || code.contains("z.infer<");
89    if !code.contains("export interface") && !code.contains("export type") && !has_zod && !has_function {
90        errors.push("missing `export interface` or `export type` (for DTOs)".into());
91    }
92
93    if !has_function {
94        errors.push("missing `export async function` or `export function`".into());
95    }
96
97    // Check for `any` type usage -- but avoid false positives in words like "many"
98    for line in code.lines() {
99        let trimmed = line.trim();
100        // Look for `: any` or `<any>` or `any;` or `any,` patterns
101        if trimmed.contains(": any")
102            || trimmed.contains("<any>")
103            || trimmed.contains("any;")
104            || trimmed.contains("any,")
105            || trimmed.contains("any)")
106        {
107            errors.push(format!(
108                "contains `any` type (should use `unknown` or specific): {}",
109                trimmed
110            ));
111            break;
112        }
113    }
114
115    errors
116}
117
118fn validate_go(code: &str) -> Vec<String> {
119    let mut errors = Vec::new();
120
121    let has_func = code.contains("func ");
122    let has_struct = code.contains("type ") && code.contains("struct {");
123
124    // Structs are only required when the code has one; exec-only queries
125    // produce just a function.
126    if !has_struct && !has_func {
127        errors.push("missing `type ... struct {` (for structs)".into());
128    }
129
130    if !has_func {
131        errors.push("missing `func ` (for functions)".into());
132    }
133
134    if !code.contains("context.Context") {
135        errors.push("missing `context.Context` as first param".into());
136    }
137
138    // Go uses tabs for indentation
139    let has_indented_lines = code.lines().any(|l| l.starts_with('\t') || l.starts_with("  "));
140    if has_indented_lines {
141        let uses_spaces = code.lines().any(|l| l.starts_with("    ") && !l.trim().is_empty());
142        if uses_spaces {
143            errors.push("uses space indentation (Go standard is tabs)".into());
144        }
145    }
146
147    // json tags only required when struct is present
148    if has_struct && !code.contains("json:\"") {
149        errors.push("missing `json:\"` tags on struct fields".into());
150    }
151
152    errors
153}
154
155fn validate_java(code: &str) -> Vec<String> {
156    let mut errors = Vec::new();
157
158    let has_static = code.contains("public static ");
159
160    // Records are only required when a struct was generated; exec-only
161    // queries produce just a method.
162    if !code.contains("public record ") && !has_static {
163        errors.push("missing `public record ` (for DTOs)".into());
164    }
165
166    if !has_static {
167        errors.push("missing `public static ` (for query methods)".into());
168    }
169
170    if !code.contains("throws SQLException") {
171        errors.push("missing `throws SQLException`".into());
172    }
173
174    if !code.contains("try (") {
175        errors.push("missing `try (` (try-with-resources)".into());
176    }
177
178    errors
179}
180
181fn validate_kotlin(code: &str) -> Vec<String> {
182    let mut errors = Vec::new();
183
184    let has_fun = code.contains("fun ");
185
186    // data class only required when a struct was generated
187    if !code.contains("data class ") && !has_fun {
188        errors.push("missing `data class ` (for DTOs)".into());
189    }
190
191    if !has_fun {
192        errors.push("missing `fun ` (for functions)".into());
193    }
194
195    if !code.contains(".use {") {
196        errors.push("missing `.use {` (resource management)".into());
197    }
198
199    errors
200}
201
202fn validate_kotlin_exposed(code: &str) -> Vec<String> {
203    let mut errors = Vec::new();
204
205    let has_fun = code.contains("fun ");
206
207    // data class or object Table only required when a struct was generated
208    if !code.contains("data class ") && !code.contains("object ") && !has_fun {
209        errors.push("missing `data class ` or `object ` (for DTOs/Tables)".into());
210    }
211
212    if !has_fun {
213        errors.push("missing `fun ` (for functions)".into());
214    }
215
216    if !code.contains("transaction {") {
217        errors.push("missing `transaction {` (Exposed transaction block)".into());
218    }
219
220    errors
221}
222
223fn validate_java_r2dbc(code: &str) -> Vec<String> {
224    let mut errors = Vec::new();
225
226    let has_static = code.contains("public static ");
227
228    if !code.contains("public record ") && !has_static {
229        errors.push("missing `public record ` (for DTOs)".into());
230    }
231
232    if !has_static {
233        errors.push("missing `public static ` (for query methods)".into());
234    }
235
236    if !code.contains("Mono<") && !code.contains("Flux<") {
237        errors.push("missing `Mono<` or `Flux<` (reactive types)".into());
238    }
239
240    if !code.contains("ConnectionFactory") {
241        errors.push("missing `ConnectionFactory` parameter".into());
242    }
243
244    errors
245}
246
247fn validate_kotlin_r2dbc(code: &str) -> Vec<String> {
248    let mut errors = Vec::new();
249
250    let has_fun = code.contains("fun ");
251
252    if !code.contains("data class ") && !has_fun {
253        errors.push("missing `data class ` (for DTOs)".into());
254    }
255
256    if !has_fun {
257        errors.push("missing `fun ` (for functions)".into());
258    }
259
260    if !code.contains("ConnectionFactory") {
261        errors.push("missing `ConnectionFactory` parameter".into());
262    }
263
264    // Should use either suspend fun or Flow
265    if !code.contains("suspend fun") && !code.contains("Flow<") {
266        errors.push("missing `suspend fun` or `Flow<` (coroutine/reactive types)".into());
267    }
268
269    errors
270}
271
272fn validate_csharp(code: &str) -> Vec<String> {
273    let mut errors = Vec::new();
274
275    let has_async = code.contains("async Task<") || code.contains("async Task ");
276
277    // Records are only required when a struct was generated
278    if !code.contains("public record ") && !has_async {
279        errors.push("missing `public record ` (for DTOs)".into());
280    }
281
282    if !has_async {
283        errors.push("missing `async Task<` or `async Task` (for async methods)".into());
284    }
285
286    if !code.contains("await ") {
287        errors.push("missing `await `".into());
288    }
289
290    errors
291}
292
293fn validate_elixir(code: &str) -> Vec<String> {
294    let mut errors = Vec::new();
295
296    let has_def = code.contains("def ") || code.contains("defp ");
297
298    // defmodule is only required when a struct was generated; exec-only
299    // queries produce just a function.
300    if !code.contains("defmodule ") && !has_def {
301        errors.push("missing `defmodule ` (for modules)".into());
302    }
303
304    // defstruct is only required when a struct was generated
305    if !code.contains("defstruct") && !has_def {
306        errors.push("missing `defstruct` (for structs)".into());
307    }
308
309    if !has_def {
310        errors.push("missing `def ` or `defp ` (for functions)".into());
311    }
312
313    if !code.contains("@type ") && !code.contains("@spec ") {
314        errors.push("missing `@type ` or `@spec ` (for typespecs)".into());
315    }
316
317    errors
318}
319
320fn validate_ruby(code: &str) -> Vec<String> {
321    let mut errors = Vec::new();
322
323    let has_method = code.contains("def self.");
324
325    // Data.define only required when a struct was generated
326    if !code.contains("Data.define") && !has_method {
327        errors.push("missing `Data.define` (for DTOs)".into());
328    }
329
330    if !has_method {
331        errors.push("missing `def self.` (for module methods)".into());
332    }
333
334    if !code.contains("# frozen_string_literal: true") {
335        errors.push("missing `# frozen_string_literal: true`".into());
336    }
337
338    if !code.contains("module Queries") {
339        errors.push("missing `module Queries` wrapper".into());
340    }
341
342    errors
343}
344
345fn validate_php(code: &str) -> Vec<String> {
346    let mut errors = Vec::new();
347
348    let has_function = code.contains("function ");
349
350    // readonly class only required when a struct was generated
351    if !code.contains("readonly class ") && !has_function {
352        errors.push("missing `readonly class ` (for DTOs)".into());
353    }
354
355    if !has_function {
356        errors.push("missing `function ` (for query functions)".into());
357    }
358
359    if !code.contains("declare(strict_types=1)") {
360        errors.push("missing `declare(strict_types=1)`".into());
361    }
362
363    if !code.contains("<?php") {
364        errors.push("missing `<?php`".into());
365    }
366
367    errors
368}
369
370/// Validate generated code using real language tools (if available).
371/// Returns None if the tool is not installed, Some(errors) otherwise.
372pub fn validate_with_tools(code: &str, backend_name: &str) -> Option<Vec<String>> {
373    match backend_name {
374        name if name.starts_with("python") => validate_python_tools(code),
375        name if name.starts_with("typescript") => validate_typescript_tools(code),
376        name if name.starts_with("go") => validate_go_tools(code),
377        name if name.starts_with("ruby") => validate_ruby_tools(code),
378        name if name.starts_with("php") => validate_php_tools(code),
379        name if name.starts_with("kotlin") => validate_kotlin_tools(code),
380        _ => None,
381    }
382}
383
384fn write_temp(code: &str, ext: &str) -> Option<std::path::PathBuf> {
385    use std::sync::atomic::{AtomicU64, Ordering};
386    static COUNTER: AtomicU64 = AtomicU64::new(0);
387    let n = COUNTER.fetch_add(1, Ordering::Relaxed);
388    let basename = if ext == ".kt" {
389        format!("ScytheValidate{n}")
390    } else {
391        format!("scythe_validate_{n}")
392    };
393    let path = std::env::temp_dir().join(format!("{basename}{ext}"));
394    // Trim trailing whitespace/newlines to avoid tool complaints about blank lines at EOF
395    let trimmed = format!("{}\n", code.trim_end());
396    std::fs::write(&path, trimmed).ok()?;
397    Some(path)
398}
399
400fn validate_python_tools(code: &str) -> Option<Vec<String>> {
401    if Command::new("python3").arg("--version").output().is_err() {
402        return None;
403    }
404    let path = write_temp(code, ".py")?;
405    let mut errors = vec![];
406
407    // ast.parse — syntax check
408    let out = Command::new("python3")
409        .args(["-c", &format!("import ast; ast.parse(open({:?}).read())", path)])
410        .output()
411        .ok()?;
412    if !out.status.success() {
413        errors.push(format!(
414            "python syntax: {}",
415            String::from_utf8_lossy(&out.stderr).lines().next().unwrap_or("")
416        ));
417    }
418
419    // ruff check
420    if Command::new("ruff").arg("--version").output().is_ok() {
421        let out = Command::new("ruff")
422            .args([
423                "check",
424                "--select",
425                "E,F,I",
426                "--target-version",
427                "py310",
428                path.to_str()?,
429            ])
430            .output()
431            .ok()?;
432        if !out.status.success() {
433            for line in String::from_utf8_lossy(&out.stdout).lines().take(3) {
434                if !line.trim().is_empty() {
435                    errors.push(format!("ruff: {line}"));
436                }
437            }
438        }
439    }
440
441    let _ = std::fs::remove_file(&path);
442    Some(errors)
443}
444
445fn validate_typescript_tools(code: &str) -> Option<Vec<String>> {
446    if Command::new("biome").arg("--version").output().is_err() {
447        return None;
448    }
449    let path = write_temp(code, ".ts")?;
450    let mut errors = vec![];
451
452    let out = Command::new("biome")
453        .args(["check", "--no-errors-on-unmatched", path.to_str()?])
454        .output()
455        .ok()?;
456    if !out.status.success() {
457        for line in String::from_utf8_lossy(&out.stderr).lines().take(3) {
458            if !line.trim().is_empty() {
459                errors.push(format!("biome: {line}"));
460            }
461        }
462    }
463
464    let _ = std::fs::remove_file(&path);
465    Some(errors)
466}
467
468fn validate_go_tools(code: &str) -> Option<Vec<String>> {
469    if Command::new("gofmt").arg("-h").output().is_err() {
470        return None;
471    }
472    let path = write_temp(code, ".go")?;
473    let mut errors = vec![];
474
475    let out = Command::new("gofmt").args(["-e", path.to_str()?]).output().ok()?;
476    if !out.status.success() {
477        for line in String::from_utf8_lossy(&out.stderr).lines().take(3) {
478            if !line.trim().is_empty() {
479                errors.push(format!("gofmt: {line}"));
480            }
481        }
482    }
483
484    let _ = std::fs::remove_file(&path);
485    Some(errors)
486}
487
488fn validate_ruby_tools(code: &str) -> Option<Vec<String>> {
489    if Command::new("ruby").arg("--version").output().is_err() {
490        return None;
491    }
492    let path = write_temp(code, ".rb")?;
493    let mut errors = vec![];
494
495    let out = Command::new("ruby").args(["-c", path.to_str()?]).output().ok()?;
496    if !out.status.success() {
497        errors.push(format!(
498            "ruby syntax: {}",
499            String::from_utf8_lossy(&out.stderr).lines().next().unwrap_or("")
500        ));
501    }
502
503    let _ = std::fs::remove_file(&path);
504    Some(errors)
505}
506
507fn validate_php_tools(code: &str) -> Option<Vec<String>> {
508    if Command::new("php").arg("--version").output().is_err() {
509        return None;
510    }
511    let path = write_temp(code, ".php")?;
512    let mut errors = vec![];
513
514    let out = Command::new("php").args(["-l", path.to_str()?]).output().ok()?;
515    if !out.status.success() {
516        errors.push(format!(
517            "php syntax: {}",
518            String::from_utf8_lossy(&out.stdout).lines().next().unwrap_or("")
519        ));
520    }
521
522    let _ = std::fs::remove_file(&path);
523    Some(errors)
524}
525
526fn validate_kotlin_tools(code: &str) -> Option<Vec<String>> {
527    if Command::new("ktlint").arg("--version").output().is_err() {
528        return None;
529    }
530    let path = write_temp(code, ".kt")?;
531    let mut errors = vec![];
532
533    let out = Command::new("ktlint")
534        .args(["--log-level=error", path.to_str()?])
535        .output()
536        .ok()?;
537    if !out.status.success() {
538        for line in String::from_utf8_lossy(&out.stdout).lines().take(3) {
539            if !line.trim().is_empty() {
540                errors.push(format!("ktlint: {line}"));
541            }
542        }
543    }
544
545    let _ = std::fs::remove_file(&path);
546    Some(errors)
547}
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552
553    #[test]
554    fn test_unknown_backend() {
555        let errors = validate_structural("some code", "unknown-backend");
556        assert_eq!(errors.len(), 1);
557        assert!(errors[0].contains("unknown backend"));
558    }
559
560    #[test]
561    fn test_rust_backends_skip() {
562        assert!(validate_structural("anything", "rust-sqlx").is_empty());
563        assert!(validate_structural("anything", "rust-tokio-postgres").is_empty());
564    }
565
566    #[test]
567    fn test_python_valid() {
568        let code = r#"from dataclasses import dataclass
569
570@dataclass
571class ListUsersRow:
572    id: int
573    name: str
574
575async def list_users(conn) -> list[ListUsersRow]:
576    pass
577"#;
578        let errors = validate_structural(code, "python-psycopg3");
579        assert!(errors.is_empty(), "expected no errors, got: {:?}", errors);
580    }
581
582    #[test]
583    fn test_python_invalid_typing() {
584        let code = r#"from typing import Optional
585
586@dataclass
587class Row:
588    id: int
589
590def query() -> List[Row]:
591    pass
592"#;
593        let errors = validate_structural(code, "python-asyncpg");
594        assert!(errors.iter().any(|e| e.contains("Optional")));
595        assert!(errors.iter().any(|e| e.contains("List[")));
596    }
597
598    #[test]
599    fn test_typescript_valid() {
600        let code = r#"export interface ListUsersRow {
601  id: number;
602  name: string;
603}
604
605export async function listUsers(): Promise<ListUsersRow[]> {
606  // ...
607}
608"#;
609        let errors = validate_structural(code, "typescript-postgres");
610        assert!(errors.is_empty(), "expected no errors, got: {:?}", errors);
611    }
612
613    #[test]
614    fn test_go_valid() {
615        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";
616        let errors = validate_structural(code, "go-pgx");
617        assert!(errors.is_empty(), "expected no errors, got: {:?}", errors);
618    }
619
620    #[test]
621    fn test_php_valid() {
622        let code = r#"<?php
623
624declare(strict_types=1);
625
626readonly class ListUsersRow {
627    public function __construct(
628        public int $id,
629        public string $name,
630    ) {}
631}
632
633function listUsers($pdo): array {
634    return [];
635}
636"#;
637        let errors = validate_structural(code, "php-pdo");
638        assert!(errors.is_empty(), "expected no errors, got: {:?}", errors);
639    }
640}