1use crate::names::{exact_method, format_class_name, member_query, parse_kind};
40use crate::{EngineError, Result};
41use asc_findrefs::{CallConstraint, Certainty, Defined, MemberQuery, Query, ResultWant, Slot, Want};
42use std::path::Path;
43
44pub fn parse_rule_in(spec: &str, dir: Option<&Path>) -> Result<Query> {
47 let mut p = Parser { src: spec, pos: 0, dir };
48 let q = p.rule(false)?;
49 match p.peek() {
50 None => Ok(q),
51 Some(c) => Err(p.error(&format!("unexpected {c:?}"))),
52 }
53}
54
55pub fn parse_condition_in(spec: &str, dir: Option<&Path>) -> Result<Query> {
57 match parse_rule_in(spec, dir)? {
58 Query::All { .. } => Err(EngineError::Query(format!("{spec:?} is several conditions; give one KIND:VALUE here"))),
59 q => Ok(q),
60 }
61}
62
63pub fn method_set(text: &str) -> Result<Vec<MemberQuery>> {
67 let mut targets: Vec<&str> = text
68 .lines()
69 .map(str::trim)
70 .filter(|l| !l.is_empty() && !l.starts_with('#'))
71 .map(|l| l.split('|').flat_map(str::split_whitespace).find(|t| t.len() > 2 && t.contains("->")).unwrap_or(l))
72 .collect();
73 targets.sort_unstable();
74 targets.dedup();
75 targets
76 .into_iter()
77 .map(|t| if t.contains(";->") && t.contains('(') { exact_method(t) } else { member_query("method", None, false, Some(t), false) })
78 .collect()
79}
80
81struct Parser<'a> {
82 src: &'a str,
83 pos: usize,
84 dir: Option<&'a Path>,
85}
86
87impl<'a> Parser<'a> {
88 fn rest(&self) -> &str {
89 &self.src[self.pos..]
90 }
91
92 fn skip_ws(&mut self) {
93 self.pos = self.src.len() - self.rest().trim_start().len();
94 }
95
96 fn peek(&mut self) -> Option<char> {
98 self.skip_ws();
99 self.rest().chars().next()
100 }
101
102 fn eat(&mut self, c: char) -> bool {
103 let found = self.peek() == Some(c);
104 if found {
105 self.pos += c.len_utf8();
106 }
107 found
108 }
109
110 fn error(&self, what: &str) -> EngineError {
111 EngineError::Query(format!("bad rule {:?}: {what} at column {}", self.src, self.pos + 1))
112 }
113
114 fn rule(&mut self, nested: bool) -> Result<Query> {
116 let mut all = Vec::new();
117 let mut none = Vec::new();
118 loop {
119 if self.eat('!') {
120 none.push(self.condition(nested)?);
121 } else {
122 all.push(self.condition(nested)?);
123 }
124 if !self.eat('&') {
125 break;
126 }
127 }
128 match (all.len(), none.is_empty()) {
129 (0, _) => Err(self.error("a rule needs a condition without `!` (`!` only removes methods)")),
130 (1, true) => Ok(all.pop().expect("one condition")),
131 _ => Ok(Query::All { all, none }),
132 }
133 }
134
135 fn condition(&mut self, nested: bool) -> Result<Query> {
137 self.skip_ws();
138 let kind_len = self.rest().find(|c: char| !c.is_ascii_alphabetic()).unwrap_or(self.rest().len());
139 if kind_len == 0 || !self.rest()[kind_len..].starts_with(':') {
140 return Err(self.error("expected KIND:VALUE, KIND one of string, literal, type, method, field, extends, impl"));
141 }
142 let kind = parse_kind(&self.rest()[..kind_len])?;
143 self.pos += kind_len + 1;
144 self.skip_ws();
145 if kind == "method" {
146 return self.method(nested);
147 }
148 if kind == "in" {
150 if !self.eat('{') {
151 return Err(self.error("in: takes a class condition in braces, e.g. in:{extends:android.app.Activity}"));
152 }
153 let inner = self.condition(true)?;
154 if !self.eat('}') {
155 return Err(self.error("expected `}`"));
156 }
157 return Ok(Query::Defined(Defined::In(Box::new(inner))));
158 }
159 let value = self.text(nested)?.trim();
160 Ok(match kind {
161 "string" => Query::String(value.to_string()),
162 "literal" => Query::StringLiteral(value.to_string()),
163 "type" => Query::Type(value.to_string()),
164 "extends" => Query::Extends(crate::names::format_class_name(value)?),
165 "impl" => Query::Implements(crate::names::format_class_name(value)?),
166 "override" => Query::Defined(Defined::Override(member_query("method", None, false, Some(value), false)?)),
167 "annotation" => Query::Defined(Defined::Annotation(crate::defs::annotation_descriptor(value)?)),
168 "flag" => Query::Defined(Defined::Flags(crate::defs::parse_flags(value)?)),
169 _ => Query::Field(member_query("field", None, false, Some(value), false)?),
170 })
171 }
172
173 fn method(&mut self, nested: bool) -> Result<Query> {
176 let mode = ["={", "=@", "*{", "*@"].iter().find(|p| self.rest().starts_with(**p)).map(|p| p.as_bytes()[0]);
179 if let Some(m) = mode {
180 self.eat(char::from(m));
181 }
182 let dispatch = |set| match mode {
183 Some(b'=') => set,
184 Some(_) => Query::Virtual { target: Box::new(set), via: Default::default(), fan_out: None },
185 None => Query::Virtual { target: Box::new(set), via: Default::default(), fan_out: Some(crate::compose::DEFAULT_FAN_OUT) },
186 };
187 let target = if self.eat('{') {
188 let inner = self.rule(true)?;
189 if !self.eat('}') {
190 return Err(self.error("expected `}`"));
191 }
192 dispatch(Query::Nested(Box::new(inner)))
193 } else if self.eat('@') {
194 let path = self.text_until(nested, Stop::Constraint)?.trim();
195 dispatch(self.method_file(path)?)
196 } else {
197 let target = self.text_until(nested, Stop::Constraint)?.trim();
198 Query::Method(member_query("method", None, false, Some(target), false)?)
199 };
200 let mut constraints = Vec::new();
201 while self.eat('[') {
202 loop {
203 constraints.push(self.constraint()?);
204 if self.eat(']') {
205 break;
206 }
207 if !self.eat(',') {
208 return Err(self.error("expected `,` or `]` after a constraint"));
209 }
210 }
211 }
212 if !self.rest()[..text_len(self.rest(), nested)].trim().is_empty() {
213 return Err(self.error("unexpected text after the method"));
214 }
215 Ok(if constraints.is_empty() { target } else { Query::Call { target: Box::new(target), constraints } })
216 }
217
218 fn constraint(&mut self) -> Result<CallConstraint> {
220 self.skip_ws();
221 let key_len = self.rest().find(|c: char| !c.is_ascii_alphanumeric()).unwrap_or(self.rest().len());
222 let key = &self.rest()[..key_len];
223 let slot = match key {
224 "ret" => None,
225 "this" => Some(Slot::This),
226 _ => match key.strip_prefix("arg").and_then(|n| n.parse().ok()) {
227 Some(n) => Some(Slot::Arg(n)),
228 None => return Err(self.error("expected argN, this or ret in a constraint")),
229 },
230 };
231 self.pos += key_len;
232 let certainty = if self.eat('=') {
233 if self.rest().starts_with('=') {
234 self.pos += 1;
235 Certainty::Must
236 } else {
237 Certainty::May
238 }
239 } else {
240 return Err(self.error("expected `=` or `==` in a constraint"));
241 };
242 self.skip_ws();
243 let Some(slot) = slot else {
244 let len = item_len(self.rest());
245 let want = match self.rest()[..len].trim() {
246 "used" => ResultWant::Used,
247 "unused" => ResultWant::Unused,
248 _ => return Err(self.error("ret takes used or unused")),
249 };
250 self.pos += len;
251 return Ok(CallConstraint::Result { want, certainty });
252 };
253 let want = self.want()?;
254 Ok(CallConstraint::Value { slot, want, certainty })
255 }
256
257 fn want(&mut self) -> Result<Want> {
259 if self.rest().starts_with('"') {
260 return Ok(Want::Str(self.quoted()?));
261 }
262 let regex = self.rest().starts_with('~');
263 if regex {
264 self.pos += 1;
265 if self.rest().starts_with('"') {
266 return Ok(Want::StrRegex(self.quoted()?));
267 }
268 }
269 let len = item_len(self.rest());
270 let src: &'a str = self.src;
271 let raw = src[self.pos..self.pos + len].trim();
272 if raw.is_empty() {
273 return Err(self.error("empty value in a constraint"));
274 }
275 self.pos += len;
276 if regex {
277 return Ok(Want::StrRegex(raw.to_string()));
278 }
279 let bad = |what: &str| EngineError::Query(format!("bad rule {:?}: {what} in constraint value {raw:?}", self.src));
280 if let Some((kind, target)) = raw.split_once(':') {
281 let target = target.trim();
282 return Ok(match kind {
283 "class" => Want::Class(format_class_name(target)?),
284 "new" => Want::New(format_class_name(target)?),
285 "ret" => Want::Ret(member_query("method", None, false, Some(target), false)?),
286 "field" => Want::Field(member_query("field", None, false, Some(target), false)?),
287 _ => return Err(bad("expected class:, new:, ret: or field:")),
288 });
289 }
290 Ok(match raw {
291 "*" => Want::Any,
292 "true" => Want::Int(1),
293 "false" | "null" => Want::Int(0),
294 "param" => Want::Param(None),
295 _ => match raw.strip_prefix("param") {
296 Some(k) => Want::Param(Some(k.parse().map_err(|_| bad("expected paramK"))?)),
297 None => Want::Int(
298 parse_int(raw).ok_or_else(|| bad("expected *, a number, \"text\", ~regex, param, class:, new:, ret: or field:"))?,
299 ),
300 },
301 })
302 }
303
304 fn quoted(&mut self) -> Result<String> {
306 let mut out = String::new();
307 let mut chars = self.rest().char_indices().skip(1);
308 while let Some((i, c)) = chars.next() {
309 match c {
310 '"' => {
311 self.pos += i + 1;
312 return Ok(out);
313 }
314 '\\' => match chars.next() {
315 Some((_, 'n')) => out.push('\n'),
316 Some((_, 't')) => out.push('\t'),
317 Some((_, c)) => out.push(c),
318 None => break,
319 },
320 c => out.push(c),
321 }
322 }
323 Err(self.error("unterminated `\"`"))
324 }
325
326 fn text(&mut self, nested: bool) -> Result<&'a str> {
329 self.text_until(nested, Stop::Text)
330 }
331
332 fn text_until(&mut self, nested: bool, stop: Stop) -> Result<&'a str> {
335 let start = self.pos;
336 let len = text_len(self.rest(), nested);
337 self.pos += match stop {
338 Stop::Text => len,
339 Stop::Constraint => constraint_start(&self.rest()[..len]).unwrap_or(len),
340 };
341 let value = &self.src[start..self.pos];
342 if value.trim().is_empty() {
343 return Err(self.error("empty value"));
344 }
345 Ok(value)
346 }
347
348 fn method_file(&self, path: &str) -> Result<Query> {
351 let full = self.dir.map_or_else(|| Path::new(path).to_path_buf(), |d| d.join(path));
353 let text = std::fs::read_to_string(&full).map_err(|e| EngineError::Query(format!("cannot read method set {path}: {e}")))?;
354 Ok(Query::MethodSet(method_set(&text).map_err(|e| EngineError::Query(format!("{path}: {e}")))?))
355 }
356}
357
358#[derive(Clone, Copy)]
360enum Stop {
361 Text,
363 Constraint,
365}
366
367pub fn has_constraints(target: &str) -> bool {
369 constraint_start(target).is_some()
370}
371
372fn constraint_start(s: &str) -> Option<usize> {
376 s.match_indices('[').map(|(i, _)| i).find(|&i| {
377 let c = s[i + 1..].trim_start();
378 c.starts_with("ret") || c.starts_with("this") || c.strip_prefix("arg").is_some_and(|n| n.starts_with(|d: char| d.is_ascii_digit()))
379 })
380}
381
382fn item_len(s: &str) -> usize {
385 let mut depth = 0usize;
386 let mut chars = s.char_indices();
387 while let Some((i, c)) = chars.next() {
388 match c {
389 '\\' => {
390 chars.next();
391 }
392 '(' | '{' => depth += 1,
393 ')' | '}' if depth > 0 => depth -= 1,
394 ',' | ']' if depth == 0 => return i,
395 _ => {}
396 }
397 }
398 s.len()
399}
400
401fn parse_int(s: &str) -> Option<i64> {
403 let (neg, digits) = s.strip_prefix('-').map_or((false, s), |d| (true, d));
404 let n = match digits.strip_prefix("0x").or_else(|| digits.strip_prefix("0X")) {
405 Some(hex) => {
407 let n = u64::from_str_radix(hex, 16).ok()?;
408 match u32::try_from(n) {
409 Ok(int) => i64::from(i32::from_ne_bytes(int.to_ne_bytes())),
410 Err(_) => i64::from_ne_bytes(n.to_ne_bytes()),
411 }
412 }
413 None => digits.parse().ok()?,
414 };
415 Some(if neg { n.checked_neg()? } else { n })
416}
417
418fn text_len(s: &str, nested: bool) -> usize {
421 let mut depth = 0usize;
422 let mut chars = s.char_indices();
423 while let Some((i, c)) = chars.next() {
424 match c {
425 '\\' => {
426 chars.next();
427 }
428 '{' | '(' => depth += 1,
429 '}' | ')' if depth > 0 => depth -= 1,
430 '}' if nested => return i,
431 '&' if depth == 0 => return i,
432 _ => {}
433 }
434 }
435 s.len()
436}
437
438#[cfg(test)]
439mod tests {
440 use super::*;
441 use crate::names::{format_class_name, parse_condition, parse_rule};
442
443 fn method_query(spec: &str) -> MemberQuery {
444 member_query("method", None, false, Some(spec), false).unwrap()
445 }
446
447 fn method(spec: &str) -> Query {
448 Query::Method(method_query(spec))
449 }
450
451 fn vq(target: Query) -> Query {
453 Query::Virtual { target: Box::new(target), via: Default::default(), fan_out: Some(crate::compose::DEFAULT_FAN_OUT) }
454 }
455
456 #[test]
457 fn parts_split_at_top_level_ampersands_only() {
458 let q = parse_rule(" string:a & !literal:b&method:Lc;->m(I)V ").unwrap();
459 assert_eq!(
460 q,
461 Query::All { all: vec![Query::String("a".into()), method("Lc;->m(I)V")], none: vec![Query::StringLiteral("b".into())] }
462 );
463 assert_eq!(parse_rule("string:(a&b)").unwrap(), Query::String("(a&b)".into()));
465 assert_eq!(parse_rule(r"string:a{1}\&b").unwrap(), Query::String(r"a{1}\&b".into()));
466 assert_eq!(
467 parse_condition("extends:android.app.Activity").unwrap(),
468 Query::Extends(format_class_name("android.app.Activity").unwrap())
469 );
470 assert_eq!(
472 parse_rule("method:Lc;->m([B)V & type:X").unwrap(),
473 Query::All { all: vec![method("Lc;->m([B)V"), Query::Type("X".into())], none: vec![] }
474 );
475 }
476
477 #[test]
478 fn method_bodies_nest_whole_rules() {
479 let inner = Query::All { all: vec![Query::StringLiteral("imei".into())], none: vec![Query::Type("Lx;".into())] };
480 assert_eq!(parse_rule("method:{literal:imei & !type:Lx;}").unwrap(), vq(Query::Nested(Box::new(inner.clone()))));
482 let q = parse_rule("method:{ method:{literal:imei & !type:Lx;} } & string:http").unwrap();
483 assert_eq!(
484 q,
485 Query::All {
486 all: vec![vq(Query::Nested(Box::new(vq(Query::Nested(Box::new(inner)))))), Query::String("http".into())],
487 none: vec![]
488 }
489 );
490 assert_eq!(parse_rule("method:{string:a{2}}").unwrap(), vq(Query::Nested(Box::new(Query::String("a{2}".into())))));
492 }
493
494 fn call(target: Query, constraints: Vec<CallConstraint>) -> Query {
495 Query::Call { target: Box::new(target), constraints }
496 }
497
498 fn arg(n: u16, want: Want, certainty: Certainty) -> CallConstraint {
499 CallConstraint::Value { slot: Slot::Arg(n), want, certainty }
500 }
501
502 #[test]
503 fn method_targets_take_call_constraints() {
504 use Certainty::{May, Must};
505 let target = || method("Lc;->m([BLjava/lang/String;)V");
506 assert_eq!(
507 parse_rule(r#"method:Lc;->m([BLjava/lang/String;)V [arg1="a,]\"b", this==new:x.Y][ret=unused]"#).unwrap(),
508 call(
509 target(),
510 vec![
511 arg(1, Want::Str("a,]\"b".into()), May),
512 CallConstraint::Value { slot: Slot::This, want: Want::New("Lx/Y;".into()), certainty: Must },
513 CallConstraint::Result { want: ResultWant::Unused, certainty: May },
514 ]
515 )
516 );
517 for (value, want) in [
518 ("*", Want::Any),
519 ("-12", Want::Int(-12)),
520 ("0x10", Want::Int(16)),
521 ("0xffffffff", Want::Int(-1)),
522 ("true", Want::Int(1)),
523 ("null", Want::Int(0)),
524 ("param", Want::Param(None)),
525 ("param3", Want::Param(Some(3))),
526 ("~^ro\\.(serial|boot){1,2}", Want::StrRegex("^ro\\.(serial|boot){1,2}".into())),
527 ("~\"a]b\"", Want::StrRegex("a]b".into())),
528 ("class:java.lang.String", Want::Class("Ljava/lang/String;".into())),
529 ("ret:android.content.Context->getSystemService", Want::Ret(method_query("android.content.Context->getSystemService"))),
530 ("field:Lc;->f:I", Want::Field(member_query("field", None, false, Some("Lc;->f:I"), false).unwrap())),
531 ] {
532 assert_eq!(parse_rule(&format!("method:x[arg0={value}]")).unwrap(), call(method("x"), vec![arg(0, want, May)]), "{value}");
533 }
534 assert_eq!(
536 parse_rule("method:x[arg0=~a&b] & string:s").unwrap(),
537 Query::All {
538 all: vec![call(method("x"), vec![arg(0, Want::StrRegex("a&b".into()), May)]), Query::String("s".into())],
539 none: vec![]
540 }
541 );
542 let q = parse_rule("method:{literal:imei}[arg0=*]").unwrap();
544 assert_eq!(q, call(vq(Query::Nested(Box::new(Query::StringLiteral("imei".into())))), vec![arg(0, Want::Any, May)]));
545 let dir = tempfile::tempdir().unwrap();
546 std::fs::write(dir.path().join("set.txt"), "Lc;->m()V\n").unwrap();
547 let q = parse_rule_in("method:@set.txt[ret==used]", Some(dir.path())).unwrap();
548 assert!(matches!(&q, Query::Call { target, .. } if matches!(**target, Query::Virtual { .. })), "{q:?}");
549 assert!(!has_constraints("Lc;->m([B[Ljava/lang/String;)V") && has_constraints("x[ret=used]"));
551 assert_eq!(parse_rule("string:[arg0]").unwrap(), Query::String("[arg0]".into()));
552 }
553
554 #[test]
555 fn definition_predicates_parse_into_defined_queries() {
556 use asc_dex::access;
557 assert_eq!(parse_rule("flag:public,static").unwrap(), Query::Defined(Defined::Flags(access::PUBLIC | access::STATIC)));
558 assert_eq!(parse_rule("annotation:a.B").unwrap(), Query::Defined(Defined::Annotation("La/B;".into())));
559 let Query::Defined(Defined::Override(m)) = parse_rule("override:a.B->run").unwrap() else { panic!("an override") };
560 assert_eq!((m.name.as_deref(), m.class.unwrap().pattern.as_str()), (Some("run"), "La/B;"));
561 assert_eq!(parse_rule("in:{extends:a.Base}").unwrap(), Query::Defined(Defined::In(Box::new(format_extends("a.Base")))));
562 let Query::All { all, none } = parse_rule("string:x & flag:native & !annotation:a.B").unwrap() else { panic!("a compound") };
564 assert_eq!(all.len(), 2);
565 assert_eq!(none, vec![Query::Defined(Defined::Annotation("La/B;".into()))]);
566 for (spec, want) in [("flag:bogus", "use public"), ("in:extends:a.B", "in: takes a class condition")] {
569 assert!(parse_rule(spec).unwrap_err().to_string().contains(want), "{spec}");
570 }
571 assert!(matches!(parse_rule("in:{string:x}").unwrap(), Query::Defined(Defined::In(_))));
572 }
573
574 fn format_extends(c: &str) -> Query {
575 Query::Extends(format_class_name(c).unwrap())
576 }
577
578 #[test]
579 fn method_sets_follow_dispatch_unless_prefixed_with_eq() {
580 use crate::compose::DEFAULT_FAN_OUT;
583 let Query::Virtual { target, via, fan_out } = parse_rule("method:{literal:x}").unwrap() else { panic!("a dispatch set") };
584 assert!(matches!(*target, Query::Nested(_)) && via.is_empty() && fan_out == Some(DEFAULT_FAN_OUT));
585 let Query::Virtual { fan_out, .. } = parse_rule("method:*{literal:x}").unwrap() else { panic!("a dispatch set") };
586 assert_eq!(fan_out, None);
587 assert!(matches!(parse_rule("method:={literal:x}").unwrap(), Query::Nested(_)));
588 let Query::Call { target, .. } = parse_rule("method:{literal:x}[arg0=1]").unwrap() else { panic!("constrained") };
590 assert!(matches!(*target, Query::Virtual { .. }));
591 let dir = tempfile::tempdir().unwrap();
592 std::fs::write(dir.path().join("s.txt"), "La/B;->m()V\n").unwrap();
593 assert!(matches!(parse_rule_in("method:@s.txt", Some(dir.path())).unwrap(), Query::Virtual { .. }));
594 assert!(matches!(parse_rule_in("method:=@s.txt", Some(dir.path())).unwrap(), Query::MethodSet(_)));
595 assert!(matches!(parse_rule_in("method:*@s.txt", Some(dir.path())).unwrap(), Query::Virtual { fan_out: None, .. }));
596 }
597
598 #[test]
599 fn errors_name_the_problem() {
600 for (spec, want) in [
601 ("bogus", "expected KIND:VALUE"),
602 ("method:", "empty value"),
603 ("metod:x", "closest: method"),
604 ("method:{string:a", "expected `}`"),
605 ("method:{string:a} x", "unexpected text after the method"),
606 ("method:x[arg0=1] y", "unexpected text after the method"),
607 ("method:x[arg0=1", "expected `,` or `]`"),
608 ("method:x[arg0=1, foo=2]", "expected argN, this or ret"),
609 ("method:x[arg0 1]", "expected `=` or `==`"),
610 ("method:x[arg0=]", "empty value"),
611 ("method:x[arg0=\"a]", "unterminated"),
612 ("method:x[arg0=bogus:y]", "expected class:, new:, ret: or field:"),
613 ("method:x[arg0=paramx]", "expected paramK"),
614 ("method:x[arg0=1.5]", "expected *, a number"),
615 ("method:x[ret=maybe]", "ret takes used or unused"),
616 ("!string:a", "needs a condition without `!`"),
617 ("string:a & ", "expected KIND:VALUE"),
618 ("method:@", "empty value"),
619 ("method:@/nonexistent/set.txt", "cannot read method set"),
620 ] {
621 let e = parse_rule(spec).unwrap_err().to_string();
622 assert!(e.contains(want), "{spec}: {e}");
623 }
624 assert!(parse_condition("string:a & string:b").unwrap_err().to_string().contains("several conditions"));
625 }
626
627 #[test]
628 fn method_files_take_any_listing() {
629 let dir = tempfile::tempdir().unwrap();
630 let text = "# from findrefs\n\
631 classes2.dex | Lb/Lib;->sink()V | \"x->y\"\n\
632 r1 | classes.dex | La/A;->run(I)V | Lb/Lib;->sink()V\n\
633 \n\
634 \x20 2 <- La/A;->run(I)V (via Lx;)\n\
635 com.a.B.go\n";
636 std::fs::write(dir.path().join("set.txt"), text).unwrap();
637 let Query::Virtual { target, .. } = parse_rule_in("method:@set.txt", Some(dir.path())).unwrap() else { panic!("a dispatch set") };
638 let Query::MethodSet(set) = *target else { panic!("a method set") };
639 let want = [exact_method("La/A;->run(I)V").unwrap(), exact_method("Lb/Lib;->sink()V").unwrap(), method_query("com.a.B.go")];
640 assert_eq!(set, want);
641 let q = exact_method("LX/0TRC;->ʻ(I)Ljava/lang/String;").unwrap();
643 assert_eq!((q.name.as_deref(), q.exact_name, q.class.unwrap().pattern.as_str()), (Some("ʻ"), true, "LX/0TRC;"));
644 }
645}