Skip to main content

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(echoed(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                echoed(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 [`names_token`](Self::names_token) can hold for any token
318    /// at all.
319    pub(crate) fn echoes_token(&self) -> bool {
320        matches!(
321            self,
322            ParseErrorKind::UnexpectedToken(_)
323                | ParseErrorKind::ExpectedName(_)
324                | ParseErrorKind::InvalidAttributeSelector(_)
325                | ParseErrorKind::UnsupportedPseudo(_)
326        )
327    }
328
329    /// Whether `token` is the one this kind's message echoes.
330    ///
331    /// The parser asks this of the tokens on either side of the
332    /// position its dependencies reported, to put the caret on the
333    /// token the message names rather than next to it. The comparison
334    /// is on the payload, so a candidate matches when it spells the
335    /// same way the message does — sanitized and elided included, which
336    /// keeps the two sides exactly comparable.
337    ///
338    /// A pseudo-class name is held without its colons and can have been
339    /// written as a plain name or as a function, so it is compared
340    /// against the token's own name. The kinds that echo nothing —
341    /// there is no token for a caret to move to — never match.
342    pub(crate) fn names_token(&self, token: &Token<'_>) -> bool {
343        match self {
344            ParseErrorKind::UnexpectedToken(text)
345            | ParseErrorKind::ExpectedName(text)
346            | ParseErrorKind::InvalidAttributeSelector(text) => *text == token_text(token),
347            ParseErrorKind::UnsupportedPseudo(name) => match token {
348                Token::Ident(written) | Token::Function(written) => *name == echoed(written),
349                _ => false,
350            },
351            _ => false,
352        }
353    }
354}
355
356/// A token as the CSS source text it was written as, sanitized and
357/// elided for printing. `to_css` writes into a `String` infallibly.
358fn token_text(token: &Token<'_>) -> String {
359    let mut css = String::new();
360    let _ = token.to_css(&mut css);
361    echoed(&css)
362}
363
364/// A piece of the selector as a message may echo it: sanitized and
365/// elided, which is the whole of "bounded and safe to print" for an
366/// echoed payload. Every message that quotes something the caller wrote
367/// — a token, a pseudo-class name, a `:lang()` range — goes through
368/// here, so the bound is one rule rather than a promise each call site
369/// has to keep.
370pub(crate) fn echoed(text: &str) -> String {
371    elide(sanitize(text))
372}
373
374/// `text` with every control character — which a message must never
375/// echo raw into a terminal — replaced by U+FFFD, as the caret gutter
376/// does for the selector itself.
377fn sanitize(text: &str) -> String {
378    text.chars()
379        .map(|c| if c.is_control() { '\u{FFFD}' } else { c })
380        .collect()
381}
382
383/// `text`, cut to [`MAX_TOKEN_ECHO`] bytes with a `…` if it is longer.
384fn elide(mut text: String) -> String {
385    if text.len() > MAX_TOKEN_ECHO {
386        text.truncate(char_boundary(&text, MAX_TOKEN_ECHO));
387        text.push('…');
388    }
389    text
390}
391/// Quote `selector` as `{:?}` would, eliding everything past
392/// [`MAX_SELECTOR_ECHO`] bytes with `…` so the message stays printable
393/// however long the selector is.
394fn quote(selector: &str) -> String {
395    if selector.len() <= MAX_SELECTOR_ECHO {
396        return format!("{selector:?}");
397    }
398    let head = &selector[..char_boundary(selector, MAX_SELECTOR_ECHO)];
399    let mut quoted = format!("{head:?}");
400    quoted.pop(); // the closing quote, put back after the ellipsis
401    quoted.push('…');
402    quoted.push('"');
403    quoted
404}
405
406/// Render a caret gutter: the line of `selector` that `offset` falls
407/// on, and a caret line whose `^` sits under it.
408///
409/// Only that one line is echoed, so a caret on the second line of a
410/// multi-line selector does not point into the first line's text, and it
411/// is windowed to [`MAX_GUTTER_WIDTH`] display columns around the caret
412/// (with `…` standing for what was cut) so a huge selector does not
413/// become a huge message.
414///
415/// The caret is padded by *display width*, not by character or byte
416/// count, so it lands under the offending character in wide (East Asian)
417/// text too; tabs, whose width depends on tab stops the caret cannot
418/// know, and other control characters are replaced by a single-column
419/// stand-in rather than echoed raw.
420fn gutter(selector: &str, offset: usize) -> (String, String) {
421    let offset = char_boundary(selector, offset.min(selector.len()));
422    let (start, end) = line_bounds(selector, offset);
423
424    // Each character of the line as it will be shown, its width, and
425    // whether it sits before the caret.
426    let cells: Vec<(char, usize, bool)> = selector[start..end]
427        .char_indices()
428        .map(|(i, c)| {
429            let (shown, width) = render(c);
430            (shown, width, start + i < offset)
431        })
432        .collect();
433    let caret_col: usize = cells.iter().filter(|c| c.2).map(|c| c.1).sum();
434    let total: usize = cells.iter().map(|c| c.1).sum();
435
436    // The window, in display columns: centred on the caret, but never
437    // starting so late that the caret falls outside it. `span` counts
438    // the column one past the line's end, where an end-of-input caret
439    // sits.
440    let span = total.max(caret_col + 1);
441    let win_start = if span <= MAX_GUTTER_WIDTH {
442        0
443    } else {
444        (caret_col.saturating_sub(MAX_GUTTER_WIDTH / 2)).min(span - MAX_GUTTER_WIDTH)
445    };
446    let win_end = win_start + MAX_GUTTER_WIDTH;
447
448    // Keep whole characters only: a wide one straddling either edge is
449    // dropped, which is what the `…` then stands for.
450    let mut shown = String::new();
451    let mut shown_start = None;
452    let mut col = 0;
453    for &(c, width, _) in &cells {
454        if col >= win_start && col + width <= win_end {
455            shown_start.get_or_insert(col);
456            shown.push(c);
457        }
458        col += width;
459    }
460
461    let mut line = String::new();
462    if win_start > 0 {
463        line.push('…');
464    }
465    line.push_str(&shown);
466    if total > win_end {
467        line.push('…');
468    }
469    let pad =
470        caret_col.saturating_sub(shown_start.unwrap_or(win_start)) + usize::from(win_start > 0);
471    (line, format!("{}^", " ".repeat(pad)))
472}
473
474/// How a character of the selector is shown in the gutter, and the
475/// display columns it then occupies.
476fn render(c: char) -> (char, usize) {
477    match c {
478        // A tab renders as anything from one to eight columns depending
479        // on the terminal's tab stops; a space is the one substitute
480        // whose width the caret can count on.
481        '\t' => (' ', 1),
482        c if c.is_control() => ('\u{FFFD}', 1),
483        // `width()` is `None` only for the controls handled above.
484        c => (c, c.width().unwrap_or(1)),
485    }
486}
487
488/// The byte range of the line of `s` containing `offset`, excluding the
489/// line break. CSS preprocessing (css-syntax § 3.3) makes `\r\n`, `\r`,
490/// `\n` and `\f` all line breaks, and cssparser's line counter follows
491/// it, so all four end a line here.
492fn line_bounds(s: &str, offset: usize) -> (usize, usize) {
493    let bytes = s.as_bytes();
494    let start = bytes[..offset]
495        .iter()
496        .rposition(|b| matches!(b, b'\n' | b'\r' | b'\x0C'))
497        .map_or(0, |i| i + 1);
498    let end = bytes[offset..]
499        .iter()
500        .position(|b| matches!(b, b'\n' | b'\r' | b'\x0C'))
501        .map_or(s.len(), |i| offset + i);
502    (start, end)
503}
504
505/// `offset`, moved back to the character boundary at or before it. The
506/// offset in a `Parse` error is derived from a source location rather
507/// than taken from an index, so it is not assumed to land cleanly.
508fn char_boundary(s: &str, mut offset: usize) -> usize {
509    while !s.is_char_boundary(offset) {
510        offset -= 1;
511    }
512    offset
513}