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

Open
wants to merge 18 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
18 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
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
139 changes: 139 additions & 0 deletions src/Hl7.Fhir.Base/FhirPath/DiagnosticsDebugTracer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/*
* 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
*/
using Hl7.Fhir.ElementModel;
using Hl7.FhirPath.Expressions;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We aim to have nullability annotations for any new classes. If those could be added, that would be great.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added nullability.

namespace Hl7.FhirPath
{

public class DiagnosticsDebugTracer : IDebugTracer
{

public void TraceCall(
Expression expr,
IEnumerable<ITypedElement> focus,
IEnumerable<ITypedElement> thisValue,
ITypedElement index,
IEnumerable<ITypedElement> totalValue,
IEnumerable<ITypedElement> result,
IEnumerable<KeyValuePair<string, IEnumerable<ITypedElement>>> variables)
{
string exprName;
if (expr is IdentifierExpression ie)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this chain of ifs could be refactored a BUNCH. maybe exprName = expr switch and then a single trace after using the result...

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The traces aren't all the same, but pretty close.
Refactored the if else out though

return;

if (expr is ConstantExpression ce)
{
Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},constant");
exprName = "constant";
}
else if (expr is ChildExpression child)
{
Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},{child.ChildName}");
exprName = child.ChildName;
}
else if (expr is IndexerExpression indexer)
{
Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},[]");
exprName = "[]";
}
else if (expr is UnaryExpression ue)
{
Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},{ue.Op}");
exprName = ue.Op;
}
else if (expr is BinaryExpression be)
{
Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},{be.Op}");
exprName = be.Op;
}
else if (expr is FunctionCallExpression fe)
{
Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},{fe.FunctionName}");
exprName = fe.FunctionName;
}
else if (expr is NewNodeListInitExpression)
{
Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},{{}} (empty)");
exprName = "{}";
}
else if (expr is AxisExpression ae)
{
if (ae.AxisName == "that")
return;
Trace.WriteLine($"Evaluated: {ae.AxisName} results: {result.Count()}");
exprName = "$" + ae.AxisName;
}
else if (expr is VariableRefExpression ve)
{
Trace.WriteLine($"{expr.Location.LineNumber},{expr.Location.LinePosition},%{ve.Name}");
exprName = "%" + ve.Name;
}
else
{
exprName = expr.GetType().Name;
#if DEBUG
Debugger.Break();
#endif
throw new Exception($"Unknown expression type: {expr.GetType().Name}");
// Trace.WriteLine($"Evaluated: {expr} results: {result.Count()}");
}

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

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

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

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

