Skip to main content

helios_fhirpath/
lib.rs

1//! # FHIRPath Expression Engine
2//!
3//! This crate provides a complete implementation of the [FHIRPath 3.0.0 specification](https://hl7.org/fhirpath/2025Jan/)
4//! for evaluating FHIRPath expressions against FHIR resources. FHIRPath is a path-based navigation
5//! and extraction language designed specifically for FHIR resources, enabling powerful queries
6//! and data manipulation operations.
7
8//!
9//! ## Overview
10//!
11//! FHIRPath is a declarative language that allows you to:
12//! - **Navigate FHIR resources** using path expressions (e.g., `Patient.name.family`)
13//! - **Filter collections** with boolean predicates (e.g., `telecom.where(system = 'email')`)
14//! - **Transform data** using built-in functions (e.g., `name.given.first()`)
15//! - **Perform calculations** with mathematical operations (e.g., `birthDate.today() - birthDate`)
16//! - **Access extensions** in FHIR resources (e.g., `Patient.extension('http://example.org/birthPlace')`)
17//! - **Work with types** using type checking and conversion (e.g., `value.is(Quantity)`)
18//!
19//! ## Key Features
20//!
21//! ### Core Functionality
22//! - **Parser**: Complete FHIRPath syntax support including literals, operators, and function calls
23//! - **Evaluator**: Fast evaluation engine with proper type handling and error reporting
24//! - **Type System**: Support for both FHIR and System namespaces with automatic type inference
25//! - **Extension Support**: Native handling of FHIR extensions and choice elements
26//!
27//! ### Language Support
28//! - **Collections**: Comprehensive collection operations (where, select, all, exists, etc.)
29//! - **Mathematics**: Arithmetic operations with proper decimal precision handling
30//! - **String Operations**: Text manipulation and pattern matching functions
31//! - **Date/Time**: Temporal operations with timezone and precision support
32//! - **Type Operations**: Dynamic type checking with `is`, `as`, and `ofType` operators
33//! - **Variables**: Support for external variables and built-in constants
34//!
35//! ### FHIR Integration
36//! - **Multi-version Support**: Works with FHIR R4, R4B, R5, and R6 via feature flags
37//! - **Resource Navigation**: Smart navigation of FHIR choice elements (e.g., `value[x]`)
38//! - **Extension Access**: Built-in `extension()` function for FHIR extension handling
39//! - **Type Hierarchy**: Understanding of FHIR resource and data type relationships
40//!
41//! ## Architecture
42//!
43//! The crate is organized into several key components:
44//!
45//! - **Public API** (`lib.rs`): Simple interface with [`evaluate_expression`] function
46//! - **Parser** (`parser.rs`): Converts FHIRPath text into an Abstract Syntax Tree (AST)
47//! - **Evaluator** (`evaluator.rs`): Executes the AST against FHIR resources  
48//! - **Function Modules**: Specialized implementations for FHIRPath functions
49//! - **Type System**: FHIR type hierarchy and namespace management
50//! - **Support Types**: Integration with the `fhirpath_support` crate for results
51//!
52//! ## Usage Examples
53//!
54//! ### Basic Navigation
55//!
56//! ```rust,no_run
57//! use helios_fhirpath::{evaluate_expression, EvaluationContext};
58//! # use helios_fhir::r4::{Patient, HumanName};
59//!
60//! # // Create a patient resource
61//! # let patient = Patient::default();
62//! # let context = EvaluationContext::new(vec![
63//! #     helios_fhir::FhirResource::R4(Box::new(helios_fhir::r4::Resource::Patient(Box::new(patient))))
64//! # ]);
65//!
66//! // Navigate to family name
67//! let result = evaluate_expression("Patient.name.family", &context)?;
68//! // Result: Collection containing family names
69//!
70//! // Get first given name  
71//! let result = evaluate_expression("Patient.name.given.first()", &context)?;
72//! // Result: First given name as string
73//!
74//! // Check if patient is active
75//! let result = evaluate_expression("Patient.active", &context)?;
76//! // Result: Boolean value
77//! # Ok::<(), String>(())
78//! ```
79//!
80//! ### Collection Operations
81//!
82//! ```rust,no_run
83//! # use helios_fhirpath::{evaluate_expression, EvaluationContext};
84//! # use helios_fhir::r4::Patient;
85//! # let patient = Patient::default();
86//! # let context = EvaluationContext::new(vec![helios_fhir::FhirResource::R4(Box::new(helios_fhir::r4::Resource::Patient(Box::new(patient))))]);
87//!
88//! // Filter email addresses
89//! let result = evaluate_expression(
90//!     "Patient.telecom.where(system = 'email')",
91//!     &context
92//! )?;
93//!
94//! // Check if any email exists
95//! let result = evaluate_expression(
96//!     "Patient.telecom.where(system = 'email').exists()",
97//!     &context
98//! )?;
99//!
100//! // Count phone numbers
101//! let result = evaluate_expression(
102//!     "Patient.telecom.where(system = 'phone').count()",
103//!     &context
104//! )?;
105//! # Ok::<(), String>(())
106//! ```
107//!
108//! ### Type Operations
109//!
110//! ```rust,no_run
111//! # use helios_fhirpath::{evaluate_expression, EvaluationContext};
112//! # use helios_fhir::r4::Observation;
113//! # let observation = Observation::default();
114//! # let context = EvaluationContext::new(vec![helios_fhir::FhirResource::R4(Box::new(helios_fhir::r4::Resource::Observation(Box::new(observation))))]);
115//!
116//! // Check if observation value is a Quantity
117//! let result = evaluate_expression(
118//!     "Observation.value.is(Quantity)",
119//!     &context
120//! )?;
121//!
122//! // Cast value to Quantity and get unit
123//! let result = evaluate_expression(
124//!     "Observation.value.as(Quantity).unit",
125//!     &context
126//! )?;
127//!
128//! // Get type information
129//! let result = evaluate_expression(
130//!     "Observation.value.type().name",
131//!     &context
132//! )?;
133//! # Ok::<(), String>(())
134//! ```
135//!
136//! ### Extension Access
137//!
138//! ```rust,no_run
139//! # use helios_fhirpath::{evaluate_expression, EvaluationContext, EvaluationResult};
140//! # use helios_fhir::r4::Patient;
141//!
142//! // Create context with patient data
143//! let mut context = EvaluationContext::new(vec![]);
144//!
145//! // Access FHIR extension by URL
146//! let result = evaluate_expression(
147//!     "Patient.extension('http://hl7.org/fhir/StructureDefinition/patient-birthPlace')",
148//!     &context
149//! )?;
150//!
151//! // Extension with variable
152//! context.set_variable_result("birthPlaceUrl", EvaluationResult::string(
153//!     "http://hl7.org/fhir/StructureDefinition/patient-birthPlace".to_string()
154//! ));
155//! let result = evaluate_expression(
156//!     "Patient.extension(%birthPlaceUrl).value",
157//!     &context
158//! )?;
159//! # Ok::<(), String>(())
160//! ```
161//!
162//! ### Mathematical Operations
163//!
164//! ```rust,no_run
165//! # use helios_fhirpath::{evaluate_expression, EvaluationContext};
166//! # let context = EvaluationContext::new(vec![]);
167//!
168//! // Basic arithmetic
169//! let result = evaluate_expression("1 + 2 * 3", &context)?; // Result: 7
170//!
171//! // Decimal operations
172//! let result = evaluate_expression("10.5 / 2.1", &context)?;
173//!
174//! // Age calculation (if Patient.birthDate exists)
175//! let result = evaluate_expression(
176//!     "today() - Patient.birthDate",
177//!     &context
178//! )?;
179//! # Ok::<(), String>(())
180//! ```
181//!
182//! ### Variables and Constants
183//!
184//! ```rust,no_run
185//! # use helios_fhirpath::{evaluate_expression, EvaluationContext, EvaluationResult};
186//! let mut context = EvaluationContext::new(vec![]);
187//!
188//! // Set custom variables
189//! context.set_variable_result("threshold", EvaluationResult::decimal(rust_decimal::Decimal::new(5, 0)));
190//! context.set_variable_result("unitSystem", EvaluationResult::string("metric".to_string()));
191//!
192//! // Use variables in expressions
193//! let result = evaluate_expression("value > %threshold", &context)?;
194//!
195//! // Built-in constants are automatically available
196//! let result = evaluate_expression("system = %ucum", &context)?; // %ucum = 'http://unitsofmeasure.org'
197//! # Ok::<(), String>(())
198//! ```
199//!
200//! ## Error Handling
201//!
202//! The [`evaluate_expression`] function returns detailed error messages for both parsing and evaluation failures:
203//!
204//! ```rust,no_run
205//! # use helios_fhirpath::{evaluate_expression, EvaluationContext};
206//! # let context = EvaluationContext::new(vec![]);
207//!
208//! // Syntax error
209//! match evaluate_expression("Patient.name.", &context) {
210//!     Err(err) => println!("Parse error: {}", err),
211//!     Ok(_) => {}
212//! }
213//!
214//! // Runtime error
215//! match evaluate_expression("Patient.nonExistentField", &context) {
216//!     Err(err) => println!("Evaluation error: {}", err),
217//!     Ok(_) => {}
218//! }
219//! ```
220//!
221//! ## Performance Considerations
222//!
223//! - **Parsing**: Expression parsing is relatively expensive; consider caching parsed expressions for repeated use
224//! - **Evaluation**: Evaluation performance depends on resource size and expression complexity
225//! - **Memory**: Large collections in FHIR resources may consume significant memory during evaluation
226//!
227//! ## Specification Compliance
228//!
229//! This implementation aims for full compliance with [FHIRPath 3.0.0](https://hl7.org/fhirpath/2025Jan/).
230//! Current implementation status includes:
231//!
232//! - ✅ **Core Language**: Literals, operators, path navigation
233//! - ✅ **Collection Functions**: where, select, first, last, tail, etc.
234//! - ✅ **Boolean Logic**: and, or, not, implies, xor
235//! - ✅ **Type Operations**: is, as, ofType with FHIR type system
236//! - ✅ **String Functions**: matches, contains, startsWith, etc.
237//! - ✅ **Math Functions**: abs, ceiling, floor, round, sqrt, etc.
238//! - ✅ **Date Functions**: today, now, date/time arithmetic
239//! - ✅ **Extension Functions**: FHIR extension access
240//! - ✅ **Variables**: External variables and built-in constants
241//! - 🟡 **Advanced Features**: Some STU (Standard for Trial Use) functions
242//!
243//! See the [FHIRPath README](https://github.com/HeliosSoftware/hfs/blob/main/crates/fhirpath/README.md)
244//! for detailed implementation status.
245//!
246//! ## FHIR Version Support
247//!
248//! This crate supports multiple FHIR versions through Cargo feature flags:
249//!
250//! ```toml
251//! [dependencies]
252//! fhirpath = { version = "0.1", features = ["R4"] }      # FHIR R4 support
253//! fhirpath = { version = "0.1", features = ["R5"] }      # FHIR R5 support  
254//! fhirpath = { version = "0.1", features = ["R4", "R5"] } # Multiple versions
255//! ```
256//!
257//! Available features:
258//! - `R4`: FHIR 4.0.1 (normative)
259//! - `R4B`: FHIR 4.3.0 (ballot)
260//! - `R5`: FHIR 5.0.0 (ballot)
261//! - `R6`: FHIR 6.0.0 (draft)
262
263// Internal modules - not part of the public API
264mod aggregate_function;
265mod aggregate_math_functions;
266mod boolean_functions;
267mod boundary_functions;
268mod collection_functions;
269mod collection_navigation;
270mod contains_function;
271mod conversion_functions;
272mod format_functions;
273mod interval_functions;
274mod json_utils;
275pub mod ucum;
276// Public for internal testing only - not part of the public API
277#[doc(hidden)]
278pub mod date_operation;
279mod datetime_impl;
280pub mod debug_trace;
281mod distinct_functions;
282mod extension_function;
283// Curated catalog of built-in functions; re-exported below.
284mod fhir_type_hierarchy;
285mod functions;
286mod long_conversion;
287mod not_function;
288mod polymorphic_access;
289mod reference_key_functions;
290mod repeat_all_function;
291mod repeat_function;
292mod resolve_function;
293mod resource_type;
294mod set_operations;
295mod subset_functions;
296mod terminology_client;
297mod terminology_functions;
298mod trace_function;
299mod type_function;
300pub mod type_inference;
301
302// Modules for CLI and server functionality
303pub mod cli;
304pub mod error;
305pub mod handlers;
306pub mod models;
307pub mod parse_debug;
308pub mod server;
309
310// Public modules needed for the public API
311pub mod evaluator;
312pub mod parser;
313
314// Public API exports - this is what users of the fhirpath crate should use
315pub use evaluator::EvaluationContext;
316pub use functions::{FunctionCategory, FunctionInfo, builtin_functions};
317pub use helios_fhirpath_support::EvaluationResult;
318
319/// Evaluates a FHIRPath expression against a given context.
320///
321/// This is the primary interface for FHIRPath evaluation. It combines parsing and evaluation
322/// into a single convenient function call.
323///
324/// # Arguments
325///
326/// * `expression` - The FHIRPath expression string to evaluate
327/// * `context` - The evaluation context containing the FHIR resource(s) to evaluate against
328///
329/// # Returns
330///
331/// Returns a `Result` containing either:
332/// - `Ok(EvaluationResult)` - The result of evaluating the expression
333/// - `Err(String)` - An error message if parsing or evaluation fails
334///
335/// # Examples
336///
337/// ```rust,no_run
338/// use helios_fhirpath::{evaluate_expression, EvaluationContext};
339/// use helios_fhir::r4::Observation;
340///
341/// // Create a context with a FHIR resource
342/// # let observation = Observation::default();
343/// let context = EvaluationContext::new(vec![helios_fhir::FhirResource::R4(Box::new(helios_fhir::r4::Resource::Observation(Box::new(observation))))]);
344///
345/// // Evaluate a simple expression
346/// let result = evaluate_expression("value.unit", &context)?;
347/// # Ok::<(), String>(())
348/// ```
349///
350/// # Notes
351///
352/// - The expression is parsed using the FHIRPath parser, which follows the FHIRPath 3.0.0 specification
353/// - Evaluation is performed against the resources in the provided context
354/// - Variables should be set on the context before calling this function
355/// - The function handles all parsing errors and evaluation errors uniformly
356pub fn evaluate_expression(
357    expression: &str,
358    context: &EvaluationContext,
359) -> Result<EvaluationResult, String> {
360    let parsed = parse_expression(expression)?;
361
362    // Evaluate the parsed expression
363    evaluator::evaluate(&parsed, context, None).map_err(|e| {
364        format!(
365            "Failed to evaluate FHIRPath expression '{}': {}",
366            expression, e
367        )
368    })
369}
370
371/// Parse a FHIRPath expression source string into a typed [`parser::Expression`] AST.
372///
373/// Provides a chumsky-free entry point for consumers that need the AST
374/// (e.g. compiling FHIRPath to SQL) without taking a dependency on the
375/// parser-combinator crate.
376pub fn parse_expression(expression: &str) -> Result<parser::Expression, String> {
377    use chumsky::Parser;
378
379    parser::parser()
380        .parse(expression)
381        .into_result()
382        .map_err(|e| {
383            format!(
384                "Failed to parse FHIRPath expression '{}': {:?}",
385                expression, e
386            )
387        })
388}
389
390/// A single parse error from [`parse_expression_diagnostics`], with its span
391/// expressed in **Unicode scalar value (`char`) offsets** into the original
392/// `expression` string — never UTF-8 byte offsets.
393///
394/// chumsky's own `Rich` errors (which the FHIRPath parser produces) report
395/// spans as byte offsets, since that is what its `&str` `Input` impl tracks
396/// internally. Callers that index into the expression by character (e.g. a
397/// browser editor counting Unicode code points, or anything that turns the
398/// span into a substring via `.chars()`) would silently miscount on any
399/// expression containing a multi-byte character, so the conversion happens
400/// once here rather than being every caller's problem.
401#[derive(Debug, Clone, PartialEq, Eq)]
402pub struct ParseDiagnostic {
403    /// `(start, end)` char offsets of the erroring span within `expression`.
404    /// `start == end` for a zero-width error (e.g. unexpected end of input).
405    pub span: (usize, usize),
406    /// A human-readable description of the problem, taken from chumsky's own
407    /// [`Display`](std::fmt::Display) rendering of the error — not a `Debug`
408    /// dump of its internal structure, and never the expression text itself.
409    pub message: String,
410}
411
412/// Parses `expression` and, on failure, returns every diagnostic chumsky's
413/// [`Rich`](chumsky::error::Rich) error reporting produced, spans converted
414/// to Unicode char offsets (see [`ParseDiagnostic`]).
415///
416/// Purely additive: [`parse_expression`] keeps its existing signature and
417/// behavior unchanged. This is a second, richer entry point for callers that
418/// need error *positions* rather than one flattened message — e.g. a lint
419/// pass that must underline the offending span inside a larger document.
420///
421/// Never evaluates the expression and never touches a FHIR resource or a
422/// terminology server: like [`parse_expression`], this is parsing alone.
423///
424/// # Examples
425///
426/// ```
427/// use helios_fhirpath::parse_expression_diagnostics;
428///
429/// assert!(parse_expression_diagnostics("Patient.name.family").is_ok());
430///
431/// let errors = parse_expression_diagnostics("Patient.name.").unwrap_err();
432/// assert!(!errors.is_empty());
433/// assert!(!errors[0].message.is_empty());
434/// ```
435pub fn parse_expression_diagnostics(
436    expression: &str,
437) -> Result<parser::Expression, Vec<ParseDiagnostic>> {
438    use chumsky::Parser;
439
440    parser::parser()
441        .parse(expression)
442        .into_result()
443        .map_err(|errors| {
444            errors
445                .iter()
446                .map(|error| {
447                    let span = error.span();
448                    ParseDiagnostic {
449                        span: (
450                            byte_to_char_offset(expression, span.start),
451                            byte_to_char_offset(expression, span.end),
452                        ),
453                        message: error.to_string(),
454                    }
455                })
456                .collect()
457        })
458}
459
460/// Converts a UTF-8 byte offset into `s` (assumed to already fall on a
461/// `char` boundary, which every span chumsky's `&str` parser produces does)
462/// into a count of Unicode scalar values before that offset.
463fn byte_to_char_offset(s: &str, byte_offset: usize) -> usize {
464    s.get(..byte_offset)
465        .map_or_else(|| s.chars().count(), |prefix| prefix.chars().count())
466}
467
468/// Parses `expression` into a [`parser::SpannedExpression`] — the same AST
469/// as [`parse_expression`], but with every node annotated with its
470/// [`parser::ExprSpan`] (a byte `position`/`length` pair into `expression`).
471///
472/// Uses [`parser::spanned_parser`] under the hood; on failure, returns the
473/// same diagnostics [`parse_expression_diagnostics`] would (span converted
474/// to Unicode char offsets — see [`ParseDiagnostic`]).
475///
476/// This is a third, purely additive entry point: [`parse_expression`] and
477/// [`parse_expression_diagnostics`] keep their existing signatures and
478/// behavior unchanged, and this function does not affect the debug tracer
479/// (`FHIRPATH_DEBUG_TRACE=1`), which already calls [`parser::spanned_parser`]
480/// directly. It exists for callers that need to locate *where* a specific
481/// construct (e.g. an external constant reference) sits in the source text —
482/// [`external_constants`] is one such caller.
483///
484/// Never evaluates the expression and never touches a FHIR resource or a
485/// terminology server.
486///
487/// # Examples
488///
489/// ```
490/// use helios_fhirpath::parse_expression_spanned;
491///
492/// let spanned = parse_expression_spanned("Patient.name.family").unwrap();
493/// assert_eq!(spanned.span.position, 0);
494///
495/// let errors = parse_expression_spanned("Patient.name.").unwrap_err();
496/// assert!(!errors.is_empty());
497/// ```
498pub fn parse_expression_spanned(
499    expression: &str,
500) -> Result<parser::SpannedExpression, Vec<ParseDiagnostic>> {
501    use chumsky::Parser;
502
503    parser::spanned_parser()
504        .parse(expression)
505        .into_result()
506        .map_err(|errors| {
507            errors
508                .iter()
509                .map(|error| {
510                    let span = error.span();
511                    ParseDiagnostic {
512                        span: (
513                            byte_to_char_offset(expression, span.start),
514                            byte_to_char_offset(expression, span.end),
515                        ),
516                        message: error.to_string(),
517                    }
518                })
519                .collect()
520        })
521}
522
523/// Converts a byte-offset [`parser::ExprSpan`] (as produced by
524/// [`parser::spanned_parser`] / [`parse_expression_spanned`]) into a
525/// `(start, end)` pair of Unicode char offsets into `expression`, with the
526/// same semantics as [`ParseDiagnostic::span`].
527///
528/// `ExprSpan` stores a byte `position`/`length` because that is what
529/// chumsky's `&str` input tracks internally (see [`parser::ExprSpan`] and
530/// [`debug_trace`], its only other consumer today). A caller indexing into
531/// `expression` by character — a browser editor counting Unicode code
532/// points, or a diagnostic span meant to be sliced with `.chars()` — would
533/// silently miscount on any expression containing a multi-byte character
534/// before the span, so this conversion exists once here rather than being
535/// every such caller's problem.
536///
537/// # Examples
538///
539/// ```
540/// use helios_fhirpath::{expr_span_to_char_offsets, parse_expression_spanned};
541///
542/// // "café" is 4 chars / 5 bytes (é is a 2-byte UTF-8 sequence).
543/// let spanned = parse_expression_spanned("'café' & %foo").unwrap();
544/// // The whole expression's span covers the full source in bytes...
545/// assert_eq!(spanned.span.position + spanned.span.length, 14);
546/// // ...but converted to chars, it covers the 13-char source instead.
547/// let (_, end) = expr_span_to_char_offsets("'café' & %foo", &spanned.span);
548/// assert_eq!(end, 13);
549/// ```
550pub fn expr_span_to_char_offsets(expression: &str, span: &parser::ExprSpan) -> (usize, usize) {
551    (
552        byte_to_char_offset(expression, span.position),
553        byte_to_char_offset(expression, span.position + span.length),
554    )
555}
556
557/// A reference to an external constant (`%name`) found by [`external_constants`].
558#[derive(Debug, Clone, PartialEq, Eq)]
559pub struct ExternalConstantRef {
560    /// The constant's name, without the leading `%` and with any
561    /// `` `backtick` `` or `'single-quote'` delimiters stripped (escape
562    /// sequences inside a delimited name are already decoded, matching what
563    /// [`Term::ExternalConstant`](parser::Term::ExternalConstant) stores).
564    pub name: String,
565    /// The **byte** span of the full token, from the `%` through the end of
566    /// the name — including its delimiters in the two quoted lexical forms.
567    /// Convert to char offsets with [`expr_span_to_char_offsets`].
568    pub span: parser::ExprSpan,
569}
570
571/// Walks every node of `expr` and returns a reference for each external
572/// constant (`%name`, `` %`quoted name` ``, or `%'quoted name'``) found
573/// anywhere in the tree — as an operand, a function argument, inside a
574/// lambda, an indexer, a union, or the operand of `is`/`as`.
575///
576/// `source` must be the exact expression string `expr` was parsed from
577/// ([`parse_expression_spanned`] or [`parser::spanned_parser`] directly);
578/// it is needed to correct a quirk of [`parser::spanned_parser`]'s spans.
579/// Every lexical token in that grammar is built from combinators ending in
580/// `.padded()`, so the recorded `ExprSpan` for an external constant also
581/// swallows any whitespace/comments immediately following the token —
582/// harmless for [`debug_trace`], the only existing consumer of those spans,
583/// but wrong for a diagnostic span meant to underline just the `%name`
584/// token. This walker recovers the exact end offset from `source` itself
585/// rather than trusting the parser's (over-wide) span, so every
586/// [`ExternalConstantRef::span`] this returns covers precisely the token.
587///
588/// # Examples
589///
590/// ```
591/// use helios_fhirpath::{external_constants, parse_expression_spanned};
592///
593/// let source = "name.where(system = %ucum)";
594/// let spanned = parse_expression_spanned(source).unwrap();
595/// let refs = external_constants(&spanned, source);
596/// assert_eq!(refs.len(), 1);
597/// assert_eq!(refs[0].name, "ucum");
598/// assert_eq!(&source[refs[0].span.position..refs[0].span.position + refs[0].span.length], "%ucum");
599/// ```
600pub fn external_constants(
601    expr: &parser::SpannedExpression,
602    source: &str,
603) -> Vec<ExternalConstantRef> {
604    let mut refs = Vec::new();
605    collect_external_constants(expr, source, &mut refs);
606    refs
607}
608
609fn collect_external_constants(
610    expr: &parser::SpannedExpression,
611    source: &str,
612    out: &mut Vec<ExternalConstantRef>,
613) {
614    use parser::{SpannedExprKind, SpannedTerm};
615
616    match &expr.kind {
617        SpannedExprKind::Term(term) => match term {
618            SpannedTerm::ExternalConstant(name) => out.push(ExternalConstantRef {
619                name: name.clone(),
620                span: exact_external_constant_span(source, &expr.span),
621            }),
622            SpannedTerm::Invocation(invocation) => {
623                collect_external_constants_in_invocation(invocation, source, out)
624            }
625            SpannedTerm::Parenthesized(inner) => collect_external_constants(inner, source, out),
626            SpannedTerm::Literal(_) => {}
627        },
628        SpannedExprKind::Invocation(base, invocation) => {
629            collect_external_constants(base, source, out);
630            collect_external_constants_in_invocation(invocation, source, out);
631        }
632        SpannedExprKind::Indexer(base, index) => {
633            collect_external_constants(base, source, out);
634            collect_external_constants(index, source, out);
635        }
636        SpannedExprKind::Polarity(_, inner) => collect_external_constants(inner, source, out),
637        SpannedExprKind::Multiplicative(left, _, right)
638        | SpannedExprKind::Additive(left, _, right)
639        | SpannedExprKind::Inequality(left, _, right)
640        | SpannedExprKind::Equality(left, _, right)
641        | SpannedExprKind::Membership(left, _, right)
642        | SpannedExprKind::Or(left, _, right)
643        | SpannedExprKind::Union(left, right)
644        | SpannedExprKind::And(left, right)
645        | SpannedExprKind::Implies(left, right) => {
646            collect_external_constants(left, source, out);
647            collect_external_constants(right, source, out);
648        }
649        SpannedExprKind::Type(inner, _, _) => collect_external_constants(inner, source, out),
650        SpannedExprKind::Lambda(_, inner) => collect_external_constants(inner, source, out),
651        SpannedExprKind::InstanceSelector(_, fields) => {
652            for (_, field_expr) in fields {
653                collect_external_constants(field_expr, source, out);
654            }
655        }
656    }
657}
658
659fn collect_external_constants_in_invocation(
660    invocation: &parser::SpannedInvocation,
661    source: &str,
662    out: &mut Vec<ExternalConstantRef>,
663) {
664    if let parser::SpannedInvocation::Function(_, args) = invocation {
665        for arg in args {
666            collect_external_constants(arg, source, out);
667        }
668    }
669}
670
671/// Recomputes the exact `(position, length)` of an external-constant token
672/// from `source`, discarding any trailing whitespace [`parser::spanned_parser`]
673/// folded into `padded_span` (see [`external_constants`]'s doc comment).
674///
675/// Falls back to `padded_span` unchanged if `source` doesn't start a valid
676/// external constant at that position — which should never happen for a
677/// span the parser itself produced, but a silent fallback is safer for a
678/// diagnostics helper than panicking on an unexpected drift between this
679/// and the grammar.
680fn exact_external_constant_span(source: &str, padded_span: &parser::ExprSpan) -> parser::ExprSpan {
681    let tail = source.get(padded_span.position..).unwrap_or("");
682    match external_constant_token_len(tail) {
683        Some(length) => parser::ExprSpan {
684            position: padded_span.position,
685            length,
686        },
687        None => padded_span.clone(),
688    }
689}
690
691/// Given `source` starting exactly at the `%` of an external constant,
692/// returns the byte length of the token itself — `%` plus the bare
693/// identifier, or plus a `` `delimited` `` / `'quoted'` name including its
694/// closing delimiter. Returns `None` if `source` doesn't start with `%`
695/// followed by a syntactically valid external-constant name.
696///
697/// Delimited/quoted forms may contain `\`-escaped characters (matching the
698/// `esc` rule [`parser::parser`] and [`parser::spanned_parser`] both use for
699/// these tokens); this only needs to skip over them without decoding them,
700/// since the decoded name is already available from the parsed AST.
701fn external_constant_token_len(source: &str) -> Option<usize> {
702    if !source.starts_with('%') {
703        return None;
704    }
705    let after_percent = '%'.len_utf8();
706    let rest = &source[after_percent..];
707    let mut chars = rest.char_indices();
708    match chars.next() {
709        Some((_, delimiter @ ('`' | '\''))) => {
710            let mut escaped = false;
711            for (i, c) in chars {
712                if escaped {
713                    escaped = false;
714                    continue;
715                }
716                match c {
717                    '\\' => escaped = true,
718                    c if c == delimiter => return Some(after_percent + i + c.len_utf8()),
719                    _ => {}
720                }
721            }
722            None // Unterminated — shouldn't happen for a node the parser accepted.
723        }
724        Some((_, first)) if first.is_ascii_alphabetic() || first == '_' => {
725            let mut end = after_percent + first.len_utf8();
726            for (i, c) in chars {
727                if c.is_ascii_alphanumeric() || c == '_' {
728                    end = after_percent + i + c.len_utf8();
729                } else {
730                    break;
731                }
732            }
733            Some(end)
734        }
735        _ => None,
736    }
737}
738
739/// The environment variables [`evaluator`] resolves as special cases when
740/// evaluating `%name` — the fixed subset of the [FHIRPath environment
741/// variables](https://hl7.org/fhirpath/2025Jan/#environment-variables) that
742/// have a single literal name. Excludes the `%vs-[name]`/`%ext-[name]`
743/// families, which are patterns rather than enumerable names, and
744/// `%terminologies`, which is a namespace object rather than a value.
745///
746/// Kept in sync with [`evaluator`]'s handling of these names by a test
747/// (`each_environment_variable_evaluates_without_an_undefined_variable_error`)
748/// that evaluates `%<name>` for each entry against a minimal context and
749/// asserts it does not produce the "undefined variable" error an unknown
750/// name like `%definitelyUnknown` does.
751pub fn environment_variables() -> &'static [&'static str] {
752    &[
753        "context",
754        "resource",
755        "rootResource",
756        "ucum",
757        "sct",
758        "loinc",
759    ]
760}
761
762/// Returns `true` if `name` (without the leading `%`) is one of
763/// [`environment_variables`].
764pub fn is_environment_variable(name: &str) -> bool {
765    environment_variables().contains(&name)
766}