Skip to content

feat: support formatting of languages embedded in tagged template literals (Rust API Only) #701

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 28 commits into from
Apr 25, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
540 changes: 362 additions & 178 deletions Cargo.lock

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ harness = false

[dependencies]
anyhow = "1.0.64"
capacity_builder = "0.5.0"
deno_ast = { version = "0.46.3", features = ["view"] }
dprint-core = { version = "0.67.4", features = ["formatting"] }
dprint-core-macros = "0.1.0"
Expand All @@ -41,5 +42,8 @@ serde_json = { version = "1.0", optional = true }

[dev-dependencies]
dprint-development = "0.10.1"
dprint-plugin-sql = "0.2.0"
malva = "0.11.1"
markup_fmt = "0.19.0"
pretty_assertions = "1.3.0"
serde_json = { version = "1.0" }
50 changes: 39 additions & 11 deletions src/format_text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,17 @@ use crate::swc::ensure_no_specific_syntax_errors;

use super::configuration::Configuration;
use super::generation::generate;
pub use super::generation::ExternalFormatter;
use super::swc::parse_swc_ast;

pub struct FormatTextOptions<'a> {
pub path: &'a Path,
pub extension: Option<&'a str>,
pub text: String,
pub config: &'a Configuration,
pub external_formatter: Option<&'a ExternalFormatter>,
}

