Implement feature "nightly-spans"

This commit is contained in:
René Kijewski 2025-08-16 04:01:59 +02:00 committed by René Kijewski
parent a0d99ba6fc
commit 8c02e48cdb
20 changed files with 312 additions and 85 deletions

View File

@ -145,7 +145,7 @@ jobs:
####################################################################################################
# STEP 2: INTERMEDIATE
# ["Test", "Package", "MSRV"]
# ["Test", "Package", "Nightly", "MSRV"]
####################################################################################################
Test:
@ -168,8 +168,8 @@ jobs:
with:
toolchain: ${{ matrix.rust }}
- uses: Swatinem/rust-cache@v2
- run: cargo test --all-features
- run: cargo test --all-targets --all-features
- run: cargo test --features full
- run: cargo test --all-targets --features full
Package:
needs: ["Rustfmt", "Docs", "Audit", "Book", "Typos", "Jinja2-Assumptions", "DevSkim", "CargoSort"]
@ -193,6 +193,23 @@ jobs:
- run: cd ${{ matrix.package }} && cargo test --all-targets
- run: cd ${{ matrix.package }} && cargo clippy --all-targets -- -D warnings
Nightly:
needs: ["Rustfmt", "Docs", "Audit", "Book", "Typos", "Jinja2-Assumptions", "DevSkim", "CargoSort"]
strategy:
matrix:
package: [
askama, askama_derive, askama_escape, askama_macros, askama_parser,
testing, testing-alloc, testing-no-std, testing-renamed,
]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@nightly
- uses: Swatinem/rust-cache@v2
- run: cd ${{ matrix.package }} && cargo test --all-features
MSRV:
needs: ["Rustfmt", "Docs", "Audit", "Book", "Typos", "Jinja2-Assumptions", "DevSkim", "CargoSort"]
runs-on: ubuntu-latest
@ -203,7 +220,7 @@ jobs:
- uses: dtolnay/rust-toolchain@master
with:
toolchain: "1.88.0"
- run: cargo check --lib -p askama --all-features
- run: cargo check --lib -p askama --features full
####################################################################################################
# STEP 2: SLOW
@ -211,7 +228,7 @@ jobs:
####################################################################################################
Fuzz:
needs: ["Test", "Package", "MSRV"]
needs: ["Test", "Package", "Nightly", "MSRV"]
strategy:
matrix:
fuzz_target:
@ -231,7 +248,7 @@ jobs:
with:
toolchain: nightly
components: rust-src
- run: curl --location --silent --show-error --fail https://github.com/cargo-bins/cargo-quickinstall/releases/download/cargo-fuzz-0.12.0/cargo-fuzz-0.12.0-x86_64-unknown-linux-gnu.tar.gz | tar -xzvvf - -C $HOME/.cargo/bin
- run: curl --location --silent --show-error --fail https://github.com/cargo-bins/cargo-quickinstall/releases/download/cargo-fuzz-0.13.1/cargo-fuzz-0.13.1-x86_64-unknown-linux-gnu.tar.gz | tar -xzvvf - -C $HOME/.cargo/bin
- uses: Swatinem/rust-cache@v2
- run: cargo fuzz run ${{ matrix.fuzz_target }} --jobs 4 -- -max_total_time=600
working-directory: fuzzing
@ -239,7 +256,7 @@ jobs:
RUSTFLAGS: '-Ctarget-feature=-crt-static'
Cluster-Fuzz:
needs: ["Test", "Package", "MSRV"]
needs: ["Test", "Package", "Nightly", "MSRV"]
runs-on: ubuntu-latest
permissions:
actions: read

View File

