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
2 changes: 2 additions & 0 deletions .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@
],
"words": [
"adoc",
"cfgs",
"clippy",
"doctest",
"repr",
"rustc",
"rustlang",
"rustup"
],
Expand Down
7 changes: 6 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
{
"rust-analyzer.cargo.cfgs": [
"debug_assertions",
"miri",
"rust-analyzer"
],
"rust-analyzer.cargo.features": "all",
"rust-analyzer.check.command": "clippy",
"rust-analyzer.checkOnSave": true,
Expand All @@ -8,4 +13,4 @@
"[toml]": {
"editor.formatOnSave": true
}
}
}
6 changes: 6 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,9 @@ quote = "1.0.42"

[lints.clippy]
test_attr_in_doctest = "allow"

[lints.rust]
unexpected_cfgs = { level = "allow", check-cfg = [
"cfg(rust_analyzer)",
"cfg(span_locations)",
] }
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ You can demonstrate just the code you want in markdown while maintaining the ben
## Examples

The `include_markdown!()` macro resolves a file path relative to the directory containing the crate `Cargo.toml` manifest file.
When compiled with Rust version 1.88.0 or newer, you can use macros like `include_relative_markdown!()` to resolve file paths relative to the source file directory that invoked the macro.

Consider a crate `README.md` with the following content:

Expand Down
81 changes: 81 additions & 0 deletions build.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,87 @@
// Copyright 2025 Heath Stewart.
// Licensed under the MIT License. See LICENSE.txt in the project root for license information.

use std::{cmp::Ordering, env, process::Command, str::FromStr};

const MIN_SPAN_LOCATIONS_VER: Version = Version::new(1, 88, 0);

fn main() {
println!("cargo::rerun-if-changed=README.md");
if matches!(rustc_version(), Ok(version) if version >= MIN_SPAN_LOCATIONS_VER) {
println!("cargo::rustc-cfg=span_locations");
}
}

fn rustc_version() -> Result<Version, Box<dyn std::error::Error>> {
let output = Command::new(env::var("RUSTC")?).arg("--version").output()?;
let stdout = String::from_utf8(output.stdout)?;
let mut words = stdout.split_whitespace();
words.next().ok_or("expected `rustc`")?;
let version: Version = words.next().ok_or("expected version")?.parse()?;
Ok(version)
}

#[derive(Debug, Default, Eq)]
struct Version {
major: u16,
minor: u16,
patch: u16,
}

impl Version {
const fn new(major: u16, minor: u16, patch: u16) -> Self {
Self {
major,
minor,
patch,
}
}
}

impl FromStr for Version {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
// cspell:ignore splitn
let mut values = s.splitn(3, ".").map(str::parse::<u16>);
Ok(Self {
major: values
.next()
.ok_or("no major version")?
.map_err(|err| err.to_string())?,
minor: values
.next()
.ok_or("no minor version")?
.map_err(|err| err.to_string())?,
patch: values
.next()
.ok_or("no patch version")?
.map_err(|err| err.to_string())?,
})
}
}

impl PartialEq for Version {
fn eq(&self, other: &Self) -> bool {
self.major == other.major && self.minor == other.minor && self.patch == other.patch
}
}

impl Ord for Version {
fn cmp(&self, other: &Self) -> Ordering {
let cmp = self.major.cmp(&other.major);
if cmp != Ordering::Equal {
return cmp;
}
let cmp = self.minor.cmp(&other.minor);
if cmp != Ordering::Equal {
return cmp;
}
self.patch.cmp(&other.patch)
}
}

