Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
Prev Previous commit
fix issues too
  • Loading branch information
ematipico committed Mar 9, 2026
commit ddbe3602355f1e1d435adfb6549a54de8648556e
5 changes: 5 additions & 0 deletions .changeset/fix-embedded-template-crash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@biomejs/biome": patch
---

Fixed [#9131](https://github.com/biomejs/biome/issues/9131), [#9112](https://github.com/biomejs/biome/issues/9112), [#9166](https://github.com/biomejs/biome/issues/9166): the formatter no longer crashes or produces corrupt output when a JS file with `experimentalEmbeddedSnippetsEnabled` contains non-embedded template literals alongside embedded ones (e.g. `console.log(\`test\`)` next to `graphql(\`...\`)`).
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
use crate::prelude::*;
use biome_formatter::write;

use biome_js_syntax::{JsSyntaxToken, JsTemplateChunkElement, TsTemplateChunkElement};
use biome_rowan::{SyntaxResult, declare_node_union};
use biome_js_syntax::{
AnyJsExpression, JsCallArgumentList, JsCallArguments, JsCallExpression, JsSyntaxToken,
JsTemplateChunkElement, JsTemplateExpression, TsTemplateChunkElement,
};
use biome_rowan::{AstNode, SyntaxResult, declare_node_union};
use biome_text_size::TextRange;

#[derive(Debug, Clone, Default)]
Expand All @@ -25,10 +28,96 @@ impl FormatNodeRule<JsTemplateChunkElement> for FormatJsTemplateChunkElement {
if !f.context().should_delegate_fmt_embedded_nodes() {
return None;
}

// Only mark template chunks that belong to a plausible embed candidate.
// A template is a candidate when it has a tag (e.g. css``, gql``, styled.div``)
// or is an argument to a simple call expression (e.g. graphql(`...`)).
// Plain templates like console.log(`test`) must NOT be marked, otherwise
// the formatter emits StartEmbedded/EndEmbedded tags that never get resolved
// and corrupt the printer's tag stack.
let template = node
.syntax()
.ancestors()
.find_map(JsTemplateExpression::cast)?;

if !is_plausible_embed_template(&template)? {
return None;
}

Some(node.template_chunk_token().ok()?.text_range())
}
}

/// Known identifier tag names that produce embedded languages.
/// Must stay in sync with the `TemplateTag` entries in `JS_DETECTORS`.
const KNOWN_EMBED_TAGS: &[&str] = &["css", "gql", "graphql"];

/// Known object/callee names for member expressions (`styled.div```)
/// and call expressions (`styled(Comp)```, `graphql(``)`).
/// Must stay in sync with the `TemplateExpression` entries in `JS_DETECTORS`.
const KNOWN_EMBED_OBJECTS: &[&str] = &["styled", "graphql"];

/// Check whether a template expression is a known embed candidate.
///
/// Returns `Some(true)` only for templates whose tag or call pattern matches
/// one of the known embed detectors:
/// - `css```, `gql```, `graphql``` (identifier tag)
/// - `styled.div```, `styled(Comp)``` (member/call with known object)
/// - `graphql(`...`)` (untagged template as argument to known callee)
///
/// Returns `None` when the AST is malformed.
fn is_plausible_embed_template(expr: &JsTemplateExpression) -> Option<bool> {
if let Some(tag) = expr.tag() {
return Some(match tag {
// css``, gql``, graphql``
AnyJsExpression::JsIdentifierExpression(ident) => {
let name = ident.name().ok()?.value_token().ok()?;
KNOWN_EMBED_TAGS
.iter()
.any(|known| name.text_trimmed() == *known)
}
// styled.div``
AnyJsExpression::JsStaticMemberExpression(member) => {
let AnyJsExpression::JsIdentifierExpression(ident) = member.object().ok()? else {
return Some(false);
};
let name = ident.name().ok()?.value_token().ok()?;
KNOWN_EMBED_OBJECTS
.iter()
.any(|known| name.text_trimmed() == *known)
}
// styled(Component)``
AnyJsExpression::JsCallExpression(call) => {
let AnyJsExpression::JsIdentifierExpression(ident) = call.callee().ok()? else {
return Some(false);
};
let name = ident.name().ok()?.value_token().ok()?;
KNOWN_EMBED_OBJECTS
.iter()
.any(|known| name.text_trimmed() == *known)
}
_ => false,
});
}

// No tag — check if template is an argument to a known call expression.
// e.g. graphql(`query { ... }`)
let call = expr
.parent::<JsCallArgumentList>()?
.parent::<JsCallArguments>()?
.parent::<JsCallExpression>()?;

let AnyJsExpression::JsIdentifierExpression(ident) = call.callee().ok()? else {
return Some(false);
};
let name = ident.name().ok()?.value_token().ok()?;
Some(
KNOWN_EMBED_OBJECTS
.iter()
.any(|known| name.text_trimmed() == *known),
)
}

declare_node_union! {
pub(crate) AnyTemplateChunkElement = JsTemplateChunkElement | TsTemplateChunkElement
}
Expand Down
62 changes: 62 additions & 0 deletions crates/biome_service/src/workspace/server.tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -646,3 +646,65 @@ const Baz = graphql`
`;
");
}

#[test]
fn issue_9131() {
const FILE_PATH: &str = "/project/file.js";
const FILE_CONTENT: &str = r#"
const bulkUpsertTransactionsMutation = graphql(`
mutation test(
$input: Test!
) {
test(input: $input) {
apple
}
}
`);

console.log(`test`) // plain template as call argument

const highlight = foo`some tagged template` // unknown tagged template
"#;

let fs = MemoryFileSystem::default();
fs.insert(Utf8PathBuf::from(FILE_PATH), FILE_CONTENT);

let (workspace, project_key) = setup_workspace_and_open_project(fs, "/");

workspace
.update_settings(UpdateSettingsParams {
project_key,
workspace_directory: None,
configuration: Configuration {
javascript: Some(JsConfiguration {
experimental_embedded_snippets_enabled: Some(true.into()),
..Default::default()
}),
..Default::default()
},
extended_configurations: vec![],
module_graph_resolution_kind: ModuleGraphResolutionKind::None,
})
.unwrap();

workspace
.open_file(OpenFileParams {
project_key,
path: BiomePath::new(FILE_PATH),
content: FileContent::FromServer,
document_file_source: None,
persist_node_cache: false,
inline_config: None,
})
.unwrap();

let result = workspace
.format_file(FormatFileParams {
project_key,
path: Utf8PathBuf::from(FILE_PATH).into(),
inline_config: None,
})
.unwrap();

insta::assert_snapshot!(result.as_code());
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
source: crates/biome_service/src/workspace/server.tests.rs
expression: result.as_code()
---
const bulkUpsertTransactionsMutation = graphql(`
mutation test($input: Test!) {
test(input: $input) {
apple
}
}
`);

console.log(`test`); // plain template as call argument

const highlight = foo`some tagged template`; // unknown tagged template