css-to-xpath 0.3.0

Translate CSS selectors to XPath 1.0 expressions
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
//! The non-tree-structural pseudo-class translations: the "never matches"
//! set, the HTML overrides, and `:lang()`/`:dir()`.
//!
//! Both `html` and `xhtml` use the HTML overrides (they differ in the
//! lowercasing flags and in where `:lang()` reads an element's language
//! from); the generic translator answers `0` (never matches) for
//! everything except `:lang()`, which it maps to XPath's `lang()`
//! function.

use crate::parser::PseudoClass;

use super::error::Error;
use super::xpath_expr::{Condition, XPathExpr, ascii_lower, xpath_literal};
use super::{Kind, Translator};

/// Where a translator reads an element's language from, for `:lang()`.
/// The two halves — which elements carry a language, and what that
/// element's language string is — are the only things that differ
/// between the flavours, so every `:lang()` condition is built from
/// [`LangSource::nearest`] plus [`LangSource::string`].
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum LangSource {
    /// Generic: `xml:lang`, which is what XPath's own `lang()` reads.
    /// Only the wildcard range needs it spelled out; every other range
    /// goes through `lang()` itself. XML binds the `xml` prefix
    /// implicitly, so it needs no entry in the caller's namespace map —
    /// libxml2 pre-binds it; a processor that resolves prefixes purely
    /// from a caller-supplied map (sxd-xpath, say) needs it registered.
    XmlLang,
    /// `html`: the `lang` content attribute alone. An HTML parser puts a
    /// literal `xml:lang` in no namespace on HTML elements, and HTML's
    /// language determination ignores it, so `@lang` is the whole story.
    Lang,
    /// `xhtml`: either attribute, since XHTML documents conventionally
    /// carry `xml:lang` (often alongside `lang`). HTML's language
    /// determination takes the nearest ancestor-or-self with either one
    /// and prefers `xml:lang` when both sit on that element.
    Both,
}

impl LangSource {
    /// The nearest ancestor-or-self carrying a language attribute (`[1]`
    /// counts backwards along a reverse axis). An element with the
    /// attribute but an empty value still stops the walk: an empty value
    /// resets the language to unknown rather than deferring to a further
    /// ancestor.
    fn nearest(self) -> &'static str {
        match self {
            LangSource::XmlLang => "ancestor-or-self::*[@xml:lang][1]",
            LangSource::Lang => "ancestor-or-self::*[@lang][1]",
            LangSource::Both => "ancestor-or-self::*[@xml:lang or @lang][1]",
        }
    }

    /// That element's language string, as an expression evaluated with
    /// the element as the context node.
    ///
    /// For [`LangSource::Both`], `xml:lang` wins whenever it is present.
    /// XPath 1.0 has no conditional, so the `lang` half is truncated to
    /// zero length (`string-length(@lang) * not(@xml:lang)` multiplies
    /// the length by 0 when `xml:lang` is there) and `concat` contributes
    /// `""` for a missing attribute — leaving exactly one of the two.
    fn string(self) -> &'static str {
        match self {
            LangSource::XmlLang => "@xml:lang",
            LangSource::Lang => "@lang",
            LangSource::Both => {
                "concat(@xml:lang, \
                 substring(@lang, 1, string-length(@lang) * not(@xml:lang)))"
            }
        }
    }
}

/// The HTML `type` attribute, ASCII-lowercased so comparisons against
/// enumerated-attribute keywords are case-insensitive: `type` is an
/// [enumerated attribute](https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#enumerated-attribute),
/// so `type="RADIO"` is a radio and `type="HIDDEN"` is hidden. This is the
/// same ASCII fold `[type=...]` itself gets (`Translator::apply_case_flag`),
/// through the same helper.
fn type_lc() -> String {
    ascii_lower("@type")
}