impl PartialOrd for Version {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
6 changes: 3 additions & 3 deletions src/asciidoc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
// Licensed under the MIT License. See LICENSE.txt in the project root for license information.

use proc_macro2::TokenStream;
use std::{fs, io};
use std::{fs, io, path::PathBuf};

pub fn include_asciidoc(item: TokenStream) -> syn::Result<TokenStream> {
super::include_file(item, collect::<fs::File>)
pub fn include_asciidoc(item: TokenStream, root: Option<PathBuf>) -> syn::Result<TokenStream> {
super::include_file(item, root, collect::<fs::File>)
}

fn collect<R: io::Read>(name: &str, iter: io::Lines<io::BufReader<R>>) -> io::Result<Vec<String>> {
Expand Down
52 changes: 44 additions & 8 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

#![doc = include_str!("../README.md")]

extern crate proc_macro;

mod asciidoc;
mod markdown;
#[cfg(test)]
Expand All @@ -19,7 +21,7 @@ use syn::{
parse2, LitStr, Token,
};

/// Include code from within a source block in an AsciiDoc file.
/// Include code from within a source block in an AsciiDoc file relative to the crate manifest directory.
///
/// Two arguments are required: a file path relative to the current source file,
/// and an id defined within the source block attributes as shown below.
Expand Down Expand Up @@ -58,12 +60,25 @@ use syn::{
/// ```
#[proc_macro]
pub fn include_asciidoc(item: proc_macro::TokenStream) -> proc_macro::TokenStream {
asciidoc::include_asciidoc(item.into())
asciidoc::include_asciidoc(item.into(), None)
.unwrap_or_else(syn::Error::into_compile_error)
.into()
}

/// Include code from within a code fence in an AsciiDoc file relative to source file directory that invoked the macro.
///
/// Available only with Rust version 1.88.0 or newer.
/// See [`include_asciidoc`] for complete details.
#[cfg(span_locations)]
#[proc_macro]
pub fn include_relative_asciidoc(item: proc_macro::TokenStream) -> proc_macro::TokenStream {
#[allow(clippy::incompatible_msrv)]
asciidoc::include_asciidoc(item.into(), proc_macro::Span::call_site().local_file())
.unwrap_or_else(syn::Error::into_compile_error)
.into()
}

/// Include code from within a code fence in a Markdown file.
/// Include code from within a code fence in a Markdown file relative to the crate manifest directory.
///
/// Two arguments are required: a file path relative to the current source file,
/// and a name defined within the code fence as shown below.
Expand Down Expand Up @@ -102,7 +117,20 @@ pub fn include_asciidoc(item: proc_macro::TokenStream) -> proc_macro::TokenStrea
/// ```
#[proc_macro]
pub fn include_markdown(item: proc_macro::TokenStream) -> proc_macro::TokenStream {
markdown::include_markdown(item.into())
markdown::include_markdown(item.into(), None)
.unwrap_or_else(syn::Error::into_compile_error)
.into()
}

/// Include code from within a code fence in a Markdown file relative to source file directory that invoked the macro.
///
/// Available only with Rust version 1.88.0 or newer.
/// See [`include_markdown`] for complete details.
#[cfg(span_locations)]
#[proc_macro]
pub fn include_relative_markdown(item: proc_macro::TokenStream) -> proc_macro::TokenStream {
#[allow(clippy::incompatible_msrv)]
markdown::include_markdown(item.into(), proc_macro::Span::call_site().local_file())
.unwrap_or_else(syn::Error::into_compile_error)
.into()
}
Expand Down Expand Up @@ -130,7 +158,7 @@ impl Parse for MarkdownArgs {
}
}

fn include_file<F>(item: TokenStream, f: F) -> syn::Result<TokenStream>
fn include_file<F>(item: TokenStream, root: Option<PathBuf>, f: F) -> syn::Result<TokenStream>
where
F: FnOnce(&str, io::Lines<io::BufReader<fs::File>>) -> io::Result<Vec<String>>,
{
Expand All @@ -140,18 +168,26 @@ where
"expected (path, name) literal string arguments",
)
})?;
let file = open(&args.path.value()).map_err(|err| syn::Error::new(args.path.span(), err))?;
let file =
open(root, &args.path.value()).map_err(|err| syn::Error::new(args.path.span(), err))?;
let content = extract(file, &args.name.value(), f)
.map_err(|err| syn::Error::new(args.name.span(), err))?;

Ok(content.parse()?)
}

