diff --git a/Cargo.lock b/Cargo.lock index 7099a5b47fd..d487851b2e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4155,6 +4155,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", + "rand 0.9.0", "rustversion", "syn 2.0.98", "trybuild", diff --git a/packages/yew-macro/Cargo.toml b/packages/yew-macro/Cargo.toml index 556de28a00f..c94a4760c20 100644 --- a/packages/yew-macro/Cargo.toml +++ b/packages/yew-macro/Cargo.toml @@ -28,6 +28,7 @@ rustversion = "1" [dev-dependencies] trybuild = "1" yew = { path = "../yew" } +rand = "0.9" [lints] workspace = true diff --git a/packages/yew-macro/src/function_component.rs b/packages/yew-macro/src/function_component.rs index d17b5b65155..a7de506526c 100644 --- a/packages/yew-macro/src/function_component.rs +++ b/packages/yew-macro/src/function_component.rs @@ -9,6 +9,7 @@ use syn::{ }; use crate::hook::BodyRewriter; +use crate::DisplayExt; #[derive(Clone)] pub struct FunctionComponent { @@ -157,14 +158,12 @@ impl FunctionComponent { fn filter_attrs_for_component_struct(&self) -> Vec { self.attrs .iter() - .filter_map(|m| { + .filter(|m| { m.path() .get_ident() - .and_then(|ident| match ident.to_string().as_str() { - "doc" | "allow" => Some(m.clone()), - _ => None, - }) + .is_some_and(|ident| (ident.eq_str("doc") || ident.eq_str("allow"))) }) + .cloned() .collect() } @@ -172,14 +171,12 @@ impl FunctionComponent { fn filter_attrs_for_component_impl(&self) -> Vec { self.attrs .iter() - .filter_map(|m| { + .filter(|m| { m.path() .get_ident() - .and_then(|ident| match ident.to_string().as_str() { - "allow" => Some(m.clone()), - _ => None, - }) + .is_some_and(|ident| ident.eq_str("allow")) }) + .cloned() .collect() } diff --git a/packages/yew-macro/src/hook/body.rs b/packages/yew-macro/src/hook/body.rs index ff49193e36b..ac272481928 100644 --- a/packages/yew-macro/src/hook/body.rs +++ b/packages/yew-macro/src/hook/body.rs @@ -8,6 +8,8 @@ use syn::{ ExprMatch, ExprWhile, Ident, Item, }; +use crate::DisplayExt; + #[derive(Debug)] pub struct BodyRewriter { branch_lock: Arc>, @@ -43,7 +45,7 @@ impl VisitMut for BodyRewriter { // Only rewrite hook calls. if let Expr::Path(ref m) = &*i.func { if let Some(m) = m.path.segments.last().as_ref().map(|m| &m.ident) { - if m.to_string().starts_with("use_") { + if m.starts_with("use_") { if self.is_branched() { emit_error!( m, @@ -69,7 +71,7 @@ impl VisitMut for BodyRewriter { match &mut *i { Expr::Macro(m) => { if let Some(ident) = m.mac.path.segments.last().as_ref().map(|m| &m.ident) { - if ident.to_string().starts_with("use_") { + if ident.starts_with("use_") { if self.is_branched() { emit_error!( ident, diff --git a/packages/yew-macro/src/hook/mod.rs b/packages/yew-macro/src/hook/mod.rs index df83c35753d..a1d2745a026 100644 --- a/packages/yew-macro/src/hook/mod.rs +++ b/packages/yew-macro/src/hook/mod.rs @@ -15,6 +15,8 @@ mod signature; pub use body::BodyRewriter; use signature::HookSignature; +use crate::DisplayExt; + #[derive(Clone)] pub struct HookFn { inner: ItemFn, @@ -42,7 +44,7 @@ impl Parse for HookFn { emit_error!(sig.unsafety, "unsafe functions can't be hooks"); } - if !sig.ident.to_string().starts_with("use_") { + if !sig.ident.starts_with("use_") { emit_error!(sig.ident, "hooks must have a name starting with `use_`"); } diff --git a/packages/yew-macro/src/html_tree/html_component.rs b/packages/yew-macro/src/html_tree/html_component.rs index 58e58988325..38f3ea55c70 100644 --- a/packages/yew-macro/src/html_tree/html_component.rs +++ b/packages/yew-macro/src/html_tree/html_component.rs @@ -1,3 +1,5 @@ +use std::fmt::{Display, Write}; + use proc_macro2::Span; use quote::{quote, quote_spanned, ToTokens}; use syn::parse::discouraged::Speculative; @@ -9,6 +11,41 @@ use super::{HtmlChildrenTree, TagTokens}; use crate::is_ide_completion; use crate::props::ComponentProps; +/// Returns `true` if `s` looks like a component name +pub fn is_component_name(s: impl Display) -> bool { + struct X { + is_ide_completion: bool, + empty: bool, + } + + impl Write for X { + fn write_str(&mut self, chunk: &str) -> std::fmt::Result { + if self.empty { + self.empty = chunk.is_empty(); + if !self.is_ide_completion + && chunk + .bytes() + .next() + .is_some_and(|b| !b.is_ascii_uppercase()) + { + return Err(std::fmt::Error); + } + } + chunk + .bytes() + .any(|b| b.is_ascii_uppercase()) + .then_some(()) + .ok_or(std::fmt::Error) + } + } + + let mut writer = X { + is_ide_completion: is_ide_completion(), + empty: true, + }; + write!(writer, "{s}").is_ok_and(|_| !writer.empty) +} + pub struct HtmlComponent { ty: Type, props: ComponentProps, diff --git a/packages/yew-macro/src/html_tree/html_dashed_name.rs b/packages/yew-macro/src/html_tree/html_dashed_name.rs index ca7fe733bcf..7c730d4ea5f 100644 --- a/packages/yew-macro/src/html_tree/html_dashed_name.rs +++ b/packages/yew-macro/src/html_tree/html_dashed_name.rs @@ -9,7 +9,7 @@ use syn::spanned::Spanned; use syn::{LitStr, Token}; use crate::stringify::Stringify; -use crate::{non_capitalized_ascii, Peek}; +use crate::{DisplayExt, Peek}; #[derive(Clone, PartialEq, Eq)] pub struct HtmlDashedName { @@ -18,17 +18,6 @@ pub struct HtmlDashedName { } impl HtmlDashedName { - /// Checks if this name is equal to the provided item (which can be anything implementing - /// `Into`). - pub fn eq_ignore_ascii_case(&self, other: S) -> bool - where - S: Into, - { - let mut s = other.into(); - s.make_ascii_lowercase(); - s == self.to_ascii_lowercase_string() - } - pub fn to_ascii_lowercase_string(&self) -> String { let mut s = self.to_string(); s.make_ascii_lowercase(); @@ -53,7 +42,7 @@ impl fmt::Display for HtmlDashedName { impl Peek<'_, Self> for HtmlDashedName { fn peek(cursor: Cursor) -> Option<(Self, Cursor)> { let (name, cursor) = cursor.ident()?; - if !non_capitalized_ascii(&name.to_string()) { + if !name.is_non_capitalized_ascii() { return None; } diff --git a/packages/yew-macro/src/html_tree/html_element.rs b/packages/yew-macro/src/html_tree/html_element.rs index 4de99bb6c87..e4526949ce2 100644 --- a/packages/yew-macro/src/html_tree/html_element.rs +++ b/packages/yew-macro/src/html_tree/html_element.rs @@ -9,7 +9,7 @@ use syn::{Expr, Ident, Lit, LitStr, Token}; use super::{HtmlChildrenTree, HtmlDashedName, TagTokens}; use crate::props::{ElementProps, Prop, PropDirective}; use crate::stringify::{Stringify, Value}; -use crate::{is_ide_completion, non_capitalized_ascii, Peek, PeekValue}; +use crate::{is_ide_completion, DisplayExt, Peek, PeekValue}; fn is_normalised_element_name(name: &str) -> bool { match name { @@ -457,8 +457,7 @@ impl ToTokens for HtmlElement { #[cfg(nightly_yew)] let invalid_void_tag_msg_start = { let span = vtag.span().unwrap(); - let source_file = span.source_file().path(); - let source_file = source_file.display(); + let source_file = span.file(); let start = span.start(); format!("[{}:{}:{}] ", source_file, start.line(), start.column()) }; @@ -668,13 +667,13 @@ impl PeekValue for HtmlElementOpen { let (tag_key, cursor) = TagName::peek(cursor)?; if let TagKey::Lit(name) = &tag_key { // Avoid parsing `` as an element. It needs to be parsed as an `HtmlList`. - if name.to_string() == "key" { + if name.eq_str("key") { let (punct, _) = cursor.punct()?; // ... unless it isn't followed by a '='. `` is a valid element! if punct.as_char() == '=' { return None; } - } else if !non_capitalized_ascii(&name.to_string()) { + } else if !name.is_non_capitalized_ascii() { return None; } } @@ -743,7 +742,7 @@ impl PeekValue for HtmlElementClose { } let (tag_key, cursor) = TagName::peek(cursor)?; - if matches!(&tag_key, TagKey::Lit(name) if !non_capitalized_ascii(&name.to_string())) { + if matches!(&tag_key, TagKey::Lit(name) if !name.is_non_capitalized_ascii()) { return None; } diff --git a/packages/yew-macro/src/html_tree/html_node.rs b/packages/yew-macro/src/html_tree/html_node.rs index d03ecb1bbfb..ee66f3a8679 100644 --- a/packages/yew-macro/src/html_tree/html_node.rs +++ b/packages/yew-macro/src/html_tree/html_node.rs @@ -7,7 +7,7 @@ use syn::Lit; use super::ToNodeIterator; use crate::stringify::Stringify; -use crate::PeekValue; +use crate::{DisplayExt, PeekValue}; pub enum HtmlNode { Literal(Box), @@ -44,10 +44,7 @@ impl PeekValue<()> for HtmlNode { fn peek(cursor: Cursor) -> Option<()> { cursor.literal().map(|_| ()).or_else(|| { let (ident, _) = cursor.ident()?; - match ident.to_string().as_str() { - "true" | "false" => Some(()), - _ => None, - } + (ident.eq_str("true") || ident.eq_str("false")).then_some(()) }) } } diff --git a/packages/yew-macro/src/html_tree/lint/mod.rs b/packages/yew-macro/src/html_tree/lint/mod.rs index 82b0c351f04..9a271765469 100644 --- a/packages/yew-macro/src/html_tree/lint/mod.rs +++ b/packages/yew-macro/src/html_tree/lint/mod.rs @@ -7,6 +7,7 @@ use syn::spanned::Spanned; use super::html_element::{HtmlElement, TagName}; use super::HtmlTree; use crate::props::{ElementProps, Prop}; +use crate::DisplayExt; /// Lints HTML elements to check if they are well formed. If the element is not well-formed, then /// use `proc-macro-error` (and the `emit_warning!` macro) to produce a warning. At present, these @@ -49,7 +50,7 @@ fn get_attribute<'a>(props: &'a ElementProps, name: &str) -> Option<&'a Prop> { props .attributes .iter() - .find(|item| item.label.eq_ignore_ascii_case(name)) + .find(|item| item.label.eq_str_ignore_ascii_case(name)) } /// Lints to check if anchor (``) tags have valid `href` attributes defined. @@ -58,7 +59,7 @@ pub struct AHrefLint; impl Lint for AHrefLint { fn lint(element: &HtmlElement) { if let TagName::Lit(ref tag_name) = element.name { - if !tag_name.eq_ignore_ascii_case("a") { + if !tag_name.eq_str_ignore_ascii_case("a") { return; }; if let Some(prop) = get_attribute(&element.props, "href") { @@ -80,7 +81,7 @@ impl Lint for AHrefLint { }; } else { emit_warning!( - quote::quote! {#tag_name}.span(), + tag_name.span(), "All `` elements should have a `href` attribute. This makes it possible \ for assistive technologies to correctly interpret what your links point to. \ https://developer.mozilla.org/en-US/docs/Learn/Accessibility/HTML#more_on_links" @@ -96,7 +97,7 @@ pub struct ImgAltLint; impl Lint for ImgAltLint { fn lint(element: &HtmlElement) { if let super::html_element::TagName::Lit(ref tag_name) = element.name { - if !tag_name.eq_ignore_ascii_case("img") { + if !tag_name.eq_str_ignore_ascii_case("img") { return; }; if get_attribute(&element.props, "alt").is_none() { diff --git a/packages/yew-macro/src/html_tree/mod.rs b/packages/yew-macro/src/html_tree/mod.rs index 7491b27c0b5..2a5d8142e22 100644 --- a/packages/yew-macro/src/html_tree/mod.rs +++ b/packages/yew-macro/src/html_tree/mod.rs @@ -6,7 +6,7 @@ use syn::parse::{Parse, ParseStream}; use syn::spanned::Spanned; use syn::{braced, token, Token}; -use crate::{is_ide_completion, PeekValue}; +use crate::PeekValue; mod html_block; mod html_component; @@ -20,7 +20,7 @@ mod lint; mod tag; use html_block::HtmlBlock; -use html_component::HtmlComponent; +use html_component::{is_component_name, HtmlComponent}; pub use html_dashed_name::HtmlDashedName; use html_element::HtmlElement; use html_if::HtmlIf; @@ -97,14 +97,10 @@ impl HtmlTree { Some(HtmlType::Component) } else if input.peek(Ident::peek_any) { let ident = Ident::parse_any(&input).ok()?; - let ident_str = ident.to_string(); if input.peek(Token![=]) || (input.peek(Token![?]) && input.peek2(Token![=])) { Some(HtmlType::List) - } else if ident_str.chars().next().unwrap().is_ascii_uppercase() - || input.peek(Token![::]) - || is_ide_completion() && ident_str.chars().any(|c| c.is_ascii_uppercase()) - { + } else if input.peek(Token![::]) || is_component_name(&ident) { Some(HtmlType::Component) } else { Some(HtmlType::Element) diff --git a/packages/yew-macro/src/html_tree/tag.rs b/packages/yew-macro/src/html_tree/tag.rs index 4c66f887089..be1ef9e7952 100644 --- a/packages/yew-macro/src/html_tree/tag.rs +++ b/packages/yew-macro/src/html_tree/tag.rs @@ -14,7 +14,7 @@ fn span_eq_hack(a: &Span, b: &Span) -> bool { fn error_replace_span(err: syn::Error, from: Span, to: impl ToTokens) -> syn::Error { let err_it = err.into_iter().map(|err| { if span_eq_hack(&err.span(), &from) { - syn::Error::new_spanned(&to, err.to_string()) + syn::Error::new_spanned(&to, err) } else { err } diff --git a/packages/yew-macro/src/lib.rs b/packages/yew-macro/src/lib.rs index cac2ac4233b..ab04440fc00 100644 --- a/packages/yew-macro/src/lib.rs +++ b/packages/yew-macro/src/lib.rs @@ -58,6 +58,8 @@ mod stringify; mod use_prepared_state; mod use_transitive_state; +use std::fmt::{Display, Write}; + use derive_props::DerivePropsInput; use function_component::{function_component_impl, FunctionComponent, FunctionComponentName}; use hook::{hook_impl, HookFn}; @@ -77,16 +79,136 @@ trait PeekValue { fn peek(cursor: Cursor) -> Option; } -fn non_capitalized_ascii(string: &str) -> bool { - if !string.is_ascii() { - false - } else if let Some(c) = string.bytes().next() { - c.is_ascii_lowercase() - } else { - false +/// Extension methods for treating `Display`able values like strings, without allocating the +/// strings. +/// +/// Needed to check the plentiful token-like values in the impl of the macros, which are +/// `Display`able but which either correspond to multiple source code tokens, or are themselves +/// tokens that don't provide a reference to their repr. +pub(crate) trait DisplayExt: Display { + /// Equivalent to [`str::eq_ignore_ascii_case`], but works for anything that's `Display` without + /// allocations + fn eq_str_ignore_ascii_case(&self, other: &str) -> bool { + /// Writer that only succeeds if all of the input is a __prefix__ of the contained string, + /// ignoring ASCII case. + /// + /// It cannot verify that `other` is not longer than the input. + struct X<'src> { + other: &'src str, + } + + impl Write for X<'_> { + fn write_str(&mut self, self_chunk: &str) -> std::fmt::Result { + if self.other.len() < self_chunk.len() { + return Err(std::fmt::Error); // `other` is shorter than `self`. + } + let other_chunk; + // Chop off a chunk from `other` the size of `self_chunk` to compare them + (other_chunk, self.other) = self.other.split_at(self_chunk.len()); + // Check if the chunks match + self_chunk + .eq_ignore_ascii_case(other_chunk) + .then_some(()) + .ok_or(std::fmt::Error) + } + } + + let mut writer = X { other }; + // The `is_ok_and` call ensures that there's nothing left over. + // If the remainder of `other` is not empty, it means `other` is longer than + // `self`. + write!(writer, "{self}").is_ok_and(|_| writer.other.is_empty()) + } + + /// Equivalent of `s1.to_string() == s2` but without allocations + fn eq_str(&self, other: &str) -> bool { + /// Writer that only succeeds if all of the input is a __prefix__ of the contained string. + /// + /// It cannot verify that `other` is not longer than the input. + struct X<'src> { + other: &'src str, + } + + impl Write for X<'_> { + fn write_str(&mut self, chunk: &str) -> std::fmt::Result { + self.other + .strip_prefix(chunk) // Try to chop off a chunk of `self` from `other` + .map(|rest| self.other = rest) // If it matched, reassign the rest of `other` + .ok_or(std::fmt::Error) // Otherwise, break out signifying a mismatch + } + } + + let mut writer = X { other }; + // The `is_ok_and` call ensures that there's nothing left over. + // If the remainder of `other` is not empty, it means `other` is longer than + // `self`. + write!(writer, "{self}").is_ok_and(|_| writer.other.is_empty()) + } + + /// Equivalent of [`str::starts_with`], but works for anything that's `Display` without + /// allocations + fn starts_with(&self, prefix: &str) -> bool { + /// Writer that only succeeds if `prefix` is a prefix of the input + struct X<'src> { + prefix: &'src str, + } + + impl Write for X<'_> { + fn write_str(&mut self, chunk: &str) -> std::fmt::Result { + match self.prefix.strip_prefix(chunk) { + // Try to chop off a chunk from `prefix` + Some(rest) => self.prefix = rest, // Reassign the rest of `prefix` on success + None => { + // Check if `prefix` became shorter than the rest of input, but can still be + // found in the input + chunk.strip_prefix(self.prefix).ok_or(std::fmt::Error)?; + self.prefix = ""; // All of `prefix` was found, ignore the rest of input + } + } + + Ok(()) + } + } + + let mut writer = X { prefix }; + write!(writer, "{self}").is_ok() + } + + /// Returns `true` if `s` only displays ASCII chars & doesn't start with a capital letter + fn is_non_capitalized_ascii(&self) -> bool { + /// Writer that succeeds only if the input is non-capitalised ASCII _or is empty_ + /// + /// The case of empty input should be checked afterwards by the checking `self.empty` + struct X { + /// Whether there was any non-empty input + empty: bool, + } + + impl Write for X { + fn write_str(&mut self, s: &str) -> std::fmt::Result { + if self.empty { + // Executed if there was no input before that + self.empty = s.is_empty(); + // Inspecting the 1st char + if s.chars().next().is_some_and(|c| c.is_ascii_uppercase()) { + // The 1st char is A-Z, the input is capitalised + return Err(std::fmt::Error); + } + } + + // Check if everything is ASCII + s.is_ascii().then_some(()).ok_or(std::fmt::Error) + } + } + + let mut writer = X { empty: true }; + // The `is_ok_and` call ensures that empty input is _NOT_ considered non-capitalised ASCII + write!(writer, "{self}").is_ok_and(|_| !writer.empty) } } +impl DisplayExt for T {} + /// Combine multiple `syn` errors into a single one. /// Returns `Result::Ok` if the given iterator is empty fn join_errors(mut it: impl Iterator) -> syn::Result<()> { @@ -188,3 +310,66 @@ pub fn use_transitive_state_without_closure(input: TokenStream) -> TokenStream { let transitive_state = parse_macro_input!(input as TransitiveState); transitive_state.to_token_stream_without_closure().into() } + +#[cfg(test)] +mod tests { + use std::fmt::{Display, Formatter, Write}; + + use rand::rngs::SmallRng; + use rand::{Rng, SeedableRng}; + + use crate::DisplayExt; + + const N_ITERS: usize = 0x4000; + const STR_LEN: usize = 32; + + /// Implements `Display` by feeding the formatter 1 `char` at a time. + /// + /// Tests the ability of [`DisplayExt`] to handle disparate chunks of strings + struct DisplayObfuscator<'a>(&'a str); + + impl Display for DisplayObfuscator<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + for ch in self.0.chars() { + f.write_char(ch)?; + } + Ok(()) + } + } + + /// Does the same thing as [`DisplayObfuscator`] but also lowercases all chars. + struct Lowercaser<'a>(&'a str); + + impl Display for Lowercaser<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + for ch in self.0.chars() { + f.write_char(ch.to_ascii_lowercase())?; + } + Ok(()) + } + } + + #[test] + fn display_ext_works() { + let rng = &mut SmallRng::from_os_rng(); + let mut s = String::with_capacity(STR_LEN); + + for i in 0..N_ITERS { + s.clear(); + // Generate `STR_LEN` ASCII chars + s.extend( + rng.random_iter::() + .take(STR_LEN) + .map(|b| (b & 127) as char), + ); + + assert!(Lowercaser(&s).eq_str_ignore_ascii_case(&s)); + assert!(DisplayObfuscator(&s).eq_str(&s)); + assert!(DisplayObfuscator(&s).starts_with(&s[..i % STR_LEN])); + assert_eq!( + DisplayObfuscator(&s).is_non_capitalized_ascii(), + s.is_non_capitalized_ascii() + ); + } + } +} diff --git a/packages/yew-macro/src/props/prop.rs b/packages/yew-macro/src/props/prop.rs index f1c0ad48079..966ffe74aaf 100644 --- a/packages/yew-macro/src/props/prop.rs +++ b/packages/yew-macro/src/props/prop.rs @@ -10,6 +10,7 @@ use syn::{braced, Block, Expr, ExprBlock, ExprMacro, ExprPath, ExprRange, Stmt, use crate::html_tree::HtmlDashedName; use crate::stringify::Stringify; +use crate::DisplayExt; #[derive(Copy, Clone)] pub enum PropDirective { @@ -226,12 +227,12 @@ impl PropList { } fn position(&self, key: &str) -> Option { - self.0.iter().position(|it| it.label.to_string() == key) + self.0.iter().position(|it| it.label.eq_str(key)) } /// Get the first prop with the given key. pub fn get_by_label(&self, key: &str) -> Option<&Prop> { - self.0.iter().find(|it| it.label.to_string() == key) + self.0.iter().find(|it| it.label.eq_str(key)) } /// Pop the first prop with the given key. diff --git a/packages/yew-macro/src/props/prop_macro.rs b/packages/yew-macro/src/props/prop_macro.rs index 2bddcebceb0..5c9bd014981 100644 --- a/packages/yew-macro/src/props/prop_macro.rs +++ b/packages/yew-macro/src/props/prop_macro.rs @@ -10,6 +10,7 @@ use syn::{Expr, Token, TypePath}; use super::{ComponentProps, Prop, PropList, Props}; use crate::html_tree::HtmlDashedName; +use crate::DisplayExt; /// Pop from `Punctuated` without leaving it in a state where it has trailing punctuation. fn pop_last_punctuated(punctuated: &mut Punctuated) -> Option { @@ -29,7 +30,7 @@ fn is_associated_properties(ty: &TypePath) -> bool { if seg.ident == "Properties" { if let Some(seg) = segments_it.next_back() { // ... and we can be reasonably sure that the previous segment is a component ... - if !crate::non_capitalized_ascii(&seg.ident.to_string()) { + if !seg.ident.is_non_capitalized_ascii() { // ... then we assume that this is an associated type like // `Component::Properties` return true;