登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go问答

Go scanner.Scanner 自定义 Error 回调怎样保留文件行列号

来源:17golang原创

时间:2026-11-05 09:06:08 107浏览 收藏

使用 go/scanner 扫描 Go 源码时,自定义错误处理应通过 Scanner.Init 的 ErrorHandler 参数注册。回调已经给出 token.Position 和消息,保留整份 Position,就能保存文件名、行号、字节列与字节偏移,展示时再格式化。

先澄清标题里的 Error:go/scanner.Scanner 没有可赋值的 Error 字段,text/scanner.Scanner 才有同名字段。本文围绕 go/scanner 的错误回调,不把另一个包的写法混进来,也不扩展到 parser 的错误恢复。

官方地址:https://pkg.go.dev/go/scanner#Scanner

只有错误消息时,先确认导入的是哪个 scanner

设想一个源码检查工具能显示“非法转义”,却无法跳到文件中的具体位置。我会先看收集器存了什么:如果回调只把 msg 追加到字符串切片,行列号往往不是扫描器没有提供,而是在应用封装时被丢掉了。

第二步看导入路径。go/scanner 与 text/scanner 的包名都叫 scanner,复制片段时容易只看到类型名。前者以源字节和 token.File 初始化,错误处理是 Init 的一个参数;后者的初始化和 Error 字段采用另一套接口。两者不能只改 import 就直接互换。

对这个问题,我更愿意先把错误记录结构定义完整,再考虑日志格式。文本输出是最后一层,位置一旦提前压成一段消息,后续排序、定位和交给编辑器都会更加麻烦。

在 Init 回调里保留完整 token.Position

解释Init参数与错误回调归属
原创静态结构图:token.File 关联文件位置,Init 接收源字节与 ErrorHandler,回调提供 Position 和消息。连线表示接口关联,不是运行流程。

文件名来自传给 FileSet.AddFile 的 name。扫描器处理的是内存中的 src,不会替你从磁盘推断文件路径。给 name 传空字符串,即使行列有效,位置文本也不包含文件名。

AddFile 的 size 应为 len(src),即实际扫描字节长度,不能用字符数代替。Scanner.Init 要求 token.File 的大小和源字节长度匹配。本文用 base=-1 让文件集合选择可用区间,不手工拼接位置编号。

回调的 pos 已经是展开后的 token.Position,无需把它强转成 token.Pos 再查询文件集合。扫描 token 时返回的紧凑位置才需要通过对应 FileSet 展开。这两类位置对象的用途要在命名中区分。

用一份错误样本同时保存位置与消息

下面是一份原创完整示例。输入里的字符串含非法转义,用于触发词法诊断。代码把位置和消息一起收集,扫描到 EOF 后逐条展示,并另外给出 ErrorCount。这里只给出使用方式,没有执行示例或虚构运行输出。

package main

import (
    "fmt"
    "go/scanner"
    "go/token"
)

type diagnostic struct {
    Position token.Position
    Message  string
}

func scanSource(name string, src []byte) ([]diagnostic, int) {
    fset := token.NewFileSet()
    file := fset.AddFile(name, -1, len(src))
    var issues []diagnostic
    var s scanner.Scanner

    // Init 也可能报告首字符错误,收集器必须提前准备。
    handler := func(pos token.Position, msg string) {
        issues = append(issues, diagnostic{
            Position: pos, // 按值保存完整位置,不只保存消息。
            Message:  msg,
        })
    }
    s.Init(file, src, handler, 0)
    for {
        _, tok, _ := s.Scan()
        if tok == token.EOF {
            break
        }
        // 错误依据来自回调和 ErrorCount,不只判断 ILLEGAL。
    }
    return issues, s.ErrorCount
}

func main() {
    // 用非法转义构造词法错误,不涉及 parser 的语法恢复。
    src := []byte("package sample\nvar name = \"\\q\"\n")
    issues, count := scanSource("sample.go", src)
    for _, issue := range issues {
        p := issue.Position
        fmt.Printf("%s: %s [offset=%d]\n", p.String(), issue.Message, p.Offset)
    }
    fmt.Printf("扫描错误数: %d\n", count)
}

Position 是按值复制的记录。上层展示可以使用 Position.String() 得到适合阅读的文件行列形式,也可以分别读取 Filename、Line、Column。Offset 则保留给需要关联原始源字节的调用方。

handler 在 Init 之前就准备好,因为初始化可能在检查首字符时调用错误处理器。若把诊断切片的重置放到 Init 之后,就可能把首字符错误覆盖掉;若只观察后续 Scan 的返回值,也会漏掉这种初始化阶段的诊断。

