From f690b32719d05cc4a356765808453841c7ff58aa Mon Sep 17 00:00:00 2001 From: Justin Garcia Date: Sat, 3 Oct 2026 05:26:39 +0000 Subject: [PATCH 1/5] Implement opt-in JSX with native React code generation Track the Iris source dialect and elaborate JSX through typed Iris.React helpers. Preserve ordinary PureScript syntax, contextual record checking, component identity, and first-class element helpers. Emit React automatic-runtime imports and executable JavaScript without a generated Iris.React runtime module. Cover source diagnostics, inferred and checked props, fragments, code generation, and dialect invalidation through compiler fixtures. Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-01a0ffe1-6de2-739b-9076-c4092e90ecae --- compiler-backend/functional/src/convert.rs | 6 + .../functional/src/convert/application.rs | 3 + .../functional/src/convert/expression.rs | 3 + .../functional/src/convert/react.rs | 222 +++++++++++++++++ .../functional/src/convert/stylex.rs | 2 +- compiler-backend/functional/src/error.rs | 2 + compiler-backend/functional/src/lib.rs | 1 + compiler-backend/functional/src/optimize.rs | 2 + compiler-backend/functional/src/pretty.rs | 30 +++ compiler-backend/functional/src/react.rs | 51 ++++ compiler-backend/functional/src/tree.rs | 2 + .../convert/generator/functional/render.rs | 36 ++- .../generator/functional/render/react.rs | 131 ++++++++++ .../generator/functional/render/tail_call.rs | 3 + compiler-core/building-types/src/lib.rs | 1 + compiler-core/building/src/engine.rs | 98 +++++--- .../building/src/lifecycle/source.rs | 1 + compiler-core/building/src/prim.rs | 1 + compiler-core/files/src/lib.rs | 17 ++ compiler-core/prim-constants/src/lib.rs | 1 + .../prim-constants/src/prim/Iris.React.purs | 46 ++++ .../checking/src/source/terms.rs | 7 + .../checking/src/source/terms/collections.rs | 40 ++- .../checking/src/source/terms/jsx.rs | 165 +++++++++++++ compiler-frontend/lexing/src/layout.rs | 68 ++++- compiler-frontend/lexing/src/lexed.rs | 8 + compiler-frontend/lexing/src/lexer.rs | 233 +++++++++++++++++- compiler-frontend/lexing/src/lib.rs | 6 +- compiler-frontend/lexing/tests/lexer.rs | 14 ++ compiler-frontend/lowering/src/algorithm.rs | 1 + .../lowering/src/algorithm/jsx.rs | 135 ++++++++++ .../lowering/src/algorithm/recursive.rs | 8 + compiler-frontend/lowering/src/error.rs | 4 + compiler-frontend/lowering/src/tree.rs | 18 +- .../parsing/src/parser/expressions.rs | 124 +++++++++- compiler-frontend/syntax/src/cst.rs | 52 ++++ compiler-frontend/syntax/src/lib.rs | 20 ++ .../iris-diagnostics/src/convert.rs | 23 +- .../Main.checking.snap | 22 ++ .../Main.diagnostics.snap | 121 +++++++++ .../Main.functional.snap | 33 +++ .../1791000240_jsx_diagnostics/Main.iris | 35 +++ .../Main.semantic.snap | 48 ++++ .../output/Main/index.js | 54 ++++ .../Main.checking.snap | 48 ++++ .../Main.diagnostics.snap | 5 + .../Main.functional.snap | 104 ++++++++ .../1791000240_jsx_expressions/Main.iris | 112 +++++++++ .../1791000240_jsx_expressions/Main.js | 2 + .../Main.semantic.snap | 129 ++++++++++ .../1791000240_jsx_expressions/Widgets.iris | 6 + .../output/Main/foreign.js | 2 + .../output/Main/index.js | 183 ++++++++++++++ .../output/Widgets/index.js | 7 + .../Main.checking.snap | 14 ++ .../Main.diagnostics.snap | 136 ++++++++++ .../Main.functional.snap | 17 ++ .../1791000240_jsx_malformed_syntax/Main.iris | 15 ++ .../Main.semantic.snap | 24 ++ .../Plain.purs | 3 + .../output/Main/index.js | 36 +++ .../Main.checking.snap | 9 + .../Main.diagnostics.snap | 16 ++ .../Main.functional.snap | 5 + .../Main.iris | 5 + .../Main.semantic.snap | 6 + .../lifecycle/source_dialect/Main.iris | 3 + .../Main.snap | 5 + .../Main.snap | 3 + .../1755261960_export_suggestion/Main.snap | 41 +-- .../1757527080_references_baseline/Main.snap | 7 +- tests-integration/src/lib.rs | 10 +- tests-integration/tests/compiler.rs | 2 +- tests-integration/tests/lifecycle.rs | 28 +++ tests-integration/tests/lsp.rs | 2 +- 75 files changed, 2805 insertions(+), 78 deletions(-) create mode 100644 compiler-backend/functional/src/convert/react.rs create mode 100644 compiler-backend/functional/src/react.rs create mode 100644 compiler-backend/javascript/src/convert/generator/functional/render/react.rs create mode 100644 compiler-core/prim-constants/src/prim/Iris.React.purs create mode 100644 compiler-frontend/checking/src/source/terms/jsx.rs create mode 100644 compiler-frontend/lowering/src/algorithm/jsx.rs create mode 100644 tests-integration/fixtures/compiler/1791000240_jsx_diagnostics/Main.checking.snap create mode 100644 tests-integration/fixtures/compiler/1791000240_jsx_diagnostics/Main.diagnostics.snap create mode 100644 tests-integration/fixtures/compiler/1791000240_jsx_diagnostics/Main.functional.snap create mode 100644 tests-integration/fixtures/compiler/1791000240_jsx_diagnostics/Main.iris create mode 100644 tests-integration/fixtures/compiler/1791000240_jsx_diagnostics/Main.semantic.snap create mode 100644 tests-integration/fixtures/compiler/1791000240_jsx_diagnostics/output/Main/index.js create mode 100644 tests-integration/fixtures/compiler/1791000240_jsx_expressions/Main.checking.snap create mode 100644 tests-integration/fixtures/compiler/1791000240_jsx_expressions/Main.diagnostics.snap create mode 100644 tests-integration/fixtures/compiler/1791000240_jsx_expressions/Main.functional.snap create mode 100644 tests-integration/fixtures/compiler/1791000240_jsx_expressions/Main.iris create mode 100644 tests-integration/fixtures/compiler/1791000240_jsx_expressions/Main.js create mode 100644 tests-integration/fixtures/compiler/1791000240_jsx_expressions/Main.semantic.snap create mode 100644 tests-integration/fixtures/compiler/1791000240_jsx_expressions/Widgets.iris create mode 100644 tests-integration/fixtures/compiler/1791000240_jsx_expressions/output/Main/foreign.js create mode 100644 tests-integration/fixtures/compiler/1791000240_jsx_expressions/output/Main/index.js create mode 100644 tests-integration/fixtures/compiler/1791000240_jsx_expressions/output/Widgets/index.js create mode 100644 tests-integration/fixtures/compiler/1791000240_jsx_malformed_syntax/Main.checking.snap create mode 100644 tests-integration/fixtures/compiler/1791000240_jsx_malformed_syntax/Main.diagnostics.snap create mode 100644 tests-integration/fixtures/compiler/1791000240_jsx_malformed_syntax/Main.functional.snap create mode 100644 tests-integration/fixtures/compiler/1791000240_jsx_malformed_syntax/Main.iris create mode 100644 tests-integration/fixtures/compiler/1791000240_jsx_malformed_syntax/Main.semantic.snap create mode 100644 tests-integration/fixtures/compiler/1791000240_jsx_malformed_syntax/Plain.purs create mode 100644 tests-integration/fixtures/compiler/1791000240_jsx_malformed_syntax/output/Main/index.js create mode 100644 tests-integration/fixtures/compiler/1791003960_jsx_component_identity/Main.checking.snap create mode 100644 tests-integration/fixtures/compiler/1791003960_jsx_component_identity/Main.diagnostics.snap create mode 100644 tests-integration/fixtures/compiler/1791003960_jsx_component_identity/Main.functional.snap create mode 100644 tests-integration/fixtures/compiler/1791003960_jsx_component_identity/Main.iris create mode 100644 tests-integration/fixtures/compiler/1791003960_jsx_component_identity/Main.semantic.snap create mode 100644 tests-integration/fixtures/lifecycle/source_dialect/Main.iris diff --git a/compiler-backend/functional/src/convert.rs b/compiler-backend/functional/src/convert.rs index ae811e844..ef718a145 100644 --- a/compiler-backend/functional/src/convert.rs +++ b/compiler-backend/functional/src/convert.rs @@ -4,6 +4,7 @@ mod application; mod declaration; mod evidence; mod expression; +mod react; mod stylex; use std::cell::{OnceCell, RefCell}; @@ -30,6 +31,7 @@ use crate::tree::{ use self::declaration::{derive_declaration, instance_declaration, term_declaration}; use self::evidence::{EvidenceHoisting, EvidenceKeys, EvidenceScope}; +use self::react::ReactModule; use self::stylex::StyleXModules; type ConversionResult = Result; @@ -87,6 +89,7 @@ struct Context<'c, Q> { dependencies: FxHashMap, indexed_dependencies: RefCell>>, stylex_modules: OnceCell, + react_module: OnceCell, /// Whether a term of a virtual StyleX module was referenced, the only source of StyleX /// intrinsics and expressions. references_stylex_module: bool, @@ -153,6 +156,7 @@ where dependencies: FxHashMap::default(), indexed_dependencies: RefCell::default(), stylex_modules: OnceCell::new(), + react_module: OnceCell::new(), references_stylex_module: false, thunk_modules: OnceCell::new(), canonical_thunk_instances: RefCell::default(), @@ -192,6 +196,8 @@ fn convert(mut context: Context<'_, impl checking::ExternalQueries>) -> Conversi } validate_runtime_exports(&context, &declarations, &surface)?; context.validate_stylex_uses(&declarations)?; + context.validate_react_uses(&declarations)?; + context.materialize_react_functions(&declarations)?; context.hoist_closed_evidence(&mut declarations)?; let recursive_globals = declarations diff --git a/compiler-backend/functional/src/convert/application.rs b/compiler-backend/functional/src/convert/application.rs index 0d64a468a..ac3c8ae39 100644 --- a/compiler-backend/functional/src/convert/application.rs +++ b/compiler-backend/functional/src/convert/application.rs @@ -122,6 +122,9 @@ where { return Ok(expression); } + if let Some(expression) = self.react_intrinsic(known_function, &known_arguments)? { + return Ok(expression); + } if let Some(effect) = self.known_effect_application(known_function, &known_arguments)? { return Ok(self.expression(ExpressionKind::Effect { effect })); } diff --git a/compiler-backend/functional/src/convert/expression.rs b/compiler-backend/functional/src/convert/expression.rs index 5adf38de3..f24ca1b55 100644 --- a/compiler-backend/functional/src/convert/expression.rs +++ b/compiler-backend/functional/src/convert/expression.rs @@ -315,6 +315,9 @@ fn variable( if let Some(expression) = context.stylex_value_intrinsic(file_id, term_id)? { return Ok(expression); } + if let Some(expression) = context.react_value_intrinsic(file_id, term_id)? { + return Ok(expression); + } let global = context.term_global(file_id, term_id)?; Ok(context.expression(ExpressionKind::Global { global })) } diff --git a/compiler-backend/functional/src/convert/react.rs b/compiler-backend/functional/src/convert/react.rs new file mode 100644 index 000000000..f3f4ae9b4 --- /dev/null +++ b/compiler-backend/functional/src/convert/react.rs @@ -0,0 +1,222 @@ +//! Recognition and lowering for the virtual React module. + +use building_types::QueryResult; +use files::FileId; +use indexing::TermItemId; +use rustc_hash::FxHashSet; + +use crate::error::UnsupportedState; +use crate::optimize::for_each_expression_child; +use crate::react::{ReactElement, ReactExpression}; +use crate::tree::{Declaration, DeclarationKind, ExpressionId, ExpressionKind, GlobalId}; + +use super::{Context, ConversionResult}; + +#[derive(Clone, Copy)] +pub(super) struct ReactModule(Option); + +#[derive(Clone, Copy)] +enum ReactIntrinsic { + Component, + Element, + ElementKeyed, + Intrinsic, + IntrinsicKeyed, + Text, + Array, + Fragment, + Empty, +} + +impl<'c, Q: checking::ExternalQueries> Context<'c, Q> { + pub(super) fn react_intrinsic( + &mut self, + function: ExpressionId, + arguments: &[ExpressionId], + ) -> ConversionResult> { + let ExpressionKind::Global { global } = self.storage[function].kind.clone() else { + return Ok(None); + }; + let GlobalId::Term(file_id, term_id) = global.id else { return Ok(None) }; + let Some(intrinsic) = self.react_intrinsic_identity(file_id, term_id)? else { + return Ok(None); + }; + let expression = match (intrinsic, arguments) { + (ReactIntrinsic::Component, [render]) => ReactExpression::Component { render: *render }, + (ReactIntrinsic::Element, [_, component, props]) => { + self.react_element(ReactElement::Component(*component), *props, None) + } + (ReactIntrinsic::ElementKeyed, [_, key, component, props]) => { + self.react_element(ReactElement::Component(*component), *props, Some(*key)) + } + (ReactIntrinsic::Intrinsic, [_, tag, props]) => { + self.react_element(ReactElement::Intrinsic(*tag), *props, None) + } + (ReactIntrinsic::IntrinsicKeyed, [_, key, tag, props]) => { + self.react_element(ReactElement::Intrinsic(*tag), *props, Some(*key)) + } + (ReactIntrinsic::Text | ReactIntrinsic::Array, [value]) => return Ok(Some(*value)), + (ReactIntrinsic::Fragment, [children]) => { + ReactExpression::Fragment { children: *children } + } + (ReactIntrinsic::Empty, []) => ReactExpression::Empty, + _ => return Ok(None), + }; + Ok(Some(self.expression(ExpressionKind::React(expression)))) + } + + fn react_element( + &self, + component: ReactElement, + props: ExpressionId, + key: Option, + ) -> ReactExpression { + let static_multiple_children = match &self.storage[props].kind { + ExpressionKind::Record { fields } => fields.iter().any(|field| { + if field.field.name != "children" { + return false; + } + match &self.storage[field.expression].kind { + ExpressionKind::Array { elements } => elements.len() > 1, + _ => false, + } + }), + _ => false, + }; + ReactExpression::Element { component, props, key, static_multiple_children } + } + + pub(super) fn react_value_intrinsic( + &mut self, + file_id: FileId, + term_id: TermItemId, + ) -> ConversionResult> { + let Some(intrinsic) = self.react_intrinsic_identity(file_id, term_id)? else { + return Ok(None); + }; + if matches!(intrinsic, ReactIntrinsic::Empty) { + return Ok(Some(self.expression(ExpressionKind::React(ReactExpression::Empty)))); + } + Ok(None) + } + + fn react_intrinsic_identity( + &self, + file_id: FileId, + term_id: TermItemId, + ) -> QueryResult> { + if self.react_module().0 != Some(file_id) { + return Ok(None); + } + let indexed = self.indexed_module(file_id)?; + let intrinsic = match indexed.items[term_id].name.as_deref() { + Some("component") => ReactIntrinsic::Component, + Some("element") => ReactIntrinsic::Element, + Some("elementKeyed") => ReactIntrinsic::ElementKeyed, + Some("intrinsic") => ReactIntrinsic::Intrinsic, + Some("intrinsicKeyed") => ReactIntrinsic::IntrinsicKeyed, + Some("text") => ReactIntrinsic::Text, + Some("array") => ReactIntrinsic::Array, + Some("fragment") => ReactIntrinsic::Fragment, + Some("empty") => ReactIntrinsic::Empty, + _ => return Ok(None), + }; + Ok(Some(intrinsic)) + } + + pub(super) fn validate_react_uses(&self, declarations: &[Declaration]) -> ConversionResult<()> { + for declaration in declarations { + let DeclarationKind::Value(root) = declaration.kind else { continue }; + self.validate_react_expression(root, root, declaration)?; + } + Ok(()) + } + + pub(super) fn materialize_react_functions( + &mut self, + declarations: &[Declaration], + ) -> ConversionResult<()> { + let roots = declarations.iter().filter_map(|declaration| match declaration.kind { + DeclarationKind::Value(root) => Some((root, declaration.global.id)), + _ => None, + }); + let mut pending = roots.collect::>(); + let mut visited = FxHashSet::default(); + while let Some((expression, declaration)) = pending.pop() { + if !visited.insert(expression) { + continue; + } + if let ExpressionKind::Global { global } = &self.storage[expression].kind + && let GlobalId::Term(file_id, term_id) = global.id + && let Some(intrinsic) = self.react_intrinsic_identity(file_id, term_id)? + { + let names: &[&str] = match intrinsic { + ReactIntrinsic::Component => { + return Err(self + .unsupported(UnsupportedState::InvalidReactComponent { declaration })); + } + ReactIntrinsic::Element | ReactIntrinsic::Intrinsic => { + &["dictionary", "target", "props"] + } + ReactIntrinsic::ElementKeyed | ReactIntrinsic::IntrinsicKeyed => { + &["dictionary", "key", "target", "props"] + } + ReactIntrinsic::Text | ReactIntrinsic::Array => &["value"], + ReactIntrinsic::Fragment => &["children"], + ReactIntrinsic::Empty => &[], + }; + let mut parameters = Vec::new(); + let mut arguments = Vec::new(); + for name in names { + let parameter = self.fresh_parameter((*name).into())?; + arguments.push( + self.expression(ExpressionKind::Local { parameter: parameter.clone() }), + ); + parameters.push(parameter); + } + let body = self + .react_intrinsic(expression, &arguments)? + .expect("React intrinsic arity mismatch"); + let function = self.parameter_abstraction(parameters, body); + let kind = self.storage[function].kind.clone(); + self.storage.replace_expression_kind(expression, kind); + } else { + for_each_expression_child(&self.storage[expression].kind, |child| { + pending.push((child, declaration)) + }); + } + } + Ok(()) + } + + fn validate_react_expression( + &self, + expression: ExpressionId, + root: ExpressionId, + declaration: &Declaration, + ) -> ConversionResult<()> { + if let ExpressionKind::React(ReactExpression::Component { .. }) = + &self.storage[expression].kind + && (expression != root || declaration.recursive_group.is_some()) + { + return Err(self.unsupported(UnsupportedState::InvalidReactComponent { + declaration: declaration.global.id, + })); + } + let mut result = Ok(()); + for_each_expression_child(&self.storage[expression].kind, |child| { + if result.is_ok() { + result = self.validate_react_expression(child, root, declaration); + } + }); + result + } + + fn react_module(&self) -> ReactModule { + *self.react_module.get_or_init(|| ReactModule(self.queries.module_file("Iris.React"))) + } + + pub(super) fn react_module_is_virtual(&self, file_id: FileId) -> bool { + self.react_module().0 == Some(file_id) + } +} diff --git a/compiler-backend/functional/src/convert/stylex.rs b/compiler-backend/functional/src/convert/stylex.rs index 6fa0222e2..d118530f5 100644 --- a/compiler-backend/functional/src/convert/stylex.rs +++ b/compiler-backend/functional/src/convert/stylex.rs @@ -447,7 +447,7 @@ where } pub(super) fn module_is_virtual(&self, file_id: FileId) -> bool { - self.stylex_modules().module(file_id).is_some() + self.stylex_modules().module(file_id).is_some() || self.react_module_is_virtual(file_id) } pub(super) fn validate_runtime_reference( diff --git a/compiler-backend/functional/src/error.rs b/compiler-backend/functional/src/error.rs index aeac01efb..b21f1e513 100644 --- a/compiler-backend/functional/src/error.rs +++ b/compiler-backend/functional/src/error.rs @@ -50,6 +50,8 @@ pub enum UnsupportedState { InvalidStyleXUse { function: String, declaration: GlobalId }, #[error("Iris.StyleX.{function} {requirement}")] InvalidStyleXContext { function: String, requirement: String, declaration: GlobalId }, + #[error("Iris.React.component must be a direct non-recursive top-level initializer")] + InvalidReactComponent { declaration: GlobalId }, #[error("virtual module declaration {module_name}.{item_name} cannot be used at runtime")] VirtualModuleRuntimeReference { module_name: String, item_name: String }, } diff --git a/compiler-backend/functional/src/lib.rs b/compiler-backend/functional/src/lib.rs index a2a161224..169756bcb 100644 --- a/compiler-backend/functional/src/lib.rs +++ b/compiler-backend/functional/src/lib.rs @@ -5,6 +5,7 @@ pub mod error; pub mod initializers; pub mod optimize; pub mod pretty; +pub mod react; pub mod stylex; pub mod tree; diff --git a/compiler-backend/functional/src/optimize.rs b/compiler-backend/functional/src/optimize.rs index 1f429167b..e1ec2366d 100644 --- a/compiler-backend/functional/src/optimize.rs +++ b/compiler-backend/functional/src/optimize.rs @@ -275,6 +275,7 @@ fn is_simple_expression( | ExpressionKind::Application { .. } | ExpressionKind::UncurriedApplication { .. } | ExpressionKind::StyleX(_) + | ExpressionKind::React(_) | ExpressionKind::IfThenElse { .. } | ExpressionKind::Case { .. } | ExpressionKind::Guarded { .. } @@ -337,6 +338,7 @@ pub fn try_for_each_expression_child( } } ExpressionKind::StyleX(stylex) => stylex.try_for_each_child(&mut visit)?, + ExpressionKind::React(react) => react.try_for_each_child(&mut visit)?, ExpressionKind::IfThenElse { condition, then, else_ } => { visit(*condition)?; visit(*then)?; diff --git a/compiler-backend/functional/src/pretty.rs b/compiler-backend/functional/src/pretty.rs index 2eb4a5ecb..b7d11942a 100644 --- a/compiler-backend/functional/src/pretty.rs +++ b/compiler-backend/functional/src/pretty.rs @@ -2,6 +2,7 @@ use pretty::{Arena, DocAllocator, DocBuilder}; +use crate::react::{ReactElement, ReactExpression}; use crate::stylex::{StyleXCallTarget, StyleXConditionalCase, StyleXExpression}; use crate::tree::{ BinaryOperator, Declaration, DeclarationKind, EffectExpression, ExpressionId, ExpressionKind, @@ -100,6 +101,7 @@ impl<'a> Printer<'a, '_> { | ExpressionKind::Application { .. } | ExpressionKind::UncurriedApplication { .. } | ExpressionKind::StyleX(_) + | ExpressionKind::React(_) | ExpressionKind::Effect { .. } | ExpressionKind::SynthesizedEvidence { .. } => ExpressionPrecedence::Application, ExpressionKind::RecordUpdate { .. } => ExpressionPrecedence::RecordUpdate, @@ -230,6 +232,7 @@ impl<'a> Printer<'a, '_> { self.arena.text("uncurried.call ").append(function).append(arguments) } ExpressionKind::StyleX(stylex) => self.stylex_expression(stylex), + ExpressionKind::React(react) => self.react_expression(react), ExpressionKind::IfThenElse { condition, then, else_ } => { let condition = self.expression(*condition); let then = self.expression(*then); @@ -335,6 +338,33 @@ impl<'a> Printer<'a, '_> { } } + fn react_expression(&self, react: &ReactExpression) -> Doc<'a> { + match react { + ReactExpression::Component { render } => { + self.arena.text("react.component ").append(self.expression(*render)) + } + ReactExpression::Element { component, props, key, .. } => { + let target = match component { + ReactElement::Component(value) => self.expression(*value), + ReactElement::Intrinsic(value) => { + self.arena.text("intrinsic ").append(self.expression(*value)) + } + }; + let key = key.map(|key| self.arena.text(" key ").append(self.expression(key))); + self.arena + .text("react.element ") + .append(target) + .append(key) + .append(" ") + .append(self.expression(*props)) + } + ReactExpression::Fragment { children } => { + self.arena.text("react.fragment ").append(self.expression(*children)) + } + ReactExpression::Empty => self.arena.text("react.empty"), + } + } + fn stylex_conditional_case(&self, case: &StyleXConditionalCase) -> Doc<'a> { let selector = self.expression_at(case.selector, ExpressionPrecedence::Atom); let marker = diff --git a/compiler-backend/functional/src/react.rs b/compiler-backend/functional/src/react.rs new file mode 100644 index 000000000..e8108c2b7 --- /dev/null +++ b/compiler-backend/functional/src/react.rs @@ -0,0 +1,51 @@ +//! Functional expressions for the native React backend. + +use crate::tree::ExpressionId; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ReactExpression { + Component { + render: ExpressionId, + }, + Element { + component: ReactElement, + props: ExpressionId, + key: Option, + static_multiple_children: bool, + }, + Fragment { + children: ExpressionId, + }, + Empty, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReactElement { + Component(ExpressionId), + Intrinsic(ExpressionId), +} + +impl ReactExpression { + pub fn try_for_each_child( + &self, + mut visit: impl FnMut(ExpressionId) -> Result<(), E>, + ) -> Result<(), E> { + match self { + ReactExpression::Component { render } => visit(*render)?, + ReactExpression::Element { component, props, key, .. } => { + match component { + ReactElement::Component(component) | ReactElement::Intrinsic(component) => { + visit(*component)?; + } + } + if let Some(key) = key { + visit(*key)?; + } + visit(*props)?; + } + ReactExpression::Fragment { children } => visit(*children)?, + ReactExpression::Empty => {} + } + Ok(()) + } +} diff --git a/compiler-backend/functional/src/tree.rs b/compiler-backend/functional/src/tree.rs index 2ec0ca8a3..69f7e5d19 100644 --- a/compiler-backend/functional/src/tree.rs +++ b/compiler-backend/functional/src/tree.rs @@ -9,6 +9,7 @@ use la_arena::{Arena, Idx}; use lowering::TypeId as SourceTypeId; use smol_str::SmolStr; +use crate::react::ReactExpression; use crate::stylex::StyleXExpression; pub type ExpressionId = Idx; @@ -190,6 +191,7 @@ pub enum ExpressionKind { Application { function: ExpressionId, arguments: Arc<[ExpressionId]>, synthetic: bool }, UncurriedApplication { function: ExpressionId, arguments: Arc<[ExpressionId]>, synthetic: bool }, StyleX(StyleXExpression), + React(ReactExpression), IfThenElse { condition: ExpressionId, then: ExpressionId, else_: ExpressionId }, Case { scrutinees: Arc<[ExpressionId]>, alternatives: Arc<[CaseAlternative]> }, Guarded { alternatives: Arc<[GuardedAlternative]> }, diff --git a/compiler-backend/javascript/src/convert/generator/functional/render.rs b/compiler-backend/javascript/src/convert/generator/functional/render.rs index d459e6195..0e2ca0ff3 100644 --- a/compiler-backend/javascript/src/convert/generator/functional/render.rs +++ b/compiler-backend/javascript/src/convert/generator/functional/render.rs @@ -1,6 +1,7 @@ //! Rendering functional trees as JavaScript modules. mod inline; +mod react; mod structure; mod stylex; mod syntax; @@ -55,6 +56,9 @@ pub(crate) struct Generator<'m> { external_named_imports: FxHashMap, external_references: Vec, stylex_namespace: Option, + react_jsx: Option, + react_jsxs: Option, + react_fragment: Option, foreign_import: Option, runtime_namespace: Option, lazy_global_names: FxHashMap, @@ -309,6 +313,10 @@ impl<'m> Generator<'m> { } let stylex_namespace = has_stylex.then(|| allocator.allocate("$stylex")); + let (jsx, jsxs, fragment) = react::required_imports(module); + let react_jsx = jsx.then(|| allocator.allocate("$jsx")); + let react_jsxs = jsxs.then(|| allocator.allocate("$jsxs")); + let react_fragment = fragment.then(|| allocator.allocate("$Fragment")); let has_foreign = module .declarations @@ -367,6 +375,9 @@ impl<'m> Generator<'m> { external_named_imports, external_references, stylex_namespace, + react_jsx, + react_jsxs, + react_fragment, foreign_import, runtime_namespace, lazy_global_names, @@ -471,6 +482,18 @@ fn render_imports(renderer: &mut ModuleRenderer<'_, '_, '_>) { if let Some(namespace) = &generator.stylex_namespace { writer.import_namespace(namespace, "@stylexjs/stylex"); } + let react_imports = [ + ("jsx", &generator.react_jsx), + ("jsxs", &generator.react_jsxs), + ("Fragment", &generator.react_fragment), + ]; + let react_imports = react_imports + .iter() + .filter_map(|(import, alias)| alias.as_deref().map(|alias| (*import, alias))) + .collect_vec(); + if !react_imports.is_empty() { + writer.import_named(&react_imports, "react/jsx-runtime"); + } if let Some(foreign_import) = &generator.foreign_import { let path = format!("./foreign.{}", foreign_import.kind.extension()); writer.import_namespace(&foreign_import.namespace, &path); @@ -481,6 +504,8 @@ fn render_imports(renderer: &mut ModuleRenderer<'_, '_, '_>) { } if !generator.external_references.is_empty() || generator.stylex_namespace.is_some() + || generator.react_jsx.is_some() + || generator.react_jsxs.is_some() || !generator.external_named_imports.is_empty() || generator.foreign_import.is_some() || generator.runtime_namespace.is_some() @@ -1649,6 +1674,9 @@ impl Generator<'_> { ExpressionKind::StyleX(stylex) => { self.render_stylex_expression(tree, writer, stylex, context) } + ExpressionKind::React(react) => { + self.render_react_expression(tree, writer, react, context) + } ExpressionKind::Effect { effect } => { let mut renderer = self.renderer(tree, writer, context); let value = effect_expression(&mut renderer, effect)?; @@ -1780,6 +1808,7 @@ impl Generator<'_> { } }) .is_err(), + ExpressionKind::React(_) => true, ExpressionKind::Error | ExpressionKind::IfThenElse { .. } | ExpressionKind::Case { .. } @@ -1819,6 +1848,7 @@ impl Generator<'_> { | ExpressionKind::Application { .. } | ExpressionKind::UncurriedApplication { .. } | ExpressionKind::StyleX(_) + | ExpressionKind::React(_) | ExpressionKind::IfThenElse { .. } | ExpressionKind::Case { .. } | ExpressionKind::Guarded { .. } @@ -1969,6 +1999,7 @@ impl Generator<'_> { }; expression } + ExpressionKind::React(_) => return Ok(None), ExpressionKind::SynthesizedEvidence { evidence } => { synthesized_evidence_expression(tree, evidence) } @@ -2034,6 +2065,7 @@ impl Generator<'_> { if self.expression_can_inline(child) { Ok(()) } else { Err(()) } }) .is_ok(), + ExpressionKind::React(_) => false, ExpressionKind::Error | ExpressionKind::RecordUpdate { .. } | ExpressionKind::IfThenElse { .. } @@ -3345,7 +3377,7 @@ fn collect_expression_references( collect_expression_references(module, *argument, seen, globals); } } - kind @ ExpressionKind::StyleX(_) => { + kind @ (ExpressionKind::StyleX(_) | ExpressionKind::React(_)) => { for_each_expression_child(kind, |child| { collect_expression_references(module, child, seen, globals); }); @@ -3563,7 +3595,7 @@ fn collect_expression_children( collect_expression_globals(module, *argument, false, globals); } } - kind @ ExpressionKind::StyleX(_) => { + kind @ (ExpressionKind::StyleX(_) | ExpressionKind::React(_)) => { for_each_expression_child(kind, |child| { collect_expression_globals(module, child, false, globals); }); diff --git a/compiler-backend/javascript/src/convert/generator/functional/render/react.rs b/compiler-backend/javascript/src/convert/generator/functional/render/react.rs new file mode 100644 index 000000000..fee1c5804 --- /dev/null +++ b/compiler-backend/javascript/src/convert/generator/functional/render/react.rs @@ -0,0 +1,131 @@ +//! JavaScript rendering for native React expressions. + +use functional::optimize::for_each_expression_child; +use functional::react::{ReactElement, ReactExpression}; +use functional::tree::{DeclarationKind, ExpressionId, ExpressionKind, Module}; +use rustc_hash::FxHashSet; + +use crate::error::ModuleResult; +use crate::tree::{ObjectProperty, Tree}; +use crate::writer::Writer; + +use super::{FunctionContext, Generator, RenderedExpression}; + +pub(super) fn required_imports(module: &Module) -> (bool, bool, bool) { + let mut jsx = false; + let mut jsxs = false; + let mut fragment = false; + let roots = module.declarations.iter().filter_map(|declaration| match declaration.kind { + DeclarationKind::Value(root) => Some(root), + _ => None, + }); + let mut pending = roots.collect::>(); + let mut visited = FxHashSet::default(); + while let Some(expression) = pending.pop() { + if !visited.insert(expression) { + continue; + } + let kind = &module.storage[expression].kind; + match kind { + ExpressionKind::React(ReactExpression::Element { + static_multiple_children, .. + }) => { + if *static_multiple_children { + jsxs = true + } else { + jsx = true + } + } + ExpressionKind::React(ReactExpression::Fragment { children }) => { + if fragment_has_static_children(module, *children) { + jsxs = true; + } else { + jsx = true; + } + fragment = true; + } + _ => {} + } + for_each_expression_child(kind, |child| pending.push(child)); + } + (jsx, jsxs, fragment) +} + +fn fragment_has_static_children(module: &Module, children: ExpressionId) -> bool { + matches!(module.storage[children].kind, ExpressionKind::Array { .. }) +} + +impl Generator<'_> { + pub(super) fn render_react_expression<'a, 't>( + &self, + tree: &'a mut Tree<'t>, + writer: &'a mut Writer<'t>, + react: &ReactExpression, + context: &'a mut FunctionContext, + ) -> ModuleResult { + let value = match react { + ReactExpression::Component { render } => { + return self.rendered_expression(tree, writer, *render, context); + } + ReactExpression::Element { component, props, key, static_multiple_children } => { + // A keyed source call evaluates its key before its element target and props. + let key = if let Some(key) = key { + let mut key = self.rendered_expression(tree, writer, *key, context)?; + self.materialize_rendered_expression( + tree, + writer, + &mut key, + "$reactKey", + context, + ); + Some(key.value) + } else { + None + }; + let mut target = match component { + ReactElement::Component(target) | ReactElement::Intrinsic(target) => { + self.rendered_expression(tree, writer, *target, context)? + } + }; + self.materialize_rendered_expression( + tree, + writer, + &mut target, + "$reactType", + context, + ); + let props = self.rendered_expression(tree, writer, *props, context)?.value; + let function = if *static_multiple_children { + self.react_jsxs.as_ref().expect("React expression has no jsxs import") + } else { + self.react_jsx.as_ref().expect("React expression has no jsx import") + }; + let mut arguments = vec![target.value, props]; + if let Some(key) = key { + arguments.push(key); + } + let function = tree.identifier(function); + tree.call(function, arguments) + } + ReactExpression::Fragment { children } => { + let function = if fragment_has_static_children(self.module, *children) { + self.react_jsxs.as_ref().expect("React fragment has no jsxs import") + } else { + self.react_jsx.as_ref().expect("React fragment has no jsx import") + }; + let children = self.rendered_expression(tree, writer, *children, context)?.value; + let props = tree.object(vec![ObjectProperty::Field { + name: "children".into(), + value: children, + }]); + let fragment = + self.react_fragment.as_ref().expect("React expression has no Fragment import"); + let function = tree.identifier(function); + let fragment = tree.identifier(fragment); + tree.call(function, vec![fragment, props]) + } + ReactExpression::Empty => tree.null(), + }; + Ok(RenderedExpression { value, pending_evaluation: true }) + } +} diff --git a/compiler-backend/javascript/src/convert/generator/functional/render/tail_call.rs b/compiler-backend/javascript/src/convert/generator/functional/render/tail_call.rs index 02c6ccfed..cf9bfdb6f 100644 --- a/compiler-backend/javascript/src/convert/generator/functional/render/tail_call.rs +++ b/compiler-backend/javascript/src/convert/generator/functional/render/tail_call.rs @@ -250,6 +250,7 @@ fn function_profile( | ExpressionKind::Application { .. } | ExpressionKind::UncurriedApplication { .. } | ExpressionKind::StyleX(_) + | ExpressionKind::React(_) | ExpressionKind::IfThenElse { .. } | ExpressionKind::Case { .. } | ExpressionKind::Guarded { .. } @@ -347,6 +348,7 @@ fn collect_tail_edges( | ExpressionKind::Application { .. } | ExpressionKind::UncurriedApplication { .. } | ExpressionKind::StyleX(_) + | ExpressionKind::React(_) | ExpressionKind::SynthesizedEvidence { .. } | ExpressionKind::TrivialEvidence => {} } @@ -390,6 +392,7 @@ fn application_head(module: &Module, expression: ExpressionId) -> Option>>, + dialect: Shards>, foreign: Shards>, foreign_content: Shards>>, module: Shards>>, @@ -216,6 +217,7 @@ struct InternedStorage { fn query_references_file(query: QueryKey, file_id: FileId) -> bool { match query { QueryKey::Content(id) + | QueryKey::Dialect(id) | QueryKey::Foreign(id) | QueryKey::ForeignValidation(id) | QueryKey::Parsed(id) @@ -605,6 +607,7 @@ impl QueryEngine { for dependency in dependencies { match dependency { QueryKey::Content(k) => input_changed!(content, k), + QueryKey::Dialect(k) => input_changed!(dialect, k), QueryKey::Foreign(k) => input_changed!(foreign, k), QueryKey::ForeignContent(k) => input_changed!(foreign_content, k), QueryKey::ForeignModule(k) => derived_changed!(foreign_module, k), @@ -846,6 +849,14 @@ impl QueryEngine { .ok_or(QueryError::MissingContent { file_id: id }) } + pub fn set_dialect(&self, id: FileId, dialect: SourceDialect) { + self.set_input(id, |input| &input.dialect, dialect); + } + + pub fn dialect(&self, id: FileId) -> SourceDialect { + self.get_input(QueryKey::Dialect(id), id, |input| &input.dialect).unwrap_or_default() + } + fn remove_file_queries( &self, file_id: FileId, @@ -883,6 +894,7 @@ impl QueryEngine { self.control.global.revision.fetch_add(1, Ordering::Relaxed); self.input.content.remove(&file_id); + self.input.dialect.remove(&file_id); self.input.foreign.remove(&file_id); let mut removed_modules = FxHashSet::default(); @@ -1053,8 +1065,9 @@ impl QueryEngine { |derived| &derived.parsed, |this| { let content = this.content(id)?; + let dialect = this.dialect(id); - let lexed = lexing::lex(&content); + let lexed = lexing::lex_with_jsx(&content, dialect == SourceDialect::Iris); let tokens = lexing::layout(&lexed); let parsed = parsing::parse(&lexed, &tokens); @@ -1782,9 +1795,11 @@ mod tests { } let content_states = engine.input.content.inner.iter().map(|shard| shard.read().len()); + let dialect_states = engine.input.dialect.inner.iter().map(|shard| shard.read().len()); let module_states = engine.input.module.inner.iter().map(|shard| shard.read().len()); let parsed_states = engine.derived.parsed.inner.iter().map(|shard| shard.read().len()); assert_eq!(content_states.sum::(), 0); + assert_eq!(dialect_states.sum::(), 0); assert_eq!(module_states.sum::(), 0); assert_eq!(parsed_states.sum::(), 0); } @@ -2202,6 +2217,7 @@ mod tests { let mut engine = QueryEngine::default(); let mut files = Files::default(); prim::configure(&mut engine, &mut files); + let initial_revision = engine.control.global.revision.load(Ordering::Relaxed); macro_rules! assert_trace { ($engine:expr, $field:ident($id:expr) => $trace:expr) => {{ @@ -2217,18 +2233,18 @@ mod tests { let indexed_a = engine.indexed(id).unwrap(); assert_trace!(engine, parsed(id) => Trace { - built: 25, - changed: 25, - dependencies: &[QueryKey::Content(id)] + built: initial_revision + 1, + changed: initial_revision + 1, + dependencies: &[QueryKey::Content(id), QueryKey::Dialect(id)] }); assert_trace!(engine, stabilized(id) => Trace { - built: 25, - changed: 25, + built: initial_revision + 1, + changed: initial_revision + 1, dependencies: &[QueryKey::Parsed(id)] }); assert_trace!(engine, indexed(id) => Trace { - built: 25, - changed: 25, + built: initial_revision + 1, + changed: initial_revision + 1, dependencies: &[QueryKey::Content(id), QueryKey::Parsed(id), QueryKey::Stabilized(id)] }); @@ -2238,18 +2254,18 @@ mod tests { let indexed_b = engine.indexed(id).unwrap(); assert_trace!(engine, parsed(id) => Trace { - built: 26, - changed: 25, - dependencies: &[QueryKey::Content(id)] + built: initial_revision + 2, + changed: initial_revision + 1, + dependencies: &[QueryKey::Content(id), QueryKey::Dialect(id)] }); assert_trace!(engine, stabilized(id) => Trace { - built: 26, - changed: 25, + built: initial_revision + 2, + changed: initial_revision + 1, dependencies: &[QueryKey::Parsed(id)] }); assert_trace!(engine, indexed(id) => Trace { - built: 26, - changed: 26, + built: initial_revision + 2, + changed: initial_revision + 2, dependencies: &[QueryKey::Content(id), QueryKey::Parsed(id), QueryKey::Stabilized(id)] }); @@ -2262,6 +2278,7 @@ mod tests { let mut engine = QueryEngine::default(); let mut files = Files::default(); prim::configure(&mut engine, &mut files); + let initial_revision = engine.control.global.revision.load(Ordering::Relaxed); macro_rules! assert_trace { ($engine:expr, $field:ident($id:expr) => $trace:expr) => {{ @@ -2280,18 +2297,18 @@ mod tests { let resolved_a = engine.resolved(id).unwrap(); assert_trace!(engine, parsed(id) => Trace { - built: 25, - changed: 25, - dependencies: &[QueryKey::Content(id)] + built: initial_revision + 1, + changed: initial_revision + 1, + dependencies: &[QueryKey::Content(id), QueryKey::Dialect(id)] }); assert_trace!(engine, indexed(id) => Trace { - built: 25, - changed: 25, + built: initial_revision + 1, + changed: initial_revision + 1, dependencies: &[QueryKey::Content(id), QueryKey::Parsed(id), QueryKey::Stabilized(id)] }); assert_trace!(engine, resolved(id) => Trace { - built: 25, - changed: 25, + built: initial_revision + 1, + changed: initial_revision + 1, dependencies: &[QueryKey::Indexed(id)] }); @@ -2304,18 +2321,18 @@ mod tests { let resolved_b = engine.resolved(id).unwrap(); assert_trace!(engine, parsed(id) => Trace { - built: 26, - changed: 26, - dependencies: &[QueryKey::Content(id)] + built: initial_revision + 2, + changed: initial_revision + 2, + dependencies: &[QueryKey::Content(id), QueryKey::Dialect(id)] }); assert_trace!(engine, indexed(id) => Trace { - built: 26, - changed: 25, + built: initial_revision + 2, + changed: initial_revision + 1, dependencies: &[QueryKey::Content(id), QueryKey::Parsed(id), QueryKey::Stabilized(id)] }); assert_trace!(engine, resolved(id) => Trace { - built: 26, - changed: 25, + built: initial_revision + 2, + changed: initial_revision + 1, dependencies: &[QueryKey::Indexed(id)] }); @@ -2328,18 +2345,18 @@ mod tests { let resolved_c = engine.resolved(id).unwrap(); assert_trace!(engine, parsed(id) => Trace { - built: 27, - changed: 27, - dependencies: &[QueryKey::Content(id)] + built: initial_revision + 3, + changed: initial_revision + 3, + dependencies: &[QueryKey::Content(id), QueryKey::Dialect(id)] }); assert_trace!(engine, indexed(id) => Trace { - built: 27, - changed: 25, + built: initial_revision + 3, + changed: initial_revision + 1, dependencies: &[QueryKey::Content(id), QueryKey::Parsed(id), QueryKey::Stabilized(id)] }); assert_trace!(engine, resolved(id) => Trace { - built: 27, - changed: 25, + built: initial_revision + 3, + changed: initial_revision + 1, dependencies: &[QueryKey::Indexed(id)] }); @@ -2379,6 +2396,7 @@ mod tests { let mut engine = QueryEngine::default(); let mut files = Files::default(); prim::configure(&mut engine, &mut files); + let initial_revision = engine.control.global.revision.load(Ordering::Relaxed); let parent = files.insert("./src/Parent.purs", "module Parent where"); let child = files.insert("./src/Child.purs", "module Child where"); @@ -2402,7 +2420,11 @@ mod tests { let guard = shard.read(); assert_eq!( ShowTrace(guard.get(&parent).unwrap()), - Trace { built: 25, changed: 25, dependencies: &[QueryKey::Parsed(child)] } + Trace { + built: initial_revision + 1, + changed: initial_revision + 1, + dependencies: &[QueryKey::Parsed(child)] + } ); } diff --git a/compiler-core/building/src/lifecycle/source.rs b/compiler-core/building/src/lifecycle/source.rs index 7afff1b9e..db79dbeee 100644 --- a/compiler-core/building/src/lifecycle/source.rs +++ b/compiler-core/building/src/lifecycle/source.rs @@ -209,6 +209,7 @@ where let id = self.source_files.insert(Arc::clone(&unit.source), Arc::clone(&text)); self.source_units.insert(id, SourceUnitKey::clone(unit)); engine.set_content(id, Arc::clone(&text)); + engine.set_dialect(id, files::SourceDialect::from_path(unit.source())); registration.register(engine, id, None); let foreign_files = source_unit.foreign_files(); for kind in files::ForeignSourceKind::ALL { diff --git a/compiler-core/building/src/prim.rs b/compiler-core/building/src/prim.rs index fe1da3817..d0ad72da3 100644 --- a/compiler-core/building/src/prim.rs +++ b/compiler-core/building/src/prim.rs @@ -11,6 +11,7 @@ pub fn configure(engine: &mut QueryEngine, files: &mut Files) { let id = files.insert(path, *content); engine.set_content(id, *content); + engine.set_dialect(id, files.dialect(id)); engine.set_module_file(name, id); } } diff --git a/compiler-core/files/src/lib.rs b/compiler-core/files/src/lib.rs index 71c9f109b..fc2a417a0 100644 --- a/compiler-core/files/src/lib.rs +++ b/compiler-core/files/src/lib.rs @@ -17,6 +17,19 @@ pub struct FileId { index: u32, } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum SourceDialect { + #[default] + PureScript, + Iris, +} + +impl SourceDialect { + pub fn from_path(path: &str) -> SourceDialect { + if path.ends_with(".iris") { SourceDialect::Iris } else { SourceDialect::PureScript } + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum ForeignSourceKind { JavaScript, @@ -162,6 +175,10 @@ impl Files { Arc::clone(&file.content) } + pub fn dialect(&self, file_id: FileId) -> SourceDialect { + SourceDialect::from_path(&self.file(file_id).path) + } + pub fn remove(&mut self, path: &str) -> Option { let file_id = self.paths.remove(path)?; self.files.remove(&file_id).expect("invariant violated: expected valid FileId"); diff --git a/compiler-core/prim-constants/src/lib.rs b/compiler-core/prim-constants/src/lib.rs index 097fcac0b..7982b9696 100644 --- a/compiler-core/prim-constants/src/lib.rs +++ b/compiler-core/prim-constants/src/lib.rs @@ -22,6 +22,7 @@ define!( PRIM_ROW_LIST, "prim/Prim.RowList.purs", "Prim.RowList"; PRIM_SYMBOL, "prim/Prim.Symbol.purs", "Prim.Symbol"; PRIM_TYPE_ERROR, "prim/Prim.TypeError.purs", "Prim.TypeError"; + IRIS_REACT, "prim/Iris.React.purs", "Iris.React"; IRIS_STYLEX, "prim/Iris.StyleX.purs", "Iris.StyleX"; IRIS_STYLEX_WHEN, "prim/Iris.StyleX.When.purs", "Iris.StyleX.When"; IRIS_STYLEX_TYPES, "prim/Iris.StyleX.Types.purs", "Iris.StyleX.Types"; diff --git a/compiler-core/prim-constants/src/prim/Iris.React.purs b/compiler-core/prim-constants/src/prim/Iris.React.purs new file mode 100644 index 000000000..2f790bc5a --- /dev/null +++ b/compiler-core/prim-constants/src/prim/Iris.React.purs @@ -0,0 +1,46 @@ +module Iris.React + ( JSX + , Component + , component + , element + , elementKeyed + , intrinsic + , intrinsicKeyed + , text + , array + , fragment + , empty + ) where + +import Prim.Row as Row + +data JSX :: Type +data JSX + +data Component :: Row Type -> Type +data Component props +type role Component nominal + +foreign import component + :: forall props. (Record props -> JSX) -> Component props + +foreign import element + :: forall props. Row.Lacks "key" props + => Component props -> Record props -> JSX + +foreign import elementKeyed + :: forall props. Row.Lacks "key" props + => String -> Component props -> Record props -> JSX + +foreign import intrinsic + :: forall props. Row.Lacks "key" props + => String -> Record props -> JSX + +foreign import intrinsicKeyed + :: forall props. Row.Lacks "key" props + => String -> String -> Record props -> JSX + +foreign import text :: String -> JSX +foreign import array :: Array JSX -> JSX +foreign import fragment :: Array JSX -> JSX +foreign import empty :: JSX diff --git a/compiler-frontend/checking/src/source/terms.rs b/compiler-frontend/checking/src/source/terms.rs index 508726214..b503e5ac5 100644 --- a/compiler-frontend/checking/src/source/terms.rs +++ b/compiler-frontend/checking/src/source/terms.rs @@ -6,6 +6,7 @@ pub mod form_do; pub mod form_let; pub mod forms; pub mod guarded; +mod jsx; use std::sync::Arc; @@ -477,6 +478,12 @@ where form_ado::infer_ado(state, context, *map, *apply, *pure, statements, *expression) } + lowering::ExpressionKind::JsxElement { kind, resolution, attributes, children } => { + jsx::infer_element(state, context, kind, *resolution, attributes, children) + } + + lowering::ExpressionKind::JsxText { value } => jsx::infer_text(state, context, value), + lowering::ExpressionKind::Constructor { resolution } => { let Some((file_id, term_id)) = resolution else { return Ok(allocate_error_expression( diff --git a/compiler-frontend/checking/src/source/terms/collections.rs b/compiler-frontend/checking/src/source/terms/collections.rs index 9b7fe5726..787612e39 100644 --- a/compiler-frontend/checking/src/source/terms/collections.rs +++ b/compiler-frontend/checking/src/source/terms/collections.rs @@ -189,7 +189,7 @@ pub fn infer_record( where Q: ExternalQueries, { - let (type_id, fields, complete) = record_core(state, context, record, RecordMode::Infer)?; + let (type_id, fields, complete) = record_core(state, context, record, RecordMode::Infer, None)?; if complete { let kind = tree::ExpressionKind::Record { fields }; Ok(super::allocate_expression(state, type_id, kind)) @@ -204,6 +204,19 @@ pub fn check_record( record: &[lowering::ExpressionRecordItem], expected: TypeId, ) -> QueryResult +where + Q: ExternalQueries, +{ + check_record_with_field(state, context, record, expected, None) +} + +pub(super) fn check_record_with_field( + state: &mut CheckState, + context: &CheckContext, + record: &[lowering::ExpressionRecordItem], + expected: TypeId, + additional: Option<(&str, ElaboratedExpression)>, +) -> QueryResult where Q: ExternalQueries, { @@ -219,6 +232,7 @@ where context, record, RecordMode::Check { expected_fields: &expected_fields.fields }, + additional, )?; unification::subtype(state, context, record_type, expected)?; if complete { @@ -231,9 +245,14 @@ where } } - let inferred = infer_record(state, context, record)?; - unification::subtype(state, context, inferred.type_id, expected)?; - Ok(inferred) + let (type_id, fields, complete) = + record_core(state, context, record, RecordMode::Infer, additional)?; + unification::subtype(state, context, type_id, expected)?; + if complete { + Ok(super::allocate_expression(state, type_id, tree::ExpressionKind::Record { fields })) + } else { + Ok(super::allocate_error_expression(state, type_id)) + } } fn find_expected_field(expected_fields: &[RowField], label: &SmolStr) -> Option { @@ -314,6 +333,7 @@ fn record_core( context: &CheckContext, record: &[lowering::ExpressionRecordItem], mode: RecordMode<'_>, + additional: Option<(&str, ElaboratedExpression)>, ) -> QueryResult<(TypeId, Arc<[tree::RecordExpressionField]>, bool)> where Q: ExternalQueries, @@ -346,6 +366,18 @@ where checked_fields.push(field.checked); } + if let Some((label, value)) = additional { + let label = SmolStr::from(label); + let value = if let Some(expected) = expected_record_field(mode, &label) { + super::check_elaborated_expression(state, context, value, expected)? + } else { + value + }; + fields.push(RowField { label: label.clone(), id: value.type_id }); + checked_fields + .push(tree::RecordExpressionField::Field { label, expression: value.expression }); + } + let row_type = context.intern_row(fields, None); let type_id = context.intern_application(context.prim.record, row_type); Ok((type_id, checked_fields.into(), complete)) diff --git a/compiler-frontend/checking/src/source/terms/jsx.rs b/compiler-frontend/checking/src/source/terms/jsx.rs new file mode 100644 index 000000000..4ef9979a8 --- /dev/null +++ b/compiler-frontend/checking/src/source/terms/jsx.rs @@ -0,0 +1,165 @@ +use building_types::QueryResult; +use lowering::{ + ExpressionId, ExpressionRecordItem, JsxElementKind, StringLiteral, TermVariableResolution, +}; + +use super::{ + ElaboratedExpression, allocate_error_expression, allocate_expression, application, collections, +}; +use crate::context::CheckContext; +use crate::core::{toolkit, unification}; +use crate::state::CheckState; +use crate::{ExternalQueries, tree}; + +enum Argument<'a> { + String(StringLiteral), + Record(&'a [ExpressionRecordItem], &'a [ExpressionId]), + Array(&'a [ExpressionId]), + Source(ExpressionId), + Value(ElaboratedExpression), +} + +pub(super) fn infer_element( + state: &mut CheckState, + context: &CheckContext, + kind: &JsxElementKind, + resolution: Option, + attributes: &[ExpressionRecordItem], + children: &[ExpressionId], +) -> QueryResult { + if matches!(kind, JsxElementKind::Fragment) { + return infer_application(state, context, "fragment", [Argument::Array(children)]); + } + let mut props = Vec::new(); + let mut key = None; + for attribute in attributes { + if let ExpressionRecordItem::RecordField { name: Some(name), value } = attribute + && name == "key" + { + key = *value; + } else { + props.push(attribute.clone()); + } + } + let mut arguments = Vec::new(); + if let Some(key) = key { + arguments.push(Argument::Source(key)); + } + let function = match kind { + JsxElementKind::Intrinsic(tag) => { + arguments.push(Argument::String(tag.clone().into())); + if key.is_some() { "intrinsicKeyed" } else { "intrinsic" } + } + JsxElementKind::Component => { + let Some(resolution) = resolution else { + return Ok(allocate_error_expression( + state, + context.unknown("missing JSX component"), + )); + }; + let type_id = toolkit::lookup_term_variable(state, context, resolution)?; + let resolution = tree::VariableResolution::Source(resolution); + let component = + allocate_expression(state, type_id, tree::ExpressionKind::Variable { resolution }); + arguments.push(Argument::Value(component)); + if key.is_some() { "elementKeyed" } else { "element" } + } + JsxElementKind::Fragment => unreachable!(), + }; + arguments.push(Argument::Record(&props, children)); + infer_application(state, context, function, arguments) +} + +pub(super) fn infer_text( + state: &mut CheckState, + context: &CheckContext, + value: &StringLiteral, +) -> QueryResult { + infer_application(state, context, "text", [Argument::String(value.clone())]) +} + +fn react_function( + state: &mut CheckState, + context: &CheckContext, + name: &str, +) -> QueryResult { + let file_id = context.queries.module_file("Iris.React").expect("missing built-in Iris.React"); + let indexed = context.queries.indexed(file_id)?; + let (term_id, _) = indexed + .items + .iter_terms() + .find(|(_, item)| item.name.as_deref() == Some(name)) + .expect("missing built-in React function"); + let type_id = toolkit::lookup_file_term(state, context, file_id, term_id)?; + let resolution = TermVariableResolution::Reference(file_id, term_id); + let resolution = tree::VariableResolution::Source(resolution); + Ok(allocate_expression(state, type_id, tree::ExpressionKind::Variable { resolution })) +} + +fn infer_application<'a, Q: ExternalQueries>( + state: &mut CheckState, + context: &CheckContext, + name: &str, + arguments: impl IntoIterator>, +) -> QueryResult { + let mut function = react_function(state, context, name)?; + + // Check generated records against each parameter rather than inferring them + // first: a component may require a rank-polymorphic property. Materializing + // the normal application also retains evidence for constrained components. + for value in arguments { + let Some(application::UnanchoredApplication { implicit, argument, result }) = + application::check_unanchored_application(state, context, function.type_id)? + else { + let argument = state.fresh_unification(context.queries, context.prim.t); + let result = state.fresh_unification(context.queries, context.prim.t); + let expected = context.intern_function(argument, result); + unification::unify(state, context, function.type_id, expected)?; + return Ok(allocate_error_expression(state, result)); + }; + let value = super::check_expected_expression( + state, + context, + argument, + |state, argument| match value { + Argument::String(value) => { + let kind = + tree::ExpressionKind::String { kind: lowering::StringKind::String, value }; + let value = allocate_expression(state, context.prim.string, kind); + application::subtype_expression(state, context, value, argument) + } + Argument::Record(record, children) => { + let children = match children { + [] => None, + [child] => { + let jsx = react_function(state, context, "empty")?.type_id; + Some(super::check_expression(state, context, *child, jsx)?) + } + _ => Some(infer_application( + state, + context, + "array", + [Argument::Array(children)], + )?), + }; + collections::check_record_with_field( + state, + context, + record, + argument, + children.map(|children| ("children", children)), + ) + } + Argument::Array(array) => collections::check_array(state, context, array, argument), + Argument::Source(source) => { + super::check_expression(state, context, source, argument) + } + Argument::Value(value) => { + super::check_elaborated_expression(state, context, value, argument) + } + }, + )?; + function = application::materialize_application(state, function, implicit, result, value); + } + Ok(function) +} diff --git a/compiler-frontend/lexing/src/layout.rs b/compiler-frontend/lexing/src/layout.rs index 9d595ba7f..b6b5309bf 100644 --- a/compiler-frontend/lexing/src/layout.rs +++ b/compiler-frontend/lexing/src/layout.rs @@ -27,6 +27,8 @@ enum Delimiter { Of, Do, Ado, + Jsx, + JsxExpression, } impl Delimiter { @@ -110,6 +112,50 @@ impl<'l, 's> Insert<'l, 's> { fn invoke(&mut self) { match self.token { + SyntaxKind::JSX_OPEN => { + self.insert_default(); + self.push_stack(self.position, Delimiter::Jsx); + } + + SyntaxKind::JSX_CLOSE_OPEN + | SyntaxKind::JSX_TAG_END + | SyntaxKind::JSX_NAME + | SyntaxKind::JSX_TEXT + | SyntaxKind::JSX_EXPRESSION_START => { + self.insert_token(self.token); + if self.token == SyntaxKind::JSX_EXPRESSION_START { + self.push_stack(self.position, Delimiter::JsxExpression); + } + } + + SyntaxKind::JSX_SELF_CLOSE | SyntaxKind::JSX_CLOSE_END => { + self.insert_token(self.token); + self.pop_stack_if(|delimiter| delimiter == Delimiter::Jsx); + } + + SyntaxKind::JSX_EXPRESSION_END => { + while let Some((_, delimiter)) = self.layout.stack.last().copied() { + if delimiter == Delimiter::JsxExpression { + self.pop_stack(); + break; + } + if delimiter == Delimiter::Jsx { + break; + } + self.pop_stack(); + if delimiter.is_indented() { + self.insert_end(); + } + } + self.insert_token(self.token); + } + + SyntaxKind::EQUAL | SyntaxKind::STRING | SyntaxKind::RAW_STRING + if self.in_jsx_markup() => + { + self.insert_token(self.token); + } + SyntaxKind::DATA if !self.qualified => { self.insert_default(); if self.is_top_declaration(self.position) { @@ -416,6 +462,19 @@ impl<'l, 's> Insert<'l, 's> { ) } + fn in_jsx_markup(&self) -> bool { + self.layout + .stack + .iter() + .rev() + .find_map(|(_, delimiter)| match delimiter { + Delimiter::Jsx => Some(true), + Delimiter::JsxExpression => Some(false), + _ => None, + }) + .unwrap_or(false) + } + fn insert_default(&mut self) { self.collapse_and_commit(Self::offside_p); self.insert_sep(); @@ -423,8 +482,13 @@ impl<'l, 's> Insert<'l, 's> { } fn insert_start(&mut self, delimiter: Delimiter) { - if let Some((past_position, _)) = - self.layout.stack.iter().rfind(|(_, delimiter)| delimiter.is_indented()) + if let Some((past_position, _)) = self + .layout + .stack + .iter() + .rev() + .take_while(|(_, delimiter)| *delimiter != Delimiter::JsxExpression) + .find(|(_, delimiter)| delimiter.is_indented()) && self.next.column <= past_position.column { return; diff --git a/compiler-frontend/lexing/src/lexed.rs b/compiler-frontend/lexing/src/lexed.rs index 68efd7b13..14755ad8a 100644 --- a/compiler-frontend/lexing/src/lexed.rs +++ b/compiler-frontend/lexing/src/lexed.rs @@ -95,6 +95,14 @@ impl<'s> LexedBuilder<'s> { } } + pub(super) fn len(&self) -> usize { + self.kinds.len() + } + + pub(super) fn last_kind(&self) -> SyntaxKind { + self.kinds.last().copied().unwrap() + } + pub(super) fn build(self) -> Lexed<'s> { Lexed { source: self.source, kinds: self.kinds, infos: self.infos, errors: self.errors } } diff --git a/compiler-frontend/lexing/src/lexer.rs b/compiler-frontend/lexing/src/lexer.rs index 14fb18a37..8a163e3f2 100644 --- a/compiler-frontend/lexing/src/lexer.rs +++ b/compiler-frontend/lexing/src/lexer.rs @@ -78,10 +78,20 @@ pub(super) struct Lexer<'s> { qualifier_position: Position, current_position: Position, lexed: LexedBuilder<'s>, + modes: Vec, + operand_start: bool, + jsx: bool, +} + +#[derive(Debug, Clone, Copy)] +enum Mode { + PureScript { hole_depth: Option }, + Tag { closing: bool, expects_name: bool }, + Children, } impl<'s> Lexer<'s> { - pub(super) fn new(source: &'s str) -> Lexer<'s> { + pub(super) fn new(source: &'s str, jsx: bool) -> Lexer<'s> { let chars = source.chars(); let annotation = 0; let qualifier = 0; @@ -100,6 +110,9 @@ impl<'s> Lexer<'s> { lexed, qualifier_position, current_position, + modes: vec![Mode::PureScript { hole_depth: None }], + operand_start: true, + jsx, }; lexer.take_annotation(); lexer.take_qualifier(); @@ -122,13 +135,173 @@ impl<'s> Lexer<'s> { } pub(super) fn take_token(&mut self) { - self.take_token_impl(); - self.take_annotation(); - self.take_qualifier(); + match self.modes.last().copied().unwrap() { + Mode::PureScript { .. } => self.take_pure_script_token(), + Mode::Tag { .. } => self.take_tag_token(), + Mode::Children => self.take_children_token(), + } + match self.modes.last().copied().unwrap() { + Mode::PureScript { .. } => { + self.take_annotation(); + self.take_qualifier(); + } + Mode::Tag { .. } => { + // A brace starts a hole here, even in value={-1}; it cannot + // introduce a PureScript block comment until inside the hole. + self.take_annotation_whitespace(); + self.annotation = self.consumed() as u32; + self.qualifier = self.annotation; + self.qualifier_position = self.current_position; + } + Mode::Children => { + self.annotation = self.consumed() as u32; + self.qualifier = self.annotation; + self.qualifier_position = self.current_position; + } + } } } impl Lexer<'_> { + fn starts_jsx(&self) -> bool { + self.jsx + && self.first() == '<' + && self.operand_start + && self.annotation == self.qualifier + && (self.second() == '>' || jsx_name_start(self.second())) + } + + fn take_pure_script_token(&mut self) { + if self.starts_jsx() { + self.take_jsx_open(); + return; + } + + if let Some(Mode::PureScript { hole_depth: Some(depth) }) = self.modes.last().copied() { + match (self.first(), depth) { + ('}', 0) => { + self.take_kind(SyntaxKind::JSX_EXPRESSION_END); + self.modes.pop(); + self.operand_start = false; + return; + } + ('{', _) => { + self.take_kind(SyntaxKind::LEFT_CURLY); + *self.modes.last_mut().unwrap() = + Mode::PureScript { hole_depth: Some(depth + 1) }; + self.operand_start = true; + return; + } + ('}', _) => { + self.take_kind(SyntaxKind::RIGHT_CURLY); + *self.modes.last_mut().unwrap() = + Mode::PureScript { hole_depth: Some(depth - 1) }; + self.operand_start = false; + return; + } + _ => {} + } + } + + let before = self.lexed.len(); + let property = before > 0 && self.lexed.last_kind() == SyntaxKind::PERIOD; + let qualified = self.annotation < self.qualifier; + self.take_token_impl(); + if self.lexed.len() > before { + let kind = self.lexed.last_kind(); + let qualified_prefix = matches!(kind, SyntaxKind::DO | SyntaxKind::ADO) + || syntax::names::OPERATOR.contains(kind); + self.operand_start = + !property && (!qualified || qualified_prefix) && token_permits_operand(kind); + } + } + + fn take_jsx_open(&mut self) { + self.take(); + let closing = if self.first() == '/' { + self.take(); + true + } else { + false + }; + self.push(if closing { SyntaxKind::JSX_CLOSE_OPEN } else { SyntaxKind::JSX_OPEN }, None); + self.modes.push(Mode::Tag { closing, expects_name: true }); + self.operand_start = true; + } + + fn take_tag_token(&mut self) { + let Mode::Tag { closing, expects_name } = self.modes.last().copied().unwrap() else { + unreachable!() + }; + match (self.first(), self.second()) { + ('/', '>') if !closing => { + self.take(); + self.take(); + self.push(SyntaxKind::JSX_SELF_CLOSE, None); + self.modes.pop(); + self.operand_start = false; + } + ('>', _) => { + self.take(); + self.push( + if closing { SyntaxKind::JSX_CLOSE_END } else { SyntaxKind::JSX_TAG_END }, + None, + ); + self.modes.pop(); + if closing { + if matches!(self.modes.last(), Some(Mode::Children)) { + self.modes.pop(); + } + self.operand_start = false; + } else { + self.modes.push(Mode::Children); + } + } + ('=', _) if !expects_name => self.take_kind(SyntaxKind::EQUAL), + ('"', _) if !expects_name => self.take_string(), + ('{', _) if !expects_name && !closing => { + self.take_kind(SyntaxKind::JSX_EXPRESSION_START); + self.modes.push(Mode::PureScript { hole_depth: Some(0) }); + self.operand_start = true; + } + (character, _) if jsx_name_start(character) => { + let start = self.consumed(); + self.take(); + self.take_while(jsx_name_continue); + let name = &self.source[start..self.consumed()]; + let valid = + name.split('.').rev().skip(1).all(|component| { + component.chars().next().is_some_and(char::is_upper_start) + }); + self.push( + SyntaxKind::JSX_NAME, + (!valid).then_some("JSX name qualifiers must start with an uppercase letter"), + ); + *self.modes.last_mut().unwrap() = Mode::Tag { closing, expects_name: false }; + } + _ => { + self.take(); + self.push(SyntaxKind::JSX_INVALID, Some("Invalid token in JSX tag")); + } + } + } + + fn take_children_token(&mut self) { + match (self.first(), self.second()) { + ('<', _) => self.take_jsx_open(), + ('{', _) => { + self.take_kind(SyntaxKind::JSX_EXPRESSION_START); + self.modes.push(Mode::PureScript { hole_depth: Some(0) }); + self.operand_start = true; + } + _ => { + self.take(); + self.take_while(|character| character != '<' && character != '{'); + self.push(SyntaxKind::JSX_TEXT, None); + } + } + } + fn first(&self) -> char { let mut chars = self.chars.clone(); chars.next().unwrap_or(EOF_CHAR) @@ -324,6 +497,20 @@ impl Lexer<'_> { position: lp_position, }; + // Complete operator names win over fragments: (<>) remains an operator, + // while (<>) starts a parenthesized JSX expression. + let operator_name = + self.chars.clone().skip_while(|character| character.is_operator()).next() == Some(')'); + if self.jsx + && self.first() == '<' + && (self.second() == '>' || jsx_name_start(self.second())) + && !operator_name + { + self.lexed.push(SyntaxKind::LEFT_PARENTHESIS, lp_info, None); + self.operand_start = true; + return; + } + if !self.first().is_operator() { return self.lexed.push(SyntaxKind::LEFT_PARENTHESIS, lp_info, None); } @@ -526,3 +713,41 @@ impl Lexer<'_> { self.push(kind, error); } } + +fn jsx_name_start(character: char) -> bool { + character.is_lower_start() || character.is_upper_start() +} + +fn jsx_name_continue(character: char) -> bool { + character.is_name() || matches!(character, '-' | '.') +} + +fn token_permits_operand(kind: SyntaxKind) -> bool { + matches!( + kind, + SyntaxKind::LEFT_PARENTHESIS + | SyntaxKind::LEFT_CURLY + | SyntaxKind::LEFT_SQUARE + | SyntaxKind::BACKSLASH + | SyntaxKind::TICK + | SyntaxKind::COMMA + | SyntaxKind::EQUAL + | SyntaxKind::PIPE + | SyntaxKind::RIGHT_ARROW + | SyntaxKind::LEFT_ARROW + | SyntaxKind::THEN + | SyntaxKind::ELSE + | SyntaxKind::IN + | SyntaxKind::OF + | SyntaxKind::CASE + | SyntaxKind::IF + | SyntaxKind::LET + | SyntaxKind::DO + | SyntaxKind::ADO + | SyntaxKind::OPERATOR + | SyntaxKind::MINUS + | SyntaxKind::COLON + | SyntaxKind::DOUBLE_PERIOD + | SyntaxKind::LEFT_THICK_ARROW + ) +} diff --git a/compiler-frontend/lexing/src/lib.rs b/compiler-frontend/lexing/src/lib.rs index 34eaf4cba..686ff59bb 100644 --- a/compiler-frontend/lexing/src/lib.rs +++ b/compiler-frontend/lexing/src/lib.rs @@ -14,7 +14,11 @@ pub struct Position { } pub fn lex(source: &str) -> Lexed<'_> { - let mut lexer = lexer::Lexer::new(source); + lex_with_jsx(source, false) +} + +pub fn lex_with_jsx(source: &str, jsx: bool) -> Lexed<'_> { + let mut lexer = lexer::Lexer::new(source, jsx); while !lexer.is_eof() { lexer.take_token(); } diff --git a/compiler-frontend/lexing/tests/lexer.rs b/compiler-frontend/lexing/tests/lexer.rs index 7aa71611e..a770d9505 100644 --- a/compiler-frontend/lexing/tests/lexer.rs +++ b/compiler-frontend/lexing/tests/lexer.rs @@ -1,5 +1,19 @@ use test_each_file::test_each_file; +#[test] +fn jsx_requires_opt_in() { + use syntax::SyntaxKind; + + for source in ["
", "(<>
)"] { + let pure_script = lexing::lex(source); + let iris = lexing::lex_with_jsx(source, true); + assert!( + (0..pure_script.len()).all(|index| pure_script.kind(index) != SyntaxKind::JSX_OPEN) + ); + assert!((0..iris.len()).any(|index| iris.kind(index) == SyntaxKind::JSX_OPEN)); + } +} + test_each_file! { in "./compiler-frontend/lexing/tests/lexer" => |content: &str| { use std::fmt::Write; diff --git a/compiler-frontend/lowering/src/algorithm.rs b/compiler-frontend/lowering/src/algorithm.rs index ae02758ef..69ef0d1bf 100644 --- a/compiler-frontend/lowering/src/algorithm.rs +++ b/compiler-frontend/lowering/src/algorithm.rs @@ -1,3 +1,4 @@ +mod jsx; mod recursive; use std::mem; diff --git a/compiler-frontend/lowering/src/algorithm/jsx.rs b/compiler-frontend/lowering/src/algorithm/jsx.rs new file mode 100644 index 000000000..9bf63f504 --- /dev/null +++ b/compiler-frontend/lowering/src/algorithm/jsx.rs @@ -0,0 +1,135 @@ +use rustc_hash::FxHashSet; +use smol_str::SmolStr; +use stabilizing::ExpectId; +use syntax::cst; + +use super::{Context, State, recursive}; +use crate::{ + ExpressionId, ExpressionKind, ExpressionRecordItem, JsxElementKind, LoweringError, NotInScope, + StringLiteral, TermVariableResolution, +}; + +pub(super) fn lower_element( + state: &mut State, + context: &Context, + element: &cst::ExpressionJsxElement, +) -> ExpressionKind { + let expression = cst::Expression::ExpressionJsxElement(element.clone()); + let id = context.stabilized.lookup_cst(&expression).expect_id(); + let opening = element.opening(); + let name = opening.as_ref().and_then(|opening| opening.name_token()); + let name = name.as_ref().map(|name| name.text(context.source)); + + if let Some(closing) = element.closing() { + let closing_name = closing.name_token(); + let closing_name = closing_name.as_ref().map(|name| name.text(context.source)); + if name != closing_name { + state.errors.push(LoweringError::JsxTagMismatch { + id: context.stabilized.lookup_cst(&closing).expect_id(), + expected: name.map(SmolStr::from), + }); + } + } + + let (kind, resolution) = match name { + None => (JsxElementKind::Fragment, None), + Some(name) if name.contains('.') || name.starts_with(char::is_uppercase) => { + let (qualifier, name) = name + .rsplit_once('.') + .map_or((None, name), |(qualifier, name)| (Some(qualifier), name)); + let mut characters = name.chars(); + let name = characters + .next() + .into_iter() + .flat_map(char::to_lowercase) + .chain(characters) + .collect::(); + let resolution = resolve_function(state, context, id, qualifier, &name); + (JsxElementKind::Component, resolution) + } + Some(name) => (JsxElementKind::Intrinsic(name.into()), None), + }; + + let mut names = FxHashSet::default(); + let has_children = element.children().is_some_and(|children| { + children.children().any(|child| match child { + cst::Expression::ExpressionJsxText(text) => { + !text_value(context, &text).as_utf16().is_empty() + } + _ => true, + }) + }); + if has_children { + names.insert(SmolStr::from("children")); + } + let attributes = opening.iter().flat_map(cst::JsxOpening::attributes).map(|attribute| { + let name = attribute.name_token().map(|name| SmolStr::from(name.text(context.source))); + if let Some(name) = &name + && !names.insert(name.clone()) + { + state.errors.push(LoweringError::DuplicateJsxAttribute { + id: context.stabilized.lookup_cst(&attribute).expect_id(), + name: name.clone(), + }); + } + let value = + attribute.expression().map(|value| recursive::lower_expression(state, context, &value)); + ExpressionRecordItem::RecordField { name, value } + }); + let attributes = attributes.collect(); + + let children = element.children(); + let children = children.iter().flat_map(cst::JsxChildren::children).filter_map(|child| { + if let cst::Expression::ExpressionJsxText(text) = &child + && text_value(context, text).as_utf16().is_empty() + { + return None; + } + Some(recursive::lower_expression(state, context, &child)) + }); + let children = children.collect(); + + ExpressionKind::JsxElement { kind, resolution, attributes, children } +} + +pub(super) fn lower_text(context: &Context, text: &cst::ExpressionJsxText) -> ExpressionKind { + let value = text_value(context, text); + ExpressionKind::JsxText { value } +} + +fn resolve_function( + state: &mut State, + context: &Context, + id: ExpressionId, + qualifier: Option<&str>, + name: &str, +) -> Option { + let resolution = state.resolve_term_full(context, qualifier, name); + if resolution.is_none() { + let name = + qualifier.map_or_else(|| name.into(), |qualifier| format!("{qualifier}.{name}").into()); + state.errors.push(LoweringError::NotInScope(NotInScope::JsxFunction { id, name })); + } + resolution +} + +fn text_value(context: &Context, text: &cst::ExpressionJsxText) -> StringLiteral { + let Some(token) = text.text_token() else { return StringLiteral::from("") }; + let text = token.text(context.source); + let mut result = String::new(); + let mut lines = text.split('\n').peekable(); + let mut first = true; + while let Some(line) = lines.next() { + let line = line.trim_end_matches('\r'); + let line = if first { line } else { line.trim_start_matches([' ', '\t']) }; + let line = if lines.peek().is_none() { line } else { line.trim_end_matches([' ', '\t']) }; + if !line.is_empty() { + if !result.is_empty() { + result.push(' '); + } + result.push_str(line); + } + first = false; + } + StringLiteral::from(result) +} diff --git a/compiler-frontend/lowering/src/algorithm/recursive.rs b/compiler-frontend/lowering/src/algorithm/recursive.rs index dcd036cee..6c2dab5c6 100644 --- a/compiler-frontend/lowering/src/algorithm/recursive.rs +++ b/compiler-frontend/lowering/src/algorithm/recursive.rs @@ -605,6 +605,14 @@ fn lower_expression_kind( let updates = recover! { lower_record_updates(state, context, &cst.record_updates()?) }; ExpressionKind::RecordUpdate { record, updates } } + cst::Expression::ExpressionJsxElement(cst) => { + super::jsx::lower_element(state, context, cst) + } + cst::Expression::ExpressionJsxText(cst) => super::jsx::lower_text(context, cst), + cst::Expression::ExpressionJsxInterpolation(cst) => { + let parenthesized = cst.expression().map(|cst| lower_expression(state, context, &cst)); + ExpressionKind::Parenthesized { parenthesized } + } } } diff --git a/compiler-frontend/lowering/src/error.rs b/compiler-frontend/lowering/src/error.rs index f634cd0ac..341dd102e 100644 --- a/compiler-frontend/lowering/src/error.rs +++ b/compiler-frontend/lowering/src/error.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use indexing::TypeItemId; +use smol_str::SmolStr; use stabilizing::AstId; use syntax::cst; @@ -8,6 +9,8 @@ use syntax::cst; pub enum LoweringError { NotInScope(NotInScope), InvalidStringEscape { source: StringLiteralSource }, + JsxTagMismatch { id: AstId, expected: Option }, + DuplicateJsxAttribute { id: AstId, name: SmolStr }, RecursiveSynonym(RecursiveGroup), RecursiveKinds(RecursiveGroup), } @@ -33,6 +36,7 @@ pub enum NotInScope { NegateFn { id: AstId }, TermOperator { id: AstId }, TypeOperator { id: AstId }, + JsxFunction { id: crate::ExpressionId, name: SmolStr }, } #[derive(Debug, PartialEq, Eq)] diff --git a/compiler-frontend/lowering/src/tree.rs b/compiler-frontend/lowering/src/tree.rs index 31bf12b69..7dcd87b9d 100644 --- a/compiler-frontend/lowering/src/tree.rs +++ b/compiler-frontend/lowering/src/tree.rs @@ -70,7 +70,7 @@ pub enum DoStatement { Discard { expression: Option }, } -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum ExpressionRecordItem { RecordField { name: Option, value: Option }, RecordPun { id: RecordPunId, name: Option, resolution: Option }, @@ -82,6 +82,13 @@ pub struct RecordAccessLabel { pub name: SmolStr, } +#[derive(Debug, PartialEq, Eq)] +pub enum JsxElementKind { + Intrinsic(SmolStr), + Component, + Fragment, +} + #[derive(Debug, PartialEq, Eq)] pub enum ExpressionKind { Typed { @@ -179,6 +186,15 @@ pub enum ExpressionKind { record: Option, updates: Arc<[RecordUpdate]>, }, + JsxElement { + kind: JsxElementKind, + resolution: Option, + attributes: Arc<[ExpressionRecordItem]>, + children: Arc<[ExpressionId]>, + }, + JsxText { + value: StringLiteral, + }, } #[derive(Debug, PartialEq, Eq)] diff --git a/compiler-frontend/parsing/src/parser/expressions.rs b/compiler-frontend/parsing/src/parser/expressions.rs index db8fd6a0b..d7248d7a6 100644 --- a/compiler-frontend/parsing/src/parser/expressions.rs +++ b/compiler-frontend/parsing/src/parser/expressions.rs @@ -452,11 +452,125 @@ fn expression_atom(p: &mut Parser) { expression(p); p.expect(SyntaxKind::RIGHT_PARENTHESIS); m.end(p, SyntaxKind::ExpressionParenthesized); + } else if p.at(SyntaxKind::JSX_OPEN) { + expression_jsx_element(p, m); } else { m.cancel(p); } } +fn expression_jsx_element(p: &mut Parser, mut m: NodeMarker) { + let (self_closing, opening_has_name) = jsx_opening(p); + if !self_closing { + jsx_children(p); + jsx_closing(p, opening_has_name); + } + m.end(p, SyntaxKind::ExpressionJsxElement); +} + +fn jsx_opening(p: &mut Parser) -> (bool, bool) { + let mut m = p.start(); + p.expect(SyntaxKind::JSX_OPEN); + + let has_name = p.eat(SyntaxKind::JSX_NAME); + let is_fragment = !has_name && p.at(SyntaxKind::JSX_TAG_END); + if !has_name && !is_fragment { + p.error("Expected JSX element name"); + } + + while p.at(SyntaxKind::JSX_NAME) { + if is_fragment { + p.error("JSX fragments cannot have attributes"); + } + jsx_attribute(p); + } + + while !p.at(SyntaxKind::JSX_TAG_END) + && !p.at(SyntaxKind::JSX_SELF_CLOSE) + && !p.at(SyntaxKind::JSX_CLOSE_OPEN) + && !p.at_eof() + { + p.error_recover("Unexpected token in JSX opening tag"); + } + + let self_closing = p.eat(SyntaxKind::JSX_SELF_CLOSE); + if self_closing && !has_name { + p.error("Self-closing JSX elements require a name"); + } else if !self_closing { + p.expect(SyntaxKind::JSX_TAG_END); + } + m.end(p, SyntaxKind::JsxOpening); + (self_closing, has_name) +} + +fn jsx_attribute(p: &mut Parser) { + let mut m = p.start(); + p.expect(SyntaxKind::JSX_NAME); + p.expect(SyntaxKind::EQUAL); + if p.at(SyntaxKind::STRING) || p.at(SyntaxKind::RAW_STRING) { + let mut value = p.start(); + p.consume(); + value.end(p, SyntaxKind::ExpressionString); + } else if p.at(SyntaxKind::JSX_EXPRESSION_START) { + expression_jsx_interpolation(p); + } else { + p.error("Expected JSX attribute value"); + } + m.end(p, SyntaxKind::JsxAttribute); +} + +fn jsx_children(p: &mut Parser) { + let mut m = p.start(); + while !p.at(SyntaxKind::JSX_CLOSE_OPEN) && !p.at_eof() { + if p.at(SyntaxKind::JSX_TEXT) { + let mut child = p.start(); + p.consume(); + child.end(p, SyntaxKind::ExpressionJsxText); + } else if p.at(SyntaxKind::JSX_EXPRESSION_START) { + expression_jsx_interpolation(p); + } else if p.at(SyntaxKind::JSX_OPEN) { + let child = p.start(); + expression_jsx_element(p, child); + } else { + p.error_recover("Unexpected token in JSX children"); + } + } + m.end(p, SyntaxKind::JsxChildren); +} + +fn expression_jsx_interpolation(p: &mut Parser) { + let mut m = p.start(); + p.expect(SyntaxKind::JSX_EXPRESSION_START); + if p.at(SyntaxKind::JSX_EXPRESSION_END) { + p.error("JSX interpolation cannot be empty"); + } else if p.at_in(EXPRESSION_START) { + expression(p); + } else { + p.error("Expected expression in JSX interpolation"); + while !p.at(SyntaxKind::JSX_EXPRESSION_END) && !p.at_eof() { + p.error_recover("Unexpected token in JSX interpolation"); + } + } + p.expect(SyntaxKind::JSX_EXPRESSION_END); + m.end(p, SyntaxKind::ExpressionJsxInterpolation); +} + +fn jsx_closing(p: &mut Parser, opening_has_name: bool) { + let mut m = p.start(); + p.expect(SyntaxKind::JSX_CLOSE_OPEN); + let closing_has_name = p.eat(SyntaxKind::JSX_NAME); + if opening_has_name && !closing_has_name { + p.error("Expected JSX closing element name"); + } else if !opening_has_name && closing_has_name { + p.error("JSX fragment closing tag cannot have a name"); + } + while !p.at(SyntaxKind::JSX_CLOSE_END) && !p.at_eof() { + p.error_recover("Unexpected token in JSX closing tag"); + } + p.expect(SyntaxKind::JSX_CLOSE_END); + m.end(p, SyntaxKind::JsxClosing); +} + fn expression_array(p: &mut Parser, mut m: NodeMarker) { while !p.at(SyntaxKind::RIGHT_SQUARE) && !p.at_eof() { if p.at_in(EXPRESSION_START) { @@ -511,6 +625,7 @@ const EXPRESSION_ATOM_START: TokenSet = TokenSet::new(&[ SyntaxKind::LEFT_SQUARE, SyntaxKind::LEFT_CURLY, SyntaxKind::LEFT_PARENTHESIS, + SyntaxKind::JSX_OPEN, ]) .union(names::LOWER) .union(names::OPERATOR_NAME); @@ -528,5 +643,10 @@ const ARGUMENT_START: TokenSet = TokenSet::new(&[ pub(super) const EXPRESSION_START: TokenSet = ARGUMENT_START.union(TokenSet::new(&[SyntaxKind::MINUS])); -const EXPRESSION_ATOM_RECOVERY: TokenSet = - TokenSet::new(&[SyntaxKind::LAYOUT_SEPARATOR, SyntaxKind::LAYOUT_END]); +const EXPRESSION_ATOM_RECOVERY: TokenSet = TokenSet::new(&[ + SyntaxKind::LAYOUT_SEPARATOR, + SyntaxKind::LAYOUT_END, + SyntaxKind::JSX_CLOSE_OPEN, + SyntaxKind::JSX_EXPRESSION_END, + SyntaxKind::END_OF_FILE, +]); diff --git a/compiler-frontend/syntax/src/cst.rs b/compiler-frontend/syntax/src/cst.rs index 53d8d6250..d5e3ee7f1 100644 --- a/compiler-frontend/syntax/src/cst.rs +++ b/compiler-frontend/syntax/src/cst.rs @@ -186,6 +186,9 @@ create_cst_enum!( | ExpressionParenthesized | ExpressionRecordAccess | ExpressionRecordUpdate + | ExpressionJsxElement + | ExpressionJsxText + | ExpressionJsxInterpolation ); create_cst_struct!( @@ -218,6 +221,8 @@ create_cst_struct!(RecordAccessLabel, RecordUpdates); create_cst_enum!(RecordUpdate | RecordUpdateLeaf | RecordUpdateBranch); +create_cst_struct!(JsxOpening, JsxChildren, JsxClosing, JsxAttribute); + create_cst_struct!(TermOperator, TypeOperator); has_child!( @@ -951,6 +956,53 @@ has_child!( | record_updates() -> RecordUpdates ); +has_child!( + ExpressionJsxElement + | opening() -> JsxOpening + | children() -> JsxChildren + | closing() -> JsxClosing +); + +has_token!( + JsxOpening + | name_token() -> JSX_NAME +); + +has_children!( + JsxOpening + | attributes() -> JsxAttribute +); + +has_token!( + JsxClosing + | name_token() -> JSX_NAME +); + +has_token!( + JsxAttribute + | name_token() -> JSX_NAME +); + +has_child!( + JsxAttribute + | expression() -> Expression +); + +has_children!( + JsxChildren + | children() -> Expression +); + +has_token!( + ExpressionJsxText + | text_token() -> JSX_TEXT +); + +has_child!( + ExpressionJsxInterpolation + | expression() -> Expression +); + has_children!( RecordUpdates | children() -> RecordUpdate diff --git a/compiler-frontend/syntax/src/lib.rs b/compiler-frontend/syntax/src/lib.rs index d4c6f5c66..2a0f3d5fb 100644 --- a/compiler-frontend/syntax/src/lib.rs +++ b/compiler-frontend/syntax/src/lib.rs @@ -92,6 +92,18 @@ pub enum SyntaxKind { LAYOUT_END, END_OF_FILE, + // JSX + JSX_OPEN, + JSX_CLOSE_OPEN, + JSX_TAG_END, + JSX_CLOSE_END, + JSX_SELF_CLOSE, + JSX_NAME, + JSX_TEXT, + JSX_EXPRESSION_START, + JSX_EXPRESSION_END, + JSX_INVALID, + // Special ERROR, @@ -217,6 +229,14 @@ pub enum SyntaxKind { ExpressionParenthesized, ExpressionRecordAccess, ExpressionRecordUpdate, + ExpressionJsxElement, + ExpressionJsxText, + ExpressionJsxInterpolation, + + JsxOpening, + JsxChildren, + JsxClosing, + JsxAttribute, CaseTrunk, CaseBranches, diff --git a/compiler-services/iris-diagnostics/src/convert.rs b/compiler-services/iris-diagnostics/src/convert.rs index 72b37d37b..375fc96af 100644 --- a/compiler-services/iris-diagnostics/src/convert.rs +++ b/compiler-services/iris-diagnostics/src/convert.rs @@ -85,7 +85,8 @@ impl ToDiagnostics for FunctionalModuleError { local_global_span(*duplicate) } FunctionalUnsupportedState::InvalidStyleXUse { declaration, .. } - | FunctionalUnsupportedState::InvalidStyleXContext { declaration, .. } => { + | FunctionalUnsupportedState::InvalidStyleXContext { declaration, .. } + | FunctionalUnsupportedState::InvalidReactComponent { declaration } => { local_global_span(*declaration) } _ => None, @@ -142,6 +143,8 @@ impl ToDiagnostics for FunctionalModuleError { format!("'Iris.StyleX.{function}' must be called directly with all of its arguments.\n\nIt cannot be passed around as a function or partially applied."), FunctionalUnsupportedState::InvalidStyleXContext { function, requirement, .. } => format!("'Iris.StyleX.{function}' {requirement}."), + FunctionalUnsupportedState::InvalidReactComponent { .. } => + "'Iris.React.component' must directly initialize a non-recursive top-level value.\n\nReact component identity must remain stable between renders.".to_owned(), FunctionalUnsupportedState::VirtualModuleRuntimeReference { module_name, item_name } => format!("'{module_name}.{item_name}' is a compile-time declaration and cannot be used at runtime."), }; @@ -329,6 +332,9 @@ impl ToDiagnostics for LoweringError { lowering::NotInScope::TypeOperator { id } => { (context.stabilized.syntax_ptr(*id), None) } + lowering::NotInScope::JsxFunction { id, name } => { + (context.stabilized.syntax_ptr(*id), Some(name.as_str())) + } }; let Some(ptr) = ptr else { return Vec::new() }; @@ -371,6 +377,21 @@ impl ToDiagnostics for LoweringError { )] } + LoweringError::JsxTagMismatch { id, expected } => { + let Some(ptr) = context.stabilized.syntax_ptr(*id) else { return Vec::new() }; + let Some(span) = context.span_from_syntax_ptr(&ptr) else { return Vec::new() }; + let expected = expected.as_deref().unwrap_or(""); + let message = format!("Expected JSX closing tag "); + vec![Diagnostic::error("JsxTagMismatch", message, span, "lowering")] + } + + LoweringError::DuplicateJsxAttribute { id, name } => { + let Some(ptr) = context.stabilized.syntax_ptr(*id) else { return Vec::new() }; + let Some(span) = context.span_from_syntax_ptr(&ptr) else { return Vec::new() }; + let message = format!("Duplicate JSX attribute '{name}'"); + vec![Diagnostic::error("DuplicateJsxAttribute", message, span, "lowering")] + } + LoweringError::RecursiveSynonym(group) => convert_recursive_group( context, &group.group, diff --git a/tests-integration/fixtures/compiler/1791000240_jsx_diagnostics/Main.checking.snap b/tests-integration/fixtures/compiler/1791000240_jsx_diagnostics/Main.checking.snap new file mode 100644 index 000000000..92fd4dd1b --- /dev/null +++ b/tests-integration/fixtures/compiler/1791000240_jsx_diagnostics/Main.checking.snap @@ -0,0 +1,22 @@ +--- +source: tests-integration/src/fixtures.rs +assertion_line: 307 +--- +Terms +button :: Iris.React.Component ( count :: Prim.Int ) +test :: Iris.React.JSX +test2 :: Iris.React.JSX +test3 :: Iris.React.JSX +test4 :: Iris.React.JSX +test5 :: ?[missing JSX component] +test6 :: Iris.React.JSX +test7 :: Iris.React.JSX +notCallable :: Prim.Int +test8 :: Iris.React.JSX +test9 :: Iris.React.JSX +test10 :: Iris.React.JSX +test11 :: Iris.React.JSX +child :: Iris.React.Component ( children :: Iris.React.JSX ) +test12 :: Iris.React.JSX + +Types diff --git a/tests-integration/fixtures/compiler/1791000240_jsx_diagnostics/Main.diagnostics.snap b/tests-integration/fixtures/compiler/1791000240_jsx_diagnostics/Main.diagnostics.snap new file mode 100644 index 000000000..01c47e3ed --- /dev/null +++ b/tests-integration/fixtures/compiler/1791000240_jsx_diagnostics/Main.diagnostics.snap @@ -0,0 +1,121 @@ +--- +source: tests-integration/src/fixtures.rs +assertion_line: 308 +--- +Parse error · Main.iris:20:29 · JSX fragment closing tag cannot have a name +Error! · [DuplicateJsxAttribute] · Main.iris:12:28 + • + │ + • Duplicate JSX attribute 'title' + │ + │ test3 =
+ │ ╰───────────── + • + +Error! · [JsxTagMismatch] · Main.iris:14:22 + • + │ + • Expected JSX closing tag
+ │ + │ test4 =
+ │ ╰─────── + • + +Error! · [NotInScope] · Main.iris:16:9 + • + │ + • 'missing' is not in scope + │ + │ test5 = + │ ╰────────── + • + +Error! · [JsxTagMismatch] · Main.iris:20:18 + • + │ + • Expected JSX closing tag + │ + │ test7 = <>
+ │ ╰─────────── + • + +Error! · [DuplicateJsxAttribute] · Main.iris:30:15 + • + │ + • Duplicate JSX attribute 'children' + │ + │ test11 =
duplicate
+ │ ╰───────────────────── + • + +Error! · [CannotUnify] · Main.iris:8:22 + • + │ + • Cannot unify 'String' with 'Int' + │ + │ test =