moire-web 1.0.0

Web dashboard server for moire: REST API, MCP tools, and live entity graph viewer
Documentation
use axum::body::Bytes;
use axum::extract::{RawQuery, State};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use facet::Facet;
use moire_trace_types::{FrameId, RelPc};
use moire_types::{SourcePreviewBatchRequest, SourcePreviewBatchResponse, SourcePreviewResponse};
use rusqlite_facet::ConnectionFacetExt;

use crate::app::AppState;
use crate::db::Db;
use crate::snapshot::table::lookup_frame_source_by_raw;
use crate::util::http::{json_error, json_ok};
use crate::util::source_path::resolve_source_path;
use moire_source_context::{
    cut_source, cut_source_compact, extract_enclosing_fn, extract_target_statement,
    highlighted_context_lines,
};

#[derive(Facet)]
struct SymbolicationCacheRow {
    source_file_path: Option<String>,
    source_line: Option<i64>,
    source_col: Option<i64>,
}

#[derive(Facet)]
struct SymbolicationCacheParams {
    module_identity: String,
    rel_pc: RelPc,
}

pub(crate) struct SourceTextLocation {
    pub source_file: String,
    pub target_line: u32,
    pub target_col: Option<u32>,
    pub total_lines: u32,
    pub content: String,
    pub language: Option<&'static str>,
}

// r[impl api.source.preview]
pub async fn api_source_preview(
    State(state): State<AppState>,
    RawQuery(raw_query): RawQuery,
) -> impl IntoResponse {
    let raw_query = raw_query.unwrap_or_default();

    // r[impl api.source.preview.frame-id]
    let frame_id_raw = match parse_query_u64(&raw_query, "frame_id") {
        Some(v) => v,
        None => {
            return json_error(
                StatusCode::BAD_REQUEST,
                "missing or invalid frame_id query parameter",
            );
        }
    };

    // r[impl api.source.preview.security]
    let (frame_id, module_identity, rel_pc) = match lookup_frame_source_by_raw(frame_id_raw) {
        Some(triple) => triple,
        None => {
            return json_error(
                StatusCode::NOT_FOUND,
                format!("unknown frame_id {frame_id_raw}"),
            );
        }
    };

    let db = state.db.clone();
    let result = tokio::task::spawn_blocking(move || {
        lookup_source_in_db(&db, frame_id, module_identity, rel_pc)
    })
    .await
    .unwrap_or_else(|error| Err(format!("join source lookup: {error}")));

    match result {
        Ok(Some(response)) => json_ok(&response),
        Ok(None) => json_error(StatusCode::NOT_FOUND, "source not available for frame"),
        Err(error) => json_error(StatusCode::INTERNAL_SERVER_ERROR, error),
    }
}

// r[impl api.source.previews]
pub async fn api_source_previews(State(state): State<AppState>, body: Bytes) -> impl IntoResponse {
    let body: SourcePreviewBatchRequest = match facet_json::from_slice(&body) {
        Ok(request) => request,
        Err(error) => {
            return json_error(
                StatusCode::BAD_REQUEST,
                format!("invalid request json: {error}"),
            );
        }
    };

    if body.frame_ids.is_empty() {
        return json_error(
            StatusCode::BAD_REQUEST,
            "frame_ids must be non-empty for source preview batch fetch",
        );
    }

    let mut lookups = Vec::with_capacity(body.frame_ids.len());
    let mut unknown_frame_ids = Vec::new();
    for frame_id in body.frame_ids {
        match lookup_frame_source_by_raw(frame_id.as_u64()) {
            Some((canonical_frame_id, module_identity, rel_pc)) => {
                lookups.push((canonical_frame_id, module_identity, rel_pc));
            }
            None => unknown_frame_ids.push(frame_id),
        }
    }
    if !unknown_frame_ids.is_empty() {
        let rendered = unknown_frame_ids
            .iter()
            .map(|id| id.as_u64().to_string())
            .collect::<Vec<_>>()
            .join(", ");
        return json_error(
            StatusCode::BAD_REQUEST,
            format!("unknown frame_id values in batch: [{rendered}]"),
        );
    }

    let db = state.db.clone();
    let result = tokio::task::spawn_blocking(move || {
        let mut previews = Vec::with_capacity(lookups.len());
        let mut unavailable_frame_ids = Vec::new();
        for (frame_id, module_identity, rel_pc) in lookups {
            match lookup_source_in_db(&db, frame_id, module_identity, rel_pc)? {
                Some(preview) => previews.push(preview),
                None => unavailable_frame_ids.push(frame_id),
            }
        }
        Ok::<SourcePreviewBatchResponse, String>(SourcePreviewBatchResponse {
            previews,
            unavailable_frame_ids,
        })
    })
    .await
    .unwrap_or_else(|error| Err(format!("join source preview batch lookup: {error}")));

    match result {
        Ok(response) => json_ok(&response),
        Err(error) => json_error(StatusCode::INTERNAL_SERVER_ERROR, error),
    }
}

fn parse_query_u64(query: &str, key: &str) -> Option<u64> {
    query.split('&').find_map(|part| {
        let (k, v) = part.split_once('=')?;
        if k == key {
            v.parse::<u64>().ok()
        } else {
            None
        }
    })
}

