Skip to content

Commit 71c7ff3

Browse files
xzbdmwadonovan
authored andcommitted
gopls: report SemanticHighlight for format string directives
This CL uses "format" as modifier type for format string directives. Fixes golang/go#71295 Change-Id: I0a6538152902db11a3f0612cc4e2964f47a2b7f3 GitHub-Last-Rev: bc9a21f GitHub-Pull-Request: #557 Reviewed-on: https://go-review.googlesource.com/c/tools/+/643138 Reviewed-by: Hongxiang Jiang <hxjiang@golang.org> LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com> Reviewed-by: Alan Donovan <adonovan@google.com>
1 parent 7a015ab commit 71c7ff3

File tree

8 files changed

+129
-26
lines changed

8 files changed

+129
-26
lines changed

gopls/doc/release/v0.18.0.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,9 @@ The Definition query now supports additional locations:
118118
When invoked on a return statement, hover reports the types of
119119
the function's result variables.
120120

121-
## Improvements to "DocumentHighlight"
121+
## UX improvements to format strings
122+
123+
### "DocumentHighlight"
122124

123125
When your cursor is inside a printf-like function, gopls now highlights the relationship between
124126
formatting verbs and arguments as visual cues to differentiate how operands are used in the format string.
@@ -129,3 +131,14 @@ fmt.Printf("Hello %s, you scored %d", name, score)
129131

130132
If the cursor is either on `%s` or `name`, gopls will highlight `%s` as a write operation,
131133
and `name` as a read operation.
134+
135+
### "SemanticHighlight"
136+
137+
Similar to the improvements to DocumentHighlight, gopls also reports formatting verbs
138+
as "format" modifier for token type "string" to better distinguish them with other parts of the format string.
139+
140+
```go
141+
fmt.Printf("Hello %s, you scored %d", name, score)
142+
```
143+
144+
`%s` and `%d` will have token type "string" and modifier "format".

