udb 0.3.1

Universal Data Broker — a Rust gRPC broker over multiple databases (Postgres, MySQL, SQLite, MongoDB, ClickHouse, Cassandra, MSSQL, Redis, Qdrant, S3, Neo4j, …) with per-tenant RLS, 2PC, sagas, and CDC.
Documentation
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};

use crate::ast::{ProtoFileAst, ProtoSchema};

mod ast_parser;
mod db_parser;
pub mod lexer;
mod naming;
mod options;
mod paths;
mod selection;
mod structure;

use ast_parser::ProtoAstParser;
use db_parser::ProtoParser;
use lexer::{LexError, Lexer};
use selection::dedupe_canonical_table_schemas;

#[cfg(test)]
use naming::{infer_sql_type, to_plural, to_snake_case};

pub const UDB_ANNOTATION_VERSION: &str = "1";

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AnnotationParserMode {
    /// Preserve legacy aliases and path/default naming conventions silently.
    Compat,
    /// Preserve compatibility, but emit diagnostics for non-canonical contract use.
    Warn,
    /// Emit diagnostics for every non-canonical contract use.
    Strict,
}

#[derive(Debug, Clone)]
pub struct ParserConfig {
    pub proto_namespace: String,
    pub annotation_mode: AnnotationParserMode,
    pub expected_annotation_version: String,
}

impl ParserConfig {
    pub fn new(proto_namespace: impl Into<String>) -> Self {
        Self {
            proto_namespace: proto_namespace.into(),
            annotation_mode: AnnotationParserMode::Compat,
            expected_annotation_version: UDB_ANNOTATION_VERSION.to_string(),
        }
    }

    pub fn with_annotation_mode(mut self, mode: AnnotationParserMode) -> Self {
        self.annotation_mode = mode;
        self
    }

    pub fn with_expected_annotation_version(mut self, version: impl Into<String>) -> Self {
        self.expected_annotation_version = version.into();
        self
    }
}

impl Default for ParserConfig {
    fn default() -> Self {
        Self::new("")
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParserDiagnostic {
    pub file: String,
    pub line: usize,
    pub column: usize,
    pub code: String,
    pub message: String,
}

impl fmt::Display for ParserDiagnostic {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "{}:{}:{} [{}]: {}",
            self.file, self.line, self.column, self.code, self.message
        )
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ParseReport {
    pub schemas: Vec<ProtoSchema>,
    pub diagnostics: Vec<ParserDiagnostic>,
}

impl ParseReport {
    pub fn passed(&self) -> bool {
        self.diagnostics.is_empty()
    }
}

#[derive(Debug)]
pub enum ParseError {
    Io {
        path: PathBuf,
        source: std::io::Error,
    },
    Lex(LexError),
    Syntax {
        file: String,
        line: usize,
        column: usize,
        message: String,
    },
    Directory {
        path: PathBuf,
        errors: Vec<String>,
    },
}

impl fmt::Display for ParseError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Io { path, source } => write!(f, "cannot read {}: {source}", path.display()),
            Self::Lex(source) => write!(f, "{source}"),
            Self::Syntax {
                file,
                line,
                column,
                message,
            } => write!(f, "{file}:{line}:{column}: {message}"),
            Self::Directory { path, errors } => write!(
                f,
                "parse_directory {}: {} file(s) failed:\n  {}",
                path.display(),
                errors.len(),
                errors.join("\n  ")
            ),
        }
    }
}

impl std::error::Error for ParseError {}

pub fn parse_directory(
    dir_path: impl AsRef<Path>,
    config: &ParserConfig,
) -> Result<Vec<ProtoSchema>, ParseError> {
    parse_directory_report(dir_path, config).map(|report| report.schemas)
}

pub fn parse_directory_report(
    dir_path: impl AsRef<Path>,
    config: &ParserConfig,
) -> Result<ParseReport, ParseError> {
    let dir_path = dir_path.as_ref();
    let mut paths = Vec::new();
    collect_proto_files(dir_path, &mut paths).map_err(|source| ParseError::Io {
        path: dir_path.to_path_buf(),
        source,
    })?;
    paths.sort();

    let mut schemas = Vec::new();
    let mut diagnostics = Vec::new();
    let mut errors = Vec::new();
    for path in paths {
        match parse_file_report(&path, config) {
            Ok(mut report) => {
                schemas.append(&mut report.schemas);
                diagnostics.append(&mut report.diagnostics);
            }
            Err(err) => errors.push(format!("{}: {err}", path.display())),
        }
    }

    let schemas = dedupe_canonical_table_schemas(schemas);
    if errors.is_empty() {
        Ok(ParseReport {
            schemas,
            diagnostics,
        })
    } else {
        Err(ParseError::Directory {
            path: dir_path.to_path_buf(),
            errors,
        })
    }
}

pub fn parse_file(
    file_path: impl AsRef<Path>,
    config: &ParserConfig,
) -> Result<Vec<ProtoSchema>, ParseError> {
    parse_file_report(file_path, config).map(|report| report.schemas)
}

/// Parse proto source held in memory (e.g. embedded native-service protos),
/// rather than reading from disk. `name` is the logical file name used in
/// diagnostics. Shares the same lex/parse path as [`parse_file_report`].
pub fn parse_proto_source(
    source: &[u8],
    name: impl Into<String>,
    config: &ParserConfig,
) -> Result<ParseReport, ParseError> {
    let file = name.into();
    let tokens = Lexer::new(source, file.clone())
        .tokenize()
        .map_err(ParseError::Lex)?;
    ProtoParser::new(tokens, file, config).parse_report()
}

pub fn parse_file_report(
    file_path: impl AsRef<Path>,
    config: &ParserConfig,
) -> Result<ParseReport, ParseError> {
    let file_path = file_path.as_ref();
    let source = fs::read(file_path).map_err(|source| ParseError::Io {
        path: file_path.to_path_buf(),
        source,
    })?;
    let file = file_path.to_string_lossy().to_string();
    let tokens = Lexer::new(&source, file.clone())
        .tokenize()
        .map_err(ParseError::Lex)?;
    ProtoParser::new(tokens, file, config).parse_report()
}

pub fn parse_ast_file(file_path: impl AsRef<Path>) -> Result<ProtoFileAst, ParseError> {
    let file_path = file_path.as_ref();
    let source = fs::read(file_path).map_err(|source| ParseError::Io {
        path: file_path.to_path_buf(),
        source,
    })?;
    let file = file_path.to_string_lossy().to_string();
    parse_ast_source(&source, file)
}

pub fn parse_ast_source(
    source: &[u8],
    file: impl Into<String>,
) -> Result<ProtoFileAst, ParseError> {
    let file = file.into();
    let tokens = Lexer::new(source, file.clone())
        .tokenize()
        .map_err(ParseError::Lex)?;
    ProtoAstParser::new(tokens, file).parse()
}

fn collect_proto_files(dir: &Path, paths: &mut Vec<PathBuf>) -> Result<(), std::io::Error> {
    for entry in fs::read_dir(dir)? {
        let entry = entry?;
        let path = entry.path();
        if path.is_dir() {
            collect_proto_files(&path, paths)?;
        } else if path.extension().and_then(|ext| ext.to_str()) == Some("proto") {
            paths.push(path);
        }
    }
    Ok(())
}

#[cfg(test)]
#[path = "tests.rs"]
mod parser_tests;