glacier-web-cli 0.5.0

Command-line tool for creating and running Glacier applications.
//! `.glacier` files: compiling them to sibling `.rs` files, and mapping
//! compiler errors in those files back to the `.glacier` source.
//!
//! `src/controllers/posts.glacier` compiles to `.glacier/controllers/posts.rs`
//! (a git-ignored build artifact the app's `build.rs` also produces), so
//! rust-analyzer and every other Rust tool see an ordinary file.

use std::collections::HashMap;
use std::fs;
use std::io::{BufRead, BufReader, IsTerminal};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::rc::Rc;

use glacier_dialect::build::{compile_file, source_path};
use glacier_dialect::{SourceMap, is_generated};

use crate::diagnostics::Source;

/// Every `.glacier` file under `src/`.
pub fn sources() -> Vec<PathBuf> {
    glacier_dialect::build::sources(Path::new("."))
}

/// Compiles every `.glacier` file under `src/`; returns how many changed.
pub fn compile_all() -> Result<usize, String> {
    glacier_dialect::build::compile_all(Path::new("."))
}

/// Formats `.glacier` files in place: those named, every one in the
/// folders named, or (with neither) every one under `src/` and `tests/`.
/// With `check`, lists unformatted files and fails instead.
pub fn format(paths: &[PathBuf], check: bool) -> Result<(), String> {
    let defaults = paths.is_empty();
    let named = if defaults {
        vec![
            PathBuf::from("src"),
            PathBuf::from("tests"),
            PathBuf::from("db"),
        ]
    } else {
        paths.to_vec()
    };
    let mut paths = Vec::new();
    for path in named {
        if path.is_dir() {
            glacier_files(&path, &mut paths);
        } else if !defaults {
            // A named file; reading it says so if it's missing.
            paths.push(path);
        }
    }
    let mut unformatted = Vec::new();
    for path in &paths {
        let source =
            fs::read_to_string(path).map_err(|e| format!("reading {}: {e}", path.display()))?;
        let formatted =
            glacier_dialect::format::format(&source, glacier_dialect::Mode::for_path(path))
                .map_err(|e| format!("{}:{e}", path.display()))?;
        if formatted == source {
            continue;
        }
        if check {
            println!("{}", path.display());
        } else {
            fs::write(path, formatted).map_err(|e| format!("writing {}: {e}", path.display()))?;
            println!("formatted {}", path.display());
        }
        unformatted.push(path);
    }
    if check && !unformatted.is_empty() {
        return Err(format!("{} file(s) need formatting", unformatted.len()));
    }
    // Say so either way, so a quiet run isn't mistaken for one that did
    // nothing at all.
    let total = paths.len();
    match (check, unformatted.len()) {
        (_, 0) => println!("{total} file(s) checked; all formatted"),
        (_, n) => println!("{total} file(s) checked; formatted {n}"),
    }
    Ok(())
}

/// Compiles `.glacier` files, then runs `cargo <subcommand>`, showing
/// errors in generated files at their `.glacier` lines. Returns success.
pub fn cargo(subcommand: &str, cargo_args: &[String]) -> bool {
    if let Err(message) = compile_all() {
        eprintln!("\x1b[31merror\x1b[0m: {message}");
        return false;
    }
    let color = std::io::stderr().is_terminal() && std::env::var_os("NO_COLOR").is_none();
    let format = if color {
        "json-diagnostic-rendered-ansi"
    } else {
        "json"
    };
    let mut child = match Command::new("cargo")
        .arg(subcommand)
        .args(["--message-format", format])
        .args(cargo_args)
        .stdout(Stdio::piped())
        .spawn()
    {
        Ok(child) => child,
        Err(e) => {
            eprintln!("error: running cargo: {e}");
            return false;
        }
    };

    let mut maps: HashMap<String, Option<SourceMap>> = HashMap::new();
    let mut sources: HashMap<String, Option<Rc<Source>>> = HashMap::new();
    let mut lookup = |file: &str| {
        sources
            .entry(file.to_owned())
            .or_insert_with(|| glacier_source(file))
            .clone()
    };
    let stdout = child.stdout.take().expect("piped stdout");
    for line in BufReader::new(stdout).lines().map_while(Result::ok) {
        let Ok(message) = serde_json::from_str::<serde_json::Value>(&line) else {
            // Not cargo's JSON: test output, with panic locations to map.
            println!("{}", map_locations(&line, &mut maps));
            continue;
        };
        if message["reason"] != "compiler-message" {
            continue;
        }
        let diagnostic = &message["message"];
        // Errors in .glacier files: shown against the .glacier source.
        if let Some(text) = crate::diagnostics::render(diagnostic, &mut lookup, color) {
            eprint!("{text}");
            continue;
        }
        let Some(rendered) = diagnostic["rendered"].as_str() else {
            continue;
        };
        eprint!(
            "{}",
            map_rendered(rendered, &diagnostic["spans"], &mut maps)
        );
    }
    child.wait().is_ok_and(|status| status.success())
}