pub(crate) fn arborium_language(path: &str) -> Option<&'static str> {
    let ext = path.rsplit('.').next()?;
    match ext {
        "rs" => Some("rust"),
        "go" => Some("go"),
        "ts" | "mts" | "cts" => Some("typescript"),
        "tsx" => Some("tsx"),
        "js" | "mjs" | "cjs" => Some("javascript"),
        "jsx" => Some("jsx"),
        "py" => Some("python"),
        "rb" => Some("ruby"),
        "java" => Some("java"),
        "kt" | "kts" => Some("kotlin"),
        "scala" => Some("scala"),
        "c" | "h" => Some("c"),
        "cpp" | "cc" | "cxx" | "hpp" | "hxx" => Some("cpp"),
        "zig" => Some("zig"),
        "sh" | "bash" => Some("bash"),
        "json" => Some("json"),
        "yaml" | "yml" => Some("yaml"),
        "toml" => Some("toml"),
        "xml" => Some("xml"),
        "html" | "htm" => Some("html"),
        "css" => Some("css"),
        "scss" => Some("scss"),
        "md" | "mdx" => Some("markdown"),
        "sql" => Some("sql"),
        "swift" => Some("swift"),
        "ex" | "exs" => Some("elixir"),
        "hs" => Some("haskell"),
        "ml" | "mli" => Some("ocaml"),
        "lua" => Some("lua"),
        "php" => Some("php"),
        "r" => Some("r"),
        _ => None,
    }
}

fn html_escape(s: &str) -> String {
    let mut result = String::with_capacity(s.len());
    for ch in s.chars() {
        match ch {
            '<' => result.push_str("&lt;"),
            '>' => result.push_str("&gt;"),
            '&' => result.push_str("&amp;"),
            '"' => result.push_str("&quot;"),
            _ => result.push(ch),
        }
    }
    result
}

pub(crate) fn lookup_source_text_location_in_db(
    db: &Db,
    module_identity: String,
    rel_pc: RelPc,
) -> Result<Option<SourceTextLocation>, String> {
    let conn = db.open()?;

    let rows = conn
        .facet_query_ref::<SymbolicationCacheRow, _>(
            "SELECT source_file_path, source_line, source_col
             FROM symbolication_cache
             WHERE module_identity = :module_identity AND rel_pc = :rel_pc",
            &SymbolicationCacheParams {
                module_identity,
                rel_pc,
            },
        )
        .map_err(|error| format!("query symbolication_cache: {error}"))?;

    let row = match rows.into_iter().next() {
        Some(row) => row,
        None => return Ok(None),
    };

    let source_file_path = match row.source_file_path {
        Some(path) if !path.is_empty() => path,
        _ => return Ok(None),
    };

    let target_line = match row.source_line {
        Some(line) if line > 0 => {
            u32::try_from(line).map_err(|_| format!("source_line {line} out of u32 range"))?
        }
        _ => return Ok(None),
    };

    let target_col = row.source_col.and_then(|col| u32::try_from(col).ok());
    let resolved_path = resolve_source_path(&source_file_path);
    let content = std::fs::read_to_string(resolved_path.as_ref())
        .map_err(|error| format!("read source file {resolved_path}: {error}"))?;
    let total_lines = u32::try_from(content.lines().count()).unwrap_or(u32::MAX);
    let language = arborium_language(&source_file_path);

    Ok(Some(SourceTextLocation {
        source_file: resolved_path.into_owned(),
        target_line,
        target_col,
        total_lines,
        content,
        language,
    }))
}

pub(crate) fn lookup_source_in_db(
    db: &Db,
    frame_id: FrameId,
    module_identity: String,
    rel_pc: RelPc,
) -> Result<Option<SourcePreviewResponse>, String> {
    let Some(location) = lookup_source_text_location_in_db(db, module_identity, rel_pc)? else {
        return Ok(None);
    };

    let SourceTextLocation {
        source_file,
        target_line,
        target_col,
        total_lines,
        content,
        language: lang,
    } = location;

    // Full file highlight
    let html = match lang {
        Some(lang_name) => {
            let mut hl = arborium::Highlighter::new();
            hl.highlight(lang_name, &content)
                .unwrap_or_else(|_| html_escape(&content))
        }
        None => html_escape(&content),
    };

    let context_lines = lang.and_then(|lang_name| {
        let cut_result = cut_source(&content, lang_name, target_line, target_col)?;
        Some(highlighted_context_lines(&cut_result, lang_name))
    });

    let compact_context_lines = lang.and_then(|lang_name| {
        let cut_result = cut_source_compact(&content, lang_name, target_line, target_col)?;
        Some(highlighted_context_lines(&cut_result, lang_name))
    });

    // Compact statement snippet for compact display (may be multi-line).
    // Long inner block bodies are elided in source_context extraction.
    let context_line = lang.and_then(|lang_name| {
        let stmt = extract_target_statement(&content, lang_name, target_line, target_col)?;
        let mut hl = arborium::Highlighter::new();
        Some(
            hl.highlight(lang_name, &stmt)
                .unwrap_or_else(|_| html_escape(&stmt)),
        )
    });

    // Frame header: identifies the enclosing function/method for display.
    let frame_header = lang.and_then(|lang_name| {
        let context = extract_enclosing_fn(&content, lang_name, target_line, target_col)?;
        let mut hl = arborium::Highlighter::new();
        Some(
            hl.highlight(lang_name, &context)
                .unwrap_or_else(|_| html_escape(&context)),
        )
    });

    Ok(Some(SourcePreviewResponse {
        frame_id,
        source_file,
        target_line,
        target_col,
        total_lines,
        html,
        context_lines,
        compact_context_lines,
        context_line,
        frame_header,
    }))
}