if (result != null)
{
foreach (var item in result)
{
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}");
}
}
}
5 changes: 5 additions & 0 deletions src/Hl7.Fhir.Base/FhirPath/Expressions/Closure.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,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 Down
16 changes: 8 additions & 8 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,17 +26,17 @@ 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, out FocusCollection focus)
{
var actualArgs = new List<IEnumerable<ITypedElement>>();
var actualArgs = new List<FocusCollection>();

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

actualArgs.Add(focus);
var newCtx = context.Nest(focus);

actualArgs.AddRange(args.Skip(1).Select(a => a(newCtx, InvokeeFactory.EmptyArgs)));
actualArgs.AddRange(args.Skip(1).Select(a => a(newCtx, InvokeeFactory.EmptyArgs, out _)));
if (actualArgs.Any(aa => !aa.Any())) return ElementNode.EmptyList;

var entry = _scope.DynamicGet(_name, actualArgs);
Expand All @@ -46,9 +47,8 @@ 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);
return entry(context, argFuncs, out _);
}
catch (TargetInvocationException tie)
{
Expand Down
71 changes: 48 additions & 23 deletions src/Hl7.Fhir.Base/FhirPath/Expressions/EvaluatorVisitor.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,29 +11,45 @@
using System.Collections.Generic;
using System.Linq;
using FP = Hl7.FhirPath.Expressions;
using FocusCollection = System.Collections.Generic.IEnumerable<Hl7.Fhir.ElementModel.ITypedElement>;

namespace Hl7.FhirPath.Expressions
{
internal class EvaluatorVisitor : FP.ExpressionVisitor<Invokee>
{
private Invokee WrapForDebugTracer(Invokee invokee, Expression expression)
{
if (_debugTrace != null)
{
return (Closure context, IEnumerable<Invokee> arguments, out FocusCollection focus) => {
var result = invokee(context, arguments, out focus);
_debugTrace?.TraceCall(expression, focus, context.GetThis(), context.GetIndex()?.FirstOrDefault(), context.GetTotal(), result, context.Variables());
return result;
};
}
return invokee;
}

public SymbolTable Symbols { get; }
private IDebugTracer _debugTrace;

public EvaluatorVisitor(SymbolTable symbols)
public EvaluatorVisitor(SymbolTable symbols, IDebugTracer debugTrace = null)
{
Symbols = symbols;
_debugTrace = debugTrace;
}


public override Invokee VisitConstant(FP.ConstantExpression expression)
{
return InvokeeFactory.Return(ElementNode.ForPrimitive(expression.Value));
return WrapForDebugTracer(InvokeeFactory.Return(ElementNode.ForPrimitive(expression.Value)), expression);
}

public override Invokee VisitFunctionCall(FP.FunctionCallExpression expression)
{
var focus = expression.Focus.ToEvaluator(Symbols);
var focus = expression.Focus.ToEvaluator(Symbols, _debugTrace);
var arguments = new List<Invokee>() { focus };
arguments.AddRange(expression.Arguments.Select(arg => arg.ToEvaluator(Symbols)));
arguments.AddRange(expression.Arguments.Select(arg => arg.ToEvaluator(Symbols, _debugTrace)));

// We have no real type information, so just pass object as the type
var types = new List<Type>() { typeof(object) }; // for the focus;
Expand All @@ -42,52 +58,62 @@ public override Invokee VisitFunctionCall(FP.FunctionCallExpression expression)
// Now locate the function based on the types and name
Invokee boundFunction = resolve(Symbols, expression.FunctionName, types);

return InvokeeFactory.Invoke(expression.FunctionName, arguments, boundFunction);
return WrapForDebugTracer(InvokeeFactory.Invoke(expression.FunctionName, arguments, boundFunction), expression);
}

public override Invokee VisitNewNodeListInit(FP.NewNodeListInitExpression expression)
{
return InvokeeFactory.Return(ElementNode.EmptyList);
return WrapForDebugTracer(InvokeeFactory.Return(ElementNode.EmptyList), expression);
}

public override Invokee VisitVariableRef(FP.VariableRefExpression expression)
{
// HACK, for now, $this is special, and we handle in run-time, not compile time...
if (expression.Name == "builtin.this")
return InvokeeFactory.GetThis;
return WrapForDebugTracer(InvokeeFactory.GetThis, expression);

// HACK, for now, $this is special, and we handle in run-time, not compile time...
if (expression.Name == "builtin.that")
return InvokeeFactory.GetThat;

// HACK, for now, $total is special, and we handle in run-time, not compile time...
if (expression.Name == "builtin.total")
return InvokeeFactory.GetTotal;
return WrapForDebugTracer(InvokeeFactory.GetTotal, expression);

// HACK, for now, $index is special, and we handle in run-time, not compile time...
if (expression.Name == "builtin.index")
return InvokeeFactory.GetIndex;
return WrapForDebugTracer(InvokeeFactory.GetIndex, expression);

// HACK, for now, %context is special, and we handle in run-time, not compile time...
if (expression.Name == "context")
return InvokeeFactory.GetContext;
return WrapForDebugTracer(InvokeeFactory.GetContext, expression);

// HACK, for now, %resource is special, and we handle in run-time, not compile time...
if (expression.Name == "resource")
return InvokeeFactory.GetResource;
return WrapForDebugTracer(InvokeeFactory.GetResource, expression);

// HACK, for now, %rootResource is special, and we handle in run-time, not compile time...
if (expression.Name == "rootResource")
return InvokeeFactory.GetRootResource;
return WrapForDebugTracer(InvokeeFactory.GetRootResource, expression);

return WrapForDebugTracer(chainResolves, expression);

return chainResolves;

IEnumerable<ITypedElement> chainResolves(Closure context, IEnumerable<Invokee> invokees)
FocusCollection chainResolves(Closure context, IEnumerable<Invokee> invokees, out FocusCollection focus)
{
return context.ResolveValue(expression.Name) ?? resolve(Symbols, expression.Name, Enumerable.Empty<Type>())(context, []);
var value = context.ResolveValue(expression.Name);
if (value != null)
{
// this was in the context, so the scope was $this (the context)
focus = context.GetThis();
return value;
}
else
{
return resolve(Symbols, expression.Name, Enumerable.Empty<Type>())(context, [], out focus);
}
}
}

private static Invokee resolve(SymbolTable scope, string name, IEnumerable<Type> argumentTypes)
{
// For now, we don't have the types or the parameters statically, so we just match on name
Expand All @@ -113,7 +139,7 @@ private static Invokee resolve(SymbolTable scope, string name, IEnumerable<Type>
}
else
{
// No function could be found, but there IS a function with the given name,
// No function could be found, but there IS a function with the given name,
// report an error about the fact that the function is known, but could not be bound
throw Error.Argument("Unknown symbol '{0}'".FormatWith(name));
}
Expand All @@ -123,11 +149,10 @@ private static Invokee resolve(SymbolTable scope, string name, IEnumerable<Type>

internal static class EvaluatorExpressionExtensions
{
public static Invokee ToEvaluator(this FP.Expression expr, SymbolTable scope)
public static Invokee ToEvaluator(this FP.Expression expr, SymbolTable scope, IDebugTracer debugTrace = null)
{
var compiler = new EvaluatorVisitor(scope);
var compiler = new EvaluatorVisitor(scope, debugTrace);
return expr.Accept(compiler);
}
}

}
4 changes: 2 additions & 2 deletions src/Hl7.Fhir.Base/FhirPath/Expressions/ExpressionNode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -356,12 +356,12 @@ public string DebuggerDisplay
public class ChildExpression : FunctionCallExpression, Sprache.IPositionAware<ChildExpression>
{
public ChildExpression(Expression focus, string name) : base(focus, OP_PREFIX + "children", TypeSpecifier.Any,
new ConstantExpression(name, TypeSpecifier.String))
new IdentifierExpression(name, TypeSpecifier.String))
{
}

public ChildExpression(Expression focus, string name, ISourcePositionInfo location) : base(focus, OP_PREFIX + "children", TypeSpecifier.Any,
new ConstantExpression(name, TypeSpecifier.String), location)
new IdentifierExpression(name, TypeSpecifier.String), location)
{
}

Expand Down
Loading