Skip to content

Introduce a fhirpath debug tracer #3210

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 27 commits into from
Aug 19, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
d24d890
Functions and child properties the name is an identifier, not a const…
brianpos Jun 24, 2025
269c6e3
ChildExpression name is an identifier (derived from constant)
brianpos Jun 24, 2025
4d45c55
Initial draft of injecting a debug tracer.
brianpos Jun 25, 2025
21539a4
Merge branch 'develop' into feature/BP-fhirpath-debugger
mmsmits Jul 9, 2025
b05954f
Update src/Hl7.Fhir.Base/FhirPath/DebugTracer.cs
brianpos Jul 18, 2025
cf9458b
Complete the external surface for how to "inject" the tracing delegat…
brianpos Jul 18, 2025
8cf189b
Include the position information for the standard extension/valueset …
brianpos Jul 18, 2025
bea44d0
Use an interface that has the function rather than a delegate.
brianpos Jul 18, 2025
ba521f3
rename debugtracer file
brianpos Jul 18, 2025
c0530a4
Include a unit test for the debug tracer
brianpos Jul 18, 2025
a84bc97
Tweak some other fhirpath unit tests to use the diagnostics tracer to…
brianpos Jul 18, 2025
bdfc37f
Merge remote-tracking branch 'hl7/develop' into feature/BP-fhirpath-d…
brianpos Jul 18, 2025
7dd77e9
Change to a few overload to the function rather than adding an option…
brianpos Jul 22, 2025
e5754af
Update the unit test with assertions that match the test data
brianpos Jul 23, 2025
28ecc36
Capture the focus used in the invokee to report to the debug tracer
brianpos Jul 23, 2025
07c71d1
Minor tweaks to cleanup co-pilots review
brianpos Jul 23, 2025
29f0eeb
Merge branch 'develop' into feature/BP-fhirpath-debugger
Kasdejong Jul 24, 2025
81435f4
refactor if else into switches
brianpos Jul 24, 2025
0b8aa99
Merge branch 'develop' into feature/BP-fhirpath-debugger
mmsmits Jul 30, 2025
0ec42ca
Introduce a closure Id (tracked based on closures created within an e…
brianpos Aug 15, 2025
7a18bd7
Partial commit issue - resolve compilation issue
brianpos Aug 15, 2025
9bc7009
Unit test updated
brianpos Aug 15, 2025
9a3744e
Since the tests process multiple expressions, dump the specific expre…
brianpos Aug 15, 2025
1d2c6fc
Stash the focus into the context, and restore it after processing the…
brianpos Aug 18, 2025
4b9e4c8
Additional unit testing
brianpos Aug 18, 2025
31d12ea
Include assertions into the debug trace tests too.
brianpos Aug 18, 2025
3489cfc
And remove the out parameter
brianpos Aug 18, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 157 additions & 0 deletions src/Hl7.Fhir.Base/FhirPath/DiagnosticsDebugTracer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
/*
* Copyright (c) 2015, Firely (info@fire.ly) and contributors
* See the file CONTRIBUTORS for details.
*
* This file is licensed under the BSD 3-Clause license
* available at https://raw.githubusercontent.com/FirelyTeam/firely-net-sdk/master/LICENSE
*/

#nullable enable

using Hl7.Fhir.ElementModel;
using Hl7.FhirPath.Expressions;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;

namespace Hl7.FhirPath
{

public class DiagnosticsDebugTracer : IDebugTracer
{
public void TraceCall(
Expression expr,
int contextId,
IEnumerable<ITypedElement>? focus,
IEnumerable<ITypedElement>? thisValue,
ITypedElement? index,
IEnumerable<ITypedElement> totalValue,
IEnumerable<ITypedElement> result,
IEnumerable<KeyValuePair<string, IEnumerable<ITypedElement>>> variables)
{
DiagnosticsDebugTracer.DebugTraceCall(expr, contextId, focus, thisValue, index, totalValue, result, variables);
}

public static void DebugTraceCall(
Expression expr,
int contextId,
IEnumerable<ITypedElement>? focus,
IEnumerable<ITypedElement>? thisValue,
ITypedElement? index,
IEnumerable<ITypedElement> totalValue,
IEnumerable<ITypedElement> result,
IEnumerable<KeyValuePair<string, IEnumerable<ITypedElement>>> variables)
{
string exprName;

switch (expr)
{
case IdentifierExpression _:
return;

case ConstantExpression ce:
Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},constant (ctx.id: {contextId})");
exprName = "constant";
break;

case ChildExpression child:
Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},{child.ChildName} (ctx.id: {contextId})");
exprName = child.ChildName;
break;

case IndexerExpression _:
Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},[] (ctx.id: {contextId})");
exprName = "[]";
break;

case UnaryExpression ue:
Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},{ue.Op} (ctx.id: {contextId})");
exprName = ue.Op;
break;

case BinaryExpression be:
Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},{be.Op} (ctx.id: {contextId})");
exprName = be.Op;
break;

case FunctionCallExpression fe:
Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},{fe.FunctionName} (ctx.id: {contextId})");
exprName = fe.FunctionName;
break;

