css_to_xpath/translate/error.rs
1//! Error types for selector translation.
2//!
3//! An [`Error`] is self-describing: its [`Display`](std::fmt::Display)
4//! impl names the construct at fault without needing the selector back,
5//! so an error that has travelled through a few layers can still be
6//! printed. [`Error::message`] additionally takes the selector and
7//! renders the full diagnostic — the quoted selector and, whenever the
8//! error knows a position, a caret gutter. The exact wording of both is
9//! part of this crate's output contract and is pinned by tests.
10//!
11//! Messages are bounded, so that a caller can print one whatever it was
12//! handed: an echoed token is elided past [`MAX_TOKEN_ECHO`] bytes, the
13//! quoted selector past [`MAX_SELECTOR_ECHO`] bytes, and a caret gutter
14//! shows a window of the line the error is on rather than the whole
15//! selector. A 30 KB selector therefore still yields a message of a few
16//! hundred bytes, with the caret intact.
17//!
18//! Nothing here echoes a dependency's `Debug` output: every parse
19//! failure is mapped to a [`ParseErrorKind`] of this crate's own, so the
20//! text a user sees does not change when `selectors` or `cssparser`
21//! renames one of its internal error variants.
22
23use std::fmt;
24
25use cssparser::{BasicParseErrorKind, ParseErrorKind as CssErrorKind, ToCss, Token};
26use selectors::parser::SelectorParseErrorKind;
27use unicode_width::UnicodeWidthChar;
28
29/// Bytes of the selector reproduced in a message's opening quote before
30/// the rest is elided with `…`. A selector at or under this length is
31/// quoted exactly as `{:?}` would quote it.
32const MAX_SELECTOR_ECHO: usize = 120;
33
34/// Bytes of a single offending token echoed in a [`ParseErrorKind`]
35/// before the rest is elided with `…`. Tokens are usually a character or
36/// two, but a name is only bounded by the selector's length.
37const MAX_TOKEN_ECHO: usize = 40;
38
39/// Display columns of the error's line kept around the caret in a
40/// gutter: enough for the offending compound and its neighbours, and
41/// narrow enough to survive an 80-column terminal alongside the
42/// two-space gutter.
43const MAX_GUTTER_WIDTH: usize = 72;
44
45/// Why a CSS selector could not be translated.
46///
47/// The variants split by *whose* rules were broken: [`Error::Parse`] for
48/// a selector CSS itself rejects, [`Error::Unsupported`] for a valid
49/// selector this crate declines to approximate.
50///
51/// A [`Error::Parse`] always knows the offending byte position, so it
52/// always renders a caret. A [`Error::Unsupported`] knows one for the
53/// constructs the pre-parse scan of the source text finds, and not for
54/// the ones the translator finds, because Servo's parsed components
55/// carry no source offsets to map a component back to the selector
56/// text. Its `offset` is therefore an [`Option`], and its message grows
57/// a caret only when it is `Some`.
58#[derive(Clone, Debug, Eq, PartialEq)]
59#[non_exhaustive]
60pub enum Error {
61 /// The selector is not valid CSS (as judged by Servo's parser).
62 Parse {
63 /// What is wrong with the selector.
64 kind: ParseErrorKind,
65 /// The 0-indexed *byte* offset of the error within the selector
66 /// string, used to render a caret pointer. It is
67 /// `selector.len()` for an error at end of input.
68 ///
69 /// Whenever `kind` echoes a piece of the selector — a token, a
70 /// pseudo-class name — this is where that piece was written,
71 /// not merely where the parse stopped, so a caller can
72 /// highlight it rather than its neighbour. For the kinds that
73 /// echo nothing, it is the stopping point.
74 offset: usize,
75 },
76 /// The selector is valid CSS, but uses a construct outside the
77 /// supported set: this crate errors rather than approximating.
78 Unsupported {
79 /// The offending construct, as a noun phrase (`` the `||` column
80 /// combinator ``) that reads as the object of "uses …".
81 construct: String,
82 /// The 0-indexed *byte* offset of the construct within the
83 /// selector string, when it is known; `None` when it is not.
84 /// See the variant split above for which constructs know it.
85 offset: Option<usize>,
86 },
87}
88
89impl Error {
90 /// Render the full user-facing message, naming the offending
91 /// selector — and, whenever the error carries a position, pointing a
92 /// caret at it.
93 ///
94 /// [`Display`](std::fmt::Display) is the one-line form for callers
95 /// that no longer hold the selector; this is the form to print when
96 /// they do.
97 #[must_use]
98 pub fn message(&self, selector: &str) -> String {
99 let quoted = quote(selector);
100 match self {
101 Error::Parse { kind, offset } => {
102 let (line, caret) = gutter(selector, *offset);
103 format!(
104 "Unable to parse the CSS selector {quoted}: {kind}\n |\n | {line}\n | {caret}"
105 )
106 }
107 Error::Unsupported {
108 construct,
109 offset: Some(offset),
110 } => {
111 let (line, caret) = gutter(selector, *offset);
112 format!(
113 "The CSS selector {quoted} uses {construct}, which this translator \
114 does not support\n |\n | {line}\n | {caret}"
115 )
116 }
117 Error::Unsupported {
118 construct,
119 offset: None,
120 } => format!(
121 "The CSS selector {quoted} uses {construct}, which this translator does not support"
122 ),
123 }
124 }
125
126 /// Deprecated alias for [`Error::message`], which borrows the error
127 /// rather than consuming it.
128 #[deprecated(since = "0.3.0", note = "use `Error::message`, which takes `&self`")]
129 #[must_use]
130 pub fn into_message(self, selector: &str) -> String {
131 self.message(selector)
132 }
133
134 /// An [`Error::Unsupported`] naming `construct`, with no position:
135 /// for the constructs found during translation, which Servo's
136 /// offset-free components cannot be mapped back to the source.
137 ///
138 /// The scan takes over every construct whose supportability is a
139 /// *lexical* fact, so that it can carry a position (see `Scan`).
140 /// What is left here needs the parsed compound to decide — whether
141 /// an of-type pseudo-class has a type to count siblings by, whether
142 /// a namespace prefix survives as an XPath name — and locating
143 /// *that* would mean a second, approximate model of where the
144 /// compounds are, which could point a caret at the wrong one of
145 /// several identical-looking constructs. A missing caret is the
146 /// better failure, so these stay positionless.
147 pub(crate) fn unsupported(construct: impl Into<String>) -> Self {
148 Error::Unsupported {
149 construct: construct.into(),
150 offset: None,
151 }
152 }
153
154 /// An [`Error::Unsupported`] naming `construct` at `offset`: for the
155 /// constructs the pre-parse scan finds, which walks the source text
156 /// and so knows where they are.
157 pub(crate) fn unsupported_at(construct: impl Into<String>, offset: usize) -> Self {
158 Error::Unsupported {
159 construct: construct.into(),
160 offset: Some(offset),
161 }
162 }
163}
164
165impl fmt::Display for Error {
166 /// The one-line form, which does not need the selector: enough to
167 /// identify the fault when the error is all a caller still has.
168 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
169 match self {
170 Error::Parse { kind, offset } => {
171 write!(f, "invalid CSS selector at byte {offset}: {kind}")
172 }
173 Error::Unsupported {
174 construct,
175 offset: Some(offset),
176 } => {
177 write!(f, "unsupported CSS construct at byte {offset}: {construct}")
178 }
179 Error::Unsupported {
180 construct,
181 offset: None,
182 } => {
183 write!(f, "unsupported CSS construct: {construct}")
184 }
185 }
186 }
187}
188
189impl std::error::Error for Error {}
190
191/// What is wrong with a selector that is not valid CSS.
192///
193/// This is a translation of the `selectors`/`cssparser` error kinds into
194/// wording of this crate's own, not a re-export of them: their variants
195/// are internal to those crates and several are unreachable from a
196/// selector parse. Anything with no closer match — including a variant a
197/// future version of either crate adds — becomes [`ParseErrorKind::Other`],
198/// so a dependency bump cannot turn into a panic.
199///
200/// Payloads that echo the selector (a token, a pseudo-class name) are
201/// sanitized: control characters are replaced, and the text is elided
202/// past 40 bytes (`MAX_TOKEN_ECHO`), so a message stays printable
203/// however long the selector is.
204#[derive(Clone, Debug, Eq, PartialEq)]
205#[non_exhaustive]
206pub enum ParseErrorKind {
207 /// The selector, or one group of a selector list, has nothing in
208 /// it: `""`, `" "`, `"a, , b"`. A group that *had* something, none
209 /// of which parsed — `#1abc` — is reported by the token that stopped
210 /// it instead.
211 EmptySelector,
212 /// A combinator with nothing after it, as in `div > `.
213 DanglingCombinator,
214 /// The selector ends in the middle of a construct.
215 EndOfInput,
216 /// A construct in a position that does not allow it: a
217 /// pseudo-element inside `:is()`, a combinator after one, a `:has()`
218 /// nested in another `:has()`.
219 InvalidPosition,
220 /// A token that cannot appear where it does, as CSS source text.
221 UnexpectedToken(String),
222 /// A name was required — after `.` or `::` — and something else was
223 /// found. Holds the offending token as CSS source text.
224 ExpectedName(String),
225 /// A pseudo-class or pseudo-element outside the supported set (which
226 /// is every pseudo-element: XPath 1.0 has no notion of one). Holds
227 /// the name, without its leading colons.
228 UnsupportedPseudo(String),
229 /// Something that cannot appear inside `[...]`, as CSS source text:
230 /// a malformed attribute name, operator, or value.
231 InvalidAttributeSelector(String),
232 /// A parse failure with no more specific kind here, already worded
233 /// as a phrase to be printed after "…selector `x`: ".
234 Other(String),
235}
236
237impl fmt::Display for ParseErrorKind {
238 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
239 match self {
240 ParseErrorKind::EmptySelector => f.write_str("the selector is empty"),
241 ParseErrorKind::DanglingCombinator => f.write_str("a combinator with nothing after it"),
242 ParseErrorKind::EndOfInput => f.write_str("the selector ends unexpectedly"),
243 ParseErrorKind::InvalidPosition => {
244 f.write_str("a construct that is not allowed in this position")
245 }
246 ParseErrorKind::UnexpectedToken(token) => write!(f, "unexpected `{token}`"),
247 ParseErrorKind::ExpectedName(token) => write!(f, "expected a name, found `{token}`"),
248 ParseErrorKind::UnsupportedPseudo(name) => {
249 // Named without its colons: the parser cannot tell how
250 // many were written, and `::before` reported as
251 // `` `:before` `` would be a third thing again.
252 write!(
253 f,
254 "`{name}` is not a supported pseudo-class or pseudo-element"
255 )
256 }
257 ParseErrorKind::InvalidAttributeSelector(token) => {
258 write!(f, "`{token}` is not valid in an attribute selector")
259 }
260 ParseErrorKind::Other(detail) => f.write_str(detail),
261 }
262 }
263}
264
265impl ParseErrorKind {
266 /// Translate one parse failure from the dependencies' vocabulary
267 /// into this crate's.
268 ///
269 /// The arms cover every kind the two crates actually produce while
270 /// parsing a selector; the rest — the `@`-rule kinds, which need a
271 /// stylesheet, and the several `selectors` variants nothing
272 /// constructs — fall through to [`ParseErrorKind::Other`], as would a
273 /// variant added upstream.
274 pub(crate) fn from_kind(kind: &CssErrorKind<'_, SelectorParseErrorKind<'_>>) -> Self {
275 use SelectorParseErrorKind as S;
276 match kind {
277 // A token after an explicit namespace prefix (`ns|5`) is
278 // just a token in the wrong place, so it joins the basic
279 // kind rather than earning wording of its own.
280 CssErrorKind::Basic(BasicParseErrorKind::UnexpectedToken(t))
281 | CssErrorKind::Custom(S::ExplicitNamespaceUnexpectedToken(t)) => {
282 ParseErrorKind::UnexpectedToken(token_text(t))
283 }
284 CssErrorKind::Basic(BasicParseErrorKind::EndOfInput) => ParseErrorKind::EndOfInput,
285 CssErrorKind::Custom(S::EmptySelector) => ParseErrorKind::EmptySelector,
286 CssErrorKind::Custom(S::DanglingCombinator) => ParseErrorKind::DanglingCombinator,
287 CssErrorKind::Custom(S::InvalidState) => ParseErrorKind::InvalidPosition,
288 CssErrorKind::Custom(S::ClassNeedsIdent(t) | S::PseudoElementExpectedIdent(t)) => {
289 ParseErrorKind::ExpectedName(token_text(t))
290 }
291 CssErrorKind::Custom(S::UnsupportedPseudoClassOrElement(name)) => {
292 ParseErrorKind::UnsupportedPseudo(elide(sanitize(name)))
293 }
294 CssErrorKind::Custom(
295 S::NoQualifiedNameInAttributeSelector(t)
296 | S::InvalidQualNameInAttr(t)
297 | S::ExpectedBarInAttr(t)
298 | S::UnexpectedTokenInAttributeSelector(t)
299 | S::BadValueInAttr(t),
300 ) => ParseErrorKind::InvalidAttributeSelector(token_text(t)),
301 CssErrorKind::Custom(S::ExpectedNamespace(prefix)) => ParseErrorKind::Other(format!(
302 "the namespace prefix `{}` is not declared",
303 elide(sanitize(prefix))
304 )),
305 _ => ParseErrorKind::Other("the selector is not valid CSS".to_owned()),
306 }
307 }
308
309 /// A [`ParseErrorKind::UnexpectedToken`] naming `token`, for the
310 /// caller that has a token in hand rather than a dependency error
311 /// holding one. Sanitized and elided exactly as the mapped kinds
312 /// are.
313 pub(crate) fn unexpected_token(token: &Token<'_>) -> Self {
314 ParseErrorKind::UnexpectedToken(token_text(token))
315 }
316
317 /// Whether `token` is the one this kind's message echoes.
318 ///
319 /// The parser asks this of the tokens on either side of the
320 /// position its dependencies reported, to put the caret on the
321 /// token the message names rather than next to it. The comparison
322 /// is on the payload, so a candidate matches when it spells the
323 /// same way the message does — sanitized and elided included, which
324 /// keeps the two sides exactly comparable.
325 ///
326 /// A pseudo-class name is held without its colons and can have been
327 /// written as a plain name or as a function, so it is compared
328 /// against the token's own name. The kinds that echo nothing —
329 /// there is no token for a caret to move to — never match.
330 pub(crate) fn names_token(&self, token: &Token<'_>) -> bool {
331 match self {
332 ParseErrorKind::UnexpectedToken(text)
333 | ParseErrorKind::ExpectedName(text)
334 | ParseErrorKind::InvalidAttributeSelector(text) => *text == token_text(token),
335 ParseErrorKind::UnsupportedPseudo(name) => match token {
336 Token::Ident(written) | Token::Function(written) => {
337 *name == elide(sanitize(written))
338 }
339 _ => false,
340 },
341 _ => false,
342 }
343 }
344}
345
346/// A token as the CSS source text it was written as, sanitized and
347/// elided for printing. `to_css` writes into a `String` infallibly.
348fn token_text(token: &Token<'_>) -> String {
349 let mut css = String::new();
350 let _ = token.to_css(&mut css);
351 elide(sanitize(&css))
352}
353
354/// `text` with every control character — which a message must never
355/// echo raw into a terminal — replaced by U+FFFD, as the caret gutter
356/// does for the selector itself.
357fn sanitize(text: &str) -> String {
358 text.chars()
359 .map(|c| if c.is_control() { '\u{FFFD}' } else { c })
360 .collect()
361}
362
363/// `text`, cut to [`MAX_TOKEN_ECHO`] bytes with a `…` if it is longer.
364fn elide(mut text: String) -> String {
365 if text.len() > MAX_TOKEN_ECHO {
366 text.truncate(char_boundary(&text, MAX_TOKEN_ECHO));
367 text.push('…');
368 }
369 text
370}
371/// Quote `selector` as `{:?}` would, eliding everything past
372/// [`MAX_SELECTOR_ECHO`] bytes with `…` so the message stays printable
373/// however long the selector is.
374fn quote(selector: &str) -> String {
375 if selector.len() <= MAX_SELECTOR_ECHO {
376 return format!("{selector:?}");
377 }
378 let head = &selector[..char_boundary(selector, MAX_SELECTOR_ECHO)];
379 let mut quoted = format!("{head:?}");
380 quoted.pop(); // the closing quote, put back after the ellipsis
381 quoted.push('…');
382 quoted.push('"');
383 quoted
384}
385
386/// Render a caret gutter: the line of `selector` that `offset` falls
387/// on, and a caret line whose `^` sits under it.
388///
389/// Only that one line is echoed, so a caret on the second line of a
390/// multi-line selector does not point into the first line's text, and it
391/// is windowed to [`MAX_GUTTER_WIDTH`] display columns around the caret
392/// (with `…` standing for what was cut) so a huge selector does not
393/// become a huge message.
394///
395/// The caret is padded by *display width*, not by character or byte
396/// count, so it lands under the offending character in wide (East Asian)
397/// text too; tabs, whose width depends on tab stops the caret cannot
398/// know, and other control characters are replaced by a single-column
399/// stand-in rather than echoed raw.
400fn gutter(selector: &str, offset: usize) -> (String, String) {
401 let offset = char_boundary(selector, offset.min(selector.len()));
402 let (start, end) = line_bounds(selector, offset);
403
404 // Each character of the line as it will be shown, its width, and
405 // whether it sits before the caret.
406 let cells: Vec<(char, usize, bool)> = selector[start..end]
407 .char_indices()
408 .map(|(i, c)| {
409 let (shown, width) = render(c);
410 (shown, width, start + i < offset)
411 })
412 .collect();
413 let caret_col: usize = cells.iter().filter(|c| c.2).map(|c| c.1).sum();
414 let total: usize = cells.iter().map(|c| c.1).sum();
415
416 // The window, in display columns: centred on the caret, but never
417 // starting so late that the caret falls outside it. `span` counts
418 // the column one past the line's end, where an end-of-input caret
419 // sits.
420 let span = total.max(caret_col + 1);
421 let win_start = if span <= MAX_GUTTER_WIDTH {
422 0
423 } else {
424 (caret_col.saturating_sub(MAX_GUTTER_WIDTH / 2)).min(span - MAX_GUTTER_WIDTH)
425 };
426 let win_end = win_start + MAX_GUTTER_WIDTH;
427
428 // Keep whole characters only: a wide one straddling either edge is
429 // dropped, which is what the `…` then stands for.
430 let mut shown = String::new();
431 let mut shown_start = None;
432 let mut col = 0;
433 for &(c, width, _) in &cells {
434 if col >= win_start && col + width <= win_end {
435 shown_start.get_or_insert(col);
436 shown.push(c);
437 }
438 col += width;
439 }
440
441 let mut line = String::new();
442 if win_start > 0 {
443 line.push('…');
444 }
445 line.push_str(&shown);
446 if total > win_end {
447 line.push('…');
448 }
449 let pad =
450 caret_col.saturating_sub(shown_start.unwrap_or(win_start)) + usize::from(win_start > 0);
451 (line, format!("{}^", " ".repeat(pad)))
452}
453
454/// How a character of the selector is shown in the gutter, and the
455/// display columns it then occupies.
456fn render(c: char) -> (char, usize) {
457 match c {
458 // A tab renders as anything from one to eight columns depending
459 // on the terminal's tab stops; a space is the one substitute
460 // whose width the caret can count on.
461 '\t' => (' ', 1),
462 c if c.is_control() => ('\u{FFFD}', 1),
463 // `width()` is `None` only for the controls handled above.
464 c => (c, c.width().unwrap_or(1)),
465 }
466}
467
468/// The byte range of the line of `s` containing `offset`, excluding the
469/// line break. CSS preprocessing (css-syntax § 3.3) makes `\r\n`, `\r`,
470/// `\n` and `\f` all line breaks, and cssparser's line counter follows
471/// it, so all four end a line here.
472fn line_bounds(s: &str, offset: usize) -> (usize, usize) {
473 let bytes = s.as_bytes();
474 let start = bytes[..offset]
475 .iter()
476 .rposition(|b| matches!(b, b'\n' | b'\r' | b'\x0C'))
477 .map_or(0, |i| i + 1);
478 let end = bytes[offset..]
479 .iter()
480 .position(|b| matches!(b, b'\n' | b'\r' | b'\x0C'))
481 .map_or(s.len(), |i| offset + i);
482 (start, end)
483}
484
485/// `offset`, moved back to the character boundary at or before it. The
486/// offset in a `Parse` error is derived from a source location rather
487/// than taken from an index, so it is not assumed to land cleanly.
488fn char_boundary(s: &str, mut offset: usize) -> usize {
489 while !s.is_char_boundary(offset) {
490 offset -= 1;
491 }
492 offset
493}