maproom 0.1.0

Semantic code search powered by embeddings and SQLite
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
//! Markdown parser implementation

use regex::Regex;
use tree_sitter::{Node, Parser};

use super::common::HierarchyTracker;
use crate::indexer::SymbolChunk;
use crate::profile_scope;

// Language provider
#[allow(dead_code)]
fn lang_markdown() -> tree_sitter::Language {
    tree_sitter_md::language()
}

/// Extract chunks from Markdown source code
pub(super) fn extract_markdown_chunks(source: &str) -> Vec<SymbolChunk> {
    profile_scope!("extract_markdown_chunks");

    let mut parser = Parser::new();
    parser.set_language(&lang_markdown()).ok();

    let tree = match parser.parse(source, None) {
        Some(t) => t,
        None => return Vec::new(),
    };

    let mut chunks = Vec::new();
    let root = tree.root_node();
    let mut hierarchy = HierarchyTracker::new();

    // Walk the tree and extract headings and code blocks
    walk_markdown_nodes(source, root, &mut chunks, &mut hierarchy);

    // Extract links using regex (tree-sitter-md limitation workaround)
    // See MD_ENHANCE-1001 ticket lines 114-118 for context
    extract_markdown_links(source, &mut chunks);

    chunks
}

fn walk_markdown_nodes(
    source: &str,
    node: Node,
    chunks: &mut Vec<SymbolChunk>,
    hierarchy: &mut HierarchyTracker,
) {
    let kind = node.kind();

    match kind {
        "atx_heading" => {
            extract_heading(source, node, chunks, hierarchy);
        }
        "fenced_code_block" => {
            extract_code_block(source, node, chunks, hierarchy);
        }
        "pipe_table" => {
            extract_table(source, node, chunks);
        }
        "list" => {
            extract_list(source, node, chunks);
        }
        // Note: tree-sitter-md does not provide structured link nodes
        // Links are parsed as individual punctuation tokens within inline content
        // Link extraction will be handled in a future ticket (MD_ENHANCE-3002)
        // using regex or alternative approach
        _ => {}
    }

    // Recurse into children
    for i in 0..node.child_count() {
        if let Some(child) = node.child(i) {
            walk_markdown_nodes(source, child, chunks, hierarchy);
        }
    }
}

fn extract_heading(
    source: &str,
    node: Node,
    chunks: &mut Vec<SymbolChunk>,
    hierarchy: &mut HierarchyTracker,
) {
    // Get heading level by checking the marker
    let mut level = 0;
    let mut heading_text = String::new();

    for i in 0..node.child_count() {
        if let Some(child) = node.child(i) {
            match child.kind() {
                "atx_h1_marker" => level = 1,
                "atx_h2_marker" => level = 2,
                "atx_h3_marker" => level = 3,
                "atx_h4_marker" => level = 4,
                "atx_h5_marker" => level = 5,
                "atx_h6_marker" => level = 6,
                "inline" => {
                    // The heading text is in the inline node
                    if let Ok(text) = child.utf8_text(source.as_bytes()) {
                        heading_text = text.trim().to_string();
                    }
                }
                _ => {}
            }
        }
    }

    if level > 0 && !heading_text.is_empty() {
        let start_line = (node.start_position().row + 1) as i32;

        // Find section end (next heading of same or higher level, or EOF)
        let end_line = find_section_end(source, node, level);

        // Update hierarchy and get parent path
        let parent_path = hierarchy.enter_heading(level as u8, heading_text.clone());

        let kind = format!("heading_{}", level);

        chunks.push(SymbolChunk {
            symbol_name: Some(heading_text),
            kind,
            signature: None,
            docstring: None,
            start_line,
            end_line,
            metadata: Some(serde_json::json!({
                "level": level,
                "parent_path": parent_path
            })),
        });
    }
}