case NewNodeListInitExpression _:
Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},{{}} (empty) (ctx.id: {contextId})");
exprName = "{}";
break;

case AxisExpression ae:
if (ae.AxisName == "that")
return;
Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},${ae.AxisName} (ctx.id: {contextId})");
exprName = "$" + ae.AxisName;
break;

case VariableRefExpression ve:
Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},%{ve.Name} (ctx.id: {contextId})");
exprName = "%" + ve.Name;
break;

default:
exprName = expr.GetType().Name;
#if DEBUG
Debugger.Break();
#endif
throw new Exception($"Unknown expression type: {expr.GetType().Name} (ctx.id: {contextId})");
// Trace.WriteLine($"Evaluated: {expr} results: {result.Count()}");
}

if (result != null)
{
foreach (var item in result)
{
DebugTraceValue($"{exprName} »", item);
}
}

if (focus != null)
{
foreach (var item in focus)
{
DebugTraceValue($"$focus", item);
}
}

if (index != null)
{
DebugTraceValue("$index", index);
}

if (thisValue != null)
{
foreach (var item in thisValue)
{
DebugTraceValue("$this", item);
}
}

if (totalValue != null)
{
foreach (var item in totalValue)
{
DebugTraceValue($"{exprName} »", item);
}
}
}

private static void DebugTraceValue(string exprName, ITypedElement? item)
{
if (item == null)
return; // possible with a null focus to kick things off
if (item.Location == "@primitivevalue@" || item.Location == "@QuantityAsPrimitiveValue@")
Trace.WriteLine($" {exprName}:\t{item.Value}\t({item.InstanceType})");
else
Trace.WriteLine($" {exprName}:\t{item.Value}\t({item.InstanceType})\t{item.Location}");
}
}
}
13 changes: 10 additions & 3 deletions src/Hl7.Fhir.Base/FhirPath/EvaluationContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ public class EvaluationContext
[Obsolete("This method does not initialize any members and will be removed in a future version. Use the empty constructor instead.")]
public static EvaluationContext CreateDefault() => new();


private int ClosuresCreated { get; set; } = 0;
internal int IncrementClosuresCreatedCount() => ClosuresCreated++;

public EvaluationContext()
{
// no defaults yet
Expand All @@ -35,13 +37,13 @@ public EvaluationContext(ITypedElement? resource, ITypedElement? rootResource)
Resource = resource;
RootResource = rootResource ?? resource;
}

[Obsolete("%resource and %rootResource are inferred from scoped nodes by the evaluator. If you do not have access to a scoped node, or if you wish to explicitly override this behaviour, use the EvaluationContext.WithResourceOverrides() method. Environment can be set explicitly after construction of the base context")]
public EvaluationContext(ITypedElement? resource, ITypedElement? rootResource, IDictionary<string, IEnumerable<ITypedElement>> environment) : this(resource, rootResource)
{
Environment = environment;
}

/// <summary>
/// The data represented by <c>%rootResource</c>.
/// </summary>
Expand All @@ -61,6 +63,11 @@ public EvaluationContext(ITypedElement? resource, ITypedElement? rootResource, I
/// A delegate that handles the output for the <c>trace()</c> function.
/// </summary>
public Action<string?, IEnumerable<ITypedElement>>? Tracer { get; set; }

/// <summary>
/// Gets or sets the tracer used for capturing debug information during evaluation
/// </summary>
public IDebugTracer? DebugTracer { get; set; }
}

