Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ unicase = "2.9.0"

[dev-dependencies]
pretty_assertions = "1.4.1"
tempfile = "3.27.0"

[lints.clippy]
all = { level = "warn", priority = -2 }
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,13 @@ Alternatively, if you are sure that another installed toolchain is compatible, y
`CARGO_RDME_RUSTDOC_TOOLCHAIN` environment variable to a specific toolchain version, or to
`"default"` in order to use the default toolchain.

### Macro support

A macro can be used in a module doc such as `#![doc = include_str!("path/to/file.txt")]`. Cargo doc
will expand those macros to produce your documentation. Cargo rdme supports the following macros:

- [include_str!](https://doc.rust-lang.org/std/macro.include_str.html)

### Heading levels

The heading levels in the crate’s documentation will, by default, be nested under the level
Expand Down
214 changes: 181 additions & 33 deletions src/extract_doc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,54 +10,86 @@ pub enum ExtractDocError {
ErrorReadingSourceFile(PathBuf),
#[error("cannot parse source file: {0}")]
ErrorParsingSourceFile(syn::Error),
#[error("cannot open included file \"{0}\"")]
ErrorReadingIncludedFile(PathBuf),
}

pub fn extract_doc_from_source_file(
file_path: impl AsRef<Path>,
) -> Result<Option<Doc>, ExtractDocError> {
let source: String = std::fs::read_to_string(file_path.as_ref())
.map_err(|_| ExtractDocError::ErrorReadingSourceFile(file_path.as_ref().to_path_buf()))?;
let file_path = file_path.as_ref();
let source: String = std::fs::read_to_string(file_path)
.map_err(|_| ExtractDocError::ErrorReadingSourceFile(file_path.to_path_buf()))?;
let base_dir = file_path.parent().unwrap_or_else(|| Path::new(""));

extract_doc_from_source_str(&source)
extract_doc_from_source_str(&source, base_dir)
}

pub fn extract_doc_from_source_str(source: &str) -> Result<Option<Doc>, ExtractDocError> {
use syn::{ExprLit, Lit, Meta, MetaNameValue, parse_str};
fn is_include_str_path(path: &syn::Path) -> bool {
match path.segments.iter().collect::<Vec<_>>().as_slice() {
[seg] => seg.ident == "include_str",
[prefix, seg] => prefix.ident == "std" && seg.ident == "include_str",
_ => false,
}
}

pub fn extract_doc_from_source_str(
source: &str,
base_dir: impl AsRef<Path>,
) -> Result<Option<Doc>, ExtractDocError> {
use syn::{ExprLit, ExprMacro, Lit, Meta, MetaNameValue, parse_str};

let base_dir = base_dir.as_ref();
let ast: syn::File = parse_str(source).map_err(ExtractDocError::ErrorParsingSourceFile)?;
let mut lines: Vec<String> = Vec::with_capacity(1024);

for attr in &ast.attrs {
if Doc::is_toplevel_doc(attr)
&& let Meta::NameValue(MetaNameValue {
value: Expr::Lit(ExprLit { lit: Lit::Str(lstr), .. }),
..
}) = &attr.meta
{
let string: String = lstr.value();

match string.lines().count() {
0 => lines.push(String::new()),
1 => {
let line = string.strip_prefix(' ').map(ToOwned::to_owned).unwrap_or(string);
lines.push(line);
}
if !Doc::is_toplevel_doc(attr) {
continue;
}

let Meta::NameValue(MetaNameValue { value, .. }) = &attr.meta else {
continue;
};

// Multiline comment.
_ => {
fn empty_line(str: &str) -> bool {
str.chars().all(char::is_whitespace)
match value {
Expr::Lit(ExprLit { lit: Lit::Str(lstr), .. }) => {
let string: String = lstr.value();

match string.lines().count() {
0 => lines.push(String::new()),
1 => {
let line =
string.strip_prefix(' ').map(ToOwned::to_owned).unwrap_or(string);
lines.push(line);
}

let comment_lines = string
.lines()
.enumerate()
.filter(|(i, l)| !(*i == 0 && empty_line(l)))
.map(|(_, l)| l.to_owned());
// Multiline comment.
_ => {
fn empty_line(str: &str) -> bool {
str.chars().all(char::is_whitespace)
}

let comment_lines = string
.lines()
.enumerate()
.filter(|(i, l)| !(*i == 0 && empty_line(l)))
.map(|(_, l)| l.to_owned());

lines.extend(comment_lines);
lines.extend(comment_lines);
}
}
}
Expr::Macro(ExprMacro { mac, .. }) if is_include_str_path(&mac.path) => {
let lstr: syn::LitStr =
mac.parse_body().map_err(ExtractDocError::ErrorParsingSourceFile)?;
let path = base_dir.join(lstr.value());
let content = std::fs::read_to_string(&path)
.map_err(|_| ExtractDocError::ErrorReadingIncludedFile(path))?;

lines.extend(content.lines().map(ToOwned::to_owned));
}
_ => {}
}
}

Expand All @@ -82,7 +114,7 @@ mod tests {
"#
};

assert!(extract_doc_from_source_str(str).unwrap().is_none());
assert!(extract_doc_from_source_str(str, Path::new("")).unwrap().is_none());
}

#[test]
Expand All @@ -101,7 +133,7 @@ mod tests {
"#
};

let doc = extract_doc_from_source_str(str).unwrap().unwrap();
let doc = extract_doc_from_source_str(str, Path::new("")).unwrap().unwrap();
let lines: Vec<&str> = doc.lines().collect();

let expected = vec![
Expand Down Expand Up @@ -132,7 +164,7 @@ mod tests {
"#
};

let doc = extract_doc_from_source_str(str).unwrap().unwrap();
let doc = extract_doc_from_source_str(str, Path::new("")).unwrap().unwrap();
let lines: Vec<&str> = doc.lines().collect();

let expected = vec![
Expand Down Expand Up @@ -160,7 +192,7 @@ mod tests {
"#
};

let doc = extract_doc_from_source_str(str).unwrap().unwrap();
let doc = extract_doc_from_source_str(str, Path::new("")).unwrap().unwrap();
let lines: Vec<&str> = doc.lines().collect();

let expected = vec![
Expand All @@ -172,4 +204,120 @@ mod tests {

assert_eq!(lines, expected);
}

#[test]
fn test_doc_from_source_str_include_str() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("included.md"),
"# Included\n\nHello from the included file.\n",
)
.unwrap();

let str = indoc! { r#"
#![doc = include_str!("included.md")]

struct Nothing {}
"#
};

let doc = extract_doc_from_source_str(str, dir.path()).unwrap().unwrap();
let lines: Vec<&str> = doc.lines().collect();

assert_eq!(lines, vec!["# Included", "", "Hello from the included file."]);
}

#[test]
fn test_doc_from_source_str_std_include_str() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("included.md"), "included contents").unwrap();

let str = r#"#![doc = std::include_str!("included.md")]"#;

let doc = extract_doc_from_source_str(str, dir.path()).unwrap().unwrap();
let lines: Vec<&str> = doc.lines().collect();

assert_eq!(lines, vec!["included contents"]);
}

#[test]
fn test_doc_from_source_str_include_str_relative_to_base_dir() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("docs")).unwrap();
std::fs::write(dir.path().join("docs/intro.md"), "intro contents").unwrap();

let str = r#"#![doc = include_str!("docs/intro.md")]"#;

let doc = extract_doc_from_source_str(str, dir.path()).unwrap().unwrap();
let lines: Vec<&str> = doc.lines().collect();

assert_eq!(lines, vec!["intro contents"]);
}

#[test]
fn test_doc_from_source_str_include_str_interleaved() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("mid.md"), "included line 1\nincluded line 2").unwrap();

let str = indoc! { r#"
//! Before the include.
#![doc = include_str!("mid.md")]
//! After the include.

struct Nothing {}
"#
};

let doc = extract_doc_from_source_str(str, dir.path()).unwrap().unwrap();
let lines: Vec<&str> = doc.lines().collect();

let expected = vec![
"Before the include.", //
"included line 1", //
"included line 2", //
"After the include.", //
];

assert_eq!(lines, expected);
}

#[test]
fn test_doc_from_source_str_include_str_verbatim() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("verbatim.md"), " leading space kept\n\ttab kept").unwrap();

