Skip to content

Commit a23bc4e

Browse files
committed
Add support for nested CSS in ts, js and tsx
1 parent 683dbe5 commit a23bc4e

File tree

2 files changed

+251
-5
lines changed

2 files changed

+251
-5
lines changed

src/parse/syntax.rs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,69 @@ pub(crate) enum Syntax<'a> {
117117
},
118118
}
119119

120+
pub struct SyntaxTreeDisplay<'a>(Vec<&'a Syntax<'a>>);
121+
122+
#[allow(dead_code)]
123+
impl<'a> SyntaxTreeDisplay<'a> {
124+
pub fn from(tree: Vec<&'a Syntax<'a>>) -> Self {
125+
Self(tree)
126+
}
127+
128+
fn print_node(
129+
f: &mut fmt::Formatter<'_>,
130+
node: &Syntax,
131+
prefix: &str,
132+
is_last: bool,
133+
) -> fmt::Result {
134+
let connector = if is_last { "└── " } else { "├── " };
135+
136+
match node {
137+
Syntax::List {
138+
open_position,
139+
close_position,
140+
children,
141+
..
142+
} => {
143+
writeln!(
144+
f,
145+
"{}{}List (open: {:?}, close: {:?})",
146+
prefix, connector, open_position, close_position
147+
)?;
148+
149+
// Prepare prefix for children
150+
// If this was the last node, children don't need the vertical bar │
151+
let child_prefix = format!("{}{}", prefix, if is_last { " " } else { "│ " });
152+
153+
for (i, child) in children.iter().enumerate() {
154+
Self::print_node(f, child, &child_prefix, i == children.len() - 1)?;
155+
}
156+
}
157+
Syntax::Atom {
158+
content,
159+
position,
160+
kind,
161+
..
162+
} => {
163+
writeln!(
164+
f,
165+
"{}{}Atom: {:?} {:#?} ({:?})",
166+
prefix, connector, content, kind, position
167+
)?;
168+
}
169+
}
170+
Ok(())
171+
}
172+
}
173+
174+
impl<'a> fmt::Display for SyntaxTreeDisplay<'a> {
175+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
176+
for (i, node) in self.0.iter().enumerate() {
177+
SyntaxTreeDisplay::print_node(f, node, "", i == self.0.len() - 1)?;
178+
}
179+
Ok(())
180+
}
181+
}
182+
120183
fn dbg_pos(pos: &[SingleLineSpan]) -> String {
121184
match pos {
122185
[] => "-".into(),

src/parse/tree_sitter_parser.rs

Lines changed: 188 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,23 @@ const OCAML_ATOM_NODES: [&str; 6] = [
9494
"attribute_id",
9595
];
9696

97+
const TS_CSS_INJECTION_QUERY: &str = r#"
98+
(_
99+
; Capture the 'callee' or 'left' side.
100+
; We accept any named node here to act as the anchor.
101+
(_) @callee
102+
103+
; Capture the template string contents.
104+
(template_string) @contents
105+
106+
; Predicate: The 'callee' text must start with 'styled' or 'css'.
107+
; This matches "styled.div", "styled(C)", and even "styled.div<{}>"
108+
; (which might be parsed as a binary expression 'styled.div < {}' or
109+
; a call expression depending on the grammar version).
110+
(#match? @callee "^(styled|css)")
111+
)
112+
"#;
113+
97114
pub(crate) fn from_language(language: guess::Language) -> TreeSitterConfig {
98115
use guess::Language::*;
99116
match language {
@@ -590,7 +607,10 @@ pub(crate) fn from_language(language: guess::Language) -> TreeSitterConfig {
590607
],
591608
highlight_query: ts::Query::new(&language, tree_sitter_javascript::HIGHLIGHT_QUERY)
592609
.unwrap(),
593-
sub_languages: vec![],
610+
sub_languages: vec![TreeSitterSubLanguage {
611+
query: ts::Query::new(&language, TS_CSS_INJECTION_QUERY).unwrap(),
612+
parse_as: Css,
613+
}],
594614
}
595615
}
596616
Json => {
@@ -1053,7 +1073,10 @@ pub(crate) fn from_language(language: guess::Language) -> TreeSitterConfig {
10531073
atom_nodes: ["string", "template_string"].into_iter().collect(),
10541074
delimiter_tokens: vec![("{", "}"), ("(", ")"), ("[", "]"), ("<", ">")],
10551075
highlight_query: ts::Query::new(&language, &highlight_query).unwrap(),
1056-
sub_languages: vec![],
1076+
sub_languages: vec![TreeSitterSubLanguage {
1077+
query: ts::Query::new(&language, TS_CSS_INJECTION_QUERY).unwrap(),
1078+
parse_as: Css,
1079+
}],
10571080
}
10581081
}
10591082
TypeScript => {
@@ -1070,7 +1093,10 @@ pub(crate) fn from_language(language: guess::Language) -> TreeSitterConfig {
10701093
.collect(),
10711094
delimiter_tokens: vec![("{", "}"), ("(", ")"), ("[", "]"), ("<", ">")],
10721095
highlight_query: ts::Query::new(&language, &highlight_query).unwrap(),
1073-
sub_languages: vec![],
1096+
sub_languages: vec![TreeSitterSubLanguage {
1097+
query: ts::Query::new(&language, TS_CSS_INJECTION_QUERY).unwrap(),
1098+
parse_as: Css,
1099+
}],
10741100
}
10751101
}
10761102
Xml => {
@@ -1198,8 +1224,39 @@ pub(crate) fn parse_subtrees(
11981224
let mut query_matches =
11991225
query_cursor.matches(&language.query, tree.root_node(), src.as_bytes());
12001226

1227+
let content_idx = language
1228+
.query
1229+
.capture_index_for_name("contents")
1230+
.unwrap_or(0);
1231+
12011232
while let Some(m) = query_matches.next() {
1202-
let node = m.nodes_for_capture_index(0).next().unwrap();
1233+
let node = match m.nodes_for_capture_index(content_idx).next() {
1234+
None => continue,
1235+
Some(node) => node,
1236+
};
1237+
1238+
let mut range = node.range();
1239+
match (language.parse_as, node.grammar_name()) {
1240+
(guess::Language::Css, "template_string") => {
1241+
// If this is a template string (starts/ends with backtick), shrink the range.
1242+
// We check the text to be safe, or just assume based on the node kind.
1243+
let node_text = &src[node.start_byte()..node.end_byte()];
1244+
if node_text.starts_with('`')
1245+
&& node_text.ends_with('`')
1246+
&& node_text.len() >= 2
1247+
{
1248+
range.start_byte += 1;
1249+
range.end_byte -= 1;
1250+
1251+
// We also need to update start_point and end_point for Tree-sitter to be happy.
1252+
// Since we know a backtick is 1 column wide and doesn't span lines:
1253+
range.start_point.column += 1;
1254+
range.end_point.column -= 1;
1255+
}
1256+
}
1257+
_ => {}
1258+
};
1259+
12031260
if node.byte_range().is_empty() {
12041261
continue;
12051262
}
@@ -1210,7 +1267,7 @@ pub(crate) fn parse_subtrees(
12101267
.set_language(&subconfig.language)
12111268
.expect("Incompatible tree-sitter version");
12121269
parser
1213-
.set_included_ranges(&[node.range()])
1270+
.set_included_ranges(&[range])
12141271
.expect("Incompatible tree-sitter version");
12151272

12161273
let tree = parser.parse(src, None).unwrap();
@@ -1833,8 +1890,12 @@ fn atom_from_cursor<'a>(
18331890

18341891
#[cfg(test)]
18351892
mod tests {
1893+
use std::collections::VecDeque;
1894+
18361895
use strum::IntoEnumIterator as _;
18371896

1897+
use crate::parse::syntax::SyntaxTreeDisplay;
1898+
18381899
use super::*;
18391900

18401901
/// Simple smoke test for tree-sitter parsing. Having a test also
@@ -1880,6 +1941,128 @@ mod tests {
18801941
};
18811942
}
18821943

1944+
fn assert_contains_atoms<'a>(nodes: &[&'a Syntax<'a>], expected_sequence: Vec<Vec<&str>>) {
1945+
let mut to_search = VecDeque::from(nodes.to_vec());
1946+
let mut expected_iter = expected_sequence.into_iter();
1947+
let mut current_expected = expected_iter.next();
1948+
1949+
while let Some(node) = to_search.pop_front() {
1950+
if let Some(expected) = &current_expected {
1951+
match node {
1952+
Syntax::List { children, .. } => {
1953+
// Extract just the normal atoms from this list to see if they match the line
1954+
let atom_texts: Vec<&str> = children
1955+
.iter()
1956+
.filter_map(|child| match child {
1957+
Syntax::Atom { content, .. } => Some(content.as_str()),
1958+
_ => None,
1959+
})
1960+
.collect();
1961+
1962+
// If this list matches the current expected line, advance expectation
1963+
if !atom_texts.is_empty() && atom_texts == *expected {
1964+
current_expected = expected_iter.next();
1965+
}
1966+
1967+
for child in children.iter().rev() {
1968+
to_search.push_front(child);
1969+
}
1970+
}
1971+
_ => {}
1972+
}
1973+
} else {
1974+
// All expectations met
1975+
return;
1976+
}
1977+
}
1978+
1979+
if let Some(remaining) = current_expected {
1980+
panic!(
1981+
"Could not find all atom sequences. \nMissing: {:?}\nDebug Tree:\n{}",
1982+
remaining,
1983+
SyntaxTreeDisplay::from(nodes.to_vec())
1984+
);
1985+
}
1986+
}
1987+
1988+
#[test]
1989+
fn test_typescript_css_injection_table() {
1990+
let arena = Arena::new();
1991+
let configs = vec![
1992+
from_language(guess::Language::TypeScript),
1993+
from_language(guess::Language::TypeScriptTsx),
1994+
from_language(guess::Language::JavaScript),
1995+
from_language(guess::Language::JavascriptJsx),
1996+
];
1997+
1998+
let cases = vec![
1999+
// Case 1: Standard styled.button
2000+
(
2001+
r#"
2002+
const Button = styled.button`
2003+
background: #BF4F74;
2004+
border-radius: 3px;
2005+
border: none;
2006+
color: white;
2007+
`
2008+
"#,
2009+
vec![
2010+
vec!["background", ":", "#BF4F74", ";"],
2011+
vec!["border-radius", ":", "3px", ";"],
2012+
vec!["border", ":", "none", ";"],
2013+
vec!["color", ":", "white", ";"],
2014+
],
2015+
),
2016+
// Case 2: Component wrapping styled(C)
2017+
(
2018+
r#"
2019+
const Button = styled(OtherButton)`
2020+
color: white;
2021+
`
2022+
"#,
2023+
vec![vec!["color", ":", "white", ";"]],
2024+
),
2025+
// Case 3: Helper css`...`
2026+
(
2027+
r#"
2028+
const Button = css`
2029+
color: white;
2030+
`
2031+
"#,
2032+
vec![vec!["color", ":", "white", ";"]],
2033+
),
2034+
// Case 4: Nested Interpolation
2035+
(
2036+
r#"
2037+
const Button = styled.button`
2038+
color: white;
2039+
${props => props.$withSeparator && css`padding-top: 22px;`}
2040+
`
2041+
"#,
2042+
vec![vec!["color", ":", "white", ";"]],
2043+
),
2044+
// Case 5: Generics
2045+
(
2046+
r#"
2047+
export const Button = styled.button<{}>` color: white; `;
2048+
"#,
2049+
vec![vec!["color", ":", "white", ";"]],
2050+
),
2051+
// Case 6: Multiline Edge Case
2052+
(
2053+
"\nconst X = styled.div`\ncolor: white;\n`",
2054+
vec![vec!["color", ":", "white", ";"]],
2055+
),
2056+
];
2057+
2058+
for config in configs {
2059+
for (src, expected_atoms) in cases.iter() {
2060+
let nodes = parse(&arena, src, &config, false);
2061+
assert_contains_atoms(&nodes, expected_atoms.clone());
2062+
}
2063+
}
2064+
}
2065+
18832066
/// Ensure that we don't crash when loading any of the
18842067
/// configs. This can happen on bad highlighting/foo.scm files.
18852068
#[test]

0 commit comments

Comments
 (0)