Skip to content

go/ast: add ParseDirective #68021

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
jimmyfrasche opened this issue Jun 15, 2024 · 46 comments
Open

go/ast: add ParseDirective #68021

jimmyfrasche opened this issue Jun 15, 2024 · 46 comments

Comments

@jimmyfrasche
Copy link
Member

Proposal Details

*ast.CommentGroup has a helpful Text method to extract the text of comments that are not directives, but there is no simple means to get the directives of the comment other than by parsing the raw comments.

Now that directives are standardized and allow third party directives, while the format is simple, it would be nice to make them easier to access.

The most common case would be for a third party tool to want to see only the directives in its namespace so this use case should be made simplest.

I imagine something like:

// Directives iterates over all
//
//    //namespace:directive arguments
//
// directives in the comment group,
// yielding namespace:directive then arguments (which may be empty).
//
// If a namespace argument is provided, only directives that match
// that namespace are iterated over.
//
// If the namespace argument is go, the directives iterated over
// include directives like "//line" which predate the standardized format.
//
// If namespace is the empty string, all directives are iterated over
func (g *CommentGroup) Directives(namespace string) iter.Seq2[string, string]
@jimmyfrasche jimmyfrasche added this to the Proposal milestone Jun 15, 2024
@jimmyfrasche
Copy link
Member Author

cc @griesemer per owners

@jimmyfrasche
Copy link
Member Author

Perhaps it should be Directives(namespaces ...string). That would make it cleaner in the rare case you want all directives and, more importantly, simpler in the case where you want your namespace but also need to, for example, account for directives from another tool that you're replacing or just need to interoperate with.

@ianlancetaylor ianlancetaylor moved this to Incoming in Proposals Jun 16, 2024
@griesemer
Copy link
Contributor

cc @findleyr @adonovan for visibility.

@findleyr
Copy link
Member

CC @lfolger, since we were just discussing the lack of an API like this one.

At a high level I do think we should expose this in an API. The proposal looks reasonable to me. I suspect that the variadic form is overkill, preferring the original proposal, but I could be convinced otherwise.

@adonovan
Copy link
Member

adonovan commented Jun 17, 2024

I'm not convinced the namespaces argument is necessary: the iterator must always visit every node, so there's no efficiency gain by having the iterator perform the filtering, and the non-monotonic behavior for len(namespaces)=0 seems undesirable. Make the iterator yield them all, and let the caller filter.

Perhaps we should define a new type, type Directive { Namespace, Name, Arguments string }, and just return a plain Seq[Directive]. That alleviates the caller from thinking about parsing the first element, or from getting confused as to how the three values are split into the two parts of a Seq2.

@jimmyfrasche
Copy link
Member Author

👍 on a new type. Though maybe it should just be type Directive { Namespace, Name string } and iter.Seq2[Directive, string] for the arguments? That would make it simpler to use the directive as a map key.

The variadic proposal may be overkill. I'm not entirely confident. The main argument is performance, as I imagine the iterator would parse the comments for each invocation. If you do need to check multiple namespaces you'd either need to parse the comments n times or iterate over all the directives and implement your own filtering logic and I based it off #67795 so I figured the non-monotonic behavior would be acceptable. OTOH a multi-namespace filter may need to do some allocations or preprocessing and those would likely be the same for the entire program so having it as a separate filter would

I do think the most common case is going to be a tool looking for its directives so having some simple namespace filtering built in is going to simplify the majority of callers who would otherwise all have to implement their own filtering. For example, the code I was writing that led me to file this only cares about two directives in its custom namespace so it would be a lot simpler to just write g.Directives("mystuff") than

for dir := range g.Directives() {
  if dir.Namespace != "mystuff" {
    continue
  }
 // ...
}

Built in filtering can also handle the special cases of extern, export, and line, which have no namespace but you'd want to show up in a query for the "go" namespace. Of course, if there's a type it could export a method to handle this.