fn find_section_end(source: &str, heading_node: Node, heading_level: usize) -> i32 {
    let start_row = heading_node.start_position().row;
    let lines: Vec<&str> = source.lines().collect();

    // Start searching from the next line after the heading
    let mut end_idx = start_row + 1;
    let mut in_code_block = false;

    while end_idx < lines.len() {
        let line = lines[end_idx];

        // Track code blocks
        if line.trim().starts_with("```") {
            in_code_block = !in_code_block;
            end_idx += 1;
            continue;
        }

        // Only check for headings outside of code blocks
        if !in_code_block {
            if let Some(next_level) = get_heading_level_from_line(line) {
                if next_level <= heading_level {
                    // Found a heading of same or higher level - section ends here
                    return end_idx as i32;
                }
            }
        }

        end_idx += 1;
    }

    // Section goes to end of file
    lines.len() as i32
}

fn get_heading_level_from_line(line: &str) -> Option<usize> {
    let trimmed = line.trim_start();
    if !trimmed.starts_with('#') {
        return None;
    }

    let mut level = 0;
    for ch in trimmed.chars() {
        if ch == '#' {
            level += 1;
            if level > 6 {
                return None; // Not a valid heading
            }
        } else if ch == ' ' {
            // Valid heading must have space after #
            return Some(level);
        } else {
            // Not a valid heading (e.g., "#tag" without space)
            return None;
        }
    }
    None
}

fn extract_code_block(
    source: &str,
    node: Node,
    chunks: &mut Vec<SymbolChunk>,
    hierarchy: &HierarchyTracker,
) {
    let mut language: Option<String> = None;
    let mut code_lines_count = 0;

    // Extract language from info_string if present
    for i in 0..node.child_count() {
        if let Some(child) = node.child(i) {
            match child.kind() {
                "info_string" => {
                    if let Ok(text) = child.utf8_text(source.as_bytes()) {
                        // Extract just the language name (first word) from info_string
                        // This handles cases like "typescript {1-3}" or "rust copy"
                        let lang_text = text.split_whitespace().next().unwrap_or(text.trim());
                        language = Some(lang_text.to_string());
                    }
                }
                "code_fence_content" => {
                    if let Ok(text) = child.utf8_text(source.as_bytes()) {
                        code_lines_count = text.lines().count();
                    }
                }
                _ => {}
            }
        }
    }

    let start_line = (node.start_position().row + 1) as i32;
    let end_line = (node.end_position().row + 1) as i32;

    // Get parent heading path for linking code block to its section
    let parent_path = hierarchy.get_current_path();

    let symbol_name = if let Some(ref lang) = language {
        format!("Code: {}", lang)
    } else {
        "Code: plain".to_string()
    };

    chunks.push(SymbolChunk {
        symbol_name: Some(symbol_name),
        kind: "code_block".to_string(),
        signature: None,
        docstring: None,
        start_line,
        end_line,
        metadata: Some(serde_json::json!({
            "language": language.unwrap_or_else(|| "plain".to_string()),
            "parent_path": parent_path,
            "lines_of_code": code_lines_count
        })),
    });
}

fn extract_table(_source: &str, node: Node, chunks: &mut Vec<SymbolChunk>) {
    let start_line = (node.start_position().row + 1) as i32;
    let end_line = (node.end_position().row + 1) as i32;

    // Count rows and columns
    let mut row_count = 0;
    let mut column_count = 0;
    let mut has_header = false;

    for i in 0..node.child_count() {
        if let Some(child) = node.child(i) {
            match child.kind() {
                "pipe_table_header" => {
                    has_header = true;
                    row_count += 1;
                    // Count cells in header to determine column count
                    for j in 0..child.child_count() {
                        if let Some(cell) = child.child(j) {
                            if cell.kind() == "pipe_table_cell" {
                                column_count += 1;
                            }
                        }
                    }
                }
                "pipe_table_row" => {
                    row_count += 1;
                }
                _ => {}
            }
        }
    }

    chunks.push(SymbolChunk {
        symbol_name: Some(format!("Table {}x{}", row_count, column_count)),
        kind: "markdown_section".to_string(),
        signature: None,
        docstring: None,
        start_line,
        end_line,
        metadata: Some(serde_json::json!({
            "section_type": "table",
            "rows": row_count,
            "columns": column_count,
            "has_header": has_header
        })),
    });
}

