From 9c3ed7845c88f265ab4f441f8f3963ca1e9b5eca Mon Sep 17 00:00:00 2001 From: Simon Bourne Date: Fri, 1 Dec 2023 11:10:37 +0000 Subject: [PATCH] Convert to mdbook plugin --- Cargo.lock | 40 +- Cargo.toml | 11 +- README.md | 12 +- examples/book/Cargo.toml | 5 +- examples/book/book.toml | 2 + examples/book/build.rs | 3 - examples/book/src/SUMMARY.md | 6 +- .../{rust-book => mdbook-rust}/Cargo.toml | 9 +- packages/mdbook-rust/src/main.rs | 269 ++++++++++++++ packages/rust-book/src/lib.rs | 351 ------------------ 10 files changed, 318 insertions(+), 390 deletions(-) delete mode 100644 examples/book/build.rs rename packages/{rust-book => mdbook-rust}/Cargo.toml (66%) create mode 100644 packages/mdbook-rust/src/main.rs delete mode 100644 packages/rust-book/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 11e8a64..6d0ad62 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -906,6 +906,12 @@ dependencies = [ "hashbrown 0.14.3", ] +[[package]] +name = "indoc" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e186cfbae8084e513daff4240b4797e342f988cecda4fb6c939150f96315fd8" + [[package]] name = "inotify" version = "0.9.6" @@ -1091,6 +1097,23 @@ dependencies = [ "warp", ] +[[package]] +name = "mdbook-rust" +version = "0.1.0" +dependencies = [ + "anyhow", + "indoc", + "itertools 0.12.0", + "mdbook", + "ra_ap_syntax", + "semver", + "serde_json", +] + +[[package]] +name = "mdbook-rust-example" +version = "0.0.0" + [[package]] name = "memchr" version = "2.6.4" @@ -1696,23 +1719,6 @@ dependencies = [ "text-size", ] -[[package]] -name = "rust-book" -version = "0.1.0" -dependencies = [ - "itertools 0.12.0", - "mdbook", - "ra_ap_syntax", - "thiserror", -] - -[[package]] -name = "rust-book-example" -version = "0.0.0" -dependencies = [ - "rust-book", -] - [[package]] name = "rustc-demangle" version = "0.1.23" diff --git a/Cargo.toml b/Cargo.toml index 6ad3eac..5860c91 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,14 +7,17 @@ version = "0.1.0" edition = "2021" license = "MIT OR Apache-2.0" authors = ["Simon Bourne "] -homepage = "https://github.com/simon-bourne/rust-book" -repository = "https://github.com/simon-bourne/rust-book" +homepage = "https://github.com/simon-bourne/mdbook-rust" +repository = "https://github.com/simon-bourne/mdbook-rust" [workspace.dependencies] -rust-book = { version = "0.1.0", path = "packages/rust-book" } +mdbook-rust = { version = "0.1.0", path = "packages/mdbook-rust" } +anyhow = "1.0.75" +indoc = "2.0.4" itertools = "0.12.0" mdbook = "0.4.36" ra_ap_syntax = "0.0.187" -thiserror = "1.0.50" +semver = "1.0.20" +serde_json = "1.0.108" xtask-base = { git = "https://github.com/simon-bourne/rust-xtask-base" } diff --git a/README.md b/README.md index ce8ed94..8d938b4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ -# Rust Book +# MDBook Rust -[![tests](https://github.com/simon-bourne/rust-book/actions/workflows/ci-tests.yml/badge.svg)](https://github.com/simon-bourne/rust-book/actions/workflows/ci-tests.yml) -[![crates.io](https://img.shields.io/crates/v/rust-book.svg)](https://crates.io/crates/rust-book) -[![Documentation](https://docs.rs/rust-book/badge.svg)](https://docs.rs/rust-book) -[![MIT/Apache-2 licensed](https://img.shields.io/crates/l/rust-book)](./LICENSE-APACHE) +[![tests](https://github.com/simon-bourne/mdbook-rust/actions/workflows/ci-tests.yml/badge.svg)](https://github.com/simon-bourne/mdbook-rust/actions/workflows/ci-tests.yml) +[![crates.io](https://img.shields.io/crates/v/mdbook-rust.svg)](https://crates.io/cratemdbook-rustok) +[![Documentation](https://docs.rs/mdbook-rust/badge.svg)](https://docs.rs/mdbook-rust) +[![MIT/Apache-2 licensed](https://img.shields.io/crates/l/mdbook-rust)](./LICENSE-APACHE) + +Enhanced Rust support for MDBook. diff --git a/examples/book/Cargo.toml b/examples/book/Cargo.toml index 855f3d7..ed70641 100644 --- a/examples/book/Cargo.toml +++ b/examples/book/Cargo.toml @@ -1,7 +1,4 @@ [package] -name = "rust-book-example" +name = "mdbook-rust-example" version = "0.0.0" edition = { workspace = true } - -[build-dependencies] -rust-book.workspace = true diff --git a/examples/book/book.toml b/examples/book/book.toml index fe972a5..87bfc7a 100644 --- a/examples/book/book.toml +++ b/examples/book/book.toml @@ -7,3 +7,5 @@ title = "Rust Book Example" [build] create-missing = false + +[preprocessor.rust] diff --git a/examples/book/build.rs b/examples/book/build.rs deleted file mode 100644 index 1b4efdc..0000000 --- a/examples/book/build.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - rust_book::build().unwrap() -} diff --git a/examples/book/src/SUMMARY.md b/examples/book/src/SUMMARY.md index 44498e3..28e4860 100644 --- a/examples/book/src/SUMMARY.md +++ b/examples/book/src/SUMMARY.md @@ -1,6 +1,6 @@ # Summary -[Lib](lib.md) +[Lib](lib.rs) -- [Chapter 1](./chapter1.md) - - [Chapter 1 - 1](./chapter1/chapter1_1.md) +- [Chapter 1](./chapter1.rs) + - [Chapter 1 - 1](./chapter1/chapter1_1.rs) diff --git a/packages/rust-book/Cargo.toml b/packages/mdbook-rust/Cargo.toml similarity index 66% rename from packages/rust-book/Cargo.toml rename to packages/mdbook-rust/Cargo.toml index 31481f0..1273cf3 100644 --- a/packages/rust-book/Cargo.toml +++ b/packages/mdbook-rust/Cargo.toml @@ -1,17 +1,20 @@ [package] -name = "rust-book" +name = "mdbook-rust" version = { workspace = true } edition = { workspace = true } license = { workspace = true } authors = { workspace = true } homepage = { workspace = true } repository = { workspace = true } -description = "Rust Book" +description = "Enhanced Rust support for MDBook" keywords = [] readme = "../../README.md" [dependencies] +anyhow.workspace = true +indoc.workspace = true itertools.workspace = true mdbook.workspace = true ra_ap_syntax.workspace = true -thiserror.workspace = true +semver.workspace = true +serde_json.workspace = true diff --git a/packages/mdbook-rust/src/main.rs b/packages/mdbook-rust/src/main.rs new file mode 100644 index 0000000..a4705e2 --- /dev/null +++ b/packages/mdbook-rust/src/main.rs @@ -0,0 +1,269 @@ +use std::{cmp::min, collections::VecDeque, env, fmt::Display, io, process}; + +use anyhow::{bail, Result}; +use indoc::eprintdoc; +use itertools::Itertools; +use mdbook::{book::Chapter, preprocess::CmdPreprocessor, BookItem}; +use ra_ap_syntax::{ + ast::{self, HasModuleItem, HasName, Item}, + AstNode, AstToken, NodeOrToken, SourceFile, SyntaxKind, SyntaxNode, SyntaxToken, +}; +use semver::{Version, VersionReq}; + +fn main() { + let args = Vec::from_iter(env::args()); + match args + .iter() + .map(String::as_str) + .collect::>() + .as_slice() + { + [_exe, "supports", _] => process::exit(0), + [_exe] => (), + [exe, args @ ..] => usage(exe, args), + args => usage("mdbook-rust", args), + } + + if let Err(e) = preprocess() { + eprintln!("{e}"); + process::exit(1); + } +} + +fn usage(exe: &str, args: &[&str]) { + let args = args.join(" "); + + eprintdoc!( + " + Invalid arguments: {args} + + Usage: + {exe} + {exe} supports [OUTPUT_FORMAT] + " + ); + process::exit(1); +} + +fn preprocess() -> Result<()> { + let (ctx, mut book) = CmdPreprocessor::parse_input(io::stdin())?; + + let book_version = Version::parse(&ctx.mdbook_version)?; + let version_req = VersionReq::parse(mdbook::MDBOOK_VERSION)?; + + if !version_req.matches(&book_version) { + eprintln!( + "Warning: MDBook version ({}) doesn't match plugin version ({})", + ctx.mdbook_version, + mdbook::MDBOOK_VERSION, + ); + } + + let mut errors = Vec::new(); + + book.for_each_mut(|item| match item { + BookItem::Chapter(chapter) => { + if let Err(e) = write_chapter(chapter) { + errors.push(e); + } + } + BookItem::Separator => (), + BookItem::PartTitle(_) => (), + }); + + errors.into_iter().try_for_each(Err)?; + serde_json::to_writer(io::stdout(), &book)?; + + Ok(()) +} + +fn write_chapter(chapter: &mut Chapter) -> Result<()> { + if let Some(path) = &chapter.path { + if path.extension() == Some("rs".as_ref()) { + let source = parse_module(&chapter.content)?; + + for item in source.items() { + if let Item::Fn(function) = item { + if is_named(&function, "body") { + if let Some(new_content) = write_function(function)? { + chapter.content = new_content; + } + } + } + } + } + } + + Ok(()) +} + +fn write_function(function: ast::Fn) -> Result> { + if let Some(stmts) = function.body().and_then(|body| body.stmt_list()) { + let mut stmts: VecDeque<_> = stmts.syntax().children_with_tokens().collect(); + + expect_kind(SyntaxKind::L_CURLY, stmts.pop_front())?; + expect_kind(SyntaxKind::R_CURLY, stmts.pop_back())?; + + let body_text = stmts.iter().map(|s| s.to_string()).collect::(); + let ws_prefixes = body_text.lines().filter_map(whitespace_prefix); + let longest_prefix = longest_prefix(ws_prefixes); + + if stmts + .front() + .and_then(|node| node.as_token()) + .is_some_and(|token| ast::Whitespace::can_cast(token.kind())) + { + stmts.pop_front(); + } + + Ok(Some(write_body(stmts, longest_prefix))) + } else { + Ok(None) + } +} + +fn write_body( + stmts: impl IntoIterator>, + longest_prefix: &str, +) -> String { + let mut whitespace = String::new(); + let mut in_code_block = false; + let mut output = String::new(); + + for node in stmts { + match &node { + NodeOrToken::Node(node) => { + output.push_str(ensure_in_code_block(&mut in_code_block, &whitespace)); + output.push_str(&write_lines(node, longest_prefix)); + whitespace.clear(); + } + NodeOrToken::Token(token) => { + if let Some(comment) = ast::Comment::cast(token.clone()) { + if comment.is_doc() { + output.push_str(ensure_in_code_block(&mut in_code_block, &whitespace)); + + output.push_str(&write_lines(comment, longest_prefix)); + } else { + output.push_str(ensure_in_markdown(&mut in_code_block, &whitespace)); + output.push_str(&write_comment(comment, longest_prefix)); + } + + whitespace.clear(); + } else if ast::Whitespace::can_cast(token.kind()) { + whitespace = + "\n".repeat(token.to_string().chars().filter(|c| *c == '\n').count()) + } else { + output.push_str(&whitespace); + output.push_str(&write_lines(token, longest_prefix)); + whitespace.clear(); + } + } + } + } + + if in_code_block { + output.push_str("\n```"); + } + + output.push('\n'); + + output +} + +fn write_lines(text: impl Display, prefix: &str) -> String { + text.to_string() + .split('\n') + .map(|line| line.strip_prefix(prefix).unwrap_or(line)) + .join("\n") +} + +fn write_comment(comment: ast::Comment, prefix: &str) -> String { + let comment_suffix = &comment.text()[comment.prefix().len()..]; + let comment_text = match comment.kind().shape { + ast::CommentShape::Line => comment_suffix, + ast::CommentShape::Block => comment_suffix.strip_suffix("*/").unwrap_or(comment_suffix), + } + .trim_start(); + + write_lines(comment_text, prefix) +} + +fn parse_module(source_text: &str) -> Result { + let parsed = SourceFile::parse(source_text); + let errors = parsed.errors(); + + if !errors.is_empty() { + bail!(errors.iter().join("\n")) + } + + Ok(parsed.tree()) +} + +fn is_named(item: &impl HasName, name: &str) -> bool { + item.name().is_some_and(|n| n.text().as_ref() == name) +} + +fn longest_prefix<'a>(mut prefixes: impl Iterator) -> &'a str { + if let Some(mut longest_prefix) = prefixes.next() { + for prefix in prefixes { + // We can use `split_at` with `find_position` as our strings + // only contain single byte chars (' ' or '\t'). + longest_prefix = longest_prefix + .split_at( + longest_prefix + .chars() + .zip(prefix.chars()) + .find_position(|(x, y)| x != y) + .map(|(position, _ch)| position) + .unwrap_or_else(|| min(longest_prefix.len(), prefix.len())), + ) + .0; + } + + longest_prefix + } else { + "" + } +} + +fn ensure_in_markdown<'a>(in_code_block: &mut bool, whitespace: &'a str) -> &'a str { + let text = if *in_code_block { + "\n```\n\n" + } else { + whitespace + }; + + *in_code_block = false; + text +} + +fn ensure_in_code_block<'a>(in_code_block: &mut bool, whitespace: &'a str) -> &'a str { + let text = if *in_code_block { + whitespace + } else { + "\n\n```rust\n" + }; + + *in_code_block = true; + text +} + +fn whitespace_prefix(line: &str) -> Option<&str> { + let non_ws = |c| c != ' ' && c != '\t'; + line.split_once(non_ws).map(|(prefix, _)| prefix) +} + +fn expect_kind( + expected: SyntaxKind, + actual: Option>, +) -> Result<()> { + let actual_kind = actual + .and_then(|last| last.into_token()) + .map(|token| token.kind()); + + if Some(expected) == actual_kind { + Ok(()) + } else { + bail!("Unexpected token") + } +} diff --git a/packages/rust-book/src/lib.rs b/packages/rust-book/src/lib.rs deleted file mode 100644 index 98562ee..0000000 --- a/packages/rust-book/src/lib.rs +++ /dev/null @@ -1,351 +0,0 @@ -use std::{ - cmp::min, - collections::VecDeque, - env::{self, VarError}, - fmt::{self, Display}, - fs::{self, File}, - io::{self, BufWriter, Write}, - path::PathBuf, - result, -}; - -use itertools::Itertools; -use mdbook::MDBook; -use ra_ap_syntax::{ - ast::{self, HasModuleItem, HasName, HasVisibility, Item, VisibilityKind}, - AstNode, AstToken, NodeOrToken, SourceFile, SyntaxKind, SyntaxNode, SyntaxToken, -}; -use thiserror::Error; - -#[derive(Error, Debug)] -pub struct Error(String); - -impl Error { - fn raise(err: impl Display) -> Result<()> { - Err(Self(err.to_string())) - } -} - -trait ToError: Display {} - -impl From for Error { - fn from(value: T) -> Self { - Self(value.to_string()) - } -} - -impl ToError for VarError {} -impl ToError for io::Error {} -impl ToError for mdbook::errors::Error {} - -impl Display for Error { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.0) - } -} - -pub type Result = result::Result; - -pub fn build() -> Result<()> { - Book::new()?.build() -} - -struct Book { - cargo_manifest_dir: PathBuf, - src_dir: PathBuf, - out_dir: PathBuf, - out_src_dir: PathBuf, - book_out_dir: PathBuf, -} - -impl Book { - fn new() -> Result { - let cargo_manifest_dir = env::var("CARGO_MANIFEST_DIR")?; - let out_dir_name = env::var("OUT_DIR")?; - let out_dir: PathBuf = [&out_dir_name, "rust-book"].into_iter().collect(); - - Ok(Self { - cargo_manifest_dir: PathBuf::from(&cargo_manifest_dir), - src_dir: [&cargo_manifest_dir, "src"].into_iter().collect(), - out_dir: out_dir.clone(), - out_src_dir: out_dir.join("src"), - book_out_dir: [&cargo_manifest_dir, "book"].into_iter().collect(), - }) - } - - fn build(&self) -> Result<()> { - // Make a best effort to remove the old build directory. - let _result = fs::remove_dir_all(&self.out_dir); - fs::create_dir_all(&self.out_dir)?; - let mdbook_config_file = "book.toml"; - let mdbook_config_path = self.cargo_manifest_dir.join(mdbook_config_file); - fs::copy(&mdbook_config_path, self.out_dir.join(mdbook_config_file))?; - fs::create_dir_all(&self.out_src_dir)?; - let summary_file = "SUMMARY.md"; - fs::copy( - self.src_dir.join(summary_file), - self.out_src_dir.join(summary_file), - )?; - self.build_modules(&[])?; - - let mut config = mdbook::Config::from_disk(mdbook_config_path)?; - config.build.build_dir = self.book_out_dir.clone(); - MDBook::load_with_config(&self.out_dir, config)?.build()?; - println!("Built rust book to '{:?}'", &self.out_dir); - - Ok(()) - } - - fn build_modules(&self, module_path: &[&str]) -> Result<()> { - let path = if module_path.is_empty() { - PathBuf::from("lib") - } else { - module_path.iter().collect() - }; - let source = self.parse_module(&path)?; - - for item in source.items() { - match item { - Item::Fn(function) => { - if is_public(&function) && is_named(&function, "body") { - self.write_function(function, &path)?; - } - } - Item::Module(module) => { - if is_public(&module) { - self.write_module(module, module_path)?; - } - } - _ => (), - } - } - - Ok(()) - } - - fn write_function(&self, function: ast::Fn, path: &PathBuf) -> Result<()> { - if let Some(stmts) = function.body().and_then(|body| body.stmt_list()) { - let output_filename = self.out_src_dir.join(path).with_extension("md"); - fs::create_dir_all(output_filename.parent().unwrap())?; - let output_file = BufWriter::new(File::create(output_filename)?); - - let mut stmts: VecDeque<_> = stmts.syntax().children_with_tokens().collect(); - - expect_kind(SyntaxKind::L_CURLY, stmts.pop_front())?; - expect_kind(SyntaxKind::R_CURLY, stmts.pop_back())?; - - let body_text = stmts.iter().map(|s| s.to_string()).collect::(); - let ws_prefixes = body_text.lines().filter_map(whitespace_prefix); - let longest_prefix = longest_prefix(ws_prefixes); - - if stmts - .front() - .and_then(|node| node.as_token()) - .is_some_and(|token| ast::Whitespace::can_cast(token.kind())) - { - stmts.pop_front(); - } - - write_body(stmts, output_file, longest_prefix)?; - } - - Ok(()) - } - - fn write_module(&self, module: ast::Module, module_path: &[&str]) -> Result<()> { - if let Some(name) = module.name() { - self.build_modules( - &module_path - .iter() - .copied() - .chain([name.text().as_str()]) - .collect::>(), - )?; - } - - Ok(()) - } - - fn parse_module(&self, path: &PathBuf) -> Result { - let module_path = &self.src_dir.join(path); - let filename = module_path.with_extension("rs"); - - let source_text = fs::read_to_string(filename).or_else(|e| { - if matches!(e.kind(), io::ErrorKind::NotFound) { - fs::read_to_string(module_path.join("mod.rs")) - } else { - Err(e) - } - })?; - - let parsed = SourceFile::parse(&source_text); - let errors = parsed.errors(); - - if !errors.is_empty() { - Error::raise(errors.iter().join("\n"))?; - } - - Ok(parsed.tree()) - } -} - -fn longest_prefix<'a>(mut prefixes: impl Iterator) -> &'a str { - if let Some(mut longest_prefix) = prefixes.next() { - for prefix in prefixes { - // We can use `split_at` with `find_position` as our strings - // only contain single byte chars (' ' or '\t'). - longest_prefix = longest_prefix - .split_at( - longest_prefix - .chars() - .zip(prefix.chars()) - .find_position(|(x, y)| x != y) - .map(|(position, _ch)| position) - .unwrap_or_else(|| min(longest_prefix.len(), prefix.len())), - ) - .0; - } - - longest_prefix - } else { - "" - } -} - -fn write_body( - stmts: impl IntoIterator>, - mut output_file: BufWriter, - longest_prefix: &str, -) -> Result<()> { - let mut whitespace = String::new(); - let mut in_code_block = false; - - for node in stmts { - match &node { - NodeOrToken::Node(node) => { - ensure_in_code_block(&mut output_file, &mut in_code_block, &whitespace)?; - write_lines(&mut output_file, node, longest_prefix)?; - whitespace.clear(); - } - NodeOrToken::Token(token) => { - if let Some(comment) = ast::Comment::cast(token.clone()) { - if comment.is_doc() { - ensure_in_code_block(&mut output_file, &mut in_code_block, &whitespace)?; - - write_lines(&mut output_file, comment, longest_prefix)?; - } else { - ensure_in_markdown(&mut output_file, &mut in_code_block, &whitespace)?; - write_comment(&mut output_file, comment, longest_prefix)?; - } - - whitespace.clear(); - } else if ast::Whitespace::can_cast(token.kind()) { - whitespace = remove_prefix(token, longest_prefix); - } else { - write!(&mut output_file, "{whitespace}")?; - write_lines(&mut output_file, token, longest_prefix)?; - whitespace.clear(); - } - } - } - } - - if in_code_block { - write!(&mut output_file, "\n```")?; - } - - writeln!(&mut output_file)?; - - Ok(()) -} - -fn write_lines(output_file: &mut BufWriter, text: impl Display, prefix: &str) -> Result<()> { - let text = text - .to_string() - .split('\n') - .map(|line| line.strip_prefix(prefix).unwrap_or(line)) - .join("\n"); - write!(output_file, "{text}")?; - - Ok(()) -} - -fn write_comment( - output_file: &mut BufWriter, - comment: ast::Comment, - prefix: &str, -) -> Result<()> { - let comment_suffix = &comment.text()[comment.prefix().len()..]; - let comment_text = match comment.kind().shape { - ast::CommentShape::Line => comment_suffix, - ast::CommentShape::Block => comment_suffix.strip_suffix("*/").unwrap_or(comment_suffix), - } - .trim_start(); - - write_lines(output_file, comment_text, prefix) -} - -fn remove_prefix(token: &SyntaxToken, prefix: &str) -> String { - let token_text = token.to_string(); - let (prefix, suffix) = token_text.rsplit_once(prefix).unwrap_or((&token_text, "")); - format!("{prefix}{suffix}") -} - -fn ensure_in_markdown( - output_file: &mut BufWriter, - in_code_block: &mut bool, - whitespace: &str, -) -> Result<()> { - if *in_code_block { - writeln!(output_file, "\n```\n")?; - } else { - write!(output_file, "{whitespace}")?; - } - - *in_code_block = false; - Ok(()) -} - -fn ensure_in_code_block( - output_file: &mut BufWriter, - in_code_block: &mut bool, - whitespace: &str, -) -> Result<()> { - if *in_code_block { - write!(output_file, "{whitespace}")?; - } else { - writeln!(output_file, "\n\n```rust")?; - } - - *in_code_block = true; - Ok(()) -} - -fn whitespace_prefix(line: &str) -> Option<&str> { - let non_ws = |c| c != ' ' && c != '\t'; - line.split_once(non_ws).map(|(prefix, _)| prefix) -} - -fn expect_kind( - expected: SyntaxKind, - actual: Option>, -) -> Result<()> { - let actual_kind = actual - .and_then(|last| last.into_token()) - .map(|token| token.kind()); - - if Some(expected) == actual_kind { - Ok(()) - } else { - Error::raise("Unexpected token") - } -} - -fn is_public(item: &impl HasVisibility) -> bool { - item.visibility() - .is_some_and(|vis| matches!(vis.kind(), VisibilityKind::Pub)) -} - -fn is_named(item: &impl HasName, name: &str) -> bool { - item.name().is_some_and(|n| n.text().as_ref() == name) -}