Skip to content

Commit 9357ea4

Browse files
committed
MEDIUM: add append command to add words to allowed dictionary
Add the `check-commit append <word> [word...]` command to simplify updating the allowed words list. When executed, it adds the provided words to the `allowed:` list in `.aspell.yml`, sorting the list and preserving existing formatting and comments. If `remote_file` points to a GitLab wiki page, it updates the remote wiki page using the GitLab API. Authentication securely supports `private_token_env`, `token_env`, or the local `glab` CLI configuration.
1 parent 9704682 commit 9357ea4

11 files changed

Lines changed: 1092 additions & 19 deletions

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,3 +133,21 @@ allowed:
133133
- both commit message and all code committed
134134
- `disabled`
135135
- check is disabled
136+
137+
### Adding allowed words
138+
139+
```bash
140+
check-commit append <word> [word...]
141+
```
142+
143+
Adds words to the `allowed:` list in `.aspell.yml` (created if missing) and
144+
sorts the whole list. When `remote_file` points at a GitLab wiki page
145+
(`https://host/group/project/-/wikis/page` or the API form), the wiki page is
146+
updated instead, preserving its formatting. Any other remote word source
147+
makes the command refuse and print where to add the word instead.
148+
149+
GitLab authentication uses `private_token_env` / `token_env` when set; when
150+
the variable is missing or empty, the token is read from a locally installed
151+
[glab](https://gitlab.com/gitlab-org/cli) (`glab config get token --host <host>`),
152+
so logged-in users need no extra setup. glab only stores tokens for hosts you
153+
logged into, so the token is never sent to unknown hosts.

aspell/append.go

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
package aspell
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"os"
7+
"regexp"
8+
"slices"
9+
"strings"
10+
11+
"gopkg.in/yaml.v3"
12+
)
13+
14+
var (
15+
// Top-level allowed: key, optionally with an inline comment.
16+
allowedKeyPattern = regexp.MustCompile(`^allowed:\s*(#.*)?$`)
17+
// Top-level allowed: key with flow-style content, e.g. "allowed: [a, b]".
18+
allowedFlowKeyPattern = regexp.MustCompile(`^allowed:\s*[^#\s]`)
19+
// List item line: indent, word, optional inline comment.
20+
allowedItemPattern = regexp.MustCompile(`^(\s*)-\s+([^#]*?)\s*(#.*)?$`)
21+
)
22+
23+
// normalizeWord lowercases and unquotes for case-insensitive comparison.
24+
func normalizeWord(w string) string {
25+
return strings.ToLower(strings.Trim(strings.TrimSpace(w), `'"`))
26+
}
27+
28+
// itemWord extracts the word token from an item line for sorting.
29+
func itemWord(line string) string {
30+
m := allowedItemPattern.FindStringSubmatch(line)
31+
if m == nil {
32+
return line
33+
}
34+
return strings.TrimSpace(m[2])
35+
}
36+
37+
func isItemLine(line string) bool {
38+
m := allowedItemPattern.FindStringSubmatch(line)
39+
return m != nil && strings.TrimSpace(m[2]) != ""
40+
}
41+
42+
func isCommentOrBlank(line string) bool {
43+
t := strings.TrimSpace(line)
44+
return t == "" || strings.HasPrefix(t, "#")
45+
}
46+
47+
// findAllowedBlock returns the [start,end) line range of allowed: items.
48+
// end only advances past comments/blanks when another item follows.
49+
func findAllowedBlock(lines []string) (start, end int) {
50+
keyIdx := -1
51+
for i, line := range lines {
52+
if allowedKeyPattern.MatchString(line) {
53+
keyIdx = i
54+
break
55+
}
56+
}
57+
if keyIdx == -1 {
58+
return -1, -1
59+
}
60+
start = keyIdx + 1
61+
end = start
62+
for j := start; j < len(lines); j++ {
63+
switch {
64+
case isItemLine(lines[j]):
65+
end = j + 1
66+
case isCommentOrBlank(lines[j]):
67+
continue
68+
default:
69+
return start, end
70+
}
71+
}
72+
return start, end
73+
}
74+
75+
func sortItems(items []string) {
76+
slices.SortStableFunc(items, func(a, b string) int {
77+
return strings.Compare(normalizeWord(itemWord(a)), normalizeWord(itemWord(b)))
78+
})
79+
}
80+
81+
// appendLocal merges words into the allowed: block, sorted, comments kept.
82+
func appendLocal(filename string, data []byte, words []string) (added, skipped []string, err error) {
83+
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
84+
if len(data) == 0 {
85+
lines = nil
86+
}
87+
for _, line := range lines {
88+
if allowedFlowKeyPattern.MatchString(line) {
89+
return nil, nil, errors.New("flow-style allowed list not supported; convert to block style")
90+
}
91+
}
92+
start, end := findAllowedBlock(lines)
93+
94+
existing := map[string]bool{}
95+
var items, comments []string
96+
indent := " "
97+
if start >= 0 {
98+
for _, line := range lines[start:end] {
99+
if isItemLine(line) {
100+
items = append(items, line)
101+
existing[normalizeWord(itemWord(line))] = true
102+
indent = allowedItemPattern.FindStringSubmatch(line)[1]
103+
} else {
104+
comments = append(comments, line)
105+
}
106+
}
107+
}
108+
109+
for _, w := range words {
110+
if existing[normalizeWord(w)] {
111+
skipped = append(skipped, w)
112+
continue
113+
}
114+
existing[normalizeWord(w)] = true
115+
items = append(items, indent+"- "+w)
116+
added = append(added, w)
117+
}
118+
119+
if len(added) == 0 {
120+
return added, skipped, nil // nothing to write
121+
}
122+
sortItems(items)
123+
124+
var out []string
125+
if start >= 0 {
126+
out = append(out, lines[:start]...)
127+
out = append(out, comments...)
128+
out = append(out, items...)
129+
out = append(out, lines[end:]...)
130+
} else {
131+
out = append(out, lines...)
132+
out = append(out, "allowed:")
133+
out = append(out, items...)
134+
}
135+
content := strings.Join(out, "\n") + "\n"
136+
return added, skipped, os.WriteFile(filename, []byte(content), 0o644)
137+
}
138+
139+
// Append adds words to the allowed list: local .aspell.yml, or a GitLab
140+
// wiki page when remote_file points at one. Other remotes are refused to
141+
// keep a single source of truth.
142+
func Append(filename string, words []string) (added, skipped []string, err error) {
143+
cleaned := make([]string, 0, len(words))
144+
for _, w := range words {
145+
w = strings.TrimSpace(w)
146+
if w == "" {
147+
return nil, nil, errors.New("empty word argument")
148+
}
149+
// Reject characters that would corrupt the YAML list item on write.
150+
if strings.ContainsAny(w, " \t\n\r:#'\"") || strings.HasPrefix(w, "-") {
151+
return nil, nil, fmt.Errorf("invalid word %q", w)
152+
}
153+
cleaned = append(cleaned, w)
154+
}
155+
156+
data, readErr := os.ReadFile(filename)
157+
if readErr != nil && !os.IsNotExist(readErr) {
158+
return nil, nil, readErr
159+
}
160+
161+
var cfg Aspell
162+
if err := yaml.Unmarshal(data, &cfg); err != nil {
163+
return nil, nil, err
164+
}
165+
166+
remoteURL := cfg.RemoteFile.URL
167+
if cfg.RemoteFile.URLEnv != "" {
168+
remoteURL = os.Getenv(cfg.RemoteFile.URLEnv)
169+
if remoteURL == "" {
170+
// Env var configured but unset/empty: never fall back to a local
171+
// edit, or the local file forks from the single source of truth.
172+
return nil, nil, fmt.Errorf(
173+
"remote_file url_env %s is set but the variable is empty; refusing to append locally",
174+
cfg.RemoteFile.URLEnv)
175+
}
176+
}
177+
if remoteURL != "" {
178+
if ref, ok := parseWikiURL(remoteURL); ok {
179+
return appendToWiki(cfg.RemoteFile, ref, cleaned)
180+
}
181+
return nil, nil, fmt.Errorf(
182+
"allowed words are managed remotely; add them there instead: %s", remoteURL)
183+
}
184+
d := cfg.Dictionaries
185+
if len(d.GitHub) > 0 || len(d.GitLab) > 0 || len(d.URLs) > 0 {
186+
var sources []string
187+
for _, gh := range d.GitHub {
188+
sources = append(sources, gh.URL)
189+
}
190+
for _, gl := range d.GitLab {
191+
sources = append(sources, gl.URL)
192+
}
193+
sources = append(sources, d.URLs...)
194+
return nil, nil, fmt.Errorf(
195+
"allowed words are managed by remote dictionaries; add them there instead: %s",
196+
strings.Join(sources, ", "))
197+
}
198+
return appendLocal(filename, data, cleaned)
199+
}

0 commit comments

Comments
 (0)