horkos 0.2.0

Cloud infrastructure language where insecure code won't compile
Documentation
//! # Horkos
//!
//! Infrastructure language where insecure code won't compile.
//!
//! Horkos is a statically typed language that compiles to Terraform HCL,
//! enforcing security invariants at compile time.
//!
//! ## Quick Start
//!
//! ```ignore
//! use horkos::{compile_source, CompileOptions};
//!
//! let source = r#"
//!     val bucket = S3.createBucket("my-data")
//! "#;
//!
//! let hcl = compile_source(source, &CompileOptions::default())?;
//! println!("{}", hcl);
//! ```

pub mod ast;
pub mod codegen;
pub mod errors;
pub mod lexer;
pub mod parser;
pub mod project;
pub mod resources;
pub mod stdlib;
pub mod types;

// Re-export project utilities
pub use project::{compile_project, Project, ProjectError};

// Re-export types for preferred overrides
pub use types::PreferredOverride;

use errors::Diagnostic;
use serde::{Deserialize, Serialize};
use std::path::Path;

/// Compilation options
#[derive(Debug, Clone)]
pub struct CompileOptions {
    /// Output directory for generated files
    pub output_dir: std::path::PathBuf,
    /// Target environment (production, staging, test)
    pub target: String,
    /// Output format
    pub format: OutputFormat,
    /// If true, don't write files
    pub dry_run: bool,
    /// Debug: print AST
    pub debug_ast: bool,
    /// Debug: print tokens
    pub debug_tokens: bool,
    /// Forbid Inline HCL blocks
    pub no_hcl: bool,
}

impl Default for CompileOptions {
    fn default() -> Self {
        Self {
            output_dir: std::path::PathBuf::from("."),
            target: "production".to_string(),
            format: OutputFormat::Hcl,
            dry_run: false,
            debug_ast: false,
            debug_tokens: false,
            no_hcl: false,
        }
    }
}

/// Output format for compilation
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum OutputFormat {
    #[default]
    Hcl,
    Json,
}

impl std::str::FromStr for OutputFormat {
    type Err = String;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s.to_lowercase().as_str() {
            "hcl" | "tf" => Ok(OutputFormat::Hcl),
            "json" => Ok(OutputFormat::Json),
            _ => Err(format!("unknown format: {} (expected 'hcl' or 'json')", s)),
        }
    }
}

/// Information about an unsafe block (for auditing)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UnsafeBlockInfo {
    pub file: String,
    pub line: usize,
    pub column: usize,
    pub reason: String,
}

/// Compile a Horkos source file to Terraform HCL.
///
/// Returns the generated HCL string on success, or a list of diagnostics on failure.
pub fn compile_file(path: &Path, options: &CompileOptions) -> Result<String, Vec<Diagnostic>> {
    let source = std::fs::read_to_string(path)
        .map_err(|e| vec![Diagnostic::error(format!("failed to read file: {}", e))])?;

    let filename = path.to_string_lossy().to_string();
    compile_source(&source, &filename, options)
}

// Re-export types for cross-file compilation
pub use types::{extract_exports, GlobalSymbolTable, ModuleExports};

/// Result of compilation, including output and any info messages.
#[derive(Debug)]
pub struct CompileResult {
    pub output: String,
    pub preferred_overrides: Vec<PreferredOverride>,
    pub unsafe_count: usize,
}

/// Compile Horkos source code to Terraform HCL.
pub fn compile_source(
    source: &str,
    filename: &str,
    options: &CompileOptions,
) -> Result<String, Vec<Diagnostic>> {
    let result = compile_source_with_globals(source, filename, options, None)?;
    Ok(result.output)
}

/// Compile Horkos source code with access to cross-file symbols.
///
/// Returns the generated HCL and any preferred param overrides.
pub fn compile_source_with_globals(
    source: &str,
    filename: &str,
    options: &CompileOptions,
    globals: Option<&GlobalSymbolTable>,
) -> Result<CompileResult, Vec<Diagnostic>> {
    // Phase 1: Lexing
    let tokens = lexer::tokenize(source, filename)?;

    if options.debug_tokens {
        eprintln!("=== Tokens ===");
        for token in &tokens {
            eprintln!("  {:?}", token);
        }
        eprintln!();
    }

    // Phase 2: Parsing
    let ast = parser::parse(tokens, source, filename)?;

    if options.debug_ast {
        eprintln!("=== AST ===");
        eprintln!("{:#?}", ast);
        eprintln!();
    }

    // Phase 3: Type checking & taint analysis (with cross-file support)
    let type_check_result =
        types::check_with_globals(&ast, source, filename, globals, options.no_hcl)?;

    // Phase 4: Code generation
    let output = match options.format {
        OutputFormat::Hcl => codegen::emit_hcl(&type_check_result.program, &options.target),
        OutputFormat::Json => codegen::emit_json(&type_check_result.program, &options.target),
    };

    // Count unsafe blocks
    let unsafe_count = type_check_result.program.count_unsafe_blocks();

    Ok(CompileResult {
        output,
        preferred_overrides: type_check_result.preferred_overrides,
        unsafe_count,
    })
}

