newdoc/
module.rs

1/*
2newdoc: Generate pre-populated documentation modules formatted with AsciiDoc.
3Copyright (C) 2022  Marek Suchánek  <msuchane@redhat.com>
4
5This program is free software: you can redistribute it and/or modify
6it under the terms of the GNU General Public License as published by
7the Free Software Foundation, either version 3 of the License, or
8(at your option) any later version.
9
10This program is distributed in the hope that it will be useful,
11but WITHOUT ANY WARRANTY; without even the implied warranty of
12MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13GNU General Public License for more details.
14
15You should have received a copy of the GNU General Public License
16along with this program.  If not, see <https://www.gnu.org/licenses/>.
17*/
18
19//! This module defines the `Module` struct, its builder struct, and methods on both structs.
20
21use std::fmt;
22use std::path::{Component, Path, PathBuf};
23
24use crate::Options;
25
26/// All possible types of the AsciiDoc module
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum ContentType {
29    Assembly,
30    Concept,
31    Procedure,
32    Reference,
33    Snippet,
34}
35
36// Implement human-readable string display for the module type
37impl fmt::Display for ContentType {
38    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39        let name = match self {
40            Self::Assembly => "assembly",
41            Self::Concept => "concept",
42            Self::Procedure => "procedure",
43            Self::Reference => "reference",
44            Self::Snippet => "snippet",
45        };
46        write!(f, "{name}")
47    }
48}
49
50/// An initial representation of the module with input data, used to construct the `Module` struct
51#[derive(Debug)]
52pub struct Input {
53    pub mod_type: ContentType,
54    pub title: String,
55    pub options: Options,
56    pub includes: Option<Vec<String>>,
57}
58
59/// A representation of the module with all its metadata and the generated AsciiDoc content
60#[derive(Debug, PartialEq, Eq)]
61pub struct Module {
62    mod_type: ContentType,
63    title: String,
64    anchor: String,
65    pub file_name: String,
66    pub include_statement: String,
67    includes: Option<Vec<String>>,
68    pub text: String,
69}
70
71/// Construct a basic builder for `Module`, storing information from the user input.
72impl Input {
73    #[must_use]
74    pub fn new(mod_type: ContentType, title: &str, options: &Options) -> Input {
75        log::debug!("Processing title `{}` of type `{:?}`", title, mod_type);
76
77        let title = String::from(title);
78        let options = options.clone();
79
80        Input {
81            mod_type,
82            title,
83            options,
84            includes: None,
85        }
86    }
87
88    /// Set the optional include statements for files that this assembly includes
89    #[must_use]
90    pub fn include(mut self, include_statements: Vec<String>) -> Self {
91        self.includes = Some(include_statements);
92        self
93    }
94
95    /// Create an ID string that is derived from the human-readable title. The ID is usable as:
96    ///
97    /// * An AsciiDoc section ID
98    /// * A DocBook section ID
99    /// * A file name
100    ///
101    /// # Examples
102    ///
103    /// ```
104    /// use newdoc::{ContentType, Input, Options};
105    ///
106    /// let mod_type = ContentType::Concept;
107    /// let title = "A test -- with #problematic ? characters";
108    /// let options = Options::default();
109    /// let input = Input::new(mod_type, title, &options);
110    ///
111    /// assert_eq!("a-test-with-problematic-characters", input.id());
112    /// ```
113    #[must_use]
114    pub fn id(&self) -> String {
115        let title = &self.title;
116        // The ID is all lower-case
117        let mut title_with_replacements: String = String::from(title).to_lowercase();
118
119        // Replace characters that aren't allowed in the ID, usually with a dash or an empty string
120        let substitutions = [
121            (" ", "-"),
122            ("(", ""),
123            (")", ""),
124            ("?", ""),
125            ("!", ""),
126            ("'", ""),
127            ("\"", ""),
128            ("#", ""),
129            ("%", ""),
130            ("&", ""),
131            ("*", ""),
132            (",", "-"),
133            (".", "-"),
134            ("/", "-"),
135            (":", "-"),
136            (";", ""),
137            ("@", "-at-"),
138            ("\\", ""),
139            ("`", ""),
140            ("$", ""),
141            ("^", ""),
142            ("|", ""),
143            ("=", "-"),
144            // Remove known semantic markup from the ID:
145            ("[package]", ""),
146            ("[option]", ""),
147            ("[parameter]", ""),
148            ("[variable]", ""),
149            ("[command]", ""),
150            ("[replaceable]", ""),
151            ("[filename]", ""),
152            ("[literal]", ""),
153            ("[systemitem]", ""),
154            ("[application]", ""),
155            ("[function]", ""),
156            ("[gui]", ""),
157            // Remove square brackets only after semantic markup:
158            ("[", ""),
159            ("]", ""),
160            // TODO: Curly braces shouldn't appear in the title in the first place.
161            // They'd be interpreted as attributes there.
162            // Print an error in that case? Escape them with AsciiDoc escapes?
163            ("{", ""),
164            ("}", ""),
165        ];
166
167        // Perform all the defined replacements on the title
168        for (old, new) in substitutions {
169            title_with_replacements = title_with_replacements.replace(old, new);
170        }
171
172        // Replace remaining characters that aren't ASCII, or that are non-alphanumeric ASCII,
173        // with dashes. For example, this replaces diacritics and typographic quotation marks.
174        title_with_replacements = title_with_replacements
175            .chars()
176            .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
177            .collect();
178
179        // Ensure the converted ID doesn't contain double dashes ("--"), because
180        // that breaks references to the ID
181        while title_with_replacements.contains("--") {
182            title_with_replacements = title_with_replacements.replace("--", "-");
183        }
184
185        // Ensure that the ID doesn't end with a dash
186        if title_with_replacements.ends_with('-') {
187            let len = title_with_replacements.len();
188            title_with_replacements = title_with_replacements[..len - 1].to_string();
189        }
190
191        title_with_replacements
192    }
193
194    /// Prepare the file name for the generated file.
195    ///
196    /// The file name is based on the module ID,
197    /// with an optional prefix and the `.adoc` extension.
198    ///
199    /// # Examples
200    ///
201    /// ```
202    /// use newdoc::{ContentType, Input, Options};
203    ///
204    /// let mod_type = ContentType::Concept;
205    /// let title = "Default file name configuration";
206    /// let options = Options::default();
207    /// let input = Input::new(mod_type, title, &options);
208    ///
209    /// assert_eq!("con_default-file-name-configuration.adoc", input.file_name());
210    ///
211    /// let mod_type = ContentType::Concept;
212    /// let title = "No prefix file name configuration";
213    /// let options = Options {
214    ///     file_prefixes: false,
215    ///     ..Default::default()
216    /// };
217    /// let input = Input::new(mod_type, title, &options);
218    ///
219    /// assert_eq!("no-prefix-file-name-configuration.adoc", input.file_name());
220    /// ```
221    #[must_use]
222    pub fn file_name(&self) -> String {
223        // Add a prefix only if they're enabled.
224        let prefix = if self.options.file_prefixes {
225            self.prefix()
226        } else {
227            ""
228        };
229
230        let id = self.id();
231
232        let suffix = ".adoc";
233
234        [prefix, &id, suffix].join("")
235    }
236
237    /// Prepare the AsciiDoc anchor or ID.
238    ///
239    /// The anchor is based on the module ID, with an optional prefix.
240    ///
241    /// # Examples
242    ///
243    /// ```
244    /// use newdoc::{ContentType, Input, Options};
245    ///
246    /// let mod_type = ContentType::Concept;
247    /// let title = "Default anchor configuration";
248    /// let options = Options::default();
249    /// let input = Input::new(mod_type, title, &options);
250    ///
251    /// assert_eq!("default-anchor-configuration", input.anchor());
252    ///
253    /// let mod_type = ContentType::Concept;
254    /// let title = "Prefix anchor configuration";
255    /// let options = Options {
256    ///     anchor_prefixes: true,
257    ///     ..Default::default()
258    /// };
259    /// let input = Input::new(mod_type, title, &options);
260    ///
261    /// assert_eq!("con_prefix-anchor-configuration", input.anchor());
262    #[must_use]
263    pub fn anchor(&self) -> String {
264        // Add a prefix only if they're enabled.
265        let prefix = if self.options.anchor_prefixes {
266            self.prefix()
267        } else {
268            ""
269        };
270
271        let id = self.id();
272
273        [prefix, &id].join("")
274    }
275
276    /// Pick the right file and ID prefix depending on the content type.
277    fn prefix(&self) -> &'static str {
278        match self.mod_type {
279            ContentType::Assembly => "assembly_",
280            ContentType::Concept => "con_",
281            ContentType::Procedure => "proc_",
282            ContentType::Reference => "ref_",
283            ContentType::Snippet => "snip_",
284        }
285    }
286
287    /// Prepare an include statement that can be used to include the generated file from elsewhere.
288    fn include_statement(&self) -> String {
289        let path_placeholder = Path::new("<path>").to_path_buf();
290
291        let include_path = match self.infer_include_dir() {
292            Some(path) => path,
293            None => path_placeholder,
294        };
295
296        format!(
297            "include::{}/{}[leveloffset=+1]",
298            include_path.display(),
299            &self.file_name()
300        )
301    }
302
303    /// Determine the start of the include statement from the target path.
304    /// Returns the relative path that can be used in the include statement, if it's possible
305    /// to determine it automatically.
306    fn infer_include_dir(&self) -> Option<PathBuf> {
307        // The first directory in the include path is either `assemblies/` or `modules/`,
308        // based on the module type, or `snippets/` for snippet files.
309        let include_root = match &self.mod_type {
310            ContentType::Assembly => "assemblies",
311            ContentType::Snippet => "snippets",
312            _ => "modules",
313        };
314
315        // TODO: Maybe convert the path earlier in the module building.
316        let relative_path = Path::new(&self.options.target_dir);
317        // Try to find the root element in an absolute path.
318        // If the absolute path cannot be constructed due to an error, search the relative path instead.
319        let target_path = match relative_path.canonicalize() {
320            Ok(path) => path,
321            Err(_) => relative_path.to_path_buf(),
322        };
323
324        // Split the target path into components
325        let component_vec: Vec<_> = target_path
326            .as_path()
327            .components()
328            .map(Component::as_os_str)
329            .collect();
330
331        // Find the position of the component that matches the root element,
332        // searching from the end of the path forward.
333        let root_position = component_vec.iter().rposition(|&c| c == include_root);
334
335        // If there is such a root element in the path, construct the include path.
336        // TODO: To be safe, check that the root path element still exists in a Git repository.
337        if let Some(position) = root_position {
338            let include_path = component_vec[position..].iter().collect::<PathBuf>();
339            Some(include_path)
340        // If no appropriate root element was found, use a generic placeholder.
341        } else {
342            None
343        }
344    }
345}
346
347impl From<Input> for Module {
348    /// Convert the `Input` builder struct into the finished `Module` struct.
349    fn from(input: Input) -> Self {
350        let module = Module {
351            mod_type: input.mod_type,
352            title: input.title.clone(),
353            anchor: input.anchor(),
354            file_name: input.file_name(),
355            include_statement: input.include_statement(),
356            includes: input.includes.clone(),
357            text: input.text(),
358        };
359
360        log::debug!("Generated module properties:");
361        log::debug!("Type: {:?}", &module.mod_type);
362        log::debug!("Anchor: {}", &module.anchor);
363        log::debug!("File name: {}", &module.file_name);
364        log::debug!("Include statement: {}", &module.include_statement);
365        log::debug!(
366            "Included modules: {}",
367            if let Some(includes) = &module.includes {
368                includes.join(", ")
369            } else {
370                "none".to_string()
371            }
372        );
373
374        module
375    }
376}
377
378impl Module {
379    /// The constructor for the Module struct. Creates a basic version of Module
380    /// without any optional features.
381    #[must_use]
382    pub fn new(mod_type: ContentType, title: &str, options: &Options) -> Module {
383        let input = Input::new(mod_type, title, options);
384        input.into()
385    }
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391    use crate::{Options, Verbosity};
392
393    fn basic_options() -> Options {
394        Options {
395            comments: false,
396            file_prefixes: true,
397            anchor_prefixes: false,
398            examples: true,
399            target_dir: PathBuf::from("."),
400            verbosity: Verbosity::Default,
401            ..Default::default()
402        }
403    }
404
405    fn path_options() -> Options {
406        Options {
407            comments: false,
408            file_prefixes: true,
409            anchor_prefixes: false,
410            examples: true,
411            target_dir: PathBuf::from("repo/modules/topic/"),
412            verbosity: Verbosity::Default,
413            ..Default::default()
414        }
415    }
416
417    #[test]
418    fn check_basic_assembly_fields() {
419        let options = basic_options();
420        let assembly = Module::new(
421            ContentType::Assembly,
422            "A testing assembly with /special-characters*",
423            &options,
424        );
425
426        assert_eq!(assembly.mod_type, ContentType::Assembly);
427        assert_eq!(
428            assembly.title,
429            "A testing assembly with /special-characters*"
430        );
431        assert_eq!(
432            assembly.anchor,
433            "a-testing-assembly-with-special-characters"
434        );
435        assert_eq!(
436            assembly.file_name,
437            "assembly_a-testing-assembly-with-special-characters.adoc"
438        );
439        assert_eq!(assembly.include_statement, "include::<path>/assembly_a-testing-assembly-with-special-characters.adoc[leveloffset=+1]");
440        assert_eq!(assembly.includes, None);
441    }
442
443    #[test]
444    fn check_module_builder_and_new() {
445        let options = basic_options();
446        let from_new: Module = Module::new(
447            ContentType::Assembly,
448            "A testing assembly with /special-characters*",
449            &options,
450        );
451        let from_builder: Module = Input::new(
452            ContentType::Assembly,
453            "A testing assembly with /special-characters*",
454            &options,
455        )
456        .into();
457        assert_eq!(from_new, from_builder);
458    }
459
460    #[test]
461    fn check_detected_path() {
462        let options = path_options();
463
464        let module = Module::new(
465            ContentType::Procedure,
466            "Testing the detected path",
467            &options,
468        );
469
470        assert_eq!(
471            module.include_statement,
472            "include::modules/topic/proc_testing-the-detected-path.adoc[leveloffset=+1]"
473        );
474    }
475}