格式化后的Golang修改AST出现奇怪缩进问题

格式化后的Golang修改AST出现奇怪缩进问题 我正在开发一个小的重构工具,它允许替换Go仓库中特定结构体字段的值。修改抽象语法树(AST)是有效的,并且相应的字段已被替换,但在用格式化后的AST替换原始源文件后,被替换的部分出现了奇怪的缩进。

我写了一个最小的示例来演示这个问题,可以在以下Playground中查看:https://go.dev/play/p/je4wZaGRZjq

由于我运气不佳且未能找到此问题的解决方案,因此非常感谢任何关于如何修复此问题的建议。

9 回复

太好了,很高兴你找到了解决办法!

更多关于格式化后的Golang修改AST出现奇怪缩进问题的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


这可能有点傻,但你能直接对结果运行 gofmt 吗?

如果有人想关注,我在GitHub上开了一个issue:https://github.com/golang/go/issues/49846

这让我产生了一个疑问:我是否应该在 github.com/golang/go 上提交一个问题,因为这可能是一个缺陷?让我感到困惑的是,如果这确实是一个缺陷,那么其他人应该早就遇到这个问题了,毕竟通过抽象语法树进行重构并不是什么全新的技术。

不,这不是一个愚蠢的问题。输出实际上已经正确地通过了 gofmt 格式化,尽管它的缩进看起来很奇怪。 为了澄清,运行 gofmt 会保持原有的缩进。

func main() {
    fmt.Println("hello world")
}

什么!?我简直不敢相信,直到我亲自运行了代码,结果你确实是对的!这真是太遗憾了。我不知道该怎么办!我刚刚尝试调整了 rawReplacement 中的空白字符,但并没有解决任何问题。

我刚刚尝试了 gofumpt,它生成的输出在我看来更加一致:

package main

import (
        "fmt"
        "strings"
)

func main() {
        s := struct{ A string }{
                A: strings.Join(
                        []string{
                                "Hello",
                                " World!",
                        },
                        ","),
        }

        fmt.Println(s.A)
}

@telo_tade 你为我指明了正确的方向,因为我刚刚找到了问题的根源 🎉

我所做的改变是将 parser.ParseExpr 替换为 parser.ParseExprFrom。前者只是 parser.ParseExprFrom 的一个包装函数,并传入一个空的 token.FileSet。这样一来,替换部分的 AST 节点就被分配到了错误的位置,它们被分配到了 example.go 中的行号,而这些行号是在它们被插入语法树之后才存在的。格式化程序似乎能够识别这些位置信息,这完全说得通,因此输出格式变得很奇怪。使用 parser.ParseExprFrom 并传入现有的 fset 可以修复位置信息问题,同时也解决了格式化问题。

	replacement, err := parser.ParseExprFrom(fset, "replacement.go", string(rawReplacement), parser.AllErrors|parser.ParseComments)

https://go.dev/play/p/PzwikCXTqzB

@telo_tade 感谢您的详细解答!

解决此问题的关键在于正确生成替换节点。您已经非常接近正确答案了,为什么不先生成当前节点,然后修改它(第17和29行),使其符合您的预期呢?

我分享的playground是我正在开发的工具的简化版本,该工具从文件中读取Go表达式,并用它来替换AST中的某个节点。因此,我无法对获取的表达式做任何假设,也就无法修改它。

它们之间的区别在于第(17和29)行与第(118和123)行。您节点中的这两行(17和29)是错误的。

 17 .  .  .  .  .  NamePos: example.go:3:2
 29 .  .  .  .  .  ValuePos: example.go:5:4
118 .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  ValuePos: example.go:7:28
123 .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  ValuePos: example.go:7:36

我认为我还没有完全理解为什么这些特定的行是问题所在。不过,我也会使用 ast.Print 来打印这些AST以调试问题。

再次感谢!

在Go中修改AST后出现缩进问题通常是因为格式化时没有正确处理节点的位置信息。当直接替换AST节点而不更新其位置信息时,go/format包可能无法正确格式化代码。

在你的示例中,主要问题是替换后的节点缺少正确的位置信息。以下是修复后的代码:

package main

import (
	"fmt"
	"go/ast"
	"go/format"
	"go/parser"
	"go/token"
	"log"
	"strings"
)