@adonovan
Copy link
Member

That would make it simpler to use the directive as a map key.

But it's not a map, it's a sequence of pairs whose keys may be duplicates.

@jimmyfrasche
Copy link
Member Author

I mean that if I wanted to collect results as map[Directive][]T where type T struct{ Node; args string} and Arguments is part of Directive I'd need to shuffle things around but if they're separated it's more straightforward.

@adonovan
Copy link
Member

I mean that if I wanted to collect results as map[Directive][]T where type T struct{ Node; args string} and Arguments is part of Directive I'd need to shuffle things around but if they're separated it's more straightforward.

That's true, but most clients will discard most directives (at least ones of the wrong namespace), so a map[string][]T will do, and three-field Directive struct gives us room for future improvements (e.g. a method to parse arguments in some emerging future standard way).

@jimmyfrasche
Copy link
Member Author

For export, extern, and line, I think the Namespace field of Directive should be set to "go". Fudging that removes an edge case. There could be a String method that renders them without the go:

@jimmyfrasche
Copy link
Member Author

The updated proposal so far is:

// Directives yields each [directive comment] in g as a [Directive].
//
// [directive comment]: https://go.dev/doc/comment#syntax
func (g *CommentGroup) Directives() iter.Seq[Directive] {
	// ...
}

type Directive struct {
	Namespace, Name, Arguments string
}

For line/export/extern directives, there are two choices:

  1. record Namespace as ""
  2. record Namespace as "go"

1 is syntactically correct. There is no namespace for such directives as they are written.

2 is semantically correct. Those directives belong to Go and, as such, implicitly belong to the "go" namespace.

These directives need to be special cased in parsing so manually setting the namespace to "go" is trivial.

For rendering to a string, these need to be special cased either way (to avoid rendering ":line" or "go:line" instead of "line") and as such Directive should be a fmt.Stringer.

So for std these need to be special cased and documented either way.

For user code, 2 removes a special case as Namespace != "" and everything that belongs to Go is labeled "go".

However, it's unlikely that anyone would care about anything other than a small fixed set of namespaces, often a singleton, and it's likely that this set contains neither "go" nor "", so few users would even be in a situation where they could run into this.

These are both right. 2 seems more right to me. But ultimately it doesn't seem likely to matter much in practice and should be documented whatever the decision.

@jimmyfrasche
Copy link
Member Author

jimmyfrasche commented Jun 19, 2024

It looks like Arguments is pretty free form. Are the following correct or did I misunderstand something:

Directive → Arguments (notes):

"//0:0 a \n"" a " (two spaces on either side)

"//a:aA\n""A"

"//line X\n""X" (first space is part of name)

If possible, it would be nice to further standardize that leading and trailing white space is ignored (and have gofmt normalize it to a single space). I can open another issue, if that's a possibility.

edit, the respective hypothetical normalizations would be:

"//0:0 a \n""//0:0 a\n"

"//a:aA\n""//a:a A\n"

edit 2 corrected //a:A example to //a:aA

@jimmyfrasche
Copy link
Member Author

I filed #68061 to simplify the namespace question I posted earlier today by litigating it out of existence

@lfolger
Copy link
Contributor

lfolger commented Jun 19, 2024

I'm not sure I understand

"//a:A\n" → "A"

I would expect it to lead to

Directive{Namespace: "a", Name: "A", Arguments: ""}

Why Is "A" and argument and not the name?
Is this to make the normalization easier?

@jimmyfrasche
Copy link
Member Author

That was a bug in the comment. Updated it to:

"//a:aA\n""//a:a A\n"

The A isn't lowercase so it's not part of the directive name so it's part of the arguments. Allowing and normalizing leading whitespace would make that clearer.

@jimmyfrasche
Copy link
Member Author

I went ahead and filed #68092 for the argument whitespace, if it's possible to change that.

@jimmyfrasche
Copy link
Member Author

Summary of how these proposals fit together.

