Skip to content

Commit

Permalink
Show spelling suggestion in symbol/property name not found error mess…
Browse files Browse the repository at this point in the history
…ages (Azure#615)

* Implement a levenshtein distance based spell checker

* Provide spelling sugesstion for not found symbol

* Spell checking for property names

* Fix tests

* Fix malformed code

* Remove a bogus test

* Fix levenshtein max boundary

* Address comments

* levenshtein => Levenshtein
  • Loading branch information
shenglol authored Oct 8, 2020
1 parent a58b28a commit 6d73442
Show file tree
Hide file tree
Showing 14 changed files with 341 additions and 13 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -273,13 +273,36 @@ public void Type_validation_narrowing_on_discriminated_object_types(TypeSymbolVa
}
}
output valueA string = myRes.properties.myDisc1.valueA
output valueB string = myRes.properties.myDisc1.valuuuueB
";

var model = GetSemanticModelForTest(program, customTypes);
model.GetAllDiagnostics().Should().SatisfyRespectively(
x => x.Should().HaveCodeAndSeverity("BCP053", expectedDiagnosticLevel).And.HaveMessage("The type choiceA does not contain property 'valuuuueB'. Available properties include 'discKey', 'valueA'.")
);
}

{
// all good
var program = @"
resource myRes 'My.Rp/myType@2020-01-01' = {
name: 'steve'
properties: {
myDisc1: {
discKey: 'choiceA'
valueA: 'hello'
}
}
}
output valueA string = myRes.properties.myDisc1.valueA
output valueB string = myRes.properties.myDisc1.valueB
";

var model = GetSemanticModelForTest(program, customTypes);
model.GetAllDiagnostics().Should().SatisfyRespectively(
x => x.Should().HaveCodeAndSeverity("BCP053", expectedDiagnosticLevel).And.HaveMessage("The type choiceA does not contain property 'valueB'. Available properties include 'discKey', 'valueA'.")
x => x.Should().HaveCodeAndSeverity("BCP083", expectedDiagnosticLevel).And.HaveMessage("The type choiceA does not contain property 'valueB'. Did you mean 'valueA'?")
);
}
}
Expand Down
1 change: 1 addition & 0 deletions src/Bicep.Core.Samples/InvalidExpressions_LF/main.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ var sampleObject = {
}

