Hotreloading of for/if/body, formatted strings, literals, component props, nested rsx, light CLI rewrite, cli TUI (#2258)

Hotreload the contents of for loops, if chains, component bodies, props, attributes, and any literals discovered in rsx!
Add a TUI renderer to the CLI.
Improve the CLI build system to be async and parallel.
Refactor RSX to allow partial expansion of expressions.
Merge autofmt implementations for consistency.
Merge the representation of elements and components under the hood.
Add a diagnostics system for rsx for improved error messages.
Drop interprocess and move to websockets for communication between the CLI and the server.
Assign IDs to nodes and attributes in a stable way to be used in non compiler contexts.
Add hotreloading to any body of component/for loop/if chain/etc.

---------

Co-authored-by: Evan Almloff <evanalmloff@gmail.com>
Co-authored-by: Liam Mitchell <liamkarlmitchell@gmail.com>
This commit is contained in:
authored and GitHub committed 2024-07-17 19:11:18 -07:00
1 parent 176e67e5b7
commit 88af3e7eff
181 files changed
+12755 -10806

No files matched your search

+543
View File
@@ -0,0 +1,543 @@
//! An arbitrary block parser.
//!
//! Is meant to parse the contents of a block that is either a component or an element.
//! We put these together to cut down on code duplication and make the parsers a bit more resilient.
//!
//! This involves custom structs for name, attributes, and children, as well as a custom parser for the block itself.
//! It also bubbles out diagnostics if it can to give better errors.
use crate::innerlude::*;
use proc_macro2::Span;
use proc_macro2_diagnostics::SpanDiagnosticExt;
use syn::{
ext::IdentExt,
parse::{Parse, ParseBuffer},
spanned::Spanned,
token::{self, Brace},
Expr, Ident, LitStr, Token,
};
/// An item in the form of
///
/// {
/// attributes,
/// ..spreads,
/// children
/// }
///
/// Does not make any guarnatees about the contents of the block - this is meant to be verified by the
/// element/component impls themselves.
///
/// The name of the block is expected to be parsed by the parent parser. It will accept items out of
/// order if possible and then bubble up diagnostics to the parent. This lets us give better errors
/// and autocomplete
#[derive(PartialEq, Eq, Clone, Debug)]
pub struct RsxBlock {
pub brace: token::Brace,
pub attributes: Vec<Attribute>,
pub spreads: Vec<Spread>,
pub children: Vec<BodyNode>,
pub diagnostics: Diagnostics,
}
impl Parse for RsxBlock {
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
let content: ParseBuffer;
let brace = syn::braced!(content in input);
RsxBlock::parse_inner(&content, brace)
}
}
impl RsxBlock {
pub fn parse_inner(content: &ParseBuffer, brace: token::Brace) -> syn::Result<Self> {
let mut items = vec![];
let mut diagnostics = Diagnostics::new();
// Lots of manual parsing but it's important to do it all here to give the best diagnostics possible
// We can do things like lookaheads, peeking, etc. to give better errors and autocomplete
// We allow parsing in any order but complain if its done out of order.
// Autofmt will fortunately fix this for us in most cases
//
// Weo do this by parsing the unambiguous cases first and then do some clever lookahead to parse the rest
while !content.is_empty() {
// Parse spread attributes
if content.peek(Token![..]) {
let dots = content.parse::<Token![..]>()?;
// in case someone tries to do ...spread which is not valid
if let Ok(extra) = content.parse::<Token![.]>() {
diagnostics.push(
extra
.span()
.error("Spread expressions only take two dots - not 3! (..spread)"),
);
}
let expr = content.parse::<Expr>()?;
let attr = Spread {
expr,
dots,
dyn_idx: DynIdx::default(),
comma: content.parse().ok(),
};
if !content.is_empty() && attr.comma.is_none() {
diagnostics.push(
attr.span()
.error("Attributes must be separated by commas")
.help("Did you forget a comma?"),
);
}
items.push(RsxItem::Spread(attr));
continue;
}
// Parse unambiguous attributes - these can't be confused with anything
if (content.peek(LitStr) || content.peek(Ident) || content.peek(Ident::peek_any))
&& content.peek2(Token![:])
&& !content.peek3(Token![:])
{
let attr = content.parse::<Attribute>()?;
if !content.is_empty() && attr.comma.is_none() {
diagnostics.push(
attr.span()
.error("Attributes must be separated by commas")
.help("Did you forget a comma?"),
);
}
items.push(RsxItem::Attribute(attr));
continue;
}
// Eagerly match on children, generally
if content.peek(LitStr)
| content.peek(Token![for])
| content.peek(Token![if])
| content.peek(Token![match])
| content.peek(token::Brace)
// web components
| (content.peek(Ident::peek_any) && content.peek2(Token![-]))
// elements
| (content.peek(Ident::peek_any) && content.peek2(token::Brace))
// todo: eager parse components?
{
items.push(RsxItem::Child(content.parse::<BodyNode>()?));
if !content.is_empty() && content.peek(Token![,]) {
let comma = content.parse::<Token![,]>()?;
diagnostics.push(
comma.span().warning(
"Elements and text nodes do not need to be separated by commas.",
),
);
}
continue;
}
// Parse shorthand attributes
// todo: this might cause complications with partial expansion... think more about the cases
// where we can imagine expansion and what better diagnostics we can providea
if content.peek(Ident)
&& !content.peek2(Brace)
&& !content.peek2(Token![:]) // regular attributes / components with generics
&& !content.peek2(Token![-]) // web components
&& !content.peek2(Token![<]) // generics on components
// generics on components
&& !content.peek2(Token![::])
{
let attribute = content.parse::<Attribute>()?;
if !content.is_empty() && attribute.comma.is_none() {
diagnostics.push(
attribute
.span()
.error("Attributes must be separated by commas")
.help("Did you forget a comma?"),
);
}
items.push(RsxItem::Attribute(attribute));
continue;
}
// Finally just attempt a bodynode parse
items.push(RsxItem::Child(content.parse::<BodyNode>()?))
}
// Validate the order of the items
RsxBlock::validate(&items, &mut diagnostics);
// todo: maybe make this a method such that the rsxblock is lossless
// Decompose into attributes, spreads, and children
let mut attributes = vec![];
let mut spreads = vec![];
let mut children = vec![];
for item in items {
match item {
RsxItem::Attribute(attr) => attributes.push(attr),
RsxItem::Spread(spread) => spreads.push(spread),
RsxItem::Child(child) => children.push(child),
}
}
Ok(Self {
attributes,
children,
spreads,
brace,
diagnostics,
})
}
/// Ensure the ordering of the items is correct
/// - Attributes must come before children
/// - Spreads must come before children
/// - Spreads must come after attributes
///
/// div {
/// key: "value",
/// ..props,
/// "Hello, world!"
/// }
fn validate(items: &[RsxItem], diagnostics: &mut Diagnostics) {
#[derive(Debug, PartialEq, Eq)]
enum ValidationState {
Attributes,
Spreads,
Children,
}
use ValidationState::*;
let mut state = ValidationState::Attributes;
for item in items.iter() {
match item {
RsxItem::Attribute(_) => {
if state == Children || state == Spreads {
diagnostics.push(
item.span()
.error("Attributes must come before children in an element"),
);
}
state = Attributes;
}
RsxItem::Spread(_) => {
if state == Children {
diagnostics.push(
item.span()
.error("Spreads must come before children in an element"),
);
}
state = Spreads;
}
RsxItem::Child(_) => {
state = Children;
}
}
}
}
}
pub enum RsxItem {
Attribute(Attribute),
Spread(Spread),
Child(BodyNode),
}
impl RsxItem {
pub fn span(&self) -> Span {
match self {
RsxItem::Attribute(attr) => attr.span(),
RsxItem::Spread(spread) => spread.dots.span(),
RsxItem::Child(child) => child.span(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use quote::quote;
#[test]
fn basic_cases() {
let input = quote! {
{ "Hello, world!" }
};
let block: RsxBlock = syn::parse2(input).unwrap();
assert_eq!(block.attributes.len(), 0);
assert_eq!(block.children.len(), 1);
let input = quote! {
{
key: "value",
onclick: move |_| {
"Hello, world!"
},
..spread,
"Hello, world!"
}
};
let block: RsxBlock = syn::parse2(input).unwrap();
dbg!(block);
let complex_element = quote! {
{
key: "value",
onclick2: move |_| {
"Hello, world!"
},
thing: if true { "value" },
otherthing: if true { "value" } else { "value" },
onclick: move |_| {
"Hello, world!"
},
..spread,
..spread1
..spread2,
"Hello, world!"
}
};
let _block: RsxBlock = syn::parse2(complex_element).unwrap();
let complex_component = quote! {
{
key: "value",
onclick2: move |_| {
"Hello, world!"
},
..spread,
"Hello, world!"
}
};
let _block: RsxBlock = syn::parse2(complex_component).unwrap();
}
/// Some tests of partial expansion to give better autocomplete
#[test]
fn partial_cases() {
let with_hander = quote! {
{
onclick: move |_| {
some.
}
}
};
let _block: RsxBlock = syn::parse2(with_hander).unwrap();
}
/// Ensure the hotreload scoring algorithm works as expected
#[test]
fn hr_score() {
let _block = quote! {
{
a: "value {cool}",
b: "{cool} value",
b: "{cool} {thing} value",
b: "{thing} value",
}
};
// loop { accumulate perfect matches }
// stop when all matches are equally valid
//
// Remove new attr one by one as we find its perfect match. If it doesn't have a perfect match, we
// score it instead.
quote! {
// start with
div {
div { class: "other {abc} {def} {hij}" } // 1, 1, 1
div { class: "thing {abc} {def}" } // 1, 1, 1
// div { class: "thing {abc}" } // 1, 0, 1
}
// end with
div {
h1 {
class: "thing {abc}" // 1, 1, MAX
}
h1 {
class: "thing {hij}" // 1, 1, MAX
}
// h2 {
// class: "thing {def}" // 1, 1, 0
// }
// h3 {
// class: "thing {def}" // 1, 1, 0
// }
}
// how about shuffling components, for, if, etc
Component {
class: "thing {abc}",
other: "other {abc} {def}",
}
Component {
class: "thing",
other: "other",
}
Component {
class: "thing {abc}",
other: "other",
}
Component {
class: "thing {abc}",
other: "other {abc} {def}",
}
};
}
#[test]
fn kitchen_sink_parse() {
let input = quote! {
// Elements
{
class: "hello",
id: "node-{node_id}",
..props,
// Text Nodes
"Hello, world!"
// Exprs
{rsx! { "hi again!" }}
for item in 0..10 {
// "Second"
div { "cool-{item}" }
}
Link {
to: "/home",
class: "link {is_ready}",
"Home"
}
if false {
div { "hi again!?" }
} else if true {
div { "its cool?" }
} else {
div { "not nice !" }
}
}
};
let _parsed: RsxBlock = syn::parse2(input).unwrap();
}
#[test]
fn simple_comp_syntax() {
let input = quote! {
{ class: "inline-block mr-4", icons::icon_14 {} }
};
let _parsed: RsxBlock = syn::parse2(input).unwrap();
}
#[test]
fn with_sutter() {
let input = quote! {
{
div {}
d
div {}
}
};
let _parsed: RsxBlock = syn::parse2(input).unwrap();
}
#[test]
fn looks_like_prop_but_is_expr() {
let input = quote! {
{
a: "asd".to_string(),
// b can be omitted, and it will be filled with its default value
c: "asd".to_string(),
d: Some("asd".to_string()),
e: Some("asd".to_string()),
}
};
let _parsed: RsxBlock = syn::parse2(input).unwrap();
}
#[test]
fn no_comma_diagnostics() {
let input = quote! {
{ a, ..ComponentProps { a: 1, b: 2, c: 3, children: VNode::empty(), onclick: Default::default() } }
};
let parsed: RsxBlock = syn::parse2(input).unwrap();
assert!(parsed.diagnostics.is_empty());
}
#[test]
fn proper_attributes() {
let input = quote! {
{
onclick: action,
href,
onmounted: onmounted,
prevent_default,
class,
rel,
target: tag_target,
aria_current,
..attributes,
{children}
}
};
let parsed: RsxBlock = syn::parse2(input).unwrap();
dbg!(parsed.attributes);
}
#[test]
fn reserved_attributes() {
let input = quote! {
{
label {
for: "blah",
}
}
};
let parsed: RsxBlock = syn::parse2(input).unwrap();
dbg!(parsed.attributes);
}
#[test]
fn diagnostics_check() {
let input = quote::quote! {
{
class: "foo bar"
"Hello world"
}
};
let _parsed: RsxBlock = syn::parse2(input).unwrap();
}
#[test]
fn incomplete_components() {
let input = quote::quote! {
{
some::cool::Component
}
};
let _parsed: RsxBlock = syn::parse2(input).unwrap();
}
}