blob: 68cc0221653c687ed6e4a6d31997a1aaeb3482f4 [file]
// 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 (
"slices"
"strings"
"github.com/rs/zerolog/log"
)
type block struct {
metadata blockMetadata
start, end int
// lines are the content of this block from the original file.
//
// Do not modify this slice:
// This slice shares the same backing array as every other keep-sorted block
// in this file. That same backing array is also used by Fixer.Fix to
// generate the fixed file content. Modifying the backing array might have
// unintended effects on other (nested) blocks. Modifying the backing array
// will have unintended effects on Fixer.Fix.
lines []string
nestedBlocks []block
}
type blockMetadata struct {
startDirective, endDirective string
opts blockOptions
}
type incompleteBlock struct {
line int
dir directive
}
type directive int
const (
startDirective directive = iota
endDirective
)
// newBlocks finds all keep-sorted blocks in lines and returns them.
//
// block.start and block.end will be the index of the keep-sorted directives
// in lines, plus the additional offset (typically 1 to convert indexes to line numbers).
//
// incompleteBlocks are the index+offset of keep-sorted directives that
// don't have a matching start or end directive.
//
// include is a function that lets the caller determine if a particular block
// should be included in the result. Mostly useful for filtering keep-sorted
// blocks to just the ones that were modified by the currently CL.
func (f *Fixer) newBlocks(filename string, lines []string, offset int, include func(start, end int) bool) (_ []block, _ []incompleteBlock, warnings []*Finding) {
var blocks []block
var incompleteBlocks []incompleteBlock
type startLine struct {
index int
line string
}
// starts is a stack of startLines.
var starts []startLine
// nestedBlocks by nesting level. nestedBlocks[0] is the slice of blocks that
// are nested under the current top-level block.
var nestedBlocks [][]block
for i, l := range lines {
if strings.Contains(l, f.startDirective) {
starts = append(starts, startLine{i, l})
} else if strings.Contains(l, f.endDirective) {
if len(starts) == 0 {
incompleteBlocks = append(incompleteBlocks, incompleteBlock{i + offset, endDirective})
continue
}
start := starts[len(starts)-1]
starts = starts[0 : len(starts)-1]
endIndex := i
// Keep any blank lines leading up to the end tag by simply excluding
// them from being sorted (any at the beginning should already be sorted
// at the top).
// The original justification for this was better handling of markdown
// lists (cr/423863898), but the markdown formatter doesn't seem to care
// about the newlines anymore.
// It's nice to keep this around so that users can add a little extra
// formatting to their keep-sorted blocks.
for endIndex > start.index && strings.TrimSpace(lines[endIndex-1]) == "" {
endIndex--
}
if !include(start.index+offset, endIndex+offset) {
continue
}
commentMarker, options, _ := strings.Cut(start.line, f.startDirective)
opts, optionWarnings := parseBlockOptions(commentMarker, options, f.defaultOptions)
for _, warn := range optionWarnings {
warnings = append(warnings, finding(filename, start.index+offset, start.index+offset, warn.Error()))
}
start.index += opts.SkipLines
if start.index >= endIndex {
continue
}
// Top-level keep-sorted directives have depth 0. Nested keep-sorted
// directives will have depth >= 1 based on how deep it is.
depth := len(starts)
block := block{
metadata: blockMetadata{
startDirective: f.startDirective,
endDirective: f.endDirective,
opts: opts,
},
start: start.index + offset,
end: endIndex + offset,
lines: lines[start.index+1 : endIndex],
}
// For example, consider depth=0:
// If we just finished a top-level block and there are first-level nested
// blocks present, we need to remove those from nestedBlocks and include
// them on this block.
// It isn't possible for len(nestedBlocks) to be > depth+1:
// At depth n, n != 0, we increase the length of nestedBlocks to be n.
// At depth m=n-1, the length of nestedBlocks will initially be n=m+1 (the assertion from above)
// and then we trim that down to be length m when we add the nested blocks
// to the current block.
if len(nestedBlocks) == depth+1 {
block.nestedBlocks = nestedBlocks[depth]
nestedBlocks = nestedBlocks[0:depth]
}
if depth == 0 {
// Top-level blocks get returned.
// Nested blocks are returned via their top-level block.
blocks = append(blocks, block)
} else {
// Otherwise, the current block appears to be nested. Add it to nestedBlocks.
for len(nestedBlocks) < depth {
nestedBlocks = append(nestedBlocks, nil)
}
nestedBlocks[depth-1] = append(nestedBlocks[depth-1], block)
}
// Invariant: len(nestedBlocks) == depth
}
}
if len(starts) > 0 {
for _, st := range starts {
incompleteBlocks = append(incompleteBlocks, incompleteBlock{st.index + offset, startDirective})
}
// There were some unfinished start directives. They might've caused some
// blocks to be incorrectly considered nested.
for _, nested := range nestedBlocks {
blocks = append(blocks, nested...)
}
}
return blocks, incompleteBlocks, warnings
}
// sorted returns a slice which represents the correct sorting of b.lines.
// If b.lines is already correctly sorted, we will return b.lines, true.
func (b block) sorted() (sorted []string, alreadySorted bool) {
alreadySorted = true
// Sort the nested blocks first so that their changes are visible to the
// outer block.
type nestedResult struct {
lines []string
alreadySorted bool
}
var nestedResults []nestedResult
for _, n := range b.nestedBlocks {
lines, already := n.sorted()
if !already {
alreadySorted = false
}
nestedResults = append(nestedResults, nestedResult{lines, already})
}
lines := b.lines
if !alreadySorted {
var lineChunks [][]string
// The total number of lines in lineChunks.
var numLines int
// Our current position within lines.
var cursor int
for i, nested := range b.nestedBlocks {
res := nestedResults[i]
if res.alreadySorted {
// This nested block was already sorted. Its content in lines is already
// correct. We will add this block to lineChunks either as an unchanged
// prefix to a changed nested block, or as the remainder of lines if there
// are no more changed nested blocks.
continue
}
offset := nested.start - b.start
// Unchanged prefix of lines.
lineChunks = append(lineChunks, lines[cursor:offset])
numLines += offset - cursor
// The piece of the nested block that changed.
lineChunks = append(lineChunks, res.lines)
numLines += len(res.lines)
// Advance cursor to the end of the nested block within lines.
cursor = offset + len(nested.lines)
}
if rem := lines[cursor:]; len(rem) > 0 {
// Are there any lines remaining in lines after handing all the nested
// blocks?
lineChunks = append(lineChunks, rem)
numLines += len(rem)
}
// See above for the scary comment telling us not to modify b.lines directly.
lines = make([]string, 0, numLines)
for _, chunk := range lineChunks {
lines = append(lines, chunk...)
}
}
log.Printf("Creating line groups for block at index %d (options %v)", b.start, b.metadata.opts)
groups := groupLines(lines, b.metadata)
trimTrailingComma := handleTrailingComma(groups)
numNewlines := int(b.metadata.opts.NewlineSeparated)
wasNewlineSeparated := true
if b.metadata.opts.NewlineSeparated > 0 {
wasNewlineSeparated = isNewlineSeparated(groups, numNewlines)
var withoutNewlines []*lineGroup
for _, lg := range groups {
if !isAllEmpty(lg) {
withoutNewlines = append(withoutNewlines, lg)
}
}
groups = withoutNewlines
}
removedDuplicate := false
if b.metadata.opts.RemoveDuplicates {
seen := map[string]bool{}
var deduped []*lineGroup
for _, lg := range groups {
if s := lg.String(); !seen[s] {
seen[s] = true
deduped = append(deduped, lg)
} else {
removedDuplicate = true
}
}
groups = deduped
}
if alreadySorted && wasNewlineSeparated && !removedDuplicate && slices.IsSortedFunc(groups, compareLineGroups) {
log.Printf("It was already sorted!")
if log.Debug().Enabled() {
for _, lg := range groups {
log.Print(lg.DebugString())
}
}
trimTrailingComma(groups)
return lines, true
}
slices.SortStableFunc(groups, compareLineGroups)
if log.Debug().Enabled() {
for _, lg := range groups {
log.Print(lg.DebugString())
}
}
trimTrailingComma(groups)
if b.metadata.opts.NewlineSeparated > 0 {
var separated []*lineGroup
newline := &lineGroup{lineGroupContent: lineGroupContent{lines: make([]string, numNewlines)}}
for _, lg := range groups {
if separated != nil {
separated = append(separated, newline)
}
separated = append(separated, lg)
}
groups = separated
}
l := make([]string, 0, len(lines))
for _, g := range groups {
l = append(l, g.allLines()...)
}
return l, false
}
// isNewlineSeparated determines if the given lineGroups are already NewlineSeparated,
// and are separated by groups containing exactly numNewlines empty lines.
//
// e.g.
// non-empty group
// newline group (repeated numNewlines times)
// non-empty group
// newline group (repeated numNewlines times)
// .
// .
// .
// non-empty group
func isNewlineSeparated(gs []*lineGroup, numNewlines int) bool {
if len(gs) == 0 {
return true
}
if isAllEmpty(gs[len(gs)-1]) {
return false
}
i := 0
for i < len(gs) {
// Expect a data group (a group with at least one non-empty line or a comment)
if isAllEmpty(gs[i]) {
return false // Expected data group, found an empty group without comments
}
i++
// If this is the last group, we are done.
if i == len(gs) {
break
}
// Expect a separator of numNewlines empty lines.
emptyLinesCount := 0
// Sum up consecutive empty line groups
for i < len(gs) && isAllEmpty(gs[i]) {
emptyLinesCount += len(gs[i].lines)
i++
}
if emptyLinesCount != numNewlines {
return false // Incorrect number of newlines in the separator
}
}
return true
}
func isAllEmpty(lg *lineGroup) bool {
if len(lg.comment) > 0 {
return false
}
for _, line := range lg.lines {
if strings.TrimSpace(line) != "" {
return false
}
}
return true
}
// handleTrailingComma handles the special case that all lines of a sorted segment are terminated
// by a comma except for the final element; in this case, we add a ',' to the
// last linegroup and strip it again after sorting.
func handleTrailingComma(lgs []*lineGroup) (trimTrailingComma func([]*lineGroup)) {
var dataGroups []*lineGroup
for _, lg := range lgs {
if len(lg.lines) > 0 {
dataGroups = append(dataGroups, lg)
}
}
if n := len(dataGroups); n > 1 && allHaveSuffix(dataGroups[0:n-1], ",") && !dataGroups[n-1].hasSuffix(",") {
dataGroups[n-1].append(",")
return func(lgs []*lineGroup) {
for i := len(lgs) - 1; i >= 0; i-- {
if len(lgs[i].lines) > 0 {
lgs[i].trimSuffix(",")
return
}
}
}
}
return func([]*lineGroup) {}
}
func allHaveSuffix(lgs []*lineGroup, s string) bool {
for _, lg := range lgs {
if !lg.hasSuffix(s) {
return false
}
}
return true
}