From 30ded09f5dfda857896f3c17baba96a40358a3d1 Mon Sep 17 00:00:00 2001 From: Paul Irwin Date: Wed, 29 Apr 2026 20:06:46 -0600 Subject: [PATCH 1/9] Implement LuceneDev4000-4002 NoInlining Analyzers & CodeFix (#1097) Adds three Roslyn analyzers under the new Performance category covering [MethodImpl(MethodImplOptions.NoInlining)] usage: - LuceneDev4000: Reports when NoInlining is applied to an interface or abstract method. The MethodImpl attribute is not inherited, so the attribute has no effect on the implementation. - LuceneDev4001: Reports when NoInlining is applied to an empty-bodied method. An empty body cannot appear above any frame in a stack trace, so preventing inlining provides no benefit. - LuceneDev4002: Reports when a method referenced by the 2-argument StackTraceHelper.DoesStackTraceContainMethod(className, methodName) overload is missing NoInlining. Without it, the JIT may inline the method out of the stack trace, silently breaking the check. A code fix is provided for 4000 and 4001 (remove the attribute). 4002 has no automated fix because the diagnostic is reported on a method declaration triggered by an invocation in another scope, which Roslyn treats as a non-local diagnostic and refuses to fix automatically. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...000_4001_4002_NoInliningCodeFixProvider.cs | 98 +++++ .../AnalyzerReleases.Unshipped.md | 15 +- ...eneDev4000_4001_4002_NoInliningAnalyzer.cs | 370 ++++++++++++++++++ .../Resources.resx | 33 ++ .../Utility/Descriptors.LuceneDev4xxx.cs | 57 +++ ...000_4001_4002_NoInliningCodeFixProvider.cs | 122 ++++++ ...eneDev4000_4001_4002_NoInliningAnalyzer.cs | 340 ++++++++++++++++ 7 files changed, 1029 insertions(+), 6 deletions(-) create mode 100644 src/Lucene.Net.CodeAnalysis.Dev.CodeFixes/LuceneDev4xxx/LuceneDev4000_4001_4002_NoInliningCodeFixProvider.cs create mode 100644 src/Lucene.Net.CodeAnalysis.Dev/LuceneDev4xxx/LuceneDev4000_4001_4002_NoInliningAnalyzer.cs create mode 100644 src/Lucene.Net.CodeAnalysis.Dev/Utility/Descriptors.LuceneDev4xxx.cs create mode 100644 tests/Lucene.Net.CodeAnalysis.Dev.CodeFixes.Tests/LuceneDev4xxx/TestLuceneDev4000_4001_4002_NoInliningCodeFixProvider.cs create mode 100644 tests/Lucene.Net.CodeAnalysis.Dev.Tests/LuceneDev4xxx/TestLuceneDev4000_4001_4002_NoInliningAnalyzer.cs diff --git a/src/Lucene.Net.CodeAnalysis.Dev.CodeFixes/LuceneDev4xxx/LuceneDev4000_4001_4002_NoInliningCodeFixProvider.cs b/src/Lucene.Net.CodeAnalysis.Dev.CodeFixes/LuceneDev4xxx/LuceneDev4000_4001_4002_NoInliningCodeFixProvider.cs new file mode 100644 index 0000000..9454595 --- /dev/null +++ b/src/Lucene.Net.CodeAnalysis.Dev.CodeFixes/LuceneDev4xxx/LuceneDev4000_4001_4002_NoInliningCodeFixProvider.cs @@ -0,0 +1,98 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System.Collections.Immutable; +using System.Composition; +using System.Threading; +using System.Threading.Tasks; +using Lucene.Net.CodeAnalysis.Dev.Utility; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Lucene.Net.CodeAnalysis.Dev.CodeFixes.LuceneDev4xxx +{ + [ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(LuceneDev4000_4001_4002_NoInliningCodeFixProvider)), Shared] + public sealed class LuceneDev4000_4001_4002_NoInliningCodeFixProvider : CodeFixProvider + { + private const string TitleRemoveAttribute = "Remove [MethodImpl(MethodImplOptions.NoInlining)]"; + + // Note: LuceneDev4002 has no code fix here. Its diagnostic is reported on the + // referenced method declaration but is triggered from a separate + // StackTraceHelper.DoesStackTraceContainMethod invocation — Roslyn treats this + // as a "non-local" diagnostic, which the code fix pipeline does not permit + // fixing automatically. The IDE still surfaces the warning on the declaration + // and the user adds the attribute manually. + public override ImmutableArray FixableDiagnosticIds => + ImmutableArray.Create( + Descriptors.LuceneDev4000_NoInliningHasNoEffect.Id, + Descriptors.LuceneDev4001_NoInliningOnEmptyMethod.Id); + + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root is null) + return; + + var diagnostic = context.Diagnostics[0]; + var node = root.FindNode(diagnostic.Location.SourceSpan, getInnermostNodeForTie: true); + + var attribute = node as AttributeSyntax + ?? node.FirstAncestorOrSelf(); + if (attribute is null) + return; + + context.RegisterCodeFix( + CodeAction.Create( + TitleRemoveAttribute, + ct => RemoveAttributeAsync(context.Document, attribute, ct), + equivalenceKey: nameof(TitleRemoveAttribute) + diagnostic.Id), + diagnostic); + } + + private static async Task RemoveAttributeAsync( + Document document, + AttributeSyntax attribute, + CancellationToken cancellationToken) + { + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + if (root is null) + return document; + + if (attribute.Parent is AttributeListSyntax attrList) + { + SyntaxNode newRoot; + if (attrList.Attributes.Count == 1) + { + newRoot = root.RemoveNode(attrList, SyntaxRemoveOptions.KeepNoTrivia)!; + } + else + { + var newList = attrList.WithAttributes(attrList.Attributes.Remove(attribute)); + newRoot = root.ReplaceNode(attrList, newList); + } + return document.WithSyntaxRoot(newRoot); + } + + return document; + } + } +} diff --git a/src/Lucene.Net.CodeAnalysis.Dev/AnalyzerReleases.Unshipped.md b/src/Lucene.Net.CodeAnalysis.Dev/AnalyzerReleases.Unshipped.md index 5b384e3..80e7811 100644 --- a/src/Lucene.Net.CodeAnalysis.Dev/AnalyzerReleases.Unshipped.md +++ b/src/Lucene.Net.CodeAnalysis.Dev/AnalyzerReleases.Unshipped.md @@ -4,12 +4,6 @@ Rule ID | Category | Severity | Notes --------------|----------|----------|----------------------------------------------------------------------------------------------------------------------------------------------------------- LuceneDev1007 | Design | Warning | Generic Dictionary indexer should not be used to retrieve values because it may throw KeyNotFoundException (value type value) LuceneDev1008 | Design | Warning | Generic Dictionary indexer should not be used to retrieve values because it may throw KeyNotFoundException (reference type value) -LuceneDev6000 | Usage | Info | IDictionary indexer may be used to retrieve values, but must be checked for null before using the value -LuceneDev6001 | Usage | Error | Missing StringComparison argument in String overloads of StartsWith/EndsWith/IndexOf/LastIndexOf; must use Ordinal/OrdinalIgnoreCase -LuceneDev6002 | Usage | Error | Invalid StringComparison value in String overloads of StartsWith/EndsWith/IndexOf/LastIndexOf; only Ordinal/OrdinalIgnoreCase allowed -LuceneDev6003 | Usage | Warning | Redundant StringComparison.Ordinal argument in Span overloads of StartsWith/EndsWith/IndexOf/LastIndexOf; should be removed -LuceneDev6004 | Usage | Error | Invalid StringComparison value in Span overloads of StartsWith/EndsWith/IndexOf/LastIndexOf; only Ordinal or OrdinalIgnoreCase allowed -LuceneDev6005 | Usage | Info | Single-character string arguments should use the char overload of StartsWith/EndsWith/IndexOf/LastIndexOf instead of a string LuceneDev2000 | Globalization | Warning | Numeric Parse/TryParse without IFormatProvider; specify CultureInfo.InvariantCulture (or CurrentCulture) explicitly LuceneDev2001 | Globalization | Warning | Numeric ToString/TryFormat without IFormatProvider; specify CultureInfo.InvariantCulture (or CurrentCulture) explicitly LuceneDev2002 | Globalization | Warning | System.Convert numeric to/from string without IFormatProvider; specify CultureInfo.InvariantCulture (or CurrentCulture) explicitly @@ -19,3 +13,12 @@ LuceneDev2005 | Globalization | Warning | Numeric value concatenated with string LuceneDev2006 | Globalization | Warning | Numeric value interpolated into string formats using current culture; use FormattableString.Invariant or wrap with .ToString(CultureInfo.InvariantCulture) explicitly LuceneDev2007 | Globalization | Warning | Numeric format/parse passes a non-invariant IFormatProvider; suppress when intentional LuceneDev2008 | Globalization | Disabled | Numeric format/parse passes CultureInfo.InvariantCulture (review-sweep aid; default Info severity, disabled by default) +LuceneDev4000 | Performance | Warning | [MethodImpl(MethodImplOptions.NoInlining)] has no effect on interface or abstract methods (the attribute is not inherited) +LuceneDev4001 | Performance | Warning | [MethodImpl(MethodImplOptions.NoInlining)] should not be used on empty-bodied methods (no benefit, harms performance) +LuceneDev4002 | Performance | Warning | Methods referenced by the 2-argument StackTraceHelper.DoesStackTraceContainMethod overload should be marked [MethodImpl(MethodImplOptions.NoInlining)] when the method body is non-empty +LuceneDev6000 | Usage | Info | IDictionary indexer may be used to retrieve values, but must be checked for null before using the value +LuceneDev6001 | Usage | Error | Missing StringComparison argument in String overloads of StartsWith/EndsWith/IndexOf/LastIndexOf; must use Ordinal/OrdinalIgnoreCase +LuceneDev6002 | Usage | Error | Invalid StringComparison value in String overloads of StartsWith/EndsWith/IndexOf/LastIndexOf; only Ordinal/OrdinalIgnoreCase allowed +LuceneDev6003 | Usage | Warning | Redundant StringComparison.Ordinal argument in Span overloads of StartsWith/EndsWith/IndexOf/LastIndexOf; should be removed +LuceneDev6004 | Usage | Error | Invalid StringComparison value in Span overloads of StartsWith/EndsWith/IndexOf/LastIndexOf; only Ordinal or OrdinalIgnoreCase allowed +LuceneDev6005 | Usage | Info | Single-character string arguments should use the char overload of StartsWith/EndsWith/IndexOf/LastIndexOf instead of a string diff --git a/src/Lucene.Net.CodeAnalysis.Dev/LuceneDev4xxx/LuceneDev4000_4001_4002_NoInliningAnalyzer.cs b/src/Lucene.Net.CodeAnalysis.Dev/LuceneDev4xxx/LuceneDev4000_4001_4002_NoInliningAnalyzer.cs new file mode 100644 index 0000000..2779bc5 --- /dev/null +++ b/src/Lucene.Net.CodeAnalysis.Dev/LuceneDev4xxx/LuceneDev4000_4001_4002_NoInliningAnalyzer.cs @@ -0,0 +1,370 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Lucene.Net.CodeAnalysis.Dev.Utility; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Lucene.Net.CodeAnalysis.Dev.LuceneDev4xxx +{ + /// + /// Analyzer for [MethodImpl(MethodImplOptions.NoInlining)] usage rules: + /// - LuceneDev4000: NoInlining has no effect on interface or abstract methods. + /// - LuceneDev4001: NoInlining on empty-bodied methods provides no benefit. + /// - LuceneDev4002: Methods referenced by StackTraceHelper.DoesStackTraceContainMethod + /// (the 2-argument overload) should be marked NoInlining when the + /// method body is non-empty. + /// + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public sealed class LuceneDev4000_4001_4002_NoInliningAnalyzer : DiagnosticAnalyzer + { + private const string StackTraceHelperFullName = "Lucene.Net.Support.ExceptionHandling.StackTraceHelper"; + private const string DoesStackTraceContainMethodName = "DoesStackTraceContainMethod"; + + public override ImmutableArray SupportedDiagnostics + => ImmutableArray.Create( + Descriptors.LuceneDev4000_NoInliningHasNoEffect, + Descriptors.LuceneDev4001_NoInliningOnEmptyMethod, + Descriptors.LuceneDev4002_MissingNoInlining); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze); + context.EnableConcurrentExecution(); + + context.RegisterCompilationStartAction(compilationCtx => + { + var methodImplAttrSymbol = compilationCtx.Compilation.GetTypeByMetadataName( + "System.Runtime.CompilerServices.MethodImplAttribute"); + + compilationCtx.RegisterSyntaxNodeAction( + ctx => AnalyzeMethodForNoInliningAttribute(ctx, methodImplAttrSymbol), + SyntaxKind.MethodDeclaration); + + compilationCtx.RegisterSyntaxNodeAction( + ctx => AnalyzeStackTraceHelperInvocation(ctx, methodImplAttrSymbol), + SyntaxKind.InvocationExpression); + }); + } + + // ----------------------------------------------------------------- + // 4000 / 4001 — examine method declarations carrying NoInlining + // ----------------------------------------------------------------- + private static void AnalyzeMethodForNoInliningAttribute( + SyntaxNodeAnalysisContext ctx, + INamedTypeSymbol? methodImplAttrSymbol) + { + if (methodImplAttrSymbol is null) + return; + + var methodDecl = (MethodDeclarationSyntax)ctx.Node; + + var attribute = FindNoInliningAttribute(methodDecl, ctx.SemanticModel, methodImplAttrSymbol); + if (attribute is null) + return; + + // 4000: interface or abstract method + if (IsInterfaceOrAbstractMethod(methodDecl)) + { + ctx.ReportDiagnostic(Diagnostic.Create( + Descriptors.LuceneDev4000_NoInliningHasNoEffect, + attribute.GetLocation(), + methodDecl.Identifier.ValueText)); + return; + } + + // 4001: empty-bodied method + if (HasEmptyBody(methodDecl)) + { + ctx.ReportDiagnostic(Diagnostic.Create( + Descriptors.LuceneDev4001_NoInliningOnEmptyMethod, + attribute.GetLocation(), + methodDecl.Identifier.ValueText)); + } + } + + // ----------------------------------------------------------------- + // 4002 — examine StackTraceHelper.DoesStackTraceContainMethod calls + // ----------------------------------------------------------------- + private static void AnalyzeStackTraceHelperInvocation( + SyntaxNodeAnalysisContext ctx, + INamedTypeSymbol? methodImplAttrSymbol) + { + if (methodImplAttrSymbol is null) + return; + + var invocation = (InvocationExpressionSyntax)ctx.Node; + + // Quick syntactic filter + if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess) + return; + if (memberAccess.Name.Identifier.ValueText != DoesStackTraceContainMethodName) + return; + + // Only the 2-argument overload (className, methodName) is in scope per the issue. + if (invocation.ArgumentList.Arguments.Count != 2) + return; + + // Resolve & verify it is the right method. + var symbol = ctx.SemanticModel.GetSymbolInfo(invocation).Symbol as IMethodSymbol; + if (symbol is null) + return; + if (symbol.ContainingType?.ToDisplayString() != StackTraceHelperFullName) + return; + + // Identify the referenced method symbol(s) from the (className, methodName) arguments. + // We prefer the most precise resolution: a `nameof(Type.Method)` expression yields a + // method-group symbol-info with candidate symbols; a string literal we resolve by name. + var classArg = invocation.ArgumentList.Arguments[0].Expression; + var methodArg = invocation.ArgumentList.Arguments[1].Expression; + + var (classNameValue, classTypeFromNameof) = ResolveClassReference(classArg, ctx.SemanticModel); + if (classNameValue is null) + return; + + var methodNameValue = ResolveMethodNameValue(methodArg, ctx.SemanticModel); + if (methodNameValue is null) + return; + + // Find the target type. Prefer the type resolved from nameof(Type), otherwise look up by + // simple name within the compilation's source assembly. + var targetType = classTypeFromNameof + ?? FindSourceTypeByName(ctx.SemanticModel.Compilation, classNameValue); + if (targetType is null) + return; + + // Examine matching methods in the target type (we check all overloads). + foreach (var member in targetType.GetMembers(methodNameValue).OfType()) + { + if (member.MethodKind != MethodKind.Ordinary) + continue; + + // Walk to the method declaration syntax (only consider source-defined methods). + foreach (var declRef in member.DeclaringSyntaxReferences) + { + if (declRef.GetSyntax(ctx.CancellationToken) is not MethodDeclarationSyntax methodDecl) + continue; + + // Skip if the method already carries NoInlining. + if (FindNoInliningAttribute(methodDecl, ctx.SemanticModel, methodImplAttrSymbol) is not null) + continue; + + // Skip empty-bodied methods (no benefit; see issue rationale). + if (HasEmptyBody(methodDecl)) + continue; + + // Skip interface/abstract — nothing to inline. + if (IsInterfaceOrAbstractMethod(methodDecl)) + continue; + + ctx.ReportDiagnostic(Diagnostic.Create( + Descriptors.LuceneDev4002_MissingNoInlining, + methodDecl.GetLocation(), + methodDecl.Identifier.ValueText)); + } + } + } + + // ----------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------- + + private static AttributeSyntax? FindNoInliningAttribute( + MethodDeclarationSyntax methodDecl, + SemanticModel semantic, + INamedTypeSymbol methodImplAttrSymbol) + { + foreach (var attrList in methodDecl.AttributeLists) + { + foreach (var attr in attrList.Attributes) + { + var attrType = semantic.GetTypeInfo(attr).Type as INamedTypeSymbol; + if (attrType is null) + { + // Sometimes GetTypeInfo on AttributeSyntax doesn't resolve cleanly; + // fall back to symbol info on the attribute name. + attrType = semantic.GetSymbolInfo(attr).Symbol?.ContainingType; + } + if (!SymbolEqualityComparer.Default.Equals(attrType, methodImplAttrSymbol)) + continue; + + if (AttributeSpecifiesNoInlining(attr, semantic)) + return attr; + } + } + return null; + } + + private static bool AttributeSpecifiesNoInlining(AttributeSyntax attr, SemanticModel semantic) + { + // [MethodImpl(MethodImplOptions.NoInlining)] + // [MethodImpl((MethodImplOptions)8)] + // [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.AggressiveInlining)] // pathological, still flag + if (attr.ArgumentList is null || attr.ArgumentList.Arguments.Count == 0) + return false; + + // Only the first positional argument controls MethodImplOptions; the second optional + // argument is MethodCodeType. Skip named arguments. + var firstPositional = attr.ArgumentList.Arguments + .FirstOrDefault(a => a.NameEquals is null && a.NameColon is null); + if (firstPositional is null) + return false; + + var constant = semantic.GetConstantValue(firstPositional.Expression); + if (constant.HasValue && constant.Value is int intValue) + { + const int NoInlining = 0x0008; + return (intValue & NoInlining) == NoInlining; + } + + // Fall back to syntactic textual check ("NoInlining" appears in the expression). + return firstPositional.Expression.ToString().Contains("NoInlining"); + } + + private static bool IsInterfaceOrAbstractMethod(MethodDeclarationSyntax methodDecl) + { + if (methodDecl.Parent is InterfaceDeclarationSyntax) + return true; + if (methodDecl.Modifiers.Any(SyntaxKind.AbstractKeyword)) + return true; + return false; + } + + private static bool HasEmptyBody(MethodDeclarationSyntax methodDecl) + { + // Abstract / interface declarations have no body — handled separately. + if (methodDecl.Body is null && methodDecl.ExpressionBody is null) + return false; + + if (methodDecl.ExpressionBody is not null) + return false; // Expression-bodied is by definition non-empty. + + return methodDecl.Body!.Statements.Count == 0; + } + + private static (string? Name, INamedTypeSymbol? TypeFromNameof) ResolveClassReference( + ExpressionSyntax expr, + SemanticModel semantic) + { + // nameof(SomeType) — preferred form, also lets us resolve the type symbol. + if (expr is InvocationExpressionSyntax inv + && inv.Expression is IdentifierNameSyntax id + && id.Identifier.ValueText == "nameof" + && inv.ArgumentList.Arguments.Count == 1) + { + var inner = inv.ArgumentList.Arguments[0].Expression; + var typeSymbol = semantic.GetTypeInfo(inner).Type as INamedTypeSymbol + ?? semantic.GetSymbolInfo(inner).Symbol as INamedTypeSymbol; + if (typeSymbol is not null) + return (typeSymbol.Name, typeSymbol); + + // nameof can also wrap a member access — fall through to literal extraction. + } + + // String literal "ClassName" + if (expr is LiteralExpressionSyntax literal && literal.IsKind(SyntaxKind.StringLiteralExpression)) + return (literal.Token.ValueText, null); + + // Constant-folded expression (e.g., a const string field) + var constant = semantic.GetConstantValue(expr); + if (constant.HasValue && constant.Value is string s) + return (s, null); + + return (null, null); + } + + private static string? ResolveMethodNameValue(ExpressionSyntax expr, SemanticModel semantic) + { + // nameof(Type.Method) or nameof(Method) — extract textual identifier + if (expr is InvocationExpressionSyntax inv + && inv.Expression is IdentifierNameSyntax id + && id.Identifier.ValueText == "nameof" + && inv.ArgumentList.Arguments.Count == 1) + { + var inner = inv.ArgumentList.Arguments[0].Expression; + return ExtractRightmostIdentifier(inner); + } + + // String literal "MethodName" + if (expr is LiteralExpressionSyntax literal && literal.IsKind(SyntaxKind.StringLiteralExpression)) + return literal.Token.ValueText; + + // Constant-folded + var constant = semantic.GetConstantValue(expr); + if (constant.HasValue && constant.Value is string s) + return s; + + return null; + } + + private static string? ExtractRightmostIdentifier(ExpressionSyntax expr) + { + return expr switch + { + IdentifierNameSyntax id => id.Identifier.ValueText, + MemberAccessExpressionSyntax ma => ma.Name.Identifier.ValueText, + _ => null, + }; + } + + private static INamedTypeSymbol? FindSourceTypeByName(Compilation compilation, string typeName) + { + // Look for a type with this simple name within the current compilation's source assembly. + // Exact-name lookup; if multiple match, return the first found. + foreach (var type in EnumerateAllTypes(compilation.Assembly.GlobalNamespace)) + { + if (type.Name == typeName) + return type; + } + return null; + } + + private static IEnumerable EnumerateAllTypes(INamespaceSymbol ns) + { + foreach (var member in ns.GetMembers()) + { + if (member is INamedTypeSymbol type) + { + yield return type; + foreach (var nested in EnumerateNestedTypes(type)) + yield return nested; + } + else if (member is INamespaceSymbol child) + { + foreach (var t in EnumerateAllTypes(child)) + yield return t; + } + } + } + + private static IEnumerable EnumerateNestedTypes(INamedTypeSymbol type) + { + foreach (var nested in type.GetTypeMembers()) + { + yield return nested; + foreach (var deeper in EnumerateNestedTypes(nested)) + yield return deeper; + } + } + } +} diff --git a/src/Lucene.Net.CodeAnalysis.Dev/Resources.resx b/src/Lucene.Net.CodeAnalysis.Dev/Resources.resx index 90216e6..5bfdc35 100644 --- a/src/Lucene.Net.CodeAnalysis.Dev/Resources.resx +++ b/src/Lucene.Net.CodeAnalysis.Dev/Resources.resx @@ -406,4 +406,37 @@ under the License. Call to '{0}' passes CultureInfo.InvariantCulture; verify this matches Lucene's behavior at this site. + + + [MethodImpl(MethodImplOptions.NoInlining)] has no effect on interface or abstract methods + + + The MethodImpl attribute is not inherited, so applying NoInlining to an interface or abstract method declaration has no effect on the eventual implementation. Apply the attribute to the implementing method instead. + + + [MethodImpl(MethodImplOptions.NoInlining)] on '{0}' has no effect because the method is abstract or an interface member; remove the attribute or apply it to the implementation. + + + + + [MethodImpl(MethodImplOptions.NoInlining)] should not be used on empty-bodied methods + + + An empty-bodied method calls nothing, so it cannot appear above any frame in a stack trace. Preventing inlining provides no benefit and only harms performance. + + + [MethodImpl(MethodImplOptions.NoInlining)] on '{0}' has no benefit because the method body is empty; remove the attribute. + + + + + Method referenced by StackTraceHelper.DoesStackTraceContainMethod should be marked NoInlining + + + If a method is referenced by StackTraceHelper.DoesStackTraceContainMethod and the JIT inlines it, the method will not appear in the stack trace and the check will silently fail. Apply [MethodImpl(MethodImplOptions.NoInlining)] to the method. + + + '{0}' is referenced by StackTraceHelper.DoesStackTraceContainMethod and should be marked with [MethodImpl(MethodImplOptions.NoInlining)] to prevent the JIT from inlining it out of the stack trace. + + diff --git a/src/Lucene.Net.CodeAnalysis.Dev/Utility/Descriptors.LuceneDev4xxx.cs b/src/Lucene.Net.CodeAnalysis.Dev/Utility/Descriptors.LuceneDev4xxx.cs new file mode 100644 index 0000000..e29a93d --- /dev/null +++ b/src/Lucene.Net.CodeAnalysis.Dev/Utility/Descriptors.LuceneDev4xxx.cs @@ -0,0 +1,57 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using Microsoft.CodeAnalysis; +using static Microsoft.CodeAnalysis.DiagnosticSeverity; +using static Lucene.Net.CodeAnalysis.Dev.Utility.Category; + +namespace Lucene.Net.CodeAnalysis.Dev.Utility +{ + public static partial class Descriptors + { + // IMPORTANT: Do not make these into properties! + // The AnalyzerReleases release management analyzers do not recognize them + // and will report RS2002 warnings if it cannot read the DiagnosticDescriptor + // instance through a field. + + // 4000: [MethodImpl(MethodImplOptions.NoInlining)] on an interface or abstract method + public static readonly DiagnosticDescriptor LuceneDev4000_NoInliningHasNoEffect = + Diagnostic( + "LuceneDev4000", + Performance, + Warning + ); + + // 4001: [MethodImpl(MethodImplOptions.NoInlining)] on an empty-bodied method + public static readonly DiagnosticDescriptor LuceneDev4001_NoInliningOnEmptyMethod = + Diagnostic( + "LuceneDev4001", + Performance, + Warning + ); + + // 4002: Method referenced by StackTraceHelper.DoesStackTraceContainMethod (2-arg) + // is missing [MethodImpl(MethodImplOptions.NoInlining)] + public static readonly DiagnosticDescriptor LuceneDev4002_MissingNoInlining = + Diagnostic( + "LuceneDev4002", + Performance, + Warning + ); + } +} diff --git a/tests/Lucene.Net.CodeAnalysis.Dev.CodeFixes.Tests/LuceneDev4xxx/TestLuceneDev4000_4001_4002_NoInliningCodeFixProvider.cs b/tests/Lucene.Net.CodeAnalysis.Dev.CodeFixes.Tests/LuceneDev4xxx/TestLuceneDev4000_4001_4002_NoInliningCodeFixProvider.cs new file mode 100644 index 0000000..dc47f5f --- /dev/null +++ b/tests/Lucene.Net.CodeAnalysis.Dev.CodeFixes.Tests/LuceneDev4xxx/TestLuceneDev4000_4001_4002_NoInliningCodeFixProvider.cs @@ -0,0 +1,122 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using Lucene.Net.CodeAnalysis.Dev.CodeFixes.LuceneDev4xxx; +using Lucene.Net.CodeAnalysis.Dev.LuceneDev4xxx; +using Lucene.Net.CodeAnalysis.Dev.TestUtilities; +using Lucene.Net.CodeAnalysis.Dev.Utility; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Testing; +using NUnit.Framework; +using System.Threading.Tasks; + +namespace Lucene.Net.CodeAnalysis.Dev.CodeFixes.Tests.LuceneDev4xxx +{ + [TestFixture] + public class TestLuceneDev4000_4001_4002_NoInliningCodeFixProvider + { + // ----------------------------------------------------------------- + // 4000: remove attribute on interface / abstract method + // ----------------------------------------------------------------- + + [Test] + public async Task Fix_LuceneDev4000_RemovesAttribute_From_InterfaceMethod() + { + var testCode = @" +using System.Runtime.CompilerServices; + +public interface ISample +{ + [MethodImpl(MethodImplOptions.NoInlining)] + void DoWork(); +}"; + + var fixedCode = @" +using System.Runtime.CompilerServices; + +public interface ISample +{ + void DoWork(); +}"; + + var expected = new DiagnosticResult(Descriptors.LuceneDev4000_NoInliningHasNoEffect) + .WithSeverity(DiagnosticSeverity.Warning) + .WithSpan(6, 6, 6, 46) + .WithArguments("DoWork"); + + var test = new InjectableCodeFixTest( + () => new LuceneDev4000_4001_4002_NoInliningAnalyzer(), + () => new LuceneDev4000_4001_4002_NoInliningCodeFixProvider()) + { + TestCode = testCode, + FixedCode = fixedCode, + ExpectedDiagnostics = { expected } + }; + + await test.RunAsync(); + } + + // ----------------------------------------------------------------- + // 4001: remove attribute on empty-bodied method + // ----------------------------------------------------------------- + + [Test] + public async Task Fix_LuceneDev4001_RemovesAttribute_From_EmptyBodiedMethod() + { + var testCode = @" +using System.Runtime.CompilerServices; + +public class Sample +{ + [MethodImpl(MethodImplOptions.NoInlining)] + public void DoWork() + { + } +}"; + + var fixedCode = @" +using System.Runtime.CompilerServices; + +public class Sample +{ + public void DoWork() + { + } +}"; + + var expected = new DiagnosticResult(Descriptors.LuceneDev4001_NoInliningOnEmptyMethod) + .WithSeverity(DiagnosticSeverity.Warning) + .WithSpan(6, 6, 6, 46) + .WithArguments("DoWork"); + + var test = new InjectableCodeFixTest( + () => new LuceneDev4000_4001_4002_NoInliningAnalyzer(), + () => new LuceneDev4000_4001_4002_NoInliningCodeFixProvider()) + { + TestCode = testCode, + FixedCode = fixedCode, + ExpectedDiagnostics = { expected } + }; + + await test.RunAsync(); + } + + // Note: LuceneDev4002 has no code fix (see CodeFixProvider class comment) — + // analyzer-only behavior is exercised in the analyzer tests. + } +} diff --git a/tests/Lucene.Net.CodeAnalysis.Dev.Tests/LuceneDev4xxx/TestLuceneDev4000_4001_4002_NoInliningAnalyzer.cs b/tests/Lucene.Net.CodeAnalysis.Dev.Tests/LuceneDev4xxx/TestLuceneDev4000_4001_4002_NoInliningAnalyzer.cs new file mode 100644 index 0000000..a54b65a --- /dev/null +++ b/tests/Lucene.Net.CodeAnalysis.Dev.Tests/LuceneDev4xxx/TestLuceneDev4000_4001_4002_NoInliningAnalyzer.cs @@ -0,0 +1,340 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using Lucene.Net.CodeAnalysis.Dev.LuceneDev4xxx; +using Lucene.Net.CodeAnalysis.Dev.TestUtilities; +using Lucene.Net.CodeAnalysis.Dev.Utility; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Testing; +using NUnit.Framework; +using System.Threading.Tasks; + +namespace Lucene.Net.CodeAnalysis.Dev.Tests.LuceneDev4xxx +{ + [TestFixture] + public class TestLuceneDev4000_4001_4002_NoInliningAnalyzer + { + // Stub of StackTraceHelper appended to test sources, matching the + // Lucene.Net.Support.ExceptionHandling.StackTraceHelper API surface. + private const string StackTraceHelperStub = @" +namespace Lucene.Net.Support.ExceptionHandling +{ + public static class StackTraceHelper + { + public static bool DoesStackTraceContainMethod(string methodName) => false; + public static bool DoesStackTraceContainMethod(string className, string methodName) => false; + } +} +"; + + // --------------------------------------------------------------------- + // LuceneDev4000: NoInlining on interface / abstract methods + // --------------------------------------------------------------------- + + [Test] + public async Task LuceneDev4000_Reports_When_NoInlining_On_InterfaceMethod() + { + var testCode = @" +using System.Runtime.CompilerServices; + +public interface ISample +{ + [MethodImpl(MethodImplOptions.NoInlining)] + void DoWork(); +}"; + + var expected = new DiagnosticResult(Descriptors.LuceneDev4000_NoInliningHasNoEffect) + .WithSeverity(DiagnosticSeverity.Warning) + .WithSpan(6, 6, 6, 46) + .WithArguments("DoWork"); + + var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev4000_4001_4002_NoInliningAnalyzer()) + { + TestCode = testCode, + ExpectedDiagnostics = { expected } + }; + + await test.RunAsync(); + } + + [Test] + public async Task LuceneDev4000_Reports_When_NoInlining_On_AbstractMethod() + { + var testCode = @" +using System.Runtime.CompilerServices; + +public abstract class Sample +{ + [MethodImpl(MethodImplOptions.NoInlining)] + public abstract void DoWork(); +}"; + + var expected = new DiagnosticResult(Descriptors.LuceneDev4000_NoInliningHasNoEffect) + .WithSeverity(DiagnosticSeverity.Warning) + .WithSpan(6, 6, 6, 46) + .WithArguments("DoWork"); + + var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev4000_4001_4002_NoInliningAnalyzer()) + { + TestCode = testCode, + ExpectedDiagnostics = { expected } + }; + + await test.RunAsync(); + } + + [Test] + public async Task LuceneDev4000_NoDiagnostic_When_NoInlining_On_RegularMethod() + { + var testCode = @" +using System.Runtime.CompilerServices; + +public class Sample +{ + [MethodImpl(MethodImplOptions.NoInlining)] + public void DoWork() + { + var x = 1 + 2; + } +}"; + + var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev4000_4001_4002_NoInliningAnalyzer()) + { + TestCode = testCode + }; + + await test.RunAsync(); + } + + // --------------------------------------------------------------------- + // LuceneDev4001: NoInlining on empty-bodied methods + // --------------------------------------------------------------------- + + [Test] + public async Task LuceneDev4001_Reports_When_NoInlining_On_EmptyBodiedMethod() + { + var testCode = @" +using System.Runtime.CompilerServices; + +public class Sample +{ + [MethodImpl(MethodImplOptions.NoInlining)] + public void DoWork() + { + } +}"; + + var expected = new DiagnosticResult(Descriptors.LuceneDev4001_NoInliningOnEmptyMethod) + .WithSeverity(DiagnosticSeverity.Warning) + .WithSpan(6, 6, 6, 46) + .WithArguments("DoWork"); + + var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev4000_4001_4002_NoInliningAnalyzer()) + { + TestCode = testCode, + ExpectedDiagnostics = { expected } + }; + + await test.RunAsync(); + } + + [Test] + public async Task LuceneDev4001_NoDiagnostic_When_NoInlining_On_ExpressionBodiedThrow() + { + // An expression-bodied method that throws is not "empty" — has a real expression body. + var testCode = @" +using System; +using System.Runtime.CompilerServices; + +public class Sample +{ + [MethodImpl(MethodImplOptions.NoInlining)] + public void DoWork() => throw new InvalidOperationException(); +}"; + + var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev4000_4001_4002_NoInliningAnalyzer()) + { + TestCode = testCode + }; + + await test.RunAsync(); + } + + [Test] + public async Task LuceneDev4001_NoDiagnostic_When_NoInlining_On_NonEmptyMethod() + { + var testCode = @" +using System.Runtime.CompilerServices; + +public class Sample +{ + [MethodImpl(MethodImplOptions.NoInlining)] + public int DoWork() + { + return 42; + } +}"; + + var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev4000_4001_4002_NoInliningAnalyzer()) + { + TestCode = testCode + }; + + await test.RunAsync(); + } + + // --------------------------------------------------------------------- + // LuceneDev4002: Methods referenced in StackTraceHelper.DoesStackTraceContainMethod + // (2-arg form) should have NoInlining when their body is non-empty. + // --------------------------------------------------------------------- + + [Test] + public async Task LuceneDev4002_Reports_When_TargetMethod_Missing_NoInlining() + { + var testCode = @" +public class Target +{ + public void Merge() + { + var x = 1; + } +} + +public class Caller +{ + public void Check() + { + if (Lucene.Net.Support.ExceptionHandling.StackTraceHelper.DoesStackTraceContainMethod(nameof(Target), nameof(Target.Merge))) + { + } + } +}" + StackTraceHelperStub; + + // Diagnostic is reported on the Target.Merge declaration. + var expected = new DiagnosticResult(Descriptors.LuceneDev4002_MissingNoInlining) + .WithSeverity(DiagnosticSeverity.Warning) + .WithSpan(4, 5, 7, 6) + .WithArguments("Merge"); + + var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev4000_4001_4002_NoInliningAnalyzer()) + { + TestCode = testCode, + ExpectedDiagnostics = { expected } + }; + + await test.RunAsync(); + } + + [Test] + public async Task LuceneDev4002_NoDiagnostic_When_TargetMethod_Already_Has_NoInlining() + { + var testCode = @" +using System.Runtime.CompilerServices; + +public class Target +{ + [MethodImpl(MethodImplOptions.NoInlining)] + public void Merge() + { + var x = 1; + } +} + +public class Caller +{ + public void Check() + { + if (Lucene.Net.Support.ExceptionHandling.StackTraceHelper.DoesStackTraceContainMethod(nameof(Target), nameof(Target.Merge))) + { + } + } +}" + StackTraceHelperStub; + + var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev4000_4001_4002_NoInliningAnalyzer()) + { + TestCode = testCode + }; + + await test.RunAsync(); + } + + [Test] + public async Task LuceneDev4002_NoDiagnostic_When_TargetMethod_Has_EmptyBody() + { + // Empty-bodied methods can never appear in a stack trace under the relevant + // call (they call nothing); NoInlining gives no benefit. Don't flag. + var testCode = @" +public class Target +{ + public void Merge() + { + } +} + +public class Caller +{ + public void Check() + { + if (Lucene.Net.Support.ExceptionHandling.StackTraceHelper.DoesStackTraceContainMethod(nameof(Target), nameof(Target.Merge))) + { + } + } +}" + StackTraceHelperStub; + + var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev4000_4001_4002_NoInliningAnalyzer()) + { + TestCode = testCode + }; + + await test.RunAsync(); + } + + [Test] + public async Task LuceneDev4002_NoDiagnostic_For_SingleArgOverload() + { + // The single-arg overload doesn't validate the owning class, so we don't + // require methods referenced through it to have NoInlining. Per the issue, + // only the 2-arg form is in scope. + var testCode = @" +public class Target +{ + public void Merge() + { + var x = 1; + } +} + +public class Caller +{ + public void Check() + { + if (Lucene.Net.Support.ExceptionHandling.StackTraceHelper.DoesStackTraceContainMethod(nameof(Target.Merge))) + { + } + } +}" + StackTraceHelperStub; + + var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev4000_4001_4002_NoInliningAnalyzer()) + { + TestCode = testCode + }; + + await test.RunAsync(); + } + } +} From 3fd68b311e6f6bf00ce324a7cd7d0bac6b58aaca Mon Sep 17 00:00:00 2001 From: Paul Irwin Date: Thu, 30 Apr 2026 15:19:35 -0600 Subject: [PATCH 2/9] Split LuceneDev4002 into its own analyzer class LuceneDev4002 (StackTraceHelper-driven NoInlining requirement) is a distinct rule from 4000/4001 (NoInlining-as-no-op detection): it has a different trigger (invocation vs. method declaration) and no code fix. Extract it into its own analyzer + test class. Shared logic for recognising the [MethodImpl(MethodImplOptions.NoInlining)] attribute, empty bodies, and interface/abstract methods moves into NoInliningAttributeHelper. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...0_4001_NoInliningOnNoOpCodeFixProvider.cs} | 10 +- ...eneDev4000_4001_4002_NoInliningAnalyzer.cs | 370 ------------------ ...neDev4000_4001_NoInliningOnNoOpAnalyzer.cs | 92 +++++ ...4002_StackTraceHelperNoInliningAnalyzer.cs | 224 +++++++++++ .../NoInliningAttributeHelper.cs | 89 +++++ ...0_4001_NoInliningOnNoOpCodeFixProvider.cs} | 13 +- ...eDev4000_4001_NoInliningOnNoOpAnalyzer.cs} | 166 +------- ...4002_StackTraceHelperNoInliningAnalyzer.cs | 178 +++++++++ 8 files changed, 597 insertions(+), 545 deletions(-) rename src/Lucene.Net.CodeAnalysis.Dev.CodeFixes/LuceneDev4xxx/{LuceneDev4000_4001_4002_NoInliningCodeFixProvider.cs => LuceneDev4000_4001_NoInliningOnNoOpCodeFixProvider.cs} (85%) delete mode 100644 src/Lucene.Net.CodeAnalysis.Dev/LuceneDev4xxx/LuceneDev4000_4001_4002_NoInliningAnalyzer.cs create mode 100644 src/Lucene.Net.CodeAnalysis.Dev/LuceneDev4xxx/LuceneDev4000_4001_NoInliningOnNoOpAnalyzer.cs create mode 100644 src/Lucene.Net.CodeAnalysis.Dev/LuceneDev4xxx/LuceneDev4002_StackTraceHelperNoInliningAnalyzer.cs create mode 100644 src/Lucene.Net.CodeAnalysis.Dev/LuceneDev4xxx/NoInliningAttributeHelper.cs rename tests/Lucene.Net.CodeAnalysis.Dev.CodeFixes.Tests/LuceneDev4xxx/{TestLuceneDev4000_4001_4002_NoInliningCodeFixProvider.cs => TestLuceneDev4000_4001_NoInliningOnNoOpCodeFixProvider.cs} (86%) rename tests/Lucene.Net.CodeAnalysis.Dev.Tests/LuceneDev4xxx/{TestLuceneDev4000_4001_4002_NoInliningAnalyzer.cs => TestLuceneDev4000_4001_NoInliningOnNoOpAnalyzer.cs} (53%) create mode 100644 tests/Lucene.Net.CodeAnalysis.Dev.Tests/LuceneDev4xxx/TestLuceneDev4002_StackTraceHelperNoInliningAnalyzer.cs diff --git a/src/Lucene.Net.CodeAnalysis.Dev.CodeFixes/LuceneDev4xxx/LuceneDev4000_4001_4002_NoInliningCodeFixProvider.cs b/src/Lucene.Net.CodeAnalysis.Dev.CodeFixes/LuceneDev4xxx/LuceneDev4000_4001_NoInliningOnNoOpCodeFixProvider.cs similarity index 85% rename from src/Lucene.Net.CodeAnalysis.Dev.CodeFixes/LuceneDev4xxx/LuceneDev4000_4001_4002_NoInliningCodeFixProvider.cs rename to src/Lucene.Net.CodeAnalysis.Dev.CodeFixes/LuceneDev4xxx/LuceneDev4000_4001_NoInliningOnNoOpCodeFixProvider.cs index 9454595..0113e22 100644 --- a/src/Lucene.Net.CodeAnalysis.Dev.CodeFixes/LuceneDev4xxx/LuceneDev4000_4001_4002_NoInliningCodeFixProvider.cs +++ b/src/Lucene.Net.CodeAnalysis.Dev.CodeFixes/LuceneDev4xxx/LuceneDev4000_4001_NoInliningOnNoOpCodeFixProvider.cs @@ -28,17 +28,11 @@ namespace Lucene.Net.CodeAnalysis.Dev.CodeFixes.LuceneDev4xxx { - [ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(LuceneDev4000_4001_4002_NoInliningCodeFixProvider)), Shared] - public sealed class LuceneDev4000_4001_4002_NoInliningCodeFixProvider : CodeFixProvider + [ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(LuceneDev4000_4001_NoInliningOnNoOpCodeFixProvider)), Shared] + public sealed class LuceneDev4000_4001_NoInliningOnNoOpCodeFixProvider : CodeFixProvider { private const string TitleRemoveAttribute = "Remove [MethodImpl(MethodImplOptions.NoInlining)]"; - // Note: LuceneDev4002 has no code fix here. Its diagnostic is reported on the - // referenced method declaration but is triggered from a separate - // StackTraceHelper.DoesStackTraceContainMethod invocation — Roslyn treats this - // as a "non-local" diagnostic, which the code fix pipeline does not permit - // fixing automatically. The IDE still surfaces the warning on the declaration - // and the user adds the attribute manually. public override ImmutableArray FixableDiagnosticIds => ImmutableArray.Create( Descriptors.LuceneDev4000_NoInliningHasNoEffect.Id, diff --git a/src/Lucene.Net.CodeAnalysis.Dev/LuceneDev4xxx/LuceneDev4000_4001_4002_NoInliningAnalyzer.cs b/src/Lucene.Net.CodeAnalysis.Dev/LuceneDev4xxx/LuceneDev4000_4001_4002_NoInliningAnalyzer.cs deleted file mode 100644 index 2779bc5..0000000 --- a/src/Lucene.Net.CodeAnalysis.Dev/LuceneDev4xxx/LuceneDev4000_4001_4002_NoInliningAnalyzer.cs +++ /dev/null @@ -1,370 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using Lucene.Net.CodeAnalysis.Dev.Utility; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; -using Microsoft.CodeAnalysis.Diagnostics; - -namespace Lucene.Net.CodeAnalysis.Dev.LuceneDev4xxx -{ - /// - /// Analyzer for [MethodImpl(MethodImplOptions.NoInlining)] usage rules: - /// - LuceneDev4000: NoInlining has no effect on interface or abstract methods. - /// - LuceneDev4001: NoInlining on empty-bodied methods provides no benefit. - /// - LuceneDev4002: Methods referenced by StackTraceHelper.DoesStackTraceContainMethod - /// (the 2-argument overload) should be marked NoInlining when the - /// method body is non-empty. - /// - [DiagnosticAnalyzer(LanguageNames.CSharp)] - public sealed class LuceneDev4000_4001_4002_NoInliningAnalyzer : DiagnosticAnalyzer - { - private const string StackTraceHelperFullName = "Lucene.Net.Support.ExceptionHandling.StackTraceHelper"; - private const string DoesStackTraceContainMethodName = "DoesStackTraceContainMethod"; - - public override ImmutableArray SupportedDiagnostics - => ImmutableArray.Create( - Descriptors.LuceneDev4000_NoInliningHasNoEffect, - Descriptors.LuceneDev4001_NoInliningOnEmptyMethod, - Descriptors.LuceneDev4002_MissingNoInlining); - - public override void Initialize(AnalysisContext context) - { - context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze); - context.EnableConcurrentExecution(); - - context.RegisterCompilationStartAction(compilationCtx => - { - var methodImplAttrSymbol = compilationCtx.Compilation.GetTypeByMetadataName( - "System.Runtime.CompilerServices.MethodImplAttribute"); - - compilationCtx.RegisterSyntaxNodeAction( - ctx => AnalyzeMethodForNoInliningAttribute(ctx, methodImplAttrSymbol), - SyntaxKind.MethodDeclaration); - - compilationCtx.RegisterSyntaxNodeAction( - ctx => AnalyzeStackTraceHelperInvocation(ctx, methodImplAttrSymbol), - SyntaxKind.InvocationExpression); - }); - } - - // ----------------------------------------------------------------- - // 4000 / 4001 — examine method declarations carrying NoInlining - // ----------------------------------------------------------------- - private static void AnalyzeMethodForNoInliningAttribute( - SyntaxNodeAnalysisContext ctx, - INamedTypeSymbol? methodImplAttrSymbol) - { - if (methodImplAttrSymbol is null) - return; - - var methodDecl = (MethodDeclarationSyntax)ctx.Node; - - var attribute = FindNoInliningAttribute(methodDecl, ctx.SemanticModel, methodImplAttrSymbol); - if (attribute is null) - return; - - // 4000: interface or abstract method - if (IsInterfaceOrAbstractMethod(methodDecl)) - { - ctx.ReportDiagnostic(Diagnostic.Create( - Descriptors.LuceneDev4000_NoInliningHasNoEffect, - attribute.GetLocation(), - methodDecl.Identifier.ValueText)); - return; - } - - // 4001: empty-bodied method - if (HasEmptyBody(methodDecl)) - { - ctx.ReportDiagnostic(Diagnostic.Create( - Descriptors.LuceneDev4001_NoInliningOnEmptyMethod, - attribute.GetLocation(), - methodDecl.Identifier.ValueText)); - } - } - - // ----------------------------------------------------------------- - // 4002 — examine StackTraceHelper.DoesStackTraceContainMethod calls - // ----------------------------------------------------------------- - private static void AnalyzeStackTraceHelperInvocation( - SyntaxNodeAnalysisContext ctx, - INamedTypeSymbol? methodImplAttrSymbol) - { - if (methodImplAttrSymbol is null) - return; - - var invocation = (InvocationExpressionSyntax)ctx.Node; - - // Quick syntactic filter - if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess) - return; - if (memberAccess.Name.Identifier.ValueText != DoesStackTraceContainMethodName) - return; - - // Only the 2-argument overload (className, methodName) is in scope per the issue. - if (invocation.ArgumentList.Arguments.Count != 2) - return; - - // Resolve & verify it is the right method. - var symbol = ctx.SemanticModel.GetSymbolInfo(invocation).Symbol as IMethodSymbol; - if (symbol is null) - return; - if (symbol.ContainingType?.ToDisplayString() != StackTraceHelperFullName) - return; - - // Identify the referenced method symbol(s) from the (className, methodName) arguments. - // We prefer the most precise resolution: a `nameof(Type.Method)` expression yields a - // method-group symbol-info with candidate symbols; a string literal we resolve by name. - var classArg = invocation.ArgumentList.Arguments[0].Expression; - var methodArg = invocation.ArgumentList.Arguments[1].Expression; - - var (classNameValue, classTypeFromNameof) = ResolveClassReference(classArg, ctx.SemanticModel); - if (classNameValue is null) - return; - - var methodNameValue = ResolveMethodNameValue(methodArg, ctx.SemanticModel); - if (methodNameValue is null) - return; - - // Find the target type. Prefer the type resolved from nameof(Type), otherwise look up by - // simple name within the compilation's source assembly. - var targetType = classTypeFromNameof - ?? FindSourceTypeByName(ctx.SemanticModel.Compilation, classNameValue); - if (targetType is null) - return; - - // Examine matching methods in the target type (we check all overloads). - foreach (var member in targetType.GetMembers(methodNameValue).OfType()) - { - if (member.MethodKind != MethodKind.Ordinary) - continue; - - // Walk to the method declaration syntax (only consider source-defined methods). - foreach (var declRef in member.DeclaringSyntaxReferences) - { - if (declRef.GetSyntax(ctx.CancellationToken) is not MethodDeclarationSyntax methodDecl) - continue; - - // Skip if the method already carries NoInlining. - if (FindNoInliningAttribute(methodDecl, ctx.SemanticModel, methodImplAttrSymbol) is not null) - continue; - - // Skip empty-bodied methods (no benefit; see issue rationale). - if (HasEmptyBody(methodDecl)) - continue; - - // Skip interface/abstract — nothing to inline. - if (IsInterfaceOrAbstractMethod(methodDecl)) - continue; - - ctx.ReportDiagnostic(Diagnostic.Create( - Descriptors.LuceneDev4002_MissingNoInlining, - methodDecl.GetLocation(), - methodDecl.Identifier.ValueText)); - } - } - } - - // ----------------------------------------------------------------- - // Helpers - // ----------------------------------------------------------------- - - private static AttributeSyntax? FindNoInliningAttribute( - MethodDeclarationSyntax methodDecl, - SemanticModel semantic, - INamedTypeSymbol methodImplAttrSymbol) - { - foreach (var attrList in methodDecl.AttributeLists) - { - foreach (var attr in attrList.Attributes) - { - var attrType = semantic.GetTypeInfo(attr).Type as INamedTypeSymbol; - if (attrType is null) - { - // Sometimes GetTypeInfo on AttributeSyntax doesn't resolve cleanly; - // fall back to symbol info on the attribute name. - attrType = semantic.GetSymbolInfo(attr).Symbol?.ContainingType; - } - if (!SymbolEqualityComparer.Default.Equals(attrType, methodImplAttrSymbol)) - continue; - - if (AttributeSpecifiesNoInlining(attr, semantic)) - return attr; - } - } - return null; - } - - private static bool AttributeSpecifiesNoInlining(AttributeSyntax attr, SemanticModel semantic) - { - // [MethodImpl(MethodImplOptions.NoInlining)] - // [MethodImpl((MethodImplOptions)8)] - // [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.AggressiveInlining)] // pathological, still flag - if (attr.ArgumentList is null || attr.ArgumentList.Arguments.Count == 0) - return false; - - // Only the first positional argument controls MethodImplOptions; the second optional - // argument is MethodCodeType. Skip named arguments. - var firstPositional = attr.ArgumentList.Arguments - .FirstOrDefault(a => a.NameEquals is null && a.NameColon is null); - if (firstPositional is null) - return false; - - var constant = semantic.GetConstantValue(firstPositional.Expression); - if (constant.HasValue && constant.Value is int intValue) - { - const int NoInlining = 0x0008; - return (intValue & NoInlining) == NoInlining; - } - - // Fall back to syntactic textual check ("NoInlining" appears in the expression). - return firstPositional.Expression.ToString().Contains("NoInlining"); - } - - private static bool IsInterfaceOrAbstractMethod(MethodDeclarationSyntax methodDecl) - { - if (methodDecl.Parent is InterfaceDeclarationSyntax) - return true; - if (methodDecl.Modifiers.Any(SyntaxKind.AbstractKeyword)) - return true; - return false; - } - - private static bool HasEmptyBody(MethodDeclarationSyntax methodDecl) - { - // Abstract / interface declarations have no body — handled separately. - if (methodDecl.Body is null && methodDecl.ExpressionBody is null) - return false; - - if (methodDecl.ExpressionBody is not null) - return false; // Expression-bodied is by definition non-empty. - - return methodDecl.Body!.Statements.Count == 0; - } - - private static (string? Name, INamedTypeSymbol? TypeFromNameof) ResolveClassReference( - ExpressionSyntax expr, - SemanticModel semantic) - { - // nameof(SomeType) — preferred form, also lets us resolve the type symbol. - if (expr is InvocationExpressionSyntax inv - && inv.Expression is IdentifierNameSyntax id - && id.Identifier.ValueText == "nameof" - && inv.ArgumentList.Arguments.Count == 1) - { - var inner = inv.ArgumentList.Arguments[0].Expression; - var typeSymbol = semantic.GetTypeInfo(inner).Type as INamedTypeSymbol - ?? semantic.GetSymbolInfo(inner).Symbol as INamedTypeSymbol; - if (typeSymbol is not null) - return (typeSymbol.Name, typeSymbol); - - // nameof can also wrap a member access — fall through to literal extraction. - } - - // String literal "ClassName" - if (expr is LiteralExpressionSyntax literal && literal.IsKind(SyntaxKind.StringLiteralExpression)) - return (literal.Token.ValueText, null); - - // Constant-folded expression (e.g., a const string field) - var constant = semantic.GetConstantValue(expr); - if (constant.HasValue && constant.Value is string s) - return (s, null); - - return (null, null); - } - - private static string? ResolveMethodNameValue(ExpressionSyntax expr, SemanticModel semantic) - { - // nameof(Type.Method) or nameof(Method) — extract textual identifier - if (expr is InvocationExpressionSyntax inv - && inv.Expression is IdentifierNameSyntax id - && id.Identifier.ValueText == "nameof" - && inv.ArgumentList.Arguments.Count == 1) - { - var inner = inv.ArgumentList.Arguments[0].Expression; - return ExtractRightmostIdentifier(inner); - } - - // String literal "MethodName" - if (expr is LiteralExpressionSyntax literal && literal.IsKind(SyntaxKind.StringLiteralExpression)) - return literal.Token.ValueText; - - // Constant-folded - var constant = semantic.GetConstantValue(expr); - if (constant.HasValue && constant.Value is string s) - return s; - - return null; - } - - private static string? ExtractRightmostIdentifier(ExpressionSyntax expr) - { - return expr switch - { - IdentifierNameSyntax id => id.Identifier.ValueText, - MemberAccessExpressionSyntax ma => ma.Name.Identifier.ValueText, - _ => null, - }; - } - - private static INamedTypeSymbol? FindSourceTypeByName(Compilation compilation, string typeName) - { - // Look for a type with this simple name within the current compilation's source assembly. - // Exact-name lookup; if multiple match, return the first found. - foreach (var type in EnumerateAllTypes(compilation.Assembly.GlobalNamespace)) - { - if (type.Name == typeName) - return type; - } - return null; - } - - private static IEnumerable EnumerateAllTypes(INamespaceSymbol ns) - { - foreach (var member in ns.GetMembers()) - { - if (member is INamedTypeSymbol type) - { - yield return type; - foreach (var nested in EnumerateNestedTypes(type)) - yield return nested; - } - else if (member is INamespaceSymbol child) - { - foreach (var t in EnumerateAllTypes(child)) - yield return t; - } - } - } - - private static IEnumerable EnumerateNestedTypes(INamedTypeSymbol type) - { - foreach (var nested in type.GetTypeMembers()) - { - yield return nested; - foreach (var deeper in EnumerateNestedTypes(nested)) - yield return deeper; - } - } - } -} diff --git a/src/Lucene.Net.CodeAnalysis.Dev/LuceneDev4xxx/LuceneDev4000_4001_NoInliningOnNoOpAnalyzer.cs b/src/Lucene.Net.CodeAnalysis.Dev/LuceneDev4xxx/LuceneDev4000_4001_NoInliningOnNoOpAnalyzer.cs new file mode 100644 index 0000000..ea50d8c --- /dev/null +++ b/src/Lucene.Net.CodeAnalysis.Dev/LuceneDev4xxx/LuceneDev4000_4001_NoInliningOnNoOpAnalyzer.cs @@ -0,0 +1,92 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System.Collections.Immutable; +using System.Linq; +using Lucene.Net.CodeAnalysis.Dev.Utility; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Lucene.Net.CodeAnalysis.Dev.LuceneDev4xxx +{ + /// + /// Reports cases where [MethodImpl(MethodImplOptions.NoInlining)] is applied but + /// has no useful effect: + /// - LuceneDev4000: on an interface or abstract method (the attribute is not + /// inherited, so it has no effect on the implementation). + /// - LuceneDev4001: on an empty-bodied method (it cannot appear above any + /// stack frame, so preventing inlining gives no benefit). + /// + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public sealed class LuceneDev4000_4001_NoInliningOnNoOpAnalyzer : DiagnosticAnalyzer + { + public override ImmutableArray SupportedDiagnostics + => ImmutableArray.Create( + Descriptors.LuceneDev4000_NoInliningHasNoEffect, + Descriptors.LuceneDev4001_NoInliningOnEmptyMethod); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze); + context.EnableConcurrentExecution(); + + context.RegisterCompilationStartAction(compilationCtx => + { + var methodImplAttrSymbol = compilationCtx.Compilation.GetTypeByMetadataName( + "System.Runtime.CompilerServices.MethodImplAttribute"); + if (methodImplAttrSymbol is null) + return; + + compilationCtx.RegisterSyntaxNodeAction( + ctx => Analyze(ctx, methodImplAttrSymbol), + SyntaxKind.MethodDeclaration); + }); + } + + private static void Analyze(SyntaxNodeAnalysisContext ctx, INamedTypeSymbol methodImplAttrSymbol) + { + var methodDecl = (MethodDeclarationSyntax)ctx.Node; + + var attribute = NoInliningAttributeHelper.FindNoInliningAttribute( + methodDecl, ctx.SemanticModel, methodImplAttrSymbol); + if (attribute is null) + return; + + // 4000: interface or abstract method + if (NoInliningAttributeHelper.IsInterfaceOrAbstractMethod(methodDecl)) + { + ctx.ReportDiagnostic(Diagnostic.Create( + Descriptors.LuceneDev4000_NoInliningHasNoEffect, + attribute.GetLocation(), + methodDecl.Identifier.ValueText)); + return; + } + + // 4001: empty-bodied method + if (NoInliningAttributeHelper.HasEmptyBody(methodDecl)) + { + ctx.ReportDiagnostic(Diagnostic.Create( + Descriptors.LuceneDev4001_NoInliningOnEmptyMethod, + attribute.GetLocation(), + methodDecl.Identifier.ValueText)); + } + } + } +} diff --git a/src/Lucene.Net.CodeAnalysis.Dev/LuceneDev4xxx/LuceneDev4002_StackTraceHelperNoInliningAnalyzer.cs b/src/Lucene.Net.CodeAnalysis.Dev/LuceneDev4xxx/LuceneDev4002_StackTraceHelperNoInliningAnalyzer.cs new file mode 100644 index 0000000..eb17532 --- /dev/null +++ b/src/Lucene.Net.CodeAnalysis.Dev/LuceneDev4xxx/LuceneDev4002_StackTraceHelperNoInliningAnalyzer.cs @@ -0,0 +1,224 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Lucene.Net.CodeAnalysis.Dev.Utility; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Lucene.Net.CodeAnalysis.Dev.LuceneDev4xxx +{ + /// + /// LuceneDev4002: Reports methods referenced by the 2-argument + /// StackTraceHelper.DoesStackTraceContainMethod(className, methodName) overload + /// that lack [MethodImpl(MethodImplOptions.NoInlining)]. Without it the JIT may + /// inline the method out of the stack trace, silently breaking the check. + /// + /// This analyzer has no code fix: the diagnostic is reported on the referenced + /// method declaration but is triggered by a separate invocation, which Roslyn + /// treats as a non-local diagnostic and does not allow code fixes for. + /// + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public sealed class LuceneDev4002_StackTraceHelperNoInliningAnalyzer : DiagnosticAnalyzer + { + private const string StackTraceHelperFullName = "Lucene.Net.Support.ExceptionHandling.StackTraceHelper"; + private const string DoesStackTraceContainMethodName = "DoesStackTraceContainMethod"; + + public override ImmutableArray SupportedDiagnostics + => ImmutableArray.Create(Descriptors.LuceneDev4002_MissingNoInlining); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze); + context.EnableConcurrentExecution(); + + context.RegisterCompilationStartAction(compilationCtx => + { + var methodImplAttrSymbol = compilationCtx.Compilation.GetTypeByMetadataName( + "System.Runtime.CompilerServices.MethodImplAttribute"); + if (methodImplAttrSymbol is null) + return; + + compilationCtx.RegisterSyntaxNodeAction( + ctx => AnalyzeInvocation(ctx, methodImplAttrSymbol), + SyntaxKind.InvocationExpression); + }); + } + + private static void AnalyzeInvocation(SyntaxNodeAnalysisContext ctx, INamedTypeSymbol methodImplAttrSymbol) + { + var invocation = (InvocationExpressionSyntax)ctx.Node; + + if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess) + return; + if (memberAccess.Name.Identifier.ValueText != DoesStackTraceContainMethodName) + return; + + // Only the 2-argument overload (className, methodName) is in scope. + if (invocation.ArgumentList.Arguments.Count != 2) + return; + + var symbol = ctx.SemanticModel.GetSymbolInfo(invocation).Symbol as IMethodSymbol; + if (symbol is null) + return; + if (symbol.ContainingType?.ToDisplayString() != StackTraceHelperFullName) + return; + + var classArg = invocation.ArgumentList.Arguments[0].Expression; + var methodArg = invocation.ArgumentList.Arguments[1].Expression; + + var (classNameValue, classTypeFromNameof) = ResolveClassReference(classArg, ctx.SemanticModel); + if (classNameValue is null) + return; + + var methodNameValue = ResolveMethodNameValue(methodArg, ctx.SemanticModel); + if (methodNameValue is null) + return; + + var targetType = classTypeFromNameof + ?? FindSourceTypeByName(ctx.SemanticModel.Compilation, classNameValue); + if (targetType is null) + return; + + foreach (var member in targetType.GetMembers(methodNameValue).OfType()) + { + if (member.MethodKind != MethodKind.Ordinary) + continue; + + foreach (var declRef in member.DeclaringSyntaxReferences) + { + if (declRef.GetSyntax(ctx.CancellationToken) is not MethodDeclarationSyntax methodDecl) + continue; + + if (NoInliningAttributeHelper.FindNoInliningAttribute(methodDecl, ctx.SemanticModel, methodImplAttrSymbol) is not null) + continue; + + if (NoInliningAttributeHelper.HasEmptyBody(methodDecl)) + continue; + + if (NoInliningAttributeHelper.IsInterfaceOrAbstractMethod(methodDecl)) + continue; + + ctx.ReportDiagnostic(Diagnostic.Create( + Descriptors.LuceneDev4002_MissingNoInlining, + methodDecl.GetLocation(), + methodDecl.Identifier.ValueText)); + } + } + } + + private static (string? Name, INamedTypeSymbol? TypeFromNameof) ResolveClassReference( + ExpressionSyntax expr, + SemanticModel semantic) + { + if (expr is InvocationExpressionSyntax inv + && inv.Expression is IdentifierNameSyntax id + && id.Identifier.ValueText == "nameof" + && inv.ArgumentList.Arguments.Count == 1) + { + var inner = inv.ArgumentList.Arguments[0].Expression; + var typeSymbol = semantic.GetTypeInfo(inner).Type as INamedTypeSymbol + ?? semantic.GetSymbolInfo(inner).Symbol as INamedTypeSymbol; + if (typeSymbol is not null) + return (typeSymbol.Name, typeSymbol); + } + + if (expr is LiteralExpressionSyntax literal && literal.IsKind(SyntaxKind.StringLiteralExpression)) + return (literal.Token.ValueText, null); + + var constant = semantic.GetConstantValue(expr); + if (constant.HasValue && constant.Value is string s) + return (s, null); + + return (null, null); + } + + private static string? ResolveMethodNameValue(ExpressionSyntax expr, SemanticModel semantic) + { + if (expr is InvocationExpressionSyntax inv + && inv.Expression is IdentifierNameSyntax id + && id.Identifier.ValueText == "nameof" + && inv.ArgumentList.Arguments.Count == 1) + { + var inner = inv.ArgumentList.Arguments[0].Expression; + return ExtractRightmostIdentifier(inner); + } + + if (expr is LiteralExpressionSyntax literal && literal.IsKind(SyntaxKind.StringLiteralExpression)) + return literal.Token.ValueText; + + var constant = semantic.GetConstantValue(expr); + if (constant.HasValue && constant.Value is string s) + return s; + + return null; + } + + private static string? ExtractRightmostIdentifier(ExpressionSyntax expr) + { + return expr switch + { + IdentifierNameSyntax id => id.Identifier.ValueText, + MemberAccessExpressionSyntax ma => ma.Name.Identifier.ValueText, + _ => null, + }; + } + + private static INamedTypeSymbol? FindSourceTypeByName(Compilation compilation, string typeName) + { + foreach (var type in EnumerateAllTypes(compilation.Assembly.GlobalNamespace)) + { + if (type.Name == typeName) + return type; + } + return null; + } + + private static IEnumerable EnumerateAllTypes(INamespaceSymbol ns) + { + foreach (var member in ns.GetMembers()) + { + if (member is INamedTypeSymbol type) + { + yield return type; + foreach (var nested in EnumerateNestedTypes(type)) + yield return nested; + } + else if (member is INamespaceSymbol child) + { + foreach (var t in EnumerateAllTypes(child)) + yield return t; + } + } + } + + private static IEnumerable EnumerateNestedTypes(INamedTypeSymbol type) + { + foreach (var nested in type.GetTypeMembers()) + { + yield return nested; + foreach (var deeper in EnumerateNestedTypes(nested)) + yield return deeper; + } + } + } +} diff --git a/src/Lucene.Net.CodeAnalysis.Dev/LuceneDev4xxx/NoInliningAttributeHelper.cs b/src/Lucene.Net.CodeAnalysis.Dev/LuceneDev4xxx/NoInliningAttributeHelper.cs new file mode 100644 index 0000000..030d5e7 --- /dev/null +++ b/src/Lucene.Net.CodeAnalysis.Dev/LuceneDev4xxx/NoInliningAttributeHelper.cs @@ -0,0 +1,89 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Lucene.Net.CodeAnalysis.Dev.LuceneDev4xxx +{ + internal static class NoInliningAttributeHelper + { + public static AttributeSyntax? FindNoInliningAttribute( + MethodDeclarationSyntax methodDecl, + SemanticModel semantic, + INamedTypeSymbol methodImplAttrSymbol) + { + foreach (var attrList in methodDecl.AttributeLists) + { + foreach (var attr in attrList.Attributes) + { + var attrType = semantic.GetTypeInfo(attr).Type as INamedTypeSymbol + ?? semantic.GetSymbolInfo(attr).Symbol?.ContainingType; + if (!SymbolEqualityComparer.Default.Equals(attrType, methodImplAttrSymbol)) + continue; + + if (AttributeSpecifiesNoInlining(attr, semantic)) + return attr; + } + } + return null; + } + + private static bool AttributeSpecifiesNoInlining(AttributeSyntax attr, SemanticModel semantic) + { + if (attr.ArgumentList is null || attr.ArgumentList.Arguments.Count == 0) + return false; + + // Only the first positional argument controls MethodImplOptions; the second + // optional argument is MethodCodeType. Skip named arguments. + var firstPositional = attr.ArgumentList.Arguments + .FirstOrDefault(a => a.NameEquals is null && a.NameColon is null); + if (firstPositional is null) + return false; + + var constant = semantic.GetConstantValue(firstPositional.Expression); + if (constant.HasValue && constant.Value is int intValue) + { + const int NoInlining = 0x0008; + return (intValue & NoInlining) == NoInlining; + } + + return firstPositional.Expression.ToString().Contains("NoInlining"); + } + + public static bool IsInterfaceOrAbstractMethod(MethodDeclarationSyntax methodDecl) + { + if (methodDecl.Parent is InterfaceDeclarationSyntax) + return true; + if (methodDecl.Modifiers.Any(SyntaxKind.AbstractKeyword)) + return true; + return false; + } + + public static bool HasEmptyBody(MethodDeclarationSyntax methodDecl) + { + if (methodDecl.Body is null && methodDecl.ExpressionBody is null) + return false; + if (methodDecl.ExpressionBody is not null) + return false; + return methodDecl.Body!.Statements.Count == 0; + } + } +} diff --git a/tests/Lucene.Net.CodeAnalysis.Dev.CodeFixes.Tests/LuceneDev4xxx/TestLuceneDev4000_4001_4002_NoInliningCodeFixProvider.cs b/tests/Lucene.Net.CodeAnalysis.Dev.CodeFixes.Tests/LuceneDev4xxx/TestLuceneDev4000_4001_NoInliningOnNoOpCodeFixProvider.cs similarity index 86% rename from tests/Lucene.Net.CodeAnalysis.Dev.CodeFixes.Tests/LuceneDev4xxx/TestLuceneDev4000_4001_4002_NoInliningCodeFixProvider.cs rename to tests/Lucene.Net.CodeAnalysis.Dev.CodeFixes.Tests/LuceneDev4xxx/TestLuceneDev4000_4001_NoInliningOnNoOpCodeFixProvider.cs index dc47f5f..c79eb92 100644 --- a/tests/Lucene.Net.CodeAnalysis.Dev.CodeFixes.Tests/LuceneDev4xxx/TestLuceneDev4000_4001_4002_NoInliningCodeFixProvider.cs +++ b/tests/Lucene.Net.CodeAnalysis.Dev.CodeFixes.Tests/LuceneDev4xxx/TestLuceneDev4000_4001_NoInliningOnNoOpCodeFixProvider.cs @@ -28,7 +28,7 @@ namespace Lucene.Net.CodeAnalysis.Dev.CodeFixes.Tests.LuceneDev4xxx { [TestFixture] - public class TestLuceneDev4000_4001_4002_NoInliningCodeFixProvider + public class TestLuceneDev4000_4001_NoInliningOnNoOpCodeFixProvider { // ----------------------------------------------------------------- // 4000: remove attribute on interface / abstract method @@ -60,8 +60,8 @@ public interface ISample .WithArguments("DoWork"); var test = new InjectableCodeFixTest( - () => new LuceneDev4000_4001_4002_NoInliningAnalyzer(), - () => new LuceneDev4000_4001_4002_NoInliningCodeFixProvider()) + () => new LuceneDev4000_4001_NoInliningOnNoOpAnalyzer(), + () => new LuceneDev4000_4001_NoInliningOnNoOpCodeFixProvider()) { TestCode = testCode, FixedCode = fixedCode, @@ -105,8 +105,8 @@ public void DoWork() .WithArguments("DoWork"); var test = new InjectableCodeFixTest( - () => new LuceneDev4000_4001_4002_NoInliningAnalyzer(), - () => new LuceneDev4000_4001_4002_NoInliningCodeFixProvider()) + () => new LuceneDev4000_4001_NoInliningOnNoOpAnalyzer(), + () => new LuceneDev4000_4001_NoInliningOnNoOpCodeFixProvider()) { TestCode = testCode, FixedCode = fixedCode, @@ -115,8 +115,5 @@ public void DoWork() await test.RunAsync(); } - - // Note: LuceneDev4002 has no code fix (see CodeFixProvider class comment) — - // analyzer-only behavior is exercised in the analyzer tests. } } diff --git a/tests/Lucene.Net.CodeAnalysis.Dev.Tests/LuceneDev4xxx/TestLuceneDev4000_4001_4002_NoInliningAnalyzer.cs b/tests/Lucene.Net.CodeAnalysis.Dev.Tests/LuceneDev4xxx/TestLuceneDev4000_4001_NoInliningOnNoOpAnalyzer.cs similarity index 53% rename from tests/Lucene.Net.CodeAnalysis.Dev.Tests/LuceneDev4xxx/TestLuceneDev4000_4001_4002_NoInliningAnalyzer.cs rename to tests/Lucene.Net.CodeAnalysis.Dev.Tests/LuceneDev4xxx/TestLuceneDev4000_4001_NoInliningOnNoOpAnalyzer.cs index a54b65a..ee08917 100644 --- a/tests/Lucene.Net.CodeAnalysis.Dev.Tests/LuceneDev4xxx/TestLuceneDev4000_4001_4002_NoInliningAnalyzer.cs +++ b/tests/Lucene.Net.CodeAnalysis.Dev.Tests/LuceneDev4xxx/TestLuceneDev4000_4001_NoInliningOnNoOpAnalyzer.cs @@ -27,21 +27,8 @@ namespace Lucene.Net.CodeAnalysis.Dev.Tests.LuceneDev4xxx { [TestFixture] - public class TestLuceneDev4000_4001_4002_NoInliningAnalyzer + public class TestLuceneDev4000_4001_NoInliningOnNoOpAnalyzer { - // Stub of StackTraceHelper appended to test sources, matching the - // Lucene.Net.Support.ExceptionHandling.StackTraceHelper API surface. - private const string StackTraceHelperStub = @" -namespace Lucene.Net.Support.ExceptionHandling -{ - public static class StackTraceHelper - { - public static bool DoesStackTraceContainMethod(string methodName) => false; - public static bool DoesStackTraceContainMethod(string className, string methodName) => false; - } -} -"; - // --------------------------------------------------------------------- // LuceneDev4000: NoInlining on interface / abstract methods // --------------------------------------------------------------------- @@ -63,7 +50,7 @@ public interface ISample .WithSpan(6, 6, 6, 46) .WithArguments("DoWork"); - var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev4000_4001_4002_NoInliningAnalyzer()) + var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev4000_4001_NoInliningOnNoOpAnalyzer()) { TestCode = testCode, ExpectedDiagnostics = { expected } @@ -89,7 +76,7 @@ public abstract class Sample .WithSpan(6, 6, 6, 46) .WithArguments("DoWork"); - var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev4000_4001_4002_NoInliningAnalyzer()) + var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev4000_4001_NoInliningOnNoOpAnalyzer()) { TestCode = testCode, ExpectedDiagnostics = { expected } @@ -113,7 +100,7 @@ public void DoWork() } }"; - var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev4000_4001_4002_NoInliningAnalyzer()) + var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev4000_4001_NoInliningOnNoOpAnalyzer()) { TestCode = testCode }; @@ -144,7 +131,7 @@ public void DoWork() .WithSpan(6, 6, 6, 46) .WithArguments("DoWork"); - var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev4000_4001_4002_NoInliningAnalyzer()) + var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev4000_4001_NoInliningOnNoOpAnalyzer()) { TestCode = testCode, ExpectedDiagnostics = { expected } @@ -167,7 +154,7 @@ public class Sample public void DoWork() => throw new InvalidOperationException(); }"; - var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev4000_4001_4002_NoInliningAnalyzer()) + var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev4000_4001_NoInliningOnNoOpAnalyzer()) { TestCode = testCode }; @@ -190,146 +177,7 @@ public int DoWork() } }"; - var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev4000_4001_4002_NoInliningAnalyzer()) - { - TestCode = testCode - }; - - await test.RunAsync(); - } - - // --------------------------------------------------------------------- - // LuceneDev4002: Methods referenced in StackTraceHelper.DoesStackTraceContainMethod - // (2-arg form) should have NoInlining when their body is non-empty. - // --------------------------------------------------------------------- - - [Test] - public async Task LuceneDev4002_Reports_When_TargetMethod_Missing_NoInlining() - { - var testCode = @" -public class Target -{ - public void Merge() - { - var x = 1; - } -} - -public class Caller -{ - public void Check() - { - if (Lucene.Net.Support.ExceptionHandling.StackTraceHelper.DoesStackTraceContainMethod(nameof(Target), nameof(Target.Merge))) - { - } - } -}" + StackTraceHelperStub; - - // Diagnostic is reported on the Target.Merge declaration. - var expected = new DiagnosticResult(Descriptors.LuceneDev4002_MissingNoInlining) - .WithSeverity(DiagnosticSeverity.Warning) - .WithSpan(4, 5, 7, 6) - .WithArguments("Merge"); - - var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev4000_4001_4002_NoInliningAnalyzer()) - { - TestCode = testCode, - ExpectedDiagnostics = { expected } - }; - - await test.RunAsync(); - } - - [Test] - public async Task LuceneDev4002_NoDiagnostic_When_TargetMethod_Already_Has_NoInlining() - { - var testCode = @" -using System.Runtime.CompilerServices; - -public class Target -{ - [MethodImpl(MethodImplOptions.NoInlining)] - public void Merge() - { - var x = 1; - } -} - -public class Caller -{ - public void Check() - { - if (Lucene.Net.Support.ExceptionHandling.StackTraceHelper.DoesStackTraceContainMethod(nameof(Target), nameof(Target.Merge))) - { - } - } -}" + StackTraceHelperStub; - - var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev4000_4001_4002_NoInliningAnalyzer()) - { - TestCode = testCode - }; - - await test.RunAsync(); - } - - [Test] - public async Task LuceneDev4002_NoDiagnostic_When_TargetMethod_Has_EmptyBody() - { - // Empty-bodied methods can never appear in a stack trace under the relevant - // call (they call nothing); NoInlining gives no benefit. Don't flag. - var testCode = @" -public class Target -{ - public void Merge() - { - } -} - -public class Caller -{ - public void Check() - { - if (Lucene.Net.Support.ExceptionHandling.StackTraceHelper.DoesStackTraceContainMethod(nameof(Target), nameof(Target.Merge))) - { - } - } -}" + StackTraceHelperStub; - - var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev4000_4001_4002_NoInliningAnalyzer()) - { - TestCode = testCode - }; - - await test.RunAsync(); - } - - [Test] - public async Task LuceneDev4002_NoDiagnostic_For_SingleArgOverload() - { - // The single-arg overload doesn't validate the owning class, so we don't - // require methods referenced through it to have NoInlining. Per the issue, - // only the 2-arg form is in scope. - var testCode = @" -public class Target -{ - public void Merge() - { - var x = 1; - } -} - -public class Caller -{ - public void Check() - { - if (Lucene.Net.Support.ExceptionHandling.StackTraceHelper.DoesStackTraceContainMethod(nameof(Target.Merge))) - { - } - } -}" + StackTraceHelperStub; - - var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev4000_4001_4002_NoInliningAnalyzer()) + var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev4000_4001_NoInliningOnNoOpAnalyzer()) { TestCode = testCode }; diff --git a/tests/Lucene.Net.CodeAnalysis.Dev.Tests/LuceneDev4xxx/TestLuceneDev4002_StackTraceHelperNoInliningAnalyzer.cs b/tests/Lucene.Net.CodeAnalysis.Dev.Tests/LuceneDev4xxx/TestLuceneDev4002_StackTraceHelperNoInliningAnalyzer.cs new file mode 100644 index 0000000..81d1a0a --- /dev/null +++ b/tests/Lucene.Net.CodeAnalysis.Dev.Tests/LuceneDev4xxx/TestLuceneDev4002_StackTraceHelperNoInliningAnalyzer.cs @@ -0,0 +1,178 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using Lucene.Net.CodeAnalysis.Dev.LuceneDev4xxx; +using Lucene.Net.CodeAnalysis.Dev.TestUtilities; +using Lucene.Net.CodeAnalysis.Dev.Utility; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Testing; +using NUnit.Framework; +using System.Threading.Tasks; + +namespace Lucene.Net.CodeAnalysis.Dev.Tests.LuceneDev4xxx +{ + [TestFixture] + public class TestLuceneDev4002_StackTraceHelperNoInliningAnalyzer + { + // Stub of StackTraceHelper appended to test sources, matching the + // Lucene.Net.Support.ExceptionHandling.StackTraceHelper API surface. + private const string StackTraceHelperStub = @" +namespace Lucene.Net.Support.ExceptionHandling +{ + public static class StackTraceHelper + { + public static bool DoesStackTraceContainMethod(string methodName) => false; + public static bool DoesStackTraceContainMethod(string className, string methodName) => false; + } +} +"; + + [Test] + public async Task LuceneDev4002_Reports_When_TargetMethod_Missing_NoInlining() + { + var testCode = @" +public class Target +{ + public void Merge() + { + var x = 1; + } +} + +public class Caller +{ + public void Check() + { + if (Lucene.Net.Support.ExceptionHandling.StackTraceHelper.DoesStackTraceContainMethod(nameof(Target), nameof(Target.Merge))) + { + } + } +}" + StackTraceHelperStub; + + var expected = new DiagnosticResult(Descriptors.LuceneDev4002_MissingNoInlining) + .WithSeverity(DiagnosticSeverity.Warning) + .WithSpan(4, 5, 7, 6) + .WithArguments("Merge"); + + var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev4002_StackTraceHelperNoInliningAnalyzer()) + { + TestCode = testCode, + ExpectedDiagnostics = { expected } + }; + + await test.RunAsync(); + } + + [Test] + public async Task LuceneDev4002_NoDiagnostic_When_TargetMethod_Already_Has_NoInlining() + { + var testCode = @" +using System.Runtime.CompilerServices; + +public class Target +{ + [MethodImpl(MethodImplOptions.NoInlining)] + public void Merge() + { + var x = 1; + } +} + +public class Caller +{ + public void Check() + { + if (Lucene.Net.Support.ExceptionHandling.StackTraceHelper.DoesStackTraceContainMethod(nameof(Target), nameof(Target.Merge))) + { + } + } +}" + StackTraceHelperStub; + + var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev4002_StackTraceHelperNoInliningAnalyzer()) + { + TestCode = testCode + }; + + await test.RunAsync(); + } + + [Test] + public async Task LuceneDev4002_NoDiagnostic_When_TargetMethod_Has_EmptyBody() + { + // Empty-bodied methods can never appear in a stack trace under the relevant + // call (they call nothing); NoInlining gives no benefit. Don't flag. + var testCode = @" +public class Target +{ + public void Merge() + { + } +} + +public class Caller +{ + public void Check() + { + if (Lucene.Net.Support.ExceptionHandling.StackTraceHelper.DoesStackTraceContainMethod(nameof(Target), nameof(Target.Merge))) + { + } + } +}" + StackTraceHelperStub; + + var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev4002_StackTraceHelperNoInliningAnalyzer()) + { + TestCode = testCode + }; + + await test.RunAsync(); + } + + [Test] + public async Task LuceneDev4002_NoDiagnostic_For_SingleArgOverload() + { + // The single-arg overload doesn't validate the owning class, so we don't + // require methods referenced through it to have NoInlining. Per the issue, + // only the 2-arg form is in scope. + var testCode = @" +public class Target +{ + public void Merge() + { + var x = 1; + } +} + +public class Caller +{ + public void Check() + { + if (Lucene.Net.Support.ExceptionHandling.StackTraceHelper.DoesStackTraceContainMethod(nameof(Target.Merge))) + { + } + } +}" + StackTraceHelperStub; + + var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev4002_StackTraceHelperNoInliningAnalyzer()) + { + TestCode = testCode + }; + + await test.RunAsync(); + } + } +} From 0d017c993794966f701352d3d83b56e976343c7f Mon Sep 17 00:00:00 2001 From: Paul Irwin Date: Thu, 30 Apr 2026 16:17:05 -0600 Subject: [PATCH 3/9] Add Sample project entries for LuceneDev4000-4002 Demonstrates each NoInlining diagnostic firing in the sample project: 4000 on an interface and an abstract method, 4001 on an empty-bodied method, and 4002 on a method referenced by the 2-arg StackTraceHelper.DoesStackTraceContainMethod overload (with a local stub mirroring the real type so the sample compiles standalone). Co-Authored-By: Claude Opus 4.7 (1M context) --- ...ceneDev4000_4001_NoInliningOnNoOpSample.cs | 56 +++++++++++++ ...ev4002_StackTraceHelperNoInliningSample.cs | 81 +++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 src/Lucene.Net.CodeAnalysis.Dev.Sample/LuceneDev4xxx/LuceneDev4000_4001_NoInliningOnNoOpSample.cs create mode 100644 src/Lucene.Net.CodeAnalysis.Dev.Sample/LuceneDev4xxx/LuceneDev4002_StackTraceHelperNoInliningSample.cs diff --git a/src/Lucene.Net.CodeAnalysis.Dev.Sample/LuceneDev4xxx/LuceneDev4000_4001_NoInliningOnNoOpSample.cs b/src/Lucene.Net.CodeAnalysis.Dev.Sample/LuceneDev4xxx/LuceneDev4000_4001_NoInliningOnNoOpSample.cs new file mode 100644 index 0000000..88d47dc --- /dev/null +++ b/src/Lucene.Net.CodeAnalysis.Dev.Sample/LuceneDev4xxx/LuceneDev4000_4001_NoInliningOnNoOpSample.cs @@ -0,0 +1,56 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +using System.Runtime.CompilerServices; + +namespace Lucene.Net.CodeAnalysis.Dev.Sample.LuceneDev4xxx; + +public interface ILuceneDev4000Sample +{ + // Triggers LuceneDev4000 (Warning): MethodImpl is not inherited, so NoInlining + // on an interface member has no effect on the implementation. + [MethodImpl(MethodImplOptions.NoInlining)] + void DoWork(); +} + +public abstract class LuceneDev4000Sample +{ + // Triggers LuceneDev4000 (Warning): same reason — abstract methods are not + // bodies that the JIT can inline. + [MethodImpl(MethodImplOptions.NoInlining)] + public abstract void DoWork(); +} + +public class LuceneDev4001Sample +{ + // Triggers LuceneDev4001 (Warning): empty-bodied methods cannot appear above + // any frame in a stack trace, so preventing inlining provides no benefit and + // only harms performance. + [MethodImpl(MethodImplOptions.NoInlining)] + public void EmptyMethod() + { + } + + // No diagnostic: regular method with a non-empty body. + [MethodImpl(MethodImplOptions.NoInlining)] + public int RealWork() + { + return 42; + } +} diff --git a/src/Lucene.Net.CodeAnalysis.Dev.Sample/LuceneDev4xxx/LuceneDev4002_StackTraceHelperNoInliningSample.cs b/src/Lucene.Net.CodeAnalysis.Dev.Sample/LuceneDev4xxx/LuceneDev4002_StackTraceHelperNoInliningSample.cs new file mode 100644 index 0000000..aef4840 --- /dev/null +++ b/src/Lucene.Net.CodeAnalysis.Dev.Sample/LuceneDev4xxx/LuceneDev4002_StackTraceHelperNoInliningSample.cs @@ -0,0 +1,81 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +using System.Runtime.CompilerServices; +using Lucene.Net.Support.ExceptionHandling; + +// Stub mirroring Lucene.Net.Support.ExceptionHandling.StackTraceHelper so the +// sample compiles in isolation. The analyzer matches by full type name. +// Suppress LuceneDev1005 — that rule flags real public types in Lucene.Net.Support; +// this is just a local stand-in for the sample. +#pragma warning disable LuceneDev1005 +namespace Lucene.Net.Support.ExceptionHandling +{ + public static class StackTraceHelper + { + public static bool DoesStackTraceContainMethod(string methodName) => false; + public static bool DoesStackTraceContainMethod(string className, string methodName) => false; + } +} +#pragma warning restore LuceneDev1005 + +namespace Lucene.Net.CodeAnalysis.Dev.Sample.LuceneDev4xxx +{ + public class LuceneDev4002_TargetWithoutNoInlining + { + // Triggers LuceneDev4002 (Warning): this method is referenced by the + // 2-argument StackTraceHelper.DoesStackTraceContainMethod overload below + // but is missing [MethodImpl(MethodImplOptions.NoInlining)]. The JIT may + // inline it out of the stack trace, silently breaking the check. + public void Merge() + { + System.Console.WriteLine(1 + 2); + } + } + + public class LuceneDev4002_TargetWithNoInlining + { + // No diagnostic: the attribute is already applied. + [MethodImpl(MethodImplOptions.NoInlining)] + public void Merge() + { + System.Console.WriteLine(1 + 2); + } + } + + public class LuceneDev4002_Caller + { + public void Check() + { + // The 2-argument overload triggers LuceneDev4002 on the referenced method. + if (StackTraceHelper.DoesStackTraceContainMethod( + nameof(LuceneDev4002_TargetWithoutNoInlining), + nameof(LuceneDev4002_TargetWithoutNoInlining.Merge))) + { + } + + // No diagnostic for this target — already has NoInlining. + if (StackTraceHelper.DoesStackTraceContainMethod( + nameof(LuceneDev4002_TargetWithNoInlining), + nameof(LuceneDev4002_TargetWithNoInlining.Merge))) + { + } + } + } +} From 0c6c007d50a072e7fc7cf95cd75244d9b45f6f52 Mon Sep 17 00:00:00 2001 From: Paul Irwin Date: Thu, 30 Apr 2026 16:39:29 -0600 Subject: [PATCH 4/9] Preserve leading comments and trim attribute indent in 4000/4001 fix The previous remove-attribute logic used SyntaxRemoveOptions.KeepNoTrivia which discarded any comments or blank lines that preceded the attribute, or KeepLeadingTrivia which left a stray indent on the next line. Replace with an approach that operates on the parent member declaration directly: strip the attribute list while moving its leading comments (minus the final whitespace block, which was just the attribute's own indent) onto the next member's leading trivia. Adds tests covering: leading comment preservation, removing one of several attributes inside a single attribute list, and removing one attribute list among multiple sibling lists. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...00_4001_NoInliningOnNoOpCodeFixProvider.cs | 60 +++++-- ...00_4001_NoInliningOnNoOpCodeFixProvider.cs | 170 ++++++++++++++++++ 2 files changed, 220 insertions(+), 10 deletions(-) diff --git a/src/Lucene.Net.CodeAnalysis.Dev.CodeFixes/LuceneDev4xxx/LuceneDev4000_4001_NoInliningOnNoOpCodeFixProvider.cs b/src/Lucene.Net.CodeAnalysis.Dev.CodeFixes/LuceneDev4xxx/LuceneDev4000_4001_NoInliningOnNoOpCodeFixProvider.cs index 0113e22..4072500 100644 --- a/src/Lucene.Net.CodeAnalysis.Dev.CodeFixes/LuceneDev4xxx/LuceneDev4000_4001_NoInliningOnNoOpCodeFixProvider.cs +++ b/src/Lucene.Net.CodeAnalysis.Dev.CodeFixes/LuceneDev4xxx/LuceneDev4000_4001_NoInliningOnNoOpCodeFixProvider.cs @@ -18,12 +18,14 @@ using System.Collections.Immutable; using System.Composition; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Lucene.Net.CodeAnalysis.Dev.Utility; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CodeActions; using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; namespace Lucene.Net.CodeAnalysis.Dev.CodeFixes.LuceneDev4xxx @@ -71,22 +73,60 @@ private static async Task RemoveAttributeAsync( if (root is null) return document; - if (attribute.Parent is AttributeListSyntax attrList) + if (attribute.Parent is not AttributeListSyntax attrList) + return document; + + SyntaxNode newRoot; + if (attrList.Attributes.Count > 1) { - SyntaxNode newRoot; - if (attrList.Attributes.Count == 1) - { - newRoot = root.RemoveNode(attrList, SyntaxRemoveOptions.KeepNoTrivia)!; - } - else + // [Foo, MethodImpl(NoInlining), Bar] → [Foo, Bar] + var newList = attrList.WithAttributes(attrList.Attributes.Remove(attribute)); + newRoot = root.ReplaceNode(attrList, newList); + return document.WithSyntaxRoot(newRoot); + } + + // Removing the whole [ ... ] list. + // + // Trivia handling: the attribute list's leading trivia is typically + // (newline)(indent)[comment(newline)(indent)]*. We want to keep any + // comments (and the newline that ends each one) but drop the final + // whitespace block — which is just the indentation for the now-removed + // attribute. The token following the list already carries its own + // newline+indent, so leaving that whitespace in would double-indent the + // next line. We move the trimmed trivia onto the next token. + var leading = attrList.GetLeadingTrivia(); + int trim = leading.Count; + while (trim > 0 && leading[trim - 1].IsKind(SyntaxKind.WhitespaceTrivia)) + { + trim--; + } + var triviaToKeep = SyntaxFactory.TriviaList(leading.Take(trim)); + + // Locate the parent that owns this attribute list. Use the parent's + // AttributeLists collection (e.g. on MethodDeclarationSyntax) so that + // removing the list and re-attaching trivia happens in a single step + // and preserves indentation of the surrounding declaration. + if (attrList.Parent is MemberDeclarationSyntax member) + { + var newAttrLists = member.AttributeLists.Remove(attrList); + MemberDeclarationSyntax newMember = member.WithAttributeLists(newAttrLists); + + // Prepend the trivia we want to keep (e.g. comments) to the new + // first token of the member declaration. + if (triviaToKeep.Count > 0) { - var newList = attrList.WithAttributes(attrList.Attributes.Remove(attribute)); - newRoot = root.ReplaceNode(attrList, newList); + var firstToken = newMember.GetFirstToken(); + var combined = triviaToKeep.AddRange(firstToken.LeadingTrivia); + newMember = newMember.ReplaceToken(firstToken, firstToken.WithLeadingTrivia(combined)); } + + newRoot = root.ReplaceNode(member, newMember); return document.WithSyntaxRoot(newRoot); } - return document; + // Fallback: just remove the list, dropping its trivia. + newRoot = root.RemoveNode(attrList, SyntaxRemoveOptions.KeepNoTrivia)!; + return document.WithSyntaxRoot(newRoot); } } } diff --git a/tests/Lucene.Net.CodeAnalysis.Dev.CodeFixes.Tests/LuceneDev4xxx/TestLuceneDev4000_4001_NoInliningOnNoOpCodeFixProvider.cs b/tests/Lucene.Net.CodeAnalysis.Dev.CodeFixes.Tests/LuceneDev4xxx/TestLuceneDev4000_4001_NoInliningOnNoOpCodeFixProvider.cs index c79eb92..a44196a 100644 --- a/tests/Lucene.Net.CodeAnalysis.Dev.CodeFixes.Tests/LuceneDev4xxx/TestLuceneDev4000_4001_NoInliningOnNoOpCodeFixProvider.cs +++ b/tests/Lucene.Net.CodeAnalysis.Dev.CodeFixes.Tests/LuceneDev4xxx/TestLuceneDev4000_4001_NoInliningOnNoOpCodeFixProvider.cs @@ -115,5 +115,175 @@ public void DoWork() await test.RunAsync(); } + + // ----------------------------------------------------------------- + // Regression: removing the attribute must preserve comments and + // blank lines that precede it. + // ----------------------------------------------------------------- + + [Test] + public async Task Fix_PreservesLeadingCommentBeforeAttribute() + { + var testCode = @" +using System.Runtime.CompilerServices; + +public class Sample +{ + // Important comment about the method. + [MethodImpl(MethodImplOptions.NoInlining)] + public void DoWork() + { + } +}"; + + var fixedCode = @" +using System.Runtime.CompilerServices; + +public class Sample +{ + // Important comment about the method. + public void DoWork() + { + } +}"; + + var expected = new DiagnosticResult(Descriptors.LuceneDev4001_NoInliningOnEmptyMethod) + .WithSeverity(DiagnosticSeverity.Warning) + .WithSpan(7, 6, 7, 46) + .WithArguments("DoWork"); + + var test = new InjectableCodeFixTest( + () => new LuceneDev4000_4001_NoInliningOnNoOpAnalyzer(), + () => new LuceneDev4000_4001_NoInliningOnNoOpCodeFixProvider()) + { + TestCode = testCode, + FixedCode = fixedCode, + ExpectedDiagnostics = { expected } + }; + + await test.RunAsync(); + } + + // ----------------------------------------------------------------- + // Multiple attributes: only the [MethodImpl(NoInlining)] attribute + // should be removed; siblings must remain intact. + // ----------------------------------------------------------------- + + [Test] + public async Task Fix_RemovesOnlyTargetAttribute_WithinSingleAttributeList() + { + // [A, MethodImpl(NoInlining), B] → [A, B] + var testCode = @" +using System; +using System.Runtime.CompilerServices; + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] +public class FooAttribute : Attribute { } + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] +public class BarAttribute : Attribute { } + +public class Sample +{ + [Foo, MethodImpl(MethodImplOptions.NoInlining), Bar] + public void DoWork() + { + } +}"; + + var fixedCode = @" +using System; +using System.Runtime.CompilerServices; + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] +public class FooAttribute : Attribute { } + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] +public class BarAttribute : Attribute { } + +public class Sample +{ + [Foo, Bar] + public void DoWork() + { + } +}"; + + var expected = new DiagnosticResult(Descriptors.LuceneDev4001_NoInliningOnEmptyMethod) + .WithSeverity(DiagnosticSeverity.Warning) + .WithSpan(13, 11, 13, 51) + .WithArguments("DoWork"); + + var test = new InjectableCodeFixTest( + () => new LuceneDev4000_4001_NoInliningOnNoOpAnalyzer(), + () => new LuceneDev4000_4001_NoInliningOnNoOpCodeFixProvider()) + { + TestCode = testCode, + FixedCode = fixedCode, + ExpectedDiagnostics = { expected } + }; + + await test.RunAsync(); + } + + [Test] + public async Task Fix_RemovesOnlyTargetAttributeList_AmongMultipleLists() + { + // [A] [MethodImpl(NoInlining)] [B] → [A] [B] + var testCode = @" +using System; +using System.Runtime.CompilerServices; + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] +public class FooAttribute : Attribute { } + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] +public class BarAttribute : Attribute { } + +public class Sample +{ + [Foo] + [MethodImpl(MethodImplOptions.NoInlining)] + [Bar] + public void DoWork() + { + } +}"; + + var fixedCode = @" +using System; +using System.Runtime.CompilerServices; + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] +public class FooAttribute : Attribute { } + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] +public class BarAttribute : Attribute { } + +public class Sample +{ + [Foo] + [Bar] + public void DoWork() + { + } +}"; + + var expected = new DiagnosticResult(Descriptors.LuceneDev4001_NoInliningOnEmptyMethod) + .WithSeverity(DiagnosticSeverity.Warning) + .WithSpan(14, 6, 14, 46) + .WithArguments("DoWork"); + + var test = new InjectableCodeFixTest( + () => new LuceneDev4000_4001_NoInliningOnNoOpAnalyzer(), + () => new LuceneDev4000_4001_NoInliningOnNoOpCodeFixProvider()) + { + TestCode = testCode, + FixedCode = fixedCode, + ExpectedDiagnostics = { expected } + }; + + await test.RunAsync(); + } } } From aba5ad889e6b838e74d538c6fe3b1dc1af64dee1 Mon Sep 17 00:00:00 2001 From: Paul Irwin Date: Thu, 30 Apr 2026 16:39:40 -0600 Subject: [PATCH 5/9] Report LuceneDev4002 at the call site instead of the target method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reporting on the target method declaration produced a non-local diagnostic — the analyzer was visiting an InvocationExpressionSyntax but raising the diagnostic on a syntax tree it had not visited. MSBuild ran a full compilation and surfaced the warning, but the IDE's per-file live analysis filtered it out, so the warning never appeared in the editor. Move the report to the DoesStackTraceContainMethod invocation. The diagnostic is now local, shows up in the IDE, and opens the door to a code fix at the call site. The message names the qualified target (e.g. 'Target.Merge') so it remains clear which method needs the attribute. Test span updated accordingly. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...uceneDev4002_StackTraceHelperNoInliningAnalyzer.cs | 11 +++++++++-- src/Lucene.Net.CodeAnalysis.Dev/Resources.resx | 2 +- ...uceneDev4002_StackTraceHelperNoInliningAnalyzer.cs | 7 +++++-- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/Lucene.Net.CodeAnalysis.Dev/LuceneDev4xxx/LuceneDev4002_StackTraceHelperNoInliningAnalyzer.cs b/src/Lucene.Net.CodeAnalysis.Dev/LuceneDev4xxx/LuceneDev4002_StackTraceHelperNoInliningAnalyzer.cs index eb17532..f5c88ba 100644 --- a/src/Lucene.Net.CodeAnalysis.Dev/LuceneDev4xxx/LuceneDev4002_StackTraceHelperNoInliningAnalyzer.cs +++ b/src/Lucene.Net.CodeAnalysis.Dev/LuceneDev4xxx/LuceneDev4002_StackTraceHelperNoInliningAnalyzer.cs @@ -99,6 +99,12 @@ private static void AnalyzeInvocation(SyntaxNodeAnalysisContext ctx, INamedTypeS if (targetType is null) return; + // Report once per call site if any matching method declaration in source + // is missing NoInlining. Locating the diagnostic on the invocation makes + // it a "local" diagnostic relative to the syntax tree the analyzer is + // visiting, which means the IDE surfaces it (compilation-wide non-local + // diagnostics are filtered out of live IDE analysis) and a code fix can + // be wired up in the future. foreach (var member in targetType.GetMembers(methodNameValue).OfType()) { if (member.MethodKind != MethodKind.Ordinary) @@ -120,8 +126,9 @@ private static void AnalyzeInvocation(SyntaxNodeAnalysisContext ctx, INamedTypeS ctx.ReportDiagnostic(Diagnostic.Create( Descriptors.LuceneDev4002_MissingNoInlining, - methodDecl.GetLocation(), - methodDecl.Identifier.ValueText)); + invocation.GetLocation(), + $"{targetType.Name}.{methodDecl.Identifier.ValueText}")); + return; } } } diff --git a/src/Lucene.Net.CodeAnalysis.Dev/Resources.resx b/src/Lucene.Net.CodeAnalysis.Dev/Resources.resx index 5bfdc35..2bf6cb7 100644 --- a/src/Lucene.Net.CodeAnalysis.Dev/Resources.resx +++ b/src/Lucene.Net.CodeAnalysis.Dev/Resources.resx @@ -436,7 +436,7 @@ under the License. If a method is referenced by StackTraceHelper.DoesStackTraceContainMethod and the JIT inlines it, the method will not appear in the stack trace and the check will silently fail. Apply [MethodImpl(MethodImplOptions.NoInlining)] to the method. - '{0}' is referenced by StackTraceHelper.DoesStackTraceContainMethod and should be marked with [MethodImpl(MethodImplOptions.NoInlining)] to prevent the JIT from inlining it out of the stack trace. + '{0}' is referenced by this StackTraceHelper.DoesStackTraceContainMethod call and should be marked with [MethodImpl(MethodImplOptions.NoInlining)] to prevent the JIT from inlining it out of the stack trace. diff --git a/tests/Lucene.Net.CodeAnalysis.Dev.Tests/LuceneDev4xxx/TestLuceneDev4002_StackTraceHelperNoInliningAnalyzer.cs b/tests/Lucene.Net.CodeAnalysis.Dev.Tests/LuceneDev4xxx/TestLuceneDev4002_StackTraceHelperNoInliningAnalyzer.cs index 81d1a0a..8f6ce5d 100644 --- a/tests/Lucene.Net.CodeAnalysis.Dev.Tests/LuceneDev4xxx/TestLuceneDev4002_StackTraceHelperNoInliningAnalyzer.cs +++ b/tests/Lucene.Net.CodeAnalysis.Dev.Tests/LuceneDev4xxx/TestLuceneDev4002_StackTraceHelperNoInliningAnalyzer.cs @@ -64,10 +64,13 @@ public void Check() } }" + StackTraceHelperStub; + // Diagnostic is now reported on the DoesStackTraceContainMethod invocation + // (call site) so the IDE can surface it and a future code fix can be hooked + // up. Argument is the qualified target method name. var expected = new DiagnosticResult(Descriptors.LuceneDev4002_MissingNoInlining) .WithSeverity(DiagnosticSeverity.Warning) - .WithSpan(4, 5, 7, 6) - .WithArguments("Merge"); + .WithSpan(14, 13, 14, 132) + .WithArguments("Target.Merge"); var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev4002_StackTraceHelperNoInliningAnalyzer()) { From f92d0207a7e1d645b0b4366c7d701949bd7c3be6 Mon Sep 17 00:00:00 2001 From: Paul Irwin Date: Thu, 30 Apr 2026 16:39:51 -0600 Subject: [PATCH 6/9] Add code fix for LuceneDev4002 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds [MethodImpl(MethodImplOptions.NoInlining)] to the target method referenced by a DoesStackTraceContainMethod call. The fix resolves the target from the call's nameof() (or string-literal) arguments, locates its declaration in source, and edits that document — even when it lives in a different file from the call. Adds 'using System.Runtime.CompilerServices;' to the target's compilation unit if missing, and prepends the new attribute list ahead of any existing attributes on the method. Promote NoInliningAttributeHelper from internal to public so the code fix project can reuse it. Adds tests covering: target with using already present, target without the using, and target with an existing attribute. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...ackTraceHelperNoInliningCodeFixProvider.cs | 300 ++++++++++++++++++ .../NoInliningAttributeHelper.cs | 2 +- ...ackTraceHelperNoInliningCodeFixProvider.cs | 243 ++++++++++++++ 3 files changed, 544 insertions(+), 1 deletion(-) create mode 100644 src/Lucene.Net.CodeAnalysis.Dev.CodeFixes/LuceneDev4xxx/LuceneDev4002_StackTraceHelperNoInliningCodeFixProvider.cs create mode 100644 tests/Lucene.Net.CodeAnalysis.Dev.CodeFixes.Tests/LuceneDev4xxx/TestLuceneDev4002_StackTraceHelperNoInliningCodeFixProvider.cs diff --git a/src/Lucene.Net.CodeAnalysis.Dev.CodeFixes/LuceneDev4xxx/LuceneDev4002_StackTraceHelperNoInliningCodeFixProvider.cs b/src/Lucene.Net.CodeAnalysis.Dev.CodeFixes/LuceneDev4xxx/LuceneDev4002_StackTraceHelperNoInliningCodeFixProvider.cs new file mode 100644 index 0000000..60cfb33 --- /dev/null +++ b/src/Lucene.Net.CodeAnalysis.Dev.CodeFixes/LuceneDev4xxx/LuceneDev4002_StackTraceHelperNoInliningCodeFixProvider.cs @@ -0,0 +1,300 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Composition; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Lucene.Net.CodeAnalysis.Dev.LuceneDev4xxx; +using Lucene.Net.CodeAnalysis.Dev.Utility; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Lucene.Net.CodeAnalysis.Dev.CodeFixes.LuceneDev4xxx +{ + /// + /// Code fix for LuceneDev4002: adds [MethodImpl(MethodImplOptions.NoInlining)] + /// to the target method declaration referenced by the + /// StackTraceHelper.DoesStackTraceContainMethod call. Adds + /// `using System.Runtime.CompilerServices;` to the target's compilation unit + /// if missing. + /// + [ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(LuceneDev4002_StackTraceHelperNoInliningCodeFixProvider)), Shared] + public sealed class LuceneDev4002_StackTraceHelperNoInliningCodeFixProvider : CodeFixProvider + { + private const string Title = "Add [MethodImpl(MethodImplOptions.NoInlining)] to the referenced method"; + + public override ImmutableArray FixableDiagnosticIds => + ImmutableArray.Create(Descriptors.LuceneDev4002_MissingNoInlining.Id); + + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var diagnostic = context.Diagnostics[0]; + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root is null) + return; + + var node = root.FindNode(diagnostic.Location.SourceSpan, getInnermostNodeForTie: true); + var invocation = node as InvocationExpressionSyntax + ?? node.FirstAncestorOrSelf(); + if (invocation is null) + return; + + context.RegisterCodeFix( + CodeAction.Create( + Title, + ct => AddNoInliningToTargetAsync(context.Document, invocation, ct), + equivalenceKey: nameof(Title)), + diagnostic); + } + + private static async Task AddNoInliningToTargetAsync( + Document document, + InvocationExpressionSyntax invocation, + CancellationToken cancellationToken) + { + var solution = document.Project.Solution; + + var semanticModel = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false); + if (semanticModel is null || invocation.ArgumentList.Arguments.Count != 2) + return solution; + + var classArg = invocation.ArgumentList.Arguments[0].Expression; + var methodArg = invocation.ArgumentList.Arguments[1].Expression; + + var (classNameValue, classTypeFromNameof) = ResolveClassReference(classArg, semanticModel); + if (classNameValue is null) + return solution; + + var methodNameValue = ResolveMethodNameValue(methodArg, semanticModel); + if (methodNameValue is null) + return solution; + + var compilation = semanticModel.Compilation; + var targetType = classTypeFromNameof + ?? FindSourceTypeByName(compilation, classNameValue); + if (targetType is null) + return solution; + + var methodImplAttrSymbol = compilation.GetTypeByMetadataName( + "System.Runtime.CompilerServices.MethodImplAttribute"); + if (methodImplAttrSymbol is null) + return solution; + + // Find the first ordinary method that needs the attribute. + MethodDeclarationSyntax? targetDecl = null; + foreach (var member in targetType.GetMembers(methodNameValue).OfType()) + { + if (member.MethodKind != MethodKind.Ordinary) + continue; + + foreach (var declRef in member.DeclaringSyntaxReferences) + { + if (declRef.GetSyntax(cancellationToken) is not MethodDeclarationSyntax methodDecl) + continue; + + var declSemantic = compilation.GetSemanticModel(methodDecl.SyntaxTree); + if (NoInliningAttributeHelper.FindNoInliningAttribute(methodDecl, declSemantic, methodImplAttrSymbol) is not null) + continue; + if (NoInliningAttributeHelper.HasEmptyBody(methodDecl)) + continue; + if (NoInliningAttributeHelper.IsInterfaceOrAbstractMethod(methodDecl)) + continue; + + targetDecl = methodDecl; + break; + } + + if (targetDecl is not null) + break; + } + + if (targetDecl is null) + return solution; + + var targetTree = targetDecl.SyntaxTree; + var targetDocument = solution.GetDocument(targetTree); + if (targetDocument is null) + return solution; + + var targetRoot = await targetTree.GetRootAsync(cancellationToken).ConfigureAwait(false); + + // Build [MethodImpl(MethodImplOptions.NoInlining)] as its own attribute + // list. Place it ahead of any existing lists, copying the method's + // leading trivia onto our new list and re-attaching one indent's worth + // of trivia between the list and the original method position. + var attribute = SyntaxFactory.Attribute( + SyntaxFactory.IdentifierName("MethodImpl"), + SyntaxFactory.AttributeArgumentList( + SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.AttributeArgument( + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + SyntaxFactory.IdentifierName("MethodImplOptions"), + SyntaxFactory.IdentifierName("NoInlining")))))); + + var leadingIndent = ExtractLeadingIndentation(targetDecl); + var newAttributeList = SyntaxFactory.AttributeList(SyntaxFactory.SingletonSeparatedList(attribute)) + .WithLeadingTrivia(targetDecl.GetLeadingTrivia()) + .WithTrailingTrivia(SyntaxFactory.EndOfLine("\n"), leadingIndent); + + var newAttributeLists = SyntaxFactory.List( + new[] { newAttributeList }.Concat(targetDecl.AttributeLists)); + + var newMethodDecl = targetDecl + .WithLeadingTrivia(SyntaxFactory.TriviaList()) + .WithAttributeLists(newAttributeLists); + + var newTargetRoot = targetRoot.ReplaceNode(targetDecl, newMethodDecl); + + // Add the using if missing. + if (newTargetRoot is CompilationUnitSyntax compilationUnit) + { + const string requiredNs = "System.Runtime.CompilerServices"; + bool hasUsing = compilationUnit.Usings.Any(u => u.Name?.ToString() == requiredNs); + if (!hasUsing) + { + var usingDirective = SyntaxFactory.UsingDirective(SyntaxFactory.ParseName(requiredNs)) + .WithTrailingTrivia(SyntaxFactory.EndOfLine("\n")); + compilationUnit = compilationUnit.AddUsings(usingDirective); + newTargetRoot = compilationUnit; + } + } + + return solution.WithDocumentSyntaxRoot(targetDocument.Id, newTargetRoot); + } + + private static SyntaxTrivia ExtractLeadingIndentation(SyntaxNode node) + { + // Indentation = trailing whitespace of the leading trivia (after the + // last newline). Used to align the new attribute list with the method. + foreach (var t in node.GetLeadingTrivia().Reverse()) + { + if (t.IsKind(SyntaxKind.WhitespaceTrivia)) + return t; + if (t.IsKind(SyntaxKind.EndOfLineTrivia)) + break; + } + return SyntaxFactory.Whitespace(""); + } + + // ---- Argument resolution (mirrors the analyzer) ---- + + private static (string? Name, INamedTypeSymbol? TypeFromNameof) ResolveClassReference( + ExpressionSyntax expr, + SemanticModel semantic) + { + if (expr is InvocationExpressionSyntax inv + && inv.Expression is IdentifierNameSyntax id + && id.Identifier.ValueText == "nameof" + && inv.ArgumentList.Arguments.Count == 1) + { + var inner = inv.ArgumentList.Arguments[0].Expression; + var typeSymbol = semantic.GetTypeInfo(inner).Type as INamedTypeSymbol + ?? semantic.GetSymbolInfo(inner).Symbol as INamedTypeSymbol; + if (typeSymbol is not null) + return (typeSymbol.Name, typeSymbol); + } + + if (expr is LiteralExpressionSyntax literal && literal.IsKind(SyntaxKind.StringLiteralExpression)) + return (literal.Token.ValueText, null); + + var constant = semantic.GetConstantValue(expr); + if (constant.HasValue && constant.Value is string s) + return (s, null); + + return (null, null); + } + + private static string? ResolveMethodNameValue(ExpressionSyntax expr, SemanticModel semantic) + { + if (expr is InvocationExpressionSyntax inv + && inv.Expression is IdentifierNameSyntax id + && id.Identifier.ValueText == "nameof" + && inv.ArgumentList.Arguments.Count == 1) + { + var inner = inv.ArgumentList.Arguments[0].Expression; + return ExtractRightmostIdentifier(inner); + } + + if (expr is LiteralExpressionSyntax literal && literal.IsKind(SyntaxKind.StringLiteralExpression)) + return literal.Token.ValueText; + + var constant = semantic.GetConstantValue(expr); + if (constant.HasValue && constant.Value is string s) + return s; + + return null; + } + + private static string? ExtractRightmostIdentifier(ExpressionSyntax expr) + { + return expr switch + { + IdentifierNameSyntax id => id.Identifier.ValueText, + MemberAccessExpressionSyntax ma => ma.Name.Identifier.ValueText, + _ => null, + }; + } + + private static INamedTypeSymbol? FindSourceTypeByName(Compilation compilation, string typeName) + { + foreach (var type in EnumerateAllTypes(compilation.Assembly.GlobalNamespace)) + { + if (type.Name == typeName) + return type; + } + return null; + } + + private static IEnumerable EnumerateAllTypes(INamespaceSymbol ns) + { + foreach (var member in ns.GetMembers()) + { + if (member is INamedTypeSymbol type) + { + yield return type; + foreach (var nested in EnumerateNestedTypes(type)) + yield return nested; + } + else if (member is INamespaceSymbol child) + { + foreach (var t in EnumerateAllTypes(child)) + yield return t; + } + } + } + + private static IEnumerable EnumerateNestedTypes(INamedTypeSymbol type) + { + foreach (var nested in type.GetTypeMembers()) + { + yield return nested; + foreach (var deeper in EnumerateNestedTypes(nested)) + yield return deeper; + } + } + } +} diff --git a/src/Lucene.Net.CodeAnalysis.Dev/LuceneDev4xxx/NoInliningAttributeHelper.cs b/src/Lucene.Net.CodeAnalysis.Dev/LuceneDev4xxx/NoInliningAttributeHelper.cs index 030d5e7..6a90f55 100644 --- a/src/Lucene.Net.CodeAnalysis.Dev/LuceneDev4xxx/NoInliningAttributeHelper.cs +++ b/src/Lucene.Net.CodeAnalysis.Dev/LuceneDev4xxx/NoInliningAttributeHelper.cs @@ -23,7 +23,7 @@ namespace Lucene.Net.CodeAnalysis.Dev.LuceneDev4xxx { - internal static class NoInliningAttributeHelper + public static class NoInliningAttributeHelper { public static AttributeSyntax? FindNoInliningAttribute( MethodDeclarationSyntax methodDecl, diff --git a/tests/Lucene.Net.CodeAnalysis.Dev.CodeFixes.Tests/LuceneDev4xxx/TestLuceneDev4002_StackTraceHelperNoInliningCodeFixProvider.cs b/tests/Lucene.Net.CodeAnalysis.Dev.CodeFixes.Tests/LuceneDev4xxx/TestLuceneDev4002_StackTraceHelperNoInliningCodeFixProvider.cs new file mode 100644 index 0000000..7931254 --- /dev/null +++ b/tests/Lucene.Net.CodeAnalysis.Dev.CodeFixes.Tests/LuceneDev4xxx/TestLuceneDev4002_StackTraceHelperNoInliningCodeFixProvider.cs @@ -0,0 +1,243 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using Lucene.Net.CodeAnalysis.Dev.CodeFixes.LuceneDev4xxx; +using Lucene.Net.CodeAnalysis.Dev.LuceneDev4xxx; +using Lucene.Net.CodeAnalysis.Dev.TestUtilities; +using Lucene.Net.CodeAnalysis.Dev.Utility; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Testing; +using NUnit.Framework; +using System.Threading.Tasks; + +namespace Lucene.Net.CodeAnalysis.Dev.CodeFixes.Tests.LuceneDev4xxx +{ + [TestFixture] + public class TestLuceneDev4002_StackTraceHelperNoInliningCodeFixProvider + { + private const string StackTraceHelperStub = @" +namespace Lucene.Net.Support.ExceptionHandling +{ + public static class StackTraceHelper + { + public static bool DoesStackTraceContainMethod(string methodName) => false; + public static bool DoesStackTraceContainMethod(string className, string methodName) => false; + } +} +"; + + [Test] + public async Task Fix_AddsAttribute_WhenUsingAlreadyPresent() + { + var testCode = @" +using System.Runtime.CompilerServices; + +public class Target +{ + public void Merge() + { + var x = 1; + } +} + +public class Caller +{ + public void Check() + { + if (Lucene.Net.Support.ExceptionHandling.StackTraceHelper.DoesStackTraceContainMethod(nameof(Target), nameof(Target.Merge))) + { + } + } +}" + StackTraceHelperStub; + + var fixedCode = @" +using System.Runtime.CompilerServices; + +public class Target +{ + [MethodImpl(MethodImplOptions.NoInlining)] + public void Merge() + { + var x = 1; + } +} + +public class Caller +{ + public void Check() + { + if (Lucene.Net.Support.ExceptionHandling.StackTraceHelper.DoesStackTraceContainMethod(nameof(Target), nameof(Target.Merge))) + { + } + } +}" + StackTraceHelperStub; + + var expected = new DiagnosticResult(Descriptors.LuceneDev4002_MissingNoInlining) + .WithSeverity(DiagnosticSeverity.Warning) + .WithSpan(16, 13, 16, 132) + .WithArguments("Target.Merge"); + + var test = new InjectableCodeFixTest( + () => new LuceneDev4002_StackTraceHelperNoInliningAnalyzer(), + () => new LuceneDev4002_StackTraceHelperNoInliningCodeFixProvider()) + { + TestCode = testCode, + FixedCode = fixedCode, + ExpectedDiagnostics = { expected } + }; + + await test.RunAsync(); + } + + [Test] + public async Task Fix_AddsAttributeAndUsing_WhenUsingMissing() + { + // No `using System.Runtime.CompilerServices;` initially — fix must add it. + var testCode = @" +public class Target +{ + public void Merge() + { + var x = 1; + } +} + +public class Caller +{ + public void Check() + { + if (Lucene.Net.Support.ExceptionHandling.StackTraceHelper.DoesStackTraceContainMethod(nameof(Target), nameof(Target.Merge))) + { + } + } +}" + StackTraceHelperStub; + + var fixedCode = @"using System.Runtime.CompilerServices; + +public class Target +{ + [MethodImpl(MethodImplOptions.NoInlining)] + public void Merge() + { + var x = 1; + } +} + +public class Caller +{ + public void Check() + { + if (Lucene.Net.Support.ExceptionHandling.StackTraceHelper.DoesStackTraceContainMethod(nameof(Target), nameof(Target.Merge))) + { + } + } +}" + StackTraceHelperStub; + + var expected = new DiagnosticResult(Descriptors.LuceneDev4002_MissingNoInlining) + .WithSeverity(DiagnosticSeverity.Warning) + .WithSpan(14, 13, 14, 132) + .WithArguments("Target.Merge"); + + var test = new InjectableCodeFixTest( + () => new LuceneDev4002_StackTraceHelperNoInliningAnalyzer(), + () => new LuceneDev4002_StackTraceHelperNoInliningCodeFixProvider()) + { + TestCode = testCode, + FixedCode = fixedCode, + ExpectedDiagnostics = { expected } + }; + + await test.RunAsync(); + } + + [Test] + public async Task Fix_PreservesExistingAttributeOnTarget() + { + // Target method has another attribute; the new MethodImpl list should + // be inserted ahead of it without disturbing it. + var testCode = @" +using System; +using System.Runtime.CompilerServices; + +[AttributeUsage(AttributeTargets.Method)] +public class FooAttribute : Attribute { } + +public class Target +{ + [Foo] + public void Merge() + { + var x = 1; + } +} + +public class Caller +{ + public void Check() + { + if (Lucene.Net.Support.ExceptionHandling.StackTraceHelper.DoesStackTraceContainMethod(nameof(Target), nameof(Target.Merge))) + { + } + } +}" + StackTraceHelperStub; + + var fixedCode = @" +using System; +using System.Runtime.CompilerServices; + +[AttributeUsage(AttributeTargets.Method)] +public class FooAttribute : Attribute { } + +public class Target +{ + [MethodImpl(MethodImplOptions.NoInlining)] + [Foo] + public void Merge() + { + var x = 1; + } +} + +public class Caller +{ + public void Check() + { + if (Lucene.Net.Support.ExceptionHandling.StackTraceHelper.DoesStackTraceContainMethod(nameof(Target), nameof(Target.Merge))) + { + } + } +}" + StackTraceHelperStub; + + var expected = new DiagnosticResult(Descriptors.LuceneDev4002_MissingNoInlining) + .WithSeverity(DiagnosticSeverity.Warning) + .WithSpan(21, 13, 21, 132) + .WithArguments("Target.Merge"); + + var test = new InjectableCodeFixTest( + () => new LuceneDev4002_StackTraceHelperNoInliningAnalyzer(), + () => new LuceneDev4002_StackTraceHelperNoInliningCodeFixProvider()) + { + TestCode = testCode, + FixedCode = fixedCode, + ExpectedDiagnostics = { expected } + }; + + await test.RunAsync(); + } + } +} From a70b5218fdae70ad3056c6e6f8d1614804cc0551 Mon Sep 17 00:00:00 2001 From: Paul Irwin Date: Thu, 30 Apr 2026 17:17:03 -0600 Subject: [PATCH 7/9] Match source line-ending convention in 4002 code fix The fix previously emitted '\n' line terminators unconditionally, which matched local Mac checkouts (LF) but failed on Linux CI where the checked-out source uses CRLF. Detect the existing line ending from the target tree's trivia and reuse it so the fixed output stays consistent with the surrounding file. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...StackTraceHelperNoInliningCodeFixProvider.cs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/Lucene.Net.CodeAnalysis.Dev.CodeFixes/LuceneDev4xxx/LuceneDev4002_StackTraceHelperNoInliningCodeFixProvider.cs b/src/Lucene.Net.CodeAnalysis.Dev.CodeFixes/LuceneDev4xxx/LuceneDev4002_StackTraceHelperNoInliningCodeFixProvider.cs index 60cfb33..f4212a6 100644 --- a/src/Lucene.Net.CodeAnalysis.Dev.CodeFixes/LuceneDev4xxx/LuceneDev4002_StackTraceHelperNoInliningCodeFixProvider.cs +++ b/src/Lucene.Net.CodeAnalysis.Dev.CodeFixes/LuceneDev4xxx/LuceneDev4002_StackTraceHelperNoInliningCodeFixProvider.cs @@ -156,9 +156,10 @@ private static async Task AddNoInliningToTargetAsync( SyntaxFactory.IdentifierName("NoInlining")))))); var leadingIndent = ExtractLeadingIndentation(targetDecl); + var endOfLine = DetectEndOfLine(targetRoot); var newAttributeList = SyntaxFactory.AttributeList(SyntaxFactory.SingletonSeparatedList(attribute)) .WithLeadingTrivia(targetDecl.GetLeadingTrivia()) - .WithTrailingTrivia(SyntaxFactory.EndOfLine("\n"), leadingIndent); + .WithTrailingTrivia(endOfLine, leadingIndent); var newAttributeLists = SyntaxFactory.List( new[] { newAttributeList }.Concat(targetDecl.AttributeLists)); @@ -177,7 +178,7 @@ private static async Task AddNoInliningToTargetAsync( if (!hasUsing) { var usingDirective = SyntaxFactory.UsingDirective(SyntaxFactory.ParseName(requiredNs)) - .WithTrailingTrivia(SyntaxFactory.EndOfLine("\n")); + .WithTrailingTrivia(endOfLine); compilationUnit = compilationUnit.AddUsings(usingDirective); newTargetRoot = compilationUnit; } @@ -186,6 +187,18 @@ private static async Task AddNoInliningToTargetAsync( return solution.WithDocumentSyntaxRoot(targetDocument.Id, newTargetRoot); } + private static SyntaxTrivia DetectEndOfLine(SyntaxNode root) + { + // Match the source's existing line-ending convention so the fixed + // output doesn't mix CRLF and LF. + foreach (var trivia in root.DescendantTrivia()) + { + if (trivia.IsKind(SyntaxKind.EndOfLineTrivia)) + return trivia; + } + return SyntaxFactory.EndOfLine("\n"); + } + private static SyntaxTrivia ExtractLeadingIndentation(SyntaxNode node) { // Indentation = trailing whitespace of the leading trivia (after the From 81cce2fcf3f4b199216abb388cab35e48365cbe5 Mon Sep 17 00:00:00 2001 From: Paul Irwin Date: Thu, 30 Apr 2026 20:24:41 -0600 Subject: [PATCH 8/9] Simplify 4002 code fix and rely on Formatter for layout Replaces hand-rolled trivia construction with a single Formatter pass. The new attribute list and using directive are built without trivia and formatted via Formatter.FormatAsync, with the workspace NewLine option explicitly set from the source file's existing line endings so the output doesn't mix CRLF and LF on platforms where Environment.NewLine disagrees with the file (e.g. CRLF source on Linux CI). Co-Authored-By: Claude Opus 4.7 (1M context) --- ...ackTraceHelperNoInliningCodeFixProvider.cs | 123 +++++++++--------- 1 file changed, 58 insertions(+), 65 deletions(-) diff --git a/src/Lucene.Net.CodeAnalysis.Dev.CodeFixes/LuceneDev4xxx/LuceneDev4002_StackTraceHelperNoInliningCodeFixProvider.cs b/src/Lucene.Net.CodeAnalysis.Dev.CodeFixes/LuceneDev4xxx/LuceneDev4002_StackTraceHelperNoInliningCodeFixProvider.cs index f4212a6..f138c8a 100644 --- a/src/Lucene.Net.CodeAnalysis.Dev.CodeFixes/LuceneDev4xxx/LuceneDev4002_StackTraceHelperNoInliningCodeFixProvider.cs +++ b/src/Lucene.Net.CodeAnalysis.Dev.CodeFixes/LuceneDev4xxx/LuceneDev4002_StackTraceHelperNoInliningCodeFixProvider.cs @@ -29,20 +29,17 @@ using Microsoft.CodeAnalysis.CodeFixes; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Formatting; +using Microsoft.CodeAnalysis.Options; +using Microsoft.CodeAnalysis.Text; namespace Lucene.Net.CodeAnalysis.Dev.CodeFixes.LuceneDev4xxx { - /// - /// Code fix for LuceneDev4002: adds [MethodImpl(MethodImplOptions.NoInlining)] - /// to the target method declaration referenced by the - /// StackTraceHelper.DoesStackTraceContainMethod call. Adds - /// `using System.Runtime.CompilerServices;` to the target's compilation unit - /// if missing. - /// [ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(LuceneDev4002_StackTraceHelperNoInliningCodeFixProvider)), Shared] public sealed class LuceneDev4002_StackTraceHelperNoInliningCodeFixProvider : CodeFixProvider { private const string Title = "Add [MethodImpl(MethodImplOptions.NoInlining)] to the referenced method"; + private const string CompilerServicesNamespace = "System.Runtime.CompilerServices"; public override ImmutableArray FixableDiagnosticIds => ImmutableArray.Create(Descriptors.LuceneDev4002_MissingNoInlining.Id); @@ -103,7 +100,6 @@ private static async Task AddNoInliningToTargetAsync( if (methodImplAttrSymbol is null) return solution; - // Find the first ordinary method that needs the attribute. MethodDeclarationSyntax? targetDecl = null; foreach (var member in targetType.GetMembers(methodNameValue).OfType()) { @@ -141,76 +137,73 @@ private static async Task AddNoInliningToTargetAsync( var targetRoot = await targetTree.GetRootAsync(cancellationToken).ConfigureAwait(false); - // Build [MethodImpl(MethodImplOptions.NoInlining)] as its own attribute - // list. Place it ahead of any existing lists, copying the method's - // leading trivia onto our new list and re-attaching one indent's worth - // of trivia between the list and the original method position. - var attribute = SyntaxFactory.Attribute( - SyntaxFactory.IdentifierName("MethodImpl"), - SyntaxFactory.AttributeArgumentList( - SyntaxFactory.SingletonSeparatedList( - SyntaxFactory.AttributeArgument( - SyntaxFactory.MemberAccessExpression( - SyntaxKind.SimpleMemberAccessExpression, - SyntaxFactory.IdentifierName("MethodImplOptions"), - SyntaxFactory.IdentifierName("NoInlining")))))); - - var leadingIndent = ExtractLeadingIndentation(targetDecl); - var endOfLine = DetectEndOfLine(targetRoot); - var newAttributeList = SyntaxFactory.AttributeList(SyntaxFactory.SingletonSeparatedList(attribute)) - .WithLeadingTrivia(targetDecl.GetLeadingTrivia()) - .WithTrailingTrivia(endOfLine, leadingIndent); - - var newAttributeLists = SyntaxFactory.List( - new[] { newAttributeList }.Concat(targetDecl.AttributeLists)); - - var newMethodDecl = targetDecl - .WithLeadingTrivia(SyntaxFactory.TriviaList()) - .WithAttributeLists(newAttributeLists); + // Build [MethodImpl(MethodImplOptions.NoInlining)] with no manual trivia, + // and let the Formatter annotation handle indentation and line endings. + var newAttributeList = SyntaxFactory.AttributeList( + SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.Attribute( + SyntaxFactory.IdentifierName("MethodImpl"), + SyntaxFactory.AttributeArgumentList( + SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.AttributeArgument( + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + SyntaxFactory.IdentifierName("MethodImplOptions"), + SyntaxFactory.IdentifierName("NoInlining")))))))) + .WithAdditionalAnnotations(Formatter.Annotation); + + var newAttributeLists = targetDecl.AttributeLists.Insert(0, newAttributeList); + var newMethodDecl = targetDecl.WithAttributeLists(newAttributeLists); var newTargetRoot = targetRoot.ReplaceNode(targetDecl, newMethodDecl); // Add the using if missing. - if (newTargetRoot is CompilationUnitSyntax compilationUnit) + if (newTargetRoot is CompilationUnitSyntax compilationUnit + && !compilationUnit.Usings.Any(u => u.Name?.ToString() == CompilerServicesNamespace)) { - const string requiredNs = "System.Runtime.CompilerServices"; - bool hasUsing = compilationUnit.Usings.Any(u => u.Name?.ToString() == requiredNs); - if (!hasUsing) - { - var usingDirective = SyntaxFactory.UsingDirective(SyntaxFactory.ParseName(requiredNs)) - .WithTrailingTrivia(endOfLine); - compilationUnit = compilationUnit.AddUsings(usingDirective); - newTargetRoot = compilationUnit; - } + var usingDirective = SyntaxFactory.UsingDirective( + SyntaxFactory.ParseName(CompilerServicesNamespace)) + .WithAdditionalAnnotations(Formatter.Annotation); + compilationUnit = compilationUnit.AddUsings(usingDirective); + newTargetRoot = compilationUnit; } - return solution.WithDocumentSyntaxRoot(targetDocument.Id, newTargetRoot); - } + var newTargetDocument = targetDocument.WithSyntaxRoot(newTargetRoot); - private static SyntaxTrivia DetectEndOfLine(SyntaxNode root) - { - // Match the source's existing line-ending convention so the fixed - // output doesn't mix CRLF and LF. - foreach (var trivia in root.DescendantTrivia()) - { - if (trivia.IsKind(SyntaxKind.EndOfLineTrivia)) - return trivia; - } - return SyntaxFactory.EndOfLine("\n"); + // Honor the source file's existing line ending convention so the fix + // doesn't introduce mixed line endings (the workspace's NewLine option + // otherwise defaults to Environment.NewLine, which can disagree with + // a source file that uses the opposite convention). + var sourceText = await targetTree.GetTextAsync(cancellationToken).ConfigureAwait(false); + var newLine = DetectNewLine(sourceText); + var options = newTargetDocument.Project.Solution.Workspace.Options + .WithChangedOption(FormattingOptions.NewLine, LanguageNames.CSharp, newLine); + + var formatted = await Formatter.FormatAsync( + newTargetDocument, + Formatter.Annotation, + options, + cancellationToken).ConfigureAwait(false); + + return formatted.Project.Solution; } - private static SyntaxTrivia ExtractLeadingIndentation(SyntaxNode node) + private static string DetectNewLine(SourceText text) { - // Indentation = trailing whitespace of the leading trivia (after the - // last newline). Used to align the new attribute list with the method. - foreach (var t in node.GetLeadingTrivia().Reverse()) + foreach (var line in text.Lines) { - if (t.IsKind(SyntaxKind.WhitespaceTrivia)) - return t; - if (t.IsKind(SyntaxKind.EndOfLineTrivia)) - break; + var lineBreakLength = line.EndIncludingLineBreak - line.End; + if (lineBreakLength == 0) + continue; + var firstChar = text[line.End]; + if (firstChar == '\r' && lineBreakLength == 2) + return "\r\n"; + if (firstChar == '\n') + return "\n"; + if (firstChar == '\r') + return "\r"; } - return SyntaxFactory.Whitespace(""); + return "\n"; } // ---- Argument resolution (mirrors the analyzer) ---- From 663eed7796437d5900f73edbd283867e23fe82f4 Mon Sep 17 00:00:00 2001 From: Paul Irwin Date: Thu, 30 Apr 2026 20:56:25 -0600 Subject: [PATCH 9/9] Address Copilot review on LuceneDev4002 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use a symbol-based attribute check (IMethodSymbol.GetAttributes) instead of inspecting AttributeSyntax with a SemanticModel from the wrong tree. The previous code threw "Syntax node is not within syntax tree" whenever the target method lived in a different file from the call site and had any attribute. Symbol-based lookup also avoids Compilation.GetSemanticModel in the analyzer (which trips RS1030). Also: - Drop the Contains("NoInlining") string fallback in the syntax-based helper; the constant-value path already resolves MethodImplOptions.NoInlining and the fallback false-positives on identifiers like "NotNoInlining". - Replace the namespace walk in FindSourceTypeByName with Compilation.GetSymbolsWithName to leverage Roslyn's symbol index. - Update the analyzer's XML doc — the PR adds a code fix; the previous comment claimed there was none. - Add cross-document tests for both analyzer and code fix; the analyzer test reproduces the SemanticModel bug above. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...ackTraceHelperNoInliningCodeFixProvider.cs | 44 ++++---------- ...4002_StackTraceHelperNoInliningAnalyzer.cs | 50 ++++------------ .../NoInliningAttributeHelper.cs | 26 ++++++++- ...ackTraceHelperNoInliningCodeFixProvider.cs | 58 +++++++++++++++++++ ...4002_StackTraceHelperNoInliningAnalyzer.cs | 50 ++++++++++++++++ 5 files changed, 154 insertions(+), 74 deletions(-) diff --git a/src/Lucene.Net.CodeAnalysis.Dev.CodeFixes/LuceneDev4xxx/LuceneDev4002_StackTraceHelperNoInliningCodeFixProvider.cs b/src/Lucene.Net.CodeAnalysis.Dev.CodeFixes/LuceneDev4xxx/LuceneDev4002_StackTraceHelperNoInliningCodeFixProvider.cs index f138c8a..68bb458 100644 --- a/src/Lucene.Net.CodeAnalysis.Dev.CodeFixes/LuceneDev4xxx/LuceneDev4002_StackTraceHelperNoInliningCodeFixProvider.cs +++ b/src/Lucene.Net.CodeAnalysis.Dev.CodeFixes/LuceneDev4xxx/LuceneDev4002_StackTraceHelperNoInliningCodeFixProvider.cs @@ -16,7 +16,6 @@ * limitations under the License. */ -using System.Collections.Generic; using System.Collections.Immutable; using System.Composition; using System.Linq; @@ -106,14 +105,14 @@ private static async Task AddNoInliningToTargetAsync( if (member.MethodKind != MethodKind.Ordinary) continue; + if (NoInliningAttributeHelper.HasNoInliningAttribute(member, methodImplAttrSymbol)) + continue; + foreach (var declRef in member.DeclaringSyntaxReferences) { if (declRef.GetSyntax(cancellationToken) is not MethodDeclarationSyntax methodDecl) continue; - var declSemantic = compilation.GetSemanticModel(methodDecl.SyntaxTree); - if (NoInliningAttributeHelper.FindNoInliningAttribute(methodDecl, declSemantic, methodImplAttrSymbol) is not null) - continue; if (NoInliningAttributeHelper.HasEmptyBody(methodDecl)) continue; if (NoInliningAttributeHelper.IsInterfaceOrAbstractMethod(methodDecl)) @@ -267,40 +266,17 @@ private static (string? Name, INamedTypeSymbol? TypeFromNameof) ResolveClassRefe private static INamedTypeSymbol? FindSourceTypeByName(Compilation compilation, string typeName) { - foreach (var type in EnumerateAllTypes(compilation.Assembly.GlobalNamespace)) - { - if (type.Name == typeName) - return type; - } - return null; - } - - private static IEnumerable EnumerateAllTypes(INamespaceSymbol ns) - { - foreach (var member in ns.GetMembers()) + // Use Roslyn's symbol-name index instead of walking every namespace. + // Restrict to the source assembly so we don't match metadata types. + foreach (var symbol in compilation.GetSymbolsWithName(n => n == typeName, SymbolFilter.Type)) { - if (member is INamedTypeSymbol type) + if (symbol is INamedTypeSymbol type + && SymbolEqualityComparer.Default.Equals(type.ContainingAssembly, compilation.Assembly)) { - yield return type; - foreach (var nested in EnumerateNestedTypes(type)) - yield return nested; - } - else if (member is INamespaceSymbol child) - { - foreach (var t in EnumerateAllTypes(child)) - yield return t; + return type; } } - } - - private static IEnumerable EnumerateNestedTypes(INamedTypeSymbol type) - { - foreach (var nested in type.GetTypeMembers()) - { - yield return nested; - foreach (var deeper in EnumerateNestedTypes(nested)) - yield return deeper; - } + return null; } } } diff --git a/src/Lucene.Net.CodeAnalysis.Dev/LuceneDev4xxx/LuceneDev4002_StackTraceHelperNoInliningAnalyzer.cs b/src/Lucene.Net.CodeAnalysis.Dev/LuceneDev4xxx/LuceneDev4002_StackTraceHelperNoInliningAnalyzer.cs index f5c88ba..243cb29 100644 --- a/src/Lucene.Net.CodeAnalysis.Dev/LuceneDev4xxx/LuceneDev4002_StackTraceHelperNoInliningAnalyzer.cs +++ b/src/Lucene.Net.CodeAnalysis.Dev/LuceneDev4xxx/LuceneDev4002_StackTraceHelperNoInliningAnalyzer.cs @@ -16,7 +16,6 @@ * limitations under the License. */ -using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; using Lucene.Net.CodeAnalysis.Dev.Utility; @@ -32,10 +31,8 @@ namespace Lucene.Net.CodeAnalysis.Dev.LuceneDev4xxx /// StackTraceHelper.DoesStackTraceContainMethod(className, methodName) overload /// that lack [MethodImpl(MethodImplOptions.NoInlining)]. Without it the JIT may /// inline the method out of the stack trace, silently breaking the check. - /// - /// This analyzer has no code fix: the diagnostic is reported on the referenced - /// method declaration but is triggered by a separate invocation, which Roslyn - /// treats as a non-local diagnostic and does not allow code fixes for. + /// The diagnostic is reported on the invocation so the IDE surfaces it as a + /// local diagnostic and a code fix can apply the attribute to the target method. /// [DiagnosticAnalyzer(LanguageNames.CSharp)] public sealed class LuceneDev4002_StackTraceHelperNoInliningAnalyzer : DiagnosticAnalyzer @@ -110,14 +107,14 @@ private static void AnalyzeInvocation(SyntaxNodeAnalysisContext ctx, INamedTypeS if (member.MethodKind != MethodKind.Ordinary) continue; + if (NoInliningAttributeHelper.HasNoInliningAttribute(member, methodImplAttrSymbol)) + continue; + foreach (var declRef in member.DeclaringSyntaxReferences) { if (declRef.GetSyntax(ctx.CancellationToken) is not MethodDeclarationSyntax methodDecl) continue; - if (NoInliningAttributeHelper.FindNoInliningAttribute(methodDecl, ctx.SemanticModel, methodImplAttrSymbol) is not null) - continue; - if (NoInliningAttributeHelper.HasEmptyBody(methodDecl)) continue; @@ -192,40 +189,17 @@ private static (string? Name, INamedTypeSymbol? TypeFromNameof) ResolveClassRefe private static INamedTypeSymbol? FindSourceTypeByName(Compilation compilation, string typeName) { - foreach (var type in EnumerateAllTypes(compilation.Assembly.GlobalNamespace)) - { - if (type.Name == typeName) - return type; - } - return null; - } - - private static IEnumerable EnumerateAllTypes(INamespaceSymbol ns) - { - foreach (var member in ns.GetMembers()) + // Use Roslyn's symbol-name index instead of walking every namespace. + // Restrict to the source assembly so we don't match metadata types. + foreach (var symbol in compilation.GetSymbolsWithName(n => n == typeName, SymbolFilter.Type)) { - if (member is INamedTypeSymbol type) - { - yield return type; - foreach (var nested in EnumerateNestedTypes(type)) - yield return nested; - } - else if (member is INamespaceSymbol child) + if (symbol is INamedTypeSymbol type + && SymbolEqualityComparer.Default.Equals(type.ContainingAssembly, compilation.Assembly)) { - foreach (var t in EnumerateAllTypes(child)) - yield return t; + return type; } } - } - - private static IEnumerable EnumerateNestedTypes(INamedTypeSymbol type) - { - foreach (var nested in type.GetTypeMembers()) - { - yield return nested; - foreach (var deeper in EnumerateNestedTypes(nested)) - yield return deeper; - } + return null; } } } diff --git a/src/Lucene.Net.CodeAnalysis.Dev/LuceneDev4xxx/NoInliningAttributeHelper.cs b/src/Lucene.Net.CodeAnalysis.Dev/LuceneDev4xxx/NoInliningAttributeHelper.cs index 6a90f55..c0e0df5 100644 --- a/src/Lucene.Net.CodeAnalysis.Dev/LuceneDev4xxx/NoInliningAttributeHelper.cs +++ b/src/Lucene.Net.CodeAnalysis.Dev/LuceneDev4xxx/NoInliningAttributeHelper.cs @@ -25,6 +25,8 @@ namespace Lucene.Net.CodeAnalysis.Dev.LuceneDev4xxx { public static class NoInliningAttributeHelper { + private const int NoInlining = 0x0008; + public static AttributeSyntax? FindNoInliningAttribute( MethodDeclarationSyntax methodDecl, SemanticModel semantic, @@ -46,6 +48,27 @@ public static class NoInliningAttributeHelper return null; } + // Symbol-based variant: works across syntax trees and avoids + // Compilation.GetSemanticModel (which would trip RS1030 in an analyzer). + public static bool HasNoInliningAttribute( + IMethodSymbol method, + INamedTypeSymbol methodImplAttrSymbol) + { + foreach (var attr in method.GetAttributes()) + { + if (!SymbolEqualityComparer.Default.Equals(attr.AttributeClass, methodImplAttrSymbol)) + continue; + + if (attr.ConstructorArguments.Length == 0) + continue; + + var first = attr.ConstructorArguments[0]; + if (first.Value is int intValue && (intValue & NoInlining) == NoInlining) + return true; + } + return false; + } + private static bool AttributeSpecifiesNoInlining(AttributeSyntax attr, SemanticModel semantic) { if (attr.ArgumentList is null || attr.ArgumentList.Arguments.Count == 0) @@ -61,11 +84,10 @@ private static bool AttributeSpecifiesNoInlining(AttributeSyntax attr, SemanticM var constant = semantic.GetConstantValue(firstPositional.Expression); if (constant.HasValue && constant.Value is int intValue) { - const int NoInlining = 0x0008; return (intValue & NoInlining) == NoInlining; } - return firstPositional.Expression.ToString().Contains("NoInlining"); + return false; } public static bool IsInterfaceOrAbstractMethod(MethodDeclarationSyntax methodDecl) diff --git a/tests/Lucene.Net.CodeAnalysis.Dev.CodeFixes.Tests/LuceneDev4xxx/TestLuceneDev4002_StackTraceHelperNoInliningCodeFixProvider.cs b/tests/Lucene.Net.CodeAnalysis.Dev.CodeFixes.Tests/LuceneDev4xxx/TestLuceneDev4002_StackTraceHelperNoInliningCodeFixProvider.cs index 7931254..56b8d1a 100644 --- a/tests/Lucene.Net.CodeAnalysis.Dev.CodeFixes.Tests/LuceneDev4xxx/TestLuceneDev4002_StackTraceHelperNoInliningCodeFixProvider.cs +++ b/tests/Lucene.Net.CodeAnalysis.Dev.CodeFixes.Tests/LuceneDev4xxx/TestLuceneDev4002_StackTraceHelperNoInliningCodeFixProvider.cs @@ -165,6 +165,64 @@ public void Check() await test.RunAsync(); } + [Test] + public async Task Fix_AddsAttributeAndUsing_InTargetDocument_WhenTargetIsInDifferentFile() + { + // Target lives in /0/Test0.cs, Caller in /0/Test1.cs. The code fix must + // edit the target's document (adding the attribute and the using) rather + // than the caller's. + var targetSource = @" +public class Target +{ + public void Merge() + { + var x = 1; + } +} +"; + + var callerSource = @" +public class Caller +{ + public void Check() + { + if (Lucene.Net.Support.ExceptionHandling.StackTraceHelper.DoesStackTraceContainMethod(nameof(Target), nameof(Target.Merge))) + { + } + } +}" + StackTraceHelperStub; + + var fixedTargetSource = @"using System.Runtime.CompilerServices; + +public class Target +{ + [MethodImpl(MethodImplOptions.NoInlining)] + public void Merge() + { + var x = 1; + } +} +"; + + var expected = new DiagnosticResult(Descriptors.LuceneDev4002_MissingNoInlining) + .WithSeverity(DiagnosticSeverity.Warning) + .WithLocation("/0/Test1.cs", line: 6, column: 13) + .WithArguments("Target.Merge"); + + var test = new InjectableCodeFixTest( + () => new LuceneDev4002_StackTraceHelperNoInliningAnalyzer(), + () => new LuceneDev4002_StackTraceHelperNoInliningCodeFixProvider()) + { + ExpectedDiagnostics = { expected } + }; + test.TestState.Sources.Add(targetSource); + test.TestState.Sources.Add(callerSource); + test.FixedState.Sources.Add(fixedTargetSource); + test.FixedState.Sources.Add(callerSource); + + await test.RunAsync(); + } + [Test] public async Task Fix_PreservesExistingAttributeOnTarget() { diff --git a/tests/Lucene.Net.CodeAnalysis.Dev.Tests/LuceneDev4xxx/TestLuceneDev4002_StackTraceHelperNoInliningAnalyzer.cs b/tests/Lucene.Net.CodeAnalysis.Dev.Tests/LuceneDev4xxx/TestLuceneDev4002_StackTraceHelperNoInliningAnalyzer.cs index 8f6ce5d..2ae3414 100644 --- a/tests/Lucene.Net.CodeAnalysis.Dev.Tests/LuceneDev4xxx/TestLuceneDev4002_StackTraceHelperNoInliningAnalyzer.cs +++ b/tests/Lucene.Net.CodeAnalysis.Dev.Tests/LuceneDev4xxx/TestLuceneDev4002_StackTraceHelperNoInliningAnalyzer.cs @@ -145,6 +145,56 @@ public void Check() await test.RunAsync(); } + [Test] + public async Task LuceneDev4002_Reports_When_TargetMethod_Is_In_Different_Document() + { + // The Target type and the Caller live in separate source files. The analyzer + // must build a SemanticModel against the Target's syntax tree before + // inspecting attributes — using the caller's SemanticModel against a node + // from a different tree throws ArgumentException. Target carries an + // unrelated attribute so the attribute-walk path actually executes. + var targetSource = @" +using System; + +[AttributeUsage(AttributeTargets.Method)] +public class FooAttribute : Attribute { } + +public class Target +{ + [Foo] + public void Merge() + { + var x = 1; + } +} +"; + + var callerSource = @" +public class Caller +{ + public void Check() + { + if (Lucene.Net.Support.ExceptionHandling.StackTraceHelper.DoesStackTraceContainMethod(nameof(Target), nameof(Target.Merge))) + { + } + } +}" + StackTraceHelperStub; + + var expected = new DiagnosticResult(Descriptors.LuceneDev4002_MissingNoInlining) + .WithSeverity(DiagnosticSeverity.Warning) + .WithLocation("/0/Test1.cs", line: 6, column: 13) + .WithArguments("Target.Merge"); + + var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev4002_StackTraceHelperNoInliningAnalyzer()) + { + ExpectedDiagnostics = { expected } + }; + test.TestState.Sources.Add(targetSource); + test.TestState.Sources.Add(callerSource); + + await test.RunAsync(); + } + [Test] public async Task LuceneDev4002_NoDiagnostic_For_SingleArgOverload() {