From 53248e9bb3123a1b684f3f9f744bb671dfa53bc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Wed, 1 Nov 2023 14:42:54 +0100 Subject: [PATCH] fix(repl): support transforming JSX/TSX (#20695) Closes https://github.com/denoland/deno/issues/16771 --------- Co-authored-by: David Sherret Co-authored-by: Marvin Hagemeister --- cli/tests/integration/repl_tests.rs | 11 +- cli/tools/repl/session.rs | 220 ++++++++++++++++++++++++++-- 2 files changed, 210 insertions(+), 21 deletions(-) diff --git a/cli/tests/integration/repl_tests.rs b/cli/tests/integration/repl_tests.rs index a6524f7181..1cdc625b25 100644 --- a/cli/tests/integration/repl_tests.rs +++ b/cli/tests/integration/repl_tests.rs @@ -497,11 +497,10 @@ fn syntax_error() { } #[test] -fn syntax_error_jsx() { - // JSX is not supported in the REPL +fn jsx_errors_without_pragma() { util::with_pty(&["repl"], |mut console| { console.write_line("const element =
;"); - console.expect("Expression expected"); + console.expect("React is not defined"); }); } @@ -897,11 +896,11 @@ fn repl_unit_tests() { console.expect("test1 ... ok ("); console.expect("test2 ... FAILED ("); console.expect(" ERRORS "); - console.expect("test2 => :7:6"); + console.expect("test2 => :6:6"); console.expect("error: Error: some message"); - console.expect(" at :8:9"); + console.expect(" at :7:9"); console.expect(" FAILURES "); - console.expect("test2 => :7:6"); + console.expect("test2 => :6:6"); console.expect("FAILED | 1 passed (1 step) | 1 failed ("); console.expect("undefined"); diff --git a/cli/tools/repl/session.rs b/cli/tools/repl/session.rs index 1bf813b933..66a39cd892 100644 --- a/cli/tools/repl/session.rs +++ b/cli/tools/repl/session.rs @@ -18,12 +18,17 @@ use crate::tools::test::TestEvent; use crate::tools::test::TestEventSender; use deno_ast::swc::ast as swc_ast; +use deno_ast::swc::common::comments::CommentKind; use deno_ast::swc::visit::noop_visit_type; use deno_ast::swc::visit::Visit; use deno_ast::swc::visit::VisitWith; use deno_ast::DiagnosticsError; use deno_ast::ImportsNotUsedAsValues; use deno_ast::ModuleSpecifier; +use deno_ast::ParsedSource; +use deno_ast::SourcePos; +use deno_ast::SourceRangedForSpanned; +use deno_ast::SourceTextInfo; use deno_core::error::AnyError; use deno_core::futures::channel::mpsc::UnboundedReceiver; use deno_core::futures::FutureExt; @@ -34,12 +39,40 @@ use deno_core::unsync::spawn; use deno_core::LocalInspectorSession; use deno_graph::source::ResolutionMode; use deno_graph::source::Resolver; +use deno_graph::Position; +use deno_graph::PositionRange; +use deno_graph::SpecifierWithRange; use deno_runtime::worker::MainWorker; use deno_semver::npm::NpmPackageReqReference; use once_cell::sync::Lazy; +use regex::Match; +use regex::Regex; use super::cdp; +fn comment_source_to_position_range( + comment_start: SourcePos, + m: &Match, + text_info: &SourceTextInfo, + is_jsx_import_source: bool, +) -> PositionRange { + // the comment text starts after the double slash or slash star, so add 2 + let comment_start = comment_start + 2; + // -1 and +1 to include the quotes, but not for jsx import sources because + // they don't have quotes + let padding = if is_jsx_import_source { 0 } else { 1 }; + PositionRange { + start: Position::from_source_pos( + comment_start + m.start() - padding, + text_info, + ), + end: Position::from_source_pos( + comment_start + m.end() + padding, + text_info, + ), + } +} + /// We store functions used in the repl on this object because /// the user might modify the `Deno` global or delete it outright. pub static REPL_INTERNALS_NAME: Lazy = Lazy::new(|| { @@ -131,6 +164,12 @@ pub struct TsEvaluateResponse { pub value: cdp::EvaluateResponse, } +struct ReplJsxState { + factory: String, + frag_factory: String, + import_source: Option, +} + pub struct ReplSession { npm_resolver: Arc, resolver: Arc, @@ -145,6 +184,7 @@ pub struct ReplSession { test_event_sender: TestEventSender, /// This is only optional because it's temporarily taken when evaluating. test_event_receiver: Option>, + jsx: ReplJsxState, } impl ReplSession { @@ -212,6 +252,11 @@ impl ReplSession { main_module, test_event_sender, test_event_receiver: Some(test_event_receiver), + jsx: ReplJsxState { + factory: "React.createElement".to_string(), + frag_factory: "React.Fragment".to_string(), + import_source: None, + }, }; // inject prelude @@ -515,32 +560,39 @@ impl ReplSession { &mut self, expression: &str, ) -> Result { - let parsed_module = deno_ast::parse_module(deno_ast::ParseParams { - specifier: "repl.ts".to_string(), - text_info: deno_ast::SourceTextInfo::from_string(expression.to_string()), - media_type: deno_ast::MediaType::TypeScript, - capture_tokens: false, - maybe_syntax: None, - scope_analysis: false, - })?; + let parsed_source = + match parse_source_as(expression.to_string(), deno_ast::MediaType::Tsx) { + Ok(parsed) => parsed, + Err(err) => { + if let Ok(parsed) = parse_source_as( + expression.to_string(), + deno_ast::MediaType::TypeScript, + ) { + parsed + } else { + return Err(err); + } + } + }; self - .check_for_npm_or_node_imports(&parsed_module.program()) + .check_for_npm_or_node_imports(&parsed_source.program()) .await?; - let transpiled_src = parsed_module + self.analyze_and_handle_jsx(&parsed_source); + + let transpiled_src = parsed_source .transpile(&deno_ast::EmitOptions { emit_metadata: false, source_map: false, inline_source_map: false, inline_sources: false, imports_not_used_as_values: ImportsNotUsedAsValues::Preserve, - // JSX is not supported in the REPL - transform_jsx: false, + transform_jsx: true, jsx_automatic: false, jsx_development: false, - jsx_factory: "React.createElement".into(), - jsx_fragment_factory: "React.Fragment".into(), + jsx_factory: self.jsx.factory.clone(), + jsx_fragment_factory: self.jsx.frag_factory.clone(), jsx_import_source: None, precompile_jsx: false, var_decl_imports: true, @@ -548,7 +600,7 @@ impl ReplSession { .text; let value = self - .evaluate_expression(&format!("'use strict'; void 0;\n{transpiled_src}")) + .evaluate_expression(&format!("'use strict'; void 0;{transpiled_src}")) .await?; Ok(TsEvaluateResponse { @@ -557,6 +609,26 @@ impl ReplSession { }) } + fn analyze_and_handle_jsx(&mut self, parsed_source: &ParsedSource) { + let Some(analyzed_pragmas) = analyze_jsx_pragmas(parsed_source) else { + return; + }; + + if !analyzed_pragmas.has_any() { + return; + } + + if let Some(jsx) = analyzed_pragmas.jsx { + self.jsx.factory = jsx.text; + } + if let Some(jsx_frag) = analyzed_pragmas.jsx_fragment { + self.jsx.frag_factory = jsx_frag.text; + } + if let Some(jsx_import_source) = analyzed_pragmas.jsx_import_source { + self.jsx.import_source = Some(jsx_import_source.text); + } + } + async fn check_for_npm_or_node_imports( &mut self, program: &swc_ast::Program, @@ -679,3 +751,121 @@ impl Visit for ImportCollector { } } } + +fn parse_source_as( + source: String, + media_type: deno_ast::MediaType, +) -> Result { + let specifier = if media_type == deno_ast::MediaType::Tsx { + "repl.tsx" + } else { + "repl.ts" + }; + + let parsed = deno_ast::parse_module(deno_ast::ParseParams { + specifier: specifier.to_string(), + text_info: deno_ast::SourceTextInfo::from_string(source), + media_type, + capture_tokens: true, + maybe_syntax: None, + scope_analysis: false, + })?; + + Ok(parsed) +} + +// TODO(bartlomieju): remove these and use regexes from `deno_graph` +/// Matches the `@jsxImportSource` pragma. +static JSX_IMPORT_SOURCE_RE: Lazy = + Lazy::new(|| Regex::new(r"(?i)^[\s*]*@jsxImportSource\s+(\S+)").unwrap()); +/// Matches the `@jsx` pragma. +static JSX_RE: Lazy = + Lazy::new(|| Regex::new(r"(?i)^[\s*]*@jsx\s+(\S+)").unwrap()); +/// Matches the `@jsxFrag` pragma. +static JSX_FRAG_RE: Lazy = + Lazy::new(|| Regex::new(r"(?i)^[\s*]*@jsxFrag\s+(\S+)").unwrap()); + +#[derive(Default, Debug)] +struct AnalyzedJsxPragmas { + /// Information about `@jsxImportSource` pragma. + jsx_import_source: Option, + + /// Matches the `@jsx` pragma. + jsx: Option, + + /// Matches the `@jsxFrag` pragma. + jsx_fragment: Option, +} + +impl AnalyzedJsxPragmas { + fn has_any(&self) -> bool { + self.jsx_import_source.is_some() + || self.jsx.is_some() + || self.jsx_fragment.is_some() + } +} + +/// Analyze provided source and return information about carious pragmas +/// used to configure the JSX tranforms. +fn analyze_jsx_pragmas( + parsed_source: &ParsedSource, +) -> Option { + if !matches!( + parsed_source.media_type(), + deno_ast::MediaType::Jsx | deno_ast::MediaType::Tsx + ) { + return None; + } + + let mut analyzed_pragmas = AnalyzedJsxPragmas::default(); + + for c in parsed_source.get_leading_comments().iter() { + if c.kind != CommentKind::Block { + continue; // invalid + } + + if let Some(captures) = JSX_IMPORT_SOURCE_RE.captures(&c.text) { + if let Some(m) = captures.get(1) { + analyzed_pragmas.jsx_import_source = Some(SpecifierWithRange { + text: m.as_str().to_string(), + range: comment_source_to_position_range( + c.start(), + &m, + parsed_source.text_info(), + true, + ), + }); + } + } + + if let Some(captures) = JSX_RE.captures(&c.text) { + if let Some(m) = captures.get(1) { + analyzed_pragmas.jsx = Some(SpecifierWithRange { + text: m.as_str().to_string(), + range: comment_source_to_position_range( + c.start(), + &m, + parsed_source.text_info(), + false, + ), + }); + } + } + + if let Some(captures) = JSX_FRAG_RE.captures(&c.text) { + if let Some(m) = captures.get(1) { + analyzed_pragmas.jsx_fragment = Some(SpecifierWithRange { + text: m.as_str().to_string(), + range: comment_source_to_position_range( + c.start(), + &m, + parsed_source.text_info(), + false, + ), + }); + } + } + } + + Some(analyzed_pragmas) +}