示例的 src 是一份固定字节内容。实际工具还应把它关联到明确的源文件版本,避免诊断记录产生后用户编辑了文件,却仍按旧偏移跳转。这个版本关联由应用管理,不是扫描器自动替你保存的文件快照。

错误位置与 Scan 返回位置分别处理

Scan 返回位置、token 和字面量;错误回调另行提供错误位置与消息。这两条信息通道不应互相替代。如果回调闭包去读取外层“上一次 Scan 的 pos”,拿到的可能是上一个 token,而不是当前错误的位置。

我会让错误记录只采用回调直接传入的位置,token 列表则采用每次 Scan 返回的位置。即使二者偶尔对应同一个源片段,也不把这种巧合写成通用规则。

扫描器还可能在遇到错误时返回可识别的 token。因此没有 token.ILLEGAL,并不能证明输入完全没有扫描错误。对于本文的收集器,issues 与 ErrorCount 才是诊断依据;如果应用自行过滤部分消息,也要明确过滤后的条数与原始错误数可能不同。

信息来源适合保存避免的替代写法
ErrorHandler 的 pos对应诊断的位置借用外层上一次 token 的位置
Scan 返回的 token.Postoken 在文件集合中的紧凑位置把整数直接当行号或字节偏移
ErrorCount扫描器发现的错误数量只数 ILLEGAL token
msg错误描述文本只存描述而丢掉结构化位置

行列仍然对不上时,检查字节与位置映射

解释位置字段与原始源文件的关联
原创静态结构图:Position 保存文件名、字节偏移、行号和字节列。源字节用于解释偏移,展示列需要另行映射;图不是实际扫描结果。

token.Position 的 Offset 从零开始,Line 从一开始,Column 也是从一开始,但按字节计数。含中文等 UTF-8 多字节内容时,字节列不等于屏幕上看到的字符列;制表符的显示宽度也会受到编辑器设置影响。

因此我会原样保留标准库给出的 Column,展示层若要画光标,再基于同一份源内容做坐标转换。不要直接减去某个固定数字,也不要把 token 的全局 Pos 值当成本文件 Offset。

还需注意位置调整指令。源码中的 //line 注释可能影响展示文件名和行列含义。拿到 Scan 返回的 token.Pos 时,可用 FileSet.PositionFor 的 adjusted 参数选择是否考虑调整;错误回调提供的是展开后的 Position,原始物理位置需求应明确记录,并基于注册的 token.File 与有效 Offset 另行映射。

普通源码诊断通常直接保留回调位置即可。处理生成代码时,建议把“给用户看的逻辑位置”和“源字节中的物理位置”分开存储,避免一个字段在不同阶段被覆盖成另一种含义。

把诊断交给上层前保留哪些信息

如果暂时不需要自定义结构体,标准库的 ErrorList 也能保存位置和消息。下面是替代收集方式的局部片段,沿用已有的 file、src 及导入;它不是需要接在前面循环后再重复扫描的步骤。

// 这个片段使用已有的 file、src;错误列表同时保存位置和消息。
var issues scanner.ErrorList
var s scanner.Scanner
s.Init(file, src, issues.Add, 0)
for {
    _, tok, _ := s.Scan()
    if tok == token.EOF {
        break
    }
}
// 逐条输出,而不是把整份列表压成一个摘要字符串。
for _, issue := range issues {
    fmt.Println(issue.Error())
}

我会保留每条错误再交给上层,而不是过早调用聚合的 Error 字符串当作全部数据。需要排序时可以使用列表提供的方法;需要去重时则先定义业务规则,不要在还没定位“行列为什么消失”之前就删掉记录。

  • 确认导入 go/scanner,并通过 Init 注册回调。
  • 文件名与源字节长度在 AddFile 时确定。
  • 错误位置直接采用回调参数,消息和位置一起保存。
  • 扫描完成检查 ErrorCount,不能只看 ILLEGAL。
  • 保留字节列与 Offset,展示层另做字符或屏幕坐标转换。

Init 的错误回调传 nil,会自动把详细错误写出来吗?

不要依赖它生成你需要的报告。go/scanner 中应安装自己的处理器来收集详细诊断,并通过 ErrorCount 了解错误数量;这也避免把 text/scanner 的默认打印行为误套过来。

文件名为空,能在打印时补一个吗?

可以在应用展示层提供标签,但更直接的做法是在注册 token.File 时就传入正确名称,并让记录保留来源身份。多文件场景尤其不宜事后根据循环变量猜测文件名。

为什么有错误却仍然能继续拿到 token?

扫描接口会尽可能继续给出可识别 token。继续扫描不等于错误不存在,应保留诊断记录。本文不据此承诺 parser 能恢复全部语法问题。

参考:go/scanner 错误回调与错误列表、go/token 位置字段和映射、text/scanner 接口区别。示例与排查场景为本文原创。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>