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;