/// Compile and return both HCL output, typed AST, and preferred overrides.
pub fn compile_and_extract(
    source: &str,
    filename: &str,
    options: &CompileOptions,
    globals: Option<&GlobalSymbolTable>,
) -> Result<(String, types::TypedProgram, Vec<PreferredOverride>), Vec<Diagnostic>> {
    let tokens = lexer::tokenize(source, filename)?;
    let ast = parser::parse(tokens, source, filename)?;
    let type_check_result =
        types::check_with_globals(&ast, source, filename, globals, options.no_hcl)?;

    let output = match options.format {
        OutputFormat::Hcl => codegen::emit_hcl(&type_check_result.program, &options.target),
        OutputFormat::Json => codegen::emit_json(&type_check_result.program, &options.target),
    };

    Ok((
        output,
        type_check_result.program,
        type_check_result.preferred_overrides,
    ))
}

/// Check a file for errors without generating output.
pub fn check_file(path: &Path) -> Result<(), Vec<Diagnostic>> {
    let source = std::fs::read_to_string(path)
        .map_err(|e| vec![Diagnostic::error(format!("failed to read file: {}", e))])?;

    let filename = path.to_string_lossy().to_string();
    check_source(&source, &filename)
}

/// Check source code for errors without generating output.
pub fn check_source(source: &str, filename: &str) -> Result<(), Vec<Diagnostic>> {
    let tokens = lexer::tokenize(source, filename)?;
    let ast = parser::parse(tokens, source, filename)?;
    let _typed_ast = types::check(&ast, source, filename, false)?;
    Ok(())
}

/// Find all unsafe blocks in a directory.
pub fn find_unsafe_blocks(path: &Path) -> Result<Vec<UnsafeBlockInfo>, String> {
    let mut results = Vec::new();

    if path.is_file() {
        if path.extension().is_some_and(|e| e == "hk") {
            results.extend(find_unsafe_in_file(path)?);
        }
    } else if path.is_dir() {
        for entry in walkdir(path)? {
            if entry.extension().is_some_and(|e| e == "le") {
                results.extend(find_unsafe_in_file(&entry)?);
            }
        }
    }

    Ok(results)
}

fn find_unsafe_in_file(path: &Path) -> Result<Vec<UnsafeBlockInfo>, String> {
    let source = std::fs::read_to_string(path)
        .map_err(|e| format!("failed to read {}: {}", path.display(), e))?;

    let filename = path.to_string_lossy().to_string();

    let tokens = lexer::tokenize(&source, &filename)
        .map_err(|d| format!("lex error in {}: {:?}", path.display(), d))?;

    let ast = parser::parse(tokens, &source, &filename)
        .map_err(|d| format!("parse error in {}: {:?}", path.display(), d))?;

    Ok(ast::find_unsafe_blocks(&ast)
        .into_iter()
        .map(|ub| UnsafeBlockInfo {
            file: filename.clone(),
            line: ub.line,
            column: ub.column,
            reason: ub.reason,
        })
        .collect())
}

fn walkdir(path: &Path) -> Result<Vec<std::path::PathBuf>, String> {
    let mut files = Vec::new();

    fn visit(dir: &Path, files: &mut Vec<std::path::PathBuf>) -> Result<(), String> {
        let entries = std::fs::read_dir(dir)
            .map_err(|e| format!("failed to read directory {}: {}", dir.display(), e))?;

        for entry in entries {
            let entry = entry.map_err(|e| e.to_string())?;
            let path = entry.path();

            if path.is_dir() {
                // Skip hidden directories and common non-source directories
                let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
                if !name.starts_with('.') && name != "node_modules" && name != "target" {
                    visit(&path, files)?;
                }
            } else {
                files.push(path);
            }
        }

        Ok(())
    }

    visit(path, &mut files)?;
    Ok(files)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_compile_options_default() {
        let opts = CompileOptions::default();
        assert_eq!(opts.target, "production");
        assert!(!opts.dry_run);
    }

    #[test]
    fn test_output_format_parse() {
        assert_eq!("hcl".parse::<OutputFormat>().unwrap(), OutputFormat::Hcl);
        assert_eq!("json".parse::<OutputFormat>().unwrap(), OutputFormat::Json);
        assert!("invalid".parse::<OutputFormat>().is_err());
    }
}