This commit is contained in:
nora 2024-03-16 11:08:34 +01:00
parent a6884454c0
commit 720b23bd8e
7 changed files with 249 additions and 295 deletions

64
Cargo.lock generated
View file

@ -2,37 +2,6 @@
# It is not intended for manual editing.
version = 3
[[package]]
name = "crossbeam-deque"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345"
[[package]]
name = "either"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07"
[[package]]
name = "equivalent"
version = "1.0.1"
@ -107,26 +76,6 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "rayon"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7237101a77a10773db45d62004a272517633fbcc3df19d96455ede1122e051"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "ryu"
version = "1.0.16"
@ -153,6 +102,17 @@ dependencies = [
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5f09b1bd632ef549eaa9f60a1f8de742bdbc698e6cee2095fc84dde5f549ae0"
dependencies = [
"itoa",
"ryu",
"serde",
]
[[package]]
name = "serde_yaml"
version = "0.9.31"
@ -183,8 +143,8 @@ version = "0.1.0"
dependencies = [
"eyre",
"glob-match",
"rayon",
"serde",
"serde_json",
"serde_yaml",
]

View file

@ -2,12 +2,13 @@
name = "target-docs"
version = "0.1.0"
edition = "2021"
license = "MIT OR Apache-2.0"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
eyre = "0.6.12"
glob-match = "0.2.1"
rayon = "1.8.1"
serde = { version = "1.0.196", features = ["derive"] }
serde = { version = "1.0.185", features = ["derive"] }
serde_json = "1.0.114"
serde_yaml = "0.9.31"

View file

@ -1,26 +1,57 @@
# target tier docs experiment
# target-docs
Experiment with automatically generating target tier docs.
This tool generates target documentation for all targets in the rustc book.
**View the deployment on <https://nilstrieb.github.io/target-tier-docs-experiment/>**
To achieve this, it uses a list of input markdown files provided in `src/doc/rustc/target_infos`. These files follow a strict format.
Every file covers a glob pattern of targets according to its file name.
## Problems
For every rustc target, we iterate through all the target infos and find matching globs.
When a glob matches, it extracts the h2 markdown sections and saves them for the target.
Currenly, the [target tier docs](https://doc.rust-lang.org/rustc/platform-support.html) are hard to navigate.
If you want to find information about a specific target, you first need to do some glob-search yourself and then also hope
that the target actually exists. This is super annoying (`:(`). Additionally, some targets are completely missing and there
is no reason to believe that the documentation won't suddenly start being out of date.
Pages are also inconsistent about which sections exist and which ones don't.
In the end, a page is generated for every target using these sections.
Sections that are not provided are stubbed out. Currently, the sections are
## Solution
- Overview
- Requirements
- Testing
- Building the target
- Cross compilation
- Building Rust programs
Enter: adding yet another preprocessing step.
In addition to the markdown sections, we also have extra data about the targets.
This is achieved through YAML frontmatter.
By adding yet another preprocessing step, we can solve all these problems.
- Have a *dedicated* page for *every single* target including information about maintainers etc.
This makes it super easy to find things when there are problems.
- Ensure that no target is completely undocumented, at least having a stub page pointing out the undocumentedness
- Error when there is documentation that is not needed anymore, for example a removed target
- Still keep the nice and easy-to-organize glob structure in the source
- Use a unified structure for all the pages
- This also allows us to put more dynamic values into the docs. For example, I put `--print cfg` there, isn't that pretty!?
The frontmatter follows the following format:
```yaml
tier: "1"
maintainers: ["@someone"]
metadata:
- target: "i686-pc-windows-gnu"
notes: "32-bit MinGW (Windows 7+)"
std: true
host: true
footnotes:
- name: "x86_32-floats-return-ABI"
content: |
Due to limitations of the C ABI, floating-point support on `i686` targets is non-compliant:
floating-point return values are passed via an x87 register, so NaN payload bits can be lost.
See [issue #114479][https://github.com/rust-lang/rust/issues/114479].
- name: "windows-support"
content: "Only Windows 10 currently undergoes automated testing. Earlier versions of Windows rely on testing and support from the community."
```
The top level keys are:
- `tier` (optional): `1`, `2` or `3`
- `maintainers` (optional): list of strings
There is also `metadata`, which is specific to every single target and not just a target "group" (the glob).
`metadata` has the following properties:
- `target`: the target name
- `notes`: a string containing a short description of the target for the table
- `std`: `true`, `false`, `unknown`, whether the target has `std`
- `host`: `true`, `false`, `unknown`, whether the target has host tools
- `footnotes` (optional): a list of footnotes, where every footnote has a `name` and `content`. These are used in the table.

View file

@ -2,33 +2,27 @@ mod parse;
mod render;
use std::{
io,
path::{Path, PathBuf},
process::Command,
};
use eyre::{bail, Context, OptionExt, Result};
use parse::{Footnote, ParsedTargetInfoFile, Tier, TriStateBool};
use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
use serde::Deserialize;
/// Information about a target obtained from `target_info.toml``.
/// Information about a target obtained from the target_info markdown file.
struct TargetDocs {
name: String,
maintainers: Vec<String>,
sections: Vec<(String, String)>,
tier: Option<Tier>,
// TODO: Make this mandatory.
metadata: Option<TargetMetadata>,
}
/// Metadata for the table
struct TargetMetadata {
notes: String,
std: TriStateBool,
host: TriStateBool,
footnotes: Vec<Footnote>,
footnotes: Vec<String>,
}
/// All the sections that we want every doc page to have.
/// It may make sense to relax this into two kinds of sections, "required" sections
/// and "optional" sections, where required sections will get stubbed out when not found
/// while optional sections will just not exist when not found.
// IMPORTANT: This is also documented in the README, keep it in sync.
const SECTIONS: &[&str] = &[
"Overview",
"Requirements",
@ -38,51 +32,34 @@ const SECTIONS: &[&str] = &[
"Building Rust programs",
];
fn is_in_rust_lang_rust() -> bool {
std::env::var("RUST_LANG_RUST") == Ok("1".to_owned())
}
fn main() -> Result<()> {
let args = std::env::args().collect::<Vec<_>>();
let input_dir = args
.get(1)
.ok_or_eyre("first argument must be path to directory containing source md files")?;
.ok_or_eyre("first argument must be path to target_infos directory containing target source md files (src/doc/rustc/target_infos/)")?;
let output_src = args
.get(2)
.ok_or_eyre("second argument must be path to `src` output directory")?;
.ok_or_eyre("second argument must be path to `src` output directory (src/doc/rustc/src)")?;
let rustc =
PathBuf::from(std::env::var("RUSTC").expect("must pass RUSTC env var pointing to rustc"));
let check_only = std::env::var("TARGET_CHECK_ONLY") == Ok("1".to_owned());
let targets = rustc_stdout(&rustc, &["--print", "target-list"]);
let targets = targets.lines().collect::<Vec<_>>();
if !is_in_rust_lang_rust() {
match std::fs::create_dir("targets/src") {
Ok(()) => {}
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {}
e @ _ => e.wrap_err("failed creating src dir")?,
}
}
let mut info_patterns = parse::load_target_infos(Path::new(input_dir))
.wrap_err("failed loading target_info")?
.into_iter()
.map(|info| {
let metadata_used = vec![false; info.metadata.len()];
TargetPatternEntry {
info,
used: false,
metadata_used,
}
TargetPatternEntry { info, used: false, footnotes_used: metadata_used }
})
.collect::<Vec<_>>();
eprintln!("Collecting rustc information");
let rustc_infos = targets
.par_iter()
.map(|target| rustc_target_info(&rustc, target))
.collect::<Vec<_>>();
let rustc_infos =
targets.iter().map(|target| rustc_target_info(&rustc, target)).collect::<Vec<_>>();
let targets = targets
.into_iter()
@ -90,42 +67,39 @@ fn main() -> Result<()> {
.zip(rustc_infos)
.collect::<Vec<_>>();
eprintln!("Rendering targets");
eprintln!("Rendering targets check_only={check_only}");
let targets_dir = Path::new(output_src).join("platform-support").join("targets");
if !check_only {
std::fs::create_dir_all(&targets_dir).wrap_err("creating platform-support/targets dir")?;
}
for (info, rustc_info) in &targets {
let doc = render::render_target_md(info, rustc_info);
std::fs::write(
Path::new(output_src)
.join("platform-support")
.join("targets")
.join(format!("{}.md", info.name)),
doc,
)
if !check_only {
std::fs::write(targets_dir.join(format!("{}.md", info.name)), doc)
.wrap_err("writing target file")?;
}
}
for target_pattern in info_patterns {
if !target_pattern.used {
bail!(
"target pattern `{}` was never used",
target_pattern.info.pattern
);
bail!("target pattern `{}` was never used", target_pattern.info.pattern);
}
for (used, meta) in
std::iter::zip(target_pattern.metadata_used, target_pattern.info.metadata)
std::iter::zip(target_pattern.footnotes_used, target_pattern.info.metadata)
{
if !used {
bail!(
"in target pattern `{}`, the metadata pattern `{}` was never used",
"in target pattern `{}`, the footnotes for target `{}` were never used",
target_pattern.info.pattern,
meta.pattern
meta.target
);
}
}
}
render::render_static(Path::new(output_src), &targets)?;
render::render_static(check_only, Path::new(output_src), &targets)?;
eprintln!("Finished generating target docs");
Ok(())
@ -134,7 +108,7 @@ fn main() -> Result<()> {
struct TargetPatternEntry {
info: ParsedTargetInfoFile,
used: bool,
metadata_used: Vec<bool>,
footnotes_used: Vec<bool>,
}
fn target_doc_info(info_patterns: &mut [TargetPatternEntry], target: &str) -> TargetDocs {
@ -143,6 +117,7 @@ fn target_doc_info(info_patterns: &mut [TargetPatternEntry], target: &str) -> Ta
let mut sections = Vec::new();
let mut metadata = None;
let mut footnotes = Vec::new();
for target_pattern_entry in info_patterns {
if glob_match::glob_match(&target_pattern_entry.info.pattern, target) {
@ -153,21 +128,34 @@ fn target_doc_info(info_patterns: &mut [TargetPatternEntry], target: &str) -> Ta
if let Some(pattern_value) = &target_pattern.tier {
if tier.is_some() {
panic!("target {target} inherits a tier from multiple patterns, create a more specific pattern and add it there");
panic!(
"target {target} inherits a tier from multiple patterns, create a more specific pattern and add it there"
);
}
tier = Some(pattern_value.clone());
}
for (section_name, content) in &target_pattern.sections {
if sections.iter().any(|(name, _)| name == section_name) {
panic!("target {target} inherits the section {section_name} from multiple patterns, create a more specific pattern and add it there");
panic!(
"target {target} inherits the section {section_name} from multiple patterns, create a more specific pattern and add it there"
);
}
sections.push((section_name.clone(), content.clone()));
}
if let Some(target_footnotes) = target_pattern.footnotes.get(target) {
target_pattern_entry.footnotes_used[i] = true;
if !footnotes.is_empty() {
panic!("target {target} is assigned metadata from more than one pattern");
}
footnotes = target_footnotes.clone();
}
for (i, metadata_pattern) in target_pattern.metadata.iter().enumerate() {
if glob_match::glob_match(&metadata_pattern.pattern, target) {
target_pattern_entry.metadata_used[i] = true;
if metadata_pattern.target == target {
target_pattern_entry.footnotes_used[i] = true;
if metadata.is_some() {
panic!("target {target} is assigned metadata from more than one pattern");
}
@ -182,18 +170,21 @@ fn target_doc_info(info_patterns: &mut [TargetPatternEntry], target: &str) -> Ta
}
}
TargetDocs {
name: target.to_owned(),
maintainers,
tier,
sections,
metadata,
}
TargetDocs { name: target.to_owned(), maintainers, sections, footnotes }
}
/// Information about a target obtained from rustc.
struct RustcTargetInfo {
target_cfgs: Vec<(String, String)>,
metadata: RustcTargetMetadata,
}
#[derive(Deserialize)]
struct RustcTargetMetadata {
description: Option<String>,
tier: Option<u8>,
host_tools: Option<bool>,
std: Option<bool>,
}
/// Get information about a target from rustc.
@ -213,7 +204,19 @@ fn rustc_target_info(rustc: &Path, target: &str) -> RustcTargetInfo {
}
})
.collect();
RustcTargetInfo { target_cfgs }
#[derive(Deserialize)]
struct TargetJson {
metadata: RustcTargetMetadata,
}
let json_spec = rustc_stdout(
rustc,
&["-Zunstable-options", "--print", "target-spec-json", "--target", target],
);
let spec = serde_json::from_str::<TargetJson>(&json_spec);
RustcTargetInfo { target_cfgs, metadata: spec.metadata }
}
fn rustc_stdout(rustc: &Path, args: &[&str]) -> String {

View file

@ -2,7 +2,7 @@
use eyre::{bail, OptionExt, Result, WrapErr};
use serde::Deserialize;
use std::{fs::DirEntry, path::Path};
use std::{collections::HashMap, fs::DirEntry, path::Path};
#[derive(Debug, PartialEq, Clone, Deserialize)]
pub enum Tier {
@ -20,9 +20,10 @@ pub struct ParsedTargetInfoFile {
pub tier: Option<Tier>,
pub maintainers: Vec<String>,
pub sections: Vec<(String, String)>,
pub metadata: Vec<ParsedTargetMetadata>,
pub footnotes: HashMap<String, Vec<String>>,
}
// IMPORTANT: This is also documented in the README, keep it in sync.
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Frontmatter {
@ -30,27 +31,19 @@ struct Frontmatter {
#[serde(default)]
maintainers: Vec<String>,
#[serde(default)]
metadata: Vec<ParsedTargetMetadata>,
footnotes: HashMap<String, Vec<String>>,
}
// IMPORTANT: This is also documented in the README, keep it in sync.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ParsedTargetMetadata {
pub pattern: String,
pub notes: String,
pub std: TriStateBool,
pub host: TriStateBool,
pub struct TargetFootnotes {
pub target: String,
#[serde(default)]
pub footnotes: Vec<Footnote>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Footnote {
pub name: String,
pub content: String,
pub footnotes: Vec<String>,
}
// IMPORTANT: This is also documented in the README, keep it in sync.
#[derive(Debug, PartialEq, Clone, Copy, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TriStateBool {
@ -89,9 +82,7 @@ fn load_single_target_info(entry: &DirEntry) -> Result<ParsedTargetInfoFile> {
fn parse_file(name: &str, content: &str) -> Result<ParsedTargetInfoFile> {
let mut frontmatter_splitter = content.split("---\n");
let frontmatter = frontmatter_splitter
.nth(1)
.ok_or_eyre("missing frontmatter")?;
let frontmatter = frontmatter_splitter.nth(1).ok_or_eyre("missing frontmatter")?;
let frontmatter_line_count = frontmatter.lines().count() + 2; // 2 from ---
@ -149,99 +140,16 @@ fn parse_file(name: &str, content: &str) -> Result<ParsedTargetInfoFile> {
}
}
sections
.iter_mut()
.for_each(|section| section.1 = section.1.trim().to_owned());
sections.iter_mut().for_each(|section| section.1 = section.1.trim().to_owned());
Ok(ParsedTargetInfoFile {
pattern: name.to_owned(),
maintainers: frontmatter.maintainers,
tier: frontmatter.tier,
sections,
metadata: frontmatter.metadata,
footnotes: frontmatter.footnotes,
})
}
#[cfg(test)]
mod tests {
use crate::parse::Tier;
#[test]
fn no_frontmatter() {
let name = "archlinux-unknown-linux-gnu.md"; // arch linux is an arch, right?
let content = "";
assert!(super::parse_file(name, content).is_err());
}
#[test]
fn invalid_section() {
let name = "6502-nintendo-nes.md";
let content = "
---
---
## Not A Real Section
";
assert!(super::parse_file(name, content).is_err());
}
#[test]
fn wrong_header() {
let name = "x86_64-known-linux-gnu.md";
let content = "
---
---
# x86_64-known-linux-gnu
";
assert!(super::parse_file(name, content).is_err());
}
#[test]
fn parse_correctly() {
let name = "cat-unknown-linux-gnu.md";
let content = r#"
---
tier: "1" # first-class cats
maintainers: ["who maintains the cat?"]
---
## Requirements
This target mostly just meows and doesn't do much.
## Testing
You can pet the cat and it might respond positively.
## Cross compilation
If you're on a dog system, there might be conflicts with the cat, be careful.
But it should be possible.
"#;
let info = super::parse_file(name, content).unwrap();
assert_eq!(info.maintainers, vec!["who maintains the cat?"]);
assert_eq!(info.pattern, name);
assert_eq!(info.tier, Some(Tier::One));
assert_eq!(
info.sections,
vec![
(
"Requirements".to_owned(),
"This target mostly just meows and doesn't do much.".to_owned(),
),
(
"Testing".to_owned(),
"You can pet the cat and it might respond positively.".to_owned(),
),
(
"Cross compilation".to_owned(),
"If you're on a dog system, there might be conflicts with the cat, be careful.\nBut it should be possible.".to_owned(),
),
]
);
}
}
mod tests;

80
src/parse/tests.rs Normal file
View file

@ -0,0 +1,80 @@
use crate::parse::Tier;
#[test]
fn no_frontmatter() {
let name = "archlinux-unknown-linux-gnu.md"; // arch linux is an arch, right?
let content = "";
assert!(super::parse_file(name, content).is_err());
}
#[test]
fn invalid_section() {
let name = "6502-nintendo-nes.md";
let content = "
---
---
## Not A Real Section
";
assert!(super::parse_file(name, content).is_err());
}
#[test]
fn wrong_header() {
let name = "x86_64-known-linux-gnu.md";
let content = "
---
---
# x86_64-known-linux-gnu
";
assert!(super::parse_file(name, content).is_err());
}
#[test]
fn parse_correctly() {
let name = "cat-unknown-linux-gnu.md";
let content = r#"
---
tier: "1" # first-class cats
maintainers: ["who maintains the cat?"]
---
## Requirements
This target mostly just meows and doesn't do much.
## Testing
You can pet the cat and it might respond positively.
## Cross compilation
If you're on a dog system, there might be conflicts with the cat, be careful.
But it should be possible.
"#;
let info = super::parse_file(name, content).unwrap();
assert_eq!(info.maintainers, vec!["who maintains the cat?"]);
assert_eq!(info.pattern, name);
assert_eq!(info.tier, Some(Tier::One));
assert_eq!(
info.sections,
vec![
(
"Requirements".to_owned(),
"This target mostly just meows and doesn't do much.".to_owned(),
),
(
"Testing".to_owned(),
"You can pet the cat and it might respond positively.".to_owned(),
),
(
"Cross compilation".to_owned(),
"If you're on a dog system, there might be conflicts with the cat, be careful.\nBut it should be possible.".to_owned(),
),
]
);
}

View file

@ -2,16 +2,13 @@ use eyre::{Context, OptionExt, Result};
use std::{fs, path::Path};
use crate::{
is_in_rust_lang_rust,
parse::{Footnote, Tier, TriStateBool},
RustcTargetInfo, TargetDocs,
};
impl TargetDocs {
fn has_host_tools(&self) -> bool {
self.metadata
.as_ref()
.map_or(false, |meta| meta.host == TriStateBool::True)
self.metadata.as_ref().map_or(false, |meta| meta.host == TriStateBool::True)
}
}
@ -64,10 +61,7 @@ pub fn render_target_md(target: &TargetDocs, rustc_info: &RustcTargetInfo) -> St
section("Maintainers", &maintainers_content);
for section_name in crate::SECTIONS {
let value = target
.sections
.iter()
.find(|(name, _)| name == section_name);
let value = target.sections.iter().find(|(name, _)| name == section_name);
let section_content = match value {
Some((_, value)) => value.clone(),
@ -112,7 +106,11 @@ fn replace_section(prev_content: &str, section_name: &str, replacement: &str) ->
}
/// Renders the non-target files like `SUMMARY.md` that depend on the target.
pub fn render_static(src_output: &Path, targets: &[(TargetDocs, RustcTargetInfo)]) -> Result<()> {
pub fn render_static(
check_only: bool,
src_output: &Path,
targets: &[(TargetDocs, RustcTargetInfo)],
) -> Result<()> {
let targets_file = src_output.join("platform-support").join("targets.md");
let old_targets = fs::read_to_string(&targets_file).wrap_err("reading summary file")?;
@ -125,26 +123,8 @@ pub fn render_static(src_output: &Path, targets: &[(TargetDocs, RustcTargetInfo)
let new_targets =
replace_section(&old_targets, "TARGET", &target_list).wrap_err("replacing targets.md")?;
if !check_only {
fs::write(targets_file, new_targets).wrap_err("writing targets.md")?;
if !is_in_rust_lang_rust() {
fs::write(
"targets/src/information.md",
"\
# platform support generated
This is an experiment of what generated target tier documentation could look like.
See <https://github.com/Nilstrieb/target-tier-docs-experiment> for the source.
The README of the repo contains more information about the motivation and benefits.
Targets of interest with information filled out are any tvos targets like [aarch64-apple-tvos](./aarch64-apple-tvos.md)
and [powerpc64-ibm-aix](./powerpc64-ibm-aix.md).
But as you might notice, all targets are actually present with a stub :3.
",
)
.wrap_err("writing front page information about experiment")?;
}
let platform_support_main = src_output.join("platform-support.md");
@ -152,8 +132,11 @@ pub fn render_static(src_output: &Path, targets: &[(TargetDocs, RustcTargetInfo)
fs::read_to_string(&platform_support_main).wrap_err("reading platform-support.md")?;
let platform_support_main_new =
render_platform_support_tables(&platform_support_main_old, targets)?;
if !check_only {
fs::write(platform_support_main, platform_support_main_new)
.wrap_err("writing platform-support.md")?;
}
Ok(())
}
@ -227,47 +210,35 @@ struct TierTable {
include_host: bool,
}
fn render_table<'a>(targets: &[(TargetDocs, RustcTargetInfo)], table: TierTable) -> Result<String> {
fn render_table(targets: &[(TargetDocs, RustcTargetInfo)], table: TierTable) -> Result<String> {
let mut rows = Vec::new();
let mut all_footnotes = Vec::new();
let targets = targets
.into_iter()
.filter(|target| (table.filter)(&target.0));
let targets = targets.into_iter().filter(|target| (table.filter)(&target.0));
for (target, _) in targets {
let meta = target.metadata.as_ref();
let mut notes = meta
.map(|meta| meta.notes.as_str())
.unwrap_or("unknown")
.to_owned();
let mut notes = meta.map(|meta| meta.notes.as_str()).unwrap_or("unknown").to_owned();
if meta.map_or(false, |meta| !meta.footnotes.is_empty()) {
let footnotes = &meta.unwrap().footnotes;
all_footnotes.extend(footnotes);
let footnotes_str = footnotes
.iter()
.map(|footnote| footnote.reference())
.collect::<Vec<_>>()
.join(" ");
let footnotes_str =
footnotes.iter().map(|footnote| footnote.reference()).collect::<Vec<_>>().join(" ");
notes = format!("{notes} {footnotes_str}");
}
let std = if table.include_std {
let std = meta
.map(|meta| render_table_tri_state_bool(meta.std))
.unwrap_or("?");
let std = meta.map(|meta| render_table_tri_state_bool(meta.std)).unwrap_or("?");
format!(" | {std}")
} else {
String::new()
};
let host = if table.include_host {
let host = meta
.map(|meta| render_table_tri_state_bool(meta.host))
.unwrap_or("?");
let host = meta.map(|meta| render_table_tri_state_bool(meta.host)).unwrap_or("?");
format!(" | {host}")
} else {
String::new()