Skip to main content

rete_core/sparql/
diag.rs

1//! Query diagnostics: a side channel for the expression **type errors** that
2//! SPARQL silently resolves: a FILTER or HAVING drops the row (an error stays an
3//! error through `!`), BIND, projection and ORDER BY see an unbound value.
4//!
5//! `FILTER(CONTAINS(?iri, "x"))` is not unsupported — it is a type error,
6//! because CONTAINS takes string literals and an IRI is not one, and the spec
7//! says a FILTER whose expression errors drops the row. That is correct and it
8//! stays: results are never changed by this module. What changes is that the error is
9//! *counted*, so a query that came back empty can say why.
10//!
11//! Recording happens only on the error path (and, for the case-sensitivity
12//! hint, once per function per query on the no-match path), into a
13//! thread-local sink — SPARQL evaluation is single-threaded, and a
14//! thread-local keeps two queries running on two server threads from
15//! mixing their warnings. The sink is bounded: one entry per
16//! `(function, argument position, argument kind)`, holding a count and the
17//! first offending value.
18
19use std::cell::RefCell;
20
21use super::{ArithOp, Builtin, FExpr, Op, QueryOutput};
22
23/// Where a type error was raised: a built-in function, or an operator
24/// (`+ - * /`, a comparison, `IN`), which SPARQL defines as functions too
25/// (`op:numeric-add`, `op:numeric-equal`, …) but which have no call syntax.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub(crate) enum Site {
28    Fn(Builtin),
29    /// The operator's SPARQL spelling: `+`, `<=`, `IN`, …
30    Op(&'static str),
31}
32
33impl From<Builtin> for Site {
34    fn from(f: Builtin) -> Self {
35        Site::Fn(f)
36    }
37}
38
39impl Site {
40    pub(crate) fn arith(op: ArithOp) -> Site {
41        Site::Op(match op {
42            ArithOp::Add => "+",
43            ArithOp::Sub => "-",
44            ArithOp::Mul => "*",
45            ArithOp::Div => "/",
46        })
47    }
48
49    pub(crate) fn compare(op: Op) -> Site {
50        Site::Op(match op {
51            Op::Eq => "=",
52            Op::Ne => "!=",
53            Op::Lt => "<",
54            Op::Le => "<=",
55            Op::Gt => ">",
56            Op::Ge => ">=",
57        })
58    }
59
60    fn name(self) -> &'static str {
61        match self {
62            Site::Fn(f) => fn_name(f),
63            Site::Op(sym) => sym,
64        }
65    }
66
67    /// The family a site belongs to, which decides the hint.
68    fn family(self) -> Family {
69        match self {
70            Site::Fn(Builtin::Abs | Builtin::Ceil | Builtin::Floor | Builtin::Round) => {
71                Family::Numeric
72            }
73            Site::Op("+" | "-" | "*" | "/") => Family::Numeric,
74            Site::Op(_) => Family::Compare,
75            Site::Fn(Builtin::Lang | Builtin::Datatype) => Family::LiteralAccessor,
76            Site::Fn(Builtin::StrDt | Builtin::StrLang) => Family::Constructor,
77            Site::Fn(_) => Family::Other,
78        }
79    }
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83enum Family {
84    Numeric,
85    Compare,
86    LiteralAccessor,
87    Constructor,
88    Other,
89}
90
91/// How serious a [`QueryWarning`] is.
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93#[non_exhaustive]
94pub enum WarningSeverity {
95    /// A SPARQL type error was raised and silently absorbed (FILTER drops the
96    /// row, BIND → unbound). The results are spec-correct; the query probably is not
97    /// what its author meant.
98    TypeError,
99    /// No error happened; a guess at why a query came back empty (for example,
100    /// string matching is case-sensitive). Phrased as a suggestion.
101    Hint,
102}
103
104impl WarningSeverity {
105    /// `"type-error"` or `"hint"` — the JSON spelling.
106    pub fn as_str(self) -> &'static str {
107        match self {
108            WarningSeverity::TypeError => "type-error",
109            WarningSeverity::Hint => "hint",
110        }
111    }
112}
113
114/// One diagnostic about a query evaluation: a counted class of type errors, or
115/// a hint. Returned by [`eval_query_with_warnings`](super::eval_query_with_warnings).
116#[derive(Debug, Clone, PartialEq, Eq)]
117#[non_exhaustive]
118pub struct QueryWarning {
119    pub severity: WarningSeverity,
120    /// The SPARQL function name, upper-case (`"CONTAINS"`), or the operator's
121    /// spelling for an operator: `+` `-` `*` `/`, `=` `!=` `<` `<=` `>` `>=`,
122    /// `IN`.
123    pub function: String,
124    /// 1-based argument position the problem is about (0 = not argument-specific).
125    /// For an operator, 1 is the left operand and 2 the right one.
126    pub argument: usize,
127    /// What the argument was: `iri`, `blank-node`, `quoted-triple`, `numeric`,
128    /// `typed-literal`, `string`, `language-tagged`, `invalid-number`,
129    /// `language-mismatch`, `unbound`, `invalid-regex`, `not-a-datetime`,
130    /// `division-by-zero` (argument 2 of `/`), or `case-sensitive` for the
131    /// hint.
132    pub kind: String,
133    /// How many evaluations raised this error (an evaluation is one row reaching
134    /// the expression; LIMIT / ASK stop early, so this is not a data count).
135    pub count: u64,
136    /// The first offending value, truncated to 80 characters (plus `…`).
137    pub sample: Option<String>,
138    /// A short, actionable suggestion.
139    pub hint: String,
140    /// The whole warning as one plain-language sentence.
141    pub message: String,
142}
143
144/// Samples are cut to this many characters (plus `…`).
145const SAMPLE_CHARS: usize = 80;
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148pub(crate) enum ArgKind {
149    Iri,
150    Blank,
151    QuotedTriple,
152    Numeric,
153    TypedLiteral,
154    LangMismatch,
155    Unbound,
156    InvalidRegex,
157    NotDatetime,
158    /// A string literal (plain or `xsd:string`) where a number, a literal
159    /// accessor's operand or a simple literal was needed.
160    StringLit,
161    /// A language-tagged literal where a simple literal was needed.
162    LangTagged,
163    /// A literal of a numeric datatype whose lexical form is not a number.
164    InvalidNumber,
165    /// The divisor of `/` was zero.
166    DivByZero,
167}
168
169impl ArgKind {
170    fn as_str(self) -> &'static str {
171        match self {
172            ArgKind::Iri => "iri",
173            ArgKind::Blank => "blank-node",
174            ArgKind::QuotedTriple => "quoted-triple",
175            ArgKind::Numeric => "numeric",
176            ArgKind::TypedLiteral => "typed-literal",
177            ArgKind::LangMismatch => "language-mismatch",
178            ArgKind::Unbound => "unbound",
179            ArgKind::InvalidRegex => "invalid-regex",
180            ArgKind::NotDatetime => "not-a-datetime",
181            ArgKind::StringLit => "string",
182            ArgKind::LangTagged => "language-tagged",
183            ArgKind::InvalidNumber => "invalid-number",
184            ArgKind::DivByZero => "division-by-zero",
185        }
186    }
187}
188
189struct Entry {
190    site: Site,
191    pos: usize,
192    kind: ArgKind,
193    count: u64,
194    sample: String,
195    /// Kind-specific detail: a datatype, the two language tags, a regex error.
196    detail: String,
197}
198
199/// Bits of the case-sensitivity probe ([`note_no_match`]).
200pub(crate) const MISS_CONTAINS: u8 = 1;
201pub(crate) const MISS_STRSTARTS: u8 = 2;
202pub(crate) const MISS_STRENDS: u8 = 4;
203pub(crate) const MISS_REGEX: u8 = 8;
204
205#[derive(Default)]
206struct Sink {
207    entries: Vec<Entry>,
208    /// Constant needles of string predicates that returned `false` on valid
209    /// string arguments at least once: `(function, needle)`.
210    misses: Vec<(Builtin, String)>,
211}
212
213thread_local! {
214    static SINK: RefCell<Sink> = RefCell::new(Sink::default());
215}
216
217/// Start a fresh diagnostic scope (drops anything a previous query left).
218pub(crate) fn begin() {
219    SINK.with(|s| {
220        let mut s = s.borrow_mut();
221        s.entries.clear();
222        s.misses.clear();
223    });
224}
225
226/// Record one type error. `describe` builds `(sample, detail)` and runs only
227/// for the first occurrence of a `(func, pos, kind)` key.
228#[cold]
229#[inline(never)]
230pub(crate) fn record(
231    site: impl Into<Site>,
232    pos: usize,
233    kind: ArgKind,
234    describe: impl FnOnce() -> (String, String),
235) {
236    let site = site.into();
237    SINK.with(|s| {
238        let mut s = s.borrow_mut();
239        if let Some(e) = s
240            .entries
241            .iter_mut()
242            .find(|e| e.site == site && e.pos == pos && e.kind == kind)
243        {
244            e.count += 1;
245            return;
246        }
247        let (sample, detail) = describe();
248        s.entries.push(Entry {
249            site,
250            pos,
251            kind,
252            count: 1,
253            sample: truncate(&sample),
254            detail,
255        });
256    });
257}
258
259/// Record that a string predicate returned no match on valid string
260/// arguments. Called once per function per query (the caller keeps a bit in
261/// `Ctx`); the needle is kept only when it is a constant in the query.
262#[cold]
263#[inline(never)]
264pub(crate) fn note_no_match(func: Builtin, needle: &FExpr) {
265    if let FExpr::Const(c) = needle {
266        let lex = crate::terms::literal_lexical(c).unwrap_or_else(|| c.clone());
267        SINK.with(|s| s.borrow_mut().misses.push((func, lex)));
268    }
269}
270
271/// A string function rejected the bound term `token` at 1-based `pos`.
272#[cold]
273#[inline(never)]
274pub(crate) fn bad_arg(func: Builtin, pos: usize, token: &str) {
275    record(func, pos, kind_of(token), || {
276        (token.to_string(), detail_of(token))
277    });
278}
279
280/// Both arguments of a binary string function are strings, but argument 2
281/// carries a language tag that argument 1 does not share (§17.4.3).
282#[cold]
283#[inline(never)]
284pub(crate) fn lang_mismatch(func: Builtin, text: &str, pat: &str) {
285    record(func, 2, ArgKind::LangMismatch, || {
286        let tag = |t: &str| match crate::terms::lang_tag(t).filter(|l| !l.is_empty()) {
287            Some(l) => format!("@{l}"),
288            None => "no tag".to_string(),
289        };
290        (pat.to_string(), format!("{} vs {}", tag(pat), tag(text)))
291    });
292}
293
294/// The argument at 1-based `pos` is the variable `var`, unbound in this row.
295#[cold]
296#[inline(never)]
297pub(crate) fn unbound(site: impl Into<Site>, pos: usize, var: &str) {
298    record(site, pos, ArgKind::Unbound, || {
299        (format!("?{var}"), String::new())
300    });
301}
302
303/// The REGEX / REPLACE pattern does not compile.
304#[cold]
305#[inline(never)]
306pub(crate) fn invalid_regex(func: Builtin, pattern: &str, flags: &str) {
307    record(func, 2, ArgKind::InvalidRegex, || {
308        (
309            pattern.to_string(),
310            crate::row::Resolver::regex_error(pattern, flags),
311        )
312    });
313}
314
315/// A date/time accessor got a value that does not parse as an xsd:dateTime.
316#[cold]
317#[inline(never)]
318pub(crate) fn bad_datetime(func: Builtin, token: &str) {
319    record(func, 1, ArgKind::NotDatetime, || {
320        let detail = match crate::terms::literal_datatype(token) {
321            Some(dt) if token.starts_with('"') => short_dt(&dt),
322            _ => kind_of(token).as_str().to_string(),
323        };
324        (token.to_string(), detail)
325    });
326}
327
328/// A numeric function or an arithmetic operator got a term that is not a
329/// number (by the rule that site applies: arithmetic takes numeric-typed
330/// literals only, ABS / CEIL / FLOOR / ROUND any literal whose text parses).
331#[cold]
332#[inline(never)]
333pub(crate) fn not_a_number(site: impl Into<Site>, pos: usize, token: &str) {
334    let kind = match term_kind(token) {
335        ArgKind::Numeric => ArgKind::InvalidNumber,
336        k => k,
337    };
338    record(site, pos, kind, || {
339        (token.to_string(), detail_of_any(token))
340    });
341}
342
343/// `/` with a zero divisor (`token` is the divisor as written).
344#[cold]
345#[inline(never)]
346pub(crate) fn div_by_zero(token: &str) {
347    record(Site::Op("/"), 2, ArgKind::DivByZero, || {
348        (token.to_string(), detail_of(token))
349    });
350}
351
352/// LANG / DATATYPE got a term that is not a literal.
353#[cold]
354#[inline(never)]
355pub(crate) fn not_a_literal(func: Builtin, token: &str) {
356    record(func, 1, kind_of(token), || {
357        (token.to_string(), String::new())
358    });
359}
360
361/// STRDT / STRLANG argument 1 is not a simple literal, or STRDT argument 2 is
362/// not an IRI.
363#[cold]
364#[inline(never)]
365pub(crate) fn bad_constructor_arg(func: Builtin, pos: usize, token: &str) {
366    record(func, pos, term_kind(token), || {
367        (token.to_string(), detail_of_any(token))
368    });
369}
370
371/// Classify any term, telling string and language-tagged literals apart from
372/// other typed ones (no allocation for IRIs and blank nodes).
373fn term_kind(token: &str) -> ArgKind {
374    if !token.starts_with('"') {
375        return kind_of(token);
376    }
377    if crate::terms::lang_tag(token).is_some_and(|l| !l.is_empty()) {
378        return ArgKind::LangTagged;
379    }
380    match crate::terms::literal_datatype(token) {
381        Some(dt) if dt == "http://www.w3.org/2001/XMLSchema#string" => ArgKind::StringLit,
382        None => ArgKind::StringLit,
383        Some(dt) if is_numeric_datatype(&dt) => ArgKind::Numeric,
384        Some(_) => ArgKind::TypedLiteral,
385    }
386}
387
388/// [`detail_of`], but a language-tagged literal reports its tag (`@en`).
389fn detail_of_any(token: &str) -> String {
390    match crate::terms::lang_tag(token).filter(|l| !l.is_empty()) {
391        Some(l) if token.starts_with('"') => format!("@{l}"),
392        _ => detail_of(token),
393    }
394}
395
396/// Classify a bound term that a string function rejected (no allocation for
397/// IRIs and blank nodes, the common case).
398fn kind_of(token: &str) -> ArgKind {
399    if token.starts_with("<<") {
400        ArgKind::QuotedTriple
401    } else if token.starts_with('<') {
402        ArgKind::Iri
403    } else if token.starts_with("_:") {
404        ArgKind::Blank
405    } else if crate::terms::literal_datatype(token).is_some_and(|dt| is_numeric_datatype(&dt)) {
406        ArgKind::Numeric
407    } else {
408        ArgKind::TypedLiteral
409    }
410}
411
412/// The short datatype of a literal (`xsd:integer`), else `""`.
413fn detail_of(token: &str) -> String {
414    match crate::terms::literal_datatype(token) {
415        Some(dt) if token.starts_with('"') => short_dt(&dt),
416        _ => String::new(),
417    }
418}
419
420fn is_numeric_datatype(dt: &str) -> bool {
421    dt.strip_prefix("http://www.w3.org/2001/XMLSchema#")
422        .is_some_and(|local| {
423            matches!(
424                local,
425                "integer"
426                    | "decimal"
427                    | "float"
428                    | "double"
429                    | "int"
430                    | "long"
431                    | "short"
432                    | "byte"
433                    | "nonNegativeInteger"
434                    | "nonPositiveInteger"
435                    | "negativeInteger"
436                    | "positiveInteger"
437                    | "unsignedInt"
438                    | "unsignedLong"
439                    | "unsignedShort"
440                    | "unsignedByte"
441            )
442        })
443}
444
445fn short_dt(dt: &str) -> String {
446    match dt.strip_prefix("http://www.w3.org/2001/XMLSchema#") {
447        Some(local) => format!("xsd:{local}"),
448        None => format!("<{dt}>"),
449    }
450}
451
452fn truncate(s: &str) -> String {
453    match s.char_indices().nth(SAMPLE_CHARS) {
454        Some((i, _)) => format!("{}…", &s[..i]),
455        None => s.to_string(),
456    }
457}
458
459/// SPARQL spelling of a builtin, for messages.
460pub(crate) fn fn_name(f: Builtin) -> &'static str {
461    match f {
462        Builtin::Str => "STR",
463        Builtin::StrLen => "STRLEN",
464        Builtin::UCase => "UCASE",
465        Builtin::LCase => "LCASE",
466        Builtin::Concat => "CONCAT",
467        Builtin::SubStr => "SUBSTR",
468        Builtin::StrBefore => "STRBEFORE",
469        Builtin::StrAfter => "STRAFTER",
470        Builtin::Contains => "CONTAINS",
471        Builtin::StrStarts => "STRSTARTS",
472        Builtin::StrEnds => "STRENDS",
473        Builtin::Regex => "REGEX",
474        Builtin::Replace => "REPLACE",
475        Builtin::EncodeForUri => "ENCODE_FOR_URI",
476        Builtin::Md5 => "MD5",
477        Builtin::Sha1 => "SHA1",
478        Builtin::Sha256 => "SHA256",
479        Builtin::Sha384 => "SHA384",
480        Builtin::Sha512 => "SHA512",
481        Builtin::Year => "YEAR",
482        Builtin::Month => "MONTH",
483        Builtin::Day => "DAY",
484        Builtin::Hours => "HOURS",
485        Builtin::Minutes => "MINUTES",
486        Builtin::Seconds => "SECONDS",
487        Builtin::Timezone => "TIMEZONE",
488        Builtin::Tz => "TZ",
489        Builtin::Abs => "ABS",
490        Builtin::Ceil => "CEIL",
491        Builtin::Floor => "FLOOR",
492        Builtin::Round => "ROUND",
493        Builtin::Lang => "LANG",
494        Builtin::Datatype => "DATATYPE",
495        Builtin::StrDt => "STRDT",
496        Builtin::StrLang => "STRLANG",
497        _ => "function",
498    }
499}
500
501fn ordinal(site: Site, pos: usize) -> String {
502    match (site, pos) {
503        (Site::Op(_), 1) => "the left operand".to_string(),
504        (Site::Op(_), 2) => "the right operand".to_string(),
505        _ => format!("argument {pos}"),
506    }
507}
508
509fn rows(n: u64) -> String {
510    if n == 1 {
511        "1 row".to_string()
512    } else {
513        format!("{n} rows")
514    }
515}
516
517/// The hint for a site family other than the string functions, or `None` to
518/// keep the kind's own hint (which was written for the string functions).
519fn family_hint(family: Family, pos: usize, kind: ArgKind) -> Option<&'static str> {
520    use ArgKind::*;
521    Some(match (family, kind) {
522        (_, Unbound | InvalidRegex | NotDatetime | LangMismatch | DivByZero) => return None,
523        (Family::Numeric, StringLit) => {
524            "a string is not a number; cast one that holds a number first, e.g. \
525             xsd:decimal(?x), or restrict the variable with isNumeric()"
526        }
527        (Family::Numeric, InvalidNumber) => {
528            "its text is not a valid number for its datatype; restrict the variable with \
529             isNumeric(), which checks the lexical form"
530        }
531        (Family::Numeric, _) => "it is not a number; restrict the variable with isNumeric()",
532        (Family::LiteralAccessor, _) => {
533            "LANG() and DATATYPE() take a literal; restrict the variable with isLiteral()"
534        }
535        (Family::Constructor, Blank | QuotedTriple) if pos == 1 => {
536            "argument 1 must be a simple literal; restrict the variable with isLiteral()"
537        }
538        (Family::Constructor, _) if pos == 1 => {
539            "argument 1 must be a simple literal (no language tag, no datatype); use STR() on \
540             it to take its lexical form"
541        }
542        (Family::Constructor, _) => {
543            "argument 2 of STRDT must be a datatype IRI, e.g. xsd:integer; IRI(?x) turns a \
544             string into one"
545        }
546        (Family::Compare | Family::Other, _) => return None,
547    })
548}
549
550impl Entry {
551    fn into_warning(self) -> QueryWarning {
552        let name = self.site.name();
553        let arg = ordinal(self.site, self.pos);
554        let n = rows(self.count);
555        let dt = |what: &str| {
556            if self.detail.is_empty() {
557                what.to_string()
558            } else {
559                format!("{what} ({})", self.detail)
560            }
561        };
562        let (what, hint) = match self.kind {
563            ArgKind::Iri => (
564                "an IRI".to_string(),
565                "wrap it in STR() to match the IRI's text".to_string(),
566            ),
567            ArgKind::Blank => (
568                "a blank node".to_string(),
569                "blank nodes are not strings; restrict the variable with isLiteral()".to_string(),
570            ),
571            ArgKind::QuotedTriple => (
572                "a quoted triple".to_string(),
573                "quoted triples are not strings; restrict the variable with isLiteral()"
574                    .to_string(),
575            ),
576            ArgKind::Numeric => (
577                dt("a number"),
578                "wrap it in STR() to match its lexical form".to_string(),
579            ),
580            ArgKind::TypedLiteral => (
581                dt("a non-string typed literal"),
582                "wrap it in STR() to match its lexical form".to_string(),
583            ),
584            ArgKind::StringLit => (
585                "a string".to_string(),
586                "wrap it in STR() to match its lexical form".to_string(),
587            ),
588            ArgKind::LangTagged => (
589                dt("a language-tagged literal"),
590                "use STR() on it to drop the language tag".to_string(),
591            ),
592            ArgKind::InvalidNumber => (
593                dt("a literal whose text is not a valid number for its datatype"),
594                String::new(),
595            ),
596            ArgKind::LangMismatch => (
597                format!(
598                    "a literal whose language tag does not match argument 1 ({})",
599                    self.detail
600                ),
601                "the language tags differ; use STR() on both arguments".to_string(),
602            ),
603            ArgKind::Unbound => (
604                format!("an unbound variable ({})", self.sample),
605                "the variable has no value in those rows (an OPTIONAL that did not match, \
606                 or a misspelled variable); use BOUND() or COALESCE() if that is intended"
607                    .to_string(),
608            ),
609            ArgKind::InvalidRegex => (
610                "an invalid regex pattern".to_string(),
611                format!(
612                    "invalid regex pattern ({}); rete uses Rust regex syntax, which has no \
613                     look-around or back-references",
614                    self.detail
615                ),
616            ),
617            ArgKind::NotDatetime => (
618                dt("a value that is not an xsd:dateTime"),
619                "this function needs an xsd:dateTime; for a year in a plain or xsd:date value \
620                 use SUBSTR(STR(?x), 1, 4)"
621                    .to_string(),
622            ),
623            ArgKind::DivByZero => (
624                "zero".to_string(),
625                "guard the divisor, e.g. IF(?d != 0, ?n / ?d, 0), or FILTER(?d != 0) first"
626                    .to_string(),
627            ),
628        };
629        let hint = match family_hint(self.site.family(), self.pos, self.kind) {
630            Some(h) => h.to_string(),
631            None => hint,
632        };
633        let mut message = match (self.kind, self.site) {
634            (ArgKind::DivByZero, _) => format!("{name} divided by zero in {n}: a SPARQL error"),
635            (_, Site::Op(_)) => {
636                format!("Operator {name} received {what} as {arg} in {n}: a SPARQL type error")
637            }
638            _ => format!("{name} received {what} as {arg} in {n}: a SPARQL type error"),
639        };
640        message.push_str(
641            ", which makes a FILTER drop the row, even under ! (BIND leaves the variable unbound)",
642        );
643        if !matches!(self.kind, ArgKind::Unbound | ArgKind::DivByZero) && !self.sample.is_empty() {
644            message.push_str(&format!("; e.g. {}", self.sample));
645        }
646        message.push_str(&format!(". Hint: {hint}."));
647        QueryWarning {
648            severity: WarningSeverity::TypeError,
649            function: name.to_string(),
650            argument: self.pos,
651            kind: self.kind.as_str().to_string(),
652            count: self.count,
653            sample: (!self.sample.is_empty()).then_some(self.sample),
654            hint,
655            message,
656        }
657    }
658}
659
660fn is_empty_result(out: &QueryOutput) -> bool {
661    match out {
662        QueryOutput::Select(_, rows) => rows.is_empty(),
663        QueryOutput::Ask(b) => !*b,
664        QueryOutput::Construct(t) => t.is_empty(),
665    }
666}
667
668/// Close the scope opened by [`begin`]: turn the sink into warnings for `out`.
669pub(crate) fn finish(out: &QueryOutput) -> Vec<QueryWarning> {
670    let (entries, misses) = SINK.with(|s| {
671        let mut s = s.borrow_mut();
672        (
673            std::mem::take(&mut s.entries),
674            std::mem::take(&mut s.misses),
675        )
676    });
677    let errored = !entries.is_empty();
678    let mut out_w: Vec<QueryWarning> = entries.into_iter().map(Entry::into_warning).collect();
679    // Case-sensitivity hint: only for an empty result, only when no type
680    // error offers a better explanation, only for a constant needle that has
681    // letters with case, and only when the predicate actually ran on string
682    // values and said no. It is a suggestion, never an error.
683    if !errored && is_empty_result(out) {
684        let mut seen: Vec<(Builtin, String)> = Vec::new();
685        for (func, needle) in misses {
686            if needle.to_lowercase() == needle.to_uppercase()
687                || seen.iter().any(|(f, n)| *f == func && *n == needle)
688            {
689                continue;
690            }
691            seen.push((func, needle.clone()));
692            let name = fn_name(func);
693            let hint = if func == Builtin::Regex {
694                format!("add the \"i\" flag: REGEX(?x, \"{needle}\", \"i\")")
695            } else {
696                format!(
697                    "compare case-folded: {name}(LCASE(?x), \"{}\")",
698                    needle.to_lowercase()
699                )
700            };
701            out_w.push(QueryWarning {
702                severity: WarningSeverity::Hint,
703                function: name.to_string(),
704                argument: 2,
705                kind: "case-sensitive".to_string(),
706                count: 0,
707                sample: Some(truncate(&needle)),
708                message: format!(
709                    "No results. {name} ran on string values but never matched \"{}\"; \
710                     string matching is case-sensitive. If you expected matches, {hint}.",
711                    truncate(&needle)
712                ),
713                hint,
714            });
715        }
716    }
717    out_w
718}
719
720/// The warnings as a JSON array (`[]` when empty), in the field order of
721/// [`QueryWarning`] with `argKind` for `kind`.
722pub fn warnings_json(warnings: &[QueryWarning]) -> String {
723    use crate::results::push_json_string;
724    let mut s = String::from("[");
725    for (i, w) in warnings.iter().enumerate() {
726        if i > 0 {
727            s.push(',');
728        }
729        s.push_str(r#"{"severity":"#);
730        push_json_string(&mut s, w.severity.as_str());
731        s.push_str(r#","function":"#);
732        push_json_string(&mut s, &w.function);
733        s.push_str(&format!(r#","argument":{}"#, w.argument));
734        s.push_str(r#","argKind":"#);
735        push_json_string(&mut s, &w.kind);
736        s.push_str(&format!(r#","count":{}"#, w.count));
737        s.push_str(r#","sample":"#);
738        match &w.sample {
739            Some(v) => push_json_string(&mut s, v),
740            None => s.push_str("null"),
741        }
742        s.push_str(r#","hint":"#);
743        push_json_string(&mut s, &w.hint);
744        s.push_str(r#","message":"#);
745        push_json_string(&mut s, &w.message);
746        s.push('}');
747    }
748    s.push(']');
749    s
750}