version_sync/
markdown_deps.rs

1#![cfg(feature = "markdown_deps_updated")]
2use pulldown_cmark::CodeBlockKind::Fenced;
3use pulldown_cmark::{Event, Parser, Tag};
4use semver::{Version, VersionReq};
5use toml::Value;
6
7use crate::helpers::{indent, read_file, version_matches_request, Result};
8
9/// A fenced code block.
10#[derive(Debug, Clone, PartialEq, Eq)]
11struct CodeBlock {
12    /// Text between the fences.
13    content: String,
14    /// Line number starting with 1.
15    first_line: usize,
16}
17
18/// Extract a dependency on the given package from a TOML code block.
19fn extract_version_request(pkg_name: &str, block: &str) -> Result<VersionReq> {
20    match block.parse::<Value>() {
21        Ok(value) => {
22            let version = value
23                .get("dependencies")
24                .or_else(|| value.get("dev-dependencies"))
25                .and_then(|deps| deps.get(pkg_name))
26                .and_then(|dep| {
27                    dep.get("version")
28                        // pkg_name = { version = "1.2.3" }
29                        .and_then(|version| version.as_str())
30                        // pkg_name = { git = "..." }
31                        .or_else(|| dep.get("git").and(Some("*")))
32                        // pkg_name = "1.2.3"
33                        .or_else(|| dep.as_str())
34                });
35            match version {
36                Some(version) => VersionReq::parse(version)
37                    .map_err(|err| format!("could not parse dependency: {}", err)),
38                None => Err(format!("no dependency on {pkg_name}")),
39            }
40        }
41        Err(err) => Err(format!("{err}")),
42    }
43}
44
45/// Check if a code block language line says the block is TOML code.
46fn is_toml_block(lang: &str) -> bool {
47    // Split the language line as LangString::parse from rustdoc:
48    // https://github.com/rust-lang/rust/blob/1.20.0/src/librustdoc/html/markdown.rs#L922
49    let mut has_toml = false;
50    for token in lang.split(|c: char| !(c == '_' || c == '-' || c.is_alphanumeric())) {
51        match token.trim() {
52            "no_sync" => return false,
53            "toml" => has_toml = true,
54            _ => {}
55        }
56    }
57    has_toml
58}
59
60/// Find all TOML code blocks in a Markdown text.
61fn find_toml_blocks(text: &str) -> Vec<CodeBlock> {
62    let parser = Parser::new(text);
63    let mut code_blocks = Vec::new();
64    let mut current_block = None;
65    for (event, range) in parser.into_offset_iter() {
66        match event {
67            Event::Start(Tag::CodeBlock(Fenced(lang))) if is_toml_block(&lang) => {
68                // Count number of newlines before the ```. This gives
69                // us the line number of the fence, counted from 0.
70                let line_count = text[..range.start].chars().filter(|&ch| ch == '\n').count();
71                current_block = Some(CodeBlock {
72                    first_line: line_count + 2,
73                    content: String::new(),
74                });
75            }
76            Event::Text(code) => {
77                if let Some(block) = current_block.as_mut() {
78                    block.content.push_str(&code);
79                }
80            }
81            Event::End(Tag::CodeBlock(_)) => {
82                if let Some(block) = current_block.take() {
83                    code_blocks.push(block);
84                }
85            }
86            _ => {}
87        }
88    }
89
90    code_blocks
91}
92
93/// Check dependencies in Markdown code blocks.
94///
95/// This function finds all TOML code blocks in `path` and looks for
96/// dependencies on `pkg_name` in those blocks. A code block fails the
97/// check if it has a dependency on `pkg_name` that doesn't match
98/// `pkg_version`, or if it has no dependency on `pkg_name` at all.
99///
100/// # Examples
101///
102/// Consider a package named `foo` with version 1.2.3. The following
103/// TOML block will pass the test:
104///
105/// ~~~markdown
106/// ```toml
107/// [dependencies]
108/// foo = "1.2.3"
109/// ```
110/// ~~~
111///
112/// Both `dependencies` and `dev-dependencies` are examined. If you
113/// want to skip a block, add `no_sync` to the language line:
114///
115/// ~~~markdown
116/// ```toml,no_sync
117/// [dependencies]
118/// foo = "1.2.3"
119/// ```
120/// ~~~
121///
122/// Code blocks also fail the check if they cannot be parsed as TOML.
123///
124/// # Errors
125///
126/// If any block fails the check, an `Err` is returned with a succinct
127/// error message. Status information has then already been printed on
128/// `stdout`.
129pub fn check_markdown_deps(path: &str, pkg_name: &str, pkg_version: &str) -> Result<()> {
130    let text = read_file(path).map_err(|err| format!("could not read {}: {}", path, err))?;
131    let version = Version::parse(pkg_version)
132        .map_err(|err| format!("bad package version {:?}: {}", pkg_version, err))?;
133
134    println!("Checking code blocks in {path}...");
135    let mut failed = false;
136    for block in find_toml_blocks(&text) {
137        let result = extract_version_request(pkg_name, &block.content)
138            .and_then(|request| version_matches_request(&version, &request));
139        match result {
140            Err(err) => {
141                failed = true;
142                println!("{} (line {}) ... {} in", path, block.first_line, err);
143                println!("{}\n", indent(&block.content));
144            }
145            Ok(()) => println!("{} (line {}) ... ok", path, block.first_line),
146        }
147    }
148
149    if failed {
150        return Err(format!("dependency errors in {path}"));
151    }
152    Ok(())
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn empty_markdown_file() {
161        assert_eq!(find_toml_blocks(""), vec![]);
162    }
163
164    #[test]
165    fn indented_code_block() {
166        assert_eq!(find_toml_blocks("    code block\n"), vec![]);
167    }
168
169    #[test]
170    fn empty_toml_block() {
171        assert_eq!(
172            find_toml_blocks("```toml\n```"),
173            vec![CodeBlock {
174                content: String::new(),
175                first_line: 2
176            }]
177        );
178    }
179
180    #[test]
181    fn no_close_fence() {
182        assert_eq!(
183            find_toml_blocks("```toml\n"),
184            vec![CodeBlock {
185                content: String::new(),
186                first_line: 2
187            }]
188        );
189    }
190
191    #[test]
192    fn nonempty_toml_block() {
193        let text = "Preceding text.\n\
194                    ```toml\n\
195                    foo\n\
196                    ```\n\
197                    Trailing text";
198        assert_eq!(
199            find_toml_blocks(text),
200            vec![CodeBlock {
201                content: String::from("foo\n"),
202                first_line: 3
203            }]
204        );
205    }
206
207    #[test]
208    fn blockquote_toml_block() {
209        let text = "> This is a blockquote\n\
210                    >\n\
211                    > ```toml\n\
212                    > foo\n\
213                    > \n\
214                    >   bar\n\
215                    >\n\
216                    > ```\n\
217                    ";
218        assert_eq!(
219            find_toml_blocks(text),
220            vec![CodeBlock {
221                content: String::from("foo\n\n  bar\n\n"),
222                first_line: 4
223            }]
224        );
225    }
226
227    #[test]
228    fn is_toml_block_simple() {
229        assert!(!is_toml_block("rust"));
230    }
231
232    #[test]
233    fn is_toml_block_comma() {
234        assert!(is_toml_block("foo,toml"));
235    }
236
237    #[test]
238    fn is_toml_block_no_sync() {
239        assert!(!is_toml_block("toml,no_sync"));
240        assert!(!is_toml_block("toml, no_sync"));
241    }
242
243    #[test]
244    fn simple() {
245        let block = "[dependencies]\n\
246                     foobar = '1.5'";
247        let request = extract_version_request("foobar", block);
248        assert_eq!(request.unwrap(), VersionReq::parse("1.5").unwrap());
249    }
250
251    #[test]
252    fn table() {
253        let block = "[dependencies]\n\
254                     foobar = { version = '1.5', default-features = false }";
255        let request = extract_version_request("foobar", block);
256        assert_eq!(request.unwrap(), VersionReq::parse("1.5").unwrap());
257    }
258
259    #[test]
260    fn git_dependency() {
261        // Git dependencies are translated into a "*" dependency
262        // and are thus always accepted.
263        let block = "[dependencies]\n\
264                     foobar = { git = 'https://example.net/foobar.git' }";
265        let request = extract_version_request("foobar", block);
266        assert_eq!(request.unwrap(), VersionReq::parse("*").unwrap());
267    }
268
269    #[test]
270    fn dev_dependencies() {
271        let block = "[dev-dependencies]\n\
272                     foobar = '1.5'";
273        let request = extract_version_request("foobar", block);
274        assert_eq!(request.unwrap(), VersionReq::parse("1.5").unwrap());
275    }
276
277    #[test]
278    fn bad_version() {
279        let block = "[dependencies]\n\
280                     foobar = '1.5.bad'";
281        let request = extract_version_request("foobar", block);
282        assert_eq!(
283            request.unwrap_err(),
284            "could not parse dependency: \
285             unexpected character 'b' while parsing patch version number"
286        );
287    }
288
289    #[test]
290    fn missing_dependency() {
291        let block = "[dependencies]\n\
292                     baz = '1.5.8'";
293        let request = extract_version_request("foobar", block);
294        assert_eq!(request.unwrap_err(), "no dependency on foobar");
295    }
296
297    #[test]
298    fn empty() {
299        let request = extract_version_request("foobar", "");
300        assert_eq!(request.unwrap_err(), "no dependency on foobar");
301    }
302
303    #[test]
304    fn bad_toml() {
305        let block = "[dependencies]\n\
306                     foobar = 1.5.8";
307        let request = extract_version_request("foobar", block);
308        assert!(request.is_err());
309    }
310
311    #[test]
312    fn bad_path() {
313        let no_such_file = if cfg!(unix) {
314            "No such file or directory (os error 2)"
315        } else {
316            "The system cannot find the file specified. (os error 2)"
317        };
318        let errmsg = format!("could not read no-such-file.md: {no_such_file}");
319        assert_eq!(
320            check_markdown_deps("no-such-file.md", "foobar", "1.2.3"),
321            Err(errmsg)
322        );
323    }
324
325    #[test]
326    fn bad_pkg_version() {
327        // This uses the README.md file from this crate.
328        assert_eq!(
329            check_markdown_deps("README.md", "foobar", "1.2"),
330            Err(String::from(
331                "bad package version \"1.2\": unexpected end of input while parsing minor version number"
332            ))
333        );
334    }
335}