mdbook_cmdrun/
cmdrun.rs

1use std::borrow::Cow;
2use std::fs;
3use std::path::Path;
4use std::path::PathBuf;
5use std::process::Command;
6
7use anyhow::Context;
8use anyhow::Result;
9use cfg_if::cfg_if;
10use lazy_static::lazy_static;
11use regex::Captures;
12use regex::Regex;
13use serde::Deserialize;
14
15use mdbook::book::Book;
16use mdbook::book::Chapter;
17use mdbook::preprocess::{Preprocessor, PreprocessorContext};
18
19use crate::utils::map_chapter;
20
21pub struct CmdRun;
22
23lazy_static! {
24    static ref CMDRUN_REG_NEWLINE: Regex = Regex::new(r"<!--[ ]*cmdrun (.*?)-->\r?\n")
25        .expect("Failed to init regex for finding newline pattern");
26    static ref CMDRUN_REG_INLINE: Regex = Regex::new(r"<!--[ ]*cmdrun (.*?)-->")
27        .expect("Failed to init regex for finding inline pattern");
28}
29
30cfg_if! {
31    if #[cfg(target_family = "unix")] {
32        const LAUNCH_SHELL_COMMAND: &str = "sh";
33        const LAUNCH_SHELL_FLAG: &str = "-c";
34        const NEWLINE: &str = "\n";
35    } else if #[cfg(target_family = "windows")] {
36        const LAUNCH_SHELL_COMMAND: &str = "cmd";
37        const LAUNCH_SHELL_FLAG: &str = "/C";
38        const NEWLINE: &str = "\r\n";
39    }
40}
41
42impl Preprocessor for CmdRun {
43    fn name(&self) -> &str {
44        "cmdrun"
45    }
46
47    fn supports_renderer(&self, renderer: &str) -> bool {
48        renderer == "html"
49    }
50
51    fn run(&self, _ctx: &PreprocessorContext, mut book: Book) -> Result<Book> {
52        map_chapter(&mut book, &mut CmdRun::run_on_chapter)?;
53
54        Ok(book)
55    }
56}
57
58lazy_static! {
59    static ref SRC_DIR: String = get_src_dir();
60}
61
62#[derive(Deserialize)]
63struct BookConfig {
64    book: BookField,
65}
66
67#[derive(Deserialize)]
68struct BookField {
69    src: Option<String>,
70}
71
72fn get_src_dir() -> String {
73    fs::read_to_string(Path::new("book.toml"))
74        .map_err(|_| None::<String>)
75        .and_then(|fc| toml::from_str::<BookConfig>(fc.as_str()).map_err(|_| None))
76        .and_then(|bc| bc.book.src.ok_or(None))
77        .unwrap_or_else(|_| String::from("src"))
78}
79
80impl CmdRun {
81    fn run_on_chapter(chapter: &mut Chapter) -> Result<()> {
82        let working_dir = &chapter
83            .path
84            .to_owned()
85            .and_then(|p| {
86                Path::new(SRC_DIR.as_str())
87                    .join(p)
88                    .parent()
89                    .map(PathBuf::from)
90            })
91            .and_then(|p| p.to_str().map(String::from))
92            .unwrap_or_default();
93
94        chapter.content = CmdRun::run_on_content(&chapter.content, working_dir)?;
95
96        Ok(())
97    }
98
99    // This method is public for regression tests
100    pub fn run_on_content(content: &str, working_dir: &str) -> Result<String> {
101        let mut err = None;
102
103        let mut result = CMDRUN_REG_NEWLINE
104            .replace_all(content, |caps: &Captures| {
105                Self::run_cmdrun(caps[1].to_string(), working_dir, false).unwrap_or_else(|e| {
106                    err = Some(e);
107                    String::new()
108                })
109            })
110            .to_string();
111
112        if let Some(e) = err {
113            return Err(e);
114        }
115
116        result = CMDRUN_REG_INLINE
117            .replace_all(result.as_str(), |caps: &Captures| {
118                Self::run_cmdrun(caps[1].to_string(), working_dir, true).unwrap_or_else(|e| {
119                    err = Some(e);
120                    String::new()
121                })
122            })
123            .to_string();
124
125        match err {
126            None => Ok(result),
127            Some(err) => Err(err),
128        }
129    }
130
131    // Some progams output linebreaks in UNIX format,
132    // this can cause problems on Windows if for any reason
133    // the user is expecting consistent linebreaks,
134    // e.g. they run the resulting markdown through a validation tool.
135    //
136    // So this function simply replaces all linebreaks with Windows linebreaks.
137    #[cfg(target_family = "windows")]
138    fn format_whitespace(str: Cow<'_, str>, inline: bool) -> String {
139        let str = match inline {
140            // When running inline it is undeseriable to have trailing whitespace
141            true => str.trim_end(),
142            false => str.as_ref(),
143        };
144
145        let mut res = str.lines().collect::<Vec<_>>().join("\r\n");
146        if !inline && !res.is_empty() {
147            res.push_str("\r\n");
148        }
149
150        return res;
151    }
152
153    #[cfg(target_family = "unix")]
154    fn format_whitespace(str: Cow<'_, str>, inline: bool) -> String {
155        match inline {
156            // Wh;n running inline it is undeseriable to have trailing whitespace
157            true => str.trim_end().to_string(),
158            false => str.to_string(),
159        }
160    }
161
162    fn cmdrun_error_message(message: &str, command: &str) -> String {
163        format!("**cmdrun error**: {} in 'cmdrun {}'", message, command)
164    }
165
166    // This method is public for unit tests
167    pub fn run_cmdrun(command: String, working_dir: &str, inline: bool) -> Result<String> {
168        // unfortunately, we need to manually parse the command string for cmdrun's
169        // exit status checking flags.
170        // Some experimentation using clap was done; however, splitting and then re-escaping
171        // the shellwords was found to be a large barrier to using this other tool.
172        let (command, correct_exit_code): (String, Option<i32>) =
173            if let Some(first_word) = command.split_whitespace().next() {
174                if first_word.starts_with('-') {
175                    if first_word.starts_with("--") {
176                        // double-tick long form
177                        match first_word {
178                            "--strict" => (
179                                command
180                                    .split_whitespace()
181                                    .skip(1)
182                                    .collect::<Vec<&str>>()
183                                    .join(" "),
184                                Some(0),
185                            ),
186                            "--expect-return-code" => {
187                                if let Some(second_word) = command.split_whitespace().nth(1) {
188                                    match second_word.parse::<i32>() {
189                                        Ok(return_code) => (
190                                            command
191                                                .split_whitespace()
192                                                .skip(2)
193                                                .collect::<Vec<&str>>()
194                                                .join(" "),
195                                            Some(return_code),
196                                        ),
197                                        Err(_) => {
198                                            return Ok(Self::cmdrun_error_message(
199                                                "No return code after '--expect-return-code'",
200                                                &command,
201                                            ));
202                                        }
203                                    }
204                                } else {
205                                    // no second word after return code, print error
206                                    return Ok(Self::cmdrun_error_message(
207                                        "No return code after '--expect-return-code'",
208                                        &command,
209                                    ));
210                                }
211                            }
212                            some_other_word => {
213                                // unrecognized flag, print error
214                                return Ok(Self::cmdrun_error_message(
215                                    &format!("Unrecognized cmdrun flag {}", some_other_word),
216                                    &command,
217                                ));
218                            }
219                        }
220                    } else {
221                        // single-tick short form
222                        let (_, exit_code) = first_word.rsplit_once('-').unwrap_or(("", "0"));
223                        match exit_code.parse::<i32>() {
224                            Ok(return_code) => (
225                                command
226                                    .split_whitespace()
227                                    .skip(1)
228                                    .collect::<Vec<&str>>()
229                                    .join(" "),
230                                Some(return_code),
231                            ),
232                            Err(_) => {
233                                return Ok(Self::cmdrun_error_message(
234                                    &format!(
235                                        "Unable to interpret short-form exit code {} as a number",
236                                        first_word
237                                    ),
238                                    &command,
239                                ));
240                            }
241                        }
242                    }
243                } else {
244                    (command, None)
245                }
246            } else {
247                (command, None)
248            };
249
250        let output = Command::new(LAUNCH_SHELL_COMMAND)
251            .args([LAUNCH_SHELL_FLAG, &command])
252            .current_dir(working_dir)
253            .output()
254            .with_context(|| "Fail to run shell")?;
255
256        let stdout = Self::format_whitespace(String::from_utf8_lossy(&output.stdout), inline);
257        match (output.status.code(), correct_exit_code) {
258            (None, _) => Ok(Self::cmdrun_error_message(
259                "Command was ended before completing",
260                &command,
261            )),
262            (Some(code), Some(correct_code)) => {
263                if code != correct_code {
264                    Ok(format!(
265                        "**cmdrun error**: '{command}' returned exit code {code} instead of {correct_code}.{0}{1}{0}{2}",
266                        NEWLINE,
267                        String::from_utf8_lossy(&output.stdout),
268                        String::from_utf8_lossy(&output.stderr)))
269                } else {
270                    Ok(stdout)
271                }
272            }
273            (Some(_code), None) => {
274                // no correct code specified, program exited with some code _code
275                // could put default check requiring code to be zero here but
276                // that would break current behavior
277                Ok(stdout)
278            }
279        }
280    }
281}