var badProperty = sampleObject.myFake
var badSpelling = sampleObject.myNul
var badPropertyIndexer = sampleObject['fake']
var badType = sampleObject.myStr / 32
var badInnerProperty = sampleObject.myInner.fake
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ var ternary = null ? 4 : false
// complex expressions
var complex = test(2 + 3*4, true || false && null)
//@[4:11) [BCP028 (Error)] Identifier 'complex' is declared multiple times. Remove or rename the duplicates. |complex|
//@[14:18) [BCP057 (Error)] The name 'test' does not exist in the current context. |test|
//@[14:18) [BCP082 (Error)] The name 'test' does not exist in the current context. Did you mean 'test1'? |test|
//@[36:49) [BCP045 (Error)] Cannot apply operator '&&' to operands of type bool and null. |false && null|
var complex = -2 && 3 && !4 && 5
//@[4:11) [BCP028 (Error)] Identifier 'complex' is declared multiple times. Remove or rename the duplicates. |complex|
Expand Down Expand Up @@ -220,7 +220,7 @@ output funcvarout array = padLeft
var fakeFunc = red() + green() * orange()
//@[15:18) [BCP057 (Error)] The name 'red' does not exist in the current context. |red|
//@[23:28) [BCP057 (Error)] The name 'green' does not exist in the current context. |green|
//@[33:39) [BCP057 (Error)] The name 'orange' does not exist in the current context. |orange|
//@[33:39) [BCP082 (Error)] The name 'orange' does not exist in the current context. Did you mean 'range'? |orange|
param fakeFuncP string {
default: blue()
//@[11:15) [BCP057 (Error)] The name 'blue' does not exist in the current context. |blue|
Expand Down Expand Up @@ -255,11 +255,11 @@ var test1 = listKeys('abcd')

// list spelled wrong
var test2 = lsitKeys('abcd', '2020-01-01')
//@[12:20) [BCP057 (Error)] The name 'lsitKeys' does not exist in the current context. |lsitKeys|
//@[12:20) [BCP082 (Error)] The name 'lsitKeys' does not exist in the current context. Did you mean 'listKeys'? |lsitKeys|

// just 'list'
var test3 = list('abcd', '2020-01-01')
//@[12:16) [BCP057 (Error)] The name 'list' does not exist in the current context. |list|
//@[12:16) [BCP082 (Error)] The name 'list' does not exist in the current context. Did you mean 'last'? |list|

// cannot compile an expression like this
var emitLimit = [
Expand Down Expand Up @@ -323,6 +323,8 @@ var sampleObject = {

var badProperty = sampleObject.myFake
//@[31:37) [BCP053 (Error)] The type object does not contain property 'myFake'. Available properties include 'myArr', 'myBool', 'myInner', 'myInt', 'myNull', 'myStr'. |myFake|
var badSpelling = sampleObject.myNul
//@[31:36) [BCP083 (Error)] The type object does not contain property 'myNul'. Did you mean 'myNull'? |myNul|
var badPropertyIndexer = sampleObject['fake']
//@[38:44) [BCP053 (Error)] The type object does not contain property 'fake'. Available properties include 'myArr', 'myBool', 'myInner', 'myInt', 'myNull', 'myStr'. |'fake'|
var badType = sampleObject.myStr / 32
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,8 @@ var sampleObject = {

var badProperty = sampleObject.myFake
//@[4:15) Variable badProperty. Type: error. Declaration start char: 0, length: 37
var badSpelling = sampleObject.myNul
//@[4:15) Variable badSpelling. Type: error. Declaration start char: 0, length: 36
var badPropertyIndexer = sampleObject['fake']
//@[4:22) Variable badPropertyIndexer. Type: error. Declaration start char: 0, length: 45
var badType = sampleObject.myStr / 32
Expand Down
14 changes: 14 additions & 0 deletions src/Bicep.Core.Samples/InvalidExpressions_LF/main.syntax.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -1736,6 +1736,20 @@ var badProperty = sampleObject.myFake
//@[31:37) IdentifierSyntax
//@[31:37) Identifier |myFake|
//@[37:38) NewLine |\n|
var badSpelling = sampleObject.myNul
//@[0:36) VariableDeclarationSyntax
//@[0:3) Identifier |var|
//@[4:15) IdentifierSyntax
//@[4:15) Identifier |badSpelling|
//@[16:17) Assignment |=|
//@[18:36) PropertyAccessSyntax
//@[18:30) VariableAccessSyntax
//@[18:30) IdentifierSyntax
//@[18:30) Identifier |sampleObject|
//@[30:31) Dot |.|
//@[31:36) IdentifierSyntax
//@[31:36) Identifier |myNul|
//@[36:37) NewLine |\n|
var badPropertyIndexer = sampleObject['fake']
//@[0:45) VariableDeclarationSyntax
//@[0:3) Identifier |var|
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1136,6 +1136,14 @@ var badProperty = sampleObject.myFake
//@[30:31) Dot |.|
//@[31:37) Identifier |myFake|
//@[37:38) NewLine |\n|
var badSpelling = sampleObject.myNul
//@[0:3) Identifier |var|
//@[4:15) Identifier |badSpelling|
//@[16:17) Assignment |=|
//@[18:30) Identifier |sampleObject|
//@[30:31) Dot |.|
//@[31:36) Identifier |myNul|
//@[36:37) NewLine |\n|
var badPropertyIndexer = sampleObject['fake']
//@[0:3) Identifier |var|
//@[4:22) Identifier |badPropertyIndexer|
Expand Down
92 changes: 92 additions & 0 deletions src/Bicep.Core.UnitTests/Text/SpellCheckerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using Bicep.Core.Text;
using FluentAssertions;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace Bicep.Core.UnitTests.Text
{
[TestClass]
public class SpellCheckerTests
{
[DataTestMethod]
[DataRow(null)]
[DataRow(new string[0])]
public void GetSpellingSuggestion_NullOrEmptyCandidatesEnumerable_ReturnsNull(string[] candidates)
{
string? result = SpellChecker.GetSpellingSuggestion("foo", candidates);

result.Should().BeNull();
}

[DataTestMethod]
public void GetSpellingSuggestion_EmptyCandidate_ReturnsNull()
{
var candidates = new [] { "", "" };

string? result = SpellChecker.GetSpellingSuggestion("foo", candidates);

result.Should().BeNull();
}

[TestMethod]
public void GetSpellingSuggestion_CandidateHasLessThanThreeCharacters_ReturnsNull()
{
var candidates = new [] { "o", "oo", "oO", "OO" };

string? result = SpellChecker.GetSpellingSuggestion("ooo", candidates);

result.Should().BeNull();
}

[DataTestMethod]
// maxLengthDifference = 1
[DataRow("ooo", "ooooo", "oooooo")]
[DataRow("ooooo", "ooo", "ooooooo")]
// maxLengthDifference = 2
[DataRow("oooooo", "ooo", "ooooooooo", "oooooooooooooo")]
[DataRow("oooooooo", "oooo", "ooooo", "ooooooooooo")]
public void GetSpellingSuggestion_LengthDifferenceExceedsMax_ReturnsNull(string name, params string[] candidates)
{
string? result = SpellChecker.GetSpellingSuggestion(name, candidates);

result.Should().BeNull();
}

[DataTestMethod]
// maxLengthDifference = 1, maxDistance = 1
[DataRow("ooo", "ooooo", "oooooo", "oxo", "oOO")]
[DataRow("ooooo", "ooo", "ooooooo", "oooooo", "oOoOO")]
// maxLengthDifference = 2, maxDistance = 2
[DataRow("oooooo", "ooo", "ooooooooo", "ooooox", "OOOOOO")]
[DataRow("oooooooo", "oooo", "ooooo", "ooooooooooo", "ooooooo", "OoOooOOo")]
public void GetSpellingSuggestion_CandidateMatchesNameCaseInsensitively_ReturnsCandidate(string name, params string[] candidates)
{
string? result = SpellChecker.GetSpellingSuggestion(name, candidates);

result.Should().Be(candidates[^1]);
}

[DataTestMethod]
// maxLengthDifference = 1, maxDistance = 1
[DataRow("ooo", "ooooo", "oooooo", "oxx", "oooo", "oxo", "xoo")]
[DataRow("ooo", "ooooo", "oooooo", "oxx", "oxo", "xoo", "oooo")]
[DataRow("ooo", "ooooo", "oooooo", "oxx", "xoo", "oooo", "oxo")]
[DataRow("ooooo", "ooo", "ooooooo", "oxxoo", "oooo", "oooooo", "oxooo")]
[DataRow("ooooo", "ooo", "ooooooo", "oxxoo", "oooooo", "oxooo", "oooo")]
[DataRow("ooooo", "ooo", "ooooooo", "oxxoo", "oxooo", "oooo", "oooooo")]
// maxLengthDifference = 2, maxDistance = 2
[DataRow("oooooo", "ooo", "ooooooooo", "oooo", "ooooo", "ooooooo", "ooooox")]
[DataRow("oooooo", "ooo", "ooooooooo", "oooo", "ooooooo", "ooooox", "ooooo")]
[DataRow("oooooo", "ooo", "ooooooooo", "oooo", "ooooox", "ooooo", "ooooooo")]
[DataRow("oooooooo", "oooo", "ooooo", "ooooooooooo", "oooooo", "ooooooo", "ooooooooo", "ooooooox")]
[DataRow("oooooooo", "oooo", "ooooo", "ooooooooooo", "oooooo", "ooooooooo", "ooooooox", "ooooooo")]
[DataRow("oooooooo", "oooo", "ooooo", "ooooooooooo", "oooooo", "ooooooox", "ooooooo", "ooooooooo")]
public void GetSpellingSuggestion_NoCaseInsensitiveMatches_ReturnsFirstCandidateWithSmallestEditDistance(string name, params string[] candidates)
{
string? result = SpellChecker.GetSpellingSuggestion(name, candidates);

result.Should().Be(candidates[^3]);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
using FluentAssertions;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace Bicep.Core.UnitTests.Position
namespace Bicep.Core.UnitTests.Text
{
[TestClass]
public class TextCoordinateConverterTests
Expand Down
11 changes: 11 additions & 0 deletions src/Bicep.Core/Diagnostics/DiagnosticBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,17 @@ public ErrorDiagnostic ArgumentCountMismatch(int argumentCount, int mininumArgum
DiagnosticLevel.Warning,
"BCP081",
$"Resource type {resourceTypeReference.FormatName()} does not have types available");

public ErrorDiagnostic SymbolicNameDoesNotExistWithSuggestion(string name, string suggestedName) => new ErrorDiagnostic(
TextSpan,
"BCP082",
$"The name '{name}' does not exist in the current context. Did you mean '{suggestedName}'?");

public Diagnostic UnknownPropertyWithSuggestion(bool warnInsteadOfError, TypeSymbol type, string badProperty, string suggestedProperty) => new Diagnostic(
TextSpan,
warnInsteadOfError ? DiagnosticLevel.Warning : DiagnosticLevel.Error,
"BCP083",
$"The type {type} does not contain property '{badProperty}'. Did you mean '{suggestedProperty}'?");
}

public static DiagnosticBuilderInternal ForPosition(TextSpan span)
Expand Down
1 change: 1 addition & 0 deletions src/Bicep.Core/SemanticModel/FunctionWildcardOverload.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Text.RegularExpressions;
using Bicep.Core.TypeSystem;

Expand Down
12 changes: 11 additions & 1 deletion src/Bicep.Core/SemanticModel/NameBindingVisitor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using Bicep.Core.Diagnostics;
using Bicep.Core.Parser;
using Bicep.Core.Syntax;
using Bicep.Core.Text;
using Bicep.Core.TypeSystem;

namespace Bicep.Core.SemanticModel
Expand Down Expand Up @@ -136,7 +137,16 @@ private Symbol LookupSymbolByName(string name, TextSpan span)
var foundSymbol = foundSymbols.FirstOrDefault();
if (foundSymbol == null)
{
return new ErrorSymbol(DiagnosticBuilder.ForPosition(span).SymbolicNameDoesNotExist(name));
var nameCandidates = this.declarations.Values
.Concat(this.namespaces.SelectMany(ns => ns.Descendants))
.Select(symbol => symbol.Name)
.ToImmutableSortedSet();

var suggestedName = SpellChecker.GetSpellingSuggestion(name, nameCandidates);

return suggestedName != null
? new ErrorSymbol(DiagnosticBuilder.ForPosition(span).SymbolicNameDoesNotExistWithSuggestion(name, suggestedName))
: new ErrorSymbol(DiagnosticBuilder.ForPosition(span).SymbolicNameDoesNotExist(name));
}

return ValidateFunctionFlags(foundSymbol, span);
Expand Down
3 changes: 1 addition & 2 deletions src/Bicep.Core/SemanticModel/Namespaces/AzNamespaceSymbol.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,7 @@ public class AzNamespaceSymbol : NamespaceSymbol

// the use of FunctionPlacementConstraints.Resources prevents use of these functions anywhere where they can't be directly inlined into a resource body
new FunctionOverload("reference", LanguageConstants.Object, 1, 3, Enumerable.Repeat(LanguageConstants.String, 3), null, FunctionFlags.RequiresInlining),
new FunctionWildcardOverload("list*", LanguageConstants.Any, 2, 3, new[] { LanguageConstants.String, LanguageConstants.String, LanguageConstants.Object }, null, new Regex("^list[a-zA-Z]+"), FunctionFlags.RequiresInlining),
}.ToImmutableArray();
new FunctionWildcardOverload("list*", LanguageConstants.Any, 2, 3, new[] { LanguageConstants.String, LanguageConstants.String, LanguageConstants.Object }, null, new Regex("^list[a-zA-Z]+"), FunctionFlags.RequiresInlining), }.ToImmutableArray();

public AzNamespaceSymbol() : base("az", AzOverloads)
{
Expand Down
Loading

0 comments on commit 6d73442

Please sign in to comment.