Skip to content

Commit b26fefa

Browse files
committed
feat: Add experimental format-preserving writer
This PR introduces an opt-in experimental writer to the various ratchet operations that modify files (pin/unpin/update/upgrade) that preserves original file formatting when comments are mixed or near YAML nodes. The new flag uses line & column positions to modify ratchet references while still using the AST traversal to find refs to operate on. This fixes an issue where block comments above or inline with certain nodes get reordered unexpectedly from the output of ratchet.
1 parent 8b4ca25 commit b26fefa

File tree

7 files changed

+331
-18
lines changed

7 files changed

+331
-18
lines changed

command/command.go

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,3 +264,191 @@ func computeNewlineTargets(before, after string) []int {
264264

265265
return result
266266
}
267+
268+
// writeYAMLFilesSurgical writes files using surgical text replacement instead of
269+
// re-serializing the YAML. This preserves the original formatting of the file.
270+
// It walks the modified yaml.Node tree and applies text replacements based on
271+
// line/column positions.
272+
func (r loadResults) writeYAMLFilesSurgical(outPath string) error {
273+
var merr error
274+
275+
for pth, f := range r {
276+
outFile := outPath
277+
if strings.HasSuffix(outPath, "/") {
278+
outFile = filepath.Join(outPath, pth)
279+
}
280+
if outFile == "" {
281+
outFile = pth
282+
}
283+
284+
final := applySurgicalReplacements(f.contents, f.node)
285+
286+
if err := atomic.Write(pth, outFile, strings.NewReader(final)); err != nil {
287+
merr = errors.Join(merr, fmt.Errorf("failed to save file %s: %w", outFile, err))
288+
continue
289+
}
290+
}
291+
292+
return merr
293+
}
294+
295+
// applySurgicalReplacements walks the yaml.Node tree and applies text replacements
296+
// to the original content based on the node's line/column positions.
297+
func applySurgicalReplacements(contents string, node *yaml.Node) string {
298+
// Collect all replacements from the node tree
299+
var replacements []surgicalReplacement
300+
collectReplacements(node, contents, &replacements)
301+
302+
if len(replacements) == 0 {
303+
return contents
304+
}
305+
306+
// Sort by line descending, then column descending, so we can apply
307+
// replacements without affecting positions of subsequent ones
308+
slices.SortFunc(replacements, func(a, b surgicalReplacement) int {
309+
if a.line != b.line {
310+
return b.line - a.line
311+
}
312+
return b.col - a.col
313+
})
314+
315+
lines := strings.Split(contents, "\n")
316+
317+
for _, rep := range replacements {
318+
if rep.line < 1 || rep.line > len(lines) {
319+
continue
320+
}
321+
322+
lineIdx := rep.line - 1
323+
line := lines[lineIdx]
324+
325+
// Find the old value starting from the column position
326+
colIdx := rep.col - 1
327+
if colIdx < 0 || colIdx >= len(line) {
328+
continue
329+
}
330+
331+
// Find the old value in the line
332+
valueStart := strings.Index(line[colIdx:], rep.oldValue)
333+
if valueStart == -1 {
334+
continue
335+
}
336+
valueStart += colIdx
337+
valueEnd := valueStart + len(rep.oldValue)
338+
339+
// Find where any existing comment starts (after the value)
340+
commentStart := -1
341+
rest := line[valueEnd:]
342+
for i := 0; i < len(rest); i++ {
343+
if rest[i] == '#' {
344+
commentStart = valueEnd + i
345+
break
346+
}
347+
}
348+
349+
// Build the new line: keep prefix, add new value, then new comment
350+
var newLine string
351+
if commentStart != -1 {
352+
// There's an existing comment - replace value and everything after
353+
newLine = line[:valueStart] + rep.newValue
354+
} else {
355+
// No existing comment - just replace value
356+
newLine = line[:valueStart] + rep.newValue + line[valueEnd:]
357+
}
358+
359+
// Add new comment if specified
360+
if rep.newComment != "" {
361+
// Check if the comment already includes the # prefix
362+
if strings.HasPrefix(rep.newComment, "#") {
363+
newLine = newLine + " " + rep.newComment
364+
} else {
365+
newLine = newLine + " # " + rep.newComment
366+
}
367+
}
368+
369+
lines[lineIdx] = newLine
370+
}
371+
372+
return strings.Join(lines, "\n")
373+
}
374+
375+
type surgicalReplacement struct {
376+
line int
377+
col int
378+
oldValue string
379+
newValue string
380+
newComment string
381+
}
382+
383+
// collectReplacements walks the node tree and collects replacements for nodes
384+
// that have been modified by the parser (detected by presence of "ratchet:" in LineComment).
385+
func collectReplacements(node *yaml.Node, contents string, replacements *[]surgicalReplacement) {
386+
if node == nil {
387+
return
388+
}
389+
390+
// Only process scalar nodes that have been modified by Pin/Update/Upgrade
391+
// These are identified by having "ratchet:" in the LineComment
392+
if node.Kind == yaml.ScalarNode && node.Line > 0 && node.Column > 0 &&
393+
strings.Contains(node.LineComment, "ratchet:") {
394+
395+
lines := strings.Split(contents, "\n")
396+
if node.Line <= len(lines) {
397+
line := lines[node.Line-1]
398+
colIdx := node.Column - 1
399+
400+
if colIdx >= 0 && colIdx < len(line) {
401+
origValue := extractValueAtPosition(line, colIdx)
402+
403+
if origValue != "" && origValue != node.Value {
404+
*replacements = append(*replacements, surgicalReplacement{
405+
line: node.Line,
406+
col: node.Column,
407+
oldValue: origValue,
408+
newValue: node.Value,
409+
newComment: node.LineComment,
410+
})
411+
}
412+
}
413+
}
414+
}
415+
416+
// Recurse into children
417+
for _, child := range node.Content {
418+
collectReplacements(child, contents, replacements)
419+
}
420+
}
421+
422+
// extractValueAtPosition extracts the YAML value at the given position in the line.
423+
// Handles both quoted and unquoted values.
424+
func extractValueAtPosition(line string, col int) string {
425+
if col >= len(line) {
426+
return ""
427+
}
428+
429+
rest := line[col:]
430+
431+
// Handle quoted strings
432+
if len(rest) > 0 && (rest[0] == '\'' || rest[0] == '"') {
433+
quote := rest[0]
434+
end := 1
435+
for end < len(rest) {
436+
if rest[end] == byte(quote) && (end == 0 || rest[end-1] != '\\') {
437+
return rest[1:end]
438+
}
439+
end++
440+
}
441+
}
442+
443+
// Handle unquoted values - read until whitespace or comment
444+
end := 0
445+
for end < len(rest) {
446+
ch := rest[end]
447+
if ch == ' ' || ch == '\t' || ch == '#' || ch == '\n' || ch == '\r' {
448+
break
449+
}
450+
end++
451+
}
452+
453+
return rest[:end]
454+
}

