You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@lucenenet.apache.org by ni...@apache.org on 2025/10/05 15:59:32 UTC

(lucenenet-codeanalysis-dev) branch main updated: Add analyzer and code fix for floating point string formatting (#9)

This is an automated email from the ASF dual-hosted git repository.

nightowl888 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/lucenenet-codeanalysis-dev.git


The following commit(s) were added to refs/heads/main by this push:
     new ba3ba01  Add analyzer and code fix for floating point string formatting (#9)
ba3ba01 is described below

commit ba3ba010f554036b382c012d3f0798dd813df014
Author: Sanjeev Kumar <ks...@gmail.com>
AuthorDate: Sun Oct 5 21:29:27 2025 +0530

    Add analyzer and code fix for floating point string formatting (#9)
    
    * Add analyzer and code fix for floating point string formatting
    
    Introduces LuceneDev1006 analyzer to detect floating point values embedded in strings via concatenation or interpolation, and updates the code fix provider to suggest using J2N.Numerics Single/Double.ToString methods. Adds supporting resources, descriptors, utility methods, sample usage, and comprehensive tests for both analyzer and code fix behaviors.
    
    * LuceneDev1006Sample.cs: Fixed issue with doubling of quotes that caused the sample not to compile.
    
    * Preserve alignment clause in interpolation code fix
    
    Ensures that the alignment clause is retained when replacing interpolation expressions in LuceneDev1001_FloatingPointFormattingCSCodeFixProvider. Also fixes string formatting in LuceneDev1006Sample to use correct quotes and spacing.
    
    * Preserve interpolation alignment and add coverage
    
    ---------
    
    Co-authored-by: Shad Storhaug <sh...@shadstorhaug.com>
---
 ...001_FloatingPointFormattingCSCodeFixProvider.cs | 241 ++++++++++++++++-----
 .../LuceneDev1006Sample.cs                         |  36 +++
 .../AnalyzerReleases.Unshipped.md                  |   1 +
 ...ngPointFormattingConcatenationCSCodeAnalyzer.cs | 126 +++++++++++
 src/Lucene.Net.CodeAnalysis.Dev/Resources.resx     |  12 +
 .../Utility/Descriptors.LuceneDev1xxx.cs           |   7 +
 .../Utility/FloatingPoint.cs                       |  44 ++++
 ...ointFormattingConcatenationCSCodeFixProvider.cs | 151 +++++++++++++
 ...ointFormattingInterpolationCSCodeFixProvider.cs | 215 ++++++++++++++++++
 ...ngPointFormattingConcatenationCSCodeAnalyzer.cs | 104 +++++++++
 ...ngPointFormattingInterpolationCSCodeAnalyzer.cs | 161 ++++++++++++++
 11 files changed, 1045 insertions(+), 53 deletions(-)

diff --git a/src/Lucene.Net.CodeAnalysis.Dev.CodeFixes/LuceneDev1xxx/LuceneDev1001_FloatingPointFormattingCSCodeFixProvider.cs b/src/Lucene.Net.CodeAnalysis.Dev.CodeFixes/LuceneDev1xxx/LuceneDev1001_FloatingPointFormattingCSCodeFixProvider.cs
index 7d278ee..c582f5b 100644
--- a/src/Lucene.Net.CodeAnalysis.Dev.CodeFixes/LuceneDev1xxx/LuceneDev1001_FloatingPointFormattingCSCodeFixProvider.cs
+++ b/src/Lucene.Net.CodeAnalysis.Dev.CodeFixes/LuceneDev1xxx/LuceneDev1001_FloatingPointFormattingCSCodeFixProvider.cs
@@ -1,4 +1,4 @@
-/*
+/*
  * 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.
@@ -36,94 +36,239 @@ namespace Lucene.Net.CodeAnalysis.Dev.CodeFixes
     public class LuceneDev1001_FloatingPointFormattingCSCodeFixProvider : CodeFixProvider
     {
         public override ImmutableArray<string> FixableDiagnosticIds =>
-            [Descriptors.LuceneDev1001_FloatingPointFormatting.Id];
+        [
+            Descriptors.LuceneDev1001_FloatingPointFormatting.Id,
+            Descriptors.LuceneDev1006_FloatingPointFormatting.Id
+        ];
 
         public override FixAllProvider GetFixAllProvider() =>
             WellKnownFixAllProviders.BatchFixer;
 
         public override async Task RegisterCodeFixesAsync(CodeFixContext context)
         {
-            Diagnostic? diagnostic = context.Diagnostics.FirstOrDefault();
-            if (diagnostic is null)
-                return;
-
             SyntaxNode? root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
             if (root is null)
                 return;
 
-            // the diagnostic in the analyzer is reported on the member access (e.g. "x.ToString")
-            // but we need the whole invocation (e.g. "x.ToString(...)").  So find the invocation
-            // by walking ancestors if needed.
-            SyntaxNode? node = root.FindNode(diagnostic.Location.SourceSpan);
-            if (node is null)
+            SemanticModel? semanticModel = await context.Document.GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false);
+            if (semanticModel is null)
                 return;
 
-            if (node is not ExpressionSyntax exprNode)
+            foreach (Diagnostic diagnostic in context.Diagnostics)
+            {
+                SyntaxNode? node = root.FindNode(diagnostic.Location.SourceSpan, getInnermostNodeForTie: true);
+                if (node is null)
+                    continue;
+
+                if (diagnostic.Id == Descriptors.LuceneDev1001_FloatingPointFormatting.Id)
+                {
+                    RegisterExplicitToStringFix(context, semanticModel, diagnostic, node);
+                }
+                else if (diagnostic.Id == Descriptors.LuceneDev1006_FloatingPointFormatting.Id)
+                {
+                    RegisterStringEmbeddingFix(context, semanticModel, diagnostic, node);
+                }
+            }
+        }
+
+        private void RegisterExplicitToStringFix(
+            CodeFixContext context,
+            SemanticModel semanticModel,
+            Diagnostic diagnostic,
+            SyntaxNode node)
+        {
+            if (node is not ExpressionSyntax expression)
                 return;
 
-            SemanticModel? semanticModel = await context.Document.GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false);
-            if (semanticModel is null)
+            if (!TryGetJ2NTypeAndMember(semanticModel, expression, out var j2nTypeName, out var memberAccess))
                 return;
 
-            if (!TryGetJ2NTypeAndMember(semanticModel, exprNode, out var j2nTypeName, out var memberAccess))
+            string codeElement = $"J2N.Numerics.{j2nTypeName}.ToString(...)";
+
+            context.RegisterCodeFix(
+                CodeActionHelper.CreateFromResource(
+                    CodeFixResources.UseX,
+                    c => ReplaceExplicitToStringAsync(context.Document, memberAccess, j2nTypeName, c),
+                    "UseJ2NToString",
+                    codeElement),
+                diagnostic);
+        }
+
+        private void RegisterStringEmbeddingFix(
+            CodeFixContext context,
+            SemanticModel semanticModel,
+            Diagnostic diagnostic,
+            SyntaxNode node)
+        {
+            ExpressionSyntax? expression = node as ExpressionSyntax ?? node.AncestorsAndSelf().OfType<ExpressionSyntax>().FirstOrDefault();
+            if (expression is null)
+                return;
+
+            if (!TryGetFloatingPointTypeName(semanticModel.GetTypeInfo(expression, context.CancellationToken), out var j2nTypeName))
                 return;
 
             string codeElement = $"J2N.Numerics.{j2nTypeName}.ToString(...)";
 
+            InterpolationSyntax? interpolation = expression.AncestorsAndSelf().OfType<InterpolationSyntax>().FirstOrDefault();
+            if (interpolation is not null)
+            {
+                context.RegisterCodeFix(
+                    CodeActionHelper.CreateFromResource(
+                        CodeFixResources.UseX,
+                        c => ReplaceInterpolationExpressionAsync(context.Document, interpolation, expression, j2nTypeName, c),
+                        "UseJ2NToString",
+                        codeElement),
+                    diagnostic);
+
+                return;
+            }
+
             context.RegisterCodeFix(
                 CodeActionHelper.CreateFromResource(
                     CodeFixResources.UseX,
-                    c => ReplaceWithJ2NToStringAsync(context.Document, memberAccess, c),
+                    c => ReplaceConcatenationExpressionAsync(context.Document, expression, j2nTypeName, c),
                     "UseJ2NToString",
                     codeElement),
                 diagnostic);
         }
 
-        private async Task<Document> ReplaceWithJ2NToStringAsync(
+        private async Task<Document> ReplaceExplicitToStringAsync(
             Document document,
             MemberAccessExpressionSyntax memberAccess,
+            string j2nTypeName,
             CancellationToken cancellationToken)
         {
-            SemanticModel? semanticModel = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false);
-            if (semanticModel is null)
+            if (memberAccess.Parent is not InvocationExpressionSyntax invocation)
                 return document;
 
-            if (!TryGetJ2NTypeAndMember(semanticModel, memberAccess, out var j2nTypeName, out _))
-                return document;
+            var newArguments = new List<ArgumentSyntax>
+            {
+                SyntaxFactory.Argument(memberAccess.Expression.WithoutTrivia())
+            };
+
+            if (invocation.ArgumentList is not null)
+                newArguments.AddRange(invocation.ArgumentList.Arguments);
+
+            InvocationExpressionSyntax replacement = CreateJ2NToStringInvocation(j2nTypeName, newArguments)
+                .WithTriviaFrom(invocation);
+
+            DocumentEditor editor = await DocumentEditor.CreateAsync(document, cancellationToken).ConfigureAwait(false);
+            editor.ReplaceNode(invocation, replacement);
+
+            return editor.GetChangedDocument();
+        }
+
+        private async Task<Document> ReplaceConcatenationExpressionAsync(
+            Document document,
+            ExpressionSyntax expression,
+            string j2nTypeName,
+            CancellationToken cancellationToken)
+        {
+            var arguments = new List<ArgumentSyntax>
+            {
+                SyntaxFactory.Argument(expression.WithoutTrivia())
+            };
+
+            InvocationExpressionSyntax replacement = CreateJ2NToStringInvocation(j2nTypeName, arguments)
+                .WithLeadingTrivia(expression.GetLeadingTrivia())
+                .WithTrailingTrivia(expression.GetTrailingTrivia());
 
-            // Build J2N.Numerics.Single/Double.ToString
-            MemberAccessExpressionSyntax j2nToStringAccess = SyntaxFactory.MemberAccessExpression(
+            DocumentEditor editor = await DocumentEditor.CreateAsync(document, cancellationToken).ConfigureAwait(false);
+            editor.ReplaceNode(expression, replacement);
+
+            return editor.GetChangedDocument();
+        }
+
+        private async Task<Document> ReplaceInterpolationExpressionAsync(
+            Document document,
+            InterpolationSyntax interpolation,
+            ExpressionSyntax expression,
+            string j2nTypeName,
+            CancellationToken cancellationToken)
+        {
+            var arguments = new List<ArgumentSyntax>
+            {
+                SyntaxFactory.Argument(expression.WithoutTrivia())
+            };
+
+            var updatedInterpolation = interpolation;
+            var alignmentClause = interpolation.AlignmentClause;
+
+            if (interpolation.FormatClause is not null)
+            {
+                var formatToken = interpolation.FormatClause.FormatStringToken;
+                var formatLiteral = SyntaxFactory.LiteralExpression(
+                    SyntaxKind.StringLiteralExpression,
+                    SyntaxFactory.Literal(formatToken.ValueText));
+                arguments.Add(SyntaxFactory.Argument(formatLiteral));
+                updatedInterpolation = updatedInterpolation.WithFormatClause(null);
+            }
+
+            InvocationExpressionSyntax replacementExpression = CreateJ2NToStringInvocation(j2nTypeName, arguments)
+                .WithLeadingTrivia(expression.GetLeadingTrivia())
+                .WithTrailingTrivia(expression.GetTrailingTrivia());
+
+            updatedInterpolation = updatedInterpolation.WithExpression(replacementExpression);
+
+            if (alignmentClause is not null)
+            {
+                updatedInterpolation = updatedInterpolation.WithAlignmentClause(alignmentClause);
+            }
+
+            updatedInterpolation = updatedInterpolation.WithAdditionalAnnotations(Formatter.Annotation);
+
+            DocumentEditor editor = await DocumentEditor.CreateAsync(document, cancellationToken).ConfigureAwait(false);
+            editor.ReplaceNode(interpolation, updatedInterpolation);
+
+            return editor.GetChangedDocument();
+        }
+
+        private static InvocationExpressionSyntax CreateJ2NToStringInvocation(
+            string j2nTypeName,
+            IEnumerable<ArgumentSyntax> arguments)
+        {
+            MemberAccessExpressionSyntax j2nTypeAccess = SyntaxFactory.MemberAccessExpression(
                 SyntaxKind.SimpleMemberAccessExpression,
                 SyntaxFactory.MemberAccessExpression(
                     SyntaxKind.SimpleMemberAccessExpression,
                     SyntaxFactory.IdentifierName("J2N"),
                     SyntaxFactory.IdentifierName("Numerics")),
-                SyntaxFactory.IdentifierName(j2nTypeName))
-                .WithAdditionalAnnotations(Formatter.Annotation);
+                SyntaxFactory.IdentifierName(j2nTypeName));
 
-            MemberAccessExpressionSyntax fullAccess = SyntaxFactory.MemberAccessExpression(
+            MemberAccessExpressionSyntax toStringAccess = SyntaxFactory.MemberAccessExpression(
                 SyntaxKind.SimpleMemberAccessExpression,
-                j2nToStringAccess,
+                j2nTypeAccess,
                 SyntaxFactory.IdentifierName("ToString"));
 
-            // Build invocation: J2N.Numerics.<Single|Double>.ToString(<expr>, <original args...>)
-            if (memberAccess.Parent is not InvocationExpressionSyntax invocation)
-                return document;
+            return SyntaxFactory.InvocationExpression(
+                toStringAccess,
+                SyntaxFactory.ArgumentList(SyntaxFactory.SeparatedList(arguments)))
+                .WithAdditionalAnnotations(Formatter.Annotation);
+        }
 
-            var newArgs = new List<ArgumentSyntax> { SyntaxFactory.Argument(memberAccess.Expression) };
-            if (invocation.ArgumentList != null)
-                newArgs.AddRange(invocation.ArgumentList.Arguments);
 
-            InvocationExpressionSyntax newInvocation = SyntaxFactory.InvocationExpression(
-                fullAccess,
-                SyntaxFactory.ArgumentList(SyntaxFactory.SeparatedList(newArgs)))
-                .WithTriviaFrom(invocation) // safe now
-                .WithAdditionalAnnotations(Formatter.Annotation);
+        private static bool TryGetFloatingPointTypeName(TypeInfo typeInfo, out string typeName)
+        {
+            if (TryGetFloatingPointTypeName(typeInfo.Type, out typeName))
+                return true;
 
-            DocumentEditor editor = await DocumentEditor.CreateAsync(document, cancellationToken).ConfigureAwait(false);
-            editor.ReplaceNode(memberAccess.Parent, newInvocation);
+            if (TryGetFloatingPointTypeName(typeInfo.ConvertedType, out typeName))
+                return true;
 
-            return editor.GetChangedDocument();
+            typeName = null!;
+            return false;
+        }
+
+        private static bool TryGetFloatingPointTypeName(ITypeSymbol? typeSymbol, out string typeName)
+        {
+            typeName = typeSymbol?.SpecialType switch
+            {
+                SpecialType.System_Single => "Single",
+                SpecialType.System_Double => "Double",
+                _ => null!
+            };
+
+            return typeName is not null;
         }
 
         private static bool TryGetJ2NTypeAndMember(
@@ -137,21 +282,11 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context)
 
             if (memberAccess is null)
             {
-                j2nTypeName = null!; // we always return false when the value is null, so we can ignore it here.
+                j2nTypeName = null!;
                 return false;
             }
 
-            var typeInfo = semanticModel.GetTypeInfo(memberAccess.Expression);
-            var type = typeInfo.Type;
-
-            j2nTypeName = type?.SpecialType switch
-            {
-                SpecialType.System_Single => "Single",
-                SpecialType.System_Double => "Double",
-                _ => null! // we always return false when the value is null, so we can ignore it here.
-            };
-
-            if (j2nTypeName is null)
+            if (!TryGetFloatingPointTypeName(semanticModel.GetTypeInfo(memberAccess.Expression), out j2nTypeName))
                 return false;
 
             return true;
diff --git a/src/Lucene.Net.CodeAnalysis.Dev.Sample/LuceneDev1006Sample.cs b/src/Lucene.Net.CodeAnalysis.Dev.Sample/LuceneDev1006Sample.cs
new file mode 100644
index 0000000..2bc6be7
--- /dev/null
+++ b/src/Lucene.Net.CodeAnalysis.Dev.Sample/LuceneDev1006Sample.cs
@@ -0,0 +1,36 @@
+/*
+ * 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.
+ */
+
+namespace Lucene.Net.CodeAnalysis.Dev.Sample;
+
+public class LuceneDev1006Sample
+{
+    private readonly float levelBottom = 1f;
+    private readonly double maxLevel = 2d;
+
+    public string DescribeConcatenation()
+    {
+        return "  level " + levelBottom + " to " + maxLevel;
+    }
+
+    public string DescribeInterpolation()
+    {
+        return $"  level {levelBottom} to {maxLevel}";
+    }
+}
diff --git a/src/Lucene.Net.CodeAnalysis.Dev/AnalyzerReleases.Unshipped.md b/src/Lucene.Net.CodeAnalysis.Dev/AnalyzerReleases.Unshipped.md
index f8f5973..c347c6a 100644
--- a/src/Lucene.Net.CodeAnalysis.Dev/AnalyzerReleases.Unshipped.md
+++ b/src/Lucene.Net.CodeAnalysis.Dev/AnalyzerReleases.Unshipped.md
@@ -3,3 +3,4 @@
  Rule ID       | Category | Severity | Notes
 ---------------|----------|----------|-----------------------------------------------------------------------------------------------------------------------------------------------------------
  LuceneDev1005 | Design   | Warning  | Types in the Lucene.Net.Support namespace should not be public
+ LuceneDev1006 | Design   | Warning  | Floating point values embedded in strings should be formatted with J2N.Numerics Single or Double ToString methods
diff --git a/src/Lucene.Net.CodeAnalysis.Dev/LuceneDev1xxx/LuceneDev1006_FloatingPointFormattingConcatenationCSCodeAnalyzer.cs b/src/Lucene.Net.CodeAnalysis.Dev/LuceneDev1xxx/LuceneDev1006_FloatingPointFormattingConcatenationCSCodeAnalyzer.cs
new file mode 100644
index 0000000..46c22b9
--- /dev/null
+++ b/src/Lucene.Net.CodeAnalysis.Dev/LuceneDev1xxx/LuceneDev1006_FloatingPointFormattingConcatenationCSCodeAnalyzer.cs
@@ -0,0 +1,126 @@
+/*
+ * 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.Utility;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Microsoft.CodeAnalysis.Diagnostics;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Linq;
+using System.Threading;
+
+namespace Lucene.Net.CodeAnalysis.Dev
+{
+    [DiagnosticAnalyzer(LanguageNames.CSharp)]
+    public class LuceneDev1006_FloatingPointFormattingConcatenationCSCodeAnalyzer : DiagnosticAnalyzer
+    {
+        public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => [Descriptors.LuceneDev1006_FloatingPointFormatting];
+
+        public override void Initialize(AnalysisContext context)
+        {
+            context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze);
+            context.EnableConcurrentExecution();
+            context.RegisterSyntaxNodeAction(AnalyzeAddExpression, SyntaxKind.AddExpression);
+            context.RegisterSyntaxNodeAction(AnalyzeInterpolatedStringExpression, SyntaxKind.InterpolatedStringExpression);
+        }
+
+        private static void AnalyzeAddExpression(SyntaxNodeAnalysisContext context)
+        {
+            if (context.Node is not BinaryExpressionSyntax addExpression)
+                return;
+
+            if (!IsStringConcatenation(addExpression, context.SemanticModel, context.CancellationToken))
+                return;
+
+            if (addExpression.Parent is BinaryExpressionSyntax parent &&
+                parent.IsKind(SyntaxKind.AddExpression) &&
+                IsStringConcatenation(parent, context.SemanticModel, context.CancellationToken))
+            {
+                // Only analyze the outermost concatenation expression to avoid duplicate diagnostics.
+                return;
+            }
+
+            foreach (var operand in FlattenConcatenation(addExpression))
+            {
+                var expression = operand is ParenthesizedExpressionSyntax parenthesized
+                    ? parenthesized.Expression
+                    : operand;
+
+                var typeInfo = context.SemanticModel.GetTypeInfo(expression, context.CancellationToken);
+                if (!FloatingPoint.IsFloatingPointType(typeInfo))
+                    continue;
+
+                ReportDiagnostic(context, expression);
+            }
+        }
+
+        private static void AnalyzeInterpolatedStringExpression(SyntaxNodeAnalysisContext context)
+        {
+            if (context.Node is not InterpolatedStringExpressionSyntax interpolatedString)
+                return;
+
+            foreach (var interpolation in interpolatedString.Contents.OfType<InterpolationSyntax>())
+            {
+                if (interpolation.Expression is null)
+                    continue;
+
+                var typeInfo = context.SemanticModel.GetTypeInfo(interpolation.Expression, context.CancellationToken);
+                if (!FloatingPoint.IsFloatingPointType(typeInfo))
+                    continue;
+
+                ReportDiagnostic(context, interpolation.Expression);
+            }
+        }
+
+        private static void ReportDiagnostic(SyntaxNodeAnalysisContext context, ExpressionSyntax expression)
+        {
+            var diagnostic = Diagnostic.Create(
+                Descriptors.LuceneDev1006_FloatingPointFormatting,
+                expression.GetLocation(),
+                expression.ToString());
+
+            context.ReportDiagnostic(diagnostic);
+        }
+
+        private static bool IsStringConcatenation(ExpressionSyntax expression, SemanticModel semanticModel, CancellationToken cancellationToken)
+        {
+            var typeInfo = semanticModel.GetTypeInfo(expression, cancellationToken);
+            return typeInfo.Type?.SpecialType == SpecialType.System_String
+                || typeInfo.ConvertedType?.SpecialType == SpecialType.System_String;
+        }
+
+        private static IEnumerable<ExpressionSyntax> FlattenConcatenation(ExpressionSyntax expression)
+        {
+            if (expression is BinaryExpressionSyntax binary && binary.IsKind(SyntaxKind.AddExpression))
+            {
+                foreach (var left in FlattenConcatenation(binary.Left))
+                    yield return left;
+
+                foreach (var right in FlattenConcatenation(binary.Right))
+                    yield return right;
+            }
+            else
+            {
+                yield return expression;
+            }
+        }
+    }
+}
diff --git a/src/Lucene.Net.CodeAnalysis.Dev/Resources.resx b/src/Lucene.Net.CodeAnalysis.Dev/Resources.resx
index 8bd0b59..2786230 100644
--- a/src/Lucene.Net.CodeAnalysis.Dev/Resources.resx
+++ b/src/Lucene.Net.CodeAnalysis.Dev/Resources.resx
@@ -206,4 +206,16 @@ under the License.
   <data name="LuceneDev1005_AnalyzerMessageFormat" xml:space="preserve">
     <value>{0} '{1}' should not have public accessibility in the Support namespace</value>
   </data>
+  <data name="LuceneDev1006_AnalyzerTitle" xml:space="preserve">
+    <value>Floating point values embedded in strings should use J2N Numerics formatting</value>
+    <comment>The title of the diagnostic.</comment>
+  </data>
+  <data name="LuceneDev1006_AnalyzerDescription" xml:space="preserve">
+    <value>Floating point values should be formatted with J2N.Numerics.Single.ToString() or J2N.Numerics.Double.ToString() before being embedded into strings to ensure consistent behavior across runtimes.</value>
+    <comment>An optional longer localizable description of the diagnostic.</comment>
+  </data>
+  <data name="LuceneDev1006_AnalyzerMessageFormat" xml:space="preserve">
+    <value>'{0}' may fail due to floating point precision issues on .NET Framework and .NET Core prior to version 3.0. Floating point values should be formatted with J2N.Numerics.Single.ToString() or J2N.Numerics.Double.ToString() before being embedded into strings.</value>
+    <comment>The format-able message the diagnostic displays.</comment>
+  </data>
 </root>
diff --git a/src/Lucene.Net.CodeAnalysis.Dev/Utility/Descriptors.LuceneDev1xxx.cs b/src/Lucene.Net.CodeAnalysis.Dev/Utility/Descriptors.LuceneDev1xxx.cs
index fcf5cb5..7fa199c 100644
--- a/src/Lucene.Net.CodeAnalysis.Dev/Utility/Descriptors.LuceneDev1xxx.cs
+++ b/src/Lucene.Net.CodeAnalysis.Dev/Utility/Descriptors.LuceneDev1xxx.cs
@@ -70,5 +70,12 @@ public static partial class Descriptors
                 Design,
                 Warning
             );
+
+        public static readonly DiagnosticDescriptor LuceneDev1006_FloatingPointFormatting =
+            Diagnostic(
+                "LuceneDev1006",
+                Design,
+                Warning
+            );
     }
 }
diff --git a/src/Lucene.Net.CodeAnalysis.Dev/Utility/FloatingPoint.cs b/src/Lucene.Net.CodeAnalysis.Dev/Utility/FloatingPoint.cs
index f9fbe97..c960d1e 100644
--- a/src/Lucene.Net.CodeAnalysis.Dev/Utility/FloatingPoint.cs
+++ b/src/Lucene.Net.CodeAnalysis.Dev/Utility/FloatingPoint.cs
@@ -39,6 +39,50 @@ public static bool IsFloatingPointType(SymbolInfo symbolInfo)
             return false;
         }
 
+
+        public static bool IsFloatingPointType(TypeInfo typeInfo)
+        {
+            if (IsFloatingPointType(typeInfo.Type))
+                return true;
+
+            if (IsFloatingPointType(typeInfo.ConvertedType))
+                return true;
+
+            return false;
+        }
+
+        public static bool IsFloatingPointType(ITypeSymbol? typeSymbol)
+        {
+            if (typeSymbol is null)
+                return false;
+
+            return IsSpecialTypeFloatingPoint(typeSymbol.SpecialType);
+        }
+
+        public static bool TryGetFloatingPointTypeName(TypeInfo typeInfo, out string typeName)
+        {
+            if (TryGetFloatingPointTypeName(typeInfo.Type, out typeName))
+                return true;
+
+            if (TryGetFloatingPointTypeName(typeInfo.ConvertedType, out typeName))
+                return true;
+
+            typeName = null!;
+            return false;
+        }
+
+        public static bool TryGetFloatingPointTypeName(ITypeSymbol? typeSymbol, out string typeName)
+        {
+            typeName = typeSymbol?.SpecialType switch
+            {
+                SpecialType.System_Single => "Single",
+                SpecialType.System_Double => "Double",
+                _ => null!
+            };
+
+            return typeName is not null;
+        }
+
         private static bool IsSpecialTypeFloatingPoint(SpecialType specialType)
         {
             return specialType == SpecialType.System_Single || specialType == SpecialType.System_Double;
diff --git a/tests/Lucene.Net.CodeAnalysis.Dev.CodeFixes.Tests/LuceneDev1xxx/TestLuceneDev1006_FloatingPointFormattingConcatenationCSCodeFixProvider.cs b/tests/Lucene.Net.CodeAnalysis.Dev.CodeFixes.Tests/LuceneDev1xxx/TestLuceneDev1006_FloatingPointFormattingConcatenationCSCodeFixProvider.cs
new file mode 100644
index 0000000..eb96e85
--- /dev/null
+++ b/tests/Lucene.Net.CodeAnalysis.Dev.CodeFixes.Tests/LuceneDev1xxx/TestLuceneDev1006_FloatingPointFormattingConcatenationCSCodeFixProvider.cs
@@ -0,0 +1,151 @@
+/*
+ * 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.TestUtilities;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Testing;
+using NUnit.Framework;
+using System.Threading.Tasks;
+using Lucene.Net.CodeAnalysis.Dev.Utility;
+
+namespace Lucene.Net.CodeAnalysis.Dev.CodeFixes
+{
+    public class TestLuceneDev1006_FloatingPointFormattingConcatenationCSCodeFixProvider
+    {
+        [Test]
+        public async Task TestFix_FloatInConcatenation()
+        {
+            var testCode = @"
+namespace J2N.Numerics
+{
+    public static class Single
+    {
+        public static string ToString(float value) => value.ToString();
+    }
+}
+
+public class C
+{
+    private float levelBottom = 1f;
+
+    public string Message()
+    {
+        return ""  level "" + levelBottom;
+    }
+}
+";
+
+            var fixedCode = @"
+namespace J2N.Numerics
+{
+    public static class Single
+    {
+        public static string ToString(float value) => value.ToString();
+    }
+}
+
+public class C
+{
+    private float levelBottom = 1f;
+
+    public string Message()
+    {
+        return ""  level "" + J2N.Numerics.Single.ToString(levelBottom);
+    }
+}
+";
+
+            var expected = new DiagnosticResult(Descriptors.LuceneDev1006_FloatingPointFormatting)
+                .WithSeverity(DiagnosticSeverity.Warning)
+                .WithMessageFormat(Descriptors.LuceneDev1006_FloatingPointFormatting.MessageFormat)
+                .WithArguments("levelBottom")
+                .WithLocation("/0/Test0.cs", line: 16, column: 29);
+
+            var test = new InjectableCodeFixTest(
+                () => new LuceneDev1006_FloatingPointFormattingConcatenationCSCodeAnalyzer(),
+                () => new LuceneDev1001_FloatingPointFormattingCSCodeFixProvider())
+            {
+                TestCode = testCode,
+                FixedCode = fixedCode,
+                ExpectedDiagnostics = { expected }
+            };
+
+            await test.RunAsync();
+        }
+
+        [Test]
+        public async Task TestFix_DoubleInConcatenation()
+        {
+            var testCode = @"
+namespace J2N.Numerics
+{
+    public static class Double
+    {
+        public static string ToString(double value) => value.ToString();
+    }
+}
+
+public class C
+{
+    private double maxLevel = 2d;
+
+    public string Message()
+    {
+        return ""  level "" + maxLevel;
+    }
+}
+";
+
+            var fixedCode = @"
+namespace J2N.Numerics
+{
+    public static class Double
+    {
+        public static string ToString(double value) => value.ToString();
+    }
+}
+
+public class C
+{
+    private double maxLevel = 2d;
+
+    public string Message()
+    {
+        return ""  level "" + J2N.Numerics.Double.ToString(maxLevel);
+    }
+}
+";
+
+            var expected = new DiagnosticResult(Descriptors.LuceneDev1006_FloatingPointFormatting)
+                .WithSeverity(DiagnosticSeverity.Warning)
+                .WithMessageFormat(Descriptors.LuceneDev1006_FloatingPointFormatting.MessageFormat)
+                .WithArguments("maxLevel")
+                .WithLocation("/0/Test0.cs", line: 16, column: 29);
+
+            var test = new InjectableCodeFixTest(
+                () => new LuceneDev1006_FloatingPointFormattingConcatenationCSCodeAnalyzer(),
+                () => new LuceneDev1001_FloatingPointFormattingCSCodeFixProvider())
+            {
+                TestCode = testCode,
+                FixedCode = fixedCode,
+                ExpectedDiagnostics = { expected }
+            };
+
+            await test.RunAsync();
+        }
+    }
+}
diff --git a/tests/Lucene.Net.CodeAnalysis.Dev.CodeFixes.Tests/LuceneDev1xxx/TestLuceneDev1006_FloatingPointFormattingInterpolationCSCodeFixProvider.cs b/tests/Lucene.Net.CodeAnalysis.Dev.CodeFixes.Tests/LuceneDev1xxx/TestLuceneDev1006_FloatingPointFormattingInterpolationCSCodeFixProvider.cs
new file mode 100644
index 0000000..0055615
--- /dev/null
+++ b/tests/Lucene.Net.CodeAnalysis.Dev.CodeFixes.Tests/LuceneDev1xxx/TestLuceneDev1006_FloatingPointFormattingInterpolationCSCodeFixProvider.cs
@@ -0,0 +1,215 @@
+/*
+ * 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.TestUtilities;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Testing;
+using NUnit.Framework;
+using System.Threading.Tasks;
+using Lucene.Net.CodeAnalysis.Dev.Utility;
+
+namespace Lucene.Net.CodeAnalysis.Dev.CodeFixes
+{
+    public class TestLuceneDev1006_FloatingPointFormattingInterpolationCSCodeFixProvider
+    {
+        [Test]
+        public async Task TestFix_FloatInInterpolation()
+        {
+            var testCode = @"
+namespace J2N.Numerics
+{
+    public static class Single
+    {
+        public static string ToString(float value) => value.ToString();
+    }
+}
+
+public class C
+{
+    private float levelBottom = 1f;
+
+    public string Message()
+    {
+        return $""  level {levelBottom}"";
+    }
+}
+";
+
+            var fixedCode = @"
+namespace J2N.Numerics
+{
+    public static class Single
+    {
+        public static string ToString(float value) => value.ToString();
+    }
+}
+
+public class C
+{
+    private float levelBottom = 1f;
+
+    public string Message()
+    {
+        return $""  level {J2N.Numerics.Single.ToString(levelBottom)}"";
+    }
+}
+";
+
+            var expected = new DiagnosticResult(Descriptors.LuceneDev1006_FloatingPointFormatting)
+                .WithSeverity(DiagnosticSeverity.Warning)
+                .WithMessageFormat(Descriptors.LuceneDev1006_FloatingPointFormatting.MessageFormat)
+                .WithArguments("levelBottom")
+                .WithLocation("/0/Test0.cs", line: 16, column: 27);
+
+            var test = new InjectableCodeFixTest(
+                () => new LuceneDev1006_FloatingPointFormattingConcatenationCSCodeAnalyzer(),
+                () => new LuceneDev1001_FloatingPointFormattingCSCodeFixProvider())
+            {
+                TestCode = testCode,
+                FixedCode = fixedCode,
+                ExpectedDiagnostics = { expected }
+            };
+
+            await test.RunAsync();
+        }
+
+
+        [Test]
+        public async Task TestFix_WithAlignmentAndFormatClause()
+        {
+            var testCode = @"namespace J2N.Numerics
+{
+    public static class Single
+    {
+        public static string ToString(float value) => value.ToString();
+        public static string ToString(float value, string format) => value.ToString(format);
+    }
+}
+
+public class C
+{
+    private float levelBottom = 1f;
+
+    public string Message()
+    {
+        return $""  level {levelBottom,5:F2}"";
+    }
+}
+";
+
+            var fixedCode = @"namespace J2N.Numerics
+{
+    public static class Single
+    {
+        public static string ToString(float value) => value.ToString();
+        public static string ToString(float value, string format) => value.ToString(format);
+    }
+}
+
+public class C
+{
+    private float levelBottom = 1f;
+
+    public string Message()
+    {
+        return $""  level {J2N.Numerics.Single.ToString(levelBottom, ""F2""),5}"";
+    }
+}
+";
+
+            var expected = new DiagnosticResult(Descriptors.LuceneDev1006_FloatingPointFormatting)
+                .WithSeverity(DiagnosticSeverity.Warning)
+                .WithMessageFormat(Descriptors.LuceneDev1006_FloatingPointFormatting.MessageFormat)
+                .WithArguments("levelBottom")
+                .WithLocation("/0/Test0.cs", line: 16, column: 27);
+
+            var test = new InjectableCodeFixTest(
+                () => new LuceneDev1006_FloatingPointFormattingConcatenationCSCodeAnalyzer(),
+                () => new LuceneDev1001_FloatingPointFormattingCSCodeFixProvider())
+            {
+                TestCode = testCode,
+                FixedCode = fixedCode,
+                ExpectedDiagnostics = { expected }
+            };
+
+            await test.RunAsync();
+        }
+
+        [Test]
+        public async Task TestFix_WithFormatClause()
+        {
+            var testCode = @"
+namespace J2N.Numerics
+{
+    public static class Single
+    {
+        public static string ToString(float value) => value.ToString();
+        public static string ToString(float value, string format) => value.ToString(format);
+    }
+}
+
+public class C
+{
+    private float levelBottom = 1f;
+
+    public string Message()
+    {
+        return $""  level {levelBottom:F2}"";
+    }
+}
+";
+
+            var fixedCode = @"
+namespace J2N.Numerics
+{
+    public static class Single
+    {
+        public static string ToString(float value) => value.ToString();
+        public static string ToString(float value, string format) => value.ToString(format);
+    }
+}
+
+public class C
+{
+    private float levelBottom = 1f;
+
+    public string Message()
+    {
+        return $""  level {J2N.Numerics.Single.ToString(levelBottom, ""F2"")}"";
+    }
+}
+";
+
+            var expected = new DiagnosticResult(Descriptors.LuceneDev1006_FloatingPointFormatting)
+                .WithSeverity(DiagnosticSeverity.Warning)
+                .WithMessageFormat(Descriptors.LuceneDev1006_FloatingPointFormatting.MessageFormat)
+                .WithArguments("levelBottom")
+                .WithLocation("/0/Test0.cs", line: 17, column: 27);
+
+            var test = new InjectableCodeFixTest(
+                () => new LuceneDev1006_FloatingPointFormattingConcatenationCSCodeAnalyzer(),
+                () => new LuceneDev1001_FloatingPointFormattingCSCodeFixProvider())
+            {
+                TestCode = testCode,
+                FixedCode = fixedCode,
+                ExpectedDiagnostics = { expected }
+            };
+
+            await test.RunAsync();
+        }
+    }
+}
diff --git a/tests/Lucene.Net.CodeAnalysis.Dev.Tests/LuceneDev1xxx/TestLuceneDev1006_FloatingPointFormattingConcatenationCSCodeAnalyzer.cs b/tests/Lucene.Net.CodeAnalysis.Dev.Tests/LuceneDev1xxx/TestLuceneDev1006_FloatingPointFormattingConcatenationCSCodeAnalyzer.cs
new file mode 100644
index 0000000..cdba3b7
--- /dev/null
+++ b/tests/Lucene.Net.CodeAnalysis.Dev.Tests/LuceneDev1xxx/TestLuceneDev1006_FloatingPointFormattingConcatenationCSCodeAnalyzer.cs
@@ -0,0 +1,104 @@
+/*
+ * 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.TestUtilities;
+using Microsoft.CodeAnalysis.Testing;
+using NUnit.Framework;
+using System.Threading.Tasks;
+using Lucene.Net.CodeAnalysis.Dev.Utility;
+
+namespace Lucene.Net.CodeAnalysis.Dev
+{
+    public class TestLuceneDev1006_FloatingPointFormattingConcatenationCSCodeAnalyzer
+    {
+        [Test]
+        public async Task TestDiagnostic_FloatValuesInConcatenation()
+        {
+            var testCode = @"
+public class C
+{
+    private float levelBottom = 1f;
+    private float maxLevel = 2f;
+
+    private string Message(string value) => value;
+
+    public void M(int upto, int start)
+    {
+        Message(""  level "" + levelBottom + "" to "" + maxLevel + "": "" + (1 + upto - start) + "" segments"");
+    }
+}
+";
+
+            var expectedLevelBottom = DiagnosticResult.CompilerWarning(Descriptors.LuceneDev1006_FloatingPointFormatting.Id)
+                .WithMessageFormat(Descriptors.LuceneDev1006_FloatingPointFormatting.MessageFormat)
+                .WithArguments("levelBottom")
+                .WithLocation("/0/Test0.cs", line: 11, column: 30);
+
+            var expectedMaxLevel = DiagnosticResult.CompilerWarning(Descriptors.LuceneDev1006_FloatingPointFormatting.Id)
+                .WithMessageFormat(Descriptors.LuceneDev1006_FloatingPointFormatting.MessageFormat)
+                .WithArguments("maxLevel")
+                .WithLocation("/0/Test0.cs", line: 11, column: 53);
+
+            var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev1006_FloatingPointFormattingConcatenationCSCodeAnalyzer())
+            {
+                TestCode = testCode,
+                ExpectedDiagnostics = { expectedLevelBottom, expectedMaxLevel }
+            };
+
+            await test.RunAsync();
+        }
+
+        [Test]
+        public async Task TestNoDiagnostic_WhenValuesAlreadyFormatted()
+        {
+            var testCode = @"
+namespace J2N.Numerics
+{
+    public static class Single
+    {
+        public static string ToString(float value) => value.ToString();
+    }
+
+    public static class Double
+    {
+        public static string ToString(double value) => value.ToString();
+    }
+}
+
+public class C
+{
+    private float levelBottom = 1f;
+    private float maxLevel = 2f;
+
+    private string Message(string value) => value;
+
+    public void M()
+    {
+        Message(""  level "" + J2N.Numerics.Single.ToString(levelBottom) + "" to "" + J2N.Numerics.Single.ToString(maxLevel));
+    }
+}
+";
+
+            var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev1006_FloatingPointFormattingConcatenationCSCodeAnalyzer())
+            {
+                TestCode = testCode
+            };
+
+            await test.RunAsync();
+        }
+    }
+}
diff --git a/tests/Lucene.Net.CodeAnalysis.Dev.Tests/LuceneDev1xxx/TestLuceneDev1006_FloatingPointFormattingInterpolationCSCodeAnalyzer.cs b/tests/Lucene.Net.CodeAnalysis.Dev.Tests/LuceneDev1xxx/TestLuceneDev1006_FloatingPointFormattingInterpolationCSCodeAnalyzer.cs
new file mode 100644
index 0000000..ff409b7
--- /dev/null
+++ b/tests/Lucene.Net.CodeAnalysis.Dev.Tests/LuceneDev1xxx/TestLuceneDev1006_FloatingPointFormattingInterpolationCSCodeAnalyzer.cs
@@ -0,0 +1,161 @@
+/*
+ * 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.TestUtilities;
+using Microsoft.CodeAnalysis.Testing;
+using NUnit.Framework;
+using System.Threading.Tasks;
+using Lucene.Net.CodeAnalysis.Dev.Utility;
+
+namespace Lucene.Net.CodeAnalysis.Dev
+{
+    public class TestLuceneDev1006_FloatingPointFormattingInterpolationCSCodeAnalyzer
+    {
+        [Test]
+        public async Task TestDiagnostic_FloatValuesInInterpolation()
+        {
+            var testCode = @"
+public class C
+{
+    private float levelBottom = 1f;
+    private double maxLevel = 2d;
+
+    private string Message(string value) => value;
+
+    public void M()
+    {
+        Message($""  level {levelBottom} to {maxLevel}"");
+    }
+}
+";
+
+            var expectedLevelBottom = DiagnosticResult.CompilerWarning(Descriptors.LuceneDev1006_FloatingPointFormatting.Id)
+                .WithMessageFormat(Descriptors.LuceneDev1006_FloatingPointFormatting.MessageFormat)
+                .WithArguments("levelBottom")
+                .WithLocation("/0/Test0.cs", line: 11, column: 28);
+
+            var expectedMaxLevel = DiagnosticResult.CompilerWarning(Descriptors.LuceneDev1006_FloatingPointFormatting.Id)
+                .WithMessageFormat(Descriptors.LuceneDev1006_FloatingPointFormatting.MessageFormat)
+                .WithArguments("maxLevel")
+                .WithLocation("/0/Test0.cs", line: 11, column: 45);
+
+            var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev1006_FloatingPointFormattingConcatenationCSCodeAnalyzer())
+            {
+                TestCode = testCode,
+                ExpectedDiagnostics = { expectedLevelBottom, expectedMaxLevel }
+            };
+
+            await test.RunAsync();
+        }
+
+        [Test]
+        public async Task TestDiagnostic_WithFormatClause()
+        {
+            var testCode = @"
+public class C
+{
+    private float levelBottom = 1f;
+
+    private string Message(string value) => value;
+
+    public void M()
+    {
+        Message($""  level {levelBottom:F2}"");
+    }
+}
+";
+
+            var expected = DiagnosticResult.CompilerWarning(Descriptors.LuceneDev1006_FloatingPointFormatting.Id)
+                .WithMessageFormat(Descriptors.LuceneDev1006_FloatingPointFormatting.MessageFormat)
+                .WithArguments("levelBottom")
+                .WithLocation("/0/Test0.cs", line: 10, column: 28);
+
+            var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev1006_FloatingPointFormattingConcatenationCSCodeAnalyzer())
+            {
+                TestCode = testCode,
+                ExpectedDiagnostics = { expected }
+            };
+
+            await test.RunAsync();
+        }
+
+        [Test]
+        public async Task TestDiagnostic_WithAlignmentAndFormatClause()
+        {
+            var testCode = @"
+public class C
+{
+    private float levelBottom = 1f;
+
+    private string Message(string value) => value;
+
+    public void M()
+    {
+        Message($""  level {levelBottom,5:F2}"");
+    }
+}
+";
+
+            var expected = DiagnosticResult.CompilerWarning(Descriptors.LuceneDev1006_FloatingPointFormatting.Id)
+                .WithMessageFormat(Descriptors.LuceneDev1006_FloatingPointFormatting.MessageFormat)
+                .WithArguments("levelBottom")
+                .WithLocation("/0/Test0.cs", line: 10, column: 28);
+
+            var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev1006_FloatingPointFormattingConcatenationCSCodeAnalyzer())
+            {
+                TestCode = testCode,
+                ExpectedDiagnostics = { expected }
+            };
+
+            await test.RunAsync();
+        }
+
+        [Test]
+        public async Task TestNoDiagnostic_WhenValuesAlreadyFormatted()
+        {
+            var testCode = @"
+namespace J2N.Numerics
+{
+    public static class Single
+    {
+        public static string ToString(float value) => value.ToString();
+        public static string ToString(float value, string format) => value.ToString(format);
+    }
+}
+
+public class C
+{
+    private float levelBottom = 1f;
+
+    private string Message(string value) => value;
+
+    public void M()
+    {
+        Message($""  level {J2N.Numerics.Single.ToString(levelBottom)}"");
+    }
+}
+";
+
+            var test = new InjectableCSharpAnalyzerTest(() => new LuceneDev1006_FloatingPointFormattingConcatenationCSCodeAnalyzer())
+            {
+                TestCode = testCode
+            };
+
+            await test.RunAsync();
+        }
+    }
+}