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