Enable runtime rust hot patching. * roll back wry to 0.45 * cleanup rustc * wip * wip: fix client/server * get the subsecond cli thing working again * back to list of builds but with accessors * migrate some methods to workspace, clean up impl a bit * pass mode through build context * use a build context, similar to cargo/rustc * merge BuildUpdate and HandleUpdate into BuilderUpdate * Move more resolution of args into runner * migrate some methods to runner * hoist out fullstack * fix build request resolver * yay request is cleaned up * fixup resolution a bit * spawn off the build! * re-wire android autodetection * re-wire android autodetection * wire back up a few more things * re-wire tooling verification * I think it's mostly in the right condition for regular app * add depinfo parser * yay okay works with regular apps again * full rebuilds are back * rewire file wtcher * wire patch! * yayyyyyy patching works and is really fast * yay android works * clean up stdout piping * yayyy memap on android so we don't need root * create a static wasm jump table * wip: global offset table for wasm * wip... thinking about making the patches relocatable * wip: customized relocation * YESSSSSS RELOCATABLE WASM * clean up impl a bit * lil bit more cleanup * lil bit more * lil bit more cleanup * lil bit more cleanup, support 32 bit platforms * sick, wasm is completely pic * hmmmm not quite working yet * woooo, patches loading from rust+wasm * integrated into the cli 😎 * condense a bit * cleaned up request a bit * bust fingerprints * Make the file diffing logic a bit more sensible * still working through a few issues, server launches but is acting weird * fix merge conflict * more merge issues * remove stuff we don't want anymore * revert name change * wip: server not working anymore :( * split apart "dioxus fullstack" into "dioxus server" and "dioxus web" * fixup a few more compile errors * grrr opening server * simultaneous frontend and backend patching * use rustc wrapper * wip: passing assets to jumptable * migrate project-like examples * patchy patchy server fns! * rollback some random changes * unwind the js files * rollback hash * no need for patch * more cleanups * lil bit more cleanup, remove some old cruft * allow patching when wasm webpage is closed * tiny bit more robust * lil bit of clean up * clean up ws code * bit more clean ups * condense * undo file moves * move back other project * move other project * migrate out harness and janky CLI * fix compile * anonymize some files, heavily document others * lots more documentation, for posterity! * ton more docs * clean up the cli a bit more * more ws cleanup * more cleanup * migrate build id to cli-config * add command file handling * random small cleanups * wip.... * wip.... * fix: use workspace for krate discovery * fix panic logging on serve by placing it *after* the logs * reorder logging to be sensible on success * bring back swc * ws cruft * small patches to relocs in loader * bump krates * hoist main funcs into the ifunc table, fixing wasm * fix tui output for long lines * add more linker logging * wow, incredible, we build fat binaries, holy heckkkkk * fix args for frontend/backend * small cleanups * properly send along build IDs to clients * fix workspace compiles * clean up jump table work a bit * clean up logging a bit * open existing * fixup some commands * open existing browser integration * wire up the new dx serve client/server syntax * fixup `dx run` command * bring back some old functionality * fix serverfn feature sets * remove toast animation and simplify its code a bit * less intrusive toast * dont change build status when patching on web * add proper cache busting to vcomponent * clean up the patch file a bit * more lints/checks cleaned up * go back to TargetArgs but a BuildTargets resolved struct * clean up more nits * use an atomicptr for jumptable * fix interaction with initial web load + suspense * don't run asset system on wasm/bindgen js in dev * reduce blast radius of PR * use profile to determine if we're in release * cleanup prod html template * cleanup profiles * fix feature resolution of fullstack * if fullstack is explicitly false, don't use it * light cleanups * drop carg config2 * pass along linker args properly * make workspace examples compile with dx again * fewer unwraps and better error loggign * bit more error handlign * small cleanup * drive-by cleanups * use queries instead of initialize for aslr stuff * fix hotpatch with higher opt level * fix aslr bug from multiple clients * fix merge conflict * fix typos * clippy * fix miscompile * fix doctest * properly rollback wry to 0.45 * fix markdown path issues, other test issues * fix test in router * fix release mode test * fix some more tests, clean up a few more items * use fnptr instead of typeid * clean up memozation via subsecond in core * use "main" as sentinel * fix imports and re-exports * get off __rust_alloc for aslr offset detection * wip * wip... fixing unoptimized web * hmmmm mmmm * close,ish, still missing the wasmbindgen dynaimc imports * aha! full wasm works now - disable externref when compilng patch modules - manually link wasm instrinsics - displace the __wbindgen_placeholder__ table with the synthesized wbg imports what an awful pain. There might be a few items missing, so will test against the docsite shortly * remove failing test * hmmmm things aren't perfect * from what I can tell, relocatable wasm just doesn't work with wasm-bindgen sad https://github.com/rustwasm/wasm-bindgen/issues/1420 * AI IS LITERALLY THE WORST * preserve wasm snippets across compiles * IT HOTPATCHES THE DOCSITE, WE ARE LIVING IN THE FUTURE * remove the macos cfg-out * fix: need to allocate 1 more page * fix url issue * properly span props * properly fill in extra symbols * light cleanups and good docs * delete cruft, better docs * More cleanups and simplifications * clippy, typos * remove harnesses * wasm-bindgen error logging * clean up deps * remove cruft * proper priv/pub * use ifuncs for all env imports * cruft * use par_iter, better output * implement a cache for the base module * delete custom sections * cache the main native module too * add par iter in a few more places * clippy, map ident * better logging and status bars * wip: some small windows support * wip more windowss * fix weird regression * implement windows jump table * small fixes for windows * better windows support * windows support pt2 * rust lld is not msvc * whole archive the rlibs * ignore windows and sys crates * logging * ohhhhhh, pop out dlls * dedupe * thANK GOD FAT LINKING WORKS ON WINDOWS * hoist caching * implement patch based on cache * fix location for pdb * pass data symbols, correct object * add all symbol for windows * add log * treat none rva as undefined * whoops * handle windows calling convention * dont use relocated arm64 stub windows * not all are text? * WINDOWS FUNCTION PATCHING WORKS but issue with statics? * use our own entry * handle windows imp symbols * add log * jump to the real symbol not the layout * whoops * hotpatching windows works completely holy heck yay * drop linker args crate * disable ssg playwright for now * light cleanups * some cleanups / preps: - bump to serverfn 0.8.2 - fix playwright test - fix clippy * use fix from #4042 * bring in other fix for liveview * hoist some configuration * use __aslr_reference instead of aslr_reference * add better errors to patcherror * fix android * panic if patching fails * clean up tomls * wip: fixing wasm compile for server * fullstack wasm again * fix cfg * fixup feature detection * fix issue with features * fix playwright! * add barebones template test harness * fix compatibility between the two hotreload engines * fix preldue of server * fix check for wasm-opt * small fixes
525 lines
17 KiB
Rust
525 lines
17 KiB
Rust
//! Parse components into the VNode::Component variant
|
|
//!
|
|
//! Uses the regular robust RsxBlock parser and then validates the component, emitting errors as
|
|
//! diagnostics. This was refactored from a straightforward parser to this validation approach so
|
|
//! that we can emit errors as diagnostics instead of returning results.
|
|
//!
|
|
//! Using this approach we can provide *much* better errors as well as partial expansion wherever
|
|
//! possible.
|
|
//!
|
|
//! It does lead to the code actually being larger than it was before, but it should be much easier
|
|
//! to work with and extend. To add new syntax, we add it to the RsxBlock parser and then add a
|
|
//! validation step here. This does make using the component as a source of truth not as good, but
|
|
//! oddly enoughly, we want the tree to actually be capable of being technically invalid. This is not
|
|
//! usual for building in Rust - you want strongly typed things to be valid - but in this case, we
|
|
//! want to accept all sorts of malformed input and then provide the best possible error messages.
|
|
//!
|
|
//! If you're generally parsing things, you'll just want to parse and then check if it's valid.
|
|
|
|
use crate::innerlude::*;
|
|
use proc_macro2::TokenStream as TokenStream2;
|
|
use proc_macro2_diagnostics::SpanDiagnosticExt;
|
|
use quote::{quote, quote_spanned, ToTokens, TokenStreamExt};
|
|
use std::{collections::HashSet, vec};
|
|
use syn::{
|
|
parse::{Parse, ParseStream},
|
|
spanned::Spanned,
|
|
token, AngleBracketedGenericArguments, Expr, Ident, PathArguments, Result,
|
|
};
|
|
|
|
#[derive(PartialEq, Eq, Clone, Debug)]
|
|
pub struct Component {
|
|
pub name: syn::Path,
|
|
pub generics: Option<AngleBracketedGenericArguments>,
|
|
pub fields: Vec<Attribute>,
|
|
pub component_literal_dyn_idx: Vec<DynIdx>,
|
|
pub spreads: Vec<Spread>,
|
|
pub brace: Option<token::Brace>,
|
|
pub children: TemplateBody,
|
|
pub dyn_idx: DynIdx,
|
|
pub diagnostics: Diagnostics,
|
|
}
|
|
|
|
impl Parse for Component {
|
|
fn parse(input: ParseStream) -> Result<Self> {
|
|
let mut name = input.parse::<syn::Path>()?;
|
|
let generics = normalize_path(&mut name);
|
|
|
|
if !input.peek(token::Brace) {
|
|
return Ok(Self::empty(name, generics));
|
|
};
|
|
|
|
let RsxBlock {
|
|
attributes: fields,
|
|
children,
|
|
brace,
|
|
spreads,
|
|
diagnostics,
|
|
} = input.parse::<RsxBlock>()?;
|
|
|
|
let literal_properties_count = fields
|
|
.iter()
|
|
.filter(|attr| matches!(attr.value, AttributeValue::AttrLiteral(_)))
|
|
.count();
|
|
let component_literal_dyn_idx = vec![DynIdx::default(); literal_properties_count];
|
|
|
|
let mut component = Self {
|
|
dyn_idx: DynIdx::default(),
|
|
children: TemplateBody::new(children),
|
|
name,
|
|
generics,
|
|
fields,
|
|
brace: Some(brace),
|
|
component_literal_dyn_idx,
|
|
spreads,
|
|
diagnostics,
|
|
};
|
|
|
|
// We've received a valid rsx block, but it's not necessarily a valid component
|
|
// validating it will dump diagnostics into the output
|
|
component.validate_component_path();
|
|
component.validate_fields();
|
|
component.validate_component_spread();
|
|
|
|
Ok(component)
|
|
}
|
|
}
|
|
|
|
impl ToTokens for Component {
|
|
fn to_tokens(&self, tokens: &mut TokenStream2) {
|
|
let Self { name, generics, .. } = self;
|
|
|
|
// Create props either from manual props or from the builder approach
|
|
let props = self.create_props();
|
|
|
|
// Make sure we emit any errors
|
|
let diagnostics = &self.diagnostics;
|
|
|
|
tokens.append_all(quote! {
|
|
dioxus_core::DynamicNode::Component({
|
|
|
|
// todo: ensure going through the trait actually works
|
|
// we want to avoid importing traits
|
|
// use dioxus_core::prelude::Properties;
|
|
use dioxus_core::prelude::Properties;
|
|
let __comp = ({
|
|
#props
|
|
}).into_vcomponent(
|
|
#name #generics,
|
|
);
|
|
#diagnostics
|
|
__comp
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
impl Component {
|
|
// Make sure this a proper component path (uppercase ident, a path, or contains an underscorea)
|
|
// This should be validated by the RsxBlock parser when it peeks bodynodes
|
|
fn validate_component_path(&mut self) {
|
|
let path = &self.name;
|
|
|
|
// First, ensure the path is not a single lowercase ident with no underscores
|
|
if path.segments.len() == 1 {
|
|
let seg = path.segments.first().unwrap();
|
|
if seg.ident.to_string().chars().next().unwrap().is_lowercase()
|
|
&& !seg.ident.to_string().contains('_')
|
|
{
|
|
self.diagnostics.push(seg.ident.span().error(
|
|
"Component names must be uppercase, contain an underscore, or abe a path.",
|
|
));
|
|
}
|
|
}
|
|
|
|
// ensure path segments doesn't have PathArguments, only the last
|
|
// segment is allowed to have one.
|
|
if path
|
|
.segments
|
|
.iter()
|
|
.take(path.segments.len() - 1)
|
|
.any(|seg| seg.arguments != PathArguments::None)
|
|
{
|
|
self.diagnostics.push(path.span().error(
|
|
"Component names must not have path arguments. Only the last segment is allowed to have one.",
|
|
));
|
|
}
|
|
|
|
// ensure last segment only have value of None or AngleBracketed
|
|
if !matches!(
|
|
path.segments.last().unwrap().arguments,
|
|
PathArguments::None | PathArguments::AngleBracketed(_)
|
|
) {
|
|
self.diagnostics.push(
|
|
path.span()
|
|
.error("Component names must have no arguments or angle bracketed arguments."),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Make sure the spread argument is being used as props spreading
|
|
fn validate_component_spread(&mut self) {
|
|
// Next, ensure that there's only one spread argument in the attributes *and* it's the last one
|
|
for spread in self.spreads.iter().skip(1) {
|
|
self.diagnostics.push(
|
|
spread
|
|
.expr
|
|
.span()
|
|
.error("Only one set of manual props is allowed for a component."),
|
|
);
|
|
}
|
|
}
|
|
|
|
pub fn get_key(&self) -> Option<&AttributeValue> {
|
|
self.fields
|
|
.iter()
|
|
.find(|attr| attr.name.is_likely_key())
|
|
.map(|attr| &attr.value)
|
|
}
|
|
|
|
/// Ensure there's no duplicate props - this will be a compile error but we can move it to a
|
|
/// diagnostic, thankfully
|
|
fn validate_fields(&mut self) {
|
|
let mut seen = HashSet::new();
|
|
|
|
for field in self.fields.iter() {
|
|
match &field.name {
|
|
AttributeName::Custom(_) => {}
|
|
AttributeName::BuiltIn(k) => {
|
|
if !seen.contains(k) {
|
|
seen.insert(k);
|
|
} else {
|
|
self.diagnostics.push(k.span().error(
|
|
"Duplicate prop field found. Only one prop field per name is allowed.",
|
|
));
|
|
}
|
|
}
|
|
AttributeName::Spread(_) => {
|
|
unreachable!(
|
|
"Spread attributes should be handled in the spread validation step."
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Create the tokens we'll use for the props of the component
|
|
///
|
|
/// todo: don't create the tokenstream from scratch and instead dump it into the existing streama
|
|
fn create_props(&self) -> TokenStream2 {
|
|
let manual_props = self.manual_props();
|
|
|
|
let name = &self.name;
|
|
let generics = &self.generics;
|
|
let inner_scope_span = self
|
|
.brace
|
|
.as_ref()
|
|
.map(|b| b.span.join())
|
|
.unwrap_or(self.name.span());
|
|
|
|
let mut tokens = if let Some(props) = manual_props.as_ref() {
|
|
quote_spanned! { props.span() => let mut __manual_props = #props; }
|
|
} else {
|
|
// we only want to span the name and generics, not the `fc_to_builder` call so jump-to-def
|
|
// only finds the single entry (#name)
|
|
let spanned = quote_spanned! { self.name.span() => #name #generics };
|
|
quote! { fc_to_builder(#spanned) }
|
|
};
|
|
|
|
tokens.append_all(self.add_fields_to_builder(
|
|
manual_props.map(|_| Ident::new("__manual_props", proc_macro2::Span::call_site())),
|
|
));
|
|
|
|
if !self.children.is_empty() {
|
|
let children = &self.children;
|
|
// If the props don't accept children, attach the error to the first child
|
|
if manual_props.is_some() {
|
|
tokens.append_all(
|
|
quote_spanned! { children.first_root_span() => __manual_props.children = #children; },
|
|
)
|
|
} else {
|
|
tokens.append_all(
|
|
quote_spanned! { children.first_root_span() => .children( #children ) },
|
|
)
|
|
}
|
|
}
|
|
|
|
if manual_props.is_some() {
|
|
tokens.append_all(quote! { __manual_props })
|
|
} else {
|
|
// If this does fail to build, point the compiler error at the Prop braces
|
|
tokens.append_all(quote_spanned! { inner_scope_span => .build() })
|
|
}
|
|
|
|
tokens
|
|
}
|
|
|
|
fn manual_props(&self) -> Option<&Expr> {
|
|
self.spreads.first().map(|spread| &spread.expr)
|
|
}
|
|
|
|
// Iterate over the props of the component (without spreads, key, and custom attributes)
|
|
pub fn component_props(&self) -> impl Iterator<Item = &Attribute> {
|
|
self.fields
|
|
.iter()
|
|
.filter(move |attr| !attr.name.is_likely_key())
|
|
}
|
|
|
|
fn add_fields_to_builder(&self, manual_props: Option<Ident>) -> TokenStream2 {
|
|
let mut dynamic_literal_index = 0;
|
|
let mut tokens = TokenStream2::new();
|
|
for attribute in self.component_props() {
|
|
let release_value = attribute.value.to_token_stream();
|
|
|
|
// In debug mode, we try to grab the value from the dynamic literal pool if possible
|
|
let value = if let AttributeValue::AttrLiteral(literal) = &attribute.value {
|
|
let idx = self.component_literal_dyn_idx[dynamic_literal_index].get();
|
|
dynamic_literal_index += 1;
|
|
let debug_value = quote! { __dynamic_literal_pool.component_property(#idx, &*__template_read, #literal) };
|
|
quote! {
|
|
{
|
|
#[cfg(debug_assertions)]
|
|
{
|
|
#debug_value
|
|
}
|
|
#[cfg(not(debug_assertions))]
|
|
{
|
|
#release_value
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
release_value
|
|
};
|
|
|
|
match &attribute.name {
|
|
AttributeName::BuiltIn(name) => {
|
|
if let Some(manual_props) = &manual_props {
|
|
tokens.append_all(quote! { #manual_props.#name = #value; })
|
|
} else {
|
|
tokens.append_all(quote! { .#name(#value) })
|
|
}
|
|
}
|
|
AttributeName::Custom(name) => {
|
|
if manual_props.is_some() {
|
|
tokens.append_all(name.span().error(
|
|
"Custom attributes are not supported for components that are spread",
|
|
).emit_as_expr_tokens());
|
|
} else {
|
|
tokens.append_all(quote! { .push_attribute(#name, None, #value, false) })
|
|
}
|
|
}
|
|
// spreads are handled elsewhere
|
|
AttributeName::Spread(_) => {}
|
|
}
|
|
}
|
|
|
|
tokens
|
|
}
|
|
|
|
fn empty(name: syn::Path, generics: Option<AngleBracketedGenericArguments>) -> Self {
|
|
let mut diagnostics = Diagnostics::new();
|
|
diagnostics.push(
|
|
name.span()
|
|
.error("Components must have a body")
|
|
.help("Components must have a body, for example `Component {}`"),
|
|
);
|
|
Component {
|
|
name,
|
|
generics,
|
|
brace: None,
|
|
fields: vec![],
|
|
spreads: vec![],
|
|
children: TemplateBody::new(vec![]),
|
|
component_literal_dyn_idx: vec![],
|
|
dyn_idx: DynIdx::default(),
|
|
diagnostics,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Normalize the generics of a path
|
|
///
|
|
/// Ensure there's a `::` after the last segment if there are generics
|
|
fn normalize_path(name: &mut syn::Path) -> Option<AngleBracketedGenericArguments> {
|
|
let seg = name.segments.last_mut()?;
|
|
|
|
let mut generics = match seg.arguments.clone() {
|
|
PathArguments::AngleBracketed(args) => {
|
|
seg.arguments = PathArguments::None;
|
|
Some(args)
|
|
}
|
|
_ => None,
|
|
};
|
|
|
|
if let Some(generics) = generics.as_mut() {
|
|
use syn::Token;
|
|
generics.colon2_token = Some(Token));
|
|
}
|
|
|
|
generics
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use prettier_please::PrettyUnparse;
|
|
use syn::parse_quote;
|
|
|
|
/// Ensure we can parse a component
|
|
#[test]
|
|
fn parses() {
|
|
let input = quote! {
|
|
MyComponent {
|
|
key: "value {something}",
|
|
prop: "value",
|
|
..props,
|
|
div {
|
|
"Hello, world!"
|
|
}
|
|
}
|
|
};
|
|
|
|
let component: Component = syn::parse2(input).unwrap();
|
|
|
|
dbg!(component);
|
|
|
|
let input_without_manual_props = quote! {
|
|
MyComponent {
|
|
key: "value {something}",
|
|
prop: "value",
|
|
div { "Hello, world!" }
|
|
}
|
|
};
|
|
|
|
let component: Component = syn::parse2(input_without_manual_props).unwrap();
|
|
dbg!(component);
|
|
}
|
|
|
|
/// Ensure we reject invalid forms
|
|
///
|
|
/// Maybe want to snapshot the errors?
|
|
#[test]
|
|
fn rejects() {
|
|
let input = quote! {
|
|
myComponent {
|
|
key: "value",
|
|
prop: "value",
|
|
prop: "other",
|
|
..props,
|
|
..other_props,
|
|
div {
|
|
"Hello, world!"
|
|
}
|
|
}
|
|
};
|
|
|
|
let component: Component = syn::parse2(input).unwrap();
|
|
dbg!(component.diagnostics);
|
|
}
|
|
|
|
#[test]
|
|
fn to_tokens_properly() {
|
|
let input = quote! {
|
|
MyComponent {
|
|
key: "value {something}",
|
|
prop: "value",
|
|
prop: "value",
|
|
prop: "value",
|
|
prop: "value",
|
|
prop: 123,
|
|
..props,
|
|
div { "Hello, world!" }
|
|
}
|
|
};
|
|
|
|
let component: Component = syn::parse2(input).unwrap();
|
|
println!("{}", component.to_token_stream());
|
|
}
|
|
|
|
#[test]
|
|
fn to_tokens_no_manual_props() {
|
|
let input_without_manual_props = quote! {
|
|
MyComponent {
|
|
key: "value {something}",
|
|
named: "value {something}",
|
|
prop: "value",
|
|
count: 1,
|
|
div { "Hello, world!" }
|
|
}
|
|
};
|
|
let component: Component = syn::parse2(input_without_manual_props).unwrap();
|
|
println!("{}", component.to_token_stream().pretty_unparse());
|
|
}
|
|
|
|
#[test]
|
|
fn generics_params() {
|
|
let input_without_children = quote! {
|
|
Outlet::<R> {}
|
|
};
|
|
let component: crate::CallBody = syn::parse2(input_without_children).unwrap();
|
|
println!("{}", component.to_token_stream().pretty_unparse());
|
|
}
|
|
|
|
#[test]
|
|
fn generics_no_fish() {
|
|
let name = quote! { Outlet<R> };
|
|
let mut p = syn::parse2::<syn::Path>(name).unwrap();
|
|
let generics = normalize_path(&mut p);
|
|
assert!(generics.is_some());
|
|
|
|
let input_without_children = quote! {
|
|
div {
|
|
Component<Generic> {}
|
|
}
|
|
};
|
|
let component: BodyNode = syn::parse2(input_without_children).unwrap();
|
|
println!("{}", component.to_token_stream().pretty_unparse());
|
|
}
|
|
|
|
#[test]
|
|
fn fmt_passes_properly() {
|
|
let input = quote! {
|
|
Link { to: Route::List, class: "pure-button", "Go back" }
|
|
};
|
|
|
|
let component: Component = syn::parse2(input).unwrap();
|
|
|
|
println!("{}", component.to_token_stream().pretty_unparse());
|
|
}
|
|
|
|
#[test]
|
|
fn incomplete_components() {
|
|
let input = quote::quote! {
|
|
some::cool::Component
|
|
};
|
|
|
|
let _parsed: Component = syn::parse2(input).unwrap();
|
|
|
|
let input = quote::quote! {
|
|
some::cool::C
|
|
};
|
|
|
|
let _parsed: syn::Path = syn::parse2(input).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn identifies_key() {
|
|
let input = quote! {
|
|
Link { key: "{value}", to: Route::List, class: "pure-button", "Go back" }
|
|
};
|
|
|
|
let component: Component = syn::parse2(input).unwrap();
|
|
|
|
// The key should exist
|
|
assert_eq!(component.get_key(), Some(&parse_quote!("{value}")));
|
|
|
|
// The key should not be included in the properties
|
|
let properties = component
|
|
.component_props()
|
|
.map(|attr| attr.name.to_string())
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(properties, ["to", "class"]);
|
|
}
|
|
}
|