version_sync/
markdown_deps.rs1#![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#[derive(Debug, Clone, PartialEq, Eq)]
11struct CodeBlock {
12 content: String,
14 first_line: usize,
16}
17
18fn 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 .and_then(|version| version.as_str())
30 .or_else(|| dep.get("git").and(Some("*")))
32 .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
45fn is_toml_block(lang: &str) -> bool {
47 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
60fn 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 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
93pub 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 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 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}