scythe_codegen/
validation.rs1use 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 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-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 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 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 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; }
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 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 for line in code.lines() {
99 let trimmed = line.trim();
100 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 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 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 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 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 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 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 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 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 if !code.contains("defmodule ") && !has_def {
301 errors.push("missing `defmodule ` (for modules)".into());
302 }
303
304 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 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 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
370pub 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 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 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 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}