-
Notifications
You must be signed in to change notification settings - Fork 32
Expand file tree
/
Copy pathoptions.go
More file actions
642 lines (565 loc) · 19 KB
/
options.go
File metadata and controls
642 lines (565 loc) · 19 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
// Copyright 2023 Google LLC
//
// Licensed 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.
package keepsorted
import (
"cmp"
"errors"
"fmt"
"iter"
"maps"
"math/big"
"reflect"
"regexp"
"slices"
"strconv"
"strings"
"unicode"
yaml "gopkg.in/yaml.v3"
)
// IntOrBool can be unmarshaled from a boolean or an integer value.
// true is unmarshaled as 1, false as 0.
type IntOrBool int
type ByRegexOption struct {
Pattern *regexp.Regexp
Template *string
}
func (opt ByRegexOption) MarshalYAML() (any, error) {
if opt.Template == nil {
return opt.Pattern.String(), nil
}
return map[string]string{opt.Pattern.String(): *opt.Template}, nil
}
// SortOrder defines whether we sort in ascending or descending order.
type SortOrder string
const (
OrderAsc SortOrder = "asc"
OrderDesc SortOrder = "desc"
)
type BlockOptions struct {
opts blockOptions
}
func DefaultBlockOptions() BlockOptions {
return BlockOptions{defaultOptions}
}
func ParseBlockOptions(options string) (BlockOptions, error) {
opts, warns := parseBlockOptions( /*commentMarker=*/ "", options, blockOptions{})
if err := errors.Join(warns...); err != nil {
return BlockOptions{}, err
}
return BlockOptions{opts}, nil
}
func (opts BlockOptions) String() string {
return opts.opts.String()
}
// blockOptions enable/disable extra features that control how a block of lines is sorted.
//
// Options support the following types:
// - bool: key=yes, key=true, key=no, key=false
// - []string: key=a,b,c,d
// - map[string]bool: key=a,b,c,d
// - int: key=123
// - ByRegexOptions key=a,b,c,d, key=[yaml_list]
type blockOptions struct {
// AllowYAMLLists determines whether list.set valued options are allowed to be specified by YAML.
AllowYAMLLists bool `key:"allow_yaml_lists"`
///////////////////////////
// Pre-sorting options //
///////////////////////////
// SkipLines is the number of lines to ignore before sorting.
SkipLines int `key:"skip_lines"`
// Group determines whether we group lines together based on increasing indentation.
Group bool
// GroupPrefixes tells us about other types of lines that should be added to a group.
GroupPrefixes map[string]bool `key:"group_prefixes"`
// Block opts us into a more complicated algorithm to try and understand blocks of code.
Block bool
// StickyComments tells us to attach comments to the line immediately below them while sorting.
StickyComments bool `key:"sticky_comments"`
// StickyPrefixes tells us about other types of lines that should behave as sticky comments.
StickyPrefixes map[string]bool `key:"sticky_prefixes"`
// Regex-based group options:
// Conceptually, GroupStartRegex lines go to the *next* group while GroupEndRegex lines go to the *current* group.
// GroupStartRegex is a list of regexes that match the start of a group of lines (does not need to match the whole line).
// If none of the listed regexes match a given line, the line is considered to be part of the same
// group as the previous line.
GroupStartRegex []*regexp.Regexp `key:"group_start_regex"`
// GroupEndRegex is a list of regexes that match the end of a group of lines (does not need to match the whole line).
// If any of the listed regexes match a given line, the line will end the current group,
// provided that it does not get ignored by other options (indented/prefixed group, block, sticky comment).
// Non-comment lines no longer end groups when GroupEndRegex is used.
GroupEndRegex []*regexp.Regexp `key:"group_end_regex"`
///////////////////////
// Sorting options //
///////////////////////
// Order is whether we sort in ascending or descending order.
Order SortOrder `key:"order"`
// CaseSensitive is whether we're case sensitive while sorting.
CaseSensitive bool `key:"case"`
// Numeric indicates that the contents should be sorted like numbers.
Numeric bool
// PrefixOrder allows the user to explicitly order lines based on their matching prefix.
PrefixOrder []string `key:"prefix_order"`
// IgnorePrefixes is a slice of prefixes that we do not consider when sorting lines.
IgnorePrefixes []string `key:"ignore_prefixes"`
// ByRegex is a slice of regexes that are used to extract the pieces of the line group that keep-sorted should sort by.
ByRegex []ByRegexOption `key:"by_regex"`
////////////////////////////
// Post-sorting options //
////////////////////////////
// NewlineSeparated indicates that the groups should be separated with newlines.
// User can specify either an integer, or a boolean (to be backward compatible).
// 'no' or '0', it means no newlines should be added;
// 'yes' or '1', it means one newline should be added;
// Any other positive integer specifies the number of newlines to separate the groups.
NewlineSeparated IntOrBool `key:"newline_separated"`
// RemoveDuplicates determines whether we drop lines that are an exact duplicate.
RemoveDuplicates bool `key:"remove_duplicates"`
// Syntax used to start a comment for keep-sorted annotation, e.g. "//".
commentMarker string
}
var (
defaultOptions = blockOptions{
AllowYAMLLists: true,
Group: true,
StickyComments: true,
StickyPrefixes: nil, // Will be populated with the comment marker of the start directive.
Order: OrderAsc,
CaseSensitive: true,
RemoveDuplicates: true,
}
fieldIndexByKey map[string]int
)
func init() {
fieldIndexByKey = make(map[string]int)
typ := reflect.TypeFor[blockOptions]()
for i := 0; i < typ.NumField(); i++ {
field := typ.Field(i)
if !field.IsExported() {
continue
}
key := key(field)
if !keyRegex.MatchString(key + "=") {
panic(fmt.Errorf("key %q for blockOptions.%s would not be matched by parser (regex: %v)", key, field.Name, keyRegex))
}
fieldIndexByKey[key] = i
}
}
func key(f reflect.StructField) string {
key := strings.ToLower(f.Name)
if k, ok := f.Tag.Lookup("key"); ok {
key = k
}
return key
}
func parseBlockOptions(commentMarker, options string, defaults blockOptions) (_ blockOptions, warnings []error) {
ret := defaults
opts := reflect.ValueOf(&ret).Elem()
var warns []error
parser := newParser(options)
for {
parser.allowYAMLLists = ret.AllowYAMLLists
key, ok := parser.popKey()
if !ok {
break
}
fieldIdx, ok := fieldIndexByKey[key]
if !ok {
warns = append(warns, fmt.Errorf("unrecognized option %q", key))
continue
}
field := opts.Field(fieldIdx)
val, err := parser.popValue(field.Type())
if err != nil {
warns = append(warns, fmt.Errorf("while parsing option %q: %w", key, err))
continue
}
field.Set(val)
}
if cm := guessCommentMarker(commentMarker); cm != "" {
ret.setCommentMarker(cm)
}
// Look at longer prefixes first, in case one of these prefixes is a prefix of another.
longestFirst := comparing(func(s string) int { return len(s) }).reversed()
slices.SortFunc(ret.IgnorePrefixes, longestFirst)
if warn := validate(&ret); len(warn) > 0 {
warns = append(warns, warn...)
}
return ret, warns
}
func formatValue(val reflect.Value) (string, error) {
switch val.Type() {
case reflect.TypeFor[bool]():
return boolString[val.Bool()], nil
case reflect.TypeFor[SortOrder]():
return string(val.Interface().(SortOrder)), nil
case reflect.TypeFor[[]string]():
return formatList(val.Interface().([]string))
case reflect.TypeFor[map[string]bool]():
return formatList(slices.Sorted(maps.Keys(val.Interface().(map[string]bool))))
case reflect.TypeFor[IntOrBool]():
return strconv.Itoa(int(val.Int())), nil
case reflect.TypeFor[int]():
return strconv.Itoa(int(val.Int())), nil
case reflect.TypeFor[[]ByRegexOption]():
opts := val.Interface().([]ByRegexOption)
vals := make([]string, len(opts))
seenTemplate := false
for i, opt := range opts {
if opt.Template != nil {
seenTemplate = true
break
}
vals[i] = opt.Pattern.String()
}
if seenTemplate {
// always presented as a yaml sequence to preserve any `k:v` items
return formatYAMLList(opts)
}
return formatList(vals)
case reflect.TypeFor[[]*regexp.Regexp]():
regexps := val.Interface().([]*regexp.Regexp)
vals := make([]string, len(regexps))
for i, regex := range regexps {
vals[i] = regex.String()
}
return formatList(vals)
}
panic(fmt.Errorf("unsupported blockOptions type: %v", val.Type()))
}
func formatList(vals []string) (string, error) {
var specialChars bool
if len(vals) > 0 && strings.HasPrefix(vals[0], "[") {
specialChars = true
} else {
for _, val := range vals {
if strings.ContainsAny(val, ", ") {
specialChars = true
break
}
}
}
if !specialChars {
return strings.Join(vals, ","), nil
}
return formatYAMLList(vals)
}
func formatYAMLList[T any](vals []T) (string, error) {
node := new(yaml.Node)
if err := node.Encode(vals); err != nil {
return "", fmt.Errorf("while converting list to YAML: %w", err)
}
node.Style |= yaml.FlowStyle
out, err := yaml.Marshal(node)
if err != nil {
return "", fmt.Errorf("while formatting YAML: %w", err)
}
return strings.TrimSpace(string(out)), nil
}
func guessCommentMarker(startLine string) string {
startLine = strings.TrimSpace(startLine)
for _, marker := range []string{"//", "#", "/*", "--", ";", "<!--"} {
if strings.HasPrefix(startLine, marker) {
return marker
}
}
return ""
}
func (opts *blockOptions) setCommentMarker(marker string) {
opts.commentMarker = marker
if opts.StickyComments {
if opts.StickyPrefixes == nil {
opts.StickyPrefixes = make(map[string]bool)
}
opts.StickyPrefixes[marker] = true
}
}
func validate(opts *blockOptions) (warnings []error) {
var warns []error
if opts.SkipLines < 0 {
warns = append(warns, fmt.Errorf("skip_lines has invalid value: %v", opts.SkipLines))
opts.SkipLines = 0
}
if opts.NewlineSeparated < 0 {
warns = append(warns, fmt.Errorf("newline_separated has invalid value: %v", opts.NewlineSeparated))
opts.NewlineSeparated = 0
}
if opts.GroupPrefixes != nil && !opts.Group {
warns = append(warns, fmt.Errorf("group_prefixes may not be used with group=no"))
opts.GroupPrefixes = nil
}
if len(opts.ByRegex) > 0 && len(opts.IgnorePrefixes) > 0 {
var pre []string
for _, p := range opts.IgnorePrefixes {
pre = append(pre, regexp.QuoteMeta(p))
}
suggestion := "(?:" + strings.Join(pre, "|") + ")"
warns = append(warns, fmt.Errorf("by_regex cannot be used with ignore_prefixes (consider adding a non-capturing group to the start of your regex instead of ignore_prefixes: %q)", suggestion))
opts.IgnorePrefixes = nil
}
if opts.GroupStartRegex != nil && opts.GroupEndRegex != nil {
warns = append(warns, fmt.Errorf("group_start_regex should not be used together with group_end_regex; ignoring group_end_regex"))
opts.GroupEndRegex = nil
}
return warns
}
func (opts blockOptions) String() string {
var s []string
val := reflect.ValueOf(opts)
var errs []error
for _, key := range slices.Sorted(maps.Keys(fieldIndexByKey)) {
field := val.Type().Field(fieldIndexByKey[key])
fieldVal := val.FieldByIndex(field.Index)
if fieldVal.IsZero() {
continue
}
val, err := formatValue(fieldVal)
if err != nil {
errs = append(errs, err)
} else {
s = append(s, fmt.Sprintf("%s=%s", key, val))
}
}
if err := errors.Join(errs...); err != nil {
panic(err)
}
return strings.Join(s, " ")
}
// hasPrefix returns the first prefix that s starts with.
func (opts blockOptions) hasPrefix(s string, prefixes iter.Seq[string]) (string, bool) {
p, _, ok := opts.cutFirstPrefix(s, prefixes)
return p, ok
}
// cutFirstPrefix finds the first prefix that s starts with and returns both the prefix and s without the prefix.
// If s does not start with any prefix, returns "", s, false.
func (opts blockOptions) cutFirstPrefix(s string, prefixes iter.Seq[string]) (pre string, after string, ok bool) {
// Don't modify s since we want to return it exactly if it doesn't start with
// any of the prefixes.
t := strings.TrimLeftFunc(s, unicode.IsSpace)
if !opts.CaseSensitive {
t = strings.ToLower(t)
}
for p := range prefixes {
q := p
if !opts.CaseSensitive {
// Ditto: Don't modify the prefix since we'll want to return it exactly.
q = strings.ToLower(p)
}
if strings.HasPrefix(t, q) {
after = s
// Remove leading whitespace (t already has its leading whitespace removed).
after = strings.TrimLeftFunc(after, unicode.IsSpace)
// Remove the prefix.
after = after[len(p):]
// Check again for leading whitespace.
after = strings.TrimLeftFunc(after, unicode.IsSpace)
return p, after, true
}
}
return "", s, false
}
// hasStickyPrefix determines if s has one of the StickyPrefixes.
func (opts blockOptions) hasStickyPrefix(s string) bool {
_, ok := opts.hasPrefix(s, maps.Keys(opts.StickyPrefixes))
return ok
}
// hasGroupPrefix determines if s has one of the GroupPrefixes.
func (opts blockOptions) hasGroupPrefix(s string) bool {
_, ok := opts.hasPrefix(s, maps.Keys(opts.GroupPrefixes))
return ok
}
// trimIgnorePrefix removes the first matching IgnorePrefixes from s, if s
// matches one of the IgnorePrefixes.
func (opts blockOptions) trimIgnorePrefix(s string) string {
_, s, _ = opts.cutFirstPrefix(s, slices.Values(opts.IgnorePrefixes))
return s
}
// matchRegexes applies ByRegex to s.
// If ByRegex is empty, returns a slice that contains just s.
// Otherwise, applies each regex to s in sequence:
// If a regex has capturing groups, the capturing groups will be added to the
// resulting slice.
// If a regex does not have capturing groups, all matched text will be added to
// the resulting slice.
func (opts blockOptions) matchRegexes(s string) []regexMatch {
if len(opts.ByRegex) == 0 {
return []regexMatch{{s}}
}
var ret []regexMatch
for _, p := range opts.ByRegex {
regex := p.Pattern
if p.Template != nil {
var result []byte
m := regex.FindAllStringSubmatchIndex(s, -1)
if m == nil {
ret = append(ret, regexDidNotMatch)
continue
}
for _, submatches := range m {
result = regex.ExpandString(result, *p.Template, s, submatches)
}
ret = append(ret, regexMatch{string(result)})
continue
}
m := regex.FindStringSubmatch(s)
if m == nil {
ret = append(ret, regexDidNotMatch)
continue
}
if len(m) == 1 {
// No capturing groups. Consider all matched text.
ret = append(ret, m)
} else {
// At least one capturing group. Only consider the capturing groups.
ret = append(ret, m[1:])
}
}
return ret
}
// regexMatch is the result of matching a regex to a string. It has 3 forms:
// 1. If the regex matched and the regex had capturing groups, it's the value
// of those capturing groups.
// 2. If the regex matched and the regex didn't have capturing groups, it's the
// value of the matched string as a singleton slice.
// 3. If the regex didn't match, it's regexDidNotMatch / nil.
type regexMatch []string
var regexDidNotMatch regexMatch = nil
func compareRegexMatches(fn cmpFunc[[]string]) cmpFunc[[]regexMatch] {
alwaysLast := comparingFunc(func(t regexMatch) bool { return t == nil }, falseFirst())
delegate := comparingFunc(func(t regexMatch) []string { return t }, fn)
return lexicographically(alwaysLast.andThen(delegate))
}
var (
mixedNumberPattern = regexp.MustCompile(`([0-9]+)|([^0-9]+)`)
)
// maybeParseNumeric handles the Numeric option.
//
// If Numeric is true, the string will be parsed into subsequences of strings and numeric values.
// If Numeric is false, the result will just be a single token of the unchanged string.
func (opts blockOptions) maybeParseNumeric(s string) numericTokens {
if !opts.Numeric {
return numericTokens{[]string{s}, nil}
}
var t numericTokens
m := mixedNumberPattern.FindAllStringSubmatch(s, -1)
for _, sm := range m {
if sm[1] != "" { // Numeric token
if t.len() == 0 {
// Make sure numericTokens "starts" with a string.
// See the comment on numericTokens for more details.
t.s = append(t.s, "")
}
i := new(big.Int)
if _, ok := i.SetString(sm[1], 10); !ok {
panic(fmt.Errorf("mixedNumberPattern yielded an unparseable int: %q", sm[1]))
}
t.i = append(t.i, i)
} else /* sm[2] != "" */ { // String token
t.s = append(t.s, sm[2])
}
}
return t
}
// numericTokens is the result of parsing all numeric tokens out of a string.
//
// e.g. a string like "Foo_123" becomes
//
// s: []string{"Foo_"},
// i: []int64{123},
//
// To make comparisons possible, numericTokens _always_ "start" with a string,
// even if the string naturally starts with a number e.g. a string like
// "123_Foo" becomes
//
// s: []string{"", "_Foo"},
// i: []int64{123},
type numericTokens struct {
s []string
i []*big.Int
}
func (t numericTokens) DebugString() string {
s := make([]string, 0, t.len())
for i := 0; i < t.len(); i++ {
if i%2 == 0 {
val := t.s[i/2]
if i == 0 && val == "" {
continue
}
s = append(s, fmt.Sprintf("%#v", t.s[i/2]))
} else {
s = append(s, fmt.Sprintf("%#v", t.i[i/2]))
}
}
if len(s) == 1 {
return s[0]
}
return fmt.Sprintf("%v", s)
}
func (t numericTokens) len() int {
return len(t.s) + len(t.i)
}
func (t numericTokens) compare(o numericTokens) int {
for i := 0; i < min(t.len(), o.len()); i++ {
if i%2 == 0 { // Start by comparing strings.
if c := strings.Compare(t.s[i/2], o.s[i/2]); c != 0 {
return c
}
} else { // Alternate by comparing with numbers.
if c := t.i[i/2].Cmp(o.i[i/2]); c != 0 {
return c
}
}
}
// If the numericTokens are all the same, whichever numericTokens that's
// smaller is less than the other.
return t.len() - o.len()
}
type prefixOrder struct {
opts blockOptions
prefixWeights map[string]int
prefixes []string
}
func newPrefixOrder(opts blockOptions) *prefixOrder {
if len(opts.PrefixOrder) == 0 {
return nil
}
// Assign a weight to each prefix so that they will be sorted into their
// predetermined order.
// Weights are negative so that entries with matching prefixes are put before
// any non-matching line (which will have a weight of 0).
//
// An empty prefix can be used to move "non-matching" entries to a position
// between other prefixes.
prefixWeights := make(map[string]int)
for i, p := range opts.PrefixOrder {
prefixWeights[p] = i - len(opts.PrefixOrder)
}
// Sort prefixes longest -> shortest to find the most appropriate weight.
longestFirst := comparing(func(s string) int { return len(s) }).reversed()
prefixes := slices.SortedStableFunc(slices.Values(opts.PrefixOrder), longestFirst)
return &prefixOrder{opts, prefixWeights, prefixes}
}
func (o *prefixOrder) match(s string) orderedPrefix {
if o == nil {
return orderedPrefix{}
}
pre, _ := o.opts.hasPrefix(s, slices.Values(o.prefixes))
return orderedPrefix{pre, o.prefixWeights[pre]}
}
type orderedPrefix struct {
prefix string
weight int
}
func (pre orderedPrefix) compare(other orderedPrefix) int {
return cmp.Compare(pre.weight, other.weight)
}