fn extract_list(_source: &str, node: Node, chunks: &mut Vec<SymbolChunk>) {
    let start_line = (node.start_position().row + 1) as i32;
    let end_line = (node.end_position().row + 1) as i32;

    // Determine list type and count items
    let mut list_type = "unordered";
    let mut item_count = 0;

    for i in 0..node.child_count() {
        if let Some(child) = node.child(i) {
            if child.kind() == "list_item" {
                item_count += 1;

                // Check first list item to determine type
                if item_count == 1 {
                    for j in 0..child.child_count() {
                        if let Some(marker) = child.child(j) {
                            if marker.kind() == "list_marker_dot" {
                                list_type = "ordered";
                                break;
                            }
                        }
                    }
                }
            }
        }
    }

    chunks.push(SymbolChunk {
        symbol_name: Some(format!("List ({} items)", item_count)),
        kind: "markdown_section".to_string(),
        signature: None,
        docstring: None,
        start_line,
        end_line,
        metadata: Some(serde_json::json!({
            "list_type": list_type,
            "item_count": item_count
        })),
    });
}

/// Extract markdown links using regex patterns.
/// This is a workaround for tree-sitter-md limitation where links are parsed
/// as individual punctuation tokens rather than structured nodes.
/// See MD_ENHANCE-1001 ticket lines 114-118 for context.
///
/// Extracts three types of links:
/// 1. Regular links: [text](url)
/// 2. Image links: ![alt](url)
/// 3. Both extract the link text/alt and the target URL
fn extract_markdown_links(source: &str, chunks: &mut Vec<SymbolChunk>) {
    // Regex patterns for markdown links
    // Regular link: [text](url) - captures text in group 1, url in group 2
    // Image link: ![alt](url) - captures alt in group 1, url in group 2
    let link_pattern = Regex::new(r"(?m)(!?)\[([^\]]*)\]\(([^)]+)\)").unwrap();

    for cap in link_pattern.captures_iter(source) {
        let is_image = cap.get(1).is_some_and(|m| m.as_str() == "!");
        let link_text = cap.get(2).map_or("", |m| m.as_str());
        let target = cap.get(3).map_or("", |m| m.as_str());

        // Skip empty targets
        if target.trim().is_empty() {
            continue;
        }

        // Classify the link type
        let link_type = classify_link(target);

        // Find the line number where this link appears
        let full_match = cap.get(0).unwrap();
        let link_position = full_match.start();
        let line_number = find_line_number(source, link_position);

        // Create chunk metadata
        let metadata = serde_json::json!({
            "link_type": link_type,
            "target": target,
            "link_text": link_text,
            "is_image": is_image,
        });

        // Create a link chunk
        let kind = if is_image { "image_link" } else { "link" };
        let symbol_name = if !link_text.is_empty() {
            Some(link_text.to_string())
        } else {
            Some(target.to_string())
        };

        chunks.push(SymbolChunk {
            symbol_name,
            kind: kind.to_string(),
            signature: Some(target.to_string()),
            docstring: None,
            start_line: line_number as i32,
            end_line: line_number as i32,
            metadata: Some(metadata),
        });
    }
}

/// Classify a link target into one of: "external", "anchor", "relative", or "absolute"
fn classify_link(target: &str) -> String {
    if target.starts_with("http://") || target.starts_with("https://") {
        "external".to_string()
    } else if target.starts_with('#') {
        "anchor".to_string()
    } else if target.starts_with('/') {
        "absolute".to_string()
    } else {
        "relative".to_string()
    }
}

/// Find the line number (1-indexed) where a character position appears in the source
fn find_line_number(source: &str, position: usize) -> usize {
    let mut current_pos = 0;
    for (line_idx, line) in source.lines().enumerate() {
        let line_len = line.len() + 1; // +1 for newline
        if current_pos + line_len > position {
            return line_idx + 1; // 1-indexed
        }
        current_pos += line_len;
    }
    1 // Default to line 1 if not found
}