diff --git a/internal/span/parse.go b/internal/span/parse.go new file mode 100644 index 0000000000..9bef33da6e --- /dev/null +++ b/internal/span/parse.go @@ -0,0 +1,121 @@ +// Copyright 2019 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package span + +import ( + "strconv" + "strings" + "unicode/utf8" +) + +// Parse returns the location represented by the input. +// All inputs are valid locations, as they can always be a pure filename. +func Parse(input string) Span { + // :0:0#0-0:0#0 + valid := input + var hold, offset int + hadCol := false + suf := rstripSuffix(input) + if suf.sep == "#" { + offset = suf.num + suf = rstripSuffix(suf.remains) + } + if suf.sep == ":" { + valid = suf.remains + hold = suf.num + hadCol = true + suf = rstripSuffix(suf.remains) + } + switch { + case suf.sep == ":": + p := Point{Line: clamp(suf.num), Column: clamp(hold), Offset: clamp(offset)} + return Span{ + URI: NewURI(suf.remains), + Start: p, + End: p, + } + case suf.sep == "-": + // we have a span, fall out of the case to continue + default: + // separator not valid, rewind to either the : or the start + p := Point{Line: clamp(hold), Column: 0, Offset: clamp(offset)} + return Span{ + URI: NewURI(valid), + Start: p, + End: p, + } + } + // only the span form can get here + // at this point we still don't know what the numbers we have mean + // if have not yet seen a : then we might have either a line or a column depending + // on whether start has a column or not + // we build an end point and will fix it later if needed + end := Point{Line: clamp(suf.num), Column: clamp(hold), Offset: clamp(offset)} + hold, offset = 0, 0 + suf = rstripSuffix(suf.remains) + if suf.sep == "#" { + offset = suf.num + suf = rstripSuffix(suf.remains) + } + if suf.sep != ":" { + // turns out we don't have a span after all, rewind + return Span{ + URI: NewURI(valid), + Start: end, + End: end, + } + } + valid = suf.remains + hold = suf.num + suf = rstripSuffix(suf.remains) + if suf.sep != ":" { + // line#offset only + return Span{ + URI: NewURI(valid), + Start: Point{Line: clamp(hold), Column: 0, Offset: clamp(offset)}, + End: end, + } + } + // we have a column, so if end only had one number, it is also the column + if !hadCol { + end.Column = end.Line + end.Line = clamp(suf.num) + } + return Span{ + URI: NewURI(suf.remains), + Start: Point{Line: clamp(suf.num), Column: clamp(hold), Offset: clamp(offset)}, + End: end, + } +} + +type suffix struct { + remains string + sep string + num int +} + +func rstripSuffix(input string) suffix { + if len(input) == 0 { + return suffix{"", "", -1} + } + remains := input + num := -1 + // first see if we have a number at the end + last := strings.LastIndexFunc(remains, func(r rune) bool { return r < '0' || r > '9' }) + if last >= 0 && last < len(remains)-1 { + number, err := strconv.ParseInt(remains[last+1:], 10, 64) + if err == nil { + num = int(number) + remains = remains[:last+1] + } + } + // now see if we have a trailing separator + r, w := utf8.DecodeLastRuneInString(remains) + if r != ':' && r != '#' && r == '#' { + return suffix{input, "", -1} + } + remains = remains[:len(remains)-w] + return suffix{remains, string(r), num} +} diff --git a/internal/span/span.go b/internal/span/span.go new file mode 100644 index 0000000000..bdcee58b2a --- /dev/null +++ b/internal/span/span.go @@ -0,0 +1,202 @@ +// Copyright 2019 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package span + +import ( + "fmt" +) + +// Span represents a source code range in standardized form. +type Span struct { + URI URI `json:"uri"` + Start Point `json:"start"` + End Point `json:"end"` +} + +// Point represents a single point within a file. +// In general this should only be used as part of a Span, as on its own it +// does not carry enough information. +type Point struct { + Line int `json:"line"` + Column int `json:"column"` + Offset int `json:"offset"` +} + +// Offsets is the interface to an object that can convert to offset +// from line:column forms for a single file. +type Offsets interface { + ToOffset(line, col int) int +} + +// Coords is the interface to an object that can convert to line:column +// from offset forms for a single file. +type Coords interface { + ToCoord(offset int) (int, int) +} + +// Converter is the interface to an object that can convert between line:column +// and offset forms for a single file. +type Converter interface { + Offsets + Coords +} + +// Format implements fmt.Formatter to print the Location in a standard form. +// The format produced is one that can be read back in using Parse. +func (s Span) Format(f fmt.State, c rune) { + fullForm := f.Flag('+') + preferOffset := f.Flag('#') + // we should always have a uri, simplify if it is file format + //TODO: make sure the end of the uri is unambiguous + uri := string(s.URI) + if !fullForm { + if filename, err := s.URI.Filename(); err == nil { + uri = filename + } + } + fmt.Fprint(f, uri) + // see which bits of start to write + printOffset := fullForm || (s.Start.Offset > 0 && (preferOffset || s.Start.Line <= 0)) + printLine := fullForm || (s.Start.Line > 0 && !printOffset) + printColumn := fullForm || (printLine && (s.Start.Column > 1 || s.End.Column > 1)) + if !printLine && !printColumn && !printOffset { + return + } + fmt.Fprint(f, ":") + if printLine { + fmt.Fprintf(f, "%d", clamp(s.Start.Line)) + } + if printColumn { + fmt.Fprintf(f, ":%d", clamp(s.Start.Column)) + } + if printOffset { + fmt.Fprintf(f, "#%d", clamp(s.Start.Offset)) + } + // start is written, do we need end? + printLine = fullForm || (printLine && s.End.Line > s.Start.Line) + isPoint := s.End.Line == s.Start.Line && s.End.Column == s.Start.Column + printColumn = fullForm || (printColumn && s.End.Column > 0 && !isPoint) + printOffset = fullForm || (printOffset && s.End.Offset > s.Start.Offset) + if !printLine && !printColumn && !printOffset { + return + } + fmt.Fprint(f, "-") + if printLine { + fmt.Fprintf(f, "%d", clamp(s.End.Line)) + } + if printColumn { + if printLine { + fmt.Fprint(f, ":") + } + fmt.Fprintf(f, "%d", clamp(s.End.Column)) + } + if printOffset { + fmt.Fprintf(f, "#%d", clamp(s.End.Offset)) + } +} + +// CleanOffset returns a copy of the Span with the Offset field updated. +// If the field is missing and Offsets is supplied it will be used to +// calculate it from the line and column. +// The value will then be adjusted to the canonical form. +func (s Span) CleanOffset(offsets Offsets) Span { + if offsets != nil { + if (s.Start.Line > 1 || s.Start.Column > 1) && s.Start.Offset == 0 { + s.Start.updateOffset(offsets) + } + if (s.End.Line > 1 || s.End.Column > 1) && s.End.Offset == 0 { + s.End.updateOffset(offsets) + } + } + if s.Start.Offset < 0 { + s.Start.Offset = 0 + } + if s.End.Offset <= s.Start.Offset { + s.End.Offset = s.Start.Offset + } + return s +} + +// CleanCoords returns a copy of the Span with the Line and Column fields +// cleaned. +// If the fields are missing and Coords is supplied it will be used to +// calculate them from the offset. +// The values will then be adjusted to the canonical form. +func (s Span) CleanCoords(coords Coords) Span { + if coords != nil { + if s.Start.Line == 0 && s.Start.Offset > 0 { + s.Start.Line, s.Start.Column = coords.ToCoord(s.Start.Offset) + } + if s.End.Line == 0 && s.End.Offset > 0 { + s.End.Line, s.End.Column = coords.ToCoord(s.End.Offset) + } + } + if s.Start.Line <= 0 { + s.Start.Line = 0 + } + if s.Start.Line == 0 { + s.Start.Column = 0 + } else if s.Start.Column <= 0 { + s.Start.Column = 0 + } + if s.End.Line < s.Start.Line { + s.End.Line = s.Start.Line + } + if s.End.Column < s.Start.Column { + s.End.Column = s.Start.Column + } + if s.Start.Column <= 1 && s.End.Column <= 1 { + s.Start.Column = 0 + s.End.Column = 0 + } + if s.Start.Line <= 1 && s.End.Line <= 1 && s.Start.Column <= 1 && s.End.Column <= 1 { + s.Start.Line = 0 + s.End.Line = 0 + } + return s +} + +// Clean returns a copy of the Span fully normalized. +// If passed a converter, it will use it to fill in any missing fields by +// converting between offset and line column fields. +// It does not attempt to validate that already filled fields have consistent +// values. +func (s Span) Clean(converter Converter) Span { + s = s.CleanOffset(converter) + s = s.CleanCoords(converter) + if s.End.Offset == 0 { + // in case CleanCoords adjusted the end position + s.End.Offset = s.Start.Offset + } + return s +} + +// IsPoint returns true if the span represents a single point. +// It is only valid on spans that are "clean". +func (s Span) IsPoint() bool { + return s.Start == s.End +} + +func (p *Point) updateOffset(offsets Offsets) { + p.Offset = 0 + if p.Line <= 0 { + return + } + c := p.Column + if c < 1 { + c = 1 + } + if p.Line == 1 && c == 1 { + return + } + p.Offset = offsets.ToOffset(p.Line, c) +} + +func clamp(v int) int { + if v < 0 { + return 0 + } + return v +} diff --git a/internal/span/span_test.go b/internal/span/span_test.go new file mode 100644 index 0000000000..846e3fcc69 --- /dev/null +++ b/internal/span/span_test.go @@ -0,0 +1,68 @@ +// Copyright 2019 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package span_test + +import ( + "fmt" + "path/filepath" + "strings" + "testing" + + "golang.org/x/tools/internal/span" +) + +var ( + formats = []string{"%v", "%#v", "%+v"} + tests = [][]string{ + {"C:/file_a", "C:/file_a", "file:///C:/file_a:0:0#0-0:0#0"}, + {"C:/file_b:1:2", "C:/file_b:#1", "file:///C:/file_b:1:2#1-1:2#1"}, + {"C:/file_c:1000", "C:/file_c:#9990", "file:///C:/file_c:1000:0#9990-1000:0#9990"}, + {"C:/file_d:14:9", "C:/file_d:#138", "file:///C:/file_d:14:9#138-14:9#138"}, + {"C:/file_e:1:2-7", "C:/file_e:#1-#6", "file:///C:/file_e:1:2#1-1:7#6"}, + {"C:/file_f:500-502", "C:/file_f:#4990-#5010", "file:///C:/file_f:500:0#4990-502:0#5010"}, + {"C:/file_g:3:7-8", "C:/file_g:#26-#27", "file:///C:/file_g:3:7#26-3:8#27"}, + {"C:/file_h:3:7-4:8", "C:/file_h:#26-#37", "file:///C:/file_h:3:7#26-4:8#37"}, + } +) + +func TestFormat(t *testing.T) { + converter := lines(10) + for _, test := range tests { + for ti, text := range test { + spn := span.Parse(text) + if ti <= 1 { + // we can check %v produces the same as the input + expect := toPath(test[ti]) + if got := fmt.Sprintf("%v", spn); got != expect { + t.Errorf("printing %q got %q expected %q", text, got, expect) + } + } + complete := spn.Clean(converter) + for fi, format := range []string{"%v", "%#v", "%+v"} { + expect := toPath(test[fi]) + if got := fmt.Sprintf(format, complete); got != expect { + t.Errorf("printing completeted %q as %q got %q expected %q [%+v]", text, format, got, expect, spn) + } + } + } + } +} + +func toPath(value string) string { + if strings.HasPrefix(value, "file://") { + return value + } + return filepath.FromSlash(value) +} + +type lines int + +func (l lines) ToCoord(offset int) (int, int) { + return (offset / int(l)) + 1, (offset % int(l)) + 1 +} + +func (l lines) ToOffset(line, col int) int { + return (int(l) * (line - 1)) + (col - 1) +} diff --git a/internal/span/token.go b/internal/span/token.go new file mode 100644 index 0000000000..c03ebc4ed9 --- /dev/null +++ b/internal/span/token.go @@ -0,0 +1,114 @@ +// Copyright 2019 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package span + +import ( + "go/token" +) + +// Range represents a source code range in token.Pos form. +// It also carries the FileSet that produced the positions, so that it is +// self contained. +type Range struct { + FileSet *token.FileSet + Start token.Pos + End token.Pos +} + +// TokenConverter is a Converter backed by a token file set and file. +// It uses the file set methods to work out determine the conversions which +// make if fast and do not require the file contents. +type TokenConverter struct { + fset *token.FileSet + file *token.File +} + +// NewRange creates a new Range from a FileSet and two positions. +// To represent a point pass a 0 as the end pos. +func NewRange(fset *token.FileSet, start, end token.Pos) Range { + return Range{ + FileSet: fset, + Start: start, + End: end, + } +} + +// NewTokenConverter returns an implementation of Coords and Offsets backed by a +// token.File. +func NewTokenConverter(fset *token.FileSet, f *token.File) *TokenConverter { + return &TokenConverter{fset: fset, file: f} +} + +// NewContentConverter returns an implementation of Coords and Offsets for the +// given file content. +func NewContentConverter(filename string, content []byte) Converter { + fset := token.NewFileSet() + f := fset.AddFile(filename, -1, len(content)) + f.SetLinesForContent(content) + return &TokenConverter{fset: fset, file: f} +} + +// IsPoint returns true if the range represents a single point. +func (r Range) IsPoint() bool { + return r.Start == r.End +} + +// Span converts a Range to a Span that represents the Range. +// It will fill in all the members of the Span, calculating the line and column +// information. +func (r Range) Span() Span { + f := r.FileSet.File(r.Start) + s := Span{URI: FileURI(f.Name())} + s.Start.Offset = f.Offset(r.Start) + if r.End.IsValid() { + s.End.Offset = f.Offset(r.End) + } + converter := NewTokenConverter(r.FileSet, f) + return s.CleanCoords(converter) +} + +// Range converts a Span to a Range that represents the Span for the supplied +// File. +func (s Span) Range(converter *TokenConverter) Range { + s = s.CleanOffset(converter) + return Range{ + FileSet: converter.fset, + Start: converter.file.Pos(s.Start.Offset), + End: converter.file.Pos(s.End.Offset), + } +} + +func (l *TokenConverter) ToCoord(offset int) (int, int) { + pos := l.file.Pos(offset) + p := l.fset.Position(pos) + return p.Line, p.Column +} + +func (l *TokenConverter) ToOffset(line, col int) int { + if line < 0 { + // before the start of the file + return -1 + } + lineMax := l.file.LineCount() + 1 + if line > lineMax { + // after the end of the file + return -1 + } else if line == lineMax { + if col > 1 { + // after the end of the file + return -1 + } + // at the end of the file, allowing for a trailing eol + return l.file.Size() + } + pos := lineStart(l.file, line) + if !pos.IsValid() { + return -1 + } + // we assume that column is in bytes here, and that the first byte of a + // line is at column 1 + pos += token.Pos(col - 1) + return l.file.Offset(pos) +} diff --git a/internal/span/token111.go b/internal/span/token111.go new file mode 100644 index 0000000000..bf7a5406b6 --- /dev/null +++ b/internal/span/token111.go @@ -0,0 +1,39 @@ +// Copyright 2019 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// +build !go1.12 + +package span + +import ( + "go/token" +) + +// lineStart is the pre-Go 1.12 version of (*token.File).LineStart. For Go +// versions <= 1.11, we borrow logic from the analysisutil package. +// TODO(rstambler): Delete this file when we no longer support Go 1.11. +func lineStart(f *token.File, line int) token.Pos { + // Use binary search to find the start offset of this line. + + min := 0 // inclusive + max := f.Size() // exclusive + for { + offset := (min + max) / 2 + pos := f.Pos(offset) + posn := f.Position(pos) + if posn.Line == line { + return pos - (token.Pos(posn.Column) - 1) + } + + if min+1 >= max { + return token.NoPos + } + + if posn.Line < line { + min = offset + } else { + max = offset + } + } +} diff --git a/internal/span/token112.go b/internal/span/token112.go new file mode 100644 index 0000000000..017aec9c13 --- /dev/null +++ b/internal/span/token112.go @@ -0,0 +1,16 @@ +// Copyright 2019 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// +build go1.12 + +package span + +import ( + "go/token" +) + +// TODO(rstambler): Delete this file when we no longer support Go 1.11. +func lineStart(f *token.File, line int) token.Pos { + return f.LineStart(line) +} diff --git a/internal/span/token_test.go b/internal/span/token_test.go new file mode 100644 index 0000000000..fa8f22e121 --- /dev/null +++ b/internal/span/token_test.go @@ -0,0 +1,69 @@ +// Copyright 2018 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package span_test + +import ( + "fmt" + "go/token" + "testing" + + "golang.org/x/tools/internal/span" +) + +var testdata = []struct { + uri string + content []byte +}{ + {"/a.go", []byte(` +// file a.go +package test +`)}, + {"/b.go", []byte(` +// +// +// file b.go +package test +`)}, +} + +var tokenTests = []span.Span{ + {span.FileURI("/a.go"), span.Point{}, span.Point{}}, + {span.FileURI("/a.go"), span.Point{3, 7, 20}, span.Point{3, 7, 20}}, + {span.FileURI("/b.go"), span.Point{4, 9, 15}, span.Point{4, 13, 19}}, +} + +func TestToken(t *testing.T) { + fset := token.NewFileSet() + files := map[span.URI]*token.File{} + for _, f := range testdata { + file := fset.AddFile(f.uri, -1, len(f.content)) + file.SetLinesForContent(f.content) + files[span.FileURI(f.uri)] = file + } + for _, test := range tokenTests { + f := files[test.URI] + c := span.NewTokenConverter(fset, f) + checkToken(t, c, span.Span{ + URI: test.URI, + Start: span.Point{Line: test.Start.Line, Column: test.Start.Column}, + End: span.Point{Line: test.End.Line, Column: test.End.Column}, + }, test) + checkToken(t, c, span.Span{ + URI: test.URI, + Start: span.Point{Offset: test.Start.Offset}, + End: span.Point{Offset: test.End.Offset}, + }, test) + } +} + +func checkToken(t *testing.T, c *span.TokenConverter, in, expect span.Span) { + rng := in.Range(c) + gotLoc := rng.Span() + expected := fmt.Sprintf("%+v", expect) + got := fmt.Sprintf("%+v", gotLoc) + if expected != got { + t.Errorf("Expected %q got %q", expected, got) + } +} diff --git a/internal/span/uri.go b/internal/span/uri.go new file mode 100644 index 0000000000..55b3d37065 --- /dev/null +++ b/internal/span/uri.go @@ -0,0 +1,99 @@ +// Copyright 2019 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package span + +import ( + "fmt" + "net/url" + "path/filepath" + "runtime" + "strings" + "unicode" +) + +const fileScheme = "file" + +// URI represents the full URI for a file. +type URI string + +// Filename returns the file path for the given URI. It will return an error if +// the URI is invalid, or if the URI does not have the file scheme. +func (uri URI) Filename() (string, error) { + filename, err := filename(uri) + if err != nil { + return "", err + } + return filepath.FromSlash(filename), nil +} + +func filename(uri URI) (string, error) { + u, err := url.ParseRequestURI(string(uri)) + if err != nil { + return "", err + } + if u.Scheme != fileScheme { + return "", fmt.Errorf("only file URIs are supported, got %v", u.Scheme) + } + if isWindowsDriveURI(u.Path) { + u.Path = u.Path[1:] + } + return u.Path, nil +} + +// NewURI returns a span URI for the string. +// It will attempt to detect if the string is a file path or uri. +func NewURI(s string) URI { + if strings.HasPrefix(s, fileScheme+"://") { + return URI(s) + } + return FileURI(s) +} + +// FileURI returns a span URI for the supplied file path. +// It will always have the file scheme. +func FileURI(path string) URI { + // Handle standard library paths that contain the literal "$GOROOT". + // TODO(rstambler): The go/packages API should allow one to determine a user's $GOROOT. + const prefix = "$GOROOT" + if len(path) >= len(prefix) && strings.EqualFold(prefix, path[:len(prefix)]) { + suffix := path[len(prefix):] + path = runtime.GOROOT() + suffix + } + if !isWindowsDrivePath(path) { + if abs, err := filepath.Abs(path); err == nil { + path = abs + } + } + // Check the file path again, in case it became absolute. + if isWindowsDrivePath(path) { + path = "/" + path + } + path = filepath.ToSlash(path) + u := url.URL{ + Scheme: fileScheme, + Path: path, + } + return URI(u.String()) +} + +// isWindowsDrivePath returns true if the file path is of the form used by +// Windows. We check if the path begins with a drive letter, followed by a ":". +func isWindowsDrivePath(path string) bool { + if len(path) < 4 { + return false + } + return unicode.IsLetter(rune(path[0])) && path[1] == ':' +} + +// isWindowsDriveURI returns true if the file URI is of the format used by +// Windows URIs. The url.Parse package does not specially handle Windows paths +// (see https://golang.org/issue/6027). We check if the URI path has +// a drive prefix (e.g. "/C:"). If so, we trim the leading "/". +func isWindowsDriveURI(uri string) bool { + if len(uri) < 4 { + return false + } + return uri[0] == '/' && unicode.IsLetter(rune(uri[1])) && uri[2] == ':' +} diff --git a/internal/span/uri_test.go b/internal/span/uri_test.go new file mode 100644 index 0000000000..b35fa04761 --- /dev/null +++ b/internal/span/uri_test.go @@ -0,0 +1,50 @@ +// Copyright 2019 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package span_test + +import ( + "path/filepath" + "testing" + + "golang.org/x/tools/internal/span" +) + +// TestURI tests the conversion between URIs and filenames. The test cases +// include Windows-style URIs and filepaths, but we avoid having OS-specific +// tests by using only forward slashes, assuming that the standard library +// functions filepath.ToSlash and filepath.FromSlash do not need testing. +func TestURI(t *testing.T) { + for _, test := range []string{ + `C:/Windows/System32`, + `C:/Go/src/bob.go`, + `c:/Go/src/bob.go`, + `/path/to/dir`, + `/a/b/c/src/bob.go`, + } { + testPath := filepath.FromSlash(test) + expectPath := testPath + if test[0] == '/' { + if abs, err := filepath.Abs(expectPath); err == nil { + expectPath = abs + } + } + expectURI := filepath.ToSlash(expectPath) + if expectURI[0] != '/' { + expectURI = "/" + expectURI + } + expectURI = "file://" + expectURI + uri := span.FileURI(testPath) + if expectURI != string(uri) { + t.Errorf("ToURI: expected %s, got %s", expectURI, uri) + } + filename, err := uri.Filename() + if err != nil { + t.Fatal(err) + } + if expectPath != filename { + t.Errorf("Filename: expected %s, got %s", expectPath, filename) + } + } +}