Skip to main content

moire_web/api/
source.rs

1use axum::body::Bytes;
2use axum::extract::{RawQuery, State};
3use axum::http::StatusCode;
4use axum::response::IntoResponse;
5use facet::Facet;
6use moire_trace_types::{FrameId, RelPc};
7use moire_types::{SourcePreviewBatchRequest, SourcePreviewBatchResponse, SourcePreviewResponse};
8use rusqlite_facet::ConnectionFacetExt;
9
10use crate::app::AppState;
11use crate::db::Db;
12use crate::snapshot::table::lookup_frame_source_by_raw;
13use crate::util::http::{json_error, json_ok};
14use crate::util::source_path::resolve_source_path;
15use moire_source_context::{
16    cut_source, cut_source_compact, extract_enclosing_fn, extract_target_statement,
17    highlighted_context_lines,
18};
19
20#[derive(Facet)]
21struct SymbolicationCacheRow {
22    source_file_path: Option<String>,
23    source_line: Option<i64>,
24    source_col: Option<i64>,
25}
26
27#[derive(Facet)]
28struct SymbolicationCacheParams {
29    module_identity: String,
30    rel_pc: RelPc,
31}
32
33pub(crate) struct SourceTextLocation {
34    pub source_file: String,
35    pub target_line: u32,
36    pub target_col: Option<u32>,
37    pub total_lines: u32,
38    pub content: String,
39    pub language: Option<&'static str>,
40}
41
42// r[impl api.source.preview]
43pub async fn api_source_preview(
44    State(state): State<AppState>,
45    RawQuery(raw_query): RawQuery,
46) -> impl IntoResponse {
47    let raw_query = raw_query.unwrap_or_default();
48
49    // r[impl api.source.preview.frame-id]
50    let frame_id_raw = match parse_query_u64(&raw_query, "frame_id") {
51        Some(v) => v,
52        None => {
53            return json_error(
54                StatusCode::BAD_REQUEST,
55                "missing or invalid frame_id query parameter",
56            );
57        }
58    };
59
60    // r[impl api.source.preview.security]
61    let (frame_id, module_identity, rel_pc) = match lookup_frame_source_by_raw(frame_id_raw) {
62        Some(triple) => triple,
63        None => {
64            return json_error(
65                StatusCode::NOT_FOUND,
66                format!("unknown frame_id {frame_id_raw}"),
67            );
68        }
69    };
70
71    let db = state.db.clone();
72    let result = tokio::task::spawn_blocking(move || {
73        lookup_source_in_db(&db, frame_id, module_identity, rel_pc)
74    })
75    .await
76    .unwrap_or_else(|error| Err(format!("join source lookup: {error}")));
77
78    match result {
79        Ok(Some(response)) => json_ok(&response),
80        Ok(None) => json_error(StatusCode::NOT_FOUND, "source not available for frame"),
81        Err(error) => json_error(StatusCode::INTERNAL_SERVER_ERROR, error),
82    }
83}
84
85// r[impl api.source.previews]
86pub async fn api_source_previews(State(state): State<AppState>, body: Bytes) -> impl IntoResponse {
87    let body: SourcePreviewBatchRequest = match facet_json::from_slice(&body) {
88        Ok(request) => request,
89        Err(error) => {
90            return json_error(
91                StatusCode::BAD_REQUEST,
92                format!("invalid request json: {error}"),
93            );
94        }
95    };
96
97    if body.frame_ids.is_empty() {
98        return json_error(
99            StatusCode::BAD_REQUEST,
100            "frame_ids must be non-empty for source preview batch fetch",
101        );
102    }
103
104    let mut lookups = Vec::with_capacity(body.frame_ids.len());
105    let mut unknown_frame_ids = Vec::new();
106    for frame_id in body.frame_ids {
107        match lookup_frame_source_by_raw(frame_id.as_u64()) {
108            Some((canonical_frame_id, module_identity, rel_pc)) => {
109                lookups.push((canonical_frame_id, module_identity, rel_pc));
110            }
111            None => unknown_frame_ids.push(frame_id),
112        }
113    }
114    if !unknown_frame_ids.is_empty() {
115        let rendered = unknown_frame_ids
116            .iter()
117            .map(|id| id.as_u64().to_string())
118            .collect::<Vec<_>>()
119            .join(", ");
120        return json_error(
121            StatusCode::BAD_REQUEST,
122            format!("unknown frame_id values in batch: [{rendered}]"),
123        );
124    }
125
126    let db = state.db.clone();
127    let result = tokio::task::spawn_blocking(move || {
128        let mut previews = Vec::with_capacity(lookups.len());
129        let mut unavailable_frame_ids = Vec::new();
130        for (frame_id, module_identity, rel_pc) in lookups {
131            match lookup_source_in_db(&db, frame_id, module_identity, rel_pc)? {
132                Some(preview) => previews.push(preview),
133                None => unavailable_frame_ids.push(frame_id),
134            }
135        }
136        Ok::<SourcePreviewBatchResponse, String>(SourcePreviewBatchResponse {
137            previews,
138            unavailable_frame_ids,
139        })
140    })
141    .await
142    .unwrap_or_else(|error| Err(format!("join source preview batch lookup: {error}")));
143
144    match result {
145        Ok(response) => json_ok(&response),
146        Err(error) => json_error(StatusCode::INTERNAL_SERVER_ERROR, error),
147    }
148}
149
150fn parse_query_u64(query: &str, key: &str) -> Option<u64> {
151    query.split('&').find_map(|part| {
152        let (k, v) = part.split_once('=')?;
153        if k == key {
154            v.parse::<u64>().ok()
155        } else {
156            None
157        }
158    })
159}
160
161pub(crate) fn arborium_language(path: &str) -> Option<&'static str> {
162    let ext = path.rsplit('.').next()?;
163    match ext {
164        "rs" => Some("rust"),
165        "go" => Some("go"),
166        "ts" | "mts" | "cts" => Some("typescript"),
167        "tsx" => Some("tsx"),
168        "js" | "mjs" | "cjs" => Some("javascript"),
169        "jsx" => Some("jsx"),
170        "py" => Some("python"),
171        "rb" => Some("ruby"),
172        "java" => Some("java"),
173        "kt" | "kts" => Some("kotlin"),
174        "scala" => Some("scala"),
175        "c" | "h" => Some("c"),
176        "cpp" | "cc" | "cxx" | "hpp" | "hxx" => Some("cpp"),
177        "zig" => Some("zig"),
178        "sh" | "bash" => Some("bash"),
179        "json" => Some("json"),
180        "yaml" | "yml" => Some("yaml"),
181        "toml" => Some("toml"),
182        "xml" => Some("xml"),
183        "html" | "htm" => Some("html"),
184        "css" => Some("css"),
185        "scss" => Some("scss"),
186        "md" | "mdx" => Some("markdown"),
187        "sql" => Some("sql"),
188        "swift" => Some("swift"),
189        "ex" | "exs" => Some("elixir"),
190        "hs" => Some("haskell"),
191        "ml" | "mli" => Some("ocaml"),
192        "lua" => Some("lua"),
193        "php" => Some("php"),
194        "r" => Some("r"),
195        _ => None,
196    }
197}
198
199fn html_escape(s: &str) -> String {
200    let mut result = String::with_capacity(s.len());
201    for ch in s.chars() {
202        match ch {
203            '<' => result.push_str("&lt;"),
204            '>' => result.push_str("&gt;"),
205            '&' => result.push_str("&amp;"),
206            '"' => result.push_str("&quot;"),
207            _ => result.push(ch),
208        }
209    }
210    result
211}
212
213pub(crate) fn lookup_source_text_location_in_db(
214    db: &Db,
215    module_identity: String,
216    rel_pc: RelPc,
217) -> Result<Option<SourceTextLocation>, String> {
218    let conn = db.open()?;
219
220    let rows = conn
221        .facet_query_ref::<SymbolicationCacheRow, _>(
222            "SELECT source_file_path, source_line, source_col
223             FROM symbolication_cache
224             WHERE module_identity = :module_identity AND rel_pc = :rel_pc",
225            &SymbolicationCacheParams {
226                module_identity,
227                rel_pc,
228            },
229        )
230        .map_err(|error| format!("query symbolication_cache: {error}"))?;
231
232    let row = match rows.into_iter().next() {
233        Some(row) => row,
234        None => return Ok(None),
235    };
236
237    let source_file_path = match row.source_file_path {
238        Some(path) if !path.is_empty() => path,
239        _ => return Ok(None),
240    };
241
242    let target_line = match row.source_line {
243        Some(line) if line > 0 => {
244            u32::try_from(line).map_err(|_| format!("source_line {line} out of u32 range"))?
245        }
246        _ => return Ok(None),
247    };
248
249    let target_col = row.source_col.and_then(|col| u32::try_from(col).ok());
250    let resolved_path = resolve_source_path(&source_file_path);
251    let content = std::fs::read_to_string(resolved_path.as_ref())
252        .map_err(|error| format!("read source file {resolved_path}: {error}"))?;
253    let total_lines = u32::try_from(content.lines().count()).unwrap_or(u32::MAX);
254    let language = arborium_language(&source_file_path);
255
256    Ok(Some(SourceTextLocation {
257        source_file: resolved_path.into_owned(),
258        target_line,
259        target_col,
260        total_lines,
261        content,
262        language,
263    }))
264}
265
266pub(crate) fn lookup_source_in_db(
267    db: &Db,
268    frame_id: FrameId,
269    module_identity: String,
270    rel_pc: RelPc,
271) -> Result<Option<SourcePreviewResponse>, String> {
272    let Some(location) = lookup_source_text_location_in_db(db, module_identity, rel_pc)? else {
273        return Ok(None);
274    };
275
276    let SourceTextLocation {
277        source_file,
278        target_line,
279        target_col,
280        total_lines,
281        content,
282        language: lang,
283    } = location;
284
285    // Full file highlight
286    let html = match lang {
287        Some(lang_name) => {
288            let mut hl = arborium::Highlighter::new();
289            hl.highlight(lang_name, &content)
290                .unwrap_or_else(|_| html_escape(&content))
291        }
292        None => html_escape(&content),
293    };
294
295    let context_lines = lang.and_then(|lang_name| {
296        let cut_result = cut_source(&content, lang_name, target_line, target_col)?;
297        Some(highlighted_context_lines(&cut_result, lang_name))
298    });
299
300    let compact_context_lines = lang.and_then(|lang_name| {
301        let cut_result = cut_source_compact(&content, lang_name, target_line, target_col)?;
302        Some(highlighted_context_lines(&cut_result, lang_name))
303    });
304
305    // Compact statement snippet for compact display (may be multi-line).
306    // Long inner block bodies are elided in source_context extraction.
307    let context_line = lang.and_then(|lang_name| {
308        let stmt = extract_target_statement(&content, lang_name, target_line, target_col)?;
309        let mut hl = arborium::Highlighter::new();
310        Some(
311            hl.highlight(lang_name, &stmt)
312                .unwrap_or_else(|_| html_escape(&stmt)),
313        )
314    });
315
316    // Frame header: identifies the enclosing function/method for display.
317    let frame_header = lang.and_then(|lang_name| {
318        let context = extract_enclosing_fn(&content, lang_name, target_line, target_col)?;
319        let mut hl = arborium::Highlighter::new();
320        Some(
321            hl.highlight(lang_name, &context)
322                .unwrap_or_else(|_| html_escape(&context)),
323        )
324    });
325
326    Ok(Some(SourcePreviewResponse {
327        frame_id,
328        source_file,
329        target_line,
330        target_col,
331        total_lines,
332        html,
333        context_lines,
334        compact_context_lines,
335        context_line,
336        frame_header,
337    }))
338}