Skip to main content

snakedown/
lib.rs

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        // TODO: This will be made more flexible once we add a permissive mode
66        // see https://github.com/savente93/snakedown/issues/38
67        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            // some tools insert an extra EOL at the end of the file
151            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    // just include python refs and std doc refs, we'll see if we actually
180    // need/want the rest
181    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    /// Asserts that two directory trees are identical in structure and content.
209    /// Reports all differences including missing files and content mismatches.
210    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        // Get all unique relative paths from both directories
225        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) => {} // Both are directories, skip
256                _ => {
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    /// Recursively collects all files and directories with paths relative to `base`.
274    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    /// Compares the content of two files.
289    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                // to keep the tests compatible between windows, that uses \n\r
299                // for line-endings instead of \n like the rest of us, we just strip
300                // any \r from both reference and output
301
302                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                // If we can't do string conversion we'll just fall back to arbitrary byte equality
314
315                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        // TODO: find a way to handle errors more nicely
510        // see also https://github.com/savente93/snakedown/issues/89
511        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}