fn open(path: &str) -> io::Result<fs::File> {
fn open(root: Option<PathBuf>, path: &str) -> io::Result<fs::File> {
let manifest_dir: PathBuf = option_env!("CARGO_MANIFEST_DIR")
.ok_or_else(|| io::Error::other("no manifest directory"))?
.into();
let path = manifest_dir.join(path);
let root: PathBuf = match root {
Some(path) => path
.parent()
.map(|dir| manifest_dir.join(dir))
.ok_or_else(|| io::Error::other("no source parent directory"))?,
None => manifest_dir,
};
let path = root.join(path);
fs::File::open(path)
}

Expand Down
6 changes: 3 additions & 3 deletions src/markdown.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
// Licensed under the MIT License. See LICENSE.txt in the project root for license information.

use proc_macro2::TokenStream;
use std::{fs, io};
use std::{fs, io, path::PathBuf};

pub fn include_markdown(item: TokenStream) -> syn::Result<TokenStream> {
super::include_file(item, collect::<fs::File>)
pub fn include_markdown(item: TokenStream, root: Option<PathBuf>) -> syn::Result<TokenStream> {
super::include_file(item, root, collect::<fs::File>)
}

fn collect<R: io::Read>(name: &str, iter: io::Lines<io::BufReader<R>>) -> io::Result<Vec<String>> {
Expand Down
16 changes: 8 additions & 8 deletions src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ fn collect<R: io::Read>(
#[test]
fn parse_two_args() {
let tokens = quote! { "README.md", "example" };
include_file(tokens.clone(), collect).expect("expected TokenStream");
include_file(tokens.clone(), None, collect).expect("expected TokenStream");

let args: MarkdownArgs = parse2(tokens).expect("expected parse2");
assert_eq!(args.path.value(), "README.md");
Expand All @@ -27,40 +27,40 @@ fn parse_two_args() {
#[test]
fn parse_no_args_err() {
let tokens = TokenStream::new();
include_file(tokens, collect).expect_err("expected parse error");
include_file(tokens, None, collect).expect_err("expected parse error");
}

#[test]
fn parse_one_args_err() {
let tokens = quote! { "README.md" };
include_file(tokens, collect).expect_err("expected parse error");
include_file(tokens, None, collect).expect_err("expected parse error");
}

#[test]
fn parse_three_args_err() {
let tokens = quote! { "README.md", "example", "other" };
include_file(tokens, collect).expect_err("expected parse error");
include_file(tokens, None, collect).expect_err("expected parse error");
}

#[test]
fn parse_no_sep_err() {
let tokens = quote! { "README.md" "example" };
include_file(tokens, collect).expect_err("expected parse error");
include_file(tokens, None, collect).expect_err("expected parse error");
}

#[test]
fn parse_semicolon_sep_err() {
let tokens = quote! { "README.md"; "example" };
include_file(tokens, collect).expect_err("expected parse error");
include_file(tokens, None, collect).expect_err("expected parse error");
}

#[test]
fn open_file() {
let file = open("README.md").expect("expected README.md");
let file = open(None, "README.md").expect("expected README.md");
assert!(matches!(file.metadata(), Ok(meta) if meta.is_file()));
}

#[test]
fn open_err() {
assert!(matches!(open("missing.txt"), Err(err) if err.kind() == io::ErrorKind::NotFound));
assert!(matches!(open(None, "missing.txt"), Err(err) if err.kind() == io::ErrorKind::NotFound));
}
26 changes: 26 additions & 0 deletions tests/readme.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,38 @@ fn test_asciidoc() -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}

#[test]
#[cfg_attr(not(span_locations), ignore)]
fn test_relative_asciidoc() -> Result<(), Box<dyn std::error::Error>> {
#[cfg(all(span_locations, not(rust_analyzer)))]
{
// rust-analyzer does not implement Span::local_file(): https://github.com/rust-lang/rust-analyzer/issues/15950
include_file::include_relative_asciidoc!("README.adoc", "example");
Ok(())
}
#[cfg(any(not(span_locations), rust_analyzer))]
panic!("not supported")
}

#[test]
fn test_markdown() -> Result<(), Box<dyn std::error::Error>> {
include_markdown!("README.md", "example");
Ok(())
}

#[test]
#[cfg_attr(not(span_locations), ignore)]
fn test_relative_markdown() -> Result<(), Box<dyn std::error::Error>> {
#[cfg(all(span_locations, not(rust_analyzer)))]
{
// rust-analyzer does not implement Span::local_file(): https://github.com/rust-lang/rust-analyzer/issues/15950
include_file::include_relative_markdown!("../README.md", "example");
Ok(())
}
#[cfg(any(not(span_locations), rust_analyzer))]
panic!("not supported")
}

#[derive(Debug)]
struct Model {
#[allow(dead_code)]
Expand Down