command/command_test.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ func Test_loadYAMLFiles(t *testing.T) {
2222
"cloudbuild.yml": "",
2323
"docker.yml": "",
2424
"drone.yml": "",
25+
"github-codeql-pr125.yml": "",
2526
"github-crazy-indent.yml": "github.yml",
2627
"github-issue-80.yml": "",
2728
"github.yml": "",
@@ -52,8 +53,8 @@ func Test_loadYAMLFiles(t *testing.T) {
5253
t.Fatal(err)
5354
}
5455

55-
if got != string(want) {
56-
t.Errorf("expected\n\n%s\n\nto be\n\n%s\n", got, want)
56+
if diff := cmp.Diff(string(want), got); diff != "" {
57+
t.Errorf("round-trip mismatch (-want +got):\n%s", diff)
5758
}
5859
})
5960
}

command/pin.go

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,10 @@ FLAGS
3636
`
3737

3838
type PinCommand struct {
39-
flagConcurrency int64
40-
flagParser string
41-
flagOut string
39+
flagConcurrency int64
40+
flagParser string
41+
flagOut string
42+
flagExperimentalPreserveYAML bool
4243
}
4344

4445
func (c *PinCommand) Desc() string {
@@ -56,6 +57,8 @@ func (c *PinCommand) Flags() *flag.FlagSet {
5657
"maximum number of concurrent resolutions")
5758
f.StringVar(&c.flagParser, "parser", "actions", "parser to use")
5859
f.StringVar(&c.flagOut, "out", "", "output path (defaults to input file)")
60+
f.BoolVar(&c.flagExperimentalPreserveYAML, "experimental-preserve-formatting", false,
61+
"(experimental) preserve original YAML formatting")
5962

6063
return f
6164
}
@@ -89,8 +92,14 @@ func (c *PinCommand) Run(ctx context.Context, originalArgs []string) error {
8992
return fmt.Errorf("failed to pin refs: %w", err)
9093
}
9194

92-
if err := loadResult.writeYAMLFiles(c.flagOut); err != nil {
93-
return fmt.Errorf("failed to save files: %w", err)
95+
if c.flagExperimentalPreserveYAML {
96+
if err := loadResult.writeYAMLFilesSurgical(c.flagOut); err != nil {
97+
return fmt.Errorf("failed to save files: %w", err)
98+
}
99+
} else {
100+
if err := loadResult.writeYAMLFiles(c.flagOut); err != nil {
101+
return fmt.Errorf("failed to save files: %w", err)
102+
}
94103
}
95104

96105
return nil

command/unpin.go

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ FLAGS
3535
`
3636

