Compare two golang interface definitions, checking the version in README matches that in the code.
ifcmp <README.md> <interface.go> <interface>
Strictly speaking, this interface comparison tool isn't really needed because a potential library user can always check the exact details of methods etc in the package listing hosted at pkg.go.dev. But a project I contributed to recently had for convenience included the interface methods in the README, with lossy comments marking sections and documentation comments removed. It would be useful to automatically check for consistency with the code. Automation could take several forms, in increasing order of effort required:
- remove section and rely on pkg.go.dev automatic documentation
- regexp to identify methods, which are then mapped, treating the remainder of the line as the map value (with some whitespace sanitisation)
- compare the Abstract Syntax Trees generated by parsing the README.md code section and the actual code
I wanted to learn more about the type system in go, to prepare for an upcoming project that will be receiving dynamically-generated JSON objects. They weren't availalble yet, but abstract syntax trees (AST) from the go parser were. So out with the sledgehammer to crack a nut. There's a nice introduction to traversing AST here.
Use Go's parser to create an AST from the actual code, and another from the interface code in the README. Extract the list of functions, their parameters and returns, and compare them (ignore the details of whitespace, comments and method order, but preserve parameter and result orders).
The README.md
is searched for code blocks containing type <interface> interface
, which are the only contents from the README which are presented to the parser.
The library I was checking contained methods with parameters and results of the following types:
*ast.Ident:
*ast.InterfaceType:
*ast.ArrayType:
*ast.StarExpr:
*ast.SelectorExpr:
The last three are recursive.
A number of other types are specified in ast
, and it is either unlikely or impossible that they show up in a valid FuncType
so are not currently supported.
There's a nice introduction to traversing AST here. A list of AST tips indicates node replacement is possible.
Applying the basic approach to our example in ./testdata
we see many lines of output when we print the whole tree.
Using the ast.Print()
method, you can see the GoCloak interface as an ast.GenDecl
(line 39), named on line 47, and identified as an *ast.InterfaceType
on line 55.
<snip>
39 . . 1: *ast.GenDecl {
40 . . . TokPos: ./testdata/gocloak.go:11:1
41 . . . Tok: type
42 . . . Lparen: -
43 . . . Specs: []ast.Spec (len = 1) {
44 . . . . 0: *ast.TypeSpec {
45 . . . . . Name: *ast.Ident {
46 . . . . . . NamePos: ./testdata/gocloak.go:11:6
47 . . . . . . Name: "GoCloak"
48 . . . . . . Obj: *ast.Object {
49 . . . . . . . Kind: type
50 . . . . . . . Name: "GoCloak"
51 . . . . . . . Decl: *(obj @ 44)
52 . . . . . . }
53 . . . . . }
54 . . . . . Assign: -
55 . . . . . Type: *ast.InterfaceType {
56 . . . . . . Interface: ./testdata/gocloak.go:11:14
57 . . . . . . Methods: *ast.FieldList {
58 . . . . . . . Opening: ./testdata/gocloak.go:11:24
59 . . . . . . . List: []*ast.Field (len = 189) {
60 . . . . . . . . 0: *ast.Field {
61 . . . . . . . . . Names: []*ast.Ident (len = 1) {
62 . . . . . . . . . . 0: *ast.Ident {
63 . . . . . . . . . . . NamePos: ./testdata/gocloak.go:13:2
64 . . . . . . . . . . . Name: "RestyClient"
65 . . . . . . . . . . . Obj: *ast.Object {
66 . . . . . . . . . . . . Kind: func
67 . . . . . . . . . . . . Name: "RestyClient"
68 . . . . . . . . . . . . Decl: *(obj @ 60)
69 . . . . . . . . . . . }
70 . . . . . . . . . . }
71 . . . . . . . . . }
<snip>
Looking in go/ast/ast.go
, we find:
type File struct {
Doc *CommentGroup // associated documentation; or nil
Package token.Pos // position of "package" keyword
Name *Ident // package name
Decls []Decl // top-level declarations; or nil
Scope *Scope // package scope (this file only)
Imports []*ImportSpec // imports in this file
Unresolved []*Ident // unresolved identifiers in this file
Comments []*CommentGroup // list of all comments in the source file
}
The Name field is the package name, not the interface name, so we cannot use that.
in go/ast/scope.go
we see
type Scope struct {
20 Outer *Scope
21 Objects map[string]*Object
22 }
We're looking here for a scope of Type, with the interface name.
The Objects map uses the object name as the key (which will be the interface name when the object is the interface)
type Object struct {
Kind ObjKind
Name string // declared name
Decl interface{} // corresponding Field, XxxSpec, FuncDecl, LabeledStmt, AssignStmt, Scope; or nil
Data interface{} // object-specific data; or nil
Type interface{} // placeholder for type information; may be nil
}
// ObjKind describes what an object represents.
type ObjKind int
// The list of possible Object kinds.
const (
Bad ObjKind = iota // for error handling
Pkg // package
Con // constant
Typ // type
Var // variable
Fun // function or method
Lbl // label
)
The Decl field for an interface is of type *ast.TypeSpec
// All expression nodes implement the Expr interface.
type Expr interface {
Node
exprNode()
}
The methods are all of FuncType
// A FuncType node represents a function type.
FuncType struct {
Func token.Pos // position of "func" keyword (token.NoPos if there is no "func")
Params *FieldList // (incoming) parameters; non-nil
Results *FieldList // (outgoing) results; or nil
}
Parameters and results are stored in FieldList
// A FieldList represents a list of Fields, enclosed by parentheses or braces.
type FieldList struct {
Opening token.Pos // position of opening parenthesis/brace, if any
List []*Field // field list; or nil
Closing token.Pos // position of closing parenthesis/brace, if any
}
Which have arrays of Field
//A Field represents a Field declaration list in a struct type
<snip>
type Field struct {
Doc *CommentGroup // associated documentation; or nil
Names []*Ident // field/method/parameter names; or nil
Type Expr // field/method/parameter type
Tag *BasicLit // field tag; or nil
Comment *CommentGroup // line comments; or nil
}
Note that Expr
can themselves contain Expr
, so the function to generate the string
representation is recursive.
Actual: GetResource(ctx context.Context, token, realm, clientID, resourceID string) (*ResourceRepresentation, error)
Readme: GetResource(ctx context.Context, token, realm, clientID, resourceID string) (*Resource, error)
Actual: UpdateResource(ctx context.Context, token, realm, clientID string, resource ResourceRepresentation) error
Readme: UpdateResource(ctx context.Context, token, realm, clientID string, resource Resource) error
Actual: DecodeAccessTokenCustomClaims(ctx context.Context, accessToken, realm, expectedAudience string, claims jwt.Claims) (*jwt.Token, error)
Readme: DecodeAccessTokenCustomClaims(ctx context.Context, accessToken, realm string, claims jwt.Claims) (*jwt.Token, error)
Actual: CreateRealmRole(ctx context.Context, token, realm string, role Role) (string, error)
Readme: CreateRealmRole(ctx context.Context, token, realm string, role Role) error
Actual: GetResources(ctx context.Context, token, realm, clientID string, params GetResourceParams) ([]*ResourceRepresentation, error)
Readme: GetResources(ctx context.Context, token, realm, clientID string) ([]*Resource, error)
Actual: CreateResource(ctx context.Context, token, realm, clientID string, resource ResourceRepresentation) (*ResourceRepresentation, error)
Readme: CreateResource(ctx context.Context, token, realm, clientID string, resource Resource) (*Resource, error)
Actual: CreateGroup(ctx context.Context, accessToken, realm string, group Group) (string, error)
Readme: CreateGroup(ctx context.Context, accessToken, realm string, group Group) error
Actual: CreateClient(ctx context.Context, accessToken, realm string, clientID Client) (string, error)
Readme: CreateClient(ctx context.Context, accessToken, realm string, clientID Client) error
Actual: CreateClientProtocolMapper(ctx context.Context, token, realm, clientID string, mapper ProtocolMapperRepresentation) (string, error)
Readme: CreateClientProtocolMapper(ctx context.Context, token, realm, clientID string, mapper ProtocolMapperRepresentation) error
Actual: CreateComponent(ctx context.Context, accessToken, realm string, component Component) (string, error)
Readme: CreateComponent(ctx context.Context, accessToken string, realm, component Component) error
Actual: LoginClientSignedJWT(ctx context.Context, clientID, realm string, key interface{}, signedMethod jwt.SigningMethod, expiresAt *jwt.Time) (*JWT, error)
Readme: LoginClientSignedJWT(ctx context.Context, clientID, realm string, key interface{}, signedMethod jwt.SigningMethod, expiresAt int64) (*JWT, error)
Actual: CreateClientRole(ctx context.Context, accessToken, realm, clientID string, role Role) (string, error)
Readme: CreateClientRole(ctx context.Context, accessToken, realm, clientID string, role Role) error
Actual: DecodeAccessToken(ctx context.Context, accessToken, realm, expectedAudience string) (*jwt.Token, *jwt.MapClaims, error)
Readme: DecodeAccessToken(ctx context.Context, accessToken, realm string) (*jwt.Token, *jwt.MapClaims, error)
Actual: DeletePolicy(ctx context.Context, token, realm, clientID, policyID string) error
Readme: DeletePolicy(ctx context.Context, token, realm, clientID string, policyID string) error
Actual: CreateClientScope(ctx context.Context, accessToken, realm string, scope ClientScope) (string, error)
Readme: CreateClientScope(ctx context.Context, accessToken, realm string, scope ClientScope) error