filter.rs
⎇
Raw
1//! Query filters: `calendar-query` (RFC 4791, 9.7) and `addressbook-query`
2//! (RFC 6352, 10.5). Parsing and evaluation.
3
4use std::borrow::Cow;
5use std::cell::RefCell;
6use std::collections::HashMap;
7use std::ops::Range;
8use std::rc::Rc;
9
10use calcard::icalendar::{
11 ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarEntry, ICalendarParameterName,
12 ICalendarParameterValue, ICalendarProperty, ICalendarRelated, ICalendarValue,
13};
14use calcard::vcard::{VCard, VCardVersion};
15use chrono::{DateTime, NaiveDateTime, TimeDelta, Utc};
16use unicode_normalization::UnicodeNormalization;
17use xmltree::Element;
18
19use crate::expand::{Instance, expand, stamp};
20use crate::freebusy::periods;
21use crate::itip::endless;
22use crate::report::Refused;
23use crate::xml::{CALDAV, CARDDAV, Name, child, elements, text};
24use crate::zone::{Zone, Zones, add};
25
26pub type TimeRange = Range<DateTime<Utc>>;
27
28#[derive(Debug, Clone, PartialEq)]
29pub struct CompFilter {
30 /// Upper case.
31 pub name: String,
32 pub not_defined: bool,
33 pub time_range: Option<TimeRange>,
34 pub props: Vec<PropFilter>,
35 pub comps: Vec<CompFilter>,
36}
37
38#[derive(Debug, Clone, PartialEq)]
39pub struct PropFilter {
40 pub name: String,
41 pub not_defined: bool,
42 pub time_range: Option<TimeRange>,
43 pub text: Option<TextMatch>,
44 pub params: Vec<ParamFilter>,
45}
46
47#[derive(Debug, Clone, PartialEq)]
48pub struct ParamFilter {
49 pub name: String,
50 pub not_defined: bool,
51 pub text: Option<TextMatch>,
52}
53
54#[derive(Debug, Clone, PartialEq)]
55pub struct CardFilter {
56 /// `test="allof"`. The default is `anyof`.
57 pub all: bool,
58 pub props: Vec<CardPropFilter>,
59}
60
61#[derive(Debug, Clone, PartialEq)]
62pub struct CardPropFilter {
63 pub name: String,
64 pub all: bool,
65 pub not_defined: bool,
66 pub texts: Vec<TextMatch>,
67 pub params: Vec<ParamFilter>,
68}
69
70#[derive(Debug, Clone, PartialEq)]
71pub struct TextMatch {
72 pub text: String,
73 pub collation: Collation,
74 pub match_type: MatchType,
75 pub negate: bool,
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum Collation {
80 Octet,
81 AsciiCasemap,
82 UnicodeCasemap,
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum MatchType {
87 Equals,
88 Contains,
89 StartsWith,
90 EndsWith,
91}
92
93impl Collation {
94 fn fold<'a>(self, s: &'a str) -> Cow<'a, str> {
95 match self {
96 Collation::Octet => Cow::Borrowed(s),
97 Collation::AsciiCasemap => Cow::Owned(s.to_ascii_lowercase()),
98 // Lowercase after NFKD, so compatibility forms such as U+FB01
99 // (the "fi" ligature) fold too.
100 Collation::UnicodeCasemap => {
101 Cow::Owned(s.nfkd().flat_map(char::to_lowercase).collect())
102 }
103 }
104 }
105}
106
107impl TextMatch {
108 /// `ns` picks the defaults: CalDAV folds ASCII only and CardDAV Unicode.
109 fn parse(e: &Element, ns: &str) -> Result<Self, Refused> {
110 let collation = match e.attributes.get("collation").map(String::as_str) {
111 None if ns == CARDDAV => Collation::UnicodeCasemap,
112 None => Collation::AsciiCasemap,
113 Some("i;octet") => Collation::Octet,
114 Some("i;ascii-casemap") => Collation::AsciiCasemap,
115 Some("i;unicode-casemap") => Collation::UnicodeCasemap,
116 Some(_) => return Err(Refused::Condition(Name::new(ns, "supported-collation"))),
117 };
118 let match_type = match e.attributes.get("match-type").map(String::as_str) {
119 None | Some("contains") => MatchType::Contains,
120 Some("equals") => MatchType::Equals,
121 Some("starts-with") => MatchType::StartsWith,
122 Some("ends-with") => MatchType::EndsWith,
123 Some(_) => return Err(Refused::Invalid),
124 };
125 Ok(TextMatch {
126 text: text(e),
127 collation,
128 match_type,
129 negate: e.attributes.get("negate-condition").map(String::as_str) == Some("yes"),
130 })
131 }
132
133 pub fn matches(&self, value: &str) -> bool {
134 let (v, t) = (self.collation.fold(value), self.collation.fold(&self.text));
135 let hit = match self.match_type {
136 MatchType::Equals => v == t,
137 MatchType::Contains => v.contains(&*t),
138 MatchType::StartsWith => v.starts_with(&*t),
139 MatchType::EndsWith => v.ends_with(&*t),
140 };
141 hit != self.negate
142 }
143}
144
145// ---------------------------------------------------------------------------
146// Parsing
147// ---------------------------------------------------------------------------
148
149/// The `<C:filter>` of a calendar-query.
150pub fn calendar_filter(e: &Element) -> Result<CompFilter, Refused> {
151 let root = comp_filter(child(e, CALDAV, "comp-filter").ok_or(Refused::Invalid)?)?;
152 if root.name != "VCALENDAR" {
153 return Err(Refused::Condition(Name::new(CALDAV, "valid-filter")));
154 }
155 Ok(root)
156}
157
158fn comp_filter(e: &Element) -> Result<CompFilter, Refused> {
159 let mut f = CompFilter {
160 name: name_attr(e)?.to_ascii_uppercase(),
161 not_defined: false,
162 time_range: None,
163 props: Vec::new(),
164 comps: Vec::new(),
165 };
166 for c in elements(e).filter(|c| Name::of(c).ns == CALDAV) {
167 match c.name.as_str() {
168 "is-not-defined" => f.not_defined = true,
169 "time-range" => f.time_range = Some(time_range(c)?),
170 "prop-filter" => f.props.push(prop_filter(c)?),
171 "comp-filter" => f.comps.push(comp_filter(c)?),
172 _ => {}
173 }
174 }
175 Ok(f)
176}
177
178fn prop_filter(e: &Element) -> Result<PropFilter, Refused> {
179 let mut f = PropFilter {
180 name: name_attr(e)?.to_string(),
181 not_defined: false,
182 time_range: None,
183 text: None,
184 params: Vec::new(),
185 };
186 for c in elements(e).filter(|c| Name::of(c).ns == CALDAV) {
187 match c.name.as_str() {
188 "is-not-defined" => f.not_defined = true,
189 "time-range" => f.time_range = Some(time_range(c)?),
190 "text-match" => f.text = Some(TextMatch::parse(c, CALDAV)?),
191 "param-filter" => f.params.push(param_filter(c, CALDAV)?),
192 _ => {}
193 }
194 }
195 Ok(f)
196}
197
198fn param_filter(e: &Element, ns: &str) -> Result<ParamFilter, Refused> {
199 let mut f = ParamFilter {
200 name: name_attr(e)?.to_string(),
201 not_defined: false,
202 text: None,
203 };
204 for c in elements(e).filter(|c| Name::of(c).ns == ns) {
205 match c.name.as_str() {
206 "is-not-defined" => f.not_defined = true,
207 "text-match" => f.text = Some(TextMatch::parse(c, ns)?),
208 _ => {}
209 }
210 }
211 Ok(f)
212}
213
214/// The `<CR:filter>` of an addressbook-query.
215pub fn card_filter(e: &Element) -> Result<CardFilter, Refused> {
216 let props = elements(e)
217 .filter(|c| Name::of(c).is(CARDDAV, "prop-filter"))
218 .map(card_prop_filter)
219 .collect::<Result<_, _>>()?;
220 Ok(CardFilter {
221 all: all_of(e)?,
222 props,
223 })
224}
225
226fn card_prop_filter(e: &Element) -> Result<CardPropFilter, Refused> {
227 let mut f = CardPropFilter {
228 name: name_attr(e)?.to_string(),
229 all: all_of(e)?,
230 not_defined: false,
231 texts: Vec::new(),
232 params: Vec::new(),
233 };
234 for c in elements(e).filter(|c| Name::of(c).ns == CARDDAV) {
235 match c.name.as_str() {
236 "is-not-defined" => f.not_defined = true,
237 "text-match" => f.texts.push(TextMatch::parse(c, CARDDAV)?),
238 "param-filter" => f.params.push(param_filter(c, CARDDAV)?),
239 _ => {}
240 }
241 }
242 Ok(f)
243}
244
245fn all_of(e: &Element) -> Result<bool, Refused> {
246 match e.attributes.get("test").map(String::as_str) {
247 None | Some("anyof") => Ok(false),
248 Some("allof") => Ok(true),
249 Some(_) => Err(Refused::Invalid),
250 }
251}
252
253fn name_attr(e: &Element) -> Result<&str, Refused> {
254 e.attributes
255 .get("name")
256 .map(String::as_str)
257 .ok_or(Refused::Invalid)
258}
259
260/// A `time-range` or `expand` element. A missing bound is open.
261pub fn time_range(e: &Element) -> Result<TimeRange, Refused> {
262 let at = |k: &str| {
263 e.attributes
264 .get(k)
265 .map(|v| {
266 NaiveDateTime::parse_from_str(v, "%Y%m%dT%H%M%SZ")
267 .map(|t| t.and_utc())
268 .map_err(|_| Refused::Invalid)
269 })
270 .transpose()
271 };
272 let start = at("start")?.unwrap_or(DateTime::<Utc>::MIN_UTC);
273 let end = at("end")?.unwrap_or(DateTime::<Utc>::MAX_UTC);
274 if start >= end {
275 return Err(Refused::Invalid);
276 }
277 Ok(start..end)
278}
279
280// ---------------------------------------------------------------------------
281// Calendar evaluation
282// ---------------------------------------------------------------------------
283
284/// Whether a calendar object matches `filter`. `floating` interprets values
285/// without a zone.
286pub fn matches_calendar(cal: &ICalendar, filter: &CompFilter, floating: &Zone) -> bool {
287 let alarm_reach = cal
288 .components
289 .iter()
290 .filter(|c| c.component_type == ICalendarComponentType::VAlarm)
291 .filter_map(relative_reach)
292 .max()
293 .unwrap_or_default();
294 let ctx = Ctx {
295 cal,
296 zones: Zones::new(cal, floating.clone()),
297 floating,
298 alarm_reach,
299 expanded: RefCell::default(),
300 };
301 ctx.comp(None, filter)
302}
303
304struct Ctx<'a> {
305 cal: &'a ICalendar,
306 zones: Zones,
307 floating: &'a Zone,
308 /// The widest reach of any relative alarm, so all alarms share one
309 /// expansion.
310 alarm_reach: TimeDelta,
311 /// One expansion per window, not one per component.
312 expanded: RefCell<HashMap<TimeRange, Rc<Window>>>,
313}
314
315/// The instances of one expansion by component.
316struct Window {
317 of: HashMap<usize, Vec<Instance>>,
318 truncated: bool,
319}
320
321/// The repetitions of alarm `a` and the time between them.
322fn repetitions(a: &ICalendarComponent) -> (i32, TimeDelta) {
323 let repeat = a
324 .property(&ICalendarProperty::Repeat)
325 .and_then(|e| e.values.first()?.as_integer())
326 .unwrap_or(0)
327 .clamp(0, 1000) as i32;
328 let every = match a
329 .property(&ICalendarProperty::Duration)
330 .and_then(|e| e.values.first())
331 {
332 Some(ICalendarValue::Duration(d)) => d.to_time_delta().unwrap_or_default(),
333 _ => TimeDelta::zero(),
334 };
335 (repeat, every)
336}
337
338/// How far from its instance a relative alarm, repetitions included, can
339/// fire, plus one second. `None` for an absolute trigger.
340fn relative_reach(a: &ICalendarComponent) -> Option<TimeDelta> {
341 let Some(ICalendarValue::Duration(d)) = a.property(&ICalendarProperty::Trigger)?.values.first()
342 else {
343 return None;
344 };
345 let (repeat, every) = repetitions(a);
346 Some(
347 d.to_time_delta()?
348 .abs()
349 .checked_add(&every.checked_mul(repeat).unwrap_or(TimeDelta::MAX))
350 .unwrap_or(TimeDelta::MAX)
351 .checked_add(&TimeDelta::seconds(1))
352 .unwrap_or(TimeDelta::MAX),
353 )
354}
355
356impl Ctx<'_> {
357 fn expand(&self, window: TimeRange) -> Rc<Window> {
358 self.expanded
359 .borrow_mut()
360 .entry(window.clone())
361 .or_insert_with(|| {
362 let exp = expand(self.cal, window, self.floating.clone());
363 let mut of: HashMap<usize, Vec<Instance>> = HashMap::new();
364 for x in exp.instances {
365 of.entry(x.component).or_default().push(x);
366 }
367 Rc::new(Window {
368 of,
369 truncated: exp.truncated,
370 })
371 })
372 .clone()
373 }
374
375 fn comp(&self, parent: Option<usize>, f: &CompFilter) -> bool {
376 let children: Vec<usize> = match parent {
377 None => (!self.cal.components.is_empty())
378 .then_some(0)
379 .into_iter()
380 .collect(),
381 Some(p) => self.cal.components[p]
382 .component_ids
383 .iter()
384 .map(|&i| i as usize)
385 .collect(),
386 };
387 let mut found = children.into_iter().filter(|&i| {
388 self.cal.components[i]
389 .component_type
390 .as_str()
391 .eq_ignore_ascii_case(&f.name)
392 });
393 if f.not_defined {
394 return found.next().is_none();
395 }
396 found.any(|i| {
397 f.time_range.as_ref().is_none_or(|r| self.overlaps(i, r))
398 && f.props.iter().all(|p| self.prop(i, p))
399 && f.comps.iter().all(|c| self.comp(Some(i), c))
400 })
401 }
402
403 /// The time-range rules of RFC 4791, 9.9.
404 fn overlaps(&self, i: usize, r: &TimeRange) -> bool {
405 let c = &self.cal.components[i];
406 let has = |p: ICalendarProperty| c.has_property(&p);
407 let time = |p: ICalendarProperty| {
408 let e = c.property(&p)?;
409 stamp(
410 &self.zones,
411 e.values.first()?.as_partial_date_time()?,
412 e.tz_id(),
413 )
414 .map(|s| s.utc())
415 };
416 match c.component_type {
417 ICalendarComponentType::VEvent
418 | ICalendarComponentType::VTodo
419 | ICalendarComponentType::VJournal
420 if has(ICalendarProperty::Dtstart) =>
421 {
422 if r.end == DateTime::<Utc>::MAX_UTC && endless(&self.cal.components[i]) {
423 return true;
424 }
425 let todo = c.component_type == ICalendarComponentType::VTodo;
426 let (due, duration) = (
427 has(ICalendarProperty::Due),
428 has(ICalendarProperty::Duration),
429 );
430 // One second wider, so that the exact rules below decide the
431 // instances that only touch the range.
432 let second = TimeDelta::seconds(1);
433 let exp = self.expand(add(r.start, -second)..add(r.end, second));
434 // Unknown instances may overlap, so the object stays in.
435 if exp.truncated {
436 return true;
437 }
438 exp.of.get(&i).into_iter().flatten().any(|x| {
439 let (s, e) = (x.start, x.end);
440 match (todo, due, duration) {
441 (true, _, true) => r.start <= e && (r.end > s || r.end >= e),
442 (true, true, _) => {
443 (r.start < e || r.start <= s) && (r.end > s || r.end >= e)
444 }
445 (true, ..) => r.start <= s && r.end > s,
446 _ if s == e => r.start <= s && r.end > s,
447 _ => r.start < e && r.end > s,
448 }
449 })
450 }
451 ICalendarComponentType::VTodo => match (
452 time(ICalendarProperty::Due),
453 time(ICalendarProperty::Completed),
454 time(ICalendarProperty::Created),
455 ) {
456 (Some(due), _, _) => r.start < due && r.end >= due,
457 (None, Some(done), Some(made)) => {
458 (r.start <= made || r.start <= done) && (r.end >= made || r.end >= done)
459 }
460 (None, Some(done), None) => r.start <= done && r.end >= done,
461 (None, None, Some(made)) => r.end > made,
462 (None, None, None) => true,
463 },
464 ICalendarComponentType::VFreebusy => {
465 let busy: Vec<_> = c
466 .properties(&ICalendarProperty::Freebusy)
467 .flat_map(|e| periods(&self.zones, e))
468 .collect();
469 if !busy.is_empty() {
470 return busy.iter().any(|(s, e)| r.start < *e && r.end > *s);
471 }
472 match (
473 time(ICalendarProperty::Dtstart),
474 time(ICalendarProperty::Dtend),
475 ) {
476 (Some(s), Some(e)) => r.start <= e && r.end > s,
477 _ => false,
478 }
479 }
480 ICalendarComponentType::VAlarm => self.alarm_overlaps(i, r),
481 _ => false,
482 }
483 }
484
485 /// Whether a trigger of the alarm, repetitions included, falls into `r`.
486 /// A relative trigger fires once per instance of the parent component.
487 fn alarm_overlaps(&self, alarm: usize, r: &TimeRange) -> bool {
488 let a = &self.cal.components[alarm];
489 let Some(trigger) = a.property(&ICalendarProperty::Trigger) else {
490 return false;
491 };
492 let (repeat, every) = repetitions(a);
493 let hit = |base: DateTime<Utc>| {
494 (0..=repeat).any(|k| {
495 let t = add(base, every.checked_mul(k).unwrap_or(TimeDelta::MAX));
496 r.start <= t && r.end > t
497 })
498 };
499 match trigger.values.first() {
500 Some(ICalendarValue::PartialDateTime(p)) => {
501 stamp(&self.zones, p, trigger.tz_id()).is_some_and(|s| hit(s.utc()))
502 }
503 Some(ICalendarValue::Duration(d)) => {
504 let Some(offset) = d.to_time_delta() else {
505 return false;
506 };
507 let Some(parent) = self
508 .cal
509 .components
510 .iter()
511 .position(|c| c.component_ids.contains(&(alarm as u32)))
512 else {
513 return false;
514 };
515 if r.end == DateTime::<Utc>::MAX_UTC && endless(&self.cal.components[parent]) {
516 return true;
517 }
518 let owner = &self.cal.components[parent];
519 // A task without DTSTART has no instances. Its alarm can only
520 // relate to DUE (RFC 4791, 9.9).
521 if !owner.has_property(&ICalendarProperty::Dtstart) {
522 return owner
523 .property(&ICalendarProperty::Due)
524 .and_then(|e| {
525 stamp(
526 &self.zones,
527 e.values.first()?.as_partial_date_time()?,
528 e.tz_id(),
529 )
530 })
531 .is_some_and(|due| hit(add(due.utc(), offset)));
532 }
533 let from_end = trigger.parameter(&ICalendarParameterName::Related)
534 == Some(&ICalendarParameterValue::Related(ICalendarRelated::End));
535 let exp =
536 self.expand(add(r.start, -self.alarm_reach)..add(r.end, self.alarm_reach));
537 if exp.truncated {
538 return true;
539 }
540 exp.of
541 .get(&parent)
542 .into_iter()
543 .flatten()
544 .any(|x| hit(add(if from_end { x.end } else { x.start }, offset)))
545 }
546 _ => false,
547 }
548 }
549
550 fn prop(&self, i: usize, f: &PropFilter) -> bool {
551 let mut found = self.cal.components[i]
552 .entries
553 .iter()
554 .filter(|e| e.name.as_str().eq_ignore_ascii_case(&f.name));
555 if f.not_defined {
556 return found.next().is_none();
557 }
558 found.any(|e| {
559 let l = ical_line(e);
560 f.time_range.as_ref().is_none_or(|r| {
561 e.values
562 .iter()
563 .filter_map(|v| stamp(&self.zones, v.as_partial_date_time()?, e.tz_id()))
564 .any(|s| r.start <= s.utc() && r.end > s.utc())
565 }) && f.text.as_ref().is_none_or(|t| t.matches(&l.value))
566 && f.params.iter().all(|p| param_matches(&l, p))
567 })
568 }
569}
570
571// ---------------------------------------------------------------------------
572// Card evaluation
573// ---------------------------------------------------------------------------
574
575pub fn matches_card(card: &VCard, f: &CardFilter) -> bool {
576 if f.props.is_empty() {
577 return true;
578 }
579 let v4 = card.version() == Some(VCardVersion::V4_0);
580 let lines: Vec<Line> = card
581 .entries
582 .iter()
583 .map(|e| {
584 let mut s = String::new();
585 let _ = e.write_to(&mut s, v4);
586 line(&s)
587 })
588 .collect();
589 let hit = |p: &CardPropFilter| card_prop(&lines, p);
590 if f.all {
591 f.props.iter().all(hit)
592 } else {
593 f.props.iter().any(hit)
594 }
595}
596
597/// Each text-match and param-filter is tested against every instance of the
598/// property; `test` combines their results.
599fn card_prop(lines: &[Line], f: &CardPropFilter) -> bool {
600 let found: Vec<&Line> = lines
601 .iter()
602 .filter(|l| l.name.eq_ignore_ascii_case(&f.name))
603 .collect();
604 if f.not_defined {
605 return found.is_empty();
606 }
607 if found.is_empty() {
608 return false;
609 }
610 let mut tests = f
611 .texts
612 .iter()
613 .map(|t| found.iter().any(|l| t.matches(&l.value)))
614 .chain(
615 f.params
616 .iter()
617 .map(|p| found.iter().any(|l| param_matches(l, p))),
618 )
619 .peekable();
620 if tests.peek().is_none() {
621 return true;
622 }
623 if f.all {
624 tests.all(|b| b)
625 } else {
626 tests.any(|b| b)
627 }
628}
629
630fn param_matches(l: &Line, f: &ParamFilter) -> bool {
631 match l
632 .params
633 .iter()
634 .find(|(n, _)| n.eq_ignore_ascii_case(&f.name))
635 {
636 None => f.not_defined,
637 Some(_) if f.not_defined => false,
638 Some((_, values)) => f
639 .text
640 .as_ref()
641 .is_none_or(|t| values.iter().any(|v| t.matches(v))),
642 }
643}
644
645// ---------------------------------------------------------------------------
646// Content lines
647// ---------------------------------------------------------------------------
648
649/// A property as text, the way text-match sees it: the group dropped, the
650/// parameter values unquoted, the value unescaped.
651struct Line {
652 name: String,
653 params: Vec<(String, Vec<String>)>,
654 value: String,
655}
656
657fn ical_line(e: &ICalendarEntry) -> Line {
658 let mut s = String::new();
659 let _ = e.write_to(&mut s);
660 line(&s)
661}
662
663/// Parses one content line as calcard's writer produces it.
664fn line(written: &str) -> Line {
665 let unfolded = written.replace("\r\n ", "").replace("\r\n\t", "");
666 let unfolded = unfolded.trim_end_matches(['\r', '\n']);
667 let head = split_unquoted(unfolded, ':')[0];
668 let value = unfolded.get(head.len() + 1..).unwrap_or_default();
669 let mut head = split_unquoted(head, ';').into_iter();
670 let name = head.next().unwrap_or_default();
671 let name = name.rsplit('.').next().unwrap_or(name).to_ascii_uppercase();
672 let params = head
673 .filter_map(|p| p.split_once('='))
674 .map(|(k, v)| {
675 let values = split_unquoted(v, ',')
676 .into_iter()
677 .map(|v| v.trim_matches('"').to_string())
678 .collect();
679 (k.to_ascii_uppercase(), values)
680 })
681 .collect();
682 Line {
683 name,
684 params,
685 value: unescape(value),
686 }
687}
688
689fn split_unquoted(s: &str, sep: char) -> Vec<&str> {
690 let mut out = Vec::new();
691 let (mut quoted, mut from) = (false, 0);
692 for (i, c) in s.char_indices() {
693 if c == '"' {
694 quoted = !quoted;
695 } else if c == sep && !quoted {
696 out.push(&s[from..i]);
697 from = i + 1;
698 }
699 }
700 out.push(&s[from..]);
701 out
702}
703
704fn unescape(s: &str) -> String {
705 let mut out = String::with_capacity(s.len());
706 let mut chars = s.chars();
707 while let Some(c) = chars.next() {
708 if c != '\\' {
709 out.push(c);
710 continue;
711 }
712 match chars.next() {
713 Some('n' | 'N') => out.push('\n'),
714 Some(c) => out.push(c),
715 None => out.push('\\'),
716 }
717 }
718 out
719}
720