/// Rewrites `--> src/x.rs:L:C` locations in generated files to
/// `src/x.glacier:L:C'`. Lines are identical by construction; columns go
/// through the source map.
fn map_rendered(
    rendered: &str,
    spans: &serde_json::Value,
    maps: &mut HashMap<String, Option<SourceMap>>,
) -> String {
    let mut out = rendered.to_owned();
    for span in spans.as_array().into_iter().flatten() {
        let Some(file) = span["file_name"].as_str() else {
            continue;
        };
        let (Some(line), Some(col)) = (span["line_start"].as_u64(), span["column_start"].as_u64())
        else {
            continue;
        };
        let map = maps
            .entry(file.to_owned())
            .or_insert_with(|| generated_map(file));
        let Some(map) = map else { continue };
        let line0 = line as usize - 1;
        let src_col = map.to_glacier(line0, col as usize - 1) + 1;
        let Some(glacier) = source_path(Path::new(file)) else {
            continue;
        };
        out = out.replace(
            &format!("{file}:{line}:{col}"),
            &format!("{}:{line}:{src_col}", glacier.display()),
        );
    }
    out
}

/// Rewrites `…/.glacier/x.rs:L:C` locations in plain text (a failing
/// test's panic message) to the `.glacier` source, relative to the current
/// directory when it's inside it.
fn map_locations(text: &str, maps: &mut HashMap<String, Option<SourceMap>>) -> String {
    let cwd = std::env::current_dir().unwrap_or_default();
    let mut out = text.to_owned();
    for word in text.split_whitespace() {
        let mut parts = word.trim_end_matches(':').rsplitn(3, ':');
        let (Some(col), Some(line), Some(file)) = (parts.next(), parts.next(), parts.next()) else {
            continue;
        };
        let (Ok(line), Ok(col)) = (line.parse::<usize>(), col.parse::<usize>()) else {
            continue;
        };
        if line == 0 || col == 0 || !file.ends_with(".rs") {
            continue;
        }
        let map = maps
            .entry(file.to_owned())
            .or_insert_with(|| generated_map(file));
        let (Some(map), Some(glacier)) = (map, source_path(Path::new(file))) else {
            continue;
        };
        let src_col = map.to_glacier(line - 1, col - 1) + 1;
        let shown = glacier
            .strip_prefix(&cwd)
            .unwrap_or(&glacier)
            .display()
            .to_string();
        out = out.replace(
            &format!("{file}:{line}:{col}"),
            &format!("{shown}:{line}:{src_col}"),
        );
    }
    out
}

/// The `.glacier` source behind `file`, if it's a generated file.
pub(crate) fn glacier_source(file: &str) -> Option<Rc<Source>> {
    let map = generated_map(file)?;
    let glacier = source_path(Path::new(file))?;
    let text = fs::read_to_string(&glacier).ok()?;
    let cwd = std::env::current_dir().unwrap_or_default();
    Some(Rc::new(Source {
        path: glacier
            .strip_prefix(&cwd)
            .unwrap_or(&glacier)
            .display()
            .to_string(),
        lines: text.lines().map(str::to_owned).collect(),
        map,
    }))
}

/// The source map of a generated file, if `file` is one.
fn generated_map(file: &str) -> Option<SourceMap> {
    let rust = fs::read_to_string(file).ok()?;
    if !is_generated(&rust) {
        return None;
    }
    compile_file(&source_path(Path::new(file))?)
        .ok()
        .map(|(_, map)| map)
}

/// Every `.glacier` file under `dir`, in name order.
fn glacier_files(dir: &Path, out: &mut Vec<PathBuf>) {
    let Ok(entries) = fs::read_dir(dir) else {
        return;
    };
    let mut entries: Vec<PathBuf> = entries.filter_map(|e| Some(e.ok()?.path())).collect();
    entries.sort();
    for path in entries {
        if path.is_dir() {
            glacier_files(&path, out);
        } else if path.extension().is_some_and(|ext| ext == "glacier") {
            out.push(path);
        }
    }
}