From 626e70215aa3181709d1e6ff09381b9a7aa6226b Mon Sep 17 00:00:00 2001 From: Will Beason Date: Fri, 13 Mar 2026 11:21:48 -0500 Subject: [PATCH 1/9] Make skip_lines option allow skipping at end skip_lines now interprets negative values as lines to skip at the end of a sorted region. skip_lines now accepts up to two values. If two values are specified, the first must be lines skipped at the start and the second lines skipped at the end. Internally this is represented as an int slice. Add ability for parse options to handle int slices. Modify golden examples to maintain intended behavior. Update documentation with example of skipping lines both at the start and end. --- README.md | 44 +++++++++++++++++++++++++-- goldens/simple.err | 2 +- goldens/simple.in | 2 +- goldens/simple.out | 2 +- keepsorted/block.go | 3 +- keepsorted/keep_sorted_test.go | 30 ++++++++++++++++++- keepsorted/options.go | 49 ++++++++++++++++++++++++++++--- keepsorted/options_parser.go | 7 +++++ keepsorted/options_parser_test.go | 6 ++++ keepsorted/options_test.go | 34 +++++++++++++++++---- 10 files changed, 163 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 8794385..35bac43 100644 --- a/README.md +++ b/README.md @@ -396,8 +396,11 @@ treated as sticky. These prefixes cannot contain space characters. #### Skipping lines In some cases, it may not be possible to have the start directive on the line -immediately before the sorted region. In this case, `skip_lines` can be used to -indicate how many lines are to be skipped before the sorted region. +immediately before the sorted region or end immediately after. In these cases, +`skip_lines` can be used to indicate how many lines are to be skipped before +and/or after the sorted region. The `skip_lines` option interprets positive +values as lines to skip at the start of the sorted region and negative values +as lines to skip at the end. For instance, this can be used with a Markdown table, to prevent the headers and the dashed line after the headers from being sorted: @@ -435,6 +438,43 @@ Alpha | Foo +This can be used to keep the footers of a table from being sorted as well: + + + + + + +
+ +```md + +Item | Cost +----- | ----- +Lemon | $1.50 +Pear | $2.10 +Apple | $1.09 +----- | ----- +Total | $4.69 + +``` + + + +```diff ++ +Item | Cost +----- | ----- +Apple | $1.09 +Lemon | $1.50 +Pear | $2.10 +----- | ----- +Total | $4.69 ++ +``` + +
+ ### Sorting options Sorting options tell keep-sorted how the logical lines in your keep-sorted diff --git a/goldens/simple.err b/goldens/simple.err index 55f1c03..77168db 100644 --- a/goldens/simple.err +++ b/goldens/simple.err @@ -1,4 +1,4 @@ -WRN skip_lines has invalid value: -1 line=113 +WRN skip_lines at start must be nonnegative: -1,1 line=113 WRN unrecognized option "foo" line=113 WRN while parsing option "ignore_prefixes": content appears to be an unterminated YAML list: "[abc, foo" line=120 exit status 1 diff --git a/goldens/simple.in b/goldens/simple.in index 9f5988a..65693cd 100644 --- a/goldens/simple.in +++ b/goldens/simple.in @@ -110,7 +110,7 @@ B // keep-sorted-test end Invalid option - keep-sorted-test start group=yes skip_lines=-1 foo=bar + keep-sorted-test start group=yes skip_lines=-1,1 foo=bar 2 1 3 diff --git a/goldens/simple.out b/goldens/simple.out index 7ef981b..5a8759c 100644 --- a/goldens/simple.out +++ b/goldens/simple.out @@ -111,7 +111,7 @@ C // keep-sorted-test end Invalid option - keep-sorted-test start group=yes skip_lines=-1 foo=bar + keep-sorted-test start group=yes skip_lines=-1,1 foo=bar 1 2 3 diff --git a/keepsorted/block.go b/keepsorted/block.go index 67f5fe3..90d79df 100644 --- a/keepsorted/block.go +++ b/keepsorted/block.go @@ -113,7 +113,8 @@ func (f *Fixer) newBlocks(filename string, lines []string, offset int, include f warnings = append(warnings, finding(filename, start.index+offset, start.index+offset, warn.Error())) } - start.index += opts.SkipLines + start.index += opts.startIndex() + endIndex += opts.endIndex() if start.index >= endIndex { continue } diff --git a/keepsorted/keep_sorted_test.go b/keepsorted/keep_sorted_test.go index 0d00180..883377b 100644 --- a/keepsorted/keep_sorted_test.go +++ b/keepsorted/keep_sorted_test.go @@ -222,7 +222,7 @@ func TestFindings(t *testing.T) { want: []*Finding{finding(filename, 3, 5, errorUnordered, automaticReplacement(3, 5, "1\n2\n3\n"))}, }, { - name: "SkipLines", + name: "SkipLinesStart", in: ` // keep-sorted-test start skip_lines=2 @@ -235,6 +235,34 @@ func TestFindings(t *testing.T) { want: []*Finding{finding(filename, 5, 7, errorUnordered, automaticReplacement(5, 7, "1\n2\n3\n"))}, }, + { + name: "SkipLinesEnd", + + in: ` +// keep-sorted-test start skip_lines=-2 +5 +4 +3 +2 +1 +// keep-sorted-test end`, + + want: []*Finding{finding(filename, 3, 5, errorUnordered, automaticReplacement(3, 5, "3\n4\n5\n"))}, + }, + { + name: "SkipLinesStartAndEnd", + + in: ` +// keep-sorted-test start skip_lines=1,-1 +5 +4 +3 +2 +1 +// keep-sorted-test end`, + + want: []*Finding{finding(filename, 4, 6, errorUnordered, automaticReplacement(4, 6, "2\n3\n4\n"))}, + }, { name: "MismatchedStart", diff --git a/keepsorted/options.go b/keepsorted/options.go index 551f3be..9cef4e5 100644 --- a/keepsorted/options.go +++ b/keepsorted/options.go @@ -75,6 +75,7 @@ func (opts BlockOptions) String() string { // - []string: key=a,b,c,d // - map[string]bool: key=a,b,c,d // - int: key=123 +// - []int: key=1,-1 // - 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. @@ -85,7 +86,7 @@ type blockOptions struct { /////////////////////////// // SkipLines is the number of lines to ignore before sorting. - SkipLines int `key:"skip_lines"` + 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. @@ -243,6 +244,8 @@ func formatValue(val reflect.Value) (string, error) { } case reflect.TypeFor[int](): return strconv.Itoa(int(val.Int())), nil + case reflect.TypeFor[[]int](): + return formatIntList(val.Interface().([]int)), nil case reflect.TypeFor[[]ByRegexOption](): opts := val.Interface().([]ByRegexOption) vals := make([]string, 0, len(opts)) @@ -301,6 +304,14 @@ func formatList(vals []string) (string, error) { return strings.TrimSpace(string(out)), nil } +func formatIntList(intVals []int) string { + vals := make([]string, 0, len(intVals)) + for _, v := range intVals { + vals = append(vals, strconv.Itoa(v)) + } + return strings.Join(vals, ",") +} + func guessCommentMarker(startLine string) string { startLine = strings.TrimSpace(startLine) for _, marker := range []string{"//", "#", "/*", "--", ";", " -Item | Cost ------ | ----- -Apple | $1.09 -Lemon | $1.50 -Pear | $2.10 ------ | ----- -Total | $4.69 + Item | Cost + ----- | ----- + Apple | $1.09 + Lemon | $1.50 + Pear | $2.10 + ----- | ----- + Total | $4.69 + ``` From e8dc3ee9bd5e42b236e7fdf708db04a09a5129af Mon Sep 17 00:00:00 2001 From: Will Beason Date: Mon, 16 Mar 2026 20:28:11 -0500 Subject: [PATCH 5/9] Allow any order of skip_lines options Per review comments, allow start/end offsets to be in any order. --- keepsorted/block.go | 4 ++-- keepsorted/keep_sorted_test.go | 14 ++++++++++++++ keepsorted/options.go | 26 ++++++++++++++++---------- keepsorted/options_parser.go | 2 +- keepsorted/options_test.go | 8 +------- 5 files changed, 34 insertions(+), 20 deletions(-) diff --git a/keepsorted/block.go b/keepsorted/block.go index 90d79df..35a5148 100644 --- a/keepsorted/block.go +++ b/keepsorted/block.go @@ -113,8 +113,8 @@ func (f *Fixer) newBlocks(filename string, lines []string, offset int, include f warnings = append(warnings, finding(filename, start.index+offset, start.index+offset, warn.Error())) } - start.index += opts.startIndex() - endIndex += opts.endIndex() + start.index += opts.startOffset() + endIndex += opts.endOffset() if start.index >= endIndex { continue } diff --git a/keepsorted/keep_sorted_test.go b/keepsorted/keep_sorted_test.go index 883377b..b90cd0b 100644 --- a/keepsorted/keep_sorted_test.go +++ b/keepsorted/keep_sorted_test.go @@ -259,6 +259,20 @@ func TestFindings(t *testing.T) { 3 2 1 +// keep-sorted-test end`, + + want: []*Finding{finding(filename, 4, 6, errorUnordered, automaticReplacement(4, 6, "2\n3\n4\n"))}, + }, + { + name: "SkipLinesEndAndStart", + + in: ` +// keep-sorted-test start skip_lines=-1,1 +5 +4 +3 +2 +1 // keep-sorted-test end`, want: []*Finding{finding(filename, 4, 6, errorUnordered, automaticReplacement(4, 6, "2\n3\n4\n"))}, diff --git a/keepsorted/options.go b/keepsorted/options.go index 9cef4e5..e526995 100644 --- a/keepsorted/options.go +++ b/keepsorted/options.go @@ -338,12 +338,12 @@ func validate(opts *blockOptions) (warnings []error) { warns = append(warns, fmt.Errorf("skip_lines accepts at most two values: %v", formatIntList(opts.SkipLines))) opts.SkipLines = nil } else if len(opts.SkipLines) == 2 { - if opts.SkipLines[0] < 0 { - warns = append(warns, fmt.Errorf("skip_lines at start must be nonnegative: %v", formatIntList(opts.SkipLines))) - opts.SkipLines = nil - } else if opts.SkipLines[1] > 0 { - warns = append(warns, fmt.Errorf("skip_lines at end must be nonpositive: %v", formatIntList(opts.SkipLines))) - opts.SkipLines = nil + if cmp.Compare(opts.SkipLines[0], 0) == cmp.Compare(opts.SkipLines[1], 0) { + // Both are the same sign. It's okay for both to be 0. + if opts.SkipLines[0] != 0 { + warns = append(warns, fmt.Errorf("skip_lines values must have opposite sign: %v", formatIntList(opts.SkipLines))) + opts.SkipLines = nil + } } } @@ -550,9 +550,12 @@ func (opts blockOptions) maybeParseNumeric(s string) numericTokens { return t } -func (opts blockOptions) startIndex() int { +func (opts blockOptions) startOffset() int { if len(opts.SkipLines) == 2 { - return opts.SkipLines[0] + if opts.SkipLines[0] > opts.SkipLines[1] { + return opts.SkipLines[0] + } + return opts.SkipLines[1] } else if len(opts.SkipLines) == 1 { if opts.SkipLines[0] > 0 { return opts.SkipLines[0] @@ -561,9 +564,12 @@ func (opts blockOptions) startIndex() int { return 0 } -func (opts blockOptions) endIndex() int { +func (opts blockOptions) endOffset() int { if len(opts.SkipLines) == 2 { - return opts.SkipLines[1] + if opts.SkipLines[1] < 0 { + return opts.SkipLines[1] + } + return opts.SkipLines[0] } else if len(opts.SkipLines) == 1 { if opts.SkipLines[0] < 0 { return opts.SkipLines[0] diff --git a/keepsorted/options_parser.go b/keepsorted/options_parser.go index 96001ec..56b136c 100644 --- a/keepsorted/options_parser.go +++ b/keepsorted/options_parser.go @@ -193,7 +193,7 @@ func (p *parser) popList() ([]string, error) { } func (p *parser) popIntList() ([]int, error) { - return popListValue(p, func(s string) (int, error) { return strconv.Atoi(s) }) + return popListValue(p, strconv.Atoi) } func (p *parser) popByRegexOption() ([]ByRegexOption, error) { diff --git a/keepsorted/options_test.go b/keepsorted/options_test.go index f73ee47..dfdb9d6 100644 --- a/keepsorted/options_test.go +++ b/keepsorted/options_test.go @@ -109,13 +109,7 @@ func TestBlockOptions(t *testing.T) { name: "ErrorSkipLinesFirstNegative", in: "skip_lines=-1,-1", - wantErr: "skip_lines at start must be nonnegative: -1,-1", - }, - { - name: "ErrorSkipLinesSecondPositive", - in: "skip_lines=1,1", - - wantErr: "skip_lines at end must be nonpositive: 1,1", + wantErr: "skip_lines values must have opposite sign: -1,-1", }, { name: "ErrorSkipLinesTooMany", From 65bc8fd9ee7459d40122b6abef035076ff6cbbf1 Mon Sep 17 00:00:00 2001 From: Will Beason Date: Mon, 16 Mar 2026 20:31:45 -0500 Subject: [PATCH 6/9] Fix golden test --- goldens/simple.err | 2 +- goldens/simple.in | 2 +- goldens/simple.out | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/goldens/simple.err b/goldens/simple.err index 77168db..89698ae 100644 --- a/goldens/simple.err +++ b/goldens/simple.err @@ -1,4 +1,4 @@ -WRN skip_lines at start must be nonnegative: -1,1 line=113 +WRN skip_lines values must have opposite sign: -1,-1 line=113 WRN unrecognized option "foo" line=113 WRN while parsing option "ignore_prefixes": content appears to be an unterminated YAML list: "[abc, foo" line=120 exit status 1 diff --git a/goldens/simple.in b/goldens/simple.in index 65693cd..b426939 100644 --- a/goldens/simple.in +++ b/goldens/simple.in @@ -110,7 +110,7 @@ B // keep-sorted-test end Invalid option - keep-sorted-test start group=yes skip_lines=-1,1 foo=bar + keep-sorted-test start group=yes skip_lines=-1,-1 foo=bar 2 1 3 diff --git a/goldens/simple.out b/goldens/simple.out index 5a8759c..5049910 100644 --- a/goldens/simple.out +++ b/goldens/simple.out @@ -111,7 +111,7 @@ C // keep-sorted-test end Invalid option - keep-sorted-test start group=yes skip_lines=-1,1 foo=bar + keep-sorted-test start group=yes skip_lines=-1,-1 foo=bar 1 2 3 From d7335f9ad4c038e42c46c19803f8ecd248dc1b45 Mon Sep 17 00:00:00 2001 From: Will Beason Date: Tue, 17 Mar 2026 21:51:02 -0500 Subject: [PATCH 7/9] Use max() instead of if Co-authored-by: Jeffrey Faer --- keepsorted/options.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/keepsorted/options.go b/keepsorted/options.go index e526995..bef81bd 100644 --- a/keepsorted/options.go +++ b/keepsorted/options.go @@ -552,10 +552,7 @@ func (opts blockOptions) maybeParseNumeric(s string) numericTokens { func (opts blockOptions) startOffset() int { if len(opts.SkipLines) == 2 { - if opts.SkipLines[0] > opts.SkipLines[1] { - return opts.SkipLines[0] - } - return opts.SkipLines[1] + return max(opts.SkipLines[0], opts.SkipLines[1]) } else if len(opts.SkipLines) == 1 { if opts.SkipLines[0] > 0 { return opts.SkipLines[0] From c614b655b4da57e24abdd5127f00420c5856be2b Mon Sep 17 00:00:00 2001 From: Will Beason Date: Tue, 17 Mar 2026 21:51:20 -0500 Subject: [PATCH 8/9] Use max() instead of if Co-authored-by: Jeffrey Faer --- keepsorted/options.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/keepsorted/options.go b/keepsorted/options.go index bef81bd..e1a617a 100644 --- a/keepsorted/options.go +++ b/keepsorted/options.go @@ -563,10 +563,7 @@ func (opts blockOptions) startOffset() int { func (opts blockOptions) endOffset() int { if len(opts.SkipLines) == 2 { - if opts.SkipLines[1] < 0 { - return opts.SkipLines[1] - } - return opts.SkipLines[0] + return min(opts.SkipLines[0], opts.SkipLines[1]) } else if len(opts.SkipLines) == 1 { if opts.SkipLines[0] < 0 { return opts.SkipLines[0] From 194b551574d4e19cd63f01f9cf9c3755db64c5dd Mon Sep 17 00:00:00 2001 From: Will Beason Date: Tue, 17 Mar 2026 22:01:28 -0500 Subject: [PATCH 9/9] Improve error message for skip_lines Per discussion, make error more explicit about what needs to be changed and what it means. Preferably users should be able to read the error message and correct things without having to refer to the documentation. --- goldens/simple.err | 2 +- keepsorted/options.go | 9 +++++++-- keepsorted/options_test.go | 10 ++++++++-- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/goldens/simple.err b/goldens/simple.err index 89698ae..f9353ff 100644 --- a/goldens/simple.err +++ b/goldens/simple.err @@ -1,4 +1,4 @@ -WRN skip_lines values must have opposite sign: -1,-1 line=113 +WRN skip_lines has conflicting values (should one of these be positive, to skip lines at the start of the block instead?): -1,-1 line=113 WRN unrecognized option "foo" line=113 WRN while parsing option "ignore_prefixes": content appears to be an unterminated YAML list: "[abc, foo" line=120 exit status 1 diff --git a/keepsorted/options.go b/keepsorted/options.go index e1a617a..6b5c6fa 100644 --- a/keepsorted/options.go +++ b/keepsorted/options.go @@ -340,8 +340,13 @@ func validate(opts *blockOptions) (warnings []error) { } else if len(opts.SkipLines) == 2 { if cmp.Compare(opts.SkipLines[0], 0) == cmp.Compare(opts.SkipLines[1], 0) { // Both are the same sign. It's okay for both to be 0. - if opts.SkipLines[0] != 0 { - warns = append(warns, fmt.Errorf("skip_lines values must have opposite sign: %v", formatIntList(opts.SkipLines))) + if opts.SkipLines[0] < 0 { + // Both are negative. + warns = append(warns, fmt.Errorf("skip_lines has conflicting values (should one of these be positive, to skip lines at the start of the block instead?): %v", formatIntList(opts.SkipLines))) + opts.SkipLines = nil + } else if opts.SkipLines[0] > 0 { + // Both are positive. + warns = append(warns, fmt.Errorf("skip_lines has conflicting values (should one of these be negative, to skip lines at the end of the block instead?): %v", formatIntList(opts.SkipLines))) opts.SkipLines = nil } } diff --git a/keepsorted/options_test.go b/keepsorted/options_test.go index dfdb9d6..0ea1475 100644 --- a/keepsorted/options_test.go +++ b/keepsorted/options_test.go @@ -106,10 +106,16 @@ func TestBlockOptions(t *testing.T) { wantErr: "newline_separated has invalid value: -1", }, { - name: "ErrorSkipLinesFirstNegative", + name: "ErrorSkipBothNegative", in: "skip_lines=-1,-1", - wantErr: "skip_lines values must have opposite sign: -1,-1", + wantErr: "skip_lines has conflicting values (should one of these be positive, to skip lines at the start of the block instead?): -1,-1", + }, + { + name: "ErrorSkipBothPositive", + in: "skip_lines=1,1", + + wantErr: "skip_lines has conflicting values (should one of these be negative, to skip lines at the end of the block instead?): 1,1", }, { name: "ErrorSkipLinesTooMany",