gopls/doc/semantictokens.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,14 +54,15 @@ and change over time. (Nonetheless, a minimal implementation would not return `k
5454
`number`, `comment`, or `string`.)
5555

5656
The maximal position isn't particularly well-specified either. To chose one example, a
57-
format string might have formatting codes (`%[4]-3.6f`), escape sequences (`\U00010604`), and regular
57+
format string might have formatting codes (`%-[4].6f`), escape sequences (`\U00010604`), and regular
5858
characters. Should these all be distinguished? One could even imagine distinguishing
5959
different runes by their Unicode language assignment, or some other Unicode property, such as
60-
being [confusable](http://www.unicode.org/Public/security/10.0.0/confusables.txt).
60+
being [confusable](http://www.unicode.org/Public/security/10.0.0/confusables.txt). While gopls does not fully adhere to such distinctions,
61+
it does recognizes formatting directives within strings, decorating them with "format" modifiers,
62+
providing more precise semantic highlighting in format strings.
6163

62-
Gopls does not come close to either of these principles. Semantic tokens are returned for
63-
identifiers, keywords, operators, comments, and literals. (Semantic tokens do not
64-
cover the file. They are not returned for
64+
Semantic tokens are returned for identifiers, keywords, operators, comments, and literals.
65+
(Semantic tokens do not cover the file. They are not returned for
6566
white space or punctuation, and there is no semantic token for labels.)
6667
The following describes more precisely what gopls
6768
does, with a few notes on possible alternative choices.

gopls/internal/analysis/modernize/omitzero.go

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,7 @@ func checkOmitEmptyField(pass *analysis.Pass, info *types.Info, curField *ast.Fi
3535
// No omitempty in json tag
3636
return
3737
}
38-
omitEmptyPos, err := astutil.PosInStringLiteral(curField.Tag, match[2])
39-
if err != nil {
40-
return
41-
}
42-
omitEmptyEnd, err := astutil.PosInStringLiteral(curField.Tag, match[3])
38+
omitEmptyPos, omitEmptyEnd, err := astutil.RangeInStringLiteral(curField.Tag, match[2], match[3])
4339
if err != nil {
4440
return
4541
}

gopls/internal/golang/highlight.go

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"golang.org/x/tools/gopls/internal/cache"
1818
"golang.org/x/tools/gopls/internal/file"
1919
"golang.org/x/tools/gopls/internal/protocol"
20+
goplsastutil "golang.org/x/tools/gopls/internal/util/astutil"
2021
internalastutil "golang.org/x/tools/internal/astutil"
2122
"golang.org/x/tools/internal/event"
2223
"golang.org/x/tools/internal/fmtstr"
@@ -210,11 +211,7 @@ func highlightPrintf(call *ast.CallExpr, idx int, cursorPos token.Pos, lit *ast.
210211

211212
// highlightPair highlights the operation and its potential argument pair if the cursor is within either range.
212213
highlightPair := func(rang fmtstr.Range, argIndex int) {
213-
rangeStart, err := internalastutil.PosInStringLiteral(lit, rang.Start)
214-
if err != nil {
215-
return
216-
}
217-
rangeEnd, err := internalastutil.PosInStringLiteral(lit, rang.End)
214+
rangeStart, rangeEnd, err := internalastutil.RangeInStringLiteral(lit, rang.Start, rang.End)
218215
if err != nil {
219216
return
220217
}
@@ -226,9 +223,9 @@ func highlightPrintf(call *ast.CallExpr, idx int, cursorPos token.Pos, lit *ast.
226223
}
227224

228225
// cursorPos can't equal to end position, otherwise the two
229-
// neighborhood such as (%[2]*d) are both highlighted if cursor in "*" (ending of [2]*).
226+
// neighborhood such as (%[2]*d) are both highlighted if cursor in "d" (ending of [2]*).
230227
if rangeStart <= cursorPos && cursorPos < rangeEnd ||
231-
arg != nil && arg.Pos() <= cursorPos && cursorPos < arg.End() {
228+
arg != nil && goplsastutil.NodeContains(arg, cursorPos) {
232229
highlightRange(result, rangeStart, rangeEnd, protocol.Write)
233230
if arg != nil {
234231
succeededArg = argIndex

gopls/internal/golang/semtok.go

Lines changed: 62 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"log"
1818
"path/filepath"
1919
"regexp"
20+
"strconv"
2021
"strings"
2122
"time"
2223

@@ -28,7 +29,9 @@ import (
2829
"golang.org/x/tools/gopls/internal/protocol/semtok"
2930
"golang.org/x/tools/gopls/internal/util/bug"
3031
"golang.org/x/tools/gopls/internal/util/safetoken"
32+
"golang.org/x/tools/internal/astutil"
3133
"golang.org/x/tools/internal/event"
34+
"golang.org/x/tools/internal/fmtstr"
3235
)
3336

3437
// semDebug enables comprehensive logging of decisions
@@ -323,16 +326,17 @@ func (tv *tokenVisitor) inspect(n ast.Node) (descend bool) {
323326
case *ast.AssignStmt:
324327
tv.token(n.TokPos, len(n.Tok.String()), semtok.TokOperator)
325328
case *ast.BasicLit:
326-
if strings.Contains(n.Value, "\n") {
327-
// has to be a string.
328-
tv.multiline(n.Pos(), n.End(), semtok.TokString)
329-
break
330-
}
331-
what := semtok.TokNumber
332329
if n.Kind == token.STRING {
333-
what = semtok.TokString
330+
if strings.Contains(n.Value, "\n") {
331+
// has to be a string.
332+
tv.multiline(n.Pos(), n.End(), semtok.TokString)
333+
} else if !tv.formatString(n) {
334+
// not a format string, color the whole as a TokString.
335+
tv.token(n.Pos(), len(n.Value), semtok.TokString)
336+
}
337+
} else {
338+
tv.token(n.Pos(), len(n.Value), semtok.TokNumber)
334339
}
335-
tv.token(n.Pos(), len(n.Value), what)
336340
case *ast.BinaryExpr:
337341
tv.token(n.OpPos, len(n.Op.String()), semtok.TokOperator)
338342
case *ast.BlockStmt:
@@ -461,6 +465,56 @@ func (tv *tokenVisitor) inspect(n ast.Node) (descend bool) {
461465
return true
462466
}
463467

468+
// formatString tries to report directives and string literals
469+
// inside a (possible) printf-like call, it returns false and does nothing
470+
// if the string is not a format string.
471+
func (tv *tokenVisitor) formatString(lit *ast.BasicLit) bool {
472+
if len(tv.stack) <= 1 {
473+
return false
474+
}
475+
call, ok := tv.stack[len(tv.stack)-2].(*ast.CallExpr)
476+
if !ok {
477+
return false
478+
}
479+
lastNonVariadic, idx := formatStringAndIndex(tv.info, call)
480+
if idx == -1 || lit != lastNonVariadic {
481+
return false
482+
}
483+
format, err := strconv.Unquote(lit.Value)
484+
if err != nil {
485+
return false
486+
}
487+
if !strings.Contains(format, "%") {
488+
return false
489+
}
490+
operations, err := fmtstr.Parse(format, idx)
491+
if err != nil {
492+
return false
493+
}
494+
495+
// It's a format string, compute interleaved sub range of directives and literals.
496+
// pos tracks literal substring position within the overall BasicLit.
497+
pos := lit.ValuePos
498+
for _, op := range operations {
499+
// Skip "%%".
500+
if op.Verb.Verb == '%' {
501+
continue
502+
}
503+
rangeStart, rangeEnd, err := astutil.RangeInStringLiteral(lit, op.Range.Start, op.Range.End)
504+
if err != nil {
505+
return false
506+
}
507+
// Report literal substring.
508+
tv.token(pos, int(rangeStart-pos), semtok.TokString)
509+
// Report formatting directive.
510+
tv.token(rangeStart, int(rangeEnd-rangeStart), semtok.TokString, semtok.ModFormat)
511+
pos = rangeEnd
512+
}
513+
// Report remaining literal substring.
514+
tv.token(pos, int(lit.End()-pos), semtok.TokString)
515+
return true
516+
}
517+
464518
func (tv *tokenVisitor) appendObjectModifiers(mods []semtok.Modifier, obj types.Object) (semtok.Type, []semtok.Modifier) {
465519
if obj.Pkg() == nil {
466520
mods = append(mods, semtok.ModDefaultLibrary)

gopls/internal/protocol/semtok/semtok.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ const (
102102
ModArray Modifier = "array"
103103
ModBool Modifier = "bool"
104104
ModChan Modifier = "chan"
105+
ModFormat Modifier = "format" // for format string directives such as "%s"
105106
ModInterface Modifier = "interface"
106107
ModMap Modifier = "map"
107108
ModNumber Modifier = "number"
@@ -123,6 +124,7 @@ var TokenModifiers = []Modifier{
123124
ModArray,
124125
ModBool,
125126
ModChan,
127+
ModFormat,
126128
ModInterface,
127129
ModMap,
128130
ModNumber,
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
This test checks semanticTokens for format string placeholders.
2+
3+
-- settings.json --
4+
{
5+
"semanticTokens": true
6+
}
7+
8+
-- flags --
9+
-ignore_extra_diags
10+
11+
-- format.go --
12+
package format
13+
14+
import "fmt"
15+
16+
func PrintfTests() {
17+
var i int
18+
var x float64
19+
fmt.Printf("%b %d %f", 3, i, x) //@ token("%b", "string", "format"), token("%d", "string", "format"),token("%f", "string", "format"),
20+
fmt.Printf("lit1%blit2%dlit3%flit4", 3, i, x) //@ token("%b", "string", "format"), token("%d", "string", "format"),token("%f", "string", "format"),token("lit1", "string", ""),token("lit2", "string", ""),token("lit3", "string", ""),
21+
fmt.Printf("%% %d lit2", 3, i, x) //@ token("%d", "string", "format"),token("%%", "string", ""),token("lit2", "string", ""),
22+
fmt.Printf("Hello %% \n %s, you \t%% \n have %d new m%%essages!", "Alice", 5) //@ token("%s", "string", "format"),token("%d", "string", "format")
23+
fmt.Printf("%d \nss \x25[2]d", 234, 123) //@ token("%d", "string", "format"),token("\\x25[2]d", "string", "format")
24+
fmt.Printf("start%[2]*.[1]*[3]dmiddle%send", 4, 5, 6) //@ token("%[2]*.[1]*[3]d", "string", "format"),token("start", "string", ""),token("%s", "string", "format"),token("middle", "string", ""),token("end", "string", "")
25+
}
26+

internal/astutil/util.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,20 @@ import (
1212
"unicode/utf8"
1313
)
1414

15+
// RangeInStringLiteral calculates the positional range within a string literal
16+
// corresponding to the specified start and end byte offsets within the logical string.
17+
func RangeInStringLiteral(lit *ast.BasicLit, start, end int) (token.Pos, token.Pos, error) {
18+
startPos, err := PosInStringLiteral(lit, start)
19+
if err != nil {
20+
return 0, 0, fmt.Errorf("start: %v", err)
21+
}
22+
endPos, err := PosInStringLiteral(lit, end)
23+
if err != nil {
24+
return 0, 0, fmt.Errorf("end: %v", err)
25+
}
26+
return startPos, endPos, nil
27+
}
28+
1529
// PosInStringLiteral returns the position within a string literal
1630
// corresponding to the specified byte offset within the logical
1731
// string that it denotes.

0 commit comments

Comments
 (0)