This proposal is for:

func (g *CommentGroup) Directives() iter.Seq[Directive] {
	// ...
}

type Directive struct {
	Namespace, Name, Arguments string
}

If #68061 is accepted, then it's invariant that Namespace != "" and all the Go directives get Namespace == "go"

If #68092 is accepted, then it's invariant that Arguments == strings.TrimSpace(Arguments)

With both accepted, any Directive can be turned back into a string with fmt.Sprintf("%s:%s %s", d.Namespace, d.Name, d.Arguments); otherwise, Directive will require a String method to handle the various special cases (though it could certainly still have a String method).

@jimmyfrasche
Copy link
Member Author

Should it be Argument singular since there's only one of them?

@rsc rsc moved this from Incoming to Active in Proposals Jul 25, 2024
@rsc
Copy link
Contributor

rsc commented Jul 25, 2024

This proposal has been added to the active column of the proposals project
and will now be reviewed at the weekly proposal review meetings.
— rsc for the proposal review group

@rsc
Copy link
Contributor

rsc commented Aug 7, 2024

I don't think we need the filter as an argument. We also need to add file:line position information in some form (token.Pos or whatever is natural), which basically requires a struct at that point.

https://go.dev/doc/comment#Syntax says "//toolname:directive".

So something like:

type Directive struct {
    Pos token.Pos
    Tool string
    Name string
    Args string
}

(type Directive should not have field Directive, hence Name)

@jimmyfrasche
Copy link
Member Author