let str = r#"#![doc = include_str!("verbatim.md")]"#;

let doc = extract_doc_from_source_str(str, dir.path()).unwrap().unwrap();
let lines: Vec<&str> = doc.lines().collect();

assert_eq!(lines, vec![" leading space kept", "\ttab kept"]);
}

#[test]
fn test_doc_from_source_str_include_str_missing_file() {
let dir = tempfile::tempdir().unwrap();

let str = r#"#![doc = include_str!("does_not_exist.md")]"#;

let err = extract_doc_from_source_str(str, dir.path()).unwrap_err();

assert!(matches!(err, ExtractDocError::ErrorReadingIncludedFile(_)));
}

#[test]
fn test_doc_from_source_str_non_include_str_macro_skipped() {
let str = indoc! { r#"
//! Real doc line.
#![doc = concat!("a", "b")]

struct Nothing {}
"#
};

let doc = extract_doc_from_source_str(str, Path::new("")).unwrap().unwrap();
let lines: Vec<&str> = doc.lines().collect();

assert_eq!(lines, vec!["Real doc line."]);
}
}
7 changes: 7 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,13 @@
//! `CARGO_RDME_RUSTDOC_TOOLCHAIN` environment variable to a specific toolchain version, or to
//! `"default"` in order to use the default toolchain.
//!
//! ## Macro support
//!
//! A macro can be used in a module doc such as `#![doc = include_str!("path/to/file.txt")]`. Cargo doc
//! will expand those macros to produce your documentation. Cargo rdme supports the following macros:
//!
//! - [include_str!](https://doc.rust-lang.org/std/macro.include_str.html)
//!
//! ## Heading levels
//!
//! The heading levels in the crate’s documentation will, by default, be nested under the level
Expand Down
4 changes: 4 additions & 0 deletions tests/include_str_doc/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[package]
name = "integration_test"
version = "0.1.0"
edition = "2021"
14 changes: 14 additions & 0 deletions tests/include_str_doc/README-expected.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
header

<!-- cargo-rdme start -->

# My crate

```text
example line one
example line two
```

<!-- cargo-rdme end -->

footer
5 changes: 5 additions & 0 deletions tests/include_str_doc/README-template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
header

<!-- cargo-rdme -->

footer
6 changes: 6 additions & 0 deletions tests/include_str_doc/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
//! # My crate
//!
//! ```text
#![doc = include_str!("snapshots/example.txt")]
//! ```
fn foo() {}
2 changes: 2 additions & 0 deletions tests/include_str_doc/src/snapshots/example.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
example line one
example line two
5 changes: 5 additions & 0 deletions tests/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ fn integration_test_multiline_doc() {
run_test("multiline_doc");
}

#[test]
fn integration_test_include_str_doc() {
run_test("include_str_doc");
}

#[test]
fn integration_test_option_cmd_override_readme_path() {
let test_name = "option_cmd_override_readme_path";
Expand Down