Skip to main content

mant_loader/loading/
input.rs

1//! Resolves bounded direct files and in-memory document inputs.
2
3#[cfg(feature = "roff")]
4use super::parse_manual_bytes;
5use super::{
6    InputFormat, LoadError, LoadHost, LoadPolicy, LoadSpec, ManualLoadError, OsStr, Path,
7    ResolvedContent, query_named_document,
8};
9use mant_codec::parse_markdown;
10
11pub(super) fn load_with(
12    spec: LoadSpec<'_>,
13    policy: LoadPolicy,
14    host: &dyn LoadHost,
15) -> Result<ResolvedContent, LoadError> {
16    super::validate_load_spec(spec, policy)?;
17    match spec {
18        LoadSpec::Document {
19            selector,
20            source,
21            manual_section,
22        } => query_named_document(selector, source, manual_section, policy, host),
23        LoadSpec::File { path, format } => query_input_file(path, format, policy, host),
24    }
25}
26
27fn query_input_file(
28    requested_path: &str,
29    format: InputFormat,
30    policy: LoadPolicy,
31    host: &dyn LoadHost,
32) -> Result<ResolvedContent, LoadError> {
33    let path = requested_path.trim();
34    if path.is_empty() {
35        return Err(LoadError::EmptyMarkdownPath);
36    }
37    let format = match format {
38        InputFormat::Auto => {
39            detect_input_format(path).ok_or_else(|| LoadError::UnsupportedInputFormat {
40                path: path.to_owned(),
41            })?
42        }
43        format => format,
44    };
45    match format {
46        InputFormat::Markdown => query_markdown_file(path, policy, host),
47        InputFormat::Roff => {
48            if policy != LoadPolicy::Combined {
49                return Err(LoadError::ConflictingSourceSelectors);
50            }
51            if !host.native_available() {
52                return Err(LoadError::NativeBackendUnavailable { tldr_topic: None });
53            }
54            let document = host.parse_manual_input(Path::new(path)).map_err(|detail| {
55                LoadError::Manual(ManualLoadError::Parse {
56                    name: path.to_owned(),
57                    detail,
58                })
59            })?;
60            if document.sections.is_empty() && document.blocks.is_empty() {
61                return Err(LoadError::NoReadableContent {
62                    name: path.to_owned(),
63                });
64            }
65            let label = document
66                .meta
67                .names
68                .first()
69                .cloned()
70                .or_else(|| document.meta.title.clone())
71                .unwrap_or_else(|| input_file_label(path));
72            Ok(ResolvedContent {
73                label,
74                address: None,
75                document: Some(document),
76                tldr: None,
77            })
78        }
79        InputFormat::Auto => unreachable!("auto input was resolved above"),
80    }
81}
82
83fn detect_input_format(path: &str) -> Option<InputFormat> {
84    let mut name = Path::new(path).file_name()?.to_str()?.to_ascii_lowercase();
85    let mut compressed = false;
86    if Path::new(&name)
87        .extension()
88        .and_then(OsStr::to_str)
89        .is_some_and(|extension| matches!(extension, "gz" | "zst"))
90    {
91        name = Path::new(&name).file_stem()?.to_str()?.to_owned();
92        compressed = true;
93    }
94    let extension = Path::new(&name).extension()?.to_str()?;
95    if matches!(extension, "md" | "markdown") {
96        return (!compressed).then_some(InputFormat::Markdown);
97    }
98    if matches!(extension, "roff" | "man" | "mdoc") {
99        return Some(InputFormat::Roff);
100    }
101    crate::is_manual_section(extension).then_some(InputFormat::Roff)
102}
103
104fn input_file_label(path: &str) -> String {
105    Path::new(path)
106        .file_name()
107        .and_then(OsStr::to_str)
108        .unwrap_or(path)
109        .to_owned()
110}
111
112fn query_markdown_file(
113    requested_path: &str,
114    policy: LoadPolicy,
115    host: &dyn LoadHost,
116) -> Result<ResolvedContent, LoadError> {
117    let path = requested_path.trim();
118    if path.is_empty() {
119        return Err(LoadError::EmptyMarkdownPath);
120    }
121    if policy != LoadPolicy::Combined {
122        return Err(LoadError::Markdown {
123            path: path.to_owned(),
124            detail: "content-only policies do not apply to direct input".to_owned(),
125        });
126    }
127    let source = host
128        .read_markdown(Path::new(path))
129        .map_err(|detail| LoadError::Markdown {
130            path: path.to_owned(),
131            detail,
132        })?;
133    load_markdown_text(&source, Some(path.to_owned()))
134}
135
136/// Parse in-memory Markdown for the direct `mant -` command.
137///
138/// This helper accepts only caller-supplied text and labels; it has no request
139/// schema or query view, and never resolves another document.
140///
141/// # Errors
142///
143/// Returns [`LoadError::EmptyMarkdown`] when parsing yields no document heading,
144/// blocks, sections, or quick reference.
145pub fn load_markdown_text(
146    source: &str,
147    source_path: Option<String>,
148) -> Result<ResolvedContent, LoadError> {
149    let label = source_path.as_deref().map_or_else(
150        || "stdin".to_owned(),
151        |path| {
152            Path::new(path)
153                .file_name()
154                .and_then(OsStr::to_str)
155                .unwrap_or(path)
156                .to_owned()
157        },
158    );
159    let error_path = source_path.clone().unwrap_or_else(|| "stdin".to_owned());
160    let parsed = parse_markdown(source, source_path).map_err(|error| LoadError::Markdown {
161        path: error_path,
162        detail: error.to_string(),
163    })?;
164    let document_is_empty = parsed.document.heading.is_none()
165        && parsed.document.blocks.is_empty()
166        && parsed.document.sections.is_empty();
167    if document_is_empty && parsed.tldr.is_none() {
168        return Err(LoadError::EmptyMarkdown {
169            label: label.clone(),
170        });
171    }
172    Ok(ResolvedContent {
173        address: None,
174        label,
175        document: (!document_is_empty).then_some(parsed.document),
176        tldr: parsed.tldr,
177    })
178}
179
180/// Parse one bounded roff stream without consulting MANPATH or following `.so`.
181///
182/// # Errors
183///
184/// Returns a native parse error or an empty-document error.
185#[cfg(feature = "roff")]
186pub fn load_roff_bytes(source: &[u8]) -> Result<ResolvedContent, LoadError> {
187    if u64::try_from(source.len()).unwrap_or(u64::MAX) > crate::MAX_MANUAL_BYTES {
188        return Err(LoadError::Manual(ManualLoadError::Parse {
189            name: "stdin".to_owned(),
190            detail: format!(
191                "roff input exceeds the {}-byte limit",
192                crate::MAX_MANUAL_BYTES
193            ),
194        }));
195    }
196    let document = parse_manual_bytes(Path::new("stdin"), source).map_err(|error| {
197        LoadError::Manual(ManualLoadError::Parse {
198            name: "stdin".to_owned(),
199            detail: error.to_string(),
200        })
201    })?;
202    if document.sections.is_empty() && document.blocks.is_empty() {
203        return Err(LoadError::NoReadableContent {
204            name: "stdin".to_owned(),
205        });
206    }
207    let label = document
208        .meta
209        .names
210        .first()
211        .cloned()
212        .or_else(|| document.meta.title.clone())
213        .unwrap_or_else(|| "stdin".to_owned());
214    Ok(ResolvedContent {
215        address: None,
216        label,
217        document: Some(document),
218        tldr: None,
219    })
220}