diff --git a/.cspell.json b/.cspell.json index a07f88f..b172ca7 100644 --- a/.cspell.json +++ b/.cspell.json @@ -15,6 +15,7 @@ "doctest", "mkdn", "repr", + "rustc", "rustlang", "rustup" ], @@ -31,6 +32,9 @@ "filename": "Cargo.toml", "dictionaries": [ "crates" + ], + "words": [ + "cfgs" ] }, { diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c2d5fe2..26ed3b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,11 @@ jobs: - macos-latest - ubuntu-latest - windows-latest + toolchain: + - stable + include: + - os: ubuntu-latest + toolchain: '1.85' steps: - name: Checkout uses: actions/checkout@v4 @@ -42,9 +47,9 @@ jobs: ~/.cargo/registry/cache/ ~/.cargo/git/db/ target/ - key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + key: ${{ runner.os }}-cargo-${{ matrix.toolchain }}-${{ hashFiles('**/Cargo.lock') }} - name: Set up toolchain - run: rustup install + run: rustup override set ${{ matrix.toolchain }} - name: Test run: cargo test --all-features --workspace @@ -62,7 +67,7 @@ jobs: ~/.cargo/registry/cache/ ~/.cargo/git/db/ target/ - key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + key: ${{ runner.os }}-cargo-stable-${{ hashFiles('**/Cargo.lock') }} - name: Set up toolchain run: rustup show - name: Check formatting diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 72fef32..2a52431 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,6 +31,16 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 + - name: Cache + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-cargo-stable-${{ hashFiles('**/Cargo.lock') }} - name: Set up toolchain run: rustup install - name: Release diff --git a/.vscode/settings.json b/.vscode/settings.json index b4da805..19bcdcd 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.lock b/Cargo.lock index af8a768..68f54d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "include-file" -version = "0.4.0" +version = "0.5.0" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 67b8c9c..5bb42ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "include-file" -version = "0.4.0" +version = "0.5.0" description = "Include sections of files into Rust source code" readme = "README.md" authors = ["Heath Stewart (https://github.com/heaths)"] @@ -15,7 +15,7 @@ license = "MIT" proc-macro = true [dependencies] -proc-macro2 = "1.0.103" +proc-macro2 = { version = "1.0.103", features = ["span-locations"] } syn = "2.0.109" [dev-dependencies] @@ -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 fb6694e..9d586b3 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,19 @@ You can demonstrate just the code you want in markdown while maintaining the ben ## Macros -* `include_asciidoc!(path, name)` includes Rust snippets from AsciiDoc files, commonly with `.asciidoc`, `.adoc`, or `.asc` extensions. -* `include_markdown!(path, name)` includes Rust snippets from Markdown files, commonly with `.markdown`, `.mdown`, `.mkdn`, or `.md` extensions. -* `include_org!(path, name)` includes Rust snippets from Org files, commonly with `.org` extension. -* `include_textile!(path, name)` includes Rust snippets from Textile files, commonly with `.textile` extension. +Macro | Description +------------------------------- | --- +`include_asciidoc!(path, name)` | Includes Rust snippets from AsciiDoc files, commonly with `.asciidoc`, `.adoc`, or `.asc` extensions. +`include_markdown!(path, name)` | Includes Rust snippets from Markdown files, commonly with `.markdown`, `.mdown`, `.mkdn`, or `.md` extensions. +`include_org!(path, name)` | Includes Rust snippets from Org files, commonly with `.org` extension. +`include_textile!(path, name)` | Includes Rust snippets from Textile files, commonly with `.textile` extension. + +All of these macros also support the following parameters: + +Parameter | Description +---------- | --- +`relative` | (*Requires rustc 1.88 or newer*) The path is relative to the source file calling the macro. May show an error in rust-analyzer until [rust-lang/rust-analyzer#15950](https://github.com/rust-lang/rust-analyzer/issues/15950) is fixed. +`scope` | Includes the Rust snippet in braces `{ .. }`. ## Examples diff --git a/build.rs b/build.rs index 08b7c5b..9ebeaf6 100644 --- a/build.rs +++ b/build.rs @@ -1,6 +1,88 @@ // 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/lib.rs b/src/lib.rs index d623189..5af455b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,25 +10,31 @@ mod org; mod tests; mod textile; -use proc_macro2::{Span, TokenStream}; +use proc_macro2::{Delimiter, Group, Span, TokenStream, TokenTree}; use std::{ - env, fmt, fs, + env, fs, io::{self, BufRead}, path::PathBuf, }; use syn::{ parse::{Parse, ParseStream}, - parse2, LitStr, Token, + parse2, + spanned::Spanned, + LitStr, Meta, Token, }; /// Include code from within a source block in an AsciiDoc file. /// -/// 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. -/// /// All AsciiDoc [source blocks](https://docs.asciidoctor.org/asciidoc/latest/verbatim/source-blocks/) /// with delimited [listing blocks](https://docs.asciidoctor.org/asciidoc/latest/verbatim/listing-blocks/) are supported. /// +/// # Arguments +/// +/// * `path` (*Required*) Path relative to the crate root directory. +/// * `name` (*Required*) Name of the code fence to include. +/// * `scope` Include the snippet in braces `{ .. }`. +/// * `relative` (*Requires rustc 1.88 or newer*) Path is relative to the source file calling the macro. +/// /// # Examples /// /// Consider the following source block in a crate `README.adoc` AsciiDoc file: @@ -67,11 +73,15 @@ pub fn include_asciidoc(item: proc_macro::TokenStream) -> proc_macro::TokenStrea /// Include code from within a code fence in a Markdown file. /// -/// Two arguments are required: a file path relative to the current source file, -/// and a name defined within the code fence as shown below. -/// /// All CommonMark [code fences](https://spec.commonmark.org/current/#fenced-code-blocks) are supported. /// +/// # Arguments +/// +/// * `path` (*Required*) Path relative to the crate root directory. +/// * `name` (*Required*) Name of the code fence to include. +/// * `scope` Include the snippet in braces `{ .. }`. +/// * `relative` (*Requires rustc 1.88 or newer*) Path is relative to the source file calling the macro. +/// /// # Examples /// /// Consider the following code fence in a crate `README.md` Markdown file: @@ -111,11 +121,15 @@ pub fn include_markdown(item: proc_macro::TokenStream) -> proc_macro::TokenStrea /// Include code from within a code block in a Textile file. /// -/// Two arguments are required: a file path relative to the current source file, -/// and an id defined within the code block as shown below. -/// /// All Textile [code blocks](https://textile-lang.com/doc/block-code) are supported. /// +/// # Arguments +/// +/// * `path` (*Required*) Path relative to the crate root directory. +/// * `name` (*Required*) Name of the code fence to include. +/// * `scope` Include the snippet in braces `{ .. }`. +/// * `relative` (*Requires rustc 1.88 or newer*) Path is relative to the source file calling the macro. +/// /// # Examples /// /// Consider the following code block in a crate `README.textile` Textile file: @@ -153,11 +167,15 @@ pub fn include_textile(item: proc_macro::TokenStream) -> proc_macro::TokenStream /// Include code from within a source block in an Org file. /// -/// Two arguments are required: a file path relative to the current source file, -/// and a name defined with `#+NAME:` immediately before the source block as shown below. -/// /// All Org [source code blocks](https://orgmode.org/manual/Structure-of-Code-Blocks.html) are supported. /// +/// # Arguments +/// +/// * `path` (*Required*) Path relative to the crate root directory. +/// * `name` (*Required*) Name of the code fence to include. +/// * `scope` Include the snippet in braces `{ .. }`. +/// * `relative` (*Requires rustc 1.88 or newer*) Path is relative to the source file calling the macro. +/// /// # Examples /// /// Consider the following source block in a crate `README.org` Org file: @@ -199,23 +217,46 @@ pub fn include_org(item: proc_macro::TokenStream) -> proc_macro::TokenStream { struct MarkdownArgs { path: LitStr, name: LitStr, -} - -impl fmt::Debug for MarkdownArgs { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("MarkdownArgs") - .field("path", &self.path.value()) - .field("name", &self.name.value()) - .finish() - } + scope: Option, + relative: Option, } impl Parse for MarkdownArgs { fn parse(input: ParseStream) -> syn::Result { - let path = input.parse()?; + const REQ_PARAMS: &str = r#"missing required string parameters ("path", "name")"#; + + let path = input + .parse() + .map_err(|err| syn::Error::new(err.span(), REQ_PARAMS))?; input.parse::()?; - let name = input.parse()?; - Ok(Self { path, name }) + let name = input + .parse() + .map_err(|err| syn::Error::new(err.span(), REQ_PARAMS))?; + + let mut scope = None; + let mut relative = None; + + if input.parse::().is_ok() { + let params = input.parse_terminated(Meta::parse, Token![,])?; + for param in params { + if param.path().is_ident("scope") { + scope = Some(param.span()); + } else if param.path().is_ident("relative") { + relative = Some(param.span()); + } else { + return Err(syn::Error::new(param.span(), "unsupported parameter")); + } + } + } else if !input.is_empty() { + return Err(syn::Error::new(input.span(), "unexpected token")); + } + + Ok(Self { + path, + name, + scope, + relative, + }) } } @@ -223,24 +264,39 @@ fn include_file(item: TokenStream, f: F) -> syn::Result where F: FnOnce(&str, io::Lines>) -> io::Result>, { - let args: MarkdownArgs = parse2(item).map_err(|_| { - syn::Error::new( - Span::call_site(), - "expected (path, name) literal string arguments", - ) - })?; - let file = open(&args.path.value()).map_err(|err| syn::Error::new(args.path.span(), err))?; + let args: MarkdownArgs = parse2(item)?; + let root = match args.relative { + #[cfg(span_locations)] + Some(span) => span.local_file(), + #[cfg(not(span_locations))] + Some(span) => return Err(syn::Error::new(span, "requires rustc 1.88 or newer")), + None => None, + }; + 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()?) + let mut content = content.parse()?; + if args.scope.is_some() { + content = TokenTree::Group(Group::new(Delimiter::Brace, content)).into(); + } + + Ok(content) } -fn open(path: &str) -> io::Result { +fn open(root: Option, path: &str) -> io::Result { let manifest_dir: PathBuf = env::var("CARGO_MANIFEST_DIR") .map_err(|_| io::Error::other("no manifest directory"))? .into(); - let path = manifest_dir.join(path); + let root = 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/tests.rs b/src/tests.rs index 22a973a..86b7e59 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -2,7 +2,7 @@ // Licensed under the MIT License. See LICENSE.txt in the project root for license information. use super::{include_file, open, MarkdownArgs}; -use proc_macro2::TokenStream; +use proc_macro2::{Delimiter, TokenStream, TokenTree}; use quote::quote; use std::io; use syn::parse2; @@ -54,13 +54,125 @@ fn parse_semicolon_sep_err() { include_file(tokens, collect).expect_err("expected parse error"); } +#[test] +fn parse_scope_param() { + let tokens = quote! { "README.md", "example", scope }; + let args: MarkdownArgs = parse2(tokens).expect("expected parse2"); + assert_eq!(args.path.value(), "README.md"); + assert_eq!(args.name.value(), "example"); + assert!(args.scope.is_some()); + assert!(args.relative.is_none()); +} + +#[test] +fn parse_relative_param() { + let tokens = quote! { "README.md", "example", relative }; + let args: MarkdownArgs = parse2(tokens).expect("expected parse2"); + assert_eq!(args.path.value(), "README.md"); + assert_eq!(args.name.value(), "example"); + assert!(args.scope.is_none()); + assert!(args.relative.is_some()); +} + +#[test] +fn parse_both_params() { + let tokens = quote! { "README.md", "example", scope, relative }; + let args: MarkdownArgs = parse2(tokens).expect("expected parse2"); + assert_eq!(args.path.value(), "README.md"); + assert_eq!(args.name.value(), "example"); + assert!(args.scope.is_some()); + assert!(args.relative.is_some()); +} + +#[test] +fn parse_both_params_reverse_order() { + let tokens = quote! { "README.md", "example", relative, scope }; + let args: MarkdownArgs = parse2(tokens).expect("expected parse2"); + assert_eq!(args.path.value(), "README.md"); + assert_eq!(args.name.value(), "example"); + assert!(args.scope.is_some()); + assert!(args.relative.is_some()); +} + +#[test] +fn parse_unsupported_param_err() { + let tokens = quote! { "README.md", "example", invalid }; + include_file(tokens, collect).expect_err("expected unsupported parameter error"); +} + +#[test] +fn parse_unsupported_param_with_valid_err() { + let tokens = quote! { "README.md", "example", scope, invalid }; + include_file(tokens, collect).expect_err("expected unsupported parameter error"); +} + +#[test] +fn parse_string_as_third_param_err() { + let tokens = quote! { "README.md", "example", "scope" }; + include_file(tokens, collect).expect_err("expected unsupported parameter error"); +} + +#[test] +fn parse_semicolon_after_second_arg_err() { + let tokens = quote! { "README.md", "example"; scope }; + include_file(tokens, collect).expect_err("expected parse error"); +} + +#[test] +fn parse_pipe_after_second_arg_err() { + let tokens = quote! { "README.md", "example" | scope }; + include_file(tokens, collect).expect_err("expected unexpected token error"); +} + +#[test] +fn parse_non_comma_separator_err() { + let tokens = quote! { "README.md", "example", scope; relative }; + include_file(tokens, collect).expect_err("expected parse error"); +} + +#[test] +fn parse_token_without_comma_err() { + let tokens = quote! { "README.md", "example" scope }; + include_file(tokens, collect).expect_err("expected unexpected token error"); +} + +#[test] +fn include_file_scope() { + let tokens = quote! { "README.md", "example", scope }; + let mut actual = include_file(tokens, collect) + .expect("expected include_file") + .into_iter(); + assert!(matches!( + actual.next(), + Some(TokenTree::Group(g)) if g.delimiter() == Delimiter::Brace, + )); +} + +#[test] +fn include_file_no_scope() { + let tokens = quote! { "README.md", "example" }; + let mut actual = include_file(tokens, collect) + .expect("expected include_file") + .into_iter(); + assert!(!matches!( + actual.next(), + Some(TokenTree::Group(g)) if g.delimiter() == Delimiter::Brace, + )); +} + #[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_relative_file() { + let file = open(Some(file!().into()), "../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 4ef129c..e6c1cae 100644 --- a/tests/readme.rs +++ b/tests/readme.rs @@ -11,7 +11,24 @@ fn test_asciidoc() -> Result<(), Box> { #[test] fn test_markdown() -> Result<(), Box> { - include_markdown!("README.md", "example"); + include_markdown!("README.md", "example", scope); + Ok(()) +} + +// rust-analyzer does not implement Span::local_file(): https://github.com/rust-lang/rust-analyzer/issues/15950 +#[cfg_attr(not(span_locations), ignore = "not supported")] +#[test] +fn test_relative_markdown() -> Result<(), Box> { + // Hide the error from the proc-macro in rust-analyzer. + #[cfg(all(span_locations, not(rust_analyzer)))] + { + include_markdown!("../README.md", "example", relative); + } + + if cfg!(rust_analyzer) { + panic!("not supported") + } + Ok(()) }