Skip to content

Commit ee2bde2

Browse files
committed
Add new rule PreferStringInterpolationWithSprintf
Fixes: #542
1 parent 41787e7 commit ee2bde2

File tree

9 files changed

+211
-0
lines changed

9 files changed

+211
-0
lines changed
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
title: FL0077
3+
category: how-to
4+
hide_menu: true
5+
---
6+
7+
# PreferStringInterpolationWithSprintf (FL0077)
8+
9+
*Introduced in `0.21.3`*
10+
11+
## Cause
12+
13+
String interpolation is done with String.Format.
14+
15+
## Rationale
16+
17+
sprintf is statically type checked and with sprintf F# compiler will complain when too few arguments are supplied (unlike with String.Format).
18+
19+
## How To Fix
20+
21+
Use sprintf instead of String.Format.
22+
23+
## Rule Settings
24+
25+
{
26+
"preferStringInterpolationWithSprintf": {
27+
"enabled": false
28+
}
29+
}

src/FSharpLint.Core/Application/Configuration.fs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,7 @@ with
307307
type ConventionsConfig =
308308
{ recursiveAsyncFunction:EnabledConfig option
309309
avoidTooShortNames:EnabledConfig option
310+
preferStringInterpolationWithSprintf:EnabledConfig option
310311
redundantNewKeyword:EnabledConfig option
311312
favourStaticEmptyFields:EnabledConfig option
312313
nestedStatements:RuleConfig<NestedStatements.Config> option
@@ -325,6 +326,7 @@ with
325326
[|
326327
this.recursiveAsyncFunction |> Option.bind (constructRuleIfEnabled RecursiveAsyncFunction.rule) |> Option.toArray
327328
this.avoidTooShortNames |> Option.bind (constructRuleIfEnabled AvoidTooShortNames.rule) |> Option.toArray
329+
this.preferStringInterpolationWithSprintf |> Option.bind (constructRuleIfEnabled PreferStringInterpolationWithSprintf.rule) |> Option.toArray
328330
this.redundantNewKeyword |> Option.bind (constructRuleIfEnabled RedundantNewKeyword.rule) |> Option.toArray
329331
this.favourReRaise |> Option.bind (constructRuleIfEnabled FavourReRaise.rule) |> Option.toArray
330332
this.favourStaticEmptyFields |> Option.bind (constructRuleIfEnabled FavourStaticEmptyFields.rule) |> Option.toArray
@@ -394,6 +396,7 @@ type Configuration =
394396
PatternMatchExpressionIndentation:EnabledConfig option
395397
RecursiveAsyncFunction:EnabledConfig option
396398
AvoidTooShortNames:EnabledConfig option
399+
PreferStringInterpolationWithSprintf:EnabledConfig option
397400
RedundantNewKeyword:EnabledConfig option
398401
FavourReRaise:EnabledConfig option
399402
FavourStaticEmptyFields:EnabledConfig option
@@ -478,6 +481,7 @@ with
478481
PatternMatchExpressionIndentation = None
479482
RecursiveAsyncFunction = None
480483
AvoidTooShortNames = None
484+
PreferStringInterpolationWithSprintf = None
481485
RedundantNewKeyword = None
482486
FavourReRaise = None
483487
FavourStaticEmptyFields = None
@@ -625,6 +629,7 @@ let flattenConfig (config:Configuration) =
625629
config.PatternMatchExpressionIndentation |> Option.bind (constructRuleIfEnabled PatternMatchExpressionIndentation.rule)
626630
config.RecursiveAsyncFunction |> Option.bind (constructRuleIfEnabled RecursiveAsyncFunction.rule)
627631
config.AvoidTooShortNames |> Option.bind (constructRuleIfEnabled AvoidTooShortNames.rule)
632+
config.PreferStringInterpolationWithSprintf |> Option.bind (constructRuleIfEnabled PreferStringInterpolationWithSprintf.rule)
628633
config.RedundantNewKeyword |> Option.bind (constructRuleIfEnabled RedundantNewKeyword.rule)
629634
config.FavourReRaise |> Option.bind (constructRuleIfEnabled FavourReRaise.rule)
630635
config.FavourStaticEmptyFields |> Option.bind (constructRuleIfEnabled FavourStaticEmptyFields.rule)

src/FSharpLint.Core/FSharpLint.Core.fsproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
<Compile Include="Rules\Conventions\CyclomaticComplexity.fs" />
5151
<Compile Include="Rules\Conventions\FavourReRaise.fs" />
5252
<Compile Include="Rules\Conventions\FavourConsistentThis.fs" />
53+
<Compile Include="Rules\Conventions\PreferStringInterpolationWithSprintf.fs" />
5354
<Compile Include="Rules\Conventions\RaiseWithTooManyArguments\RaiseWithTooManyArgumentsHelper.fs" />
5455
<Compile Include="Rules\Conventions\RaiseWithTooManyArguments\FailwithWithSingleArgument.fs" />
5556
<Compile Include="Rules\Conventions\RaiseWithTooManyArguments\RaiseWithSingleArgument.fs" />
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
module FSharpLint.Rules.PreferStringInterpolationWithSprintf
2+
3+
open System
4+
open FSharpLint.Framework
5+
open FSharpLint.Framework.Suggestion
6+
open FSharp.Compiler.Syntax
7+
open FSharpLint.Framework.Ast
8+
open FSharpLint.Framework.Rules
9+
10+
let mutable moduleIdentifiers = Set.empty
11+
let mutable letIdentifiers = Set.empty
12+
13+
let private isStringFormat (identifiers: List<Ident>) =
14+
"String" = identifiers.[0].idText && "Format" = identifiers.[1].idText
15+
16+
let private genereateErrorMessage range =
17+
{ Range = range
18+
Message = Resources.GetString "RulesPreferStringInterpolationWithSprintf"
19+
SuggestedFix = None
20+
TypeChecks = List.empty }
21+
|> Array.singleton
22+
23+
let runner args =
24+
match args.AstNode with
25+
| AstNode.Expression(SynExpr.App(_, _, SynExpr.LongIdent(_, LongIdentWithDots(ids, _), _, _), paren, range)) when ids.Length = 2 && isStringFormat ids ->
26+
let isTopMember (text: string) =
27+
moduleIdentifiers.Contains text
28+
match paren with
29+
| SynExpr.Paren(SynExpr.Tuple(_, [SynExpr.Const(SynConst.String(_, _, _), _); _], _, _), _, _, _) ->
30+
genereateErrorMessage range
31+
| SynExpr.Paren(SynExpr.Tuple(_, [SynExpr.Ident identifier; _], _, _), _, _, range) ->
32+
33+
if isTopMember identifier.idText then
34+
genereateErrorMessage range
35+
else
36+
let isNamedBinding binding =
37+
match binding with
38+
| SynBinding(_, _, _, _, _, _, _, SynPat.Named(_, ident, _, _, _), _, _, _, _) ->
39+
identifier.idText = ident.idText
40+
| _ -> false
41+
42+
let isVisible asts =
43+
let rec loop asts =
44+
match asts with
45+
| AstNode.Expression (SynExpr.LetOrUse (_, _, [binding], _, _)) :: rest ->
46+
isNamedBinding binding || loop rest
47+
| _ :: rest -> loop rest
48+
| [] -> false
49+
loop asts
50+
51+
let getTopLevelParent index =
52+
let rec loop index =
53+
let parents = List.rev (args.GetParents index)
54+
match parents with
55+
| AstNode.ModuleDeclaration (SynModuleDecl.Let _) :: rest -> rest
56+
| _ -> loop (index + 1)
57+
loop index
58+
59+
if letIdentifiers.Contains identifier.idText && isVisible (getTopLevelParent 2) then
60+
genereateErrorMessage range
61+
else
62+
Array.empty
63+
| _ -> Array.empty
64+
| AstNode.Binding(SynBinding(_, _, _, _, _, _, _, SynPat.Named(_, identifier, _, _, _), _, SynExpr.Const(SynConst.String(value, _, _), _), range, _)) when value.Contains "{0}" ->
65+
let parents = args.GetParents 1
66+
match parents with
67+
| AstNode.ModuleDeclaration(SynModuleDecl.Let _) :: _ ->
68+
moduleIdentifiers <- moduleIdentifiers.Add(identifier.idText)
69+
| _ -> letIdentifiers <- letIdentifiers.Add(identifier.idText)
70+
Array.empty
71+
| AstNode.ModuleDeclaration(SynModuleDecl.Let _) ->
72+
letIdentifiers <- Set.empty
73+
Array.empty
74+
| AstNode.ModuleDeclaration(SynModuleDecl.NestedModule _) ->
75+
moduleIdentifiers <- Set.empty
76+
letIdentifiers <- Set.empty
77+
Array.empty
78+
| _ -> Array.empty
79+
80+
let cleanup () =
81+
moduleIdentifiers <- Set.empty
82+
letIdentifiers <- Set.empty
83+
84+
let rule =
85+
{ Name = "PreferStringInterpolationWithSprintf"
86+
Identifier = Identifiers.PreferStringInterpolationWithSprintf
87+
RuleConfig = { AstNodeRuleConfig.Runner = runner; Cleanup = cleanup } }
88+
|> AstNodeRule

src/FSharpLint.Core/Rules/Identifiers.fs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,4 @@ let FavourReRaise = identifier 73
8181
let FavourConsistentThis = identifier 74
8282
let AvoidTooShortNames = identifier 75
8383
let FavourStaticEmptyFields = identifier 76
84+
let FavourStaticEmptyFields = identifier 77

src/FSharpLint.Core/Text.resx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,4 +345,7 @@
345345
<data name="RulesFavourStaticEmptyFieldsForArray" xml:space="preserve">
346346
<value>Consider using 'Array.empty' instead.</value>
347347
</data>
348+
<data name="RulesPreferStringInterpolationWithSprintf" xml:space="preserve">
349+
<value>Use sprintf instead of String.Format.</value>
350+
</data>
348351
</root>

src/FSharpLint.Core/fsharplint.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,9 @@
277277
"indentation": {
278278
"enabled": false
279279
},
280+
"preferStringInterpolationWithSprintf": {
281+
"enabled": false
282+
},
280283
"maxCharactersOnLine": {
281284
"enabled": false,
282285
"config": {

tests/FSharpLint.Core.Tests/FSharpLint.Core.Tests.fsproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
<Compile Include="Rules\Conventions\SourceLength.fs" />
3838
<Compile Include="Rules\Conventions\NoPartialFunctions.fs" />
3939
<Compile Include="Rules\Conventions\FavourReRaise.fs" />
40+
<Compile Include="Rules\Conventions\PreferStringInterpolationWithSprintf.fs" />
4041
<Compile Include="Rules\Conventions\FavourConsistentThis.fs" />
4142
<Compile Include="Rules\Conventions\AvoidTooShortNames.fs" />
4243
<Compile Include="Rules\Conventions\Naming\NamingHelpers.fs" />
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
module FSharpLint.Core.Tests.Rules.Conventions.PreferStringInterpolationWithSprintf
2+
3+
open NUnit.Framework
4+
open FSharpLint.Rules
5+
6+
[<TestFixture>]
7+
type TestConventionsPreferStringInterpolationWithSprintf() =
8+
inherit TestAstNodeRuleBase.TestAstNodeRuleBase(PreferStringInterpolationWithSprintf.rule)
9+
10+
[<Test>]
11+
member this.StringInterpolationWithSprintfShouldNotProduceError() =
12+
this.Parse """
13+
let someString = sprintf "Hello %s" world"""
14+
15+
Assert.IsTrue this.NoErrorsExist
16+
17+
[<Test>]
18+
member this.StringInterpolationWithStringFormatShouldProduceError() =
19+
this.Parse """
20+
let someString = String.Format("Hello {0}", world)"""
21+
22+
Assert.IsTrue this.ErrorsExist
23+
24+
25+
[<Test>]
26+
member this.StringInterpolationWithStringFormatAndExternalTemplateShouldNotProduceError() =
27+
this.Parse """
28+
let someFunction someTemplate =
29+
Console.WriteLine(String.Format(someTemplate, world))"""
30+
31+
Assert.IsTrue this.NoErrorsExist
32+
33+
34+
[<Test>]
35+
member this.StringInterpolationWithStringFormatAndLocalVariableShouldProduceError() =
36+
this.Parse """
37+
let someTemplate = "Hello {0}"
38+
let someString = String.Format(someTemplate, world)"""
39+
40+
Assert.IsTrue this.ErrorsExist
41+
42+
43+
[<Test>]
44+
member this.StringInterpolationWithMultipleModuleWithSameVariableNameShouldNotProduceError() =
45+
this.Parse """
46+
module Foo =
47+
let someTemplate = "Hello, this is not for String.Format actually"
48+
module Bar =
49+
let someFunction someTemplate =
50+
Console.WriteLine(String.Format(someTemplate, "world"))"""
51+
52+
Assert.IsTrue this.NoErrorsExist
53+
54+
55+
[<Test>]
56+
member this.StringInterpolationWithSameVariableNameInInnerLetShouldNotProduceError() =
57+
this.Parse """
58+
module Bar =
59+
let exampleFunction () =
60+
let someTemplate = "Hello, this is not for String.Format actually"
61+
someTemplate
62+
let someFunction someTemplate =
63+
let returnConstInt () =
64+
89
65+
Console.WriteLine(String.Format(someTemplate, "world"))"""
66+
67+
Assert.IsTrue this.NoErrorsExist
68+
69+
70+
[<Test>]
71+
member this.StringInterpolationWithSameVariableNameWithLocalLetShouldNotProduceError() =
72+
this.Parse """
73+
module Bar =
74+
let exampleFunction someTemplate =
75+
let someResults =
76+
let someTemplate = "Hello, this is not for String.Format actually"
77+
someTemplate
78+
Console.WriteLine(String.Format(someTemplate, "world"))"""
79+
80+
Assert.IsTrue this.NoErrorsExist

0 commit comments

Comments
 (0)