Skip to main content

helios_sof/
lint.rs

1//! Structural + FHIRPath-syntax linting for ViewDefinition documents (#753
2//! evaluation POC, matured into the single lint engine for `$sql-run`,
3//! `sof-cli`, `pysof`, and the ViewDefinition editor by #821).
4//!
5//! [`lint_view_definition`] is the single source of truth for "is this JSON
6//! document a well-formed ViewDefinition": it walks a raw [`serde_json::Value`]
7//! (never a typed `helios_fhir` resource — a document being edited is often
8//! not valid enough to deserialize) and returns every problem it finds,
9//! located by [RFC 6901](https://www.rfc-editor.org/rfc/rfc6901) JSON pointer.
10//!
11//! This is deliberately **structural, syntactic, and — for `resource` — a
12//! name check against the compiled-in resource types**: the principle #821
13//! states is "the browser only knows syntax; the server knows FHIR", and
14//! this module is the FHIR side of that split for the ViewDefinition shape
15//! itself. It does not evaluate FHIRPath expressions, does not resolve
16//! terminology, and does not touch storage: every check here — including the
17//! `resource` name check, which only consults `helios_fhir`'s compiled-in
18//! resource type list — is a pure function of the document.
19//!
20//! # What this checks
21//!
22//! - The document is `{"resourceType": "ViewDefinition", ...}`
23//!   ([`DiagnosticCode::NotAViewDefinition`]).
24//! - Every key, at every node, is one this module's own key model (see
25//!   [`Node::fields`]) allows for that node
26//!   ([`DiagnosticCode::UnknownKey`]), has the JSON type the model expects
27//!   ([`DiagnosticCode::WrongType`]), and — for required keys — is present
28//!   ([`DiagnosticCode::MissingRequired`]) and non-empty
29//!   ([`DiagnosticCode::EmptyRequired`]).
30//! - A `select` produces some output
31//!   ([`DiagnosticCode::SelectWithoutOutput`]) and carries at most one
32//!   iteration directive ([`DiagnosticCode::MultipleIterationDirectives`]).
33//! - Column names don't collide within one output row
34//!   ([`DiagnosticCode::DuplicateColumnName`]).
35//! - Every FHIRPath expression (`column[].path`, `where[].path`, `forEach`,
36//!   `forEachOrNull`, each element of `repeat`) parses
37//!   ([`DiagnosticCode::FhirPathSyntax`]), via [`helios_fhirpath`]'s parser —
38//!   syntax only, never evaluated.
39//! - Every `%name` reference inside a FHIRPath expression that parses
40//!   successfully names something that actually exists: an entry in
41//!   `constant[].name`, one of the FHIRPath environment variables the
42//!   evaluator resolves (`%context`, `%resource`, `%rootResource`, `%ucum`,
43//!   `%sct`, `%loinc`), or a SQL-on-FHIR environment variable this crate
44//!   itself binds (`%rowIndex`) ([`DiagnosticCode::UndeclaredConstant`]).
45//!   Locating the reference still doesn't evaluate the expression — it
46//!   walks the parsed AST [`helios_fhirpath::external_constants`] returns.
47//! - `resource` names a resource type of some FHIR version compiled into
48//!   this build ([`DiagnosticCode::UnknownResourceType`]) — the one check
49//!   here that consults `helios_fhir` (for the list of names), still a pure
50//!   function of the document (#1014).
51//!
52//! # Actionability and localization (#821)
53//!
54//! Every [`Diagnostic`] carries `args` — the values its English `message`
55//! interpolates, as named strings — and `fixes`, structural edits (pointer-
56//! addressed, never text-addressed: this module never sees source text)
57//! believed to resolve it. `message` itself is always English and never
58//! localized here; a caller that wants the diagnostic in another language
59//! (the `/ui/sql/view-definitions/lint` handler, for one) renders its own
60//! catalog from `code` + `args` instead of using `message` at all. See
61//! [`Fix`] and the `args` doc on [`Diagnostic`] for the exact contract.
62//!
63//! [`node_keys`] exposes the same key model these checks are built on, so a
64//! consumer that wants "what keys are valid here" (a completion endpoint,
65//! for instance) doesn't have to duplicate it.
66//!
67//! # Example
68//!
69//! ```
70//! use helios_sof::lint::{lint_view_definition, DiagnosticCode, Severity};
71//! use serde_json::json;
72//!
73//! let doc = json!({
74//!     "resourceType": "ViewDefinition",
75//!     "status": "active",
76//!     "resource": "Patient",
77//!     "select": [{
78//!         "column": [{ "name": "id", "path": "getResourceKey(" }]
79//!     }]
80//! });
81//!
82//! let diagnostics = lint_view_definition(&doc);
83//! assert_eq!(diagnostics.len(), 1);
84//! assert_eq!(diagnostics[0].code, DiagnosticCode::FhirPathSyntax);
85//! assert_eq!(diagnostics[0].severity, Severity::Error);
86//! assert_eq!(diagnostics[0].pointer, "/select/0/column/0/path");
87//! ```
88
89use serde_json::Value;
90use std::collections::{BTreeMap, HashSet};
91use std::sync::OnceLock;
92
93// ---------------------------------------------------------------------------
94// Public types (RF1)
95// ---------------------------------------------------------------------------
96
97/// A location inside the **string value** a [`Diagnostic`] points at,
98/// expressed in Unicode `char` offsets — never UTF-8 bytes — so a browser
99/// counting Unicode code points (or anything else that is not counting raw
100/// bytes) can index into the string directly. Only ever set for
101/// [`DiagnosticCode::FhirPathSyntax`] and [`DiagnosticCode::UndeclaredConstant`]
102/// — every other diagnostic already locates itself precisely enough with
103/// `pointer` alone.
104#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
105pub struct Span {
106    pub start: usize,
107    pub end: usize,
108}
109
110/// How serious a [`Diagnostic`] is. Nothing in this POC blocks Save — both
111/// severities are informational, but `Warning` is reserved for a future
112/// check that flags something suspicious rather than something the
113/// ViewDefinition spec (or this module's own key model) outright forbids;
114/// every check implemented today reports `Error`.
115#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
116#[serde(rename_all = "kebab-case")]
117pub enum Severity {
118    Error,
119    Warning,
120}
121
122/// What kind of problem a [`Diagnostic`] reports. `#[non_exhaustive]`: this
123/// is a POC rule set (see the module docs for what is deliberately out of
124/// scope), and future work is expected to add codes, not just consumers
125/// matching on the ones that exist today.
126#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
127#[serde(rename_all = "kebab-case")]
128#[non_exhaustive]
129pub enum DiagnosticCode {
130    /// The document is not `{"resourceType": "ViewDefinition", ...}`. When
131    /// this fires it is always the *only* diagnostic — nothing else about
132    /// the document can be meaningfully checked.
133    NotAViewDefinition,
134    /// A key this module's model does not recognize for its node.
135    UnknownKey,
136    /// A key the model marks required is absent.
137    MissingRequired,
138    /// A key's value is not the JSON type the model expects for it.
139    WrongType,
140    /// A required string is empty or all whitespace, or a required array is
141    /// empty.
142    EmptyRequired,
143    /// Two columns feeding the same output row share a `name`.
144    DuplicateColumnName,
145    /// A `select` sets more than one of `forEach`, `forEachOrNull`, `repeat`
146    /// — the `sql-expressions` invariant `validate_select_with_context`
147    /// already enforces at run time; this is its structural, all-errors
148    /// counterpart.
149    MultipleIterationDirectives,
150    /// A `select` has none of `column`, `select`, `unionAll` — it can never
151    /// produce a column.
152    SelectWithoutOutput,
153    /// A FHIRPath expression does not parse.
154    ///
155    /// `#[serde(rename)]` overrides the enum's own `kebab-case`: serde's
156    /// auto-casing splits on every capital, which would turn `FhirPath`
157    /// into `fhir-path` (two words) instead of the one word `fhirpath` the
158    /// wire contract (RF1's own example, and the rest of this codebase's
159    /// naming — `helios-fhirpath`, `helios_fhirpath`) uses everywhere else.
160    #[serde(rename = "fhirpath-syntax")]
161    FhirPathSyntax,
162    /// A FHIRPath expression parses, but references `%name` for a `name`
163    /// that is neither declared in `constant[]` nor a FHIRPath environment
164    /// variable the evaluator resolves. See the module docs for the exact
165    /// set of names this treats as declared.
166    UndeclaredConstant,
167    /// `resource` is a non-empty string that names no resource type of any
168    /// FHIR version compiled into this build (`"Nope"`, or `"patient"` —
169    /// the comparison is case-sensitive, as FHIR resource type names are).
170    /// A missing, empty or non-string `resource` is `MissingRequired` /
171    /// `EmptyRequired` / `WrongType` instead, never this.
172    UnknownResourceType,
173}
174
175/// A structural edit [`lint_view_definition`] believes would resolve (or at
176/// least meaningfully address) the [`Diagnostic`] it is attached to,
177/// expressed purely in terms of an [RFC 6901](https://www.rfc-editor.org/rfc/rfc6901)
178/// JSON pointer — never a text position. This module never sees the
179/// document's source text (a browser's CodeMirror instance does), so it
180/// cannot offer a text edit; a pointer is the one location format both sides
181/// agree on. `#[non_exhaustive]`: more fix shapes are expected as the lint
182/// grows more rules with obvious one-click resolutions.
183#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
184#[serde(tag = "kind", rename_all = "kebab-case")]
185#[non_exhaustive]
186pub enum Fix {
187    /// Rename the object key at `pointer` to `to`, keeping its value.
188    /// `pointer` names the *property*, not its parent object (e.g.
189    /// `/select/0/columns`, for a `columns` key that should be `column`).
190    RenameKey { pointer: String, to: String },
191    /// Remove the object key at `pointer` entirely.
192    RemoveKey { pointer: String },
193    /// Replace the string value at `pointer` with `value`.
194    SetString { pointer: String, value: String },
195}
196
197/// One problem [`lint_view_definition`] found, located by
198/// [RFC 6901](https://www.rfc-editor.org/rfc/rfc6901) JSON pointer
199/// (`""` is the document root; `~0`/`~1` escape `~`/`/` inside a key).
200///
201/// `message` is always English and never localized — `helios_sof` has no
202/// locale of its own, and `$sql-run`, `sof-cli`, and `pysof` all surface it
203/// verbatim. `args` carries the same information `message` interpolates, as
204/// named strings a caller (the `/ui/sql/view-definitions/lint` handler, in
205/// particular) can hand to its own catalog to render the message in the
206/// user's language instead; it is `{}` when `message` has nothing to
207/// interpolate. `fixes` are structural edits `lint_view_definition` believes
208/// address this diagnostic — see [`Fix`] — and is `[]` when it has none to
209/// offer.
210#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
211#[serde(rename_all = "camelCase")]
212pub struct Diagnostic {
213    pub pointer: String,
214    pub message: String,
215    pub severity: Severity,
216    pub code: DiagnosticCode,
217    pub span: Option<Span>,
218    pub args: BTreeMap<String, String>,
219    pub fixes: Vec<Fix>,
220}
221
222// ---------------------------------------------------------------------------
223// The key model (RF2) — the single source of truth this module's UnknownKey/
224// MissingRequired/WrongType checks drive off. Cross-checked in `tests` below
225// against the generated `helios_fhir::r4` structs.
226// ---------------------------------------------------------------------------
227
228/// The JSON shape a modeled key's value must have.
229#[derive(Clone, Copy)]
230enum Kind {
231    String,
232    Number,
233    Boolean,
234    /// An array whose elements must each be a JSON string (`repeat`).
235    StringArray,
236    /// A nested object, itself checked against another node's model.
237    Object(Node),
238    /// An array whose elements must each be an object, checked against
239    /// another node's model (`column`, `select`, `unionAll`, `constant`,
240    /// `where`, `tag`).
241    ObjectArray(Node),
242    /// Accepted, but this POC's model does not police its shape further —
243    /// every one of these is a real `ViewDefinition` field (see the
244    /// `key_model_matches_generated_structs` test) whose own JSON type is
245    /// either a full FHIR datatype (`Meta`, `Period`, `ContactDetail`, a
246    /// `Reference`-bearing `Extension`, ...) or a `value[x]` choice —
247    /// modeling those precisely is real scope this POC does not need to
248    /// carry to prove the CM6 + server-lint architecture out.
249    Any,
250}
251
252impl Kind {
253    /// Whether this kind is a primitive (or array of primitives) — the FHIR
254    /// primitive-extension convention (`"_status": {"extension": [...]}`)
255    /// only applies to *primitive* values, never to objects or object
256    /// arrays.
257    fn is_primitive(self) -> bool {
258        matches!(
259            self,
260            Kind::String | Kind::Number | Kind::Boolean | Kind::StringArray
261        )
262    }
263}
264
265/// One key this model allows on some [`Node`], and whether it is required.
266struct Field {
267    key: &'static str,
268    required: bool,
269    kind: Kind,
270}
271
272/// The node kinds a ViewDefinition document is built from. Each maps to one
273/// generated `helios_fhir::r4` struct (`Node::fields` names it), which is
274/// what the RF2 cross-check test in `tests` verifies.
275#[derive(Clone, Copy, PartialEq, Eq)]
276enum Node {
277    /// `helios_fhir::r4::ViewDefinition`.
278    Root,
279    /// `helios_fhir::r4::ViewDefinitionSelect`.
280    Select,
281    /// `helios_fhir::r4::ViewDefinitionSelectColumn`.
282    Column,
283    /// `helios_fhir::r4::ViewDefinitionSelectColumnTag`.
284    Tag,
285    /// `helios_fhir::r4::ViewDefinitionWhere`.
286    Where,
287    /// `helios_fhir::r4::ViewDefinitionConstant`.
288    Constant,
289}
290
291impl Node {
292    /// The keys this node accepts, in the order they appear on the
293    /// generated struct.
294    ///
295    /// `id`/`extension`/`modifierExtension` are `ViewDefinition`'s own
296    /// fields (inherited from the base `Resource`/`DomainResource` shape) —
297    /// they are declared here, on [`Node::Root`], and nowhere else, because
298    /// the generated backbone structs for `select`/`column`/`tag`/`where`/
299    /// `constant` do not carry them (verified directly against
300    /// `crates/fhir/src/r4.rs`: none of `ViewDefinitionSelect`,
301    /// `ViewDefinitionSelectColumn`, `ViewDefinitionSelectColumnTag`,
302    /// `ViewDefinitionWhere`, or `ViewDefinitionConstant` has an `id`,
303    /// `extension`, or `modifierExtension` field). RF2's own principle —
304    /// the model matches the generated structs — is what settles this in
305    /// favor of the struct over more general prose elsewhere.
306    fn fields(self) -> &'static [Field] {
307        match self {
308            Node::Root => &[
309                Field {
310                    key: "resourceType",
311                    required: true,
312                    kind: Kind::String,
313                },
314                Field {
315                    key: "id",
316                    required: false,
317                    kind: Kind::Any,
318                },
319                Field {
320                    key: "meta",
321                    required: false,
322                    kind: Kind::Any,
323                },
324                Field {
325                    key: "implicitRules",
326                    required: false,
327                    kind: Kind::String,
328                },
329                Field {
330                    key: "language",
331                    required: false,
332                    kind: Kind::String,
333                },
334                Field {
335                    key: "text",
336                    required: false,
337                    kind: Kind::Any,
338                },
339                Field {
340                    key: "contained",
341                    required: false,
342                    kind: Kind::Any,
343                },
344                Field {
345                    key: "extension",
346                    required: false,
347                    kind: Kind::Any,
348                },
349                Field {
350                    key: "modifierExtension",
351                    required: false,
352                    kind: Kind::Any,
353                },
354                Field {
355                    key: "url",
356                    required: false,
357                    kind: Kind::String,
358                },
359                Field {
360                    key: "identifier",
361                    required: false,
362                    kind: Kind::Any,
363                },
364                Field {
365                    key: "version",
366                    required: false,
367                    kind: Kind::String,
368                },
369                // Choice type (value[x]-style): the JSON key carries the
370                // chosen variant's suffix, never the bare "versionAlgorithm".
371                Field {
372                    key: "versionAlgorithmString",
373                    required: false,
374                    kind: Kind::String,
375                },
376                Field {
377                    key: "versionAlgorithmCoding",
378                    required: false,
379                    kind: Kind::Any,
380                },
381                Field {
382                    key: "name",
383                    required: false,
384                    kind: Kind::String,
385                },
386                Field {
387                    key: "title",
388                    required: false,
389                    kind: Kind::String,
390                },
391                // RF3's prose lists `status` as required alongside `resource`/
392                // `select`, matching the generated struct's non-Option `status:
393                // Code` field - but the official SQL-on-FHIR conformance fixtures
394                // this crate already vendors (crates/sof/tests/sql-on-fhir/tests/)
395                // omit it in 33 of 133 non-error test views, and none of those are
396                // `expectError` cases. helios-fhir's generated deserializer also
397                // defaults a missing required scalar rather than rejecting it (see
398                // the `key_model_matches_generated_structs` test), so nothing else
399                // in this codebase treats `status` as load-bearing either. Modeled
400                // as optional so this lint agrees with its own acceptance bar: the
401                // existing suite's valid ViewDefinitions must lint clean.
402                Field {
403                    key: "status",
404                    required: false,
405                    kind: Kind::String,
406                },
407                Field {
408                    key: "experimental",
409                    required: false,
410                    kind: Kind::Boolean,
411                },
412                Field {
413                    key: "date",
414                    required: false,
415                    kind: Kind::String,
416                },
417                Field {
418                    key: "publisher",
419                    required: false,
420                    kind: Kind::String,
421                },
422                Field {
423                    key: "contact",
424                    required: false,
425                    kind: Kind::Any,
426                },
427                Field {
428                    key: "description",
429                    required: false,
430                    kind: Kind::String,
431                },
432                Field {
433                    key: "useContext",
434                    required: false,
435                    kind: Kind::Any,
436                },
437                Field {
438                    key: "jurisdiction",
439                    required: false,
440                    kind: Kind::Any,
441                },
442                Field {
443                    key: "purpose",
444                    required: false,
445                    kind: Kind::String,
446                },
447                Field {
448                    key: "copyright",
449                    required: false,
450                    kind: Kind::String,
451                },
452                Field {
453                    key: "copyrightLabel",
454                    required: false,
455                    kind: Kind::String,
456                },
457                Field {
458                    key: "approvalDate",
459                    required: false,
460                    kind: Kind::String,
461                },
462                Field {
463                    key: "lastReviewDate",
464                    required: false,
465                    kind: Kind::String,
466                },
467                Field {
468                    key: "effectivePeriod",
469                    required: false,
470                    kind: Kind::Any,
471                },
472                Field {
473                    key: "topic",
474                    required: false,
475                    kind: Kind::Any,
476                },
477                Field {
478                    key: "author",
479                    required: false,
480                    kind: Kind::Any,
481                },
482                Field {
483                    key: "editor",
484                    required: false,
485                    kind: Kind::Any,
486                },
487                Field {
488                    key: "reviewer",
489                    required: false,
490                    kind: Kind::Any,
491                },
492                Field {
493                    key: "endorser",
494                    required: false,
495                    kind: Kind::Any,
496                },
497                Field {
498                    key: "relatedArtifact",
499                    required: false,
500                    kind: Kind::Any,
501                },
502                Field {
503                    key: "resource",
504                    required: true,
505                    kind: Kind::String,
506                },
507                Field {
508                    key: "profile",
509                    required: false,
510                    kind: Kind::Any,
511                },
512                Field {
513                    key: "fhirVersion",
514                    required: false,
515                    kind: Kind::Any,
516                },
517                Field {
518                    key: "constant",
519                    required: false,
520                    kind: Kind::ObjectArray(Node::Constant),
521                },
522                Field {
523                    key: "select",
524                    required: true,
525                    kind: Kind::ObjectArray(Node::Select),
526                },
527                Field {
528                    key: "where",
529                    required: false,
530                    kind: Kind::ObjectArray(Node::Where),
531                },
532            ],
533            Node::Select => &[
534                Field {
535                    key: "column",
536                    required: false,
537                    kind: Kind::ObjectArray(Node::Column),
538                },
539                Field {
540                    key: "select",
541                    required: false,
542                    kind: Kind::ObjectArray(Node::Select),
543                },
544                Field {
545                    key: "forEach",
546                    required: false,
547                    kind: Kind::String,
548                },
549                Field {
550                    key: "forEachOrNull",
551                    required: false,
552                    kind: Kind::String,
553                },
554                Field {
555                    key: "repeat",
556                    required: false,
557                    kind: Kind::StringArray,
558                },
559                Field {
560                    key: "unionAll",
561                    required: false,
562                    kind: Kind::ObjectArray(Node::Select),
563                },
564            ],
565            Node::Column => &[
566                Field {
567                    key: "path",
568                    required: true,
569                    kind: Kind::String,
570                },
571                Field {
572                    key: "name",
573                    required: true,
574                    kind: Kind::String,
575                },
576                Field {
577                    key: "description",
578                    required: false,
579                    kind: Kind::String,
580                },
581                Field {
582                    key: "collection",
583                    required: false,
584                    kind: Kind::Boolean,
585                },
586                Field {
587                    key: "type",
588                    required: false,
589                    kind: Kind::String,
590                },
591                Field {
592                    key: "tag",
593                    required: false,
594                    kind: Kind::ObjectArray(Node::Tag),
595                },
596            ],
597            Node::Tag => &[
598                Field {
599                    key: "name",
600                    required: true,
601                    kind: Kind::String,
602                },
603                Field {
604                    key: "value",
605                    required: true,
606                    kind: Kind::String,
607                },
608            ],
609            Node::Where => &[
610                Field {
611                    key: "path",
612                    required: true,
613                    kind: Kind::String,
614                },
615                Field {
616                    key: "description",
617                    required: false,
618                    kind: Kind::String,
619                },
620            ],
621            Node::Constant => &[
622                Field {
623                    key: "name",
624                    required: true,
625                    kind: Kind::String,
626                },
627                // value[x]: exactly one of 18 keys — handled specially in
628                // `check_constant_value`, not through the generic
629                // required-field loop (a choice type has no single "the
630                // key", so `required` here would be meaningless).
631                Field {
632                    key: "valueBase64Binary",
633                    required: false,
634                    kind: Kind::String,
635                },
636                Field {
637                    key: "valueBoolean",
638                    required: false,
639                    kind: Kind::Boolean,
640                },
641                Field {
642                    key: "valueCanonical",
643                    required: false,
644                    kind: Kind::String,
645                },
646                Field {
647                    key: "valueCode",
648                    required: false,
649                    kind: Kind::String,
650                },
651                Field {
652                    key: "valueDate",
653                    required: false,
654                    kind: Kind::String,
655                },
656                Field {
657                    key: "valueDateTime",
658                    required: false,
659                    kind: Kind::String,
660                },
661                Field {
662                    key: "valueDecimal",
663                    required: false,
664                    kind: Kind::Number,
665                },
666                Field {
667                    key: "valueId",
668                    required: false,
669                    kind: Kind::String,
670                },
671                Field {
672                    key: "valueInstant",
673                    required: false,
674                    kind: Kind::String,
675                },
676                Field {
677                    key: "valueInteger",
678                    required: false,
679                    kind: Kind::Number,
680                },
681                Field {
682                    key: "valueOid",
683                    required: false,
684                    kind: Kind::String,
685                },
686                Field {
687                    key: "valueString",
688                    required: false,
689                    kind: Kind::String,
690                },
691                Field {
692                    key: "valuePositiveInt",
693                    required: false,
694                    kind: Kind::Number,
695                },
696                Field {
697                    key: "valueTime",
698                    required: false,
699                    kind: Kind::String,
700                },
701                Field {
702                    key: "valueUnsignedInt",
703                    required: false,
704                    kind: Kind::Number,
705                },
706                Field {
707                    key: "valueUri",
708                    required: false,
709                    kind: Kind::String,
710                },
711                Field {
712                    key: "valueUrl",
713                    required: false,
714                    kind: Kind::String,
715                },
716                Field {
717                    key: "valueUuid",
718                    required: false,
719                    kind: Kind::String,
720                },
721            ],
722        }
723    }
724}
725
726// ---------------------------------------------------------------------------
727// Key-model introspection (#821): what a completion endpoint needs to know
728// ---------------------------------------------------------------------------
729
730/// The FHIRPath function catalog and the FHIRPath environment variables,
731/// re-exported for a completion endpoint's `function`/`variable` candidates.
732///
733/// `helios_fhirpath` is already this crate's own dependency (used above to
734/// parse every expression this module lints); re-exporting these four items
735/// here — rather than a caller like `helios-ui` taking a direct
736/// `helios-fhirpath` dependency of its own just to read a static catalog —
737/// keeps that catalog reachable through the one edge `helios-ui` already has
738/// to this crate, the same way [`node_keys`] exposes the key model instead of
739/// a caller duplicating it.
740pub use helios_fhirpath::{
741    FunctionCategory, FunctionInfo, builtin_functions, environment_variables,
742};
743
744/// The JSON shape [`node_keys`] reports for one key — [`Kind`] without the
745/// nested [`Node`] a caller outside this module has no use for (and no way
746/// to name, since `Node` itself is private).
747#[derive(Debug, Clone, Copy, PartialEq, Eq)]
748pub enum KeyKind {
749    String,
750    Number,
751    Boolean,
752    /// An array of strings (`repeat`).
753    StringArray,
754    /// A nested object, itself with its own keys.
755    Object,
756    /// An array of objects, each with its own keys.
757    ObjectArray,
758    /// Accepted, but not modeled further — see [`Kind::Any`].
759    Other,
760}
761
762impl From<Kind> for KeyKind {
763    fn from(kind: Kind) -> Self {
764        match kind {
765            Kind::String => KeyKind::String,
766            Kind::Number => KeyKind::Number,
767            Kind::Boolean => KeyKind::Boolean,
768            Kind::StringArray => KeyKind::StringArray,
769            Kind::Object(_) => KeyKind::Object,
770            Kind::ObjectArray(_) => KeyKind::ObjectArray,
771            Kind::Any => KeyKind::Other,
772        }
773    }
774}
775
776/// One key [`node_keys`] reports as valid at a node, in the order
777/// [`Node::fields`] declares it.
778#[derive(Debug, Clone, Copy, PartialEq, Eq)]
779pub struct KeyInfo {
780    pub key: &'static str,
781    pub required: bool,
782    pub kind: KeyKind,
783}
784
785/// The keys this module's key model allows at the node `pointer` identifies,
786/// in [`Node::fields`]'s own declaration order — the same model
787/// [`lint_view_definition`]'s `unknown-key`/`missing-required`/`wrong-type`
788/// checks are built on, exposed so a caller (a completion endpoint, in
789/// particular) can answer "what keys are valid here" without duplicating it.
790///
791/// The node is resolved from `pointer` **alone** — no document is walked:
792/// `""` is the document root; each `/`-separated segment that names an
793/// object key steps into that key's own nested node (an
794/// `Object`/`ObjectArray` field); a segment made entirely of ASCII digits is
795/// skipped instead, since it names an array index, not a key — so
796/// `/select/0/column/1` (one column object) and `/select/0/column` (the
797/// array containing it) resolve to the same node. Returns `None` once a
798/// segment names a key this module's model doesn't recognize, or one whose
799/// value is a scalar or [`Kind::Any`] — neither has keys of its own to
800/// report.
801pub fn node_keys(pointer: &str) -> Option<Vec<KeyInfo>> {
802    let mut node = Node::Root;
803    for raw_segment in pointer.split('/').skip(1) {
804        let segment = unescape_pointer_segment(raw_segment);
805        if !segment.is_empty() && segment.bytes().all(|b| b.is_ascii_digit()) {
806            continue;
807        }
808        let field = node.fields().iter().find(|f| f.key == segment.as_ref())?;
809        node = match field.kind {
810            Kind::Object(inner) | Kind::ObjectArray(inner) => inner,
811            Kind::String | Kind::Number | Kind::Boolean | Kind::StringArray | Kind::Any => {
812                return None;
813            }
814        };
815    }
816    Some(
817        node.fields()
818            .iter()
819            .map(|f| KeyInfo {
820                key: f.key,
821                required: f.required,
822                kind: f.kind.into(),
823            })
824            .collect(),
825    )
826}
827
828/// `%name` references treated as declared for
829/// [`DiagnosticCode::UndeclaredConstant`] beyond `constant[].name` and
830/// [`helios_fhirpath::environment_variables`] — SQL-on-FHIR's own
831/// environment variable(s), bound by `helios_sof` itself
832/// (`extract_view_definition_constants` in `crates/sof/src/lib.rs`) rather
833/// than resolved by `helios_fhirpath`'s evaluator, so `helios_fhirpath`
834/// has no way to know about them.
835///
836/// - `rowIndex`: the 0-based position of the current element during
837///   `forEach`/`forEachOrNull`/`repeat` iteration (0 outside one) — used by
838///   several of this crate's own vendored SQL-on-FHIR conformance fixtures
839///   (`tests/sql-on-fhir/tests/row_index.json`), which
840///   `official_sql_on_fhir_fixtures_that_are_not_error_cases_lint_clean`
841///   requires to keep linting clean of errors.
842const SQL_ON_FHIR_ENVIRONMENT_VARIABLES: &[&str] = &["rowIndex"];
843
844/// The 18 `value[x]` keys on [`Node::Constant`] — see `check_constant_value`.
845const CONSTANT_VALUE_KEYS: &[&str] = &[
846    "valueBase64Binary",
847    "valueBoolean",
848    "valueCanonical",
849    "valueCode",
850    "valueDate",
851    "valueDateTime",
852    "valueDecimal",
853    "valueId",
854    "valueInstant",
855    "valueInteger",
856    "valueOid",
857    "valueString",
858    "valuePositiveInt",
859    "valueTime",
860    "valueUnsignedInt",
861    "valueUri",
862    "valueUrl",
863    "valueUuid",
864];
865
866/// Every resource type name of every FHIR version compiled into this
867/// build, sorted and deduplicated — the same "any enabled version" union
868/// the REST layer admits for URL resource types (`crates/rest/src/fhir_types.rs`),
869/// computed independently here so `lint.rs` never depends on `helios_rest`
870/// (which itself depends on `helios_sof`, so that would be a cycle).
871///
872/// The comparison this backs is exact and case-sensitive — see
873/// [`DiagnosticCode::UnknownResourceType`] — never
874/// `helios_fhir::FhirResourceTypeProvider::is_resource_type`, which is
875/// case-insensitive.
876fn known_resource_types() -> &'static [&'static str] {
877    static CACHE: OnceLock<Vec<&'static str>> = OnceLock::new();
878    CACHE
879        .get_or_init(|| {
880            #[allow(unused_mut)]
881            let mut names: Vec<&'static str> = Vec::new();
882            #[cfg(feature = "R4")]
883            names.extend(
884                <helios_fhir::r4::Resource as helios_fhir::FhirResourceTypeProvider>::get_resource_type_names(
885                ),
886            );
887            #[cfg(feature = "R4B")]
888            names.extend(
889                <helios_fhir::r4b::Resource as helios_fhir::FhirResourceTypeProvider>::get_resource_type_names(
890                ),
891            );
892            #[cfg(feature = "R5")]
893            names.extend(
894                <helios_fhir::r5::Resource as helios_fhir::FhirResourceTypeProvider>::get_resource_type_names(
895                ),
896            );
897            #[cfg(feature = "R6")]
898            names.extend(
899                <helios_fhir::r6::Resource as helios_fhir::FhirResourceTypeProvider>::get_resource_type_names(
900                ),
901            );
902            names.sort_unstable();
903            names.dedup();
904            names
905        })
906        .as_slice()
907}
908
909// ---------------------------------------------------------------------------
910// Public entry point (RF1)
911// ---------------------------------------------------------------------------
912
913/// Lints `doc` as a ViewDefinition and returns every diagnostic found,
914/// ordered by `pointer` in document order (array elements sort by their
915/// numeric index, not lexicographically) and stable across runs.
916///
917/// Never panics — every branch below degrades to "not this shape, nothing
918/// more to check here" rather than indexing or unwrapping into a `Value`
919/// that turned out not to have the shape a rule expected.
920///
921/// See the module docs for what this checks and what it deliberately does
922/// not.
923pub fn lint_view_definition(doc: &Value) -> Vec<Diagnostic> {
924    let mut diagnostics = Vec::new();
925
926    let Some(root) = doc.as_object() else {
927        diagnostics.push(not_a_view_definition(
928            "a ViewDefinition document must be a JSON object".to_string(),
929            "a non-object document".to_string(),
930        ));
931        return diagnostics;
932    };
933
934    let resource_type = root.get("resourceType").and_then(Value::as_str);
935    if resource_type != Some("ViewDefinition") {
936        let found = match root.get("resourceType") {
937            None => "no `resourceType`".to_string(),
938            Some(Value::String(s)) => format!("resourceType {s:?}"),
939            Some(other) => format!("a non-string resourceType ({})", type_name(other)),
940        };
941        diagnostics.push(not_a_view_definition(
942            format!("expected resourceType \"ViewDefinition\", found {found}"),
943            found,
944        ));
945        return diagnostics;
946    }
947
948    validate_node(Node::Root, doc, "", &mut diagnostics);
949
950    if let Some(Value::String(resource)) = root.get("resource")
951        && !resource.trim().is_empty()
952        && known_resource_types()
953            .binary_search(&resource.as_str())
954            .is_err()
955    {
956        diagnostics.push(unknown_resource_type(resource));
957    }
958
959    if let Some(constants) = root.get("constant").and_then(Value::as_array) {
960        for (i, constant) in constants.iter().enumerate() {
961            check_constant_value(constant, &format!("/constant/{i}"), &mut diagnostics);
962        }
963    }
964
965    // Declared once for the whole document: every `constant[].name` whose
966    // value is a string — a `name` of the wrong JSON type is already
967    // reported by `validate_node`/`WrongType` and never makes something
968    // "declared" here. Borrows straight from `doc`, so it stays valid for
969    // every `check_expression` call below.
970    let declared_constants: HashSet<&str> = root
971        .get("constant")
972        .and_then(Value::as_array)
973        .into_iter()
974        .flatten()
975        .filter_map(|c| c.get("name").and_then(Value::as_str))
976        .collect();
977
978    if let Some(selects) = root.get("select").and_then(Value::as_array) {
979        // Every `column[].name` anywhere in the document, gathered up front
980        // (#821 validation): a `duplicate-column-name` fix must never
981        // suggest a name that collides with *any* column in the document,
982        // not only the ones `check_duplicate_columns`' row-scoped walk has
983        // already passed by the time it hits the duplicate — a later
984        // sibling column, or one in a different select entirely, is just as
985        // real a collision. `check_duplicate_columns` grows this same set
986        // with every name it suggests, so two duplicates in one pass never
987        // suggest each other's name either.
988        let mut used_names: HashSet<String> = HashSet::new();
989        for select in selects {
990            collect_column_names(select, &mut used_names);
991        }
992
993        let mut column_scope = Vec::new();
994        for (i, select) in selects.iter().enumerate() {
995            let pointer = format!("/select/{i}");
996            check_select_shape(select, &pointer, &mut diagnostics);
997            column_scope = check_duplicate_columns(
998                select,
999                &pointer,
1000                column_scope,
1001                &mut used_names,
1002                &mut diagnostics,
1003            );
1004            check_fhirpath_in_select(select, &pointer, &declared_constants, &mut diagnostics);
1005        }
1006    }
1007
1008    if let Some(wheres) = root.get("where").and_then(Value::as_array) {
1009        for (i, w) in wheres.iter().enumerate() {
1010            if let Some(path) = w.get("path").and_then(Value::as_str) {
1011                check_expression(
1012                    path,
1013                    &format!("/where/{i}/path"),
1014                    &declared_constants,
1015                    &mut diagnostics,
1016                );
1017            }
1018        }
1019    }
1020
1021    // RF1: ordered by true document position, not by comparing pointer
1022    // text (which would sort `/select/*` before `/where/*` purely because
1023    // "select" < "where" lexicographically, regardless of which one the
1024    // source document actually declares first). `sort_by_cached_key`
1025    // computes each key once and is a stable sort, so diagnostics that
1026    // land on the exact same position (e.g. two MissingRequired on the
1027    // same container) keep their original relative order — deterministic
1028    // since every pass above walks the document in a fixed, repeatable
1029    // order.
1030    diagnostics.sort_by_cached_key(|d| {
1031        (
1032            document_position(doc, &d.pointer),
1033            d.span.map(|span| span.start).unwrap_or(0),
1034        )
1035    });
1036    diagnostics
1037}
1038
1039// ---------------------------------------------------------------------------
1040// Pointer → FHIRPath expression (#821)
1041// ---------------------------------------------------------------------------
1042
1043/// Renders an RFC 6901 JSON pointer as a dotted FHIRPath-style expression
1044/// rooted at `ViewDefinition`, e.g. `/select/0/column/1/path` becomes
1045/// `ViewDefinition.select[0].column[1].path`, and the document root (`""`)
1046/// becomes plain `ViewDefinition`.
1047///
1048/// This is the shape `$sql-run`'s `422` response puts in
1049/// `OperationOutcome.issue.expression` (`crate::error::ServerError`) so a
1050/// client can jump straight to the offending node without knowing JSON
1051/// Pointer syntax — and it's `pub` so the `/ui/sql/view-definitions/lint`
1052/// handler can reuse it for the same purpose in the editor.
1053///
1054/// Each `/`-separated pointer segment either names an array index (all
1055/// ASCII digits — a ViewDefinition document never has an object key that is
1056/// itself numeric, so this can't misfire) and becomes a `[N]` suffix on the
1057/// segment before it, or names an object key and becomes a `.key` suffix,
1058/// after undoing RFC 6901's own escaping (`~1` → `/`, then `~0` → `~`, in
1059/// that order — reversing the encoding, which escapes `~` before `/`).
1060pub fn pointer_to_fhirpath(pointer: &str) -> String {
1061    let mut expression = String::from("ViewDefinition");
1062    if pointer.is_empty() {
1063        return expression;
1064    }
1065    for segment in pointer.split('/').skip(1) {
1066        let key = segment.replace("~1", "/").replace("~0", "~");
1067        if key.as_bytes().iter().all(u8::is_ascii_digit) && !key.is_empty() {
1068            expression.push('[');
1069            expression.push_str(&key);
1070            expression.push(']');
1071        } else {
1072            expression.push('.');
1073            expression.push_str(&key);
1074        }
1075    }
1076    expression
1077}
1078
1079// ---------------------------------------------------------------------------
1080// OperationOutcome shape (#821)
1081// ---------------------------------------------------------------------------
1082
1083/// Stable `coding.system` for the `helios_sof::lint` diagnostic each
1084/// `OperationOutcome.issue.details.coding[0]` carries in
1085/// [`lint_operation_outcome`]. `coding.code` is the diagnostic's own
1086/// [`DiagnosticCode`] in the wire form [`lint_view_definition`] already
1087/// serializes it in — see [`diagnostic_coding_code`].
1088pub const LINT_DIAGNOSTIC_CODING_SYSTEM: &str =
1089    "http://heliossoftware.com/fhir/CodeSystem/view-definition-lint";
1090
1091/// Builds a FHIR `OperationOutcome` from lint diagnostics: one `issue` per
1092/// **error**-severity diagnostic in `diagnostics` (any warning is silently
1093/// dropped — this renders the shape a caller uses to *reject* a request,
1094/// not to surface every diagnostic the lint found).
1095///
1096/// This is the single source of truth for turning `lint_view_definition`'s
1097/// output into an HTTP-facing `422` body, shared by every server that lints
1098/// an inline ViewDefinition before typed-parsing it: `sof-server`'s own
1099/// `$sql-run` handler (`crate::error::ServerError::InvalidViewDefinition`)
1100/// and HFS's `$sql-run` handler (`crates/rest/src/handlers/sof`).
1101///
1102/// # Example
1103///
1104/// ```
1105/// use helios_sof::lint::{lint_view_definition, lint_operation_outcome};
1106/// use serde_json::json;
1107///
1108/// let doc = json!({ "resourceType": "Patient" });
1109/// let outcome = lint_operation_outcome(&lint_view_definition(&doc));
1110/// assert_eq!(outcome["resourceType"], "OperationOutcome");
1111/// assert_eq!(outcome["issue"][0]["code"], "structure");
1112/// ```
1113pub fn lint_operation_outcome(diagnostics: &[Diagnostic]) -> Value {
1114    let issues: Vec<Value> = diagnostics
1115        .iter()
1116        .filter(|diagnostic| diagnostic.severity == Severity::Error)
1117        .map(diagnostic_issue)
1118        .collect();
1119    serde_json::json!({
1120        "resourceType": "OperationOutcome",
1121        "issue": issues,
1122    })
1123}
1124
1125/// One `OperationOutcome.issue` for a single lint [`Diagnostic`], regardless
1126/// of its own severity — callers that only want error-severity issues
1127/// filter before mapping (see [`lint_operation_outcome`]).
1128fn diagnostic_issue(diagnostic: &Diagnostic) -> Value {
1129    serde_json::json!({
1130        "severity": diagnostic.severity,
1131        "code": issue_code(diagnostic.code),
1132        "diagnostics": diagnostic.message,
1133        "details": {
1134            "text": diagnostic.message,
1135            "coding": [{
1136                "system": LINT_DIAGNOSTIC_CODING_SYSTEM,
1137                "code": diagnostic_coding_code(diagnostic.code),
1138            }],
1139        },
1140        "expression": [pointer_to_fhirpath(&diagnostic.pointer)],
1141    })
1142}
1143
1144/// FHIR `OperationOutcome.issue.code` for a lint diagnostic — the fixed
1145/// mapping: `structure` for shape/schema violations the document itself
1146/// gets wrong, `required` for a required key that's missing or empty,
1147/// `invalid` for everything else (semantic rules and the FHIRPath-syntax
1148/// check, neither of which is a schema violation).
1149///
1150/// Matched without a wildcard arm on purpose: adding a `DiagnosticCode`
1151/// variant to this module must fail this build until its issue code is
1152/// decided here too.
1153fn issue_code(code: DiagnosticCode) -> &'static str {
1154    match code {
1155        DiagnosticCode::NotAViewDefinition
1156        | DiagnosticCode::UnknownKey
1157        | DiagnosticCode::WrongType => "structure",
1158        DiagnosticCode::MissingRequired | DiagnosticCode::EmptyRequired => "required",
1159        DiagnosticCode::DuplicateColumnName
1160        | DiagnosticCode::MultipleIterationDirectives
1161        | DiagnosticCode::SelectWithoutOutput
1162        | DiagnosticCode::FhirPathSyntax
1163        | DiagnosticCode::UndeclaredConstant => "invalid",
1164        // `resource` is a FHIR `code` bound to the `resource-types` value
1165        // set; a value outside that binding is exactly what IssueType
1166        // `code-invalid` ("a code or system in the input value violates
1167        // applicable rules") means, distinct from the generic `invalid`
1168        // used for the other semantic checks above.
1169        DiagnosticCode::UnknownResourceType => "code-invalid",
1170    }
1171}
1172
1173/// The kebab-case wire string [`DiagnosticCode`] already serializes as
1174/// (`fhirpath-syntax` for the FHIRPath-parser check; every other variant is
1175/// its own name) — read back through `serde_json` rather than
1176/// hand-duplicating the mapping, so `details.coding[0].code` can never drift
1177/// from what `lint_view_definition`'s own JSON output uses for the same
1178/// diagnostic.
1179fn diagnostic_coding_code(code: DiagnosticCode) -> String {
1180    match serde_json::to_value(code) {
1181        Ok(Value::String(code)) => code,
1182        _ => unreachable!("DiagnosticCode serializes to a JSON string"),
1183    }
1184}
1185
1186fn type_name(value: &Value) -> &'static str {
1187    match value {
1188        Value::Null => "null",
1189        Value::Bool(_) => "a boolean",
1190        Value::Number(_) => "a number",
1191        Value::String(_) => "a string",
1192        Value::Array(_) => "an array",
1193        Value::Object(_) => "an object",
1194    }
1195}
1196
1197// ---------------------------------------------------------------------------
1198// NotAViewDefinition / UnknownKey / MissingRequired / WrongType / EmptyRequired
1199// ---------------------------------------------------------------------------
1200
1201/// The sole `not-a-view-definition` diagnostic [`lint_view_definition`]
1202/// returns when it fires — `found` names what the document actually was
1203/// (`"a non-object document"`, `` `resourceType "Patient"` ``, or a
1204/// non-string `resourceType`'s own JSON type), matching the wording already
1205/// folded into `message`.
1206fn not_a_view_definition(message: String, found: String) -> Diagnostic {
1207    let mut args = BTreeMap::new();
1208    args.insert("found".to_string(), found);
1209    Diagnostic {
1210        pointer: String::new(),
1211        message,
1212        severity: Severity::Error,
1213        code: DiagnosticCode::NotAViewDefinition,
1214        span: None,
1215        args,
1216        fixes: Vec::new(),
1217    }
1218}
1219
1220/// Checks every key on `value` (a node of kind `node`) against
1221/// [`Node::fields`], recursing into any `Object`/`ObjectArray` field whose
1222/// own value has the right JSON type. `value` is assumed to already be
1223/// known to be an object — call sites that hold an `Option<&Value>` check
1224/// that first (as part of their own `WrongType` handling), since "not an
1225/// object" is itself something the *caller* reports.
1226fn validate_node(node: Node, value: &Value, pointer: &str, out: &mut Vec<Diagnostic>) {
1227    let Some(obj) = value.as_object() else {
1228        return;
1229    };
1230    let fields = node.fields();
1231
1232    for (key, val) in obj {
1233        if let Some(base) = key.strip_prefix('_') {
1234            // FHIR's primitive-extension sibling (`"_status": {"id": ...,
1235            // "extension": [...]}`) is legal on any primitive field this
1236            // node has, regardless of whether the node itself carries its
1237            // own id/extension (see the `Node::fields` doc comment on why
1238            // that is not the same question).
1239            if fields
1240                .iter()
1241                .any(|f| f.key == base && f.kind.is_primitive())
1242            {
1243                continue;
1244            }
1245        }
1246        match fields.iter().find(|f| f.key == key.as_str()) {
1247            Some(f) => check_field_value(f, val, pointer, out),
1248            None => out.push(unknown_key(pointer, key, fields, obj)),
1249        }
1250    }
1251
1252    for f in fields.iter().filter(|f| f.required) {
1253        if !obj.contains_key(f.key) {
1254            out.push(missing_required(pointer, f.key));
1255        }
1256    }
1257}
1258
1259/// Type-checks one field's value and, for required fields, its emptiness;
1260/// recurses into `Object`/`ObjectArray`/`StringArray` element shapes when
1261/// the value's own type is correct. A `WrongType` at this node stops here —
1262/// nothing inside a value of the wrong shape is inspected.
1263fn check_field_value(f: &Field, val: &Value, parent_pointer: &str, out: &mut Vec<Diagnostic>) {
1264    let pointer = child_pointer(parent_pointer, f.key);
1265    let type_ok = match f.kind {
1266        Kind::Any => true,
1267        Kind::String => val.is_string(),
1268        Kind::Number => val.is_number(),
1269        Kind::Boolean => val.is_boolean(),
1270        Kind::StringArray | Kind::ObjectArray(_) => val.is_array(),
1271        Kind::Object(_) => val.is_object(),
1272    };
1273    if !type_ok {
1274        out.push(wrong_type(&pointer, f.kind, val));
1275        return;
1276    }
1277
1278    if f.required && is_empty(f.kind, val) {
1279        out.push(empty_required(&pointer, f.key));
1280        return;
1281    }
1282
1283    match f.kind {
1284        Kind::Any | Kind::String | Kind::Number | Kind::Boolean => {}
1285        Kind::StringArray => {
1286            for (i, item) in val.as_array().into_iter().flatten().enumerate() {
1287                if !item.is_string() {
1288                    out.push(wrong_type(&format!("{pointer}/{i}"), Kind::String, item));
1289                }
1290            }
1291        }
1292        Kind::Object(node) => validate_node(node, val, &pointer, out),
1293        Kind::ObjectArray(node) => {
1294            for (i, item) in val.as_array().into_iter().flatten().enumerate() {
1295                let item_pointer = format!("{pointer}/{i}");
1296                if item.is_object() {
1297                    validate_node(node, item, &item_pointer, out);
1298                } else {
1299                    out.push(wrong_type(&item_pointer, Kind::Object(node), item));
1300                }
1301            }
1302        }
1303    }
1304}
1305
1306fn is_empty(kind: Kind, val: &Value) -> bool {
1307    match kind {
1308        Kind::String => val.as_str().is_some_and(|s| s.trim().is_empty()),
1309        Kind::StringArray | Kind::ObjectArray(_) => val.as_array().is_some_and(|a| a.is_empty()),
1310        Kind::Any | Kind::Number | Kind::Boolean | Kind::Object(_) => false,
1311    }
1312}
1313
1314/// The `value[x]` choice on a `constant`: exactly one of the 18
1315/// `CONSTANT_VALUE_KEYS` must be present. `validate_node` already checked
1316/// each key's own JSON type (and flagged `UnknownKey` for anything else);
1317/// this only checks *how many* of the 18 are present, since that is a
1318/// cross-key rule the generic per-field loop cannot express.
1319fn check_constant_value(constant: &Value, pointer: &str, out: &mut Vec<Diagnostic>) {
1320    let Some(obj) = constant.as_object() else {
1321        return;
1322    };
1323    let name = obj.get("name").and_then(Value::as_str);
1324    let present: Vec<&str> = CONSTANT_VALUE_KEYS
1325        .iter()
1326        .filter(|k| obj.contains_key(**k))
1327        .copied()
1328        .collect();
1329    match present.len() {
1330        0 => out.push(constant_value_diagnostic(
1331            pointer,
1332            DiagnosticCode::MissingRequired,
1333            "missing required key `value[x]`".to_string(),
1334            "missing",
1335            name,
1336        )),
1337        1 => {}
1338        _ => out.push(constant_value_diagnostic(
1339            pointer,
1340            DiagnosticCode::WrongType,
1341            format!(
1342                "a constant may set only one value[x] key, found {}: {}",
1343                present.len(),
1344                present.join(", ")
1345            ),
1346            "multiple",
1347            name,
1348        )),
1349    }
1350}
1351
1352/// The diagnostic [`check_constant_value`] reports for a `constant`'s
1353/// `value[x]` choice, whichever of the two ways it went wrong: no `value[x]`
1354/// key present at all (`code: MissingRequired`, `variant: "missing"`) or
1355/// more than one present (`code: WrongType`, `variant: "multiple"`) —
1356/// carrying `args.variant` so a translated message can select the right
1357/// wording for either, and `args.name` when the constant itself names one,
1358/// so that wording can name it too. This is a different `args` shape than
1359/// the generic `missing-required`/`wrong-type` diagnostics
1360/// `missing_required`/`wrong_type` below build (`key` / `expected`+`found`)
1361/// — the value[x] choice is a cross-key rule, not "this one key has the
1362/// wrong shape", so `key`/`expected`/`found` would not describe it
1363/// accurately.
1364fn constant_value_diagnostic(
1365    pointer: &str,
1366    code: DiagnosticCode,
1367    message: String,
1368    variant: &'static str,
1369    name: Option<&str>,
1370) -> Diagnostic {
1371    let mut args = BTreeMap::new();
1372    args.insert("variant".to_string(), variant.to_string());
1373    if let Some(name) = name {
1374        args.insert("name".to_string(), name.to_string());
1375    }
1376    Diagnostic {
1377        pointer: pointer.to_string(),
1378        message,
1379        severity: Severity::Error,
1380        code,
1381        span: None,
1382        args,
1383        fixes: Vec::new(),
1384    }
1385}
1386
1387/// Damerau-Levenshtein edit distance (optimal string alignment — a
1388/// transposition counts as one edit, but a substring is never transposed
1389/// more than once) between `a` and `b`, over `char`s. Used only for
1390/// `unknown-key` typo suggestions ([`suggest_key`]); no crate dependency
1391/// carries this, so it is implemented directly rather than adding one for a
1392/// handful of short-string comparisons.
1393fn damerau_levenshtein_distance(a: &str, b: &str) -> usize {
1394    let a: Vec<char> = a.chars().collect();
1395    let b: Vec<char> = b.chars().collect();
1396    let (rows, cols) = (a.len() + 1, b.len() + 1);
1397    let mut d = vec![vec![0usize; cols]; rows];
1398    for (i, row) in d.iter_mut().enumerate() {
1399        row[0] = i;
1400    }
1401    for (j, cell) in d[0].iter_mut().enumerate() {
1402        *cell = j;
1403    }
1404    for i in 1..rows {
1405        for j in 1..cols {
1406            let cost = usize::from(a[i - 1] != b[j - 1]);
1407            d[i][j] = (d[i - 1][j] + 1)
1408                .min(d[i][j - 1] + 1)
1409                .min(d[i - 1][j - 1] + cost);
1410            if i > 1 && j > 1 && a[i - 1] == b[j - 2] && a[i - 2] == b[j - 1] {
1411                d[i][j] = d[i][j].min(d[i - 2][j - 2] + 1);
1412            }
1413        }
1414    }
1415    d[rows - 1][cols - 1]
1416}
1417
1418/// Whether lowercase `a` and `b` are identical except that one has exactly
1419/// one trailing `s` the other doesn't (`column`/`columns`) — a suggestion
1420/// rule of its own because a short enough pair (e.g. a 2-character key) can
1421/// exceed the edit-distance threshold [`suggest_key`] otherwise applies
1422/// while still being an obvious singular/plural typo.
1423fn is_singular_plural_variant(a: &str, b: &str) -> bool {
1424    a.strip_suffix('s') == Some(b) || b.strip_suffix('s') == Some(a)
1425}
1426
1427/// The best `unknown-key` typo suggestion for `key` among `fields`, or
1428/// `None` if nothing qualifies: a Damerau-Levenshtein distance of at most 2
1429/// (case-insensitive) or a singular/plural match
1430/// ([`is_singular_plural_variant`]), ties broken by the lower distance and
1431/// then by the field's position in `Node::fields` (declaration order). A key
1432/// already present on `existing` is never suggested — renaming to it would
1433/// just create a second problem.
1434fn suggest_key<'a>(
1435    key: &str,
1436    fields: &'a [Field],
1437    existing: &serde_json::Map<String, Value>,
1438) -> Option<&'a str> {
1439    let key_lower = key.to_ascii_lowercase();
1440    fields
1441        .iter()
1442        .enumerate()
1443        .filter(|(_, f)| !existing.contains_key(f.key))
1444        .filter_map(|(index, f)| {
1445            let candidate_lower = f.key.to_ascii_lowercase();
1446            let distance = damerau_levenshtein_distance(&key_lower, &candidate_lower);
1447            let qualifies =
1448                distance <= 2 || is_singular_plural_variant(&key_lower, &candidate_lower);
1449            qualifies.then_some((distance, index, f.key))
1450        })
1451        .min_by_key(|&(distance, index, _)| (distance, index))
1452        .map(|(_, _, key)| key)
1453}
1454
1455/// `key` is a key `fields` doesn't model, found on the object at `pointer`
1456/// (`present`, so a suggestion never proposes a key already there). Offers a
1457/// [`Fix::RenameKey`] to the best typo suggestion ([`suggest_key`]), if any,
1458/// followed always by a [`Fix::RemoveKey`] — dropping the unrecognized key
1459/// is always a valid way to resolve this, suggestion or not.
1460fn unknown_key(
1461    pointer: &str,
1462    key: &str,
1463    fields: &[Field],
1464    present: &serde_json::Map<String, Value>,
1465) -> Diagnostic {
1466    let key_pointer = child_pointer(pointer, key);
1467    let mut args = BTreeMap::new();
1468    args.insert("key".to_string(), key.to_string());
1469    let mut fixes = Vec::new();
1470    if let Some(suggestion) = suggest_key(key, fields, present) {
1471        args.insert("suggestion".to_string(), suggestion.to_string());
1472        fixes.push(Fix::RenameKey {
1473            pointer: key_pointer.clone(),
1474            to: suggestion.to_string(),
1475        });
1476    }
1477    fixes.push(Fix::RemoveKey {
1478        pointer: key_pointer.clone(),
1479    });
1480    Diagnostic {
1481        pointer: key_pointer,
1482        message: format!("unknown key `{key}`"),
1483        severity: Severity::Error,
1484        code: DiagnosticCode::UnknownKey,
1485        span: None,
1486        args,
1487        fixes,
1488    }
1489}
1490
1491fn missing_required(pointer: &str, key: &str) -> Diagnostic {
1492    let mut args = BTreeMap::new();
1493    args.insert("key".to_string(), key.to_string());
1494    Diagnostic {
1495        pointer: pointer.to_string(),
1496        message: format!("missing required key `{key}`"),
1497        severity: Severity::Error,
1498        code: DiagnosticCode::MissingRequired,
1499        span: None,
1500        args,
1501        fixes: Vec::new(),
1502    }
1503}
1504
1505fn empty_required(pointer: &str, key: &str) -> Diagnostic {
1506    let mut args = BTreeMap::new();
1507    args.insert("key".to_string(), key.to_string());
1508    Diagnostic {
1509        pointer: pointer.to_string(),
1510        message: "required value must not be empty".to_string(),
1511        severity: Severity::Error,
1512        code: DiagnosticCode::EmptyRequired,
1513        span: None,
1514        args,
1515        fixes: Vec::new(),
1516    }
1517}
1518
1519/// Builds the [`DiagnosticCode::UnknownResourceType`] diagnostic for a
1520/// `resource` value that does not name a resource type of any FHIR version
1521/// compiled into this build.
1522fn unknown_resource_type(found: &str) -> Diagnostic {
1523    let mut args = BTreeMap::new();
1524    args.insert("found".to_string(), found.to_string());
1525    Diagnostic {
1526        pointer: "/resource".to_string(),
1527        message: format!("unknown resource type {found:?}"),
1528        severity: Severity::Error,
1529        code: DiagnosticCode::UnknownResourceType,
1530        span: None,
1531        args,
1532        fixes: Vec::new(),
1533    }
1534}
1535
1536fn wrong_type(pointer: &str, expected: Kind, found: &Value) -> Diagnostic {
1537    let expected = match expected {
1538        Kind::String => "a string",
1539        Kind::Number => "a number",
1540        Kind::Boolean => "a boolean",
1541        Kind::StringArray | Kind::ObjectArray(_) => "an array",
1542        Kind::Object(_) => "an object",
1543        Kind::Any => "any value",
1544    };
1545    let found = type_name(found);
1546    let mut args = BTreeMap::new();
1547    args.insert("expected".to_string(), expected.to_string());
1548    args.insert("found".to_string(), found.to_string());
1549    Diagnostic {
1550        pointer: pointer.to_string(),
1551        message: format!("expected {expected}, found {found}"),
1552        severity: Severity::Error,
1553        code: DiagnosticCode::WrongType,
1554        span: None,
1555        args,
1556        fixes: Vec::new(),
1557    }
1558}
1559
1560// ---------------------------------------------------------------------------
1561// SelectWithoutOutput / MultipleIterationDirectives
1562// ---------------------------------------------------------------------------
1563
1564/// A `select` "has" `column`/`select`/`unionAll` for the purposes of
1565/// [`DiagnosticCode::SelectWithoutOutput`] when the key is present *and* a
1566/// non-empty array — an empty array produces exactly as little output as an
1567/// absent key.
1568fn has_output_content(obj: &serde_json::Map<String, Value>, key: &str) -> bool {
1569    obj.get(key)
1570        .and_then(Value::as_array)
1571        .is_some_and(|a| !a.is_empty())
1572}
1573
1574fn check_select_shape(select: &Value, pointer: &str, out: &mut Vec<Diagnostic>) {
1575    let Some(obj) = select.as_object() else {
1576        return;
1577    };
1578
1579    if !has_output_content(obj, "column")
1580        && !has_output_content(obj, "select")
1581        && !has_output_content(obj, "unionAll")
1582    {
1583        out.push(Diagnostic {
1584            pointer: pointer.to_string(),
1585            message: "a select must have at least one of column, select, or unionAll".to_string(),
1586            severity: Severity::Error,
1587            code: DiagnosticCode::SelectWithoutOutput,
1588            span: None,
1589            args: BTreeMap::new(),
1590            fixes: Vec::new(),
1591        });
1592    }
1593
1594    // Presence-based, like the sql-expressions invariant this mirrors
1595    // (`validate_select_with_context` above): an empty `repeat: []` still
1596    // counts as "set", matching `forEach.exists()` in the FHIRPath
1597    // invariant, which is true for an empty collection too.
1598    const ITERATION_DIRECTIVES: [&str; 3] = ["forEach", "forEachOrNull", "repeat"];
1599    let directive_count = ITERATION_DIRECTIVES
1600        .iter()
1601        .filter(|k| obj.contains_key(**k))
1602        .count();
1603    if directive_count > 1 {
1604        out.push(multiple_iteration_directives(
1605            pointer,
1606            obj,
1607            &ITERATION_DIRECTIVES,
1608        ));
1609    }
1610
1611    if let Some(nested) = obj.get("select").and_then(Value::as_array) {
1612        for (i, child) in nested.iter().enumerate() {
1613            check_select_shape(child, &format!("{pointer}/select/{i}"), out);
1614        }
1615    }
1616    if let Some(branches) = obj.get("unionAll").and_then(Value::as_array) {
1617        for (i, branch) in branches.iter().enumerate() {
1618            check_select_shape(branch, &format!("{pointer}/unionAll/{i}"), out);
1619        }
1620    }
1621}
1622
1623/// The `multiple-iteration-directives` diagnostic for the `select` at
1624/// `pointer`: `args.keys` lists the directives `obj` actually sets, in
1625/// `directives`' order (`forEach`, `forEachOrNull`, `repeat`), joined with
1626/// `, `. `fixes` offers removing every one of them after the first, in that
1627/// same order, so applying all of a select's fixes in sequence leaves
1628/// exactly one directive behind — whichever the document declared first.
1629fn multiple_iteration_directives(
1630    pointer: &str,
1631    obj: &serde_json::Map<String, Value>,
1632    directives: &[&str],
1633) -> Diagnostic {
1634    let present: Vec<&str> = directives
1635        .iter()
1636        .copied()
1637        .filter(|key| obj.contains_key(*key))
1638        .collect();
1639    let mut args = BTreeMap::new();
1640    args.insert("keys".to_string(), present.join(", "));
1641    let fixes = present
1642        .iter()
1643        .skip(1)
1644        .map(|key| Fix::RemoveKey {
1645            pointer: child_pointer(pointer, key),
1646        })
1647        .collect();
1648    Diagnostic {
1649        pointer: pointer.to_string(),
1650        message: "a select may set at most one of forEach, forEachOrNull, repeat".to_string(),
1651        severity: Severity::Error,
1652        code: DiagnosticCode::MultipleIterationDirectives,
1653        span: None,
1654        args,
1655        fixes,
1656    }
1657}
1658
1659// ---------------------------------------------------------------------------
1660// DuplicateColumnName
1661// ---------------------------------------------------------------------------
1662
1663/// Every `column[].name` reachable from `select` — through nested `select[]`
1664/// and `unionAll[]` alike, regardless of row scoping — added to `out`.
1665/// [`lint_view_definition`] walks the whole document's `select[]` array with
1666/// this before checking anything, to seed [`check_duplicate_columns`]'
1667/// `used_names` with every column name that exists anywhere, not only the
1668/// ones a row-scoped walk happens to have already passed.
1669fn collect_column_names(select: &Value, out: &mut HashSet<String>) {
1670    let Some(obj) = select.as_object() else {
1671        return;
1672    };
1673    if let Some(columns) = obj.get("column").and_then(Value::as_array) {
1674        for column in columns {
1675            if let Some(name) = column.get("name").and_then(Value::as_str) {
1676                out.insert(name.to_string());
1677            }
1678        }
1679    }
1680    if let Some(nested) = obj.get("select").and_then(Value::as_array) {
1681        for child in nested {
1682            collect_column_names(child, out);
1683        }
1684    }
1685    if let Some(branches) = obj.get("unionAll").and_then(Value::as_array) {
1686        for branch in branches {
1687            collect_column_names(branch, out);
1688        }
1689    }
1690}
1691
1692/// The `duplicate-column-name` diagnostic for one repeated column `name` at
1693/// `name_pointer`. The one fix offered renames the duplicate to `name_2` —
1694/// or `name_3`, `name_4`, ... — whichever suffix is the first not already in
1695/// `used_names`: every column name in the *whole document* (seeded by
1696/// [`collect_column_names`], not just the ones a row-scoped walk has already
1697/// passed — a later sibling column, or one in an entirely different select,
1698/// is just as real a collision), plus every name a fix has already
1699/// suggested this pass. The chosen suffix is inserted back into
1700/// `used_names` before returning, so the next duplicate in the same pass
1701/// never suggests it either.
1702fn duplicate_column_name(
1703    name_pointer: &str,
1704    name: &str,
1705    used_names: &mut HashSet<String>,
1706) -> Diagnostic {
1707    let mut suffix = 2;
1708    let mut candidate = format!("{name}_{suffix}");
1709    while used_names.contains(&candidate) {
1710        suffix += 1;
1711        candidate = format!("{name}_{suffix}");
1712    }
1713    used_names.insert(candidate.clone());
1714    let mut args = BTreeMap::new();
1715    args.insert("name".to_string(), name.to_string());
1716    Diagnostic {
1717        pointer: name_pointer.to_string(),
1718        message: format!("duplicate column name `{name}`"),
1719        severity: Severity::Error,
1720        code: DiagnosticCode::DuplicateColumnName,
1721        span: None,
1722        args,
1723        fixes: vec![Fix::SetString {
1724            pointer: name_pointer.to_string(),
1725            value: candidate,
1726        }],
1727    }
1728}
1729
1730/// Walks one `select` (and everything reachable through nested `select[]`)
1731/// checking for column names already seen in `scope` — every
1732/// `(name, pointer)` from the *same output row*, threaded in by the caller.
1733/// `used_names` is a separate, document-wide set (see
1734/// [`collect_column_names`]) threaded through purely so
1735/// [`duplicate_column_name`] can pick a fix value that collides with
1736/// nothing in the document — it plays no part in *detecting* a duplicate,
1737/// only in naming its fix.
1738///
1739/// Returns `scope` extended with every name this select and its non-
1740/// `unionAll` descendants added, so a caller iterating sibling selects (the
1741/// document root's own `select[]` array, or a `select`'s own nested
1742/// `select[]`) can thread duplicate detection across all of them in
1743/// document order. `unionAll` branches are checked as their own scope
1744/// (seeded with the caller's `scope`, since they share the containing row)
1745/// but their columns are never threaded back out — each branch is its own
1746/// set, and branches never see each other's names.
1747fn check_duplicate_columns(
1748    select: &Value,
1749    pointer: &str,
1750    mut scope: Vec<(String, String)>,
1751    used_names: &mut HashSet<String>,
1752    out: &mut Vec<Diagnostic>,
1753) -> Vec<(String, String)> {
1754    let Some(obj) = select.as_object() else {
1755        return scope;
1756    };
1757
1758    if let Some(columns) = obj.get("column").and_then(Value::as_array) {
1759        for (i, column) in columns.iter().enumerate() {
1760            let Some(name) = column.get("name").and_then(Value::as_str) else {
1761                continue;
1762            };
1763            let name_pointer = format!("{pointer}/column/{i}/name");
1764            if scope.iter().any(|(seen, _)| seen == name) {
1765                out.push(duplicate_column_name(&name_pointer, name, used_names));
1766            }
1767            scope.push((name.to_string(), name_pointer));
1768        }
1769    }
1770
1771    if let Some(nested) = obj.get("select").and_then(Value::as_array) {
1772        for (i, child) in nested.iter().enumerate() {
1773            scope = check_duplicate_columns(
1774                child,
1775                &format!("{pointer}/select/{i}"),
1776                scope,
1777                used_names,
1778                out,
1779            );
1780        }
1781    }
1782
1783    if let Some(branches) = obj.get("unionAll").and_then(Value::as_array) {
1784        for (i, branch) in branches.iter().enumerate() {
1785            check_duplicate_columns(
1786                branch,
1787                &format!("{pointer}/unionAll/{i}"),
1788                scope.clone(),
1789                used_names,
1790                out,
1791            );
1792        }
1793    }
1794
1795    scope
1796}
1797
1798// ---------------------------------------------------------------------------
1799// FhirPathSyntax and UndeclaredConstant
1800// ---------------------------------------------------------------------------
1801
1802fn check_fhirpath_in_select(
1803    select: &Value,
1804    pointer: &str,
1805    declared_constants: &HashSet<&str>,
1806    out: &mut Vec<Diagnostic>,
1807) {
1808    let Some(obj) = select.as_object() else {
1809        return;
1810    };
1811
1812    if let Some(columns) = obj.get("column").and_then(Value::as_array) {
1813        for (i, column) in columns.iter().enumerate() {
1814            if let Some(path) = column.get("path").and_then(Value::as_str) {
1815                check_expression(
1816                    path,
1817                    &format!("{pointer}/column/{i}/path"),
1818                    declared_constants,
1819                    out,
1820                );
1821            }
1822        }
1823    }
1824    for key in ["forEach", "forEachOrNull"] {
1825        if let Some(expr) = obj.get(key).and_then(Value::as_str) {
1826            check_expression(expr, &format!("{pointer}/{key}"), declared_constants, out);
1827        }
1828    }
1829    if let Some(items) = obj.get("repeat").and_then(Value::as_array) {
1830        for (i, item) in items.iter().enumerate() {
1831            if let Some(expr) = item.as_str() {
1832                check_expression(
1833                    expr,
1834                    &format!("{pointer}/repeat/{i}"),
1835                    declared_constants,
1836                    out,
1837                );
1838            }
1839        }
1840    }
1841    if let Some(nested) = obj.get("select").and_then(Value::as_array) {
1842        for (i, child) in nested.iter().enumerate() {
1843            check_fhirpath_in_select(
1844                child,
1845                &format!("{pointer}/select/{i}"),
1846                declared_constants,
1847                out,
1848            );
1849        }
1850    }
1851    if let Some(branches) = obj.get("unionAll").and_then(Value::as_array) {
1852        for (i, branch) in branches.iter().enumerate() {
1853            check_fhirpath_in_select(
1854                branch,
1855                &format!("{pointer}/unionAll/{i}"),
1856                declared_constants,
1857                out,
1858            );
1859        }
1860    }
1861}
1862
1863/// Parses one FHIRPath expression string, and:
1864/// - on a syntax error, pushes a single [`DiagnosticCode::FhirPathSyntax`]
1865///   diagnostic — the first error `helios_fhirpath::parse_expression_spanned`
1866///   reports, which is the one closest to where the parser actually gave up;
1867/// - on success, pushes one [`DiagnosticCode::UndeclaredConstant`] per
1868///   `%name` reference the expression contains that isn't in
1869///   `declared_constants` or a FHIRPath environment variable. A non-parsing
1870///   expression is never checked for undeclared constants — `FhirPathSyntax`
1871///   alone already reports it.
1872fn check_expression(
1873    expression: &str,
1874    pointer: &str,
1875    declared_constants: &HashSet<&str>,
1876    out: &mut Vec<Diagnostic>,
1877) {
1878    if expression.trim().is_empty() {
1879        let message = "empty expression".to_string();
1880        let mut args = BTreeMap::new();
1881        args.insert("detail".to_string(), message.clone());
1882        out.push(Diagnostic {
1883            pointer: pointer.to_string(),
1884            message,
1885            severity: Severity::Error,
1886            code: DiagnosticCode::FhirPathSyntax,
1887            span: Some(Span { start: 0, end: 0 }),
1888            args,
1889            fixes: Vec::new(),
1890        });
1891        return;
1892    }
1893    match helios_fhirpath::parse_expression_spanned(expression) {
1894        Err(errors) => {
1895            if let Some(first) = errors.into_iter().next() {
1896                let mut args = BTreeMap::new();
1897                args.insert("detail".to_string(), first.message.clone());
1898                out.push(Diagnostic {
1899                    pointer: pointer.to_string(),
1900                    message: first.message,
1901                    severity: Severity::Error,
1902                    code: DiagnosticCode::FhirPathSyntax,
1903                    span: Some(Span {
1904                        start: first.span.0,
1905                        end: first.span.1,
1906                    }),
1907                    args,
1908                    fixes: Vec::new(),
1909                });
1910            }
1911        }
1912        Ok(parsed) => {
1913            for constant_ref in helios_fhirpath::external_constants(&parsed, expression) {
1914                if declared_constants.contains(constant_ref.name.as_str())
1915                    || helios_fhirpath::is_environment_variable(&constant_ref.name)
1916                    || SQL_ON_FHIR_ENVIRONMENT_VARIABLES.contains(&constant_ref.name.as_str())
1917                {
1918                    continue;
1919                }
1920                let (start, end) =
1921                    helios_fhirpath::expr_span_to_char_offsets(expression, &constant_ref.span);
1922                let mut args = BTreeMap::new();
1923                args.insert("name".to_string(), constant_ref.name.clone());
1924                out.push(Diagnostic {
1925                    pointer: pointer.to_string(),
1926                    message: format!("undeclared constant `%{}`", constant_ref.name),
1927                    severity: Severity::Error,
1928                    code: DiagnosticCode::UndeclaredConstant,
1929                    span: Some(Span { start, end }),
1930                    args,
1931                    fixes: Vec::new(),
1932                });
1933            }
1934        }
1935    }
1936}
1937
1938// ---------------------------------------------------------------------------
1939// JSON pointers (RFC 6901) and diagnostic ordering (RF1)
1940// ---------------------------------------------------------------------------
1941
1942/// Appends `key`, RFC 6901-escaped, to `pointer`.
1943fn child_pointer(pointer: &str, key: &str) -> String {
1944    if key.contains('~') || key.contains('/') {
1945        // `~` must be escaped before `/` — escaping `/` first would turn the
1946        // `~1` it produces right back into something the `~`-escape step
1947        // would mangle a second time.
1948        format!("{pointer}/{}", key.replace('~', "~0").replace('/', "~1"))
1949    } else {
1950        format!("{pointer}/{key}")
1951    }
1952}
1953
1954/// Reverses [`child_pointer`]'s RFC 6901 escaping for one segment. `~1` must
1955/// be restored to `/` before `~0` is restored to `~` — the reverse of the
1956/// escaping order — or a literal `~` immediately followed by a literal `/`
1957/// would round-trip incorrectly.
1958fn unescape_pointer_segment(segment: &str) -> std::borrow::Cow<'_, str> {
1959    if segment.contains('~') {
1960        std::borrow::Cow::Owned(segment.replace("~1", "/").replace("~0", "~"))
1961    } else {
1962        std::borrow::Cow::Borrowed(segment)
1963    }
1964}
1965
1966/// Computes a comparable "document position" for `pointer`, without this
1967/// module ever tracking byte offsets itself: at each object level, a
1968/// segment's ordinal is its index among ALL of that object's own keys (not
1969/// just the ones this module's model recognizes) in the order they were
1970/// declared in the source document — guaranteed to be the true declaration
1971/// order by `serde_json`'s `preserve_order` feature, enabled on this
1972/// crate's own `serde_json` dependency (`Cargo.toml`) specifically for
1973/// this; at each array level, a segment's ordinal is simply its own numeric
1974/// index.
1975///
1976/// Comparing two such paths lexicographically (`Vec<usize>`'s derived
1977/// `Ord`) recovers true document order: a shared prefix means "the same
1978/// container", and — because a shorter sequence that is a prefix of a
1979/// longer one sorts first — a diagnostic on a container itself (e.g.
1980/// `MissingRequired`, whose pointer is the container, not one of its keys)
1981/// always sorts before anything reported inside one of that container's own
1982/// children, exactly matching where the container's own opening `{`/`[`
1983/// sits relative to its contents in the source text.
1984///
1985/// A pointer built from data that is not actually reachable this way (not
1986/// expected for anything this module itself constructs, but this must
1987/// never panic regardless) stops at the first segment that does not
1988/// resolve, returning whatever prefix of the position was found — still a
1989/// valid, monotonic position, just less precise.
1990fn document_position(root: &Value, pointer: &str) -> Vec<usize> {
1991    let mut position = Vec::new();
1992    let mut node = root;
1993    for raw_segment in pointer.split('/').skip(1) {
1994        let segment = unescape_pointer_segment(raw_segment);
1995        match node {
1996            Value::Object(map) => match map.keys().position(|k| k == segment.as_ref()) {
1997                Some(index) => {
1998                    position.push(index);
1999                    node = map.get(segment.as_ref()).expect("just located by key");
2000                }
2001                None => break,
2002            },
2003            Value::Array(items) => match segment.parse::<usize>() {
2004                Ok(index) if index < items.len() => {
2005                    position.push(index);
2006                    node = &items[index];
2007                }
2008                _ => break,
2009            },
2010            _ => break,
2011        }
2012    }
2013    position
2014}
2015
2016#[cfg(test)]
2017mod tests;