Skip to content

Commit b3abdf2

Browse files
kt3kbartlomieju
andauthored
feat: support formatting of languages embedded in tagged template literals (Rust API Only) (#701)
Co-authored-by: Bartek Iwańczuk <[email protected]>
1 parent e869d47 commit b3abdf2

File tree

13 files changed

+802
-196
lines changed

13 files changed

+802
-196
lines changed

Cargo.lock

Lines changed: 362 additions & 178 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ harness = false
3131

3232
[dependencies]
3333
anyhow = "1.0.64"
34+
capacity_builder = "0.5.0"
3435
deno_ast = { version = "0.46.3", features = ["view"] }
3536
dprint-core = { version = "0.67.4", features = ["formatting"] }
3637
dprint-core-macros = "0.1.0"
@@ -41,5 +42,8 @@ serde_json = { version = "1.0", optional = true }
4142

4243
[dev-dependencies]
4344
dprint-development = "0.10.1"
45+
dprint-plugin-sql = "0.2.0"
46+
malva = "0.11.1"
47+
markup_fmt = "0.19.0"
4448
pretty_assertions = "1.3.0"
4549
serde_json = { version = "1.0" }

src/format_text.rs

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,17 @@ use crate::swc::ensure_no_specific_syntax_errors;
1010

1111
use super::configuration::Configuration;
1212
use super::generation::generate;
13+
pub use super::generation::ExternalFormatter;
1314
use super::swc::parse_swc_ast;
1415

16+
pub struct FormatTextOptions<'a> {
17+
pub path: &'a Path,
18+
pub extension: Option<&'a str>,
19+
pub text: String,
20+
pub config: &'a Configuration,
21+
pub external_formatter: Option<&'a ExternalFormatter>,
22+
}
23+
1524
/// Formats a file.
1625
///
1726
/// Returns the file text or an error when it failed to parse.
@@ -35,19 +44,32 @@ use super::swc::parse_swc_ast;
3544
/// // now format many files (it is recommended to parallelize this)
3645
/// let files_to_format = vec![(PathBuf::from("path/to/file.ts"), "const t = 5 ;")];
3746
/// for (file_path, file_text) in files_to_format {
38-
/// let result = format_text(&file_path, None, file_text.into(), &config);
47+
/// let result = format_text(FormatTextOptions {
48+
/// path: &file_path,
49+
/// extension: None,
50+
/// text: file_text.into(),
51+
/// config: &config,
52+
/// external_formatter: None,
53+
/// });
3954
/// // save result here...
4055
/// }
4156
/// ```
42-
pub fn format_text(file_path: &Path, file_extension: Option<&str>, file_text: String, config: &Configuration) -> Result<Option<String>> {
57+
pub fn format_text(options: FormatTextOptions) -> Result<Option<String>> {
58+
let FormatTextOptions {
59+
path: file_path,
60+
extension: file_extension,
61+
text: file_text,
62+
config,
63+
external_formatter,
64+
} = options;
4365
if super::utils::file_text_has_ignore_comment(&file_text, &config.ignore_file_comment_text) {
4466
Ok(None)
4567
} else {
4668
let had_bom = file_text.starts_with("\u{FEFF}");
4769
let file_text = if had_bom { file_text[3..].to_string() } else { file_text };
4870
let file_text: Arc<str> = file_text.into();
4971
let parsed_source = parse_swc_ast(file_path, file_extension, file_text)?;
50-
match inner_format(&parsed_source, config)? {
72+
match inner_format(&parsed_source, config, external_formatter)? {
5173
Some(new_text) => Ok(Some(new_text)),
5274
None => {
5375
if had_bom {
@@ -61,20 +83,20 @@ pub fn format_text(file_path: &Path, file_extension: Option<&str>, file_text: St
6183
}
6284

6385
/// Formats an already parsed source. This is useful as a performance optimization.
64-
pub fn format_parsed_source(source: &ParsedSource, config: &Configuration) -> Result<Option<String>> {
86+
pub fn format_parsed_source(source: &ParsedSource, config: &Configuration, external_formatter: Option<&ExternalFormatter>) -> Result<Option<String>> {
6587
if super::utils::file_text_has_ignore_comment(source.text(), &config.ignore_file_comment_text) {
6688
Ok(None)
6789
} else {
6890
ensure_no_specific_syntax_errors(source)?;
69-
inner_format(source, config)
91+
inner_format(source, config, external_formatter)
7092
}
7193
}
7294

73-
fn inner_format(parsed_source: &ParsedSource, config: &Configuration) -> Result<Option<String>> {
95+
fn inner_format(parsed_source: &ParsedSource, config: &Configuration, external_formatter: Option<&ExternalFormatter>) -> Result<Option<String>> {
7496
let result = dprint_core::formatting::format(
7597
|| {
7698
#[allow(clippy::let_and_return)]
77-
let print_items = generate(parsed_source, config);
99+
let print_items = generate(parsed_source, config, external_formatter);
78100
// println!("{}", print_items.get_as_text());
79101
print_items
80102
},
@@ -91,7 +113,7 @@ fn inner_format(parsed_source: &ParsedSource, config: &Configuration) -> Result<
91113
pub fn trace_file(file_path: &Path, file_text: &str, config: &Configuration) -> dprint_core::formatting::TracingResult {
92114
let parsed_source = parse_swc_ast(file_path, None, file_text.into()).unwrap();
93115
ensure_no_specific_syntax_errors(&parsed_source).unwrap();
94-
dprint_core::formatting::trace_printing(|| generate(&parsed_source, config), config_to_print_options(file_text, config))
116+
dprint_core::formatting::trace_printing(|| generate(&parsed_source, config, None), config_to_print_options(file_text, config))
95117
}
96118

97119
fn config_to_print_options(file_text: &str, config: &Configuration) -> PrintOptions {
@@ -111,9 +133,15 @@ mod test {
111133
fn strips_bom() {
112134
for input_text in ["\u{FEFF}const t = 5;\n", "\u{FEFF}const t = 5;"] {
113135
let config = crate::configuration::ConfigurationBuilder::new().build();
114-
let result = format_text(&std::path::PathBuf::from("test.ts"), None, input_text.into(), &config)
115-
.unwrap()
116-
.unwrap();
136+
let result = format_text(FormatTextOptions {
137+
path: &std::path::PathBuf::from("test.ts"),
138+
extension: None,
139+
text: input_text.into(),
140+
config: &config,
141+
external_formatter: None,
142+
})
143+
.unwrap()
144+
.unwrap();
117145
assert_eq!(result, "const t = 5;\n");
118146
}
119147
}

src/generation/context.rs

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,41 @@ use super::*;
1818
use crate::configuration::*;
1919
use crate::utils::Stack;
2020

21+
/// A callback that will be called when encountering certain tagged templates.
22+
///
23+
/// Currently supports `css`, `html` and `sql` tagged templated.
24+
///
25+
/// Examples:
26+
/// ```ignore
27+
/// const styles = css`color: red;`;
28+
///
29+
/// const markup = html`<html>
30+
/// <body>
31+
/// <h1>Hello!<h1>
32+
/// </body>
33+
/// </html>`;
34+
///
35+
/// const query = sql`
36+
/// SELECT
37+
/// *
38+
/// FROM
39+
/// users
40+
/// WHERE
41+
/// active IS TRUE;
42+
/// ```
43+
///
44+
/// External formatter should return `None` if it doesn't understand given `MediaType`, in such
45+
/// cases the templates will be left as they are.
46+
///
47+
/// Only templates with no interpolation are supported.
48+
pub type ExternalFormatter = dyn Fn(MediaType, String, &Configuration) -> Option<String>;
49+
2150
pub struct Context<'a> {
2251
pub media_type: MediaType,
2352
pub program: Program<'a>,
2453
pub config: &'a Configuration,
2554
pub comments: CommentTracker<'a>,
55+
pub external_formatter: Option<&'a ExternalFormatter>,
2656
pub token_finder: TokenFinder<'a>,
2757
pub current_node: Node<'a>,
2858
pub parent_stack: Stack<Node<'a>>,
@@ -43,12 +73,20 @@ pub struct Context<'a> {
4373
}
4474

4575
impl<'a> Context<'a> {
46-
pub fn new(media_type: MediaType, tokens: &'a [TokenAndSpan], current_node: Node<'a>, program: Program<'a>, config: &'a Configuration) -> Context<'a> {
76+
pub fn new(
77+
media_type: MediaType,
78+
tokens: &'a [TokenAndSpan],
79+
current_node: Node<'a>,
80+
program: Program<'a>,
81+
config: &'a Configuration,
82+
external_formatter: Option<&'a ExternalFormatter>,
83+
) -> Context<'a> {
4784
Context {
4885
media_type,
4986
program,
5087
config,
5188
comments: CommentTracker::new(program, tokens),
89+
external_formatter,
5290
token_finder: TokenFinder::new(program),
5391
current_node,
5492
parent_stack: Default::default(),

src/generation/generate.rs

Lines changed: 105 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,20 @@ use super::*;
2525
use crate::configuration::*;
2626
use crate::utils;
2727

28-
pub fn generate(parsed_source: &ParsedSource, config: &Configuration) -> PrintItems {
28+
pub fn generate(parsed_source: &ParsedSource, config: &Configuration, external_formatter: Option<&ExternalFormatter>) -> PrintItems {
2929
// eprintln!("Leading: {:?}", parsed_source.comments().leading_map());
3030
// eprintln!("Trailing: {:?}", parsed_source.comments().trailing_map());
3131

3232
parsed_source.with_view(|program| {
3333
let program_node = program.into();
34-
let mut context = Context::new(parsed_source.media_type(), parsed_source.tokens(), program_node, program, config);
34+
let mut context = Context::new(
35+
parsed_source.media_type(),
36+
parsed_source.tokens(),
37+
program_node,
38+
program,
39+
config,
40+
external_formatter,
41+
);
3542
let mut items = gen_node(program_node, &mut context);
3643
items.push_condition(if_true(
3744
"endOfFileNewLine",
@@ -3001,6 +3008,97 @@ fn gen_spread_element<'a>(node: &SpreadElement<'a>, context: &mut Context<'a>) -
30013008
items
30023009
}
30033010

3011+
/// Formats the tagged template literal using an external formatter.
3012+
/// Detects the type of embedded language automatically.
3013+
fn maybe_gen_tagged_tpl_with_external_formatter<'a>(node: &TaggedTpl<'a>, context: &mut Context<'a>) -> Option<PrintItems> {
3014+
let external_formatter = context.external_formatter.as_ref()?;
3015+
let media_type = detect_embedded_language_type(node)?;
3016+
3017+
// First creates text with placeholders for the expressions.
3018+
let placeholder_text = "dpr1nt_";
3019+
let text = capacity_builder::StringBuilder::<String>::build(|builder| {
3020+
let expr_len = node.tpl.exprs.len();
3021+
for (i, quasi) in node.tpl.quasis.iter().enumerate() {
3022+
builder.append(quasi.raw().as_str());
3023+
if i < expr_len {
3024+
builder.append(placeholder_text);
3025+
if i < 10 {
3026+
// increase chance all placeholders have the same length
3027+
builder.append("0");
3028+
}
3029+
builder.append(i); // give each placeholder a unique name so the formatter doesn't remove duplicates
3030+
builder.append("_d");
3031+
}
3032+
}
3033+
})
3034+
.unwrap();
3035+
3036+
// Then formats the text with the external formatter.
3037+
let formatted_tpl = external_formatter(media_type, text, context.config)?;
3038+
3039+
let mut items = PrintItems::new();
3040+
items.push_sc(sc!("`"));
3041+
items.push_signal(Signal::NewLine);
3042+
items.push_signal(Signal::StartIndent);
3043+
let mut index = 0;
3044+
for line in formatted_tpl.lines() {
3045+
let mut pos = 0;
3046+
let mut parts = line.split(placeholder_text).enumerate().peekable();
3047+
while let Some((i, part)) = parts.next() {
3048+
let end = pos + part.len();
3049+
if i > 0 {
3050+
pos += part.find("_d").unwrap() + 2;
3051+
}
3052+
let text = &line[pos..end];
3053+
if !text.is_empty() {
3054+
items.push_string(text.to_string());
3055+
}
3056+
if parts.peek().is_some() {
3057+
items.push_sc(sc!("${"));
3058+
items.extend(gen_node(node.tpl.exprs[index].into(), context));
3059+
items.push_sc(sc!("}"));
3060+
pos = end + placeholder_text.len();
3061+
index += 1;
3062+
}
3063+
}
3064+
items.push_signal(Signal::NewLine);
3065+
}
3066+
items.push_signal(Signal::FinishIndent);
3067+
items.push_sc(sc!("`"));
3068+
Some(items)
3069+
}
3070+
3071+
/// Detects the type of embedded language in a tagged template literal.
3072+
fn detect_embedded_language_type<'a>(node: &TaggedTpl<'a>) -> Option<MediaType> {
3073+
match node.tag {
3074+
Expr::Ident(ident) => {
3075+
match ident.sym().as_str() {
3076+
"css" => Some(MediaType::Css), // css`...`
3077+
"html" => Some(MediaType::Html), // html`...`
3078+
"sql" => Some(MediaType::Sql), // sql`...`
3079+
_ => None,
3080+
}
3081+
}
3082+
Expr::Member(member_expr) => {
3083+
if let Expr::Ident(ident) = member_expr.obj {
3084+
if ident.sym().as_str() == "styled" {
3085+
return Some(MediaType::Css); // styled.foo`...`
3086+
}
3087+
}
3088+
None
3089+
}
3090+
Expr::Call(call_expr) => {
3091+
if let Callee::Expr(Expr::Ident(ident)) = call_expr.callee {
3092+
if ident.sym().as_str() == "styled" {
3093+
return Some(MediaType::Css); // styled(Button)`...`
3094+
}
3095+
}
3096+
None
3097+
}
3098+
_ => None,
3099+
}
3100+
}
3101+
30043102
fn gen_tagged_tpl<'a>(node: &TaggedTpl<'a>, context: &mut Context<'a>) -> PrintItems {
30053103
let use_space = context.config.tagged_template_space_before_literal;
30063104
let mut items = gen_node(node.tag.into(), context);
@@ -3017,6 +3115,11 @@ fn gen_tagged_tpl<'a>(node: &TaggedTpl<'a>, context: &mut Context<'a>) -> PrintI
30173115
items.extend(generated_between_comments);
30183116
}
30193117

3118+
if let Some(formatted_tpl) = maybe_gen_tagged_tpl_with_external_formatter(node, context) {
3119+
items.push_condition(conditions::indent_if_start_of_line(formatted_tpl));
3120+
return items;
3121+
}
3122+
30203123
items.push_condition(conditions::indent_if_start_of_line(gen_node(node.tpl.into(), context)));
30213124
items
30223125
}

src/generation/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,5 @@ use context::*;
1212
use generate_types::*;
1313
use tokens::*;
1414

15+
pub use context::ExternalFormatter;
1516
pub use generate::generate;

src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ mod utils;
1818

1919
pub use format_text::format_parsed_source;
2020
pub use format_text::format_text;
21+
pub use format_text::ExternalFormatter;
22+
pub use format_text::FormatTextOptions;
2123

2224
#[cfg(feature = "tracing")]
2325
pub use format_text::trace_file;

src/swc.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,6 @@ Merge conflict marker encountered. at file:///test.ts:6:1
359359
// a source file that had a non-fatal diagnostic
360360
let parsed_source = parse_inner_no_diagnostic_check(&file_path, None, text.into()).unwrap();
361361
let config = ConfigurationBuilder::new().build();
362-
assert_eq!(crate::format_parsed_source(&parsed_source, &config).err().unwrap().to_string(), expected);
362+
assert_eq!(crate::format_parsed_source(&parsed_source, &config, None).err().unwrap().to_string(), expected);
363363
}
364364
}

src/wasm_plugin.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,15 @@ impl SyncPluginHandler<Configuration> for TypeScriptPluginHandler {
6060

6161
fn format(&mut self, request: SyncFormatRequest<Configuration>, _format_with_host: impl FnMut(SyncHostFormatRequest) -> FormatResult) -> FormatResult {
6262
let file_text = String::from_utf8(request.file_bytes)?;
63-
super::format_text(request.file_path, None, file_text, request.config).map(|maybe_text| maybe_text.map(|t| t.into_bytes()))
63+
super::format_text(super::FormatTextOptions {
64+
path: request.file_path,
65+
extension: None,
66+
text: file_text,
67+
config: request.config,
68+
// todo: support this in Wasm
69+
external_formatter: None,
70+
})
71+
.map(|maybe_text| maybe_text.map(|t| t.into_bytes()))
6472
}
6573
}
6674

0 commit comments

Comments
 (0)