sview 0.1.0

Agent-friendly structure views of source and document files
Documentation
use crate::{model::Node, util::truncate_preview};

pub(crate) fn analyze_markdown(source: &str, preview_len: usize) -> Vec<Node> {
    let lines = source.lines().collect::<Vec<_>>();
    let mut items = Vec::<MarkdownItem>::new();
    let mut heading_stack = Vec::<(usize, usize)>::new();
    let mut line_index = 0;

    if lines.first().is_some_and(|line| line.trim() == "---") {
        if let Some(end_index) = lines.iter().skip(1).position(|line| line.trim() == "---") {
            let end_line = end_index + 2;
            items.push(MarkdownItem {
                parent: None,
                heading_level: None,
                node: Node {
                    kind: "frontmatter".to_string(),
                    level: None,
                    name: Some("frontmatter".to_string()),
                    start_line: 1,
                    end_line,
                    preview: Some("---".to_string()),
                    children: Vec::new(),
                },
            });
            line_index = end_line;
        }
    }

    while line_index < lines.len() {
        let line = lines[line_index];
        let line_number = line_index + 1;

        if let Some((level, name)) = parse_heading(line) {
            while heading_stack
                .last()
                .is_some_and(|(stack_level, _)| *stack_level >= level)
            {
                heading_stack.pop();
            }
            let parent = heading_stack.last().map(|(_, index)| *index);
            let index = items.len();
            items.push(MarkdownItem {
                parent,
                heading_level: Some(level),
                node: Node {
                    kind: "heading".to_string(),
                    level: Some(level),
                    name: Some(name),
                    start_line: line_number,
                    end_line: lines.len(),
                    preview: Some(truncate_preview(line.trim(), preview_len)),
                    children: Vec::new(),
                },
            });
            heading_stack.push((level, index));
            line_index += 1;
            continue;
        }

        if line.trim_start().starts_with("```") || line.trim_start().starts_with("~~~") {
            let fence = &line.trim_start()[..3];
            let start_line = line_number;
            line_index += 1;
            while line_index < lines.len() && !lines[line_index].trim_start().starts_with(fence) {
                line_index += 1;
            }
            let end_line = if line_index < lines.len() {
                line_index += 1;
                line_index
            } else {
                lines.len()
            };
            items.push(MarkdownItem {
                parent: heading_stack.last().map(|(_, index)| *index),
                heading_level: None,
                node: Node {
                    kind: "code_block".to_string(),
                    level: None,
                    name: code_block_name(line),
                    start_line,
                    end_line,
                    preview: Some(truncate_preview(line.trim(), preview_len)),
                    children: Vec::new(),
                },
            });
            continue;
        }

        if is_list_item(line) {
            let start_line = line_number;
            line_index += 1;
            while line_index < lines.len()
                && (is_list_item(lines[line_index]) || lines[line_index].trim().is_empty())
            {
                line_index += 1;
            }
            items.push(MarkdownItem {
                parent: heading_stack.last().map(|(_, index)| *index),
                heading_level: None,
                node: Node {
                    kind: "list".to_string(),
                    level: None,
                    name: Some("list".to_string()),
                    start_line,
                    end_line: line_index,
                    preview: Some(truncate_preview(line.trim(), preview_len)),
                    children: Vec::new(),
                },
            });
            continue;
        }

        line_index += 1;
    }

    for index in 0..items.len() {
        if let Some(level) = items[index].heading_level {
            if let Some(next_heading) = items[index + 1..].iter().find(|item| {
                item.heading_level
                    .is_some_and(|next_level| next_level <= level)
            }) {
                items[index].node.end_line = next_heading.node.start_line.saturating_sub(1);
            }
        }
    }

    build_markdown_tree(&items, None)
}

#[derive(Debug, Clone)]
struct MarkdownItem {
    parent: Option<usize>,
    heading_level: Option<usize>,
    node: Node,
}

fn build_markdown_tree(items: &[MarkdownItem], parent: Option<usize>) -> Vec<Node> {
    items
        .iter()
        .enumerate()
        .filter(|(_, item)| item.parent == parent)
        .map(|(index, item)| {
            let mut node = item.node.clone();
            node.children = build_markdown_tree(items, Some(index));
            node
        })
        .collect()
}

fn parse_heading(line: &str) -> Option<(usize, String)> {
    let trimmed = line.trim_start();
    let level = trimmed
        .chars()
        .take_while(|character| *character == '#')
        .count();
    if !(1..=6).contains(&level) || !trimmed[level..].starts_with(' ') {
        return None;
    }

    let name = trimmed[level..].trim().trim_end_matches('#').trim();
    if name.is_empty() {
        None
    } else {
        Some((level, name.to_string()))
    }
}

fn code_block_name(line: &str) -> Option<String> {
    let info = line.trim_start()[3..].trim();
    (!info.is_empty()).then(|| info.to_string())
}

fn is_list_item(line: &str) -> bool {
    let trimmed = line.trim_start();
    if trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ") {
        return true;
    }

    let Some((digits, rest)) = trimmed.split_once('.') else {
        return false;
    };
    !digits.is_empty()
        && digits.chars().all(|character| character.is_ascii_digit())
        && rest.starts_with(' ')
}