From e6720b809f64fc2772be18d3b99be67b8330c5c3 Mon Sep 17 00:00:00 2001
From: Ludovic Fernandez <ldez@users.noreply.github.com>
Date: Tue, 20 Feb 2024 12:59:49 +0100
Subject: [PATCH] misspell: add extra-words (#4401)

---
 .golangci.reference.yml                   | 11 +++
 pkg/config/linters_settings.go            | 10 ++-
 pkg/golinters/misspell.go                 | 33 ++++++++
 pkg/golinters/misspell_test.go            | 94 +++++++++++++++++++++++
 test/testdata/configs/misspell_custom.yml |  7 ++
 test/testdata/misspell_custom.go          | 10 +++
 6 files changed, 163 insertions(+), 2 deletions(-)
 create mode 100644 pkg/golinters/misspell_test.go
 create mode 100644 test/testdata/configs/misspell_custom.yml
 create mode 100644 test/testdata/misspell_custom.go

diff --git a/.golangci.reference.yml b/.golangci.reference.yml
index 1f6b8a26..7766f290 100644
--- a/.golangci.reference.yml
+++ b/.golangci.reference.yml
@@ -1361,9 +1361,20 @@ linters-settings:
     # Setting locale to US will correct the British spelling of 'colour' to 'color'.
     # Default is to use a neutral variety of English.
     locale: US
+    # Typos to ignore.
+    # Should be in lower case.
     # Default: []
     ignore-words:
       - someword
+    # Extra word corrections.
+    # `typo` and `correction` should only contain letters.
+    # The words are case-insensitive.
+    # Default: []
+    extra-words:
+      - typo: "iff"
+        correction: "if"
+      - typo: "cancelation"
+        correction: "cancellation"
     # Mode of the analysis:
     # - default: checks all the file content.
     # - restricted: checks only comments.
diff --git a/pkg/config/linters_settings.go b/pkg/config/linters_settings.go
index db121883..701beb22 100644
--- a/pkg/config/linters_settings.go
+++ b/pkg/config/linters_settings.go
@@ -663,12 +663,18 @@ type MalignedSettings struct {
 }
 
 type MisspellSettings struct {
-	Mode   string `mapstructure:"mode"`
-	Locale string
+	Mode       string               `mapstructure:"mode"`
+	Locale     string               `mapstructure:"locale"`
+	ExtraWords []MisspellExtraWords `mapstructure:"extra-words"`
 	// TODO(ldez): v2 the option must be renamed to `IgnoredRules`.
 	IgnoreWords []string `mapstructure:"ignore-words"`
 }
 
+type MisspellExtraWords struct {
+	Typo       string `mapstructure:"typo"`
+	Correction string `mapstructure:"correction"`
+}
+
 type MustTagSettings struct {
 	Functions []struct {
 		Name   string `mapstructure:"name"`
diff --git a/pkg/golinters/misspell.go b/pkg/golinters/misspell.go
index 0f69cdb8..8a97534c 100644
--- a/pkg/golinters/misspell.go
+++ b/pkg/golinters/misspell.go
@@ -5,6 +5,7 @@ import (
 	"go/token"
 	"strings"
 	"sync"
+	"unicode"
 
 	"github.com/golangci/misspell"
 	"golang.org/x/tools/go/analysis"
@@ -95,6 +96,11 @@ func createMisspellReplacer(settings *config.MisspellSettings) (*misspell.Replac
 		return nil, fmt.Errorf("unknown locale: %q", settings.Locale)
 	}
 
+	err := appendExtraWords(replacer, settings.ExtraWords)
+	if err != nil {
+		return nil, fmt.Errorf("process extra words: %w", err)
+	}
+
 	if len(settings.IgnoreWords) != 0 {
 		replacer.RemoveRule(settings.IgnoreWords)
 	}
@@ -153,3 +159,30 @@ func runMisspellOnFile(lintCtx *linter.Context, filename string, replacer *missp
 
 	return res, nil
 }
+
+func appendExtraWords(replacer *misspell.Replacer, extraWords []config.MisspellExtraWords) error {
+	if len(extraWords) == 0 {
+		return nil
+	}
+
+	extra := make([]string, 0, len(extraWords)*2)
+
+	for _, word := range extraWords {
+		if word.Typo == "" || word.Correction == "" {
+			return fmt.Errorf("typo (%q) and correction (%q) fields should not be empty", word.Typo, word.Correction)
+		}
+
+		if strings.ContainsFunc(word.Typo, func(r rune) bool { return !unicode.IsLetter(r) }) {
+			return fmt.Errorf("the word %q in the 'typo' field should only contain letters", word.Typo)
+		}
+		if strings.ContainsFunc(word.Correction, func(r rune) bool { return !unicode.IsLetter(r) }) {
+			return fmt.Errorf("the word %q in the 'correction' field should only contain letters", word.Correction)
+		}
+
+		extra = append(extra, strings.ToLower(word.Typo), strings.ToLower(word.Correction))
+	}
+
+	replacer.AddRuleList(extra)
+
+	return nil
+}
diff --git a/pkg/golinters/misspell_test.go b/pkg/golinters/misspell_test.go
new file mode 100644
index 00000000..29e0862f
--- /dev/null
+++ b/pkg/golinters/misspell_test.go
@@ -0,0 +1,94 @@
+package golinters
+
+import (
+	"testing"
+
+	"github.com/golangci/misspell"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/golangci/golangci-lint/pkg/config"
+)
+
+func Test_appendExtraWords(t *testing.T) {
+	extraWords := []config.MisspellExtraWords{
+		{
+			Typo:       "iff",
+			Correction: "if",
+		},
+		{
+			Typo:       "canCELation",
+			Correction: "canceLLaTION",
+		},
+	}
+
+	replacer := &misspell.Replacer{}
+
+	err := appendExtraWords(replacer, extraWords)
+	require.NoError(t, err)
+
+	expected := []string{"iff", "if", "cancelation", "cancellation"}
+
+	assert.Equal(t, replacer.Replacements, expected)
+}
+
+func Test_appendExtraWords_error(t *testing.T) {
+	testCases := []struct {
+		desc       string
+		extraWords []config.MisspellExtraWords
+		expected   string
+	}{
+		{
+			desc: "empty fields",
+			extraWords: []config.MisspellExtraWords{{
+				Typo:       "",
+				Correction: "",
+			}},
+			expected: `typo ("") and correction ("") fields should not be empty`,
+		},
+		{
+			desc: "empty typo",
+			extraWords: []config.MisspellExtraWords{{
+				Typo:       "",
+				Correction: "if",
+			}},
+			expected: `typo ("") and correction ("if") fields should not be empty`,
+		},
+		{
+			desc: "empty correction",
+			extraWords: []config.MisspellExtraWords{{
+				Typo:       "iff",
+				Correction: "",
+			}},
+			expected: `typo ("iff") and correction ("") fields should not be empty`,
+		},
+		{
+			desc: "invalid characters in typo",
+			extraWords: []config.MisspellExtraWords{{
+				Typo:       "i'ff",
+				Correction: "if",
+			}},
+			expected: `the word "i'ff" in the 'typo' field should only contain letters`,
+		},
+		{
+			desc: "invalid characters in correction",
+			extraWords: []config.MisspellExtraWords{{
+				Typo:       "iff",
+				Correction: "i'f",
+			}},
+			expected: `the word "i'f" in the 'correction' field should only contain letters`,
+		},
+	}
+
+	for _, test := range testCases {
+		test := test
+		t.Run(test.desc, func(t *testing.T) {
+			t.Parallel()
+
+			replacer := &misspell.Replacer{}
+
+			err := appendExtraWords(replacer, test.extraWords)
+			require.EqualError(t, err, test.expected)
+		})
+	}
+}
diff --git a/test/testdata/configs/misspell_custom.yml b/test/testdata/configs/misspell_custom.yml
new file mode 100644
index 00000000..c0f3a27c
--- /dev/null
+++ b/test/testdata/configs/misspell_custom.yml
@@ -0,0 +1,7 @@
+linters-settings:
+  misspell:
+    extra-words:
+      - typo: "iff"
+        correction: "if"
+      - typo: "cancelation"
+        correction: "cancellation"
diff --git a/test/testdata/misspell_custom.go b/test/testdata/misspell_custom.go
new file mode 100644
index 00000000..32362fb6
--- /dev/null
+++ b/test/testdata/misspell_custom.go
@@ -0,0 +1,10 @@
+//golangcitest:args -Emisspell
+//golangcitest:config_path testdata/configs/misspell_custom.yml
+package testdata
+
+func Misspell() {
+	// comment with incorrect spelling: occured // want "`occured` is a misspelling of `occurred`"
+}
+
+// the word iff should be reported here // want "\\`iff\\` is a misspelling of \\`if\\`"
+// the word cancelation should be reported here // want "\\`cancelation\\` is a misspelling of \\`cancellation\\`"