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(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}