Skip to main content

lexd/
help.rs

1//! In-editor help system for Lex format documentation.
2//!
3//! Provides access to the Lex specification and guide documents from within
4//! editors. This enables features like `:LexHelp` commands that display
5//! contextual documentation without leaving the editor.
6//!
7//! The help system discovers `.lex` files in the `docs/` and `specs/` directories
8//! of the Lex repository and returns their contents filtered by topic.
9//!
10//! # Compile-time Dependency
11//!
12//! This module uses `CARGO_MANIFEST_DIR` to locate documentation files at compile
13//! time. This works for development builds but requires the documentation to be
14//! bundled with the binary for distribution. For CLI usage, the docs are typically
15//! available; for LSP commands, consider alternative discovery mechanisms.
16
17use std::fs;
18use std::io;
19use std::path::{Path, PathBuf};
20
21/// A single help document with its title and content.
22#[derive(Debug, Clone, PartialEq)]
23pub struct HelpEntry {
24    /// Display title derived from the file path (e.g., "specs/grammar.lex").
25    pub title: String,
26    /// The full content of the documentation file.
27    pub content: String,
28}
29
30/// Response containing matching help entries.
31#[derive(Debug, Clone, PartialEq)]
32pub struct HelpResponse {
33    /// Matching documentation entries, possibly empty if no matches found.
34    pub entries: Vec<HelpEntry>,
35}
36
37/// Queries the documentation for help on a topic.
38///
39/// If `topic` is `Some`, filters entries to those whose paths contain the topic
40/// string (case-insensitive). If `None`, returns default entries: the main
41/// overview document and general reference, or up to 3 arbitrary docs if those
42/// aren't found.
43///
44/// # Errors
45///
46/// Returns `io::Error` if the documentation directories cannot be read.
47pub fn query_help(topic: Option<&str>) -> io::Result<HelpResponse> {
48    let files = discover_files(&["comms/docs", "comms/specs"])?;
49    let matches = match topic {
50        Some(keyword) => filter_by_topic(&files, keyword),
51        None => default_entries(&files),
52    };
53    let mut entries = Vec::new();
54    for path in matches {
55        if let Ok(content) = fs::read_to_string(&path) {
56            entries.push(HelpEntry {
57                title: display_title(&path),
58                content,
59            });
60        }
61    }
62    Ok(HelpResponse { entries })
63}
64
65fn discover_files(roots: &[&str]) -> io::Result<Vec<PathBuf>> {
66    let base = Path::new(env!("CARGO_MANIFEST_DIR"))
67        .parent()
68        .and_then(|p| p.parent())
69        .unwrap_or_else(|| Path::new("."));
70    let mut files = Vec::new();
71    for root in roots {
72        let path = base.join(root);
73        if path.exists() {
74            collect_files(&path, &mut files)?;
75        }
76    }
77    Ok(files)
78}
79
80fn collect_files(dir: &Path, files: &mut Vec<PathBuf>) -> io::Result<()> {
81    for entry in fs::read_dir(dir)? {
82        let entry = entry?;
83        let path = entry.path();
84        if path.is_dir() {
85            collect_files(&path, files)?;
86        } else if path.extension().and_then(|ext| ext.to_str()) == Some("lex") {
87            files.push(path);
88        }
89    }
90    Ok(())
91}
92
93fn filter_by_topic(files: &[PathBuf], topic: &str) -> Vec<PathBuf> {
94    let needle = topic.to_ascii_lowercase();
95    files
96        .iter()
97        .filter(|path| {
98            path.to_string_lossy()
99                .to_ascii_lowercase()
100                .contains(&needle)
101        })
102        .cloned()
103        .collect()
104}
105
106fn default_entries(files: &[PathBuf]) -> Vec<PathBuf> {
107    let mut entries = Vec::new();
108    if let Some(entry) = find_file(files, "on-all-of-lex") {
109        entries.push(entry);
110    }
111    if let Some(entry) = find_file(files, "general.lex") {
112        entries.push(entry);
113    }
114    if entries.is_empty() {
115        entries.extend_from_slice(&files[..files.len().min(3)]);
116    }
117    entries
118}
119
120fn find_file(files: &[PathBuf], needle: &str) -> Option<PathBuf> {
121    let lower = needle.to_ascii_lowercase();
122    files
123        .iter()
124        .find(|path| path.to_string_lossy().to_ascii_lowercase().contains(&lower))
125        .cloned()
126}
127
128fn display_title(path: &Path) -> String {
129    path.strip_prefix("docs")
130        .or_else(|_| path.strip_prefix("specs"))
131        .unwrap_or(path)
132        .to_string_lossy()
133        .trim_start_matches('/')
134        .trim_start_matches('\\')
135        .replace("\\", "/")
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn returns_general_help_without_topic() {
144        let response = query_help(None).expect("help");
145        assert!(!response.entries.is_empty());
146        assert!(
147            response.entries[0].title.contains("on-all-of-lex")
148                || response.entries[0].title.contains("general")
149        );
150    }
151
152    #[test]
153    fn filters_entries_by_topic() {
154        let response = query_help(Some("grammar")).expect("help");
155        assert!(response
156            .entries
157            .iter()
158            .any(|entry| entry.title.to_ascii_lowercase().contains("grammar")));
159    }
160}