kr580 1.0.0

Desktop KR580VM80 / Intel 8080 emulator.
Documentation
use std::collections::BTreeSet;
use std::sync::Arc;

use super::HelpNode;
use crate::i18n::Lang;

const MAX_PREVIEW_LINES: usize = 4;

#[derive(Clone)]
pub(crate) struct HelpSearchIndex {
    lang: Lang,
    records: Vec<HelpSearchRecord>,
}

#[derive(Clone)]
struct HelpSearchRecord {
    node: HelpNode,
    haystack: String,
    raw_lines: Vec<String>,
    lower_lines: Vec<String>,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct HelpSearchResult {
    node: HelpNode,
    preview_lines: Vec<String>,
}

#[derive(Clone)]
pub(crate) struct HelpSearchRequest {
    generation: u64,
    lang: Lang,
    query: String,
    index: Arc<HelpSearchIndex>,
}

#[derive(Clone, Debug)]
pub(crate) struct HelpSearchResponse {
    generation: u64,
    lang: Lang,
    query: String,
    results: Vec<HelpSearchResult>,
}

impl HelpSearchIndex {
    pub(crate) fn new(lang: Lang) -> Self {
        let mut records = Vec::new();
        let mut seen = BTreeSet::new();
        for node in HelpNode::ROOTS {
            collect_records(node, lang, &mut Vec::new(), &mut seen, &mut records);
        }
        Self { lang, records }
    }

    pub(crate) fn lang(&self) -> Lang {
        self.lang
    }

    pub(crate) fn search(&self, query: &str) -> Vec<HelpSearchResult> {
        let needle = query.trim().to_lowercase();
        if needle.is_empty() {
            return Vec::new();
        }

        self.records
            .iter()
            .filter(|record| record.haystack.contains(&needle))
            .map(|record| HelpSearchResult {
                node: record.node,
                preview_lines: record.preview_lines(&needle),
            })
            .collect()
    }
}

impl HelpSearchRequest {
    pub(crate) fn new(
        generation: u64,
        lang: Lang,
        query: String,
        index: Arc<HelpSearchIndex>,
    ) -> Self {
        Self {
            generation,
            lang,
            query,
            index,
        }
    }

    pub(crate) fn resolve(self) -> HelpSearchResponse {
        let results = self.index.search(&self.query);
        HelpSearchResponse {
            generation: self.generation,
            lang: self.lang,
            query: self.query,
            results,
        }
    }
}

impl HelpSearchResponse {
    pub(crate) fn generation(&self) -> u64 {
        self.generation
    }

    pub(crate) fn lang(&self) -> Lang {
        self.lang
    }

    pub(crate) fn query(&self) -> &str {
        &self.query
    }

    pub(crate) fn into_results(self) -> Vec<HelpSearchResult> {
        self.results
    }
}

pub(crate) async fn run_help_search(request: HelpSearchRequest) -> HelpSearchResponse {
    request.resolve()
}

impl HelpSearchRecord {
    fn preview_lines(&self, needle: &str) -> Vec<String> {
        let mut lines = self
            .raw_lines
            .iter()
            .zip(&self.lower_lines)
            .filter(|(_, lower)| lower.contains(needle))
            .map(|(raw, _)| raw.clone())
            .take(MAX_PREVIEW_LINES)
            .collect::<Vec<_>>();

        if lines.is_empty() {
            lines = self
                .raw_lines
                .iter()
                .take(MAX_PREVIEW_LINES)
                .cloned()
                .collect();
        }
        lines
    }
}

impl HelpSearchResult {
    pub(crate) fn node(&self) -> HelpNode {
        self.node
    }

    pub(crate) fn preview_lines(&self) -> &[String] {
        &self.preview_lines
    }
}

fn collect_records(
    node: HelpNode,
    lang: Lang,
    path: &mut Vec<&'static str>,
    seen: &mut BTreeSet<HelpNode>,
    records: &mut Vec<HelpSearchRecord>,
) {
    path.push(lang.t(node.label_key()));

    if node.is_category() {
        for child in node.children() {
            collect_records(*child, lang, path, seen, records);
        }
    } else if seen.insert(node) {
        records.push(record_for(node, lang, path));
    }

    path.pop();
}

fn record_for(node: HelpNode, lang: Lang, path: &[&str]) -> HelpSearchRecord {
    let content = lang.t(node.content_key());
    let raw_lines = content
        .lines()
        .map(str::trim)
        .filter(|line| !line.is_empty())
        .map(str::to_owned)
        .collect::<Vec<_>>();
    let lower_lines = raw_lines
        .iter()
        .map(|line| line.to_lowercase())
        .collect::<Vec<_>>();
    let mut haystack = path.join(" ").to_lowercase();
    haystack.push('\n');
    haystack.push_str(&content.to_lowercase());

    HelpSearchRecord {
        node,
        haystack,
        raw_lines,
        lower_lines,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn category_labels_match_child_topics_through_breadcrumbs() {
        let index = HelpSearchIndex::new(Lang::Ru);

        let results = index.search("состав");

        assert!(
            results
                .iter()
                .any(|result| result.node == HelpNode::TopicSystemComponents)
        );
    }

    #[test]
    fn previews_are_bounded_to_four_lines() {
        let index = HelpSearchIndex::new(Lang::Ru);

        let results = index.search("команда");

        assert!(results.iter().all(|result| result.preview_lines.len() <= 4));
    }
}