1pub mod config;
2pub mod fs;
3pub mod indexing;
4pub mod parsing;
5pub mod render;
6
7use std::fs::{File, create_dir_all};
8use std::io::Write;
9use std::path::PathBuf;
10
11use crate::config::ConfigBuilder;
12use crate::fs::{crawl_notebooks, crawl_package};
13pub use crate::fs::{get_module_name, get_package_modules, walk_package};
14use crate::indexing::external::cache::init_cache;
15use crate::indexing::external::fetch::fill_cache;
16use crate::indexing::index::RawIndex;
17use crate::parsing::sphinx::inv_file::parse_objects_inv_file;
18use crate::parsing::sphinx::types::{ExternalSphinxRef, StdRole};
19use crate::render::formats::Renderer;
20pub use crate::render::render_module;
21use crate::render::{jupyter::render_notebook, render_object};
22use parsing::sphinx::types::SphinxType;
23
24use color_eyre::Result;
25use color_eyre::eyre::eyre;
26use tera::Context;
27use url::Url;
28
29pub async fn render_docs(config_builder: ConfigBuilder) -> Result<Vec<PathBuf>> {
30 let config = config_builder.build()?;
31 let absolute_pkg_path = config.pkg_path.canonicalize()?;
32 let out_api_path = if let Some(content_path) = config.renderer.content_path() {
33 config
34 .site_root
35 .join(content_path)
36 .join(&config.api_content_path)
37 } else {
38 config.site_root.join(&config.api_content_path)
39 };
40
41 let errored = vec![];
42
43 let mut ctx = Context::new();
44 let sd_version = env!("CARGO_PKG_VERSION_MAJOR");
45 ctx.insert("SNAKEDOWN_VERSION", &sd_version);
46
47 tracing::info!("indexing package at {}", &absolute_pkg_path.display());
48 let mut index = RawIndex::new(
49 absolute_pkg_path.clone(),
50 config.skip_undoc,
51 config.skip_private,
52 )?;
53
54 let cache_path = init_cache(None)?;
55
56 if config.offline {
57 tracing::info!("Skipping fetching external indexes because running in offline mode.")
58 } else {
59 fill_cache(&config.externals).await?;
60 }
61
62 for (key, ext_index) in config.externals {
63 let inv_path = cache_path.join("sphinx").join(key).with_extension("inv");
64
65 if !inv_path.exists() && config.offline {
68 continue;
69 }
70 let external_base_url = Url::parse(&ext_index.url)?;
71
72 let inv_references = parse_objects_inv_file(&inv_path)?;
73 for r in inv_references {
74 if !should_include_reference(&r) {
75 continue;
76 }
77 index
78 .external_object_store
79 .insert(r.name, external_base_url.clone().join(&r.location)?);
80 }
81 }
82
83 crawl_package(
84 &mut index,
85 &absolute_pkg_path,
86 config.skip_private,
87 config.exclude.clone(),
88 )?;
89
90 if let Some(nb_path) = &config.notebook_path {
91 tracing::debug!("crawling notebooks");
92 crawl_notebooks(&mut index, nb_path)?;
93 }
94
95 match index.validate_references() {
96 Ok(_) => Ok(()),
97 Err(errors) => Err(eyre!(
98 "Found {} invalid references(s):\n{:?}",
99 errors.len(),
100 errors
101 )),
102 }?;
103
104 index.pre_process(&config.renderer, &config.api_content_path)?;
105
106 if !config.skip_write {
107 create_dir_all(&out_api_path)?;
108 }
109
110 for (key, object) in index.internal_object_store.iter() {
111 let file_path = out_api_path.join(key).with_added_extension("md");
112 let rendered = render_object(object, key.clone(), &config.renderer, &ctx)?;
113 let rendered_trimmed = rendered.trim_start();
114 if !config.skip_write {
115 let mut file = File::create(file_path)?;
116 file.write_all(rendered_trimmed.as_bytes())?;
117 }
118 }
119
120 if let Some(notebook_path) = &config.notebook_path {
121 let out_nb_path = if let Some(content_path) = config.renderer.content_path() {
122 config.site_root.clone().join(content_path).join(
123 config
124 .notebook_content_path
125 .clone()
126 .unwrap_or(notebook_path.clone()),
127 )
128 } else {
129 config.site_root.clone().join(
130 config
131 .notebook_content_path
132 .clone()
133 .unwrap_or(notebook_path.clone()),
134 )
135 };
136 if !config.skip_write {
137 create_dir_all(&out_nb_path)?;
138 }
139 for (key, cells) in index.notebook_store.iter() {
140 let dir_path = out_nb_path.join(key);
141 let file_path = dir_path.clone().join("index").with_added_extension("md");
142 let mut rendered = render_notebook(
143 dir_path
144 .file_stem()
145 .map(|p| p.display().to_string())
146 .as_deref(),
147 cells,
148 &config.renderer,
149 )?;
150 if !rendered.text.ends_with("\n") {
152 rendered.text.push('\n');
153 }
154
155 if !config.skip_write {
156 create_dir_all(dir_path.clone())?;
157 let mut file = File::create(file_path)?;
158 file.write_all(rendered.text.as_bytes())?;
159 for img in rendered.images {
160 let mut img_file = File::create(dir_path.join(img.name))?;
161 img_file.write_all(&img.data)?;
162 }
163 }
164 }
165 }
166
167 if let Some((index_file_path, index_file_content)) =
168 &config.renderer.index_file(Some("API".to_string()))
169 && !config.skip_write
170 {
171 let mut file = File::create(out_api_path.join(index_file_path))?;
172 file.write_all(index_file_content.as_bytes())?;
173 }
174
175 Ok(errored)
176}
177
178fn should_include_reference(r: &ExternalSphinxRef) -> bool {
179 match r.sphinx_type {
182 SphinxType::Std(StdRole::Doc) | SphinxType::Python(_) => true,
183 SphinxType::C(_)
184 | SphinxType::Std(_)
185 | SphinxType::Mathematics(_)
186 | SphinxType::Cpp(_)
187 | SphinxType::JavaScript(_)
188 | SphinxType::ReStructuredText(_) => false,
189 }
190}
191
192#[cfg(test)]
193mod test {
194
195 use std::ffi::OsString;
196 use std::path::{Path, PathBuf};
197
198 use crate::config::ConfigBuilder;
199 use crate::render::SSG;
200 use crate::render_docs;
201
202 use pretty_assertions::assert_eq;
203 use std::collections::HashSet;
204
205 use color_eyre::eyre::{Result, WrapErr, bail, eyre};
206 use walkdir::WalkDir;
207
208 pub fn assert_dir_trees_equal<P: AsRef<Path>>(expected: P, actual: P) {
211 match compare_dirs(expected.as_ref(), actual.as_ref()) {
212 Ok(_) => (),
213 Err(e) => panic!("Directory trees differ:\n{e}"),
214 }
215 }
216
217 #[allow(clippy::unwrap_used)]
218 fn compare_dirs(expected: &Path, actual: &Path) -> Result<()> {
219 let entries_expected = collect_files(expected)?;
220 let entries_actual = collect_files(actual)?;
221
222 let mut errors = Vec::new();
223
224 let paths_expected: HashSet<_> = entries_expected.keys().collect();
226 let paths_actual: HashSet<_> = entries_actual.keys().collect();
227
228 let only_in_expected = paths_expected.difference(&paths_actual);
229 let only_in_actual = paths_actual.difference(&paths_expected);
230 let mut in_both: Vec<_> = paths_expected.intersection(&paths_actual).collect();
231
232 in_both.sort();
233
234 for path in only_in_expected {
235 errors.push(format!("Only in {expected:?} (expected): {path:?}"));
236 }
237
238 for path in only_in_actual {
239 errors.push(format!("Only in {actual:?} (actual): {path:?}"));
240 }
241
242 for path in in_both {
243 let full_expected = entries_expected.get(*path).unwrap();
244 let full_actual = entries_actual.get(*path).unwrap();
245
246 let meta_expected = full_expected.metadata().wrap_err("reading metadata 1")?;
247 let meta_actual = full_actual.metadata().wrap_err("reading metadata 2")?;
248
249 match (meta_expected.is_file(), meta_actual.is_file()) {
250 (true, true) => {
251 if let Err(e) = compare_files(full_expected, full_actual) {
252 errors.push(format!("Content differs at {path:?}: {e}"));
253 }
254 }
255 (false, false) => {} _ => {
257 errors.push(format!("Type mismatch at {path:?}: file vs directory"));
258 }
259 }
260 }
261
262 if errors.is_empty() {
263 Ok(())
264 } else {
265 Err(eyre!(
266 "Found {} difference(s):\n{}",
267 errors.len(),
268 errors.join("\n")
269 ))
270 }
271 }
272
273 fn collect_files(base: &Path) -> Result<std::collections::HashMap<PathBuf, PathBuf>> {
275 let mut map = std::collections::HashMap::new();
276 for entry in WalkDir::new(base)
277 .into_iter()
278 .filter_map(Result::ok)
279 .filter(|p| p.path().extension() != Some(&OsString::from("png")))
280 {
281 let path = entry.path();
282 let rel = path.strip_prefix(base)?;
283 map.insert(rel.to_path_buf(), path.to_path_buf());
284 }
285 Ok(map)
286 }
287
288 fn compare_files(expected: &Path, actual: &Path) -> Result<()> {
290 let buf_expected = std::fs::read(expected)?;
291 let buf_actual = std::fs::read(actual)?;
292
293 let expected_string_result = String::from_utf8(buf_expected.clone());
294
295 match expected_string_result {
296 Ok(mut expected_string) => {
297 let mut actual_string = String::from_utf8(buf_actual)?;
298 actual_string = actual_string.replace("\r\n", "\n");
303 expected_string = expected_string.replace("\r\n", "\n");
304
305 assert_eq!(
306 expected_string,
307 actual_string,
308 "{} is different",
309 expected.display()
310 );
311 }
312 Err(_) => {
313 assert_eq!(buf_expected, buf_actual);
316 }
317 }
318
319 Ok(())
320 }
321
322 #[tokio::test]
323 async fn render_test_pkg_docs_full() -> Result<()> {
324 let temp_dir = assert_fs::TempDir::new()?;
325 let test_pkg_dir = PathBuf::from("tests/test_pkg");
326 let expected_api_result_dir = PathBuf::from("tests/rendered_full");
327 let expected_notebooks_result_dir = PathBuf::from("tests/rendered_notebooks/");
328 let api_content_path = PathBuf::from("api");
329 let notebook_content_path = PathBuf::from("notebooks");
330 let mut config_builder = ConfigBuilder::default()
331 .init_with_defaults()
332 .with_pkg_path(Some(test_pkg_dir))
333 .with_api_content_path(Some(api_content_path.clone()))
334 .with_notebook_content_path(Some(notebook_content_path.clone()))
335 .with_site_root(Some(temp_dir.to_path_buf()))
336 .with_skip_undoc(Some(false))
337 .with_skip_private(Some(false))
338 .with_notebook_path(Some(PathBuf::from("tests/test_notebooks")))
339 .with_ssg(Some(crate::render::SSG::Markdown));
340 config_builder.exclude_paths(vec![
341 PathBuf::from("test_pkg/excluded_file.py"),
342 PathBuf::from("test_pkg/excluded_module"),
343 PathBuf::from("test_pkg/miss_spelled_ref.py"),
344 ]);
345
346 config_builder.add_external(
347 "numpy".to_string(),
348 Some("numpy".to_string()),
349 "https://numpy.org/doc/stable".to_string(),
350 )?;
351
352 render_docs(config_builder).await?;
353
354 assert_dir_trees_equal(
355 expected_api_result_dir.as_path(),
356 temp_dir.join(api_content_path).as_path(),
357 );
358 assert_dir_trees_equal(
359 expected_notebooks_result_dir.as_path(),
360 temp_dir.join(notebook_content_path).as_path(),
361 );
362
363 Ok(())
364 }
365 #[tokio::test]
366 async fn render_test_pkg_docs_no_private_no_undoc() -> Result<()> {
367 let temp_dir = assert_fs::TempDir::new()?;
368 let test_pkg_dir = PathBuf::from("tests/test_pkg");
369 let expected_api_result_dir = PathBuf::from("tests/rendered_no_private");
370 let expected_notebooks_result_dir = PathBuf::from("tests/rendered_notebooks/");
371 let notebook_path = PathBuf::from("tests/test_notebooks/");
372 let api_content_path = PathBuf::from("api");
373 let notebook_content_path = PathBuf::from("notebooks/");
374 let mut config_builder = ConfigBuilder::default()
375 .init_with_defaults()
376 .with_pkg_path(Some(test_pkg_dir))
377 .with_api_content_path(Some(api_content_path.clone()))
378 .with_notebook_content_path(Some(notebook_content_path.clone()))
379 .with_site_root(Some(temp_dir.to_path_buf()))
380 .with_skip_undoc(Some(true))
381 .with_notebook_path(Some(notebook_path))
382 .with_ssg(Some(SSG::Markdown))
383 .with_skip_private(Some(true));
384 config_builder.exclude_paths(vec![
385 PathBuf::from("test_pkg/excluded_file.py"),
386 PathBuf::from("test_pkg/excluded_module"),
387 PathBuf::from("test_pkg/miss_spelled_ref.py"),
388 ]);
389
390 render_docs(config_builder).await?;
391
392 assert_dir_trees_equal(
393 expected_api_result_dir.as_path(),
394 temp_dir.join(api_content_path).as_path(),
395 );
396 assert_dir_trees_equal(
397 expected_notebooks_result_dir.as_path(),
398 temp_dir.join(notebook_content_path).as_path(),
399 );
400
401 Ok(())
402 }
403
404 #[tokio::test]
405 async fn render_test_pkg_docs_skip_write_exit_on_err() -> Result<()> {
406 let temp_dir = assert_fs::TempDir::new()?;
407 let test_pkg_dir = PathBuf::from("tests/test_pkg");
408 let api_content_path = PathBuf::from("api/");
409 let mut config_builder = ConfigBuilder::default()
410 .init_with_defaults()
411 .with_pkg_path(Some(test_pkg_dir))
412 .with_api_content_path(Some(api_content_path))
413 .with_site_root(Some(temp_dir.to_path_buf()))
414 .with_skip_undoc(Some(true))
415 .with_skip_write(Some(true))
416 .with_notebook_content_path(None)
417 .with_notebook_path(None)
418 .with_ssg(Some(SSG::Markdown))
419 .with_skip_private(Some(true));
420 config_builder.exclude_paths(vec![
421 PathBuf::from("test_pkg/excluded_file.py"),
422 PathBuf::from("test_pkg/excluded_module"),
423 PathBuf::from("test_pkg/miss_spelled_ref.py"),
424 ]);
425
426 render_docs(config_builder).await?;
427
428 Ok(())
429 }
430 #[tokio::test]
431 async fn render_test_pkg_docs_exit_on_err() -> Result<()> {
432 let temp_dir = assert_fs::TempDir::new()?;
433 let test_pkg_dir = PathBuf::from("tests/test_pkg");
434 let api_content_path = PathBuf::from("api/");
435 let mut config_builder = ConfigBuilder::default()
436 .init_with_defaults()
437 .with_pkg_path(Some(test_pkg_dir))
438 .with_api_content_path(Some(api_content_path))
439 .with_site_root(Some(temp_dir.to_path_buf()))
440 .with_skip_undoc(Some(true))
441 .with_notebook_content_path(None)
442 .with_notebook_path(None)
443 .with_ssg(Some(SSG::Markdown))
444 .with_skip_private(Some(true));
445 config_builder.exclude_paths(vec![
446 PathBuf::from("test_pkg/excluded_file.py"),
447 PathBuf::from("test_pkg/excluded_module"),
448 PathBuf::from("test_pkg/miss_spelled_ref.py"),
449 ]);
450
451 render_docs(config_builder).await?;
452
453 Ok(())
454 }
455
456 #[tokio::test]
457 async fn render_with_skip_write_does_not_write_files() -> Result<()> {
458 let temp_dir = assert_fs::TempDir::new()?;
459 let test_pkg_dir = PathBuf::from("tests/test_pkg");
460 let notebook_path = PathBuf::from("tests/test_notebooks/");
461 let api_content_path = PathBuf::from("api");
462 let notebook_content_path = PathBuf::from("notebooks/");
463 let mut config_builder = ConfigBuilder::default()
464 .init_with_defaults()
465 .with_pkg_path(Some(test_pkg_dir))
466 .with_api_content_path(Some(api_content_path.clone()))
467 .with_notebook_content_path(Some(notebook_content_path.clone()))
468 .with_site_root(Some(temp_dir.to_path_buf()))
469 .with_skip_undoc(Some(true))
470 .with_notebook_path(Some(notebook_path))
471 .with_ssg(Some(SSG::Markdown))
472 .with_skip_write(Some(true))
473 .with_skip_private(Some(true));
474 config_builder.exclude_paths(vec![
475 PathBuf::from("test_pkg/excluded_file.py"),
476 PathBuf::from("test_pkg/excluded_module"),
477 PathBuf::from("test_pkg/miss_spelled_ref.py"),
478 ]);
479
480 render_docs(config_builder).await?;
481
482 let number_of_files = std::fs::read_dir(temp_dir.path())?.count();
483
484 assert_eq!(number_of_files, 0);
485
486 Ok(())
487 }
488
489 #[tokio::test]
490 async fn render_test_pkg_suggests_correct_unknown_refs() -> Result<()> {
491 let temp_dir = assert_fs::TempDir::new()?;
492 let test_pkg_dir = PathBuf::from("tests/test_pkg");
493 let api_content_path = PathBuf::from("api/");
494 let mut config_builder = ConfigBuilder::default()
495 .init_with_defaults()
496 .with_pkg_path(Some(test_pkg_dir))
497 .with_api_content_path(Some(api_content_path))
498 .with_site_root(Some(temp_dir.to_path_buf()))
499 .with_skip_undoc(Some(true))
500 .with_ssg(Some(SSG::Markdown))
501 .with_skip_private(Some(true));
502 config_builder.exclude_paths(vec![
503 PathBuf::from("test_pkg/excluded_file.py"),
504 PathBuf::from("test_pkg/excluded_module"),
505 ]);
506
507 let result = render_docs(config_builder).await;
508
509 match result {
512 Ok(_) => bail!("render_docs did not exit with an error"),
513 Err(e) => {
514 let err_msg = format!("{:?}", e);
515 assert!(err_msg.contains("test_pkg.bar.great, in object test_pkg.miss_spelled_ref.the_little_function_that_could did you mean test_pkg.bar.greet?"));
516 assert!(err_msg.contains("unknown reference: nimpy.fft, in object test_pkg.miss_spelled_ref.the_little_function_that_could did you mean numpy.fft?"));
517 assert!(err_msg.contains("unknown reference: asdfasdfasdf, in object test_pkg.miss_spelled_ref.the_little_function_that_could"));
518 }
519 }
520
521 Ok(())
522 }
523}