public static class EvaluationContextExtensions
Expand Down
83 changes: 63 additions & 20 deletions src/Hl7.Fhir.Base/FhirPath/Expressions/Closure.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/*
/*
* Copyright (c) 2015, Firely (info@fire.ly) and contributors
* See the file CONTRIBUTORS for details.
*
*
* This file is licensed under the BSD 3-Clause license
* available at https://raw.githubusercontent.com/FirelyTeam/firely-net-sdk/master/LICENSE
*/
Expand All @@ -17,44 +17,86 @@ namespace Hl7.FhirPath.Expressions
{
internal class Closure
{
public Closure()
internal int Id { get; private set; }

public Closure(EvaluationContext ctx)
{
EvaluationContext = ctx;
Id = ctx.IncrementClosuresCreatedCount();
_debugTracerActive = ctx.DebugTracer != null;
}

public Closure(Closure parent, EvaluationContext ctx)
{
Parent = parent;
EvaluationContext = ctx;
Id = ctx.IncrementClosuresCreatedCount();
_debugTracerActive = ctx.DebugTracer != null;
}

/// <summary>
/// When the debug/trace is enabled this property is used to record the focus of the closure.
/// <br/>VALUE IS NOT USED OUTSIDE DEBUG - without debug/tracer, the value is not consistent.
/// </summary>
/// <remarks>
/// It is set in the delegate produced for each node by the evaluator visitor.
/// The debug tracer will reset the focus in the closure after calling the delegate it's wrapping.
/// ensuring that argument evaluation doesn't impact the focus logged in the debug trace in other
/// calls.
/// </remarks>
public IEnumerable<ITypedElement> focus
{
get
{
if (!_debugTracerActive)
return ElementNode.EmptyList;
return _focus;
}
set
{
if (!_debugTracerActive)
return;
_focus = value;
}
}

private IEnumerable<ITypedElement> _focus;
private bool _debugTracerActive = false;

public EvaluationContext EvaluationContext { get; private set; }

public static Closure Root(ITypedElement root, EvaluationContext ctx = null)
{
var newContext = ctx ?? new EvaluationContext();

var node = root as ScopedNode;

newContext.Resource ??= node != null // if the value has been manually set, we do nothing. Otherwise, if the root is a scoped node:
? getResourceFromNode(node) // we infer the resource from the scoped node
: (root?.Definition?.IsResource is true // if we do not have a scoped node, we see if this is even a resource to begin with
? root // if it is, we use the root as the resource
: null // if not, this breaks the spec in every way (but we will still continue, hopefully we do not need %resource or %rootResource)
);
);

// Same thing, but we copy the resource into the root resource if we cannot infer it from the node.
newContext.RootResource ??= node != null
? getRootResourceFromNode(node)
: newContext.Resource;
var newClosure = new Closure() { EvaluationContext = ctx ?? new EvaluationContext() };
newContext.RootResource ??= node != null
? getRootResourceFromNode(node)
: newContext.Resource;

var newClosure = new Closure(ctx ?? new EvaluationContext());

var input = new[] { root };

foreach (var assignment in newClosure.EvaluationContext.Environment)
{
newClosure.SetValue(assignment.Key, assignment.Value);
}

newClosure.SetThis(input);
newClosure.SetThat(input);
newClosure.SetIndex(ElementNode.CreateList(0));
newClosure.SetOriginalContext(input);

if (newContext.Resource != null) newClosure.SetResource(new[] { newContext.Resource });
if (newContext.RootResource != null) newClosure.SetRootResource(new[] { newContext.RootResource });

Expand All @@ -63,6 +105,11 @@ public static Closure Root(ITypedElement root, EvaluationContext ctx = null)

private Dictionary<string, IEnumerable<ITypedElement>> _namedValues = new Dictionary<string, IEnumerable<ITypedElement>>();

internal IEnumerable<KeyValuePair<string, IEnumerable<ITypedElement>>> Variables()
{
return _namedValues;
}

public virtual void SetValue(string name, IEnumerable<ITypedElement> value)
{
_namedValues.Remove(name);
Expand All @@ -74,11 +121,7 @@ public virtual void SetValue(string name, IEnumerable<ITypedElement> value)

public virtual Closure Nest()
{
return new Closure()
{
Parent = this,
EvaluationContext = this.EvaluationContext
};
return new Closure(this, EvaluationContext);
}


Expand All @@ -100,7 +143,7 @@ public virtual IEnumerable<ITypedElement> ResolveValue(string name)
}

private static ScopedNode getResourceFromNode(ScopedNode node) => node.AtResource ? node : node.ParentResource;

private static ScopedNode getRootResourceFromNode(ScopedNode node)
{
var resource = getResourceFromNode(node);
Expand Down
18 changes: 12 additions & 6 deletions src/Hl7.Fhir.Base/FhirPath/Expressions/DynaDispatcher.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/*
/*
* Copyright (c) 2015, Firely (info@fire.ly) and contributors
* See the file CONTRIBUTORS for details.
*
*
* This file is licensed under the BSD 3-Clause license
* available at https://raw.githubusercontent.com/FirelyTeam/firely-net-sdk/master/LICENSE
*/
Expand All @@ -11,6 +11,7 @@
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using FocusCollection = System.Collections.Generic.IEnumerable<Hl7.Fhir.ElementModel.ITypedElement>;

namespace Hl7.FhirPath.Expressions
{
Expand All @@ -25,11 +26,12 @@ public DynaDispatcher(string name, SymbolTable scope)
private readonly string _name;
private readonly SymbolTable _scope;

public IEnumerable<ITypedElement> Dispatcher(Closure context, IEnumerable<Invokee> args)
public FocusCollection Dispatcher(Closure context, IEnumerable<Invokee> args)
{
var actualArgs = new List<IEnumerable<ITypedElement>>();
var actualArgs = new List<FocusCollection>();

var focus = args.First()(context, InvokeeFactory.EmptyArgs);
context.focus = focus;
if (!focus.Any()) return ElementNode.EmptyList;

actualArgs.Add(focus);
Expand All @@ -46,9 +48,13 @@ public IEnumerable<ITypedElement> Dispatcher(Closure context, IEnumerable<Invoke
{
// The Get() here should never fail, since we already know there's a (dynamic) matching candidate
// Need to clean up this duplicate logic later

var argFuncs = actualArgs.Select(InvokeeFactory.Return);
return entry(context, argFuncs);
var result = entry(context, argFuncs);

// Dynamically dispatched function arguments aren't wrapped
// for the debug/trace, so need to manually put the focus back
context.focus = focus;
return result;
}
catch (TargetInvocationException tie)
{
Expand Down
Loading