/// Every element name in the HTML overrides is matched by `local-name()`,
/// never by a qualified name or a bare node test. The overrides are the one
/// part of a translation the caller cannot spell themselves, and the
/// document they run against may put HTML's elements in a namespace: in
/// XHTML they are in `http://www.w3.org/1999/xhtml`, so `ancestor::fieldset`
/// matches nothing, and under a bound prefix (`<h:input>`) a qualified-name
/// comparison against `'input'` fails too. Matching by local name makes the
/// fragments work for `*|input` and `h|input` subjects alike, and leaves
/// `Mode::Html` unchanged in meaning — libxml2's HTML parser produces no
/// namespaces, so there `local-name()` and the qualified name always agree.
/// The crate's namespace rule for *written* names (an unprefixed name is
/// the null namespace) is unaffected: that governs the subject the user
/// writes, which is still translated as documented.
///
/// A form control is disabled by a `fieldset[disabled]` ancestor unless it
/// sits inside that fieldset's first `legend` child (HTML's "actually
/// disabled" carve-out keeps a disabled group's caption usable). Each such
/// first-legend ancestor protects against exactly one disabled fieldset
/// (distinct legends have distinct parents), so the control is
/// fieldset-disabled iff it has more disabled-fieldset ancestors than
/// protecting legends — which counts nested disabled fieldsets correctly.
const FIELDSET_DISABLED: &str = "count(ancestor::*[local-name() = 'fieldset'][@disabled]) > \
     count(ancestor::*[local-name() = 'legend']\
     [not(preceding-sibling::*[local-name() = 'legend'])]\
     [parent::*[local-name() = 'fieldset'][@disabled]])";

/// The elements HTML's `:enabled` and `:disabled` apply to.
/// Form-associated custom elements are in the spec's list too, but
/// nothing in static markup identifies one, so they are left out.
/// Hyperlinks are not in the list — `a[href]` matches `:link`, never
/// `:enabled` — and neither are the obsolete `keygen` and `command`.
const DISABLEABLE: [&str; 7] = [
    "button", "input", "select", "textarea", "optgroup", "option", "fieldset",
];

/// [`DISABLEABLE`] as a condition, for a subject whose local name the
/// compound does not pin.
fn disableable() -> String {
    let names: Vec<String> = DISABLEABLE
        .iter()
        .map(|name| format!("local-name() = '{name}'"))
        .collect();
    format!("({})", names.join(" or "))
}

/// HTML's "actually disabled", to be read together with [`DISABLEABLE`]:
/// `:disabled` is that set and this condition, `:enabled` is that set and
/// not this condition, so the two stay exact complements.
///
/// An element is actually disabled if it carries `@disabled`; an `option`
/// is disabled by a disabled parent `optgroup` as well (the spec walks up
/// to the nearest `optgroup`, which in conforming markup is the parent);
/// and a control — or a nested `fieldset`, which is itself a disabled
/// fieldset — is disabled by a disabled `fieldset` ancestor. The fieldset
/// rule is the one that reaches neither `optgroup` nor `option`.
fn actually_disabled() -> String {
    format!(
        "@disabled or \
         (local-name() = 'option' and parent::*[local-name() = 'optgroup'][@disabled]) or \
         (not(local-name() = 'optgroup' or local-name() = 'option') \
          and {FIELDSET_DISABLED})"
    )
}

/// The `input` types on which `required` has no effect, as a
/// `|`-delimited haystack: `contains()` against it tests all seven with
/// one mention of `@type`, where seven `=` comparisons would repeat the
/// whole `translate()` fold (see [`type_lc`]) seven times.
const REQUIRED_INERT_TYPES: &str = "|hidden|range|color|submit|image|reset|button|";

/// The `input` types the `readonly` attribute has no effect on, in the
/// same `|`-delimited form. An unrecognised or missing `type` is the
/// Text state, which `readonly` *does* apply to, so the inert types are
/// the ones worth listing: no value outside this list is inert.
const READONLY_INERT_TYPES: &str =
    "|hidden|color|checkbox|radio|file|submit|image|reset|button|range|";

/// The `input` types the `placeholder` attribute has no effect on, same
/// form and same reasoning: `placeholder` applies to Text, Search, URL,
/// Telephone, Email, Password and Number, and to whatever an invalid or
/// missing `type` falls back to (Text).
const PLACEHOLDER_INERT_TYPES: &str = concat!(
    "|hidden|checkbox|radio|file|submit|image|reset|button|color|range|",
    "date|month|week|time|datetime-local|"
);

