use std::path::Path;
#[must_use]
pub fn is_glimmer_file(path: &Path) -> bool {
path.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ext == "gts" || ext == "gjs")
}
#[must_use]
pub fn strip_glimmer_templates(source: &str) -> Option<String> {
let mut bytes = source.as_bytes().to_vec();
let mut cursor = 0;
let mut changed = false;
let n = source.len();
while let Some(relative_start) = source[cursor..].find("<template") {
let start = cursor + relative_start;
let after_template_word = start + "<template".len();
let opening_end = source[after_template_word..]
.find('>')
.map_or(n, |r| after_template_word + r + 1);
let close_relative = source[opening_end..].find("</template>");
let (close_start_abs, close_end) = match close_relative {
Some(r) => (opening_end + r, opening_end + r + "</template>".len()),
None => (n, n),
};
let opening_len = opening_end - start;
let closing_len = close_end - close_start_abs;
let in_expr_position = is_expression_position(source.as_bytes(), start);
let can_use_expr_form =
in_expr_position && close_relative.is_some() && opening_len >= 2 && closing_len >= 2;
if can_use_expr_form {
bytes[start] = b'(';
bytes[start + 1] = b'`';
for byte in &mut bytes[start + 2..opening_end] {
if !matches!(*byte, b'\n' | b'\r') {
*byte = b' ';
}
}
for byte in &mut bytes[opening_end..close_start_abs] {
if matches!(*byte, b'`' | b'$' | b'\\') {
*byte = b' ';
}
}
for byte in &mut bytes[close_start_abs..close_end - 2] {
if !matches!(*byte, b'\n' | b'\r') {
*byte = b' ';
}
}
bytes[close_end - 2] = b'`';
bytes[close_end - 1] = b')';
} else {
for byte in &mut bytes[start..close_end] {
if !matches!(*byte, b'\n' | b'\r') {
*byte = b' ';
}
}
}
changed = true;
cursor = close_end;
if cursor >= n {
break;
}
}
if changed {
String::from_utf8(bytes).ok()
} else {
None
}
}
fn is_expression_position(bytes: &[u8], pos: usize) -> bool {
let mut idx = pos;
while idx > 0 && matches!(bytes[idx - 1], b' ' | b'\t' | b'\n' | b'\r') {
idx -= 1;
}
if idx == 0 {
return false;
}
let prev = bytes[idx - 1];
if matches!(prev, b'=' | b',' | b'(' | b'?' | b':') {
return true;
}
if !is_identifier_byte(prev) {
return false;
}
let ident = prev_identifier(bytes, idx);
matches!(
ident,
b"default" | b"return" | b"throw" | b"yield" | b"await" | b"new"
)
}
fn is_identifier_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_' || b == b'$'
}
fn prev_identifier(bytes: &[u8], end: usize) -> &[u8] {
let mut start = end;
while start > 0 && is_identifier_byte(bytes[start - 1]) {
start -= 1;
}
&bytes[start..end]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strips_template_blocks_and_preserves_newlines() {
let source =
"import x from './x';\n<template>\n <x />\n</template>\nexport const y = x;\n";
let stripped = strip_glimmer_templates(source).expect("template should be stripped");
assert!(stripped.contains("import x from './x';"));
assert!(stripped.contains("export const y = x;"));
assert!(!stripped.contains("<template>"));
assert_eq!(stripped.len(), source.len());
assert_eq!(stripped.lines().count(), source.lines().count());
}
#[test]
fn strips_class_body_template_to_empty_class() {
let source = "import Component from '@glimmer/component';\nexport default class X extends Component {\n <template>billing</template>\n}\n";
let stripped = strip_glimmer_templates(source).expect("template should be stripped");
assert!(stripped.contains("class X extends Component {"));
assert!(!stripped.contains("<template>"));
assert!(!stripped.contains('('));
assert_eq!(stripped.len(), source.len());
}
#[test]
fn replaces_module_level_template_expression_with_parenthesised_literal() {
let source = "const x = <template>foo</template>;\n";
let stripped = strip_glimmer_templates(source).expect("template should be stripped");
assert!(stripped.contains("const x = (`"));
assert!(stripped.contains("`);"));
assert!(!stripped.contains("<template>"));
assert_eq!(stripped.len(), source.len());
}
#[test]
fn handles_multi_template_module_and_class_in_same_file() {
let source = "import C from '@glimmer/component';\nconst W = <template>\n one\n</template>;\nexport default class X extends C {\n <template>\n two\n </template>\n}\n";
let stripped = strip_glimmer_templates(source).expect("templates should be stripped");
assert!(stripped.contains("const W = (`"));
assert!(stripped.contains("`);"));
assert!(stripped.contains("class X extends C {"));
assert!(!stripped.contains("<template>"));
assert!(!stripped.contains("</template>"));
assert_eq!(stripped.len(), source.len());
assert_eq!(stripped.lines().count(), source.lines().count());
}
#[test]
fn escapes_backtick_dollar_backslash_inside_expression_template() {
let source = "const x = <template>a`b${c}d\\e</template>;\n";
let stripped = strip_glimmer_templates(source).expect("template should be stripped");
assert!(!stripped.contains('`') || stripped.matches('`').count() == 2);
assert!(!stripped.contains("${"));
assert!(!stripped.contains('\\'));
assert!(stripped.contains("a b "));
assert!(stripped.contains("d e"));
assert_eq!(stripped.len(), source.len());
}
#[test]
fn unclosed_template_blanks_to_eof_without_expression_form() {
let source = "const x = <template>oops\nexport const y = 1;\n";
let stripped = strip_glimmer_templates(source).expect("template should be stripped");
assert!(!stripped.contains("<template>"));
assert_eq!(stripped.len(), source.len());
}
#[test]
fn handles_template_after_typed_initializer() {
let source = "const x: TOC<{}> = <template>hi</template>;\n";
let stripped = strip_glimmer_templates(source).expect("template should be stripped");
assert!(stripped.contains("const x: TOC<{}> = (`"));
assert!(stripped.contains("`);"));
assert_eq!(stripped.len(), source.len());
}
#[test]
fn handles_template_in_decorator_call() {
let source = "@Some(<template>x</template>)\nclass Foo {}\n";
let stripped = strip_glimmer_templates(source).expect("template should be stripped");
assert!(stripped.contains("@Some((`"));
assert!(stripped.contains("`))"));
assert_eq!(stripped.len(), source.len());
}
#[test]
fn handles_template_after_export_default() {
let source =
"import Icon from './icon';\nexport default <template>\n <Icon />\n</template>\n";
let stripped = strip_glimmer_templates(source).expect("template should be stripped");
assert!(stripped.contains("import Icon from './icon';"));
assert!(stripped.contains("export default (`"));
assert!(stripped.contains("`)"));
assert!(!stripped.contains("<template>"));
assert_eq!(stripped.len(), source.len());
}
#[test]
fn handles_template_after_return_keyword() {
let source = "function build() {\n return <template>hi</template>;\n}\n";
let stripped = strip_glimmer_templates(source).expect("template should be stripped");
assert!(stripped.contains("return (`"));
assert!(stripped.contains("`);"));
assert_eq!(stripped.len(), source.len());
}
#[test]
fn handles_template_after_throw_keyword() {
let source = "function fail() {\n throw <template>x</template>;\n}\n";
let stripped = strip_glimmer_templates(source).expect("template should be stripped");
assert!(stripped.contains("throw (`"));
assert!(stripped.contains("`);"));
assert_eq!(stripped.len(), source.len());
}
#[test]
fn identifier_ending_in_keyword_suffix_falls_through_to_blank() {
let source = "mydefault <template>x</template>\n";
let stripped = strip_glimmer_templates(source).expect("template should be stripped");
assert!(!stripped.contains("(`"));
assert!(!stripped.contains("<template>"));
assert_eq!(stripped.len(), source.len());
}
}