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
42pub 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 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 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
85pub 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("<"),
204 '>' => result.push_str(">"),
205 '&' => result.push_str("&"),
206 '"' => result.push_str("""),
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 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 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 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}