From ef253ce7ef8463ba7a765c2cd45ade768cf52d30 Mon Sep 17 00:00:00 2001 From: Pavel Gelver Date: Sun, 3 May 2026 19:24:14 +0200 Subject: [PATCH 1/5] chore: configure JetBrains IDE settings --- src/.idea/.idea.Acuminator/.idea/.gitignore | 15 +++++++++++++++ src/.idea/.idea.Acuminator/.idea/.name | 1 + src/.idea/.idea.Acuminator/.idea/indexLayout.xml | 8 ++++++++ src/.idea/.idea.Acuminator/.idea/vcs.xml | 6 ++++++ 4 files changed, 30 insertions(+) create mode 100644 src/.idea/.idea.Acuminator/.idea/.gitignore create mode 100644 src/.idea/.idea.Acuminator/.idea/.name create mode 100644 src/.idea/.idea.Acuminator/.idea/indexLayout.xml create mode 100644 src/.idea/.idea.Acuminator/.idea/vcs.xml diff --git a/src/.idea/.idea.Acuminator/.idea/.gitignore b/src/.idea/.idea.Acuminator/.idea/.gitignore new file mode 100644 index 000000000..a03673464 --- /dev/null +++ b/src/.idea/.idea.Acuminator/.idea/.gitignore @@ -0,0 +1,15 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Rider ignored files +/modules.xml +/.idea.Acuminator.iml +/contentModel.xml +/projectSettingsUpdater.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/src/.idea/.idea.Acuminator/.idea/.name b/src/.idea/.idea.Acuminator/.idea/.name new file mode 100644 index 000000000..5df8cc921 --- /dev/null +++ b/src/.idea/.idea.Acuminator/.idea/.name @@ -0,0 +1 @@ +Acuminator \ No newline at end of file diff --git a/src/.idea/.idea.Acuminator/.idea/indexLayout.xml b/src/.idea/.idea.Acuminator/.idea/indexLayout.xml new file mode 100644 index 000000000..7b08163ce --- /dev/null +++ b/src/.idea/.idea.Acuminator/.idea/indexLayout.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/src/.idea/.idea.Acuminator/.idea/vcs.xml b/src/.idea/.idea.Acuminator/.idea/vcs.xml new file mode 100644 index 000000000..6c0b86358 --- /dev/null +++ b/src/.idea/.idea.Acuminator/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file From 4ad366f659d3a59a26e64c4565f3655c7c5f2035 Mon Sep 17 00:00:00 2001 From: Pavel Gelver Date: Sun, 3 May 2026 21:04:45 +0200 Subject: [PATCH 2/5] feat: cache symbol info lookups in NestedInvocationWalker --- .../Roslyn/NestedInvocationWalker.cs | 18 ++++++---- .../Roslyn/SymbolInfoCache.cs | 34 +++++++++++++++++++ 2 files changed, 45 insertions(+), 7 deletions(-) create mode 100644 src/Acuminator/Acuminator.Utilities/Roslyn/SymbolInfoCache.cs diff --git a/src/Acuminator/Acuminator.Utilities/Roslyn/NestedInvocationWalker.cs b/src/Acuminator/Acuminator.Utilities/Roslyn/NestedInvocationWalker.cs index 73d396e43..4719b7e4c 100644 --- a/src/Acuminator/Acuminator.Utilities/Roslyn/NestedInvocationWalker.cs +++ b/src/Acuminator/Acuminator.Utilities/Roslyn/NestedInvocationWalker.cs @@ -53,6 +53,8 @@ public abstract class NestedInvocationWalker : CSharpSyntaxWalker private readonly ISet<(SyntaxNode, DiagnosticDescriptor)> _reportedDiagnostics = new HashSet<(SyntaxNode, DiagnosticDescriptor)>(); + private readonly SymbolInfoCache _cache = new(); + /// /// Cancellation token /// @@ -119,20 +121,22 @@ protected virtual HashSet GetTypesToBypass() => protected virtual T? GetSymbol(ExpressionSyntax node) where T : class, ISymbol { - var semanticModel = GetSemanticModel(node.SyntaxTree); - - if (semanticModel != null) + SymbolInfo? cached = _cache.GetOrCreate(node, () => { - var symbolInfo = semanticModel.GetSymbolInfo(node, CancellationToken); + SemanticModel? semanticModel = GetSemanticModel(node.SyntaxTree); + return semanticModel?.GetSymbolInfo(node, CancellationToken); + }); - if (symbolInfo.Symbol is T symbol) + if (cached is not null) + { + if (cached.Value.Symbol is T symbol) { return symbol; } - if (!symbolInfo.CandidateSymbols.IsEmpty) + if (!cached.Value.CandidateSymbols.IsEmpty) { - return symbolInfo.CandidateSymbols.OfType().FirstOrDefault(); + return cached.Value.CandidateSymbols.OfType().FirstOrDefault(); } } diff --git a/src/Acuminator/Acuminator.Utilities/Roslyn/SymbolInfoCache.cs b/src/Acuminator/Acuminator.Utilities/Roslyn/SymbolInfoCache.cs new file mode 100644 index 000000000..c533580fd --- /dev/null +++ b/src/Acuminator/Acuminator.Utilities/Roslyn/SymbolInfoCache.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Acuminator.Utilities.Roslyn; + +/// +/// Caches Roslyn symbol lookup results for expression syntax nodes visited by a syntax walker. +/// +internal sealed class SymbolInfoCache +{ + private readonly Dictionary _map = new(); + + /// + /// Gets the cached symbol information for the specified expression, or creates and stores it using the provided factory. + /// + public SymbolInfo? GetOrCreate(ExpressionSyntax key, Func factory) + { + if (_map.TryGetValue(key, out SymbolInfo cached)) + { + return cached; + } + + SymbolInfo? potentialValue = factory(); + if (potentialValue is not null) + { + _map[key] = potentialValue.Value; + return potentialValue; + } + + return null; + } +} From 5dfc45cfec7f10fdf5264192e45466c2dc46b78d Mon Sep 17 00:00:00 2001 From: Pavel Gelver Date: Tue, 5 May 2026 17:24:15 +0200 Subject: [PATCH 3/5] feat: add cache statistics --- .../Roslyn/SymbolInfoCache.cs | 113 +++++++++++++++++- 1 file changed, 112 insertions(+), 1 deletion(-) diff --git a/src/Acuminator/Acuminator.Utilities/Roslyn/SymbolInfoCache.cs b/src/Acuminator/Acuminator.Utilities/Roslyn/SymbolInfoCache.cs index c533580fd..91e258c13 100644 --- a/src/Acuminator/Acuminator.Utilities/Roslyn/SymbolInfoCache.cs +++ b/src/Acuminator/Acuminator.Utilities/Roslyn/SymbolInfoCache.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Threading; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -8,9 +9,19 @@ namespace Acuminator.Utilities.Roslyn; /// /// Caches Roslyn symbol lookup results for expression syntax nodes visited by a syntax walker. /// -internal sealed class SymbolInfoCache +public sealed class SymbolInfoCache { private readonly Dictionary _map = new(); +#if SYMBOL_INFO_CACHE_STATISTICS + private readonly SymbolInfoCacheStatistics _statistics; +#endif + + public SymbolInfoCache(Type owner) + { +#if SYMBOL_INFO_CACHE_STATISTICS + _statistics = SymbolInfoCacheStatisticsContext.Register(owner); +#endif + } /// /// Gets the cached symbol information for the specified expression, or creates and stores it using the provided factory. @@ -19,16 +30,116 @@ internal sealed class SymbolInfoCache { if (_map.TryGetValue(key, out SymbolInfo cached)) { +#if SYMBOL_INFO_CACHE_STATISTICS + _statistics.Hit(); +#endif return cached; } +#if SYMBOL_INFO_CACHE_STATISTICS + _statistics.Miss(); +#endif + SymbolInfo? potentialValue = factory(); if (potentialValue is not null) { _map[key] = potentialValue.Value; +#if SYMBOL_INFO_CACHE_STATISTICS + _statistics.Set(key.GetType()); +#endif return potentialValue; } return null; } + +#if SYMBOL_INFO_CACHE_STATISTICS + public sealed class SymbolInfoCacheStatistics + { + private readonly Dictionary _byTypeCounter = new(); + private readonly Type _owner; + private readonly int _cacheId; + + private long _hitsTotal; + private long _missesTotal; + private long _setsTotal; + + public SymbolInfoCacheStatistics(int cacheId, Type owner) + { + _owner = owner; + _cacheId = cacheId; + } + + public void Hit() => _hitsTotal++; + + public void Miss() => _missesTotal++; + + public void Set(Type type) + { + if (!_byTypeCounter.ContainsKey(type)) + { + _byTypeCounter[type] = 1; + } + else + { + _byTypeCounter[type]++; + } + _setsTotal++; + } + + public StatisticsSnapshot GetSnapshot() + { + var lookupsTotal = _hitsTotal + _missesTotal; + return new StatisticsSnapshot( + _cacheId, + _owner, + _hitsTotal, + _missesTotal, + _setsTotal, + _byTypeCounter, + CalcRatio(_hitsTotal, lookupsTotal), + CalcRatio(_missesTotal, lookupsTotal) + ); + } + + private static double CalcRatio(long value, long total) => total == 0 ? 0 : (double)value / total; + } + + public static class SymbolInfoCacheStatisticsContext + { + private static readonly object Gate = new(); + private static readonly List Statistics = new(); + private static int _nextCacheId; + + internal static SymbolInfoCacheStatistics Register(Type owner) + { + var cacheId = Interlocked.Increment(ref _nextCacheId); + var statistics = new SymbolInfoCacheStatistics(cacheId, owner); + + lock (Gate) + { + Statistics.Add(statistics); + } + + return statistics; + } + + public static StatisticsSnapshot[] GetStatistics() + { + lock (Gate) + { + var result = new StatisticsSnapshot[Statistics.Count]; + for (var i = 0; i < Statistics.Count; i++) + { + result[i] = Statistics[i].GetSnapshot(); + } + + return result; + } + } + } + + public record StatisticsSnapshot(int CacheId, Type Owner, long HitsTotal, long MissesTotal, long SetsTotal, Dictionary ByTypeCounter, double HitRatio, double MissRatio); +#endif } + From 8a73725a6c44c91c5136e3cf00f818bd28c1c699 Mon Sep 17 00:00:00 2001 From: Pavel Gelver Date: Tue, 5 May 2026 17:24:15 +0200 Subject: [PATCH 4/5] feat: add cache statistics --- .../Roslyn/NestedInvocationWalker.cs | 4 +- .../Roslyn/SymbolInfoCache.cs | 113 +++++++++++++++++- 2 files changed, 115 insertions(+), 2 deletions(-) diff --git a/src/Acuminator/Acuminator.Utilities/Roslyn/NestedInvocationWalker.cs b/src/Acuminator/Acuminator.Utilities/Roslyn/NestedInvocationWalker.cs index 4719b7e4c..47b2f1433 100644 --- a/src/Acuminator/Acuminator.Utilities/Roslyn/NestedInvocationWalker.cs +++ b/src/Acuminator/Acuminator.Utilities/Roslyn/NestedInvocationWalker.cs @@ -53,7 +53,7 @@ public abstract class NestedInvocationWalker : CSharpSyntaxWalker private readonly ISet<(SyntaxNode, DiagnosticDescriptor)> _reportedDiagnostics = new HashSet<(SyntaxNode, DiagnosticDescriptor)>(); - private readonly SymbolInfoCache _cache = new(); + private readonly SymbolInfoCache _cache; /// /// Cancellation token @@ -89,6 +89,8 @@ protected NestedInvocationWalker(PXContext pxContext, CancellationToken cancella //Use lazy to avoid calling virtual methods inside the constructor _typesToBypass = new Lazy>(valueFactory: GetTypesToBypass, isThreadSafe: false); + + _cache = new SymbolInfoCache(GetType()); } /// diff --git a/src/Acuminator/Acuminator.Utilities/Roslyn/SymbolInfoCache.cs b/src/Acuminator/Acuminator.Utilities/Roslyn/SymbolInfoCache.cs index c533580fd..91e258c13 100644 --- a/src/Acuminator/Acuminator.Utilities/Roslyn/SymbolInfoCache.cs +++ b/src/Acuminator/Acuminator.Utilities/Roslyn/SymbolInfoCache.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Threading; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -8,9 +9,19 @@ namespace Acuminator.Utilities.Roslyn; /// /// Caches Roslyn symbol lookup results for expression syntax nodes visited by a syntax walker. /// -internal sealed class SymbolInfoCache +public sealed class SymbolInfoCache { private readonly Dictionary _map = new(); +#if SYMBOL_INFO_CACHE_STATISTICS + private readonly SymbolInfoCacheStatistics _statistics; +#endif + + public SymbolInfoCache(Type owner) + { +#if SYMBOL_INFO_CACHE_STATISTICS + _statistics = SymbolInfoCacheStatisticsContext.Register(owner); +#endif + } /// /// Gets the cached symbol information for the specified expression, or creates and stores it using the provided factory. @@ -19,16 +30,116 @@ internal sealed class SymbolInfoCache { if (_map.TryGetValue(key, out SymbolInfo cached)) { +#if SYMBOL_INFO_CACHE_STATISTICS + _statistics.Hit(); +#endif return cached; } +#if SYMBOL_INFO_CACHE_STATISTICS + _statistics.Miss(); +#endif + SymbolInfo? potentialValue = factory(); if (potentialValue is not null) { _map[key] = potentialValue.Value; +#if SYMBOL_INFO_CACHE_STATISTICS + _statistics.Set(key.GetType()); +#endif return potentialValue; } return null; } + +#if SYMBOL_INFO_CACHE_STATISTICS + public sealed class SymbolInfoCacheStatistics + { + private readonly Dictionary _byTypeCounter = new(); + private readonly Type _owner; + private readonly int _cacheId; + + private long _hitsTotal; + private long _missesTotal; + private long _setsTotal; + + public SymbolInfoCacheStatistics(int cacheId, Type owner) + { + _owner = owner; + _cacheId = cacheId; + } + + public void Hit() => _hitsTotal++; + + public void Miss() => _missesTotal++; + + public void Set(Type type) + { + if (!_byTypeCounter.ContainsKey(type)) + { + _byTypeCounter[type] = 1; + } + else + { + _byTypeCounter[type]++; + } + _setsTotal++; + } + + public StatisticsSnapshot GetSnapshot() + { + var lookupsTotal = _hitsTotal + _missesTotal; + return new StatisticsSnapshot( + _cacheId, + _owner, + _hitsTotal, + _missesTotal, + _setsTotal, + _byTypeCounter, + CalcRatio(_hitsTotal, lookupsTotal), + CalcRatio(_missesTotal, lookupsTotal) + ); + } + + private static double CalcRatio(long value, long total) => total == 0 ? 0 : (double)value / total; + } + + public static class SymbolInfoCacheStatisticsContext + { + private static readonly object Gate = new(); + private static readonly List Statistics = new(); + private static int _nextCacheId; + + internal static SymbolInfoCacheStatistics Register(Type owner) + { + var cacheId = Interlocked.Increment(ref _nextCacheId); + var statistics = new SymbolInfoCacheStatistics(cacheId, owner); + + lock (Gate) + { + Statistics.Add(statistics); + } + + return statistics; + } + + public static StatisticsSnapshot[] GetStatistics() + { + lock (Gate) + { + var result = new StatisticsSnapshot[Statistics.Count]; + for (var i = 0; i < Statistics.Count; i++) + { + result[i] = Statistics[i].GetSnapshot(); + } + + return result; + } + } + } + + public record StatisticsSnapshot(int CacheId, Type Owner, long HitsTotal, long MissesTotal, long SetsTotal, Dictionary ByTypeCounter, double HitRatio, double MissRatio); +#endif } + From 85f2a69ca02c50d585bc7d7783d2f3cffe973ab1 Mon Sep 17 00:00:00 2001 From: Pavel Gelver Date: Wed, 6 May 2026 18:32:29 +0200 Subject: [PATCH 5/5] feat: ignore invocation expressions in VisitMemberAccessExpression --- .../Roslyn/NestedInvocationWalker.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/Acuminator/Acuminator.Utilities/Roslyn/NestedInvocationWalker.cs b/src/Acuminator/Acuminator.Utilities/Roslyn/NestedInvocationWalker.cs index 47b2f1433..b58c56119 100644 --- a/src/Acuminator/Acuminator.Utilities/Roslyn/NestedInvocationWalker.cs +++ b/src/Acuminator/Acuminator.Utilities/Roslyn/NestedInvocationWalker.cs @@ -210,8 +210,16 @@ public override void VisitInvocationExpression(InvocationExpressionSyntax node) public override void VisitMemberAccessExpression(MemberAccessExpressionSyntax node) { - VisitPropertyOrIndexerAccessExpression(node); - base.VisitMemberAccessExpression(node); + if (node.Parent is InvocationExpressionSyntax invocation && invocation.Expression == node) + { + // we already visit this node by VisitInvocationExpression, so we just skip it here + base.VisitMemberAccessExpression(node); + } + else + { + VisitPropertyOrIndexerAccessExpression(node); + base.VisitMemberAccessExpression(node); + } } public override void VisitElementAccessExpression(ElementAccessExpressionSyntax node)