diff --git a/.cspell.json b/.cspell.json index 5da1c17..affb9b5 100644 --- a/.cspell.json +++ b/.cspell.json @@ -11,9 +11,11 @@ ], "words": [ "adoc", + "cfgs", "clippy", "doctest", "repr", + "rustc", "rustlang", "rustup" ], diff --git a/.vscode/settings.json b/.vscode/settings.json index b4da805..3d899c2 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -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, @@ -8,4 +13,4 @@ "[toml]": { "editor.formatOnSave": true } -} \ No newline at end of file +} diff --git a/Cargo.toml b/Cargo.toml index a4115f2..a3709e7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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)", +] } diff --git a/README.md b/README.md index 9b8700e..2062433 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/build.rs b/build.rs index 08b7c5b..dfeb227 100644 --- a/build.rs +++ b/build.rs @@ -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> { + 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 { + // cspell:ignore splitn + let mut values = s.splitn(3, ".").map(str::parse::); + 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 { + Some(self.cmp(other)) + } } diff --git a/src/asciidoc.rs b/src/asciidoc.rs index 4c16d85..0599b38 100644 --- a/src/asciidoc.rs +++ b/src/asciidoc.rs @@ -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 { - super::include_file(item, collect::) +pub fn include_asciidoc(item: TokenStream, root: Option) -> syn::Result { + super::include_file(item, root, collect::) } fn collect(name: &str, iter: io::Lines>) -> io::Result> { diff --git a/src/lib.rs b/src/lib.rs index d42dce2..9f70702 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,8 @@ #![doc = include_str!("../README.md")] +extern crate proc_macro; + mod asciidoc; mod markdown; #[cfg(test)] @@ -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. @@ -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. @@ -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() } @@ -130,7 +158,7 @@ impl Parse for MarkdownArgs { } } -fn include_file(item: TokenStream, f: F) -> syn::Result +fn include_file(item: TokenStream, root: Option, f: F) -> syn::Result where F: FnOnce(&str, io::Lines>) -> io::Result>, { @@ -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 { +fn open(root: Option, path: &str) -> io::Result { 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) } diff --git a/src/markdown.rs b/src/markdown.rs index bdf7784..9b333a8 100644 --- a/src/markdown.rs +++ b/src/markdown.rs @@ -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 { - super::include_file(item, collect::) +pub fn include_markdown(item: TokenStream, root: Option) -> syn::Result { + super::include_file(item, root, collect::) } fn collect(name: &str, iter: io::Lines>) -> io::Result> { diff --git a/src/tests.rs b/src/tests.rs index 22a973a..a0fe72b 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -17,7 +17,7 @@ fn collect( #[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"); @@ -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)); } diff --git a/tests/readme.rs b/tests/readme.rs index 423180e..90b8971 100644 --- a/tests/readme.rs +++ b/tests/readme.rs @@ -9,12 +9,38 @@ fn test_asciidoc() -> Result<(), Box> { Ok(()) } +#[test] +#[cfg_attr(not(span_locations), ignore)] +fn test_relative_asciidoc() -> Result<(), Box> { + #[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> { include_markdown!("README.md", "example"); Ok(()) } +#[test] +#[cfg_attr(not(span_locations), ignore)] +fn test_relative_markdown() -> Result<(), Box> { + #[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)]