func main() {
	src := `package main

type Foo struct {
	Bar string
}`

	fset := token.NewFileSet()
	f, err := parser.ParseFile(fset, "", src, parser.ParseComments)
	if err != nil {
		log.Fatal(err)
	}

	ast.Inspect(f, func(n ast.Node) bool {
		if ts, ok := n.(*ast.TypeSpec); ok && ts.Name.Name == "Foo" {
			if st, ok := ts.Type.(*ast.StructType); ok {
				for _, field := range st.Fields.List {
					if field.Names[0].Name == "Bar" {
						// 创建新的字段值并设置正确的位置
						newValue := &ast.BasicLit{
							Kind:  token.STRING,
							Value: `"new value"`,
						}
						
						// 复制原始值的位置信息
						if field.Tag != nil {
							newValue.ValuePos = field.Tag.ValuePos
						} else if field.Type != nil {
							// 如果没有tag,使用类型的位置
							newValue.ValuePos = field.Type.Pos()
						}
						
						// 将新值作为tag添加,而不是替换字段
						field.Tag = newValue
					}
				}
			}
		}
		return true
	})

	var buf strings.Builder
	if err := format.Node(&buf, fset, f); err != nil {
		log.Fatal(err)
	}

	fmt.Println(buf.String())
}

更健壮的解决方案是使用astutil包并确保正确设置位置信息:

package main

import (
	"fmt"
	"go/ast"
	"go/format"
	"go/parser"
	"go/token"
	"go/astutil"
	"log"
	"strings"
)

func main() {
	src := `package main

type Foo struct {
	Bar string
}`

	fset := token.NewFileSet()
	f, err := parser.ParseFile(fset, "", src, parser.ParseComments)
	if err != nil {
		log.Fatal(err)
	}

	// 使用astutil进行更安全的AST操作
	astutil.Apply(f, nil, func(c *astutil.Cursor) bool {
		if field, ok := c.Node().(*ast.Field); ok {
			if len(field.Names) > 0 && field.Names[0].Name == "Bar" {
				// 创建新的tag
				newTag := &ast.BasicLit{
					Kind:     token.STRING,
					Value:    `"new value"`,
					ValuePos: field.Type.Pos(), // 设置正确的位置
				}
				field.Tag = newTag
			}
		}
		return true
	})

	// 使用format.Node时指定fset
	var buf strings.Builder
	if err := format.Node(&buf, fset, f); err != nil {
		log.Fatal(err)
	}

	fmt.Println(buf.String())
}

如果你需要完全替换字段而不仅仅是添加tag,可以这样做:

package main

import (
	"fmt"
	"go/ast"
	"go/format"
	"go/parser"
	"go/token"
	"log"
	"strings"
)

func main() {
	src := `package main

type Foo struct {
	Bar string
	Baz int
}`

	fset := token.NewFileSet()
	f, err := parser.ParseFile(fset, "", src, parser.ParseComments)
	if err != nil {
		log.Fatal(err)
	}

	ast.Inspect(f, func(n ast.Node) bool {
		if ts, ok := n.(*ast.TypeSpec); ok && ts.Name.Name == "Foo" {
			if st, ok := ts.Type.(*ast.StructType); ok {
				for i, field := range st.Fields.List {
					if field.Names[0].Name == "Bar" {
						// 创建新字段
						newField := &ast.Field{
							Names: []*ast.Ident{ast.NewIdent("Bar")},
							Type:  &ast.Ident{Name: "string"},
							Tag:   &ast.BasicLit{Kind: token.STRING, Value: `"new value"`},
						}
						
						// 复制位置信息
						if field.Type != nil {
							newField.Type.SetPos(field.Type.Pos())
						}
						if field.Tag != nil {
							newField.Tag.SetPos(field.Tag.Pos())
						}
						
						// 替换字段
						st.Fields.List[i] = newField
					}
				}
			}
		}
		return true
	})

	var buf strings.Builder
	if err := format.Node(&buf, fset, f); err != nil {
		log.Fatal(err)
	}

	fmt.Println(buf.String())
}

关键点是确保新创建的AST节点具有正确的位置信息。如果位置信息不正确,格式化器就无法确定正确的缩进级别。

回到顶部