/// Formats a file.
///
/// Returns the file text or an error when it failed to parse.
Expand All @@ -35,19 +44,32 @@ use super::swc::parse_swc_ast;
/// // now format many files (it is recommended to parallelize this)
/// let files_to_format = vec![(PathBuf::from("path/to/file.ts"), "const t = 5 ;")];
/// for (file_path, file_text) in files_to_format {
/// let result = format_text(&file_path, None, file_text.into(), &config);
/// let result = format_text(FormatTextOptions {
/// path: &file_path,
/// extension: None,
/// text: file_text.into(),
/// config: &config,
/// external_formatter: None,
/// });
/// // save result here...
/// }
/// ```
pub fn format_text(file_path: &Path, file_extension: Option<&str>, file_text: String, config: &Configuration) -> Result<Option<String>> {
pub fn format_text(options: FormatTextOptions) -> Result<Option<String>> {
let FormatTextOptions {
path: file_path,
extension: file_extension,
text: file_text,
config,
external_formatter,
} = options;
if super::utils::file_text_has_ignore_comment(&file_text, &config.ignore_file_comment_text) {
Ok(None)
} else {
let had_bom = file_text.starts_with("\u{FEFF}");
let file_text = if had_bom { file_text[3..].to_string() } else { file_text };
let file_text: Arc<str> = file_text.into();
let parsed_source = parse_swc_ast(file_path, file_extension, file_text)?;
match inner_format(&parsed_source, config)? {
match inner_format(&parsed_source, config, external_formatter)? {
Some(new_text) => Ok(Some(new_text)),
None => {
if had_bom {
Expand All @@ -61,20 +83,20 @@ pub fn format_text(file_path: &Path, file_extension: Option<&str>, file_text: St
}

/// Formats an already parsed source. This is useful as a performance optimization.
pub fn format_parsed_source(source: &ParsedSource, config: &Configuration) -> Result<Option<String>> {
pub fn format_parsed_source(source: &ParsedSource, config: &Configuration, external_formatter: Option<&ExternalFormatter>) -> Result<Option<String>> {
if super::utils::file_text_has_ignore_comment(source.text(), &config.ignore_file_comment_text) {
Ok(None)
} else {
ensure_no_specific_syntax_errors(source)?;
inner_format(source, config)
inner_format(source, config, external_formatter)
}
}

fn inner_format(parsed_source: &ParsedSource, config: &Configuration) -> Result<Option<String>> {
fn inner_format(parsed_source: &ParsedSource, config: &Configuration, external_formatter: Option<&ExternalFormatter>) -> Result<Option<String>> {
let result = dprint_core::formatting::format(
|| {
#[allow(clippy::let_and_return)]
let print_items = generate(parsed_source, config);
let print_items = generate(parsed_source, config, external_formatter);
// println!("{}", print_items.get_as_text());
print_items
},
Expand All @@ -91,7 +113,7 @@ fn inner_format(parsed_source: &ParsedSource, config: &Configuration) -> Result<
pub fn trace_file(file_path: &Path, file_text: &str, config: &Configuration) -> dprint_core::formatting::TracingResult {
let parsed_source = parse_swc_ast(file_path, None, file_text.into()).unwrap();
ensure_no_specific_syntax_errors(&parsed_source).unwrap();
dprint_core::formatting::trace_printing(|| generate(&parsed_source, config), config_to_print_options(file_text, config))
dprint_core::formatting::trace_printing(|| generate(&parsed_source, config, None), config_to_print_options(file_text, config))
}

fn config_to_print_options(file_text: &str, config: &Configuration) -> PrintOptions {
Expand All @@ -111,9 +133,15 @@ mod test {
fn strips_bom() {
for input_text in ["\u{FEFF}const t = 5;\n", "\u{FEFF}const t = 5;"] {
let config = crate::configuration::ConfigurationBuilder::new().build();
let result = format_text(&std::path::PathBuf::from("test.ts"), None, input_text.into(), &config)
.unwrap()
.unwrap();
let result = format_text(FormatTextOptions {
path: &std::path::PathBuf::from("test.ts"),
extension: None,
text: input_text.into(),
config: &config,
external_formatter: None,
})
.unwrap()
.unwrap();
assert_eq!(result, "const t = 5;\n");
}
}
Expand Down
40 changes: 39 additions & 1 deletion src/generation/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,41 @@ use super::*;
use crate::configuration::*;
use crate::utils::Stack;

/// A callback that will be called when encountering certain tagged templates.
///
/// Currently supports `css`, `html` and `sql` tagged templated.
///
/// Examples:
/// ```ignore
/// const styles = css`color: red;`;
///
/// const markup = html`<html>
/// <body>
/// <h1>Hello!<h1>
/// </body>
/// </html>`;
///
/// const query = sql`
/// SELECT
/// *
/// FROM
/// users
/// WHERE
/// active IS TRUE;
/// ```
///
/// External formatter should return `None` if it doesn't understand given `MediaType`, in such
/// cases the templates will be left as they are.
///
/// Only templates with no interpolation are supported.
pub type ExternalFormatter = dyn Fn(MediaType, String, &Configuration) -> Option<String>;

pub struct Context<'a> {
pub media_type: MediaType,
pub program: Program<'a>,
pub config: &'a Configuration,
pub comments: CommentTracker<'a>,
pub external_formatter: Option<&'a ExternalFormatter>,
pub token_finder: TokenFinder<'a>,
pub current_node: Node<'a>,
pub parent_stack: Stack<Node<'a>>,
Expand All @@ -43,12 +73,20 @@ pub struct Context<'a> {
}

impl<'a> Context<'a> {
pub fn new(media_type: MediaType, tokens: &'a [TokenAndSpan], current_node: Node<'a>, program: Program<'a>, config: &'a Configuration) -> Context<'a> {
pub fn new(
media_type: MediaType,
tokens: &'a [TokenAndSpan],
current_node: Node<'a>,
program: Program<'a>,
config: &'a Configuration,
external_formatter: Option<&'a ExternalFormatter>,
) -> Context<'a> {
Context {
media_type,
program,
config,
comments: CommentTracker::new(program, tokens),
external_formatter,
token_finder: TokenFinder::new(program),
current_node,
parent_stack: Default::default(),
Expand Down
107 changes: 105 additions & 2 deletions src/generation/generate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,20 @@ use super::*;
use crate::configuration::*;
use crate::utils;

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

parsed_source.with_view(|program| {
let program_node = program.into();
let mut context = Context::new(parsed_source.media_type(), parsed_source.tokens(), program_node, program, config);
let mut context = Context::new(
parsed_source.media_type(),
parsed_source.tokens(),
program_node,
program,
config,
external_formatter,
);
let mut items = gen_node(program_node, &mut context);
items.push_condition(if_true(
"endOfFileNewLine",
Expand Down Expand Up @@ -3001,6 +3008,97 @@ fn gen_spread_element<'a>(node: &SpreadElement<'a>, context: &mut Context<'a>) -
items
}

/// Formats the tagged template literal using an external formatter.
/// Detects the type of embedded language automatically.
fn maybe_gen_tagged_tpl_with_external_formatter<'a>(node: &TaggedTpl<'a>, context: &mut Context<'a>) -> Option<PrintItems> {
let external_formatter = context.external_formatter.as_ref()?;
let media_type = detect_embedded_language_type(node)?;

// First creates text with placeholders for the expressions.
let placeholder_text = "dpr1nt_";
let text = capacity_builder::StringBuilder::<String>::build(|builder| {
let expr_len = node.tpl.exprs.len();
for (i, quasi) in node.tpl.quasis.iter().enumerate() {
builder.append(quasi.raw().as_str());
if i < expr_len {
builder.append(placeholder_text);
if i < 10 {
// increase chance all placeholders have the same length
builder.append("0");
}
builder.append(i); // give each placeholder a unique name so the formatter doesn't remove duplicates
builder.append("_d");
}
}
})
.unwrap();

// Then formats the text with the external formatter.
let formatted_tpl = external_formatter(media_type, text, context.config)?;

let mut items = PrintItems::new();
items.push_sc(sc!("`"));
items.push_signal(Signal::NewLine);
items.push_signal(Signal::StartIndent);
let mut index = 0;
for line in formatted_tpl.lines() {
let mut pos = 0;
let mut parts = line.split(placeholder_text).enumerate().peekable();
while let Some((i, part)) = parts.next() {
let end = pos + part.len();
if i > 0 {
pos += part.find("_d").unwrap() + 2;
}
let text = &line[pos..end];
if !text.is_empty() {
items.push_string(text.to_string());
}
if parts.peek().is_some() {
items.push_sc(sc!("${"));
items.extend(gen_node(node.tpl.exprs[index].into(), context));
items.push_sc(sc!("}"));
pos = end + placeholder_text.len();
index += 1;
}
}
items.push_signal(Signal::NewLine);
}
items.push_signal(Signal::FinishIndent);
items.push_sc(sc!("`"));
Some(items)
}

/// Detects the type of embedded language in a tagged template literal.
fn detect_embedded_language_type<'a>(node: &TaggedTpl<'a>) -> Option<MediaType> {
match node.tag {
Expr::Ident(ident) => {
match ident.sym().as_str() {
"css" => Some(MediaType::Css), // css`...`
"html" => Some(MediaType::Html), // html`...`
"sql" => Some(MediaType::Sql), // sql`...`
_ => None,
}
}
Expr::Member(member_expr) => {
if let Expr::Ident(ident) = member_expr.obj {
if ident.sym().as_str() == "styled" {
return Some(MediaType::Css); // styled.foo`...`
}
}
None
}
Expr::Call(call_expr) => {
if let Callee::Expr(Expr::Ident(ident)) = call_expr.callee {
if ident.sym().as_str() == "styled" {
return Some(MediaType::Css); // styled(Button)`...`
}
}
None
}
_ => None,
}
}

fn gen_tagged_tpl<'a>(node: &TaggedTpl<'a>, context: &mut Context<'a>) -> PrintItems {
let use_space = context.config.tagged_template_space_before_literal;
let mut items = gen_node(node.tag.into(), context);
Expand All @@ -3017,6 +3115,11 @@ fn gen_tagged_tpl<'a>(node: &TaggedTpl<'a>, context: &mut Context<'a>) -> PrintI
items.extend(generated_between_comments);
}

if let Some(formatted_tpl) = maybe_gen_tagged_tpl_with_external_formatter(node, context) {
items.push_condition(conditions::indent_if_start_of_line(formatted_tpl));
return items;
}

items.push_condition(conditions::indent_if_start_of_line(gen_node(node.tpl.into(), context)));
items
}
Expand Down
1 change: 1 addition & 0 deletions src/generation/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ use context::*;
use generate_types::*;
use tokens::*;

pub use context::ExternalFormatter;
pub use generate::generate;
2 changes: 2 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ mod utils;

pub use format_text::format_parsed_source;
pub use format_text::format_text;
pub use format_text::ExternalFormatter;
pub use format_text::FormatTextOptions;

#[cfg(feature = "tracing")]
pub use format_text::trace_file;
Expand Down
2 changes: 1 addition & 1 deletion src/swc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,6 @@ Merge conflict marker encountered. at file:///test.ts:6:1
// a source file that had a non-fatal diagnostic
let parsed_source = parse_inner_no_diagnostic_check(&file_path, None, text.into()).unwrap();
let config = ConfigurationBuilder::new().build();
assert_eq!(crate::format_parsed_source(&parsed_source, &config).err().unwrap().to_string(), expected);
assert_eq!(crate::format_parsed_source(&parsed_source, &config, None).err().unwrap().to_string(), expected);
}
}
10 changes: 9 additions & 1 deletion src/wasm_plugin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,15 @@ impl SyncPluginHandler<Configuration> for TypeScriptPluginHandler {

fn format(&mut self, request: SyncFormatRequest<Configuration>, _format_with_host: impl FnMut(SyncHostFormatRequest) -> FormatResult) -> FormatResult {
let file_text = String::from_utf8(request.file_bytes)?;
super::format_text(request.file_path, None, file_text, request.config).map(|maybe_text| maybe_text.map(|t| t.into_bytes()))
super::format_text(super::FormatTextOptions {
path: request.file_path,
extension: None,
text: file_text,
config: request.config,
// todo: support this in Wasm
external_formatter: None,
})
.map(|maybe_text| maybe_text.map(|t| t.into_bytes()))
}
}

Expand Down
Loading
Loading