diff --git a/Cargo.lock b/Cargo.lock index c614545a..0e47fcd2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -420,7 +420,7 @@ dependencies = [ [[package]] name = "rsass" -version = "0.9.6" +version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" dependencies = [ "lazy_static 1.2.0 (registry+https://github.com/rust-lang/crates.io-index)", @@ -586,7 +586,7 @@ dependencies = [ "rand 0.6.4 (registry+https://github.com/rust-lang/crates.io-index)", "regex 1.1.0 (registry+https://github.com/rust-lang/crates.io-index)", "regex-syntax 0.6.4 (registry+https://github.com/rust-lang/crates.io-index)", - "rsass 0.9.6 (registry+https://github.com/rust-lang/crates.io-index)", + "rsass 0.9.8 (registry+https://github.com/rust-lang/crates.io-index)", "serde 1.0.80 (registry+https://github.com/rust-lang/crates.io-index)", "serde_derive 1.0.80 (registry+https://github.com/rust-lang/crates.io-index)", "serde_json 1.0.33 (registry+https://github.com/rust-lang/crates.io-index)", @@ -697,7 +697,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" "checksum redox_users 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)" = "214a97e49be64fd2c86f568dd0cb2c757d2cc53de95b273b6ad0a1c908482f26" "checksum regex 1.1.0 (registry+https://github.com/rust-lang/crates.io-index)" = "37e7cbbd370869ce2e8dff25c7018702d10b21a20ef7135316f8daecd6c25b7f" "checksum regex-syntax 0.6.4 (registry+https://github.com/rust-lang/crates.io-index)" = "4e47a2ed29da7a9e1960e1639e7a982e6edc6d49be308a3b02daf511504a16d1" -"checksum rsass 0.9.6 (registry+https://github.com/rust-lang/crates.io-index)" = "7a5dde55023a6c19470f7aeb59f75f897d8b80cbe00d61dfcaf7bbbe3de4c0a6" +"checksum rsass 0.9.8 (registry+https://github.com/rust-lang/crates.io-index)" = "7f4534cc03040beacd2668621815f26fe57e5b7cfe085790f98e5e87c1612316" "checksum rustc-demangle 0.1.9 (registry+https://github.com/rust-lang/crates.io-index)" = "bcfe5b13211b4d78e5c2cadfebd7769197d95c639c35a50057eb4c05de811395" "checksum rustc_version 0.2.3 (registry+https://github.com/rust-lang/crates.io-index)" = "138e3e0acb6c9fb258b19b67cb8abd63c00679d2851805ea151465464fe9030a" "checksum ryu 0.2.7 (registry+https://github.com/rust-lang/crates.io-index)" = "eb9e9b8cde282a9fe6a42dd4681319bfb63f121b8a8ee9439c6f4107e58a46f7" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index a013190e..01f269fb 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -31,7 +31,7 @@ serde = "1.0" serde_derive = "1.0" regex-syntax = "0.6.4" regex = "1" -rsass = "0.9" +rsass = "^0.9.8" [dependencies.tree-sitter] version = ">= 0.3.7" diff --git a/cli/src/properties.rs b/cli/src/properties.rs index fccfd7ed..66cc5589 100644 --- a/cli/src/properties.rs +++ b/cli/src/properties.rs @@ -2,8 +2,8 @@ use crate::error::{Error, Result}; use log::info; use rsass; use rsass::sass::Value; +use rsass::selectors::SelectorPart; use serde_derive::Serialize; -use std::cmp::Ordering; use std::collections::hash_map::Entry; use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; use std::fmt::{self, Write}; @@ -27,11 +27,12 @@ type PropertySetId = usize; #[derive(Clone, PartialEq, Eq)] struct SelectorStep { - kind: String, - is_named: bool, - is_immediate: bool, + kind: Option, + field: Option, child_index: Option, text_pattern: Option, + is_named: Option, + is_immediate: bool, } #[derive(PartialEq, Eq)] @@ -175,6 +176,7 @@ impl Builder { transition_map.insert(( PropertyTransitionJSON { kind: step.kind.clone(), + field: step.field.clone(), named: step.is_named, index: step.child_index, text: step.text_pattern.clone(), @@ -235,19 +237,11 @@ impl Builder { // first, and in the event of a tie, transitions corresponding to later rules // in the cascade are tried first. transition_list.sort_by(|a, b| { - let result = a.0.kind.cmp(&b.0.kind); - if result != Ordering::Equal { - return result; - } - let result = a.0.named.cmp(&b.0.named); - if result != Ordering::Equal { - return result; - } - let result = transition_specificity(&b.0).cmp(&transition_specificity(&a.0)); - if result != Ordering::Equal { - return result; - } - b.1.cmp(&a.1) + (transition_specificity(&b.0).cmp(&transition_specificity(&a.0))) + .then_with(|| b.1.cmp(&a.1)) + .then_with(|| a.0.kind.cmp(&b.0.kind)) + .then_with(|| a.0.named.cmp(&b.0.named)) + .then_with(|| a.0.field.cmp(&b.0.field)) }); // Compute the merged properties that apply in the current state. @@ -256,11 +250,7 @@ impl Builder { // rules will override less specific selectors and earlier rules. let mut properties = PropertySet::new(); selector_matches.sort_unstable_by(|a, b| { - let result = a.specificity.cmp(&b.specificity); - if result != Ordering::Equal { - return result; - } - a.rule_id.cmp(&b.rule_id) + (a.specificity.cmp(&b.specificity)).then_with(|| a.rule_id.cmp(&b.rule_id)) }); selector_matches.dedup(); for selector_match in selector_matches { @@ -322,6 +312,7 @@ impl Builder { transition.state_id = *replacement; } } + state.transitions.dedup(); } } @@ -356,8 +347,14 @@ impl Builder { } fn selector_specificity(selector: &Selector) -> u32 { - let mut result = selector.0.len() as u32; + let mut result = 0; for step in &selector.0 { + if step.kind.is_some() { + result += 1; + } + if step.field.is_some() { + result += 1; + } if step.child_index.is_some() { result += 1; } @@ -370,6 +367,12 @@ fn selector_specificity(selector: &Selector) -> u32 { fn transition_specificity(transition: &PropertyTransitionJSON) -> u32 { let mut result = 0; + if transition.kind.is_some() { + result += 1; + } + if transition.field.is_some() { + result += 1; + } if transition.index.is_some() { result += 1; } @@ -380,19 +383,37 @@ fn transition_specificity(transition: &PropertyTransitionJSON) -> u32 { } fn step_matches_transition(step: &SelectorStep, transition: &PropertyTransitionJSON) -> bool { - step.kind == transition.kind - && step.is_named == transition.named - && (step.child_index == transition.index || step.child_index.is_none()) - && (step.text_pattern == transition.text || step.text_pattern.is_none()) + step.kind + .as_ref() + .map_or(true, |kind| transition.kind.as_ref() == Some(kind)) + && step + .is_named + .map_or(true, |named| transition.named == Some(named)) + && step + .field + .as_ref() + .map_or(true, |field| transition.field.as_ref() == Some(field)) + && step + .child_index + .map_or(true, |index| transition.index == Some(index)) + && step + .text_pattern + .as_ref() + .map_or(true, |text| transition.text.as_ref() == Some(text)) } impl fmt::Debug for SelectorStep { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "(")?; - if self.is_named { - write!(f, "{}", self.kind)?; - } else { - write!(f, "\"{}\"", self.kind)?; + if let Some(kind) = &self.kind { + if self.is_named.unwrap() { + write!(f, "{}", kind)?; + } else { + write!(f, "[token='{}']", kind)?; + } + } + if let Some(field) = &self.field { + write!(f, ".{}", field)?; } if let Some(n) = self.child_index { write!(f, ":nth-child({})", n)?; @@ -416,7 +437,7 @@ impl fmt::Debug for Selector { } write!(f, "{:?}", step)?; } - write!(f, "]")?; + write!(f, " (specificity: {})]", selector_specificity(self))?; Ok(()) } } @@ -522,52 +543,134 @@ fn parse_sass_items( rsass::Item::Rule(selectors, items) => { let mut full_selectors = Vec::new(); for prefix in selector_prefixes { - let mut part_string = String::new(); - let mut next_step_is_immediate = false; for selector in &selectors.s { let mut prefix = prefix.clone(); + let mut operator_was_immediate: Option = Some(false); for part in &selector.0 { - part_string.clear(); - write!(&mut part_string, "{}", part).unwrap(); - let part_string = part_string.trim(); - if !part_string.is_empty() { - if part_string == "&" { - continue; - } else if part_string.starts_with(":nth-child(") { - if let Some(last_step) = prefix.last_mut() { - if let Ok(index) = usize::from_str_radix( - &part_string[11..(part_string.len() - 1)], - 10, - ) { - last_step.child_index = Some(index); + match part { + SelectorPart::BackRef => { + operator_was_immediate = None; + } + SelectorPart::Simple(value) => { + if let Some(value) = value.single_raw() { + for (i, value) in value.split('.').enumerate() { + if value.is_empty() { + continue; + } + let value = value.to_string(); + check_node_kind(&value)?; + if i > 0 { + if let Some(immediate) = operator_was_immediate { + prefix.push(SelectorStep { + kind: None, + field: Some(value), + is_named: None, + child_index: None, + text_pattern: None, + is_immediate: immediate, + }) + } else { + prefix.last_mut().unwrap().field = Some(value); + } + } else { + if let Some(immediate) = operator_was_immediate { + prefix.push(SelectorStep { + kind: Some(value.to_string()), + field: None, + child_index: None, + text_pattern: None, + is_named: Some(true), + is_immediate: immediate, + }); + } else { + return Err(Error(format!("Node type {} must be separated by whitespace or the `>` operator", value))); + } + } + operator_was_immediate = None; + } + } else { + return Err(interpolation_error()); + } + operator_was_immediate = None; + } + SelectorPart::Attribute { name, val, .. } => { + match name.single_raw() { + None => return Err(interpolation_error()), + Some("text") => { + if operator_was_immediate.is_some() { + return Err(Error("The `text` attribute must be used in combination with a node type or field".to_string())); + } + if let Some(last_step) = prefix.last_mut() { + last_step.text_pattern = + Some(get_string_value(val.to_string())?) + } + } + Some("token") => { + if let Some(immediate) = operator_was_immediate { + prefix.push(SelectorStep { + kind: Some(get_string_value(val.to_string())?), + field: None, + is_named: Some(false), + child_index: None, + text_pattern: None, + is_immediate: immediate, + }); + operator_was_immediate = None; + } else { + return Err(Error("The `token` attribute canot be used in combination with a node type".to_string())); + } + } + _ => { + return Err(Error(format!( + "Unsupported attribute {}", + part + ))); } } - } else if part_string.starts_with("[text=") { - if let Some(last_step) = prefix.last_mut() { - last_step.text_pattern = Some( - part_string[7..(part_string.len() - 2)].to_string(), - ) + } + SelectorPart::PseudoElement { .. } => { + return Err(Error( + "Pseudo elements are not supported".to_string(), + )); + } + SelectorPart::Pseudo { name, arg } => match name.single_raw() { + None => return Err(interpolation_error()), + Some("nth-child") => { + if let Some(arg) = arg { + let mut arg_str = String::new(); + write!(&mut arg_str, "{}", arg).unwrap(); + if let Some(last_step) = prefix.last_mut() { + if let Ok(i) = usize::from_str_radix(&arg_str, 10) { + last_step.child_index = Some(i); + } else { + return Err(Error(format!( + "Invalid child index {}", + arg + ))); + } + } + } + } + _ => { + return Err(Error(format!( + "Unsupported pseudo-class {}", + part + ))); + } + }, + SelectorPart::Descendant => { + operator_was_immediate = Some(false); + } + SelectorPart::RelOp(operator) => { + let operator = *operator as char; + if operator == '>' { + operator_was_immediate = Some(true); + } else { + return Err(Error(format!( + "Unsupported operator {}", + operator + ))); } - } else if part_string == ">" { - next_step_is_immediate = true; - } else if part_string.starts_with("[token=") { - prefix.push(SelectorStep { - kind: part_string[8..(part_string.len() - 2)].to_string(), - is_named: false, - child_index: None, - text_pattern: None, - is_immediate: next_step_is_immediate, - }); - next_step_is_immediate = false; - } else { - prefix.push(SelectorStep { - kind: part_string.to_string(), - is_named: true, - child_index: None, - text_pattern: None, - is_immediate: next_step_is_immediate, - }); - next_step_is_immediate = false; } } } @@ -596,7 +699,7 @@ fn parse_sass_value(value: &Value) -> Result { if let Some(s) = s.single_raw() { Ok(PropertyValue::String(s.to_string())) } else { - Err(Error("String interpolation is not supported".to_string())) + Err(interpolation_error()) } } Value::Call(name, raw_args) => { @@ -665,13 +768,36 @@ fn resolve_path(base: &Path, p: &str) -> Result { Err(Error(format!("Could not resolve import path `{}`", p))) } +fn check_node_kind(name: &String) -> Result<()> { + for c in name.chars() { + if !c.is_alphanumeric() && c != '_' { + return Err(Error(format!("Invalid identifier '{}'", name))); + } + } + Ok(()) +} + +fn get_string_value(mut s: String) -> Result { + if s.starts_with("'") && s.ends_with("'") || s.starts_with('"') && s.ends_with('"') { + s.pop(); + s.remove(0); + Ok(s) + } else { + Err(Error(format!("Unsupported string literal {}", s))) + } +} + +fn interpolation_error() -> Error { + Error("String interpolation is not supported".to_string()) +} + #[cfg(test)] mod tests { use super::*; use regex::Regex; #[test] - fn test_immediate_child_and_descendant_selectors() { + fn test_properties_immediate_child_and_descendant_selectors() { let sheet = generate_property_sheet( "foo.css", " @@ -776,7 +902,7 @@ mod tests { } #[test] - fn test_text_attribute() { + fn test_properties_text_attribute() { let sheet = generate_property_sheet( "foo.css", " @@ -800,26 +926,93 @@ mod tests { .unwrap(); assert_eq!( - *query(&sheet, vec![("f1", true, 0)], "abc"), + *query(&sheet, vec![("f1", None, true, 0)], "abc"), props(&[("color", "red")]) ); assert_eq!( - *query(&sheet, vec![("f1", true, 0)], "Abc"), + *query(&sheet, vec![("f1", None, true, 0)], "Abc"), props(&[("color", "green")]) ); assert_eq!( - *query(&sheet, vec![("f1", true, 0)], "AB_CD"), + *query(&sheet, vec![("f1", None, true, 0)], "AB_CD"), props(&[("color", "blue")]) ); - assert_eq!(*query(&sheet, vec![("f2", true, 0)], "Abc"), props(&[])); assert_eq!( - *query(&sheet, vec![("f2", true, 0)], "ABC"), + *query(&sheet, vec![("f2", None, true, 0)], "Abc"), + props(&[]) + ); + assert_eq!( + *query(&sheet, vec![("f2", None, true, 0)], "ABC"), props(&[("color", "purple")]) ); } #[test] - fn test_cascade_ordering_as_tie_breaker() { + fn test_properties_with_fields() { + let sheet = generate_property_sheet( + "foo.css", + " + a { + color: red; + &.x { + color: green; + b { + color: blue; + &.y { color: yellow; } + } + } + b { color: orange; } + b.y { color: indigo; } + } + .x { color: violet; } + ", + ) + .unwrap(); + + assert_eq!( + *query(&sheet, vec![("a", None, true, 0)], ""), + props(&[("color", "red")]) + ); + assert_eq!( + *query(&sheet, vec![("a", Some("x"), true, 0)], ""), + props(&[("color", "green")]) + ); + assert_eq!( + *query( + &sheet, + vec![("a", Some("x"), true, 0), ("b", None, true, 0)], + "" + ), + props(&[("color", "blue")]) + ); + assert_eq!( + *query( + &sheet, + vec![("a", Some("x"), true, 0), ("b", Some("y"), true, 0)], + "" + ), + props(&[("color", "yellow")]) + ); + assert_eq!( + *query(&sheet, vec![("b", Some("x"), true, 0)], ""), + props(&[("color", "violet")]) + ); + assert_eq!( + *query(&sheet, vec![("a", None, true, 0), ("b", None, true, 0)], ""), + props(&[("color", "orange")]) + ); + assert_eq!( + *query( + &sheet, + vec![("a", None, true, 0), ("b", Some("y"), true, 0)], + "" + ), + props(&[("color", "indigo")]) + ); + } + + #[test] + fn test_properties_cascade_ordering_as_tie_breaker() { let sheet = generate_property_sheet( "foo.css", " @@ -832,29 +1025,49 @@ mod tests { .unwrap(); assert_eq!( - *query(&sheet, vec![("f1", true, 0), ("f2", true, 0)], "x"), + *query( + &sheet, + vec![("f1", None, true, 0), ("f2", None, true, 0)], + "x" + ), props(&[]) ); assert_eq!( - *query(&sheet, vec![("f1", true, 0), ("f2", true, 1)], "x"), + *query( + &sheet, + vec![("f1", None, true, 0), ("f2", None, true, 1)], + "x" + ), props(&[("color", "red")]) ); assert_eq!( - *query(&sheet, vec![("f1", true, 1), ("f2", true, 1)], "x"), + *query( + &sheet, + vec![("f1", None, true, 1), ("f2", None, true, 1)], + "x" + ), props(&[("color", "green")]) ); assert_eq!( - *query(&sheet, vec![("f1", true, 1), ("f2", true, 1)], "a"), + *query( + &sheet, + vec![("f1", None, true, 1), ("f2", None, true, 1)], + "a" + ), props(&[("color", "blue")]) ); assert_eq!( - *query(&sheet, vec![("f1", true, 1), ("f2", true, 1)], "ab"), + *query( + &sheet, + vec![("f1", None, true, 1), ("f2", None, true, 1)], + "ab" + ), props(&[("color", "violet")]) ); } #[test] - fn test_css_function_calls() { + fn test_properties_css_function_calls() { let sheet = generate_property_sheet( "foo.css", " @@ -891,7 +1104,7 @@ mod tests { } #[test] - fn test_array_by_declaring_property_multiple_times() { + fn test_properties_array_by_declaring_property_multiple_times() { let sheet = generate_property_sheet( "foo.css", " @@ -937,25 +1150,26 @@ mod tests { ) -> &'a PropertySet { query( sheet, - node_stack.into_iter().map(|s| (s, true, 0)).collect(), + node_stack.into_iter().map(|s| (s, None, true, 0)).collect(), "", ) } fn query<'a>( sheet: &'a PropertySheetJSON, - node_stack: Vec<(&'static str, bool, usize)>, + node_stack: Vec<(&'static str, Option<&'static str>, bool, usize)>, leaf_text: &str, ) -> &'a PropertySet { let mut state_id = 0; - for (kind, is_named, child_index) in node_stack { + for (kind, field, is_named, child_index) in node_stack { let state = &sheet.states[state_id]; state_id = state .transitions .iter() .find(|transition| { - transition.kind == kind - && transition.named == is_named + transition.kind.as_ref().map_or(true, |k| k == kind) + && transition.named.map_or(true, |n| n == is_named) + && transition.field.as_ref().map_or(true, |f| field == Some(f)) && transition.index.map_or(true, |index| index == child_index) && (transition .text diff --git a/lib/binding/lib.rs b/lib/binding/lib.rs index c5738608..38e75a1f 100644 --- a/lib/binding/lib.rs +++ b/lib/binding/lib.rs @@ -62,8 +62,15 @@ struct PropertyTransition { text_regex_index: Option, } +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +enum NodeId { + Kind(u16), + Field(u16), + KindAndField(u16, u16), +} + struct PropertyState { - transitions: HashMap>, + transitions: HashMap>, property_set_id: usize, default_next_state_id: usize, } @@ -83,11 +90,15 @@ pub struct PropertySheet

> { #[derive(Debug, Deserialize, Serialize, Hash, PartialEq, Eq)] pub struct PropertyTransitionJSON { #[serde(rename = "type")] - pub kind: String, - pub named: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub kind: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub named: Option, #[serde(skip_serializing_if = "Option::is_none")] pub index: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub field: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub text: Option, pub state_id: usize, } @@ -137,6 +148,22 @@ impl Language { pub fn node_kind_is_named(&self, id: u16) -> bool { unsafe { ffi::ts_language_symbol_type(self.0, id) == ffi::TSSymbolType_TSSymbolTypeRegular } } + + pub fn field_id_for_name(&self, field_name: impl AsRef<[u8]>) -> Option { + let field_name = field_name.as_ref(); + let id = unsafe { + ffi::ts_language_field_id_for_name( + self.0, + field_name.as_ptr() as *const c_char, + field_name.len() as u32, + ) + }; + if id == 0 { + None + } else { + Some(id) + } + } } unsafe impl Send for Language {} @@ -657,7 +684,9 @@ impl<'a, P> TreePropertyCursor<'a, P> { property_sheet, source, }; - let state = result.next_state(&result.current_state(), result.cursor.node().kind_id(), 0); + let kind_id = result.cursor.node().kind_id(); + let field_id = result.cursor.field_id(); + let state = result.next_state(&result.current_state(), kind_id, field_id, 0); result.state_stack.push(state); result } @@ -676,7 +705,8 @@ impl<'a, P> TreePropertyCursor<'a, P> { let next_state_id = { let state = &self.current_state(); let kind_id = self.cursor.node().kind_id(); - self.next_state(state, kind_id, child_index) + let field_id = self.cursor.field_id(); + self.next_state(state, kind_id, field_id, child_index) }; self.state_stack.push(next_state_id); self.child_index_stack.push(child_index); @@ -693,7 +723,8 @@ impl<'a, P> TreePropertyCursor<'a, P> { let next_state_id = { let state = &self.current_state(); let kind_id = self.cursor.node().kind_id(); - self.next_state(state, kind_id, child_index) + let field_id = self.cursor.field_id(); + self.next_state(state, kind_id, field_id, child_index) }; self.state_stack.push(next_state_id); self.child_index_stack.push(child_index); @@ -717,12 +748,25 @@ impl<'a, P> TreePropertyCursor<'a, P> { &self, state: &PropertyState, node_kind_id: u16, + node_field_id: Option, node_child_index: usize, ) -> usize { - state - .transitions - .get(&node_kind_id) - .and_then(|transitions| { + let keys; + let key_count; + if let Some(node_field_id) = node_field_id { + key_count = 3; + keys = [ + NodeId::KindAndField(node_kind_id, node_field_id), + NodeId::Field(node_field_id), + NodeId::Kind(node_kind_id), + ]; + } else { + key_count = 1; + keys = [NodeId::Kind(node_kind_id); 3]; + } + + for key in &keys[0..key_count] { + if let Some(transitions) = state.transitions.get(key) { for transition in transitions.iter() { if let Some(text_regex_index) = transition.text_regex_index { let node = self.cursor.node(); @@ -740,11 +784,12 @@ impl<'a, P> TreePropertyCursor<'a, P> { } } - return Some(transition.state_id); + return transition.state_id; } - None - }) - .unwrap_or(state.default_next_state_id) + } + } + + state.default_next_state_id } fn current_state(&self) -> &PropertyState { @@ -848,18 +893,38 @@ impl

PropertySheet

{ None }; - for i in 0..(node_kind_count as u16) { - if transition.kind == language.node_kind_for_id(i) - && transition.named == language.node_kind_is_named(i) - { - let entry = transitions.entry(i).or_insert(Vec::new()); - entry.push(PropertyTransition { - child_index: transition.index, - state_id: transition.state_id, - text_regex_index, - }); + let kind_id = transition.kind.as_ref().and_then(|kind| { + let named = transition.named.unwrap(); + for i in 0..(node_kind_count as u16) { + if kind == language.node_kind_for_id(i) + && named == language.node_kind_is_named(i) + { + return Some(i); + } } - } + None + }); + + let field_id = transition + .field + .as_ref() + .and_then(|field| language.field_id_for_name(&field)); + + let key = match (kind_id, field_id) { + (Some(kind_id), None) => NodeId::Kind(kind_id), + (None, Some(field_id)) => NodeId::Field(field_id), + (Some(kind_id), Some(field_id)) => NodeId::KindAndField(kind_id, field_id), + (None, None) => continue, + }; + + transitions + .entry(key) + .or_insert(Vec::new()) + .push(PropertyTransition { + child_index: transition.index, + state_id: transition.state_id, + text_regex_index, + }); } states.push(PropertyState { transitions,