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 dependencies' kinds say what went wrong but not with what, so
270    /// a kind that echoes a piece of the selector takes it from `token`,
271    /// the token the parser located the failure at. Without one it has
272    /// nothing to echo, and falls back to [`ParseErrorKind::Other`].
273    ///
274    /// The arms cover every kind the two crates actually produce while
275    /// parsing a selector; the rest — the `@`-rule kinds, which need a
276    /// stylesheet, and the several `selectors` variants nothing
277    /// constructs (`ExpectedNamespace` among them, as every prefix maps
278    /// to itself) — fall through to [`ParseErrorKind::Other`], as would a
279    /// variant added upstream.
280    pub(crate) fn from_kind(
281        kind: &CssErrorKind<SelectorParseErrorKind>,
282        token: Option<&Token<'_>>,
283    ) -> Self {
284        use SelectorParseErrorKind as S;
285        match (kind, token) {
286            // A token after an explicit namespace prefix (`ns|5`) is
287            // just a token in the wrong place, so it joins the basic
288            // kind rather than earning wording of its own.
289            (
290                CssErrorKind::Basic(BasicParseErrorKind::UnexpectedToken)
291                | CssErrorKind::Custom(S::ExplicitNamespaceUnexpectedToken),
292                Some(t),
293            ) => ParseErrorKind::UnexpectedToken(token_text(t)),
294            (CssErrorKind::Basic(BasicParseErrorKind::EndOfInput), _) => ParseErrorKind::EndOfInput,
295            (CssErrorKind::Custom(S::EmptySelector), _) => ParseErrorKind::EmptySelector,
296            (CssErrorKind::Custom(S::DanglingCombinator), _) => ParseErrorKind::DanglingCombinator,
297            (CssErrorKind::Custom(S::InvalidState), _) => ParseErrorKind::InvalidPosition,
298            (CssErrorKind::Custom(S::ClassNeedsIdent | S::PseudoElementExpectedIdent), Some(t)) => {
299                ParseErrorKind::ExpectedName(token_text(t))
300            }
301            (
302                CssErrorKind::Custom(S::UnsupportedPseudoClassOrElement),
303                Some(Token::Ident(name) | Token::Function(name)),
304            ) => ParseErrorKind::UnsupportedPseudo(echoed(name)),
305            (
306                CssErrorKind::Custom(
307                    S::NoQualifiedNameInAttributeSelector
308                    | S::InvalidQualNameInAttr
309                    | S::ExpectedBarInAttr
310                    | S::UnexpectedTokenInAttributeSelector
311                    | S::BadValueInAttr,
312                ),
313                Some(t),
314            ) => ParseErrorKind::InvalidAttributeSelector(token_text(t)),
315            _ => ParseErrorKind::Other("the selector is not valid CSS".to_owned()),
316        }
317    }
318
319    /// A [`ParseErrorKind::UnexpectedToken`] naming `token`, for the
320    /// caller that has a token in hand rather than a dependency error
321    /// holding one. Sanitized and elided exactly as the mapped kinds
322    /// are.
323    pub(crate) fn unexpected_token(token: &Token<'_>) -> Self {
324        ParseErrorKind::UnexpectedToken(token_text(token))
325    }
326}
327
328/// A token as the CSS source text it was written as, sanitized and
329/// elided for printing. `to_css` writes into a `String` infallibly.
330fn token_text(token: &Token<'_>) -> String {
331    let mut css = String::new();
332    let _ = token.to_css(&mut css);
333    echoed(&css)
334}
335
336/// A piece of the selector as a message may echo it: sanitized and
337/// elided, which is the whole of "bounded and safe to print" for an
338/// echoed payload. Every message that quotes something the caller wrote
339/// — a token, a pseudo-class name, a `:lang()` range — goes through
340/// here, so the bound is one rule rather than a promise each call site
341/// has to keep.
342pub(crate) fn echoed(text: &str) -> String {
343    elide(sanitize(text))
344}
345
346/// `text` with every control character — which a message must never
347/// echo raw into a terminal — replaced by U+FFFD, as the caret gutter
348/// does for the selector itself.
349fn sanitize(text: &str) -> String {
350    text.chars()
351        .map(|c| if c.is_control() { '\u{FFFD}' } else { c })
352        .collect()
353}
354
355/// `text`, cut to [`MAX_TOKEN_ECHO`] bytes with a `…` if it is longer.
356fn elide(mut text: String) -> String {
357    if text.len() > MAX_TOKEN_ECHO {
358        text.truncate(char_boundary(&text, MAX_TOKEN_ECHO));
359        text.push('…');
360    }
361    text
362}
363/// Quote `selector` as `{:?}` would, eliding everything past
364/// [`MAX_SELECTOR_ECHO`] bytes with `…` so the message stays printable
365/// however long the selector is.
366fn quote(selector: &str) -> String {
367    if selector.len() <= MAX_SELECTOR_ECHO {
368        return format!("{selector:?}");
369    }
370    let head = &selector[..char_boundary(selector, MAX_SELECTOR_ECHO)];
371    let mut quoted = format!("{head:?}");
372    quoted.pop(); // the closing quote, put back after the ellipsis
373    quoted.push('…');
374    quoted.push('"');
375    quoted
376}
377
378/// Render a caret gutter: the line of `selector` that `offset` falls
379/// on, and a caret line whose `^` sits under it.
380///
381/// Only that one line is echoed, so a caret on the second line of a
382/// multi-line selector does not point into the first line's text, and it
383/// is windowed to [`MAX_GUTTER_WIDTH`] display columns around the caret
384/// (with `…` standing for what was cut) so a huge selector does not
385/// become a huge message.
386///
387/// The caret is padded by *display width*, not by character or byte
388/// count, so it lands under the offending character in wide (East Asian)
389/// text too; tabs, whose width depends on tab stops the caret cannot
390/// know, and other control characters are replaced by a single-column
391/// stand-in rather than echoed raw.
392fn gutter(selector: &str, offset: usize) -> (String, String) {
393    let offset = char_boundary(selector, offset.min(selector.len()));
394    let (start, end) = line_bounds(selector, offset);
395
396    // Each character of the line as it will be shown, its width, and
397    // whether it sits before the caret.
398    let cells: Vec<(char, usize, bool)> = selector[start..end]
399        .char_indices()
400        .map(|(i, c)| {
401            let (shown, width) = render(c);
402            (shown, width, start + i < offset)
403        })
404        .collect();
405    let caret_col: usize = cells.iter().filter(|c| c.2).map(|c| c.1).sum();
406    let total: usize = cells.iter().map(|c| c.1).sum();
407
408    // The window, in display columns: centred on the caret, but never
409    // starting so late that the caret falls outside it. `span` counts
410    // the column one past the line's end, where an end-of-input caret
411    // sits.
412    let span = total.max(caret_col + 1);
413    let win_start = if span <= MAX_GUTTER_WIDTH {
414        0
415    } else {
416        (caret_col.saturating_sub(MAX_GUTTER_WIDTH / 2)).min(span - MAX_GUTTER_WIDTH)
417    };
418    let win_end = win_start + MAX_GUTTER_WIDTH;
419
420    // Keep whole characters only: a wide one straddling either edge is
421    // dropped, which is what the `…` then stands for.
422    let mut shown = String::new();
423    let mut shown_start = None;
424    let mut col = 0;
425    for &(c, width, _) in &cells {
426        if col >= win_start && col + width <= win_end {
427            shown_start.get_or_insert(col);
428            shown.push(c);
429        }
430        col += width;
431    }
432
433    let mut line = String::new();
434    if win_start > 0 {
435        line.push('…');
436    }
437    line.push_str(&shown);
438    if total > win_end {
439        line.push('…');
440    }
441    let pad =
442        caret_col.saturating_sub(shown_start.unwrap_or(win_start)) + usize::from(win_start > 0);
443    (line, format!("{}^", " ".repeat(pad)))
444}
445
446/// How a character of the selector is shown in the gutter, and the
447/// display columns it then occupies.
448fn render(c: char) -> (char, usize) {
449    match c {
450        // A tab renders as anything from one to eight columns depending
451        // on the terminal's tab stops; a space is the one substitute
452        // whose width the caret can count on.
453        '\t' => (' ', 1),
454        c if c.is_control() => ('\u{FFFD}', 1),
455        // `width()` is `None` only for the controls handled above.
456        c => (c, c.width().unwrap_or(1)),
457    }
458}
459
460/// The byte range of the line of `s` containing `offset`, excluding the
461/// line break. CSS preprocessing (css-syntax § 3.3) makes `\r\n`, `\r`,
462/// `\n` and `\f` all line breaks, and cssparser's line counter follows
463/// it, so all four end a line here.
464fn line_bounds(s: &str, offset: usize) -> (usize, usize) {
465    let bytes = s.as_bytes();
466    let start = bytes[..offset]
467        .iter()
468        .rposition(|b| matches!(b, b'\n' | b'\r' | b'\x0C'))
469        .map_or(0, |i| i + 1);
470    let end = bytes[offset..]
471        .iter()
472        .position(|b| matches!(b, b'\n' | b'\r' | b'\x0C'))
473        .map_or(s.len(), |i| offset + i);
474    (start, end)
475}
476
477/// `offset`, moved back to the character boundary at or before it. The
478/// offset in an error is a public field, which a caller can set to
479/// anything, so it is not assumed to land cleanly.
480fn char_boundary(s: &str, mut offset: usize) -> usize {
481    while !s.is_char_boundary(offset) {
482        offset -= 1;
483    }
484    offset
485}