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}