3737
type UnpinCommand struct {
38-
flagOut string
38+
flagOut string
39+
flagExperimentalPreserveYAML bool
3940
}
4041

4142
func (c *UnpinCommand) Desc() string {
@@ -50,6 +51,8 @@ func (c *UnpinCommand) Flags() *flag.FlagSet {
5051
}
5152

5253
f.StringVar(&c.flagOut, "out", "", "output path (defaults to input file)")
54+
f.BoolVar(&c.flagExperimentalPreserveYAML, "experimental-preserve-formatting", false,
55+
"(experimental) preserve original YAML formatting")
5356

5457
return f
5558
}
@@ -73,8 +76,14 @@ func (c *UnpinCommand) Run(ctx context.Context, originalArgs []string) error {
7376
return fmt.Errorf("failed to pin refs: %w", err)
7477
}
7578

76-
if err := loadResult.writeYAMLFiles(c.flagOut); err != nil {
77-
return fmt.Errorf("failed to save files: %w", err)
79+
if c.flagExperimentalPreserveYAML {
80+
if err := loadResult.writeYAMLFilesSurgical(c.flagOut); err != nil {
81+
return fmt.Errorf("failed to save files: %w", err)
82+
}
83+
} else {
84+
if err := loadResult.writeYAMLFiles(c.flagOut); err != nil {
85+
return fmt.Errorf("failed to save files: %w", err)
86+
}
7887
}
7988

8089
return nil

command/update.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,14 @@ func (c *UpdateCommand) Run(ctx context.Context, originalArgs []string) error {
8282
return fmt.Errorf("failed to pin refs: %w", err)
8383
}
8484

85-
if err := loadResult.writeYAMLFiles(c.flagOut); err != nil {
86-
return fmt.Errorf("failed to save files: %w", err)
85+
if c.flagExperimentalPreserveYAML {
86+
if err := loadResult.writeYAMLFilesSurgical(c.flagOut); err != nil {
87+
return fmt.Errorf("failed to save files: %w", err)
88+
}
89+
} else {
90+
if err := loadResult.writeYAMLFiles(c.flagOut); err != nil {
91+
return fmt.Errorf("failed to save files: %w", err)
92+
}
8793
}
8894

8995
return nil

command/upgrade.go

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,11 @@ FLAGS
3333
`
3434

3535
type UpgradeCommand struct {
36-
flagConcurrency int64
37-
flagParser string
38-
flagOut string
39-
flagPin bool
36+
flagConcurrency int64
37+
flagParser string
38+
flagOut string
39+
flagPin bool
40+
flagExperimentalPreserveYAML bool
4041
}
4142

4243
func (c *UpgradeCommand) Desc() string {
@@ -55,6 +56,8 @@ func (c *UpgradeCommand) Flags() *flag.FlagSet {
5556
f.StringVar(&c.flagParser, "parser", "actions", "parser to use")
5657
f.StringVar(&c.flagOut, "out", "", "output path (defaults to input file)")
5758
f.BoolVar(&c.flagPin, "pin", true, "pin resolved upgraded versions")
59+
f.BoolVar(&c.flagExperimentalPreserveYAML, "experimental-preserve-formatting", false,
60+
"(experimental) preserve original YAML formatting")
5861

5962
return f
6063
}
@@ -98,8 +101,14 @@ func (c *UpgradeCommand) Run(ctx context.Context, originalArgs []string) error {
98101
}
99102
}
100103

101-
if err := loadResult.writeYAMLFiles(c.flagOut); err != nil {
102-
return fmt.Errorf("failed to save files: %w", err)
104+
if c.flagExperimentalPreserveYAML {
105+
if err := loadResult.writeYAMLFilesSurgical(c.flagOut); err != nil {
106+
return fmt.Errorf("failed to save files: %w", err)
107+
}
108+
} else {
109+
if err := loadResult.writeYAMLFiles(c.flagOut); err != nil {
110+
return fmt.Errorf("failed to save files: %w", err)
111+
}
103112
}
104113

105114
return nil

0 commit comments

Comments
 (0)