nounsql 0.1.3

NounSQL: a DSL for database schema design, compiled to SQL DDL.
use std::io::{Read, Write};
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use nounsql_core::{Diagnostic, Severity, codegen, diag, dialect, parse, resolve};

#[derive(Parser)]
#[command(name = "nounsql", version, about = "DB設計用DSLコンパイラ")]
struct Cli {
    #[command(subcommand)]
    command: Command,

    /// 出力ターゲット
    #[arg(long, global = true, default_value = "postgres")]
    dialect: String,

    /// 出力先。省略すると標準出力に書く
    #[arg(short, long, global = true, value_name = "PATH")]
    output: Option<PathBuf>,

    /// 警告があっても失敗させる
    #[arg(long, global = true)]
    deny_warnings: bool,
}

#[derive(Subcommand)]
enum Command {
    /// 解析と検証だけ行い、診断を表示する
    Check { input: PathBuf },
    /// 構文木をデバッグ出力する
    Ast { input: PathBuf },
    /// 中間表現(解決済みスキーマ)を出力する
    Ir {
        input: PathBuf,
        /// JSON で出力する。外部のコード生成に渡すときはこちら
        #[arg(long)]
        json: bool,
    },
    /// DDL を出力する
    Sql { input: PathBuf },
}

impl Command {
    fn input(&self) -> &Path {
        match self {
            Command::Check { input }
            | Command::Ast { input }
            | Command::Ir { input, .. }
            | Command::Sql { input } => input,
        }
    }
}

fn main() -> Result<()> {
    let cli = Cli::parse();

    let Some(dialect) = dialect::by_name(&cli.dialect) else {
        anyhow::bail!(
            "知らない dialect `{}`。使えるのは {}",
            cli.dialect,
            dialect::names().join(" / ")
        );
    };

    let input = cli.command.input();
    let (src, path) = read_input(input)?;

    let (doc, mut diags) = parse(&src);
    let mut out = String::new();

    if count(&diags, Severity::Error) == 0 {
        let (schema, mut resolved) = resolve(&doc, dialect);
        let has_error = count(&resolved, Severity::Error) > 0;
        diags.append(&mut resolved);

        match &cli.command {
            Command::Ast { .. } => out = format!("{doc:#?}\n"),
            Command::Ir { json, .. } => {
                out = if *json {
                    let mut text = serde_json::to_string_pretty(&schema)
                        .context("中間表現を JSON にできない")?;
                    text.push('\n');
                    text
                } else {
                    format!("{schema:#?}\n")
                }
            }
            Command::Sql { .. } if !has_error => out = codegen::emit(dialect, &schema),
            Command::Check { .. } if !has_error => {
                out = format!(
                    "ok: {} tables, {} columns\n",
                    schema.tables.len(),
                    schema.tables.iter().map(|t| t.columns.len()).sum::<usize>()
                )
            }
            _ => {}
        }
    } else if let Command::Ast { .. } = cli.command {
        // 構文エラーがあっても、そこまで組み立てた木は見せる。
        out = format!("{doc:#?}\n");
    }

    if !diags.is_empty() {
        eprint!("{}", diag::render(&src, &path, &diags));
    }
    if !out.is_empty() {
        write_output(cli.output.as_deref(), &out)?;
    }

    let errors = count(&diags, Severity::Error);
    let warnings = count(&diags, Severity::Warning);
    if errors > 0 || (cli.deny_warnings && warnings > 0) {
        if errors == 0 {
            eprintln!("error: 警告が {warnings} 件ある(--deny-warnings)");
        }
        std::process::exit(1);
    }
    Ok(())
}

fn count(diags: &[Diagnostic], severity: Severity) -> usize {
    diags.iter().filter(|d| d.severity == severity).count()
}

/// `-` なら標準入力から読む。
fn read_input(path: &Path) -> Result<(String, String)> {
    if path == Path::new("-") {
        let mut src = String::new();
        std::io::stdin()
            .read_to_string(&mut src)
            .context("標準入力を読めない")?;
        return Ok((src, "<stdin>".into()));
    }
    let name = path.display().to_string();
    let src = std::fs::read_to_string(path).with_context(|| format!("読めない: {name}"))?;
    Ok((src, name))
}

fn write_output(path: Option<&Path>, text: &str) -> Result<()> {
    match path {
        Some(path) => {
            if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
                std::fs::create_dir_all(dir)
                    .with_context(|| format!("作れない: {}", dir.display()))?;
            }
            std::fs::write(path, text).with_context(|| format!("書けない: {}", path.display()))
        }
        None => {
            let mut stdout = std::io::stdout().lock();
            stdout.write_all(text.as_bytes())?;
            stdout.flush().map_err(Into::into)
        }
    }
}