pub mod dependencies;
pub mod genindex;
pub mod metadata;
pub mod numbers;
pub mod py_domain;
pub mod resolve;
pub mod std_domain;
pub mod toctree;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use serde_json::{json, Map as JsonMap, Value as JsonValue};
use crate::doctree::Node;
pub const ENV_VERSION: u32 = 3;
const ENV_FILENAME: &str = "env.bin";
pub use py_domain::PyDomainData;
pub use std_domain::StdDomainData;
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct IndexEntryRecord {
pub entry_type: String,
pub value: String,
pub target_id: String,
pub main: bool,
pub category_key: Option<String>,
}
pub struct FileTimes<'a> {
pub source_modified_us: &'a dyn Fn(&str) -> Option<u64>,
pub doctree_exists: &'a dyn Fn(&str) -> bool,
pub dependency_modified_us: &'a dyn Fn(&Path) -> Option<u64>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Outdated {
pub added: BTreeSet<String>,
pub changed: BTreeSet<String>,
pub removed: BTreeSet<String>,
}
impl Outdated {
pub fn to_read(&self) -> BTreeSet<String> {
self.added.union(&self.changed).cloned().collect()
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct BuildEnvironment {
pub version: u32,
pub root_doc: String,
pub all_docs: BTreeMap<String, u64>,
pub dependencies: BTreeMap<String, BTreeSet<PathBuf>>,
pub included: BTreeMap<String, BTreeSet<String>>,
pub reread_always: BTreeSet<String>,
pub metadata: BTreeMap<String, BTreeMap<String, String>>,
pub titles: BTreeMap<String, Node>,
pub longtitles: BTreeMap<String, Node>,
pub tocs: BTreeMap<String, Node>,
pub toc_num_entries: BTreeMap<String, u32>,
pub toc_secnumbers: BTreeMap<String, BTreeMap<String, Vec<u32>>>,
pub toc_fignumbers: BTreeMap<String, BTreeMap<String, BTreeMap<String, Vec<u32>>>>,
pub toctree_includes: BTreeMap<String, Vec<String>>,
pub files_to_rebuild: BTreeMap<String, BTreeSet<String>>,
pub glob_toctrees: BTreeSet<String>,
pub numbered_toctrees: BTreeSet<String>,
pub std: StdDomainData,
pub py: PyDomainData,
pub index_entries: BTreeMap<String, Vec<IndexEntryRecord>>,
}
impl BuildEnvironment {
pub fn load(cache_dir: &Path) -> Option<Self> {
let bytes = std::fs::read(cache_dir.join(ENV_FILENAME)).ok()?;
let (env, _consumed): (Self, usize) =
bincode::serde::decode_from_slice(&bytes, bincode::config::standard()).ok()?;
if env.version != ENV_VERSION {
return None;
}
Some(env)
}
pub fn save(&mut self, cache_dir: &Path) -> anyhow::Result<()> {
let previous = std::mem::replace(&mut self.version, ENV_VERSION);
let write = || -> anyhow::Result<()> {
std::fs::create_dir_all(cache_dir)?;
let bytes = bincode::serde::encode_to_vec(&*self, bincode::config::standard())?;
std::fs::write(cache_dir.join(ENV_FILENAME), bytes)?;
Ok(())
};
match write() {
Ok(()) => Ok(()),
Err(e) => {
self.version = previous;
Err(e)
}
}
}
pub fn get_outdated_files(
&self,
found: &BTreeSet<String>,
config_changed: bool,
times: &FileTimes<'_>,
) -> Outdated {
let mut outdated = Outdated {
removed: self
.all_docs
.keys()
.filter(|docname| !found.contains(docname.as_str()))
.cloned()
.collect(),
..Default::default()
};
if config_changed {
outdated.added = found.clone();
return outdated;
}
for docname in found {
if !self.all_docs.contains_key(docname) {
outdated.added.insert(docname.clone());
} else if self.has_doc_changed(docname, times) {
outdated.changed.insert(docname.clone());
}
}
if !outdated.added.is_empty() || !outdated.removed.is_empty() {
for docname in &self.glob_toctrees {
if found.contains(docname) && !outdated.added.contains(docname) {
outdated.changed.insert(docname.clone());
}
}
}
outdated
}
fn has_doc_changed(&self, docname: &str, times: &FileTimes<'_>) -> bool {
if self.reread_always.contains(docname) {
return true;
}
if !(times.doctree_exists)(docname) {
return true;
}
let Some(&read_time) = self.all_docs.get(docname) else {
return true;
};
match (times.source_modified_us)(docname) {
None => return true,
Some(modified) if modified > read_time => return true,
Some(_) => {}
}
for dependency in self.dependencies.get(docname).into_iter().flatten() {
match (times.dependency_modified_us)(dependency) {
None => return true,
Some(modified) if modified > read_time => return true,
Some(_) => {}
}
}
false
}
pub fn clear_doc(&mut self, docname: &str) {
self.all_docs.remove(docname);
self.included.remove(docname);
self.reread_always.remove(docname);
self.dependencies.remove(docname);
self.metadata.remove(docname);
self.titles.remove(docname);
self.longtitles.remove(docname);
self.tocs.remove(docname);
self.toc_secnumbers.remove(docname);
self.toc_fignumbers.remove(docname);
self.toc_num_entries.remove(docname);
self.toctree_includes.remove(docname);
self.glob_toctrees.remove(docname);
self.numbered_toctrees.remove(docname);
self.files_to_rebuild.retain(|_, containing| {
containing.remove(docname);
!containing.is_empty()
});
self.std
.progoptions
.retain(|_, (fn_, _)| fn_.as_str() != docname);
self.std
.objects
.retain(|_, (fn_, _)| fn_.as_str() != docname);
self.std.terms.retain(|_, (fn_, _)| fn_.as_str() != docname);
self.std
.labels
.retain(|_, (fn_, _, _)| fn_.as_str() != docname);
self.std
.anonlabels
.retain(|_, (fn_, _)| fn_.as_str() != docname);
self.py.clear_doc(docname);
self.index_entries.remove(docname);
}
pub fn snapshot(&self) -> JsonValue {
let objects: Vec<JsonValue> = self
.std
.objects
.iter()
.map(|((objtype, name), (docname, labelid))| {
json!({
"objtype": objtype,
"name": name,
"docname": docname,
"labelid": labelid,
})
})
.collect();
let progoptions: Vec<JsonValue> = self
.std
.progoptions
.iter()
.map(|((program, name), (docname, labelid))| {
json!({
"program": program,
"name": name,
"docname": docname,
"labelid": labelid,
})
})
.collect();
let py_objects: Vec<JsonValue> = self
.py
.objects
.iter()
.map(|(name, entry)| {
json!({
"name": name,
"docname": entry.docname,
"node_id": entry.node_id,
"objtype": entry.objtype,
"aliased": entry.aliased,
})
})
.collect();
let py_modules: Vec<JsonValue> = self
.py
.modules
.iter()
.map(|(name, entry)| {
json!({
"name": name,
"docname": entry.docname,
"node_id": entry.node_id,
"synopsis": entry.synopsis,
"platform": entry.platform,
"deprecated": entry.deprecated,
})
})
.collect();
let mut index_entries = JsonMap::new();
for (docname, entries) in &self.index_entries {
let arr: Vec<JsonValue> = entries
.iter()
.map(|e| {
json!([
e.entry_type,
e.value,
e.target_id,
if e.main { "main" } else { "" },
e.category_key,
])
})
.collect();
index_entries.insert(docname.clone(), JsonValue::Array(arr));
}
let relations: JsonMap<String, JsonValue> = toctree::collect_relations(self)
.into_iter()
.map(|(docname, (parent, prev, next))| (docname, json!([parent, prev, next])))
.collect();
json!({
"version": self.version,
"root_doc": self.root_doc,
"all_docs": self.all_docs,
"relations": JsonValue::Object(relations),
"metadata": self.metadata,
"dependencies": self.dependencies,
"included": self.included,
"reread_always": self.reread_always,
"titles_pformat": pformat_map(&self.titles),
"longtitles_pformat": pformat_map(&self.longtitles),
"tocs_pformat": pformat_map(&self.tocs),
"toc_num_entries": self.toc_num_entries,
"toc_secnumbers": self.toc_secnumbers,
"toc_fignumbers": self.toc_fignumbers,
"toctree_includes": self.toctree_includes,
"files_to_rebuild": self.files_to_rebuild,
"glob_toctrees": self.glob_toctrees,
"numbered_toctrees": self.numbered_toctrees,
"std": {
"labels": self.std.labels,
"anonlabels": self.std.anonlabels,
"objects": objects,
"progoptions": progoptions,
"terms": self.std.terms,
},
"py_objects": py_objects,
"py_modules": py_modules,
"index_entries": JsonValue::Object(index_entries),
})
}
}
fn pformat_map(nodes: &BTreeMap<String, Node>) -> JsonValue {
let map: JsonMap<String, JsonValue> = nodes
.iter()
.map(|(docname, node)| (docname.clone(), JsonValue::String(node.pformat())))
.collect();
JsonValue::Object(map)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::doctree::{kinds, Span};
fn sample_node() -> Node {
let mut root = Node::elem(kinds::BULLET_LIST, Span::ZERO);
let mut item = Node::elem(kinds::LIST_ITEM, Span::ZERO);
item.children
.push(Node::text_node("Chapter One", Span::ZERO));
root.children.push(item);
root
}
fn populated_env() -> BuildEnvironment {
let mut env = BuildEnvironment {
version: ENV_VERSION,
root_doc: "index".to_string(),
..Default::default()
};
env.all_docs.insert("index".to_string(), 1_700_000_000);
env.metadata.insert(
"index".to_string(),
BTreeMap::from([("orphan".to_string(), String::new())]),
);
env.dependencies.insert(
"index".to_string(),
BTreeSet::from([PathBuf::from("/src/index.rst")]),
);
env.included.insert(
"index".to_string(),
BTreeSet::from(["chapters/intro".to_string()]),
);
env.reread_always.insert("index".to_string());
env.titles.insert("index".to_string(), sample_node());
env.longtitles.insert("index".to_string(), sample_node());
env.tocs.insert("index".to_string(), sample_node());
env.toc_num_entries.insert("index".to_string(), 3);
env.toc_secnumbers.insert(
"index".to_string(),
BTreeMap::from([(String::new(), vec![1]), ("#sec".to_string(), vec![1, 1])]),
);
env.toc_fignumbers.insert(
"index".to_string(),
BTreeMap::from([(
"figure".to_string(),
BTreeMap::from([("fig1".to_string(), vec![1])]),
)]),
);
env.toctree_includes.insert(
"index".to_string(),
vec!["chapters/intro".to_string(), "chapters/two".to_string()],
);
env.files_to_rebuild.insert(
"chapters/intro".to_string(),
BTreeSet::from(["index".to_string()]),
);
env.glob_toctrees.insert("index".to_string());
env.numbered_toctrees.insert("index".to_string());
env.std.labels.insert(
"intro".to_string(),
(
"index".to_string(),
"intro-id".to_string(),
"Introduction".to_string(),
),
);
env.std.anonlabels.insert(
"intro".to_string(),
("index".to_string(), "intro-id".to_string()),
);
env.std.objects.insert(
("envvar".to_string(), "PATH".to_string()),
("index".to_string(), "envvar-path".to_string()),
);
env.std.progoptions.insert(
(Some("myprog".to_string()), "--verbose".to_string()),
("index".to_string(), "cmdoption-verbose".to_string()),
);
env.std.terms.insert(
"glossary term".to_string(),
("index".to_string(), "term-glossary-term".to_string()),
);
env.py.note_object(
"zeta.func",
py_domain::PyObjectEntry {
docname: "index".to_string(),
node_id: "zeta.func".to_string(),
objtype: "function".to_string(),
aliased: false,
},
);
env.py.note_object(
"alpha.func",
py_domain::PyObjectEntry {
docname: "index".to_string(),
node_id: "alpha.func".to_string(),
objtype: "function".to_string(),
aliased: true,
},
);
env.py.note_module(
"zeta",
py_domain::PyModuleEntry {
docname: "index".to_string(),
node_id: "module-zeta".to_string(),
synopsis: "Zed things.".to_string(),
platform: "posix".to_string(),
deprecated: true,
},
);
env.index_entries.insert(
"index".to_string(),
vec![IndexEntryRecord {
entry_type: "single".to_string(),
value: "PATH".to_string(),
target_id: "index-0".to_string(),
main: true,
category_key: None,
}],
);
env
}
#[test]
fn round_trip_through_bincode_preserves_node_valued_fields() {
let tmp = tempfile::TempDir::new().unwrap();
let mut env = populated_env();
env.save(tmp.path()).expect("save succeeds");
let restored = BuildEnvironment::load(tmp.path()).expect("load succeeds");
assert_eq!(restored, env);
assert_eq!(restored.titles["index"], sample_node());
assert_eq!(restored.tocs["index"].pformat(), sample_node().pformat());
}
#[test]
fn save_always_stamps_current_env_version() {
let tmp = tempfile::TempDir::new().unwrap();
let mut env = BuildEnvironment {
version: 0, ..Default::default()
};
env.save(tmp.path()).unwrap();
assert_eq!(env.version, ENV_VERSION);
let restored = BuildEnvironment::load(tmp.path()).unwrap();
assert_eq!(restored.version, ENV_VERSION);
}
#[test]
fn failed_save_leaves_the_in_memory_version_untouched() {
let tmp = tempfile::TempDir::new().unwrap();
let blocked = tmp.path().join("not-a-dir");
std::fs::write(&blocked, b"").unwrap();
let mut env = BuildEnvironment {
version: 0,
..Default::default()
};
assert!(env.save(&blocked).is_err());
assert_eq!(env.version, 0);
}
#[test]
fn load_returns_none_when_file_is_missing() {
let tmp = tempfile::TempDir::new().unwrap();
assert!(BuildEnvironment::load(tmp.path()).is_none());
}
#[test]
fn load_returns_none_on_decode_error() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(
tmp.path().join(ENV_FILENAME),
b"not a valid bincode blob at all",
)
.unwrap();
assert!(BuildEnvironment::load(tmp.path()).is_none());
}
#[test]
fn load_returns_none_when_version_does_not_match_current() {
let tmp = tempfile::TempDir::new().unwrap();
let stale = BuildEnvironment {
version: ENV_VERSION + 1,
..Default::default()
};
let bytes = bincode::serde::encode_to_vec(&stale, bincode::config::standard()).unwrap();
std::fs::write(tmp.path().join(ENV_FILENAME), bytes).unwrap();
assert!(BuildEnvironment::load(tmp.path()).is_none());
}
#[test]
fn clear_doc_scrubs_every_per_doc_field() {
let mut env = populated_env();
env.files_to_rebuild
.get_mut("chapters/intro")
.unwrap()
.insert("other".to_string());
env.all_docs.insert("other".to_string(), 1_700_000_001);
env.clear_doc("index");
assert!(!env.all_docs.contains_key("index"));
assert!(!env.included.contains_key("index"));
assert!(!env.reread_always.contains("index"));
assert!(!env.dependencies.contains_key("index"));
assert!(!env.metadata.contains_key("index"));
assert!(!env.titles.contains_key("index"));
assert!(!env.longtitles.contains_key("index"));
assert!(!env.tocs.contains_key("index"));
assert!(!env.toc_secnumbers.contains_key("index"));
assert!(!env.toc_fignumbers.contains_key("index"));
assert!(!env.toc_num_entries.contains_key("index"));
assert!(!env.toctree_includes.contains_key("index"));
assert!(!env.glob_toctrees.contains("index"));
assert!(!env.numbered_toctrees.contains("index"));
assert_eq!(
env.files_to_rebuild.get("chapters/intro"),
Some(&BTreeSet::from(["other".to_string()]))
);
assert!(!env.std.labels.contains_key("intro"));
assert!(!env.std.anonlabels.contains_key("intro"));
assert_eq!(env.std.labels, StdDomainData::default().labels);
assert_eq!(env.std.anonlabels, StdDomainData::default().anonlabels);
assert!(env.std.objects.is_empty());
assert!(env.std.progoptions.is_empty());
assert!(env.std.terms.is_empty());
assert!(env.py.objects.is_empty() && env.py.objects_index.is_empty());
assert!(env.py.modules.is_empty() && env.py.modules_index.is_empty());
assert!(env.index_entries.is_empty());
}
#[test]
fn clear_doc_deletes_files_to_rebuild_key_when_value_set_becomes_empty() {
let mut env = BuildEnvironment::default();
env.files_to_rebuild.insert(
"chapters/intro".to_string(),
BTreeSet::from(["index".to_string()]),
);
env.clear_doc("index");
assert!(
!env.files_to_rebuild.contains_key("chapters/intro"),
"an emptied value-set must delete its key, not linger as an empty set"
);
}
#[derive(Default)]
struct Fs {
sources: BTreeMap<String, Option<u64>>,
doctrees: BTreeSet<String>,
deps: BTreeMap<PathBuf, Option<u64>>,
}
const READ_TIME: u64 = 1_000_000;
fn steady_state() -> (BuildEnvironment, Fs) {
let mut env = BuildEnvironment {
root_doc: "index".to_string(),
..Default::default()
};
env.all_docs.insert("index".to_string(), READ_TIME);
env.all_docs.insert("a".to_string(), READ_TIME);
let fs = Fs {
sources: BTreeMap::from([
("index".to_string(), Some(READ_TIME - 1)),
("a".to_string(), Some(READ_TIME - 1)),
]),
doctrees: BTreeSet::from(["index".to_string(), "a".to_string()]),
deps: BTreeMap::new(),
};
(env, fs)
}
fn found(docnames: &[&str]) -> BTreeSet<String> {
docnames.iter().map(|d| d.to_string()).collect()
}
fn outdated_with(
env: &BuildEnvironment,
fs: &Fs,
docnames: &[&str],
config_changed: bool,
) -> Outdated {
env.get_outdated_files(
&found(docnames),
config_changed,
&FileTimes {
source_modified_us: &|docname| fs.sources.get(docname).copied().flatten(),
doctree_exists: &|docname| fs.doctrees.contains(docname),
dependency_modified_us: &|path| fs.deps.get(path).copied().flatten(),
},
)
}
fn outdated(env: &BuildEnvironment, fs: &Fs, docnames: &[&str]) -> Outdated {
outdated_with(env, fs, docnames, false)
}
#[test]
fn nothing_is_outdated_in_a_steady_state() {
let (env, fs) = steady_state();
let out = outdated(&env, &fs, &["index", "a"]);
assert_eq!(out, Outdated::default());
assert!(out.to_read().is_empty());
}
#[test]
fn a_document_the_environment_has_never_seen_is_added() {
let (env, mut fs) = steady_state();
fs.sources.insert("new".to_string(), Some(READ_TIME));
let out = outdated(&env, &fs, &["index", "a", "new"]);
assert_eq!(out.added, found(&["new"]));
assert!(out.changed.is_empty());
assert!(out.removed.is_empty());
}
#[test]
fn a_document_that_is_gone_is_removed() {
let (env, fs) = steady_state();
let out = outdated(&env, &fs, &["index"]);
assert_eq!(out.removed, found(&["a"]));
assert!(out.added.is_empty());
assert!(out.changed.is_empty());
}
#[test]
fn a_changed_configuration_re_reads_everything() {
let (env, fs) = steady_state();
let out = outdated_with(&env, &fs, &["index", "a"], true);
assert_eq!(out.added, found(&["index", "a"]));
assert!(
out.changed.is_empty(),
"sphinx puts every document in `added` and leaves `changed` empty"
);
}
#[test]
fn a_source_newer_than_its_read_time_has_changed() {
let (env, mut fs) = steady_state();
fs.sources.insert("a".to_string(), Some(READ_TIME + 1));
assert_eq!(outdated(&env, &fs, &["index", "a"]).changed, found(&["a"]));
}
#[test]
fn a_source_read_within_the_same_microsecond_has_not_changed() {
let (env, mut fs) = steady_state();
fs.sources.insert("a".to_string(), Some(READ_TIME));
assert!(
outdated(&env, &fs, &["index", "a"]).changed.is_empty(),
"the comparison is strictly-newer, like sphinx's"
);
}
#[test]
fn an_unstattable_source_has_changed() {
let (env, mut fs) = steady_state();
fs.sources.insert("a".to_string(), None);
assert_eq!(outdated(&env, &fs, &["index", "a"]).changed, found(&["a"]));
}
#[test]
fn a_missing_doctree_file_has_changed() {
let (env, mut fs) = steady_state();
fs.doctrees.remove("a");
assert_eq!(outdated(&env, &fs, &["index", "a"]).changed, found(&["a"]));
}
#[test]
fn a_document_that_asked_to_be_re_read_always_has_changed() {
let (mut env, fs) = steady_state();
env.reread_always.insert("a".to_string());
assert_eq!(outdated(&env, &fs, &["index", "a"]).changed, found(&["a"]));
}
#[test]
fn a_dependency_newer_than_the_read_time_has_changed() {
let (mut env, mut fs) = steady_state();
let pic = PathBuf::from("/src/pic.png");
env.dependencies
.insert("a".to_string(), BTreeSet::from([pic.clone()]));
fs.deps.insert(pic.clone(), Some(READ_TIME - 1));
assert!(outdated(&env, &fs, &["index", "a"]).changed.is_empty());
fs.deps.insert(pic, Some(READ_TIME + 1));
assert_eq!(outdated(&env, &fs, &["index", "a"]).changed, found(&["a"]));
}
#[test]
fn a_missing_dependency_has_changed() {
let (mut env, mut fs) = steady_state();
let pic = PathBuf::from("/src/pic.png");
env.dependencies
.insert("a".to_string(), BTreeSet::from([pic.clone()]));
fs.deps.insert(pic, None);
assert_eq!(outdated(&env, &fs, &["index", "a"]).changed, found(&["a"]));
}
#[test]
fn adding_or_removing_a_file_re_reads_every_glob_toctree() {
let (mut env, mut fs) = steady_state();
env.glob_toctrees.insert("index".to_string());
env.glob_toctrees.insert("gone".to_string());
assert!(outdated(&env, &fs, &["index", "a"]).changed.is_empty());
fs.sources.insert("new".to_string(), Some(READ_TIME));
fs.doctrees.insert("new".to_string());
let added = outdated(&env, &fs, &["index", "a", "new"]);
assert_eq!(added.added, found(&["new"]));
assert_eq!(added.changed, found(&["index"]));
let removed = outdated(&env, &fs, &["index"]);
assert_eq!(removed.removed, found(&["a"]));
assert_eq!(removed.changed, found(&["index"]));
}
#[test]
fn a_glob_container_that_is_new_itself_stays_in_added() {
let (mut env, mut fs) = steady_state();
env.glob_toctrees.insert("new".to_string());
fs.sources.insert("new".to_string(), Some(READ_TIME));
let out = outdated(&env, &fs, &["index", "a", "new"]);
assert_eq!(out.added, found(&["new"]));
assert!(
out.changed.is_empty(),
"a document is read once; being added already covers it"
);
}
#[test]
fn the_read_set_is_the_added_and_changed_documents() {
let (mut env, mut fs) = steady_state();
fs.sources.insert("new".to_string(), Some(READ_TIME));
fs.doctrees.remove("a");
env.all_docs.insert("gone".to_string(), READ_TIME);
let out = outdated(&env, &fs, &["index", "a", "new"]);
assert_eq!(out.to_read(), found(&["a", "new"]));
assert_eq!(out.removed, found(&["gone"]));
}
#[test]
fn snapshot_converts_tuple_keyed_maps_and_index_entry_main_flag() {
let env = populated_env();
let snapshot = env.snapshot();
let objects = snapshot["std"]["objects"].as_array().unwrap();
assert_eq!(objects.len(), 1);
assert_eq!(objects[0]["objtype"], "envvar");
assert_eq!(objects[0]["name"], "PATH");
assert_eq!(objects[0]["docname"], "index");
let progoptions = snapshot["std"]["progoptions"].as_array().unwrap();
assert_eq!(progoptions[0]["program"], "myprog");
assert_eq!(progoptions[0]["name"], "--verbose");
let entries = snapshot["index_entries"]["index"].as_array().unwrap();
assert_eq!(entries.len(), 1);
let entry = entries[0].as_array().unwrap();
assert_eq!(entry[0], "single");
assert_eq!(entry[3], "main");
let py_objects = snapshot["py_objects"].as_array().unwrap();
assert_eq!(
py_objects
.iter()
.map(|o| o["name"].as_str().unwrap())
.collect::<Vec<_>>(),
vec!["zeta.func", "alpha.func"]
);
assert_eq!(py_objects[1]["aliased"], true);
let py_modules = snapshot["py_modules"].as_array().unwrap();
assert_eq!(py_modules.len(), 1);
assert_eq!(py_modules[0]["name"], "zeta");
assert_eq!(py_modules[0]["node_id"], "module-zeta");
assert_eq!(py_modules[0]["synopsis"], "Zed things.");
assert_eq!(py_modules[0]["platform"], "posix");
assert_eq!(py_modules[0]["deprecated"], true);
assert_eq!(
snapshot["tocs_pformat"]["index"],
JsonValue::String(sample_node().pformat())
);
}
}