@ -57,6 +57,7 @@ blocks = ["askama_macros?/blocks"]
code-in-doc = ["askama_macros?/code-in-doc"]
config = ["askama_macros?/config"]
derive = ["dep:askama_macros", "dep:askama_macros"]
nightly-spans = ["askama_macros/nightly-spans"]
serde_json = ["std", "askama_macros?/serde_json", "dep:serde", "dep:serde_json"]
std = [
"alloc",

View File

@ -56,8 +56,15 @@ default = [
alloc = []
blocks = ["syn/full"]
code-in-doc = ["dep:pulldown-cmark"]
config = ["external-sources", "dep:basic-toml", "dep:serde", "dep:serde_derive", "parser/config"]
config = [
"external-sources",
"dep:basic-toml",
"dep:serde",
"dep:serde_derive",
"parser/config",
]
external-sources = []
nightly-spans = []
proc-macro = ["proc-macro2/proc-macro"]
serde_json = []
std = ["alloc"]

View File

@ -174,40 +174,49 @@ impl<'a, 'h> Generator<'a, 'h> {
TmplKind::Block(trait_name) => field_new(trait_name, span),
};
let mut full_paths = TokenStream::new();
let mut paths_ts = TokenStream::new();
if let Some(full_config_path) = &self.input.config.full_config_path {
let full_config_path = self.rel_path(full_config_path).display().to_string();
full_paths = quote_spanned!(span=>
paths_ts.extend(quote_spanned!(span =>
const _: &[askama::helpers::core::primitive::u8] =
askama::helpers::core::include_bytes!(#full_config_path);
);
askama::helpers::core::include_bytes!(#full_config_path);
));
}
// Make sure the compiler understands that the generated code depends on the template files.
let mut paths = self
.contexts
.keys()
.map(|path| -> &Path { path })
.collect::<Vec<_>>();
paths.sort();
let paths = paths
.into_iter()
.filter(|path| {
.iter()
.map(|(path, _ctx)| {
(
&***path,
#[cfg(not(feature = "external-sources"))]
(),
#[cfg(feature = "external-sources")]
_ctx,
)
})
.filter(|&(path, _)| {
// Skip the fake path of templates defined in rust source.
match self.input.source {
#[cfg(feature = "external-sources")]
Source::Path(_) => true,
Source::Source(_) => **path != *self.input.path,
Source::Source(_) => *path != *self.input.path,
}
})
.fold(TokenStream::new(), |mut acc, path| {
let path = self.rel_path(path).display().to_string();
acc.extend(quote_spanned!(span=>
const _: &[askama::helpers::core::primitive::u8] =
askama::helpers::core::include_bytes!(#path);
));
acc
});
.collect::<Vec<_>>();
paths.sort_by_key(|&(path, _)| path);
for (path, _ctx) in paths {
let path = self.rel_path(path).display().to_string();
paths_ts.extend(quote_spanned!(span=>
const _: &[askama::helpers::core::primitive::u8] =
askama::helpers::core::include_bytes!(#path);
));
#[cfg(all(feature = "external-sources", feature = "nightly-spans"))]
_ctx.resolve_path(&path);
}
let mut content = Buffer::new();
let size_hint = self.impl_template_inner(ctx, &mut content)?;
@ -238,8 +247,7 @@ impl<'a, 'h> Generator<'a, 'h> {
helpers::{ResultConverter as _, core::fmt::Write as _},
};
#full_paths
#paths
#paths_ts
#content
askama::Result::Ok(())
}

View File

@ -177,6 +177,11 @@ impl<'a> Context<'a> {
pub(crate) fn file_info_of(&self, node: Span) -> Option<FileInfo<'a>> {
self.path.map(|path| FileInfo::of(node, path, self.parsed))
}
#[cfg(all(feature = "external-sources", feature = "nightly-spans"))]
pub(crate) fn resolve_path(&self, path: &str) {
self.literal.resolve_path(path);
}
}
fn ensure_top(

View File

@ -455,7 +455,7 @@ impl TemplateArgs {
source: match args.source {
#[cfg(feature = "external-sources")]
Some(PartialTemplateArgsSource::Path(s)) => {
(Source::Path(s.value().into()), SourceSpan::Path(s.span()))
(Source::Path(s.value().into()), SourceSpan::from_path(s)?)
}
Some(PartialTemplateArgsSource::Source(s)) => {
let (source, span) = SourceSpan::from_source(s)?;
@ -463,7 +463,7 @@ impl TemplateArgs {
}
#[cfg(feature = "code-in-doc")]
Some(PartialTemplateArgsSource::InDoc(span, source)) => {
(source, SourceSpan::Span(span))
(source, SourceSpan::CodeInDoc(span))
}
None => {
return Err(CompileError::no_file_info(

View File

@ -1,6 +1,10 @@
#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))]
#![deny(elided_lifetimes_in_paths)]
#![deny(unreachable_pub)]
#![cfg_attr(
all(feature = "external-sources", feature = "nightly-spans"),
feature(proc_macro_def_site, proc_macro_expand)
)]
extern crate proc_macro;

View File

@ -11,38 +11,53 @@ use crate::spans::rustc_literal_escaper::unescape;
#[allow(private_interfaces)] // don't look behind the curtain
#[derive(Clone, Debug)]
pub(crate) enum SourceSpan {
Empty(Span),
Source(SpannedSource),
// TODO: transclude source file
Path(Span),
#[cfg(feature = "external-sources")]
Path(SpannedPath),
// TODO: implement for "code-in-doc"
#[cfg_attr(not(feature = "code-in-doc"), allow(dead_code))]
Span(Span),
Empty(Span),
CodeInDoc(Span),
}
impl SourceSpan {
pub(crate) fn empty() -> SourceSpan {
Self::Empty(Span::call_site())
}
pub(crate) fn from_source(source: LitStr) -> Result<(String, Self), CompileError> {
let (source, span) = SpannedSource::from_source(source)?;
let (source, span) = SpannedSource::new(source)?;
Ok((source, Self::Source(span)))
}
#[cfg(feature = "external-sources")]
pub(crate) fn from_path(config: LitStr) -> Result<SourceSpan, CompileError> {
Ok(Self::Path(SpannedPath::new(config)?))
}
pub(crate) fn config_span(&self) -> Span {
match self {
SourceSpan::Source(literal) => literal.config_span(),
SourceSpan::Path(span) | SourceSpan::Span(span) | Self::Empty(span) => *span,
SourceSpan::Source(v) => v.config_span(),
#[cfg(feature = "external-sources")]
SourceSpan::Path(v) => v.config_span(),
SourceSpan::CodeInDoc(span) | Self::Empty(span) => *span,
}
}
pub(crate) fn content_subspan(&self, bytes: Range<usize>) -> Option<Span> {
match self {
Self::Source(source) => source.content_subspan(bytes),
Self::Path(_) | Self::Span(_) => None,
Self::Empty(_) => None,
Self::Source(v) => v.content_subspan(bytes),
#[cfg(feature = "external-sources")]
SourceSpan::Path(v) => v.content_subspan(bytes),
Self::CodeInDoc(_) | Self::Empty(_) => None,
}
}
pub(crate) fn empty() -> SourceSpan {
Self::Empty(Span::call_site())
#[cfg(all(feature = "external-sources", feature = "nightly-spans"))]
pub(crate) fn resolve_path(&self, path: &str) {
if let Self::Path(v) = self {
v.resolve_path(path);
}
}
}
@ -76,7 +91,7 @@ impl SpannedSource {
}
}
fn from_source(source: LitStr) -> Result<(String, Self), CompileError> {
fn new(source: LitStr) -> Result<(String, Self), CompileError> {
let literal = source.token();
let unparsed = literal.to_string();
let result = if unparsed.starts_with('r') {
@ -134,3 +149,114 @@ impl SpannedSource {
Ok((source, Self { literal, positions }))
}
}
#[cfg(feature = "external-sources")]
#[cfg_attr(not(feature = "nightly-spans"), derive(Debug, Clone))]
struct SpannedPath {
config: Span,
#[cfg(feature = "nightly-spans")]
literal: std::cell::Cell<Option<Literal>>,
}
#[cfg(feature = "external-sources")]
impl SpannedPath {
fn new(config: LitStr) -> Result<Self, CompileError> {
Ok(Self {
config: config.span(),
#[cfg(feature = "nightly-spans")]
literal: std::cell::Cell::new(None),
})
}
#[inline]
fn config_span(&self) -> Span {
self.config
}
}
#[cfg(all(feature = "external-sources", not(feature = "nightly-spans")))]
impl SpannedPath {
#[inline]
fn content_subspan(&self, _: Range<usize>) -> Option<Span> {
None
}
}
#[cfg(all(feature = "external-sources", feature = "nightly-spans"))]
const _: () = {
use std::cell::Cell;
use std::fmt;
use proc_macro::TokenStream as TokenStream1;
use proc_macro2::{TokenStream as TokenStream2, TokenTree};
use quote::quote_spanned;
impl fmt::Debug for SpannedPath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SpannedPath")
.field("config", &self.config)
.field("span", &self.literal_span())
.finish()
}
}
impl Clone for SpannedPath {
fn clone(&self) -> Self {
Self {
config: self.config.clone(),
literal: Cell::new(self.literal()),
}
}
}
impl SpannedPath {
fn content_subspan(&self, bytes: Range<usize>) -> Option<Span> {
let literal = self.literal.take()?;
let span = literal.subspan(bytes);
self.literal.set(Some(literal));
span
}
fn literal_span(&self) -> Option<Span> {
let literal = self.literal.take()?;
let span = literal.span();
self.literal.set(Some(literal));
Some(span)
}
fn literal(&self) -> Option<Literal> {
let literal = self.literal.take()?;
self.literal.set(Some(literal.clone()));
Some(literal)
}
fn resolve_path(&self, path: &str) {
if proc_macro::is_available()
&& let Ok(stream) = TokenStream1::from(quote_spanned! {
// In token expansion, `extern crate some_name` does not work. Only crates that
// were imported output _before_ the `#[derive(Template)]` invocation can be
// used.
//
// In the macro expansion, using an identifier that was not defined will emit
// an error `Diagnostic`. We cannot un-emit a `Diagnostic`, so this would be a
// hard compilation error.
//
// At `call_site()`, macro `include_str!` may not exist (`#[no_implicit_prelude]`),
// or may be shadowed. The symbol `askama` may not exist or be shadowed, too.
//
// At `def_site()`, the we know that the macro exists. We do not know if `core`
// or `::core` exists, but the unprefixed macro `include_str!` does exist, and
// it cannot be shadowed from outside of this function call.
//
// <https://doc.rust-lang.org/reference/names/preludes.html#r-names.preludes.lang.entities>
proc_macro::Span::def_site().into() => include_str!(#path)
})
.expand_expr()
&& let Some(TokenTree::Literal(literal)) =
TokenStream2::from(stream).into_iter().next()
{
self.literal.set(Some(literal));
}
}
}
};

View File

@ -32,6 +32,7 @@ alloc = ["askama_derive/alloc"]
blocks = ["askama_derive/blocks"]
code-in-doc = ["askama_derive/code-in-doc"]
config = ["askama_derive/config"]
nightly-spans = ["askama_derive/nightly-spans"]
serde_json = ["askama_derive/serde_json"]
std = ["askama_derive/std"]
urlencode = ["askama_derive/urlencode"]

View File

@ -10,3 +10,6 @@ publish = false
askama = { path = "../askama", version = "0.14.0", default-features = false, features = ["alloc", "derive"] }
assert_matches = "1.5.0"
[features]
nightly-spans = ["askama/nightly-spans"]

View File

@ -0,0 +1 @@
Hello {%- if let Some(user) = user? -%} , {{ user }} {%- endif -%}!

View File

@ -7,7 +7,7 @@ use askama::Template;
use assert_matches::assert_matches;
#[test]
fn hello_world() {
fn test_source() {
#[derive(Template)]
#[template(
ext = "html",
@ -17,30 +17,40 @@ fn hello_world() {
user: Result<Option<&'a str>, CustomError>,
}
test_common(|user| Hello { user });
}
#[test]
fn test_path() {
#[derive(Template)]
#[template(path = "hello-world.html")]
struct Hello<'a> {
user: Result<Option<&'a str>, CustomError>,
}
test_common(|user| Hello { user });
}
#[track_caller]
fn test_common<'a, T: Template + 'a>(hello: fn(Result<Option<&'a str>, CustomError>) -> T) {
let mut buffer = [0; 32];
let tmpl = Hello { user: Ok(None) };
let tmpl = hello(Ok(None));
let mut cursor = Cursor::new(&mut buffer);
assert_matches!(tmpl.render_into(&mut cursor), Ok(()));
assert_eq!(cursor.finalize(), Ok("Hello!"));
let tmpl = Hello {
user: Ok(Some("user")),
};
let tmpl = hello(Ok(Some("user")));
let mut cursor = Cursor::new(&mut buffer);
assert_matches!(tmpl.render_into(&mut cursor), Ok(()));
assert_eq!(cursor.finalize(), Ok("Hello, user!"));
let tmpl = Hello {
user: Ok(Some("<user>")),
};
let tmpl = hello(Ok(Some("<user>")));
let mut cursor = Cursor::new(&mut buffer);
assert_matches!(tmpl.render_into(&mut cursor), Ok(()));
assert_eq!(cursor.finalize(), Ok("Hello, &#60;user&#62;!"));
let tmpl = Hello {
user: Err(CustomError),
};
let tmpl = hello(Err(CustomError));
let mut cursor = Cursor::new(&mut buffer);
let err = match tmpl.render_into(&mut cursor) {
Err(askama::Error::Custom(err)) => err,

View File

@ -10,3 +10,6 @@ publish = false
askama = { path = "../askama", version = "0.14.0", default-features = false, features = ["derive"] }
assert_matches = "1.5.0"
[features]
nightly-spans = ["askama/nightly-spans"]

View File

@ -0,0 +1 @@
Hello {%- if let Some(user) = user? -%} , {{ user }} {%- endif -%}!

View File

@ -7,7 +7,7 @@ use askama::Template;
use assert_matches::assert_matches;
#[test]
fn hello_world() {
fn test_source() {
#[derive(Template)]
#[template(
ext = "html",
@ -17,30 +17,40 @@ fn hello_world() {
user: Result<Option<&'a str>, fmt::Error>,
}
test_common(|user| Hello { user })
}
#[test]
fn test_path() {
#[derive(Template)]
#[template(path = "hello-world.html")]
struct Hello<'a> {
user: Result<Option<&'a str>, fmt::Error>,
}
test_common(|user| Hello { user })
}
#[track_caller]
fn test_common<'a, T: Template + 'a>(hello: fn(Result<Option<&'a str>, fmt::Error>) -> T) {
let mut buffer = [0; 32];
let tmpl = Hello { user: Ok(None) };
let tmpl = hello(Ok(None));
let mut cursor = Cursor::new(&mut buffer);
assert_matches!(tmpl.render_into(&mut cursor), Ok(()));
assert_eq!(cursor.finalize(), Ok("Hello!"));
let tmpl = Hello {
user: Ok(Some("user")),
};
let tmpl = hello(Ok(Some("user")));
let mut cursor = Cursor::new(&mut buffer);
assert_matches!(tmpl.render_into(&mut cursor), Ok(()));
assert_eq!(cursor.finalize(), Ok("Hello, user!"));
let tmpl = Hello {
user: Ok(Some("<user>")),
};
let tmpl = hello(Ok(Some("<user>")));
let mut cursor = Cursor::new(&mut buffer);
assert_matches!(tmpl.render_into(&mut cursor), Ok(()));
assert_eq!(cursor.finalize(), Ok("Hello, &#60;user&#62;!"));
let tmpl = Hello {
user: Err(fmt::Error),
};
let tmpl = hello(Err(fmt::Error));
let mut cursor = Cursor::new(&mut buffer);
assert_matches!(tmpl.render_into(&mut cursor), Err(askama::Error::Fmt));
}

View File

@ -10,3 +10,6 @@ publish = false
some_name = { package = "askama", path = "../askama", version = "0.14.0", default-features = false, features = ["derive"] }
assert_matches = "1.5.0"
[features]
nightly-spans = ["some_name/nightly-spans"]

View File

@ -0,0 +1 @@
Hello {%- if let Some(user) = user? -%} , {{ user }} {%- endif -%}!

View File

@ -17,7 +17,7 @@ pub(crate) mod some {
}
#[test]
fn hello_world() {
fn test_source() {
#[derive(Template)]
#[template(
ext = "html",
@ -28,28 +28,41 @@ fn hello_world() {
user: Result<Option<&'a str>, fmt::Error>,
}
let tmpl = Hello { user: Ok(None) };
test_common(|user| Hello { user });
}
#[test]
fn test_path() {
#[derive(Template)]
#[template(
path = "hello-world.html",
askama = some::deeply::nested::path::with::some_name
)]
struct Hello<'a> {
user: Result<Option<&'a str>, fmt::Error>,
}
test_common(|user| Hello { user });
}
#[track_caller]
fn test_common<'a, T: Template + 'a>(hello: fn(Result<Option<&'a str>, fmt::Error>) -> T) {
let tmpl = hello(Ok(None));
let mut cursor = String::new();
assert_matches!(tmpl.render_into(&mut cursor), Ok(()));
assert_eq!(cursor, "Hello!");
let tmpl = Hello {
user: Ok(Some("user")),
};
let tmpl = hello(Ok(Some("user")));
let mut cursor = String::new();
assert_matches!(tmpl.render_into(&mut cursor), Ok(()));
assert_eq!(cursor, "Hello, user!");
let tmpl = Hello {
user: Ok(Some("<user>")),
};
let tmpl = hello(Ok(Some("<user>")));
let mut cursor = String::new();
assert_matches!(tmpl.render_into(&mut cursor), Ok(()));
assert_eq!(cursor, "Hello, &#60;user&#62;!");
let tmpl = Hello {
user: Err(fmt::Error),
};
let tmpl = hello(Err(fmt::Error));
let mut cursor = String::new();
assert_matches!(tmpl.render_into(&mut cursor), Err(some_name::Error::Fmt));
}

View File

@ -31,6 +31,7 @@ trybuild = "1.0.100"
default = ["blocks", "code-in-doc", "serde_json"]
blocks = ["askama/blocks"]
code-in-doc = ["askama/code-in-doc"]
nightly-spans = ["askama/nightly-spans"]
serde_json = ["dep:serde_json", "askama/serde_json"]
[lints.rust]

View File

@ -2,14 +2,26 @@
use ::askama::Template;
#[derive(Template)]
#[template(path = "hello.html")]
struct HelloTemplate<'a> {
name: &'a str,
}
#[test]
fn main() {
fn test_source() {
#[derive(Template)]
#[template(ext = "html", source = "Hello, {{ name }}!")]
struct HelloTemplate<'a> {
name: &'a str,
}
let hello = HelloTemplate { name: "world" };
::std::assert_eq!("Hello, world!", hello.render().unwrap());
}
#[test]
fn test_path() {
#[derive(Template)]
#[template(path = "hello.html")]
struct HelloTemplate<'a> {
name: &'a str,
}
let hello = HelloTemplate { name: "world" };
::std::assert_eq!("Hello, world!", hello.render().unwrap());
}