Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 154 additions & 0 deletions internal/document/document.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"go/types"
"log/slog"
"os"
"regexp"
"strconv"
"strings"
"sync"
Expand Down Expand Up @@ -38,11 +39,14 @@ func IsDocDeprecated(docs []string) bool {

func NewDocument(
relative string,
originAbs string,
pkg *packages.Package,
pkgSymbols *lookup.Package,
) *Document {
return &Document{
RelativePath: relative,
originAbs: originAbs,
lineLen: loadLineLengths(originAbs),
pkg: pkg,
pkgSymbols: pkgSymbols,

Expand Down Expand Up @@ -70,6 +74,156 @@ type Document struct {
// pkgSymbols maps positions to symbol names within
// this document.
pkgSymbols *lookup.Package

// originAbs is the cleaned, symlink-resolved absolute path of the source file
// this document represents; lineLen is the byte length of each of its lines
// (0-indexed), or nil if it couldn't be read. Together they let AppendOccurrence
// callers reject occurrences whose //line-adjusted range escapes the real file.
originAbs string
lineLen []int

// lines is the document's source split by line, loaded lazily by lineText:
// only documents that need a range repair pay to keep their source resident.
// linesLoaded distinguishes "not tried yet" from "tried and unreadable".
lines []string
linesLoaded bool

// occurrences accumulates every occurrence routed to this document. Usually
// that is only its own file's, but a generated file may attribute occurrences
// here via //line directives (e.g. cgo's rewritten source). extraSymbols
// accumulates SymbolInformation from the file(s) that map here.
occurrences []*scip.Occurrence
extraSymbols []*scip.SymbolInformation
}

// InBounds reports whether r is a well-formed range that fits within this
// document's source file. It rejects:
// - negative line/column (a `//line file:N` directive with no column collapses
// positions to column 0 -> scip -1, which is malformed);
// - lines past EOF or columns past the line (cgo's `defer C.f(x)` rewrites and
// inserted `_cgoCheckPointer` thunks land here);
// - reversed ranges (end before start).
//
// Such occurrences cannot be faithfully represented and are dropped rather than
// emitted with a bogus location (which downstream SCIP consumers reject). A
// document whose source could not be read admits everything (no over-dropping).
func (d *Document) InBounds(r scip.Range) bool {
sl, sc, el, ec := int(r.Start.Line), int(r.Start.Character), int(r.End.Line), int(r.End.Character)
if sl < 0 || sc < 0 || el < 0 || ec < 0 {
return false
}
if el < sl || (el == sl && ec < sc) {
return false
}
if d.lineLen == nil {
return true
}
if sl >= len(d.lineLen) || el >= len(d.lineLen) {
return false
}
return sc <= d.lineLen[sl] && ec <= d.lineLen[el]
}

// cgoSourceName matches the text cgo rewrote into a mangled identifier: a
// `C.foo` selector, or a bare identifier for manglings that drop the prefix.
// Anchored, so it only matches at the column the range starts on.
var cgoSourceName = regexp.MustCompile(`^(?:C\.)?[\p{L}_][\p{L}\p{Nd}_]*`)

// RepairRange rebuilds an out-of-bounds range from the source text it actually
// covers, returning the replacement and true when one is available.
//
// A range's width comes from the identifier the type checker sees, and cgo's
// identifiers are mangled: `C.puts` is rewritten to `_Cfunc_puts`, so a range
// anchored at the right column is emitted five characters too wide and can spill
// past the end of the real line. The position is fine; only the width is wrong.
// Measuring the identifier that is genuinely at that column recovers the
// occurrence instead of discarding it.
//
// Only a single-line range starting inside real source can be repaired. A
// synthesized position -- cgo's `defer C.f(x)` wrapper, which lands at
// end-of-line where there is no identifier to measure -- matches nothing and is
// still dropped, so this never invents a location.
func (d *Document) RepairRange(r scip.Range) (scip.Range, bool) {
if r.Start.Line != r.End.Line || r.Start.Line < 0 || r.Start.Character < 0 {
return r, false
}
line, ok := d.lineText(int(r.Start.Line))
if !ok || int(r.Start.Character) >= len(line) {
return r, false
}
name := cgoSourceName.FindString(line[r.Start.Character:])
if name == "" {
return r, false
}
repaired := scip.Range{
Start: r.Start,
End: scip.Position{
Line: r.Start.Line,
Character: r.Start.Character + int32(len(name)),
},
}
if !d.InBounds(repaired) {
return r, false
}
return repaired, true
}

// lineText returns 0-indexed line l of this document's source. The source is
// read on first use and cached, so documents that never need a repair keep only
// their line lengths.
func (d *Document) lineText(l int) (string, bool) {
if !d.linesLoaded {
d.linesLoaded = true
if b, err := os.ReadFile(d.originAbs); err == nil {
d.lines = strings.Split(string(b), "\n")
}
}
if l < 0 || l >= len(d.lines) {
return "", false
}
return strings.TrimSuffix(d.lines[l], "\r"), true
}

// AppendOccurrence records occ against this document. Called from the single
// file-walking goroutine, so no synchronization is required.
func (d *Document) AppendOccurrence(occ *scip.Occurrence) {
d.occurrences = append(d.occurrences, occ)
}

// AddSymbols records SymbolInformation contributed by a file that maps here.
func (d *Document) AddSymbols(syms []*scip.SymbolInformation) {
d.extraSymbols = append(d.extraSymbols, syms...)
}

// ToScip renders the accumulated occurrences and symbols as a scip.Document.
func (d *Document) ToScip() *scip.Document {
occurrences := d.occurrences
if d.PackageOccurrence != nil {
occurrences = append([]*scip.Occurrence{d.PackageOccurrence}, occurrences...)
}
return &scip.Document{
Language: "go",
RelativePath: d.RelativePath,
Occurrences: occurrences,
Symbols: d.extraSymbols,
}
}

// loadLineLengths returns the byte length of each line of path (0-indexed), or
// nil if it can't be read. Used to bounds-check occurrence ranges against the
// real source (cgo rewrites some constructs to positions past the original
// line/EOF; those can't be faithfully represented and are dropped).
func loadLineLengths(path string) []int {
b, err := os.ReadFile(path)
if err != nil {
return nil
}
lines := strings.Split(string(b), "\n")
lengths := make([]int, len(lines))
for i, line := range lines {
lengths[i] = len(strings.TrimSuffix(line, "\r"))
}
return lengths
}

func (d *Document) GetSymbol(pos token.Pos) (string, bool) {
Expand Down
56 changes: 56 additions & 0 deletions internal/document/inbounds_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package document

import (
"testing"

"github.com/scip-code/scip/bindings/go/scip"
)

func rng(sl, sc, el, ec int32) scip.Range {
return scip.Range{
Start: scip.Position{Line: sl, Character: sc},
End: scip.Position{Line: el, Character: ec},
}
}

func TestInBounds(t *testing.T) {
// Two lines, byte lengths 5 and 10.
d := &Document{lineLen: []int{5, 10}}

cases := []struct {
name string
r scip.Range
want bool
}{
{"in bounds, single line", rng(0, 0, 0, 5), true},
{"end column at EOL", rng(1, 0, 1, 10), true},
{"spans two lines", rng(0, 1, 1, 2), true},
{"start column past EOL", rng(0, 6, 0, 6), false},
{"end column past EOL", rng(1, 0, 1, 11), false},
{"line past EOF", rng(2, 0, 2, 0), false},
{"negative start column", rng(0, -1, 0, -1), false},
{"negative line", rng(-1, 0, -1, 0), false},
{"reversed same line", rng(0, 4, 0, 1), false},
{"reversed across lines", rng(1, 0, 0, 0), false},
}
for _, tc := range cases {
if got := d.InBounds(tc.r); got != tc.want {
t.Errorf("%s: InBounds(%v) = %v, want %v", tc.name, tc.r, got, tc.want)
}
}
}

func TestInBoundsUnknownSourceAdmitsWellFormed(t *testing.T) {
// A document whose source couldn't be read (lineLen == nil) must not
// over-drop: it admits any well-formed range but still rejects malformed ones.
d := &Document{}
if !d.InBounds(rng(999, 0, 999, 3)) {
t.Error("nil lineLen should admit a well-formed range")
}
if d.InBounds(rng(0, -1, 0, 0)) {
t.Error("nil lineLen should still reject a negative column")
}
if d.InBounds(rng(2, 0, 1, 0)) {
t.Error("nil lineLen should still reject a reversed range")
}
}
91 changes: 91 additions & 0 deletions internal/document/repair_range_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package document

import (
"os"
"path/filepath"
"testing"

"github.com/scip-code/scip/bindings/go/scip"
)

// docFor writes src to a temp file and returns a Document over it, the way
// NewDocument would.
func docFor(t *testing.T, src string) *Document {
t.Helper()
path := filepath.Join(t.TempDir(), "geometry.go")
if err := os.WriteFile(path, []byte(src), 0o600); err != nil {
t.Fatal(err)
}
return &Document{originAbs: path, lineLen: loadLineLengths(path)}
}

// The line cgo mangles: `C.puts` is rewritten to `_Cfunc_puts`, so the range is
// sized 11 instead of 6 and runs past the end of the real line.
const putsSrc = "package example\n\nfunc f(cs *C.char) {\n C.puts(cs)\n}\n"

func TestRepairRangeRecoversMangledCgoName(t *testing.T) {
d := docFor(t, putsSrc)
// Line 3 is " C.puts(cs)" (14 bytes); the mangled range ends at 15.
oob := rng(3, 4, 3, 15)
if d.InBounds(oob) {
t.Fatal("test setup: range should be out of bounds")
}

got, ok := d.RepairRange(oob)
if !ok {
t.Fatal("RepairRange should recover a mangled cgo name")
}
if want := rng(3, 4, 3, 10); got != want {
t.Errorf("RepairRange = %v, want %v (covering `C.puts`)", got, want)
}
if !d.InBounds(got) {
t.Error("repaired range must be in bounds")
}
}

func TestRepairRangeRejectsUnmeasurablePositions(t *testing.T) {
d := docFor(t, putsSrc)

cases := []struct {
name string
r scip.Range
}{
// cgo's `defer C.f(x)` wrapper lands at end-of-line, where there is no
// identifier to measure -- inventing a range there would be a guess.
{"start at end of line", rng(3, 14, 3, 25)},
{"start past end of line", rng(3, 40, 3, 51)},
{"line past EOF", rng(99, 0, 99, 5)},
{"multi-line range", rng(3, 4, 4, 2)},
{"negative line", rng(-1, 0, -1, 5)},
{"negative column", rng(3, -1, 3, 5)},
// Column 0 of ` C.puts(cs)` is a space: no identifier starts there.
{"start on non-identifier", rng(3, 0, 3, 40)},
}
for _, tc := range cases {
if _, ok := d.RepairRange(tc.r); ok {
t.Errorf("%s: RepairRange(%v) should not repair", tc.name, tc.r)
}
}
}

func TestRepairRangeUnreadableSource(t *testing.T) {
// A document whose source can't be read has nothing to measure against.
d := &Document{originAbs: filepath.Join(t.TempDir(), "missing.go")}
if _, ok := d.RepairRange(rng(0, 0, 0, 5)); ok {
t.Error("unreadable source should not repair")
}
}

func TestRepairRangeHandlesNonASCIIIdentifiers(t *testing.T) {
// Ranges are byte offsets, and Go identifiers may be non-ASCII, so the
// repaired width must be measured in bytes.
d := docFor(t, "package example\n\nvar été = 1\n")
// Line 2 is "var été = 1"; `été` starts at byte 4 and is 5 bytes long.
got, ok := d.RepairRange(rng(2, 4, 2, 99))
if !ok {
t.Fatal("should repair a non-ASCII identifier")
}
if want := rng(2, 4, 2, 9); got != want {
t.Errorf("RepairRange = %v, want %v", got, want)
}
}
Loading
Loading