@aclements my primary motivation was to make it simpler for third parties to use directives. Even if it can't be used at all internally for whatever reason it's still sufficiently useful. (If it can be used internally that's a nice bonus, of course)

@aclements
Copy link
Member

@jimmyfrasche I basically agree. I mostly want to see some concrete evidence that this API actually simplifies code that needs to read directives. I don't expect there to be many examples from std, which is totally fine, it's just a handy "unbiased" source of test cases. The one example @adonovan posted above seems like kind of a wash.

@jimmyfrasche
Copy link
Member Author

One of the problems with comparing it to existing code is all existing code is going to be doing the parsing and filtering in one step: it parses only one particular directive instead of parsing all directives then selecting the one of interest. That's why the initial proposal had a built in filtering mechanism.

With the filtering mechanism back in place, the code posted above would be somewhat simpler

func extractMagicComments(f *ast.File) []string {
	...
	for _, cg := range f.Comments {
		for dir := range cg.Directives("go:embed", "go:build") {
			result := fmt.Sprintf("%s:%s %s", dir.Tool, dir.Name, dir.Args)
			results = append(results, result)

(and simpler still if Directive had a String() string method)

Although both rewrites are incorrect as the original also looked for +build and included // in the output.

That said maybe the solution is to go lower level and just have a:

func ParseDirective(text string) (Directive, bool)

@aclements
Copy link
Member

@gri points out that ParseDirective has the advantage that it can work on any text, while CommentGroup.Directives requires you to have a CommentGroup, which makes ParseDirective more composable.

CommentGroup already splits //-style comments into lines, so the fact that ParseDirective only returns a single directive doesn't seem to add any burden to the caller. And /*-style comments aren't allowed to contain directives.

Can we specify exactly what ParseDirective accepts?

@jimmyfrasche
Copy link
Member Author

I'd been working on the assumption that they only go in // but can't line directives be in /**/ comments? If so, it might be fine to just say "does not work with (all) line directives". Since it's purpose is to work with custom directives maybe it'd be fine to further limit it to only parsing the new x:y directives? That would simplify a lot of things for little loss.

After looking back, I'm not sure the new directive format is entirely specified. For the x:y family what's specified is only how to tell if a line is a directive but nothing past that point. In x:yz A the format stops at y and strictly speaking doesn't say whether z is part of the name or A is an argument: it just says, yup, what we have here is a directive.

@jimmyfrasche
Copy link
Member Author

Ignoring everything else, if the solution is ParseDirective there should be an example using it with CommentGroup

@aclements
Copy link
Member

The current documentation on the directive format is here: https://go.dev/doc/comment#syntax. It's true that's not totally specified, but it's pretty close. As part of this proposal, we should tighten up that document and write a precise spec for ParseDirective. I think we all agree that ParseDirective is the best API because it's loosely coupled with the AST representation (I agree that there should be an example or at least a pointer from CommentGroup).

@ianlancetaylor ianlancetaylor changed the title proposal: go/ast: add CommentGroup.Directives iterator proposal: go/ast: add ParseDirective Oct 2, 2024
@aclements
Copy link
Member

aclements commented Oct 2, 2024

Given the loose coupling of ParseDirective, and that it's about parsing and not about the AST, I think it should go either in go/parser or go/token. Even though it's called ParseDirective, I'm inclined to put it in go/token since go/parser is currently all about producing ASTs, and this function isn't. go/token is also the lowest it can go in the dependency DAG, whereas go/parser would be the highest reasonable place to put it (even higher than go/ast).

@aclements
Copy link
Member

aclements commented Oct 4, 2024

Have all remaining concerns about this proposal been addressed?

The proposal is:

package token

// A Directive is a comment of this form:
//
//    //tool:name args
//
// For example, this directive:
//
//     //go:generate stringer -type Op -trimprefix Op
//
// would have Tool "go", Name "generate", and Args "stringer -type Op -trimprefix Op".
// See https://go.dev/doc/comment#Syntax for specification.
type Directive struct {
    Pos  Pos // position of start of line comment
    Tool string // may be "" if Name is "line", "extern", "export"
    Name string
    Args string // may contain whitespace
}

func ParseDirective(text string) (Directive, bool)

And to refine https://go.dev/doc/comment#syntax into a precise syntax specification.

@jimmyfrasche
Copy link
Member Author

Directive can no longer know Pos.

Has anyone pulled non-Go directives out of the corpus/proxy to see how it's being used in the wild to help inform the syntax refinement?

@willfaught
Copy link
Contributor

go/ast seems like a more consistent place for it, IMO. CommentMap is also in ast.

@jimmyfrasche
Copy link
Member Author

I get not putting it in ast or parser but token also feels off.

Another option would be just giving it its own package:

package directive // "go/directive"
type Value struct { /* ... */ }
func Parse(string) (Value, bool) { /* ... */ }

@smoyer64
Copy link

smoyer64 commented Oct 5, 2024

I wish I'd seen this sooner - I was just working on this last week and I hope it's not to late to add my two cents.

When parsing CommentGroups for directives, I also need to know whether that comment group is stand-alone or whether it's a Go doc. If it's a Go doc, I want to know what it's documenting (for my case, it's always some implementation of ast.Decl.) Since the parser returns all comments found in a package/file, I end up parsing some CommentGroups twice and created code to deduplicate them.

For my use case, I actually prune the Go docs from the list of all CommentGroups before I start processing the directives. In case it's not obvious, these directives that are in Go docs are directives about the related ast.Decl.

Thanks so much for this proposal - it's going to shrink my code either way!

@aclements
Copy link
Member

Directive can no longer know Pos.

Oops, good catch.

go/ast seems like a more consistent place for it, IMO. CommentMap is also in ast.

I don't feel strongly about this. I'll note that CommentMap has to live in go/ast because it's all about ast.CommentGroups.

... Since the parser returns all comments found in a package/file, I end up parsing some CommentGroups twice and created code to deduplicate them.

I'm afraid I didn't quite follow this. But, generally speaking, the way you parse directives does depend whether you're looking for directives associated with Decls or just directives anywhere in the file. I'm not sure what would lead to having to parse some CommentGroups twice.

One thing https://go.dev/doc/comment#syntax is not very clear on is the required context around a directive comment. E.g., go generate only accepts //go:generate comments that appear at the very beginning of a line (no leading whitespace). That pretty annoying to determine from an ast.Comment. In fact, the ast package itself does not require this, nor do other users of ast, like the cgo tool for //export.

The doc also doesn't capture how the "argument" to the directive is formatted. E.g., //go:generate can be followed by a space or a tab, but //line, //extern, and //export must be followed by a space.

@adonovan
Copy link
Member

Directive can no longer know Pos.

True, but if calling ParseDirective(comment.Text), the directive's Pos is simply comment.Pos() + len("//").

One thing https://go.dev/doc/comment#syntax is not very clear on is the required context around a directive comment. E.g., go generate only accepts //go:generate comments that appear at the very beginning of a line (no leading whitespace). That pretty annoying to determine from an ast.Comment.

It is important and quite intentional that directives may only appear at the start of a comment, as this prevents indented code blocks and arbitrarily wrapped paragraphs from being misinterpreted as a directive.

In fact, the ast package itself does not require this, nor do other users of ast, like the cgo tool for //export.

The ast.isDirective function is called only after it has been established that there is no space at the start of the comment.

@jimmyfrasche
Copy link
Member Author

@alandonovan go generate is enforcing that there are no spaces before the // in addition to the requirement that there are no spaces after it.

I think what go generate is doing is basically fine. It's using an approximate heuristic to avoid parsing the file (at the expense of allowing false positives from a directive in a multiline string). I don't think that needs to be considered here. It's still following the directive format, it's just adding an additional constraint (and failure mode).

@aclements
Copy link
Member

aclements commented Oct 23, 2024

Have all remaining concerns about this proposal been addressed?

The proposal is:

package ast

// A Directive is a comment of this form:
//
//    //tool:name args
//
// For example, this directive:
//
//     //go:generate stringer -type Op -trimprefix Op
//
// would have Tool "go", Name "generate", and Args "stringer -type Op -trimprefix Op".
// See https://go.dev/doc/comment#Syntax for specification.
type Directive struct {
    Tool string // may be "" if Name is "line", "extern", "export"
    Name string
    Args string // may contain whitespace
}

func ParseDirective(text string) (Directive, bool)

And to refine https://go.dev/doc/comment#syntax into a precise syntax specification.

@aclements aclements moved this from Active to Likely Accept in Proposals Oct 30, 2024
@aclements
Copy link
Member

Based on the discussion above, this proposal seems like a likely accept.

The proposal is:

package ast

// A Directive is a comment of this form:
//
//    //tool:name args
//
// For example, this directive:
//
//     //go:generate stringer -type Op -trimprefix Op
//
// would have Tool "go", Name "generate", and Args "stringer -type Op -trimprefix Op".
// See https://go.dev/doc/comment#Syntax for specification.
type Directive struct {
    Tool string // may be "" if Name is "line", "extern", "export"
    Name string
    Args string // may contain whitespace
}

func ParseDirective(text string) (Directive, bool)

And to refine https://go.dev/doc/comment#syntax into a precise syntax specification.

@aclements aclements moved this from Likely Accept to Accepted in Proposals Nov 6, 2024
@aclements
Copy link
Member

No change in consensus, so accepted. 🎉
This issue now tracks the work of implementing the proposal.

The proposal is:

package ast

// A Directive is a comment of this form:
//
//    //tool:name args
//
// For example, this directive:
//
//     //go:generate stringer -type Op -trimprefix Op
//
// would have Tool "go", Name "generate", and Args "stringer -type Op -trimprefix Op".
// See https://go.dev/doc/comment#Syntax for specification.
type Directive struct {
    Tool string // may be "" if Name is "line", "extern", "export"
    Name string
    Args string // may contain whitespace
}

func ParseDirective(text string) (Directive, bool)

And to refine https://go.dev/doc/comment#syntax into a precise syntax specification.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
Status: Accepted
Development

No branches or pull requests

10 participants