/// Whether `@type` names one of the keywords in a `|`-delimited
/// haystack, such as [`REQUIRED_INERT_TYPES`].
///
/// `contains(haystack, concat('|', type, '|'))` on its own would also
/// accept a value spelling out several keywords in a row
/// (`type="hidden|range"`), so the pipe-free guard keeps the test exact:
/// a value containing `|` is none of the keywords. The comparison folds
/// case because `type` is an HTML enumerated attribute.
fn type_is_one_of(keywords: &str) -> String {
    let type_lc = type_lc();
    format!(
        "contains('{keywords}', concat('|', {type_lc}, '|')) \
         and not(contains({type_lc}, '|'))"
    )
}

/// Whether `@type` names one of [`REQUIRED_INERT_TYPES`].
fn required_is_inert() -> String {
    type_is_one_of(REQUIRED_INERT_TYPES)
}

/// The elements the `required` attribute applies to, for `:required` and
/// `:optional` (HTML spec): `select`, `textarea`, and `input` except the
/// types on which `required` has no effect — those match neither
/// pseudo-class, whatever attributes they carry. For a subject whose
/// local name the compound does not pin.
fn required_applies() -> String {
    format!(
        "((local-name() = 'input' and not({})) or \
         local-name() = 'select' or \
         local-name() = 'textarea')",
        required_is_inert()
    )
}

/// HTML's "actually disabled" for a form control — everything in
/// [`DISABLEABLE`] bar `optgroup` and `option`, whose own rules the
/// fieldset one does not reach. Shared by `:disabled`/`:enabled` and by
/// the mutability half of `:read-write`.
fn control_actually_disabled() -> String {
    format!("@disabled or {FIELDSET_DISABLED}")
}

/// The `contenteditable` values that *set* a state rather than inheriting
/// one, `|`-delimited for the same `contains()` idiom [`type_is_one_of`]
/// uses on `@type`. The leading `||` admits the empty value, which is the
/// True state; every other value — `inherit`, a typo, anything — leaves
/// the element inheriting its parent's state, so the walk must pass it by.
const CONTENTEDITABLE_STATES: &str = "||true|plaintext-only|false|";

/// Whether the element is editable: the nearest ancestor-or-self that
/// sets a `contenteditable` state sets it to something other than
/// `false`.
///
/// This is the third arm of `:read-write` — "elements that are editing
/// hosts or editable" — and the whole of it for an element outside the
/// form controls. It has the shape of the `:lang()` walk: a reverse-axis
/// `[1]` picks the nearest element that settles the question, and a
/// further predicate asks what it settled it to. `designMode`, the other
/// way a document becomes editable, is not in the markup.
fn editable() -> String {
    let ce_lc = ascii_lower("@contenteditable");
    format!(
        "ancestor-or-self::*[@contenteditable and \
         contains('{CONTENTEDITABLE_STATES}', concat('|', {ce_lc}, '|')) \
         and not(contains(@contenteditable, '|'))][1]\
         [not({ce_lc} = 'false')]"
    )
}

/// `:read-write` for an `input`: `readonly` applies to its type (an
/// invalid or missing `type` is Text, which it applies to), and the
/// control is neither read-only nor disabled.
fn input_mutable() -> String {
    format!(
        "not({}) and not(@readonly) and not({})",
        type_is_one_of(READONLY_INERT_TYPES),
        control_actually_disabled()
    )
}

/// `:read-write` for a `textarea`: no type to consider, so just neither
/// read-only nor disabled.
fn textarea_mutable() -> String {
    format!("not(@readonly) and not({})", control_actually_disabled())
}

/// `:read-write` — a mutable `input` or `textarea`, or an editable
/// element. `:read-only` is Selectors 4's complement of it, so both are
/// built from this one expression and the two partition every element.
fn read_write(name: Option<&str>) -> Condition {
    let editable = editable();
    match name {
        Some("input") => or_group(&format!("({}) or {editable}", input_mutable())),
        Some("textarea") => or_group(&format!("({}) or {editable}", textarea_mutable())),
        // A control is not the only editable thing: any element inside a
        // contenteditable subtree is user-alterable, whatever its name.
        Some(_) => plain(&editable),
        None => or_group(&format!(
            "(local-name() = 'input' and {}) or \
             (local-name() = 'textarea' and {}) or \
             {editable}",
            input_mutable(),
            textarea_mutable()
        )),
    }
}

/// A submit button, as a condition on an element of unknown name: a
/// `button` whose `type` is neither `reset` nor `button` (the missing and
/// invalid value default is Submit), or an `input` of type `submit` or
/// `image`.
fn submit_button() -> String {
    let type_lc = type_lc();
    format!(
        "(local-name() = 'button' and not({type_lc} = 'reset' or {type_lc} = 'button')) or \
         (local-name() = 'input' and ({type_lc} = 'submit' or {type_lc} = 'image'))"
    )
}

/// The tail of `:default`'s first arm, to be read after a test that the
/// element *is* a submit button: that it is its form's default button,
/// the first submit button in tree order whose form owner is that form.
///
/// The form owner is taken to be the nearest ancestor `form`, which is
/// what it is for every control that does not carry a `form` attribute
/// (see the README's Approximations). XPath 1.0 has no node-identity
/// operator, so "the form's first submit button is me" is written with
/// the union-count idiom: `count(A | B) = 1` holds exactly when the two
/// node-sets are the same single node. The ancestor test in front of it
/// is not redundant — with no ancestor form the union would be `.` alone,
/// which also counts 1.
fn is_default_button() -> String {
    format!(
        "ancestor::*[local-name() = 'form'] and \
         count(. | ancestor::*[local-name() = 'form'][1]/descendant::*[{}][1]) = 1",
        submit_button()
    )
}

impl Translator {
    pub(crate) fn apply_pseudo_class(
        &self,
        xpath: &mut XPathExpr,
        pc: &PseudoClass,
    ) -> Result<(), Error> {
        // The HTML overrides name elements through `local-name()`, so
        // when the compound already pins the subject's local name every
        // disjunct written for another name is decided here rather than
        // by the XPath engine: only the arm that can match is emitted,
        // and a name outside the pseudo-class's element set collapses to
        // `0`. `None` — a wildcard subject — keeps the full expression.
        // The element part of a compound is always translated before its
        // conditions, so the name is already known by the time any
        // pseudo-class is applied.
        let name = xpath.local_name.clone();
        let name = name.as_deref();
        match (self.kind(), pc) {
            (_, PseudoClass::Dir(_)) => {
                // :dir() matches by *resolved* directionality, which needs
                // runtime bidi resolution, so it never matches — in both
                // translators. A nearest-@dir-ancestor walk (like the HTML
                // :lang() translation) was considered and rejected: it
                // gets dir="auto" (first-strong-character detection),
                // bdi/form-control defaults, and HTML's invalid-value-
                // means-inherit rule wrong, all of which occur in real
                // markup.
                xpath.add_condition("0");
            }
            (Kind::Generic, PseudoClass::Lang(args)) => {
                self.lang_generic(xpath, args)?;
            }
            (Kind::Html, PseudoClass::Lang(args)) => {
                self.lang_html(xpath, args)?;
            }
            // HTML overrides
            (Kind::Html, PseudoClass::Checked) => {
                xpath.push_condition(checked_condition(name));
            }
            // :any-link is :link ∪ :visited. A static document has no
            // visited state, so every link counts as unvisited and the
            // two pseudo-classes coincide — :any-link shares :link's
            // translation verbatim. HTML matches both on an `a` or
            // `area` with an `href`; the `link` element carries an
            // `href` but is not one of the elements HTML requires to
            // match :link/:visited, so it is not in the set.
            (Kind::Html, PseudoClass::Link) | (Kind::Html, PseudoClass::AnyLink) => {
                xpath.add_condition(&match name {
                    Some("a" | "area") => "@href".to_owned(),
                    Some(_) => "0".to_owned(),
                    None => "@href and (local-name() = 'a' or local-name() = 'area')".to_owned(),
                });
            }
            (Kind::Html, PseudoClass::Required) => {
                xpath.add_condition(&required_condition(name, "@required"));
            }
            (Kind::Html, PseudoClass::Optional) => {
                xpath.add_condition(&required_condition(name, "not(@required)"));
            }
            // `:disabled` and `:enabled` are one expression read two
            // ways, so they always partition the element set.
            (Kind::Html, PseudoClass::Disabled) => {
                xpath.push_condition(disabled_condition(name, /* want_disabled = */ true));
            }
            (Kind::Html, PseudoClass::Enabled) => {
                xpath.push_condition(disabled_condition(name, /* want_disabled = */ false));
            }
            // `:read-write` and `:read-only` are the same trick over a
            // wider set: Selectors 4 defines the latter as the
            // complement of the former, so the two partition *every*
            // element, controls and prose alike.
            (Kind::Html, PseudoClass::ReadWrite) => {
                xpath.push_condition(read_write(name));
            }
            (Kind::Html, PseudoClass::ReadOnly) => {
                // `not(...)` supplies its own grouping, whatever is
                // inside it.
                xpath.add_condition(&format!("not({})", read_write(name).expr));
            }
            (Kind::Html, PseudoClass::Default) => {
                xpath.push_condition(default_condition(name));
            }
            (Kind::Html, PseudoClass::PlaceholderShown) => {
                xpath.push_condition(placeholder_shown_condition(name));
            }
            // Everything else never matches.
            _ => {
                xpath.add_condition("0");
            }
        }
        Ok(())
    }

    /// Generic `:lang()`: XPath's `lang()` does language-range prefix
    /// matching natively, so `en` and `en-*` both become `lang('en')`-style
    /// tests. A bare `*` matches elements whose language is *known*, which
    /// `lang()` cannot express, so it walks the language source instead.
    fn lang_generic(&self, xpath: &mut XPathExpr, ranges: &[String]) -> Result<(), Error> {
        let mut conditions: Vec<String> = Vec::new();
        for value in ranges {
            check_wildcard_position(value)?;
            if value == "*" {
                conditions.push(lang_known_condition(self.lang_source()));
            } else if let Some(prefix) = value.strip_suffix("-*") {
                // The trailing '-' goes with the wildcard: lang('en-')
                // would never match, since libxml2 expects the argument
                // itself to end at a subtag boundary.
                conditions.push(format!("lang({})", xpath_literal(prefix)));
            } else {
                conditions.push(format!("lang({})", xpath_literal(value)));
            }
        }
        add_lang_conditions(xpath, &conditions);
        Ok(())
    }

    /// HTML `:lang()`: the language of the nearest ancestor-or-self that
    /// has one (see [`LangSource`]) is matched against the range by RFC
    /// 4647 extended filtering, built out of ASCII-lowercased substring
    /// tests (see [`lang_ancestor_condition`]).
    fn lang_html(&self, xpath: &mut XPathExpr, ranges: &[String]) -> Result<(), Error> {
        let mut conditions: Vec<String> = Vec::new();
        for value in ranges {
            check_wildcard_position(value)?;
            if value == "*" {
                conditions.push(lang_known_condition(self.lang_source()));
            } else {
                // A trailing wildcard ("en-*") matches the same range as
                // the one without it ("en"): RFC 4647 skips a trailing
                // `*` over whatever is left, and so does stopping early.
                let range = value.strip_suffix("-*").unwrap_or(value);
                conditions.push(lang_ancestor_condition(self.lang_source(), range));
            }
        }
        add_lang_conditions(xpath, &conditions);
        Ok(())
    }
}

/// `:checked` — a selected `option`, or a checked checkbox/radio `input`.
fn checked_condition(name: Option<&str>) -> Condition {
    let type_lc = type_lc();
    match name {
        Some("option") => plain("@selected"),
        Some("input") => plain(&format!(
            "@checked and ({type_lc} = 'checkbox' or {type_lc} = 'radio')"
        )),
        Some(_) => plain("0"),
        None => or_group(&format!(
            "(@selected and local-name() = 'option') or \
             (@checked and local-name() = 'input' \
             and ({type_lc} = 'checkbox' or {type_lc} = 'radio'))"
        )),
    }
}

/// `:default` — a checked checkbox/radio `input`, a selected `option`,
/// or a form's default submit button. The first two arms are `:checked`
/// read off the same attributes; only the third is new.
fn default_condition(name: Option<&str>) -> Condition {
    let type_lc = type_lc();
    let default_button = is_default_button();
    match name {
        Some("option") => plain("@selected"),
        Some("button") => plain(&format!(
            "not({type_lc} = 'reset' or {type_lc} = 'button') and {default_button}"
        )),
        Some("input") => or_group(&format!(
            "(@checked and ({type_lc} = 'checkbox' or {type_lc} = 'radio')) or \
             (({type_lc} = 'submit' or {type_lc} = 'image') and {default_button})"
        )),
        Some(_) => plain("0"),
        None => or_group(&format!(
            "(@selected and local-name() = 'option') or \
             (@checked and local-name() = 'input' \
             and ({type_lc} = 'checkbox' or {type_lc} = 'radio')) or \
             (({}) and {default_button})",
            submit_button()
        )),
    }
}

/// `:placeholder-shown` — an `input` or `textarea` with a non-empty
/// `placeholder` the type allows, and no value. A document says only
/// what the *initial* value is, so this is the state before any typing
/// (see the README's Approximations).
fn placeholder_shown_condition(name: Option<&str>) -> Condition {
    // `input`'s value is the `value` attribute; a `textarea`'s is its
    // text content, and a missing attribute has string-length 0 too, so
    // one `not(string-length(...))` covers "absent or empty" in both.
    let input = format!(
        "string-length(@placeholder) > 0 and not({}) and not(string-length(@value))",
        type_is_one_of(PLACEHOLDER_INERT_TYPES)
    );
    let textarea = "string-length(@placeholder) > 0 and not(string-length())";
    match name {
        Some("input") => plain(&input),
        Some("textarea") => plain(textarea),
        Some(_) => plain("0"),
        None => or_group(&format!(
            "(local-name() = 'input' and {input}) or \
             (local-name() = 'textarea' and {textarea})"
        )),
    }
}

/// `:required` and `:optional`, which differ only in `attr` — the test on
/// the `required` attribute itself — and share the element set.
fn required_condition(name: Option<&str>, attr: &str) -> String {
    match name {
        Some("select" | "textarea") => attr.to_owned(),
        Some("input") => format!("{attr} and not({})", required_is_inert()),
        Some(_) => "0".to_owned(),
        None => format!("{attr} and {}", required_applies()),
    }
}

/// `:disabled` (`want_disabled`) and `:enabled`, which are the same
/// element set and the same "actually disabled" condition, negated.
///
/// A pinned local name settles both halves: outside [`DISABLEABLE`]
/// neither pseudo-class matches, and inside it the three arms of
/// [`actually_disabled`] reduce to the one written for that name — the
/// disabled-parent-`optgroup` rule for an `option`, the
/// disabled-`fieldset`-ancestor rule for everything the spec applies it
/// to, and neither for an `optgroup` itself.
fn disabled_condition(name: Option<&str>, want_disabled: bool) -> Condition {
    let Some(name) = name else {
        let (set, actually) = (disableable(), actually_disabled());
        return plain(&if want_disabled {
            format!("{set} and ({actually})")
        } else {
            format!("{set} and not({actually})")
        });
    };
    if !DISABLEABLE.contains(&name) {
        return plain("0");
    }
    let (actually, or_group) = match name {
        "optgroup" => ("@disabled".to_owned(), false),
        "option" => (
            "@disabled or parent::*[local-name() = 'optgroup'][@disabled]".to_owned(),
            true,
        ),
        _ => (control_actually_disabled(), true),
    };
    if want_disabled {
        Condition {
            expr: actually,
            or_group,
        }
    } else {
        // `not(...)` supplies its own grouping, whatever is inside it.
        plain(&format!("not({actually})"))
    }
}

/// A condition with no top-level `or`.
fn plain(expr: &str) -> Condition {
    Condition {
        expr: expr.to_owned(),
        or_group: false,
    }
}

/// A condition whose expression has a top-level `or`.
fn or_group(expr: &str) -> Condition {
    Condition {
        expr: expr.to_owned(),
        or_group: true,
    }
}

/// A wildcard is accepted only as a whole range (`*`) or as the final
/// subtag (`en-*`); RFC 4647 extended filtering also allows it in any
/// interior position (`*-CH`, `de-*-DE`), which is rejected rather than
/// silently over- or under-matching. `Mode::Generic` delegates to XPath's
/// `lang()` and cannot express an interior wildcard at all; the HTML
/// modes' subtag chain could (a `*` subtag is one the chain simply skips),
/// but a range that is an error in one mode and a match in another is a
/// worse contract than one that is an error everywhere. (The parser has
/// already rejected a `*` that is not a whole subtag, such as `en*`, so a
/// range that passes here is either `*` itself or ends in `-*`.)
fn check_wildcard_position(range: &str) -> Result<(), Error> {
    if let Some(pos) = range.find('*')
        && pos != range.len() - 1
    {
        return Err(Error::unsupported(format!(
            "the :lang() language range {range:?} \
             (a wildcard outside the final subtag)"
        )));
    }
    Ok(())
}

/// The shared condition-combining tail of both `:lang()` translations: a
/// single condition is added as-is, multiple are OR-joined.
fn add_lang_conditions(xpath: &mut XPathExpr, conditions: &[String]) {
    match conditions.len() {
        0 => {}
        1 => xpath.add_condition(&conditions[0]),
        _ => xpath.add_or_condition(&conditions.join(" or ")),
    }
}

/// The wildcard range `*`, which matches an element whose language is
/// known. The language comes from the nearest ancestor-or-self carrying a
/// language attribute, and an empty value there resets the language to
/// unknown rather than deferring to a further ancestor — so the nearest
/// one must also be non-empty.
fn lang_known_condition(source: LangSource) -> String {
    format!(
        "{}[string-length({}) > 0]",
        source.nearest(),
        source.string()
    )
}

/// The element's language as the comparisons below want it: ASCII-folded
/// and dash-terminated, so every subtag — including the last — is
/// bounded by a `-` on the right.
fn folded_lang(source: LangSource) -> String {
    format!("concat({}, '-')", ascii_lower(source.string()))
}

/// The nearest-ancestor language test, as RFC 4647 extended filtering
/// over the folded language string: the language's first subtag must
/// equal the range's first, and each later range subtag must appear as a
/// whole subtag after the previous one matched.
///
/// The chain walks the language string with `substring-after`, keeping
/// the remainder dash-bounded on both ends so `-de-` can only match a
/// whole subtag. Each step takes the *earliest* remaining occurrence,
/// which is the greedy choice that leaves the longest tail, so it finds a
/// match whenever one exists. A subtag that is absent makes
/// `substring-after` return `''`, and every later `contains` is then
/// false — the right answer.
///
/// `range` arrives with any trailing `-*` already stripped and no
/// interior wildcard (see [`check_wildcard_position`]), and is lowercased
/// here to meet the folded language string.
///
/// A single-subtag range is just the `starts-with`, which is both the
/// whole of extended filtering for that shape and the dash-terminated
/// prefix match the translation has always emitted. The one RFC rule the
/// chain does not model is that a subtag may not be skipped past a
/// *singleton* (a one-character subtag, such as the `x` opening a
/// private-use section): measuring the length of every skipped subtag is
/// not expressible in XPath 1.0, so `:lang(de-DE)` also matches
/// `de-x-de`. See the README's Approximations.
fn lang_ancestor_condition(source: LangSource, range: &str) -> String {
    let range = range.to_ascii_lowercase();
    let mut subtags = range.split('-');
    let lang = folded_lang(source);

    // The first subtag is an equality, which on the dash-terminated
    // string is a prefix match.
    let first = xpath_literal(&format!("{}-", subtags.next().expect("split is non-empty")));
    let mut conditions = format!("starts-with({lang}, {first})");
    // Everything after it is a whole-subtag search through the tail.
    let mut tail = format!("substring-after({lang}, {first})");
    for subtag in subtags {
        let needle = xpath_literal(&format!("-{subtag}-"));
        let bounded = format!("concat('-', {tail})");
        conditions.push_str(&format!(" and contains({bounded}, {needle})"));
        tail = format!("substring-after({bounded}, {needle})");
    }

    format!("{}[{conditions}]", source.nearest())
}