Compare commits
6
Commits
main
...
26c5008136
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26c5008136 | ||
|
|
74d6b90d69 | ||
|
|
7b11c62f53 | ||
|
|
8eed54b3da | ||
|
|
4be16dcdf5 | ||
|
|
ded9cabdf4 |
+41
-1
@@ -9,7 +9,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
-
|
||||
- **Workspace-wide navigation in the language server.** `gasm lsp` indexes
|
||||
the `.s` files under the workspace root beyond the documents the editor
|
||||
has open, so go-to-definition, find references and workspace symbol search
|
||||
reach files that were never opened. An open buffer always shadows its
|
||||
disk copy, and watched-file events together with a per-query freshness
|
||||
check keep the index current.
|
||||
- **Quick fixes for the textflag include and the argument area.** The
|
||||
`missing-textflag-include` warning offers to add the include after the
|
||||
last one in the file, and the `abi-argsize` warning offers to set the
|
||||
TEXT argument area to the size the `// func` signature implies, computed
|
||||
by the new `lint.ExpectedArgSize`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The corpus audit assembles like the build.** A file's `//go:build`
|
||||
constraint decides which target architectures attempt it: cpu_x86.s is
|
||||
an x86 build alone, and the msan and goexperiment.runtimesecret trees
|
||||
are compiled by no supported build, so they leave the measured set
|
||||
instead of failing it. The headline now reads "assemble for every
|
||||
applicable target": every real-code GOROOT assembly file, the tree
|
||||
without testdata, assembles for all four architectures (250 of 250,
|
||||
100 %); over the whole tree including testdata the measure is 271 of
|
||||
322 (84.2 %).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Rename edits land in their own documents.** A rename collected the
|
||||
ranges of every reference across the open documents but applied them all
|
||||
to the document that started it, so renaming a symbol used in a second
|
||||
file moved that file's text into the first. Each edit now applies to the
|
||||
document it was collected in.
|
||||
- **Negative numeric PC-relative jumps.** `JMP -3(PC)`, the shape the
|
||||
runtime's exit loops write (sys_linux_amd64.s, sys_netbsd_amd64.s),
|
||||
resolved to nothing: only the forward forms counted. A negative count
|
||||
now walks the same instruction statements backwards, labels excluded,
|
||||
byte-identical with the toolchain.
|
||||
- **The arm64 move-wide family reads its immediate as an unsigned
|
||||
pattern.** `MOVK $(40000<<48)` folds to a negative int64 and was
|
||||
rejected; the toolchain picks the 16-bit lane from the 64-bit bit
|
||||
pattern, so the encoder now does the same, and a zero immediate is
|
||||
rejected where the toolchain rejects it.
|
||||
|
||||
## [0.35.0] - 2026-09-22
|
||||
|
||||
|
||||
@@ -100,7 +100,9 @@ to give that syntax the tooling it deserves.
|
||||
find references, rename, formatting, inlay hints, code actions, signature
|
||||
help, document highlights, workspace symbol search, #include document
|
||||
links and folding ranges over stdio; definition, references and rename
|
||||
work across every open document.
|
||||
work across every open document and the indexed workspace files beyond
|
||||
them, and the quick fixes add a missing textflag.h include and set the
|
||||
argument area from the // func signature.
|
||||
- **Comparators and audits.** `gasm diff` compares the machine code of two
|
||||
assembly files byte-for-byte, `gasm profile` shows basic-block structure,
|
||||
`gasm audit-instructions` diffs the encoder against the installed toolchain,
|
||||
@@ -127,10 +129,13 @@ can emit today is narrower, and a recognised but unencodable instruction is
|
||||
reported as an explicit error, never as a wrong byte.
|
||||
|
||||
The same measurement runs over GOROOT's whole assembly corpus:
|
||||
`gasm audit-instructions --corpus` reports 291 of 353 attemptable files
|
||||
(82.4 %) assembling for every target architecture today (files named for
|
||||
other Go ports are counted but never attempted), with the top failure
|
||||
reasons per architecture; the number moves with every release.
|
||||
`gasm audit-instructions --corpus` reports every real-code GOROOT assembly
|
||||
file (the tree without testdata) assembling for every target its build
|
||||
admits: 250 of 250, 100 %. Over the whole tree including testdata the
|
||||
measure is 271 of 322 attemptable (84.2 %); files named for other Go ports
|
||||
are counted but never attempted, and `//go:build` constraints decide which
|
||||
targets attempt a file at all, exactly as the build does. The number moves
|
||||
with every release.
|
||||
|
||||
### Validation status
|
||||
|
||||
|
||||
+14
-14
@@ -3069,29 +3069,29 @@ func encodeARM64MoveWide(mnem string, baseOp uint32, ops []*ast.Operand) ([]byte
|
||||
// base, so MOVZ and MOVN come along for free.
|
||||
opc := baseOp >> 29 & 3
|
||||
sf := baseOp >> 31 & 1
|
||||
v := arm64Imm64(ops[0])
|
||||
if v < 0 {
|
||||
return nil, fmt.Errorf("%s: negative immediate %d", mnem, v)
|
||||
// The toolchain's optab case 33, shared by the whole family in both
|
||||
// widths: the immediate is one unsigned 64-bit pattern (a high-lane
|
||||
// constant such as $(40000<<48) arrives negative through int64
|
||||
// folding), it must occupy exactly one 16-bit lane, zero is rejected,
|
||||
// and the W forms cannot reach the top half.
|
||||
u := uint64(arm64Imm64(ops[0]))
|
||||
if u == 0 {
|
||||
return nil, fmt.Errorf("%s: zero immediate cannot be handled", mnem)
|
||||
}
|
||||
hw := -1
|
||||
for i := range 4 {
|
||||
if v>>(uint(i)*16)&0xFFFF != 0 {
|
||||
hw = i
|
||||
for lane := range 4 {
|
||||
if u&^(uint64(0xFFFF)<<(lane*16)) == 0 {
|
||||
hw = lane
|
||||
break
|
||||
}
|
||||
}
|
||||
if hw < 0 {
|
||||
hw = 0 // zero: every chunk is zero, hw = 0 carries it
|
||||
}
|
||||
for i := hw + 1; i < 4; i++ {
|
||||
if v>>(uint(i)*16)&0xFFFF != 0 {
|
||||
return nil, fmt.Errorf("%s: immediate %d does not fit one 16-bit chunk", mnem, v)
|
||||
}
|
||||
return nil, fmt.Errorf("%s: immediate %#x does not fit one 16-bit chunk", mnem, u)
|
||||
}
|
||||
if sf == 0 && hw > 1 {
|
||||
return nil, fmt.Errorf("%s: immediate %d out of range for the 32-bit form", mnem, v)
|
||||
return nil, fmt.Errorf("%s: immediate %#x out of range for the 32-bit form", mnem, u)
|
||||
}
|
||||
return a64wordLE(a64MoveWide(sf, opc, uint32(hw), uint32(v>>uint(hw*16)&0xFFFF), uint32(rd))), nil
|
||||
return a64wordLE(a64MoveWide(sf, opc, uint32(hw), uint32(u>>uint(hw*16)&0xFFFF), uint32(rd))), nil
|
||||
}
|
||||
|
||||
// ---- Bitfield/EXTR encoding ----
|
||||
|
||||
@@ -1051,6 +1051,41 @@ func TestArm64MOVK(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestArm64MOVKHighLane pins the shifted high-lane immediate the arm64 test
|
||||
// kernels write: $(40000<<48) folds to a negative int64, and the toolchain
|
||||
// reads the value as an unsigned 64-bit pattern when it picks the lane.
|
||||
func TestArm64MOVKHighLane(t *testing.T) {
|
||||
got := arm64Words(t, "\tMOVK $(40000<<48), R0\n\tMOVK $0x9c40000000000000, R1\n")
|
||||
want := []uint32{
|
||||
0xf2f38800, // MOVK $(40000<<48), R0 (go tool asm: f2f38800)
|
||||
0xf2f38801, // MOVK hw=3
|
||||
0xd65f03c0,
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("word count = %d, want %d", len(got), len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("word %d = %08x, want %08x", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestArm64MoveWideZeroImmediate pins the toolchain's rejection of a zero
|
||||
// immediate in the move-wide family (optab case 33: "zero shifts cannot be
|
||||
// handled"): every lane is zero, so no hw field can carry it.
|
||||
func TestArm64MoveWideZeroImmediate(t *testing.T) {
|
||||
for _, mnem := range []string{"MOVK", "MOVZ", "MOVN"} {
|
||||
f, errs := parser.Parse("test_arm64.s", "#include \"textflag.h\"\n\nTEXT ·f(SB), NOSPLIT, $0-0\n\t"+mnem+" $0, R0\n\tRET\n")
|
||||
if len(errs) > 0 {
|
||||
t.Fatalf("%s: parse: %v", mnem, errs)
|
||||
}
|
||||
if _, err := AssembleFileARM64(f); err == nil {
|
||||
t.Errorf("%s $0: expected error, got nil", mnem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestArm64LoadImm64 tests 64-bit immediate loading.
|
||||
func TestArm64LoadImm64(t *testing.T) {
|
||||
src := `#include "textflag.h"
|
||||
|
||||
+17
-1
@@ -543,11 +543,27 @@ func pcJumpOffset(op *ast.Operand) (int, bool) {
|
||||
// pcJumpTarget resolves a numeric jump at statement index j: N counts the
|
||||
// instruction statements after the jump itself (N = 0 is the jump's own
|
||||
// address, the classic park loop), and the target is the start of the Nth
|
||||
// one. It reports false when the count runs past the end of the function.
|
||||
// one. A negative N counts the same way backwards, before the jump: the
|
||||
// exit loops write JMP -3(PC) to land three instructions earlier. Labels
|
||||
// count not, in either direction. It reports false when the count runs
|
||||
// past the end of the function, or before its first instruction.
|
||||
func pcJumpTarget(t *ast.Text, j, n int, pcs []int) (int, bool) {
|
||||
if n == 0 {
|
||||
return pcs[j], true
|
||||
}
|
||||
if n < 0 {
|
||||
seen := 0
|
||||
for k := j - 1; k >= 0; k-- {
|
||||
if _, ok := t.Body[k].(*ast.Instr); !ok {
|
||||
continue
|
||||
}
|
||||
seen--
|
||||
if seen == n {
|
||||
return pcs[k], true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
seen := 0
|
||||
for k := j + 1; k < len(t.Body); k++ {
|
||||
if _, ok := t.Body[k].(*ast.Instr); !ok {
|
||||
|
||||
@@ -370,6 +370,58 @@ end:
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssembleNumericPCJumps pins the numeric ±N(PC) branch operands: N
|
||||
// counts instruction statements, skipping labels, in both directions (the
|
||||
// runtime's exit loops write JMP -3(PC)), N = 0 parks on the jump itself.
|
||||
func TestAssembleNumericPCJumps(t *testing.T) {
|
||||
fn := firstText(t, `
|
||||
#include "textflag.h"
|
||||
TEXT ·exit(SB), NOSPLIT, $0
|
||||
MOVB $1, AL
|
||||
lab:
|
||||
MOVB $2, AL
|
||||
MOVB $3, AL
|
||||
JMP -3(PC)
|
||||
MOVB $4, AL
|
||||
park:
|
||||
JMP 0(PC)
|
||||
MOVB $5, AL
|
||||
JMP 2(PC)
|
||||
MOVB $6, AL
|
||||
RET
|
||||
`)
|
||||
code, _, err := Assemble(fn)
|
||||
if err != nil {
|
||||
t.Fatalf("Assemble: %v", err)
|
||||
}
|
||||
// From the Go-assembled function:
|
||||
// MOVB $1, AL b001
|
||||
// MOVB $2, AL b002
|
||||
// MOVB $3, AL b003
|
||||
// JMP -3(PC) ebf8 (three instructions back, past lab:)
|
||||
// MOVB $4, AL b004
|
||||
// JMP 0(PC) ebfe (the park loop)
|
||||
// MOVB $5, AL b005
|
||||
// JMP 2(PC) eb02 (over MOVB $6 to the RET)
|
||||
// MOVB $6, AL b006
|
||||
// RET c3
|
||||
want := []byte{
|
||||
0xb0, 0x01,
|
||||
0xb0, 0x02,
|
||||
0xb0, 0x03,
|
||||
0xeb, 0xf8,
|
||||
0xb0, 0x04,
|
||||
0xeb, 0xfe,
|
||||
0xb0, 0x05,
|
||||
0xeb, 0x02,
|
||||
0xb0, 0x06,
|
||||
0xc3,
|
||||
}
|
||||
if hexBytes(code) != hexBytes(want) {
|
||||
t.Errorf("numeric-PC mismatch:\n got: %s\n want: %s", hexBytes(code), hexBytes(want))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssemblePrefetch(t *testing.T) {
|
||||
fn := firstText(t, `
|
||||
#include "textflag.h"
|
||||
|
||||
@@ -393,6 +393,52 @@ func TestRunCorpusAuditGOOS(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunCorpusAuditBuildConstraint covers the //go:build classification end
|
||||
// to end: a generic-named file whose constraint admits one target is
|
||||
// attempted there alone (cpu_x86.s on amd64), and a file whose constraint
|
||||
// admits none of the four targets is never attempted (the msan and
|
||||
// goexperiment trees).
|
||||
func TestRunCorpusAuditBuildConstraint(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
write := func(name, src string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(src), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write("x86.s", "//go:build 386 || amd64\n\nTEXT \xc2\xb7f(SB), NOSPLIT, $0\n\tRET\n")
|
||||
write("racey.s", "//go:build race\n\nTEXT \xc2\xb7r(SB), NOSPLIT, $0\n\tRET\n")
|
||||
write("plain.s", "TEXT \xc2\xb7p(SB), NOSPLIT, $0\n\tRET\n")
|
||||
|
||||
stats, err := runCorpusAudit(dir, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("runCorpusAudit: %v", err)
|
||||
}
|
||||
tally := func(name string) *corpusTally {
|
||||
for i, tg := range stats.targets {
|
||||
if tg.name == name {
|
||||
return stats.tallies[i]
|
||||
}
|
||||
}
|
||||
t.Fatalf("no tally for %s", name)
|
||||
return nil
|
||||
}
|
||||
if stats.narrowed != 1 || stats.excluded != 1 || stats.generic != 1 {
|
||||
t.Errorf("buckets = narrowed %d, excluded %d, generic %d; want 1, 1, 1", stats.narrowed, stats.excluded, stats.generic)
|
||||
}
|
||||
if a := tally("amd64"); a.attempted != 2 || a.assembled != 2 {
|
||||
t.Errorf("amd64 = %d/%d, want 2/2 (x86.s and plain.s)", a.assembled, a.attempted)
|
||||
}
|
||||
for _, name := range []string{"arm64", "riscv64", "loong64"} {
|
||||
if a := tally(name); a.attempted != 1 || a.assembled != 1 {
|
||||
t.Errorf("%s = %d/%d, want 1/1 (plain.s only)", name, a.assembled, a.attempted)
|
||||
}
|
||||
}
|
||||
if stats.full != 2 {
|
||||
t.Errorf("full = %d, want 2 (x86.s over its one target, plain.s over all four)", stats.full)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateGoAsmHeaderRuntime pins the generator against the real thing:
|
||||
// the runtime package of the ambient toolchain, whose header the toolchain's
|
||||
// own -asmhdr output was sampled from. Skipped in short mode: it type-checks
|
||||
|
||||
+122
-14
@@ -5,6 +5,7 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go/build/constraint"
|
||||
"maps"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -476,8 +477,10 @@ type corpusStats struct {
|
||||
root string
|
||||
files int
|
||||
generic int // files attempted for all four architectures
|
||||
narrowed int // files whose //go:build admits a proper subset of the four
|
||||
excluded int // files whose //go:build admits none of the four: never compiled
|
||||
otherPort int // files named for another Go port: never attempted
|
||||
full int // files that assembled for every target architecture
|
||||
full int // files that assembled for every applicable target architecture
|
||||
targets []corpusTarget
|
||||
tallies []*corpusTally
|
||||
}
|
||||
@@ -573,6 +576,57 @@ func otherGOOSFile(path string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// buildConstraint returns the file's leading //go:build expression, or nil
|
||||
// when the file carries none. The constraint governs the same header block
|
||||
// go/build reads: blank lines and comments may precede it, and the first
|
||||
// line that is neither ends the block. A constraint that does not parse
|
||||
// narrows nothing, so the file stays in the attempted set: the audit must
|
||||
// never exclude a file the toolchain would compile.
|
||||
func buildConstraint(src string) constraint.Expr {
|
||||
for line := range strings.SplitSeq(src, "\n") {
|
||||
t := strings.TrimSpace(line)
|
||||
switch {
|
||||
case t == "":
|
||||
continue
|
||||
case strings.HasPrefix(t, "//"):
|
||||
if constraint.IsGoBuild(t) {
|
||||
e, err := constraint.Parse(t)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return e
|
||||
}
|
||||
continue
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// unixOS is go/build's unixOS set: the GOOSes the unix build tag admits.
|
||||
var unixOS = map[string]bool{
|
||||
"aix": true, "android": true, "darwin": true, "dragonfly": true,
|
||||
"freebsd": true, "hurd": true, "illumos": true, "ios": true,
|
||||
"linux": true, "netbsd": true, "openbsd": true, "solaris": true,
|
||||
}
|
||||
|
||||
// constraintTags answers the build tags a plain `go build` sets for a
|
||||
// target: the GOOS and GOARCH, gc, and unix on the unix-like GOOSes. No
|
||||
// experiment, sanitiser or cgo tag is ever true: the audit models the
|
||||
// default build, and no GOROOT assembly file's constraint hinges on cgo.
|
||||
func constraintTags(goarch, goos string) func(string) bool {
|
||||
return func(tag string) bool {
|
||||
switch tag {
|
||||
case goarch, goos, "gc":
|
||||
return true
|
||||
case "unix":
|
||||
return unixOS[goos]
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func runCorpusAudit(root string, dirs includeDirs) (*corpusStats, error) {
|
||||
files, err := asmFiles(root)
|
||||
if err != nil {
|
||||
@@ -590,8 +644,8 @@ func runCorpusAudit(root string, dirs includeDirs) (*corpusStats, error) {
|
||||
tallies[i] = &corpusTally{reasons: map[string]int{}, example: map[string]string{}}
|
||||
}
|
||||
// full is the north-star number: a file counts when every architecture
|
||||
// its name allows assembles it.
|
||||
full, generic, otherPort := 0, 0, 0
|
||||
// its build admits assembles it.
|
||||
full, generic, otherPort, narrowedCount, excluded := 0, 0, 0, 0, 0
|
||||
|
||||
// Header generation is created on first use, so a corpus with no
|
||||
// go_asm.h includes never pays for a temp directory.
|
||||
@@ -614,28 +668,78 @@ func runCorpusAudit(root string, dirs includeDirs) (*corpusStats, error) {
|
||||
// invisible to a file-name rule).
|
||||
goos := goosFromFilename(path)
|
||||
|
||||
// The GOOS the header generation type-checks under follows the
|
||||
// file's name when the name carries one; the ambient GOOS is the
|
||||
// honest guess otherwise.
|
||||
namedArch := arch.FromFilename(path)
|
||||
var wanted []int // indexes into targets
|
||||
if a := arch.FromFilename(path); a != arch.Unknown {
|
||||
other := false
|
||||
switch {
|
||||
case namedArch != arch.Unknown:
|
||||
for i, tg := range targets {
|
||||
if tg.a == a {
|
||||
if tg.a == namedArch {
|
||||
wanted = append(wanted, i)
|
||||
}
|
||||
}
|
||||
} else if otherPortFile(path) {
|
||||
case otherPortFile(path):
|
||||
// A file named for a Go port gasm does not support (arm,
|
||||
// 386, s390x, ...) or for another GOOS is compiled by no
|
||||
// supported-arch build, so it is neither generic nor a
|
||||
// per-arch attempt: counting it as generic would make the
|
||||
// headline unreachably low for reasons no supported target
|
||||
// can fix.
|
||||
other = true
|
||||
otherPort++
|
||||
} else {
|
||||
generic++
|
||||
default:
|
||||
for i := range targets {
|
||||
wanted = append(wanted, i)
|
||||
}
|
||||
}
|
||||
|
||||
// A //go:build constraint narrows the set of targets the file is
|
||||
// assembled for, the way the go command compiles the file only for
|
||||
// the targets the expression admits: cpu_x86.s belongs to the x86
|
||||
// build alone, and a file whose constraint admits none of the four
|
||||
// targets (the goexperiment.runtimesecret and msan trees) is
|
||||
// compiled by no supported build. The tags mirror what a plain
|
||||
// `go build` sets: the GOOS and GOARCH, gc, and unix on the
|
||||
// unix-like GOOSes; no experiment, sanitiser or cgo tag is ever
|
||||
// true. The GOOS is the file's own when the name carries one,
|
||||
// else the ambient one.
|
||||
goosForEval := goos
|
||||
if goosForEval == "" {
|
||||
goosForEval = runtime.GOOS
|
||||
}
|
||||
narrowed := false
|
||||
if len(wanted) > 0 {
|
||||
if ce := buildConstraint(src); ce != nil {
|
||||
kept := make([]int, 0, len(wanted))
|
||||
for _, i := range wanted {
|
||||
tg := targets[i]
|
||||
if ce.Eval(constraintTags(goarchName(tg.a), goosForEval)) {
|
||||
kept = append(kept, i)
|
||||
}
|
||||
}
|
||||
if len(kept) < len(wanted) {
|
||||
narrowed = true
|
||||
}
|
||||
wanted = kept
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case other:
|
||||
// already tallied above
|
||||
case len(wanted) == 0:
|
||||
excluded++
|
||||
case namedArch != arch.Unknown:
|
||||
// a per-arch attempt over the constraint's subset
|
||||
case narrowed:
|
||||
narrowedCount++
|
||||
default:
|
||||
generic++
|
||||
}
|
||||
|
||||
// A file that includes go_asm.h parses against a per-target header:
|
||||
// the defines differ per architecture (internal/cpu's layout, for
|
||||
// one) and per GOOS (sys_darwin_arm64.s's trampoline constants,
|
||||
@@ -719,6 +823,8 @@ func runCorpusAudit(root string, dirs includeDirs) (*corpusStats, error) {
|
||||
root: root,
|
||||
files: len(files),
|
||||
generic: generic,
|
||||
narrowed: narrowedCount,
|
||||
excluded: excluded,
|
||||
otherPort: otherPort,
|
||||
full: full,
|
||||
targets: targets,
|
||||
@@ -728,13 +834,15 @@ func runCorpusAudit(root string, dirs includeDirs) (*corpusStats, error) {
|
||||
|
||||
// printCorpusStats renders the corpus audit report.
|
||||
func printCorpusStats(s *corpusStats, list bool) {
|
||||
fmt.Printf("corpus %s: %d files (%d generic, attempted for all architectures; %d named for other Go ports, never attempted)\n", s.root, s.files, s.generic, s.otherPort)
|
||||
fmt.Printf("corpus %s: %d files (%d generic, attempted for all architectures; %d narrowed by //go:build; %d excluded by //go:build; %d named for other Go ports, never attempted)\n",
|
||||
s.root, s.files, s.generic, s.narrowed, s.excluded, s.otherPort)
|
||||
// The rate is over the files a supported build would attempt: the
|
||||
// other ports' files sit in the count for completeness but can never
|
||||
// assemble, so counting them in the denominator would report the gap
|
||||
// of architectures gasm deliberately does not target.
|
||||
attemptable := max(s.files-s.otherPort, 1)
|
||||
fmt.Printf(" assemble for every target architecture: %d of %d attemptable (%.1f%%)\n", s.full, attemptable, 100*float64(s.full)/float64(attemptable))
|
||||
// other ports' files and the ones no supported target compiles sit in
|
||||
// the count for completeness but can never assemble, so counting them
|
||||
// in the denominator would report the gap of platforms gasm
|
||||
// deliberately does not target.
|
||||
attemptable := max(s.files-s.otherPort-s.excluded, 1)
|
||||
fmt.Printf(" assemble for every applicable target: %d of %d attemptable (%.1f%%)\n", s.full, attemptable, 100*float64(s.full)/float64(attemptable))
|
||||
for i, tg := range s.targets {
|
||||
t := s.tallies[i]
|
||||
fmt.Printf(" %s: %d/%d attempted\n", tg.name, t.assembled, t.attempted)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"sourcedock.dev/petrbalvin/gasm-devkit/arch"
|
||||
@@ -64,3 +65,62 @@ func TestGasmEncodable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildConstraint pins the //go:build reader: the constraint governs the
|
||||
// leading comment block, the first non-comment line ends it (a tag below a
|
||||
// #include governs nothing, exactly as go/build drops it), and a file
|
||||
// without one admits every target.
|
||||
func TestBuildConstraint(t *testing.T) {
|
||||
admits := func(src, goarch, goos string) bool {
|
||||
t.Helper()
|
||||
e := buildConstraint(src)
|
||||
if e == nil {
|
||||
return true
|
||||
}
|
||||
return e.Eval(constraintTags(goarch, goos))
|
||||
}
|
||||
const ret = "TEXT \xc2\xb7f(SB), NOSPLIT, $0\n\tRET\n"
|
||||
cases := []struct {
|
||||
name string
|
||||
src string
|
||||
amd64, arm64 bool
|
||||
}{
|
||||
{"no constraint", ret, true, true},
|
||||
{"x86 only", "//go:build 386 || amd64\n\n" + ret, true, false},
|
||||
{"arm64 and linux", "//go:build arm64 && linux\n\n" + ret, false, true},
|
||||
{"msan never", "//go:build msan\n\n" + ret, false, false},
|
||||
{"experiment never", "//go:build goexperiment.runtimesecret\n\n" + ret, false, false},
|
||||
{"below an include governs nothing", "#include \"textflag.h\"\n//go:build amd64\n" + ret, true, true},
|
||||
{"unparsable narrows nothing", "//go:build (amd64\n" + ret, true, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := admits(c.src, "amd64", runtime.GOOS); got != c.amd64 {
|
||||
t.Errorf("amd64 admission = %v, want %v", got, c.amd64)
|
||||
}
|
||||
if got := admits(c.src, "arm64", runtime.GOOS); got != c.arm64 {
|
||||
t.Errorf("arm64 admission = %v, want %v", got, c.arm64)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestConstraintTags pins the tag set a plain `go build` sets: the GOOS and
|
||||
// GOARCH, gc, unix on the unix-like GOOSes; nothing else is ever true.
|
||||
func TestConstraintTags(t *testing.T) {
|
||||
ok := constraintTags("amd64", "linux")
|
||||
for _, tag := range []string{"amd64", "linux", "gc", "unix"} {
|
||||
if !ok(tag) {
|
||||
t.Errorf("tag %q = false, want true", tag)
|
||||
}
|
||||
}
|
||||
for _, tag := range []string{"arm64", "freebsd", "darwin", "cgo", "race", "msan", "goexperiment.runtimesecret"} {
|
||||
if ok(tag) {
|
||||
t.Errorf("tag %q = true, want false", tag)
|
||||
}
|
||||
}
|
||||
fb := constraintTags("arm64", "freebsd")
|
||||
if !fb("unix") {
|
||||
t.Error("unix on freebsd = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,11 +246,18 @@ document store, republishes diagnostics on every change, and provides:
|
||||
pseudo-registers, labels, immediates, comments, directives, textflag macros);
|
||||
- **navigation**: go-to-definition from a label reference to its definition,
|
||||
find references, document highlights of every use of the symbol under the
|
||||
cursor, rename, and workspace symbol search over the open documents;
|
||||
cursor, rename, and workspace symbol search over the open documents and
|
||||
the indexed workspace files: the `.s` files under the workspace root that
|
||||
the editor has never opened, where an open buffer shadows its disk copy
|
||||
and watched-file events plus a per-query freshness check keep the index
|
||||
current;
|
||||
- **assists**: document formatting through the `format` package, inlay hints
|
||||
(the frame size after the TEXT argument area), signature help (the callee's
|
||||
`// func` signature while the cursor is on a `CALL`), and code actions
|
||||
offering quick fixes for the `missing-ret` and `unused-label` diagnostics.
|
||||
offering quick fixes for the `missing-ret` and `unused-label` diagnostics,
|
||||
the `missing-textflag-include` warning (the include after the last one in
|
||||
the file) and the `abi-argsize` warning (the argument area set to the size
|
||||
the `// func` signature implies).
|
||||
- **document information**: pull diagnostics (`textDocument/diagnostic`),
|
||||
#include document links (resolved against the document directory, then
|
||||
`$GOROOT/pkg/include`) and folding ranges (one collapsible region per
|
||||
|
||||
+7
-1
@@ -455,7 +455,13 @@ semantic tokens, go-to-definition, find references, rename, document
|
||||
formatting, inlay hints, code actions, signature help, document highlights,
|
||||
workspace symbol search, #include document links, and folding ranges for
|
||||
function bodies. Definition, references and rename work across every open
|
||||
document.
|
||||
document and the wider workspace on disk: the server indexes the `.s` files
|
||||
under the workspace root that the editor has never opened, an open buffer
|
||||
always shadows its disk copy, and watched-file events together with a
|
||||
per-query freshness check keep the index current. The quick fixes add the
|
||||
missing `#include "textflag.h"`, set the TEXT argument area to the size the
|
||||
`// func` signature implies, add a missing `RET`, and remove an unused
|
||||
label.
|
||||
|
||||
## version
|
||||
|
||||
|
||||
@@ -11,6 +11,15 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ExpectedArgSize computes the argument-area size (parameters plus results,
|
||||
// laid out with Go's alignment rules on a 64-bit target) implied by the
|
||||
// `// func …` signature in a doc comment. It returns ok=false when there is
|
||||
// no parseable signature or it uses a type whose size cannot be determined
|
||||
// (a named type), so the caller can skip the fix rather than guess.
|
||||
func ExpectedArgSize(doc string) (int64, bool) {
|
||||
return abiExpectedArgSize(doc)
|
||||
}
|
||||
|
||||
// abiExpectedArgSize computes the argument-area size (parameters plus results,
|
||||
// laid out with Go's alignment rules on a 64-bit target) from the `// func …`
|
||||
// signature in a TEXT function's doc comment. It returns ok=false when there
|
||||
|
||||
+87
-33
@@ -11,6 +11,7 @@ import (
|
||||
"regexp"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf16"
|
||||
@@ -127,8 +128,8 @@ func (s *Server) openASTs() []openAST {
|
||||
|
||||
// definition returns the location of the named label or function: a local
|
||||
// label in the current document wins, then the TEXT functions of every open
|
||||
// document are searched, so a `CALL ·helper(SB)` jumps to its definition in
|
||||
// another file.
|
||||
// document and indexed workspace file are searched, so a `CALL ·helper(SB)`
|
||||
// jumps to its definition even when the defining file was never opened.
|
||||
func (s *Server) definition(p definitionParams) []Location {
|
||||
text := s.docs[p.TextDocument.URI]
|
||||
word, _ := wordAt(text, p.Position)
|
||||
@@ -156,15 +157,15 @@ func (s *Server) definition(p definitionParams) []Location {
|
||||
}
|
||||
}
|
||||
|
||||
// Function definitions across the open workspace.
|
||||
for _, of := range s.openASTs() {
|
||||
for _, d := range of.file.Decls {
|
||||
// Function definitions across the workspace.
|
||||
for _, u := range s.scanUnits() {
|
||||
for _, d := range u.file.Decls {
|
||||
t, ok := d.(*ast.Text)
|
||||
if !ok || !realSymbol(t.Name) {
|
||||
continue
|
||||
}
|
||||
if t.Name.Name == name {
|
||||
return []Location{{URI: of.uri, Range: clientRange(s.docs[of.uri], symRange(t.Name))}}
|
||||
return []Location{{URI: u.uri, Range: clientRange(u.text, symRange(t.Name))}}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -172,10 +173,11 @@ func (s *Server) definition(p definitionParams) []Location {
|
||||
}
|
||||
|
||||
// references returns all locations where the symbol under the cursor appears
|
||||
// across every open document. The current document matches labels and any
|
||||
// operand name, as before; other documents only match SB-qualified operand
|
||||
// references and the definition itself, because a bare name is a
|
||||
// function-local label whose repeats in other files are unrelated.
|
||||
// across every open document and indexed workspace file. The current
|
||||
// document matches labels and any operand name, as before; every other
|
||||
// source only matches SB-qualified operand references and the definition
|
||||
// itself, because a bare name is a function-local label whose repeats in
|
||||
// other files are unrelated.
|
||||
func (s *Server) references(p referenceParams) []Location {
|
||||
text := s.docs[p.TextDocument.URI]
|
||||
word, _ := wordAt(text, p.Position)
|
||||
@@ -186,22 +188,22 @@ func (s *Server) references(p referenceParams) []Location {
|
||||
uri := p.TextDocument.URI
|
||||
|
||||
var out []Location
|
||||
for _, of := range s.openASTs() {
|
||||
sameDoc := of.uri == uri
|
||||
for _, d := range of.file.Decls {
|
||||
for _, u := range s.scanUnits() {
|
||||
sameDoc := u.uri == uri
|
||||
for _, d := range u.file.Decls {
|
||||
t, ok := d.(*ast.Text)
|
||||
if !ok || !realSymbol(t.Name) {
|
||||
continue
|
||||
}
|
||||
// Include the definition if requested.
|
||||
if p.Context.IncludeDeclaration && t.Name.Name == name {
|
||||
out = append(out, Location{URI: of.uri, Range: clientRange(s.docs[of.uri], symRange(t.Name))})
|
||||
out = append(out, Location{URI: u.uri, Range: clientRange(u.text, symRange(t.Name))})
|
||||
}
|
||||
for _, stmt := range t.Body {
|
||||
switch st := stmt.(type) {
|
||||
case *ast.Label:
|
||||
if sameDoc && st.Name.Text == name {
|
||||
out = append(out, Location{URI: of.uri, Range: clientRange(s.docs[of.uri], tokenRange(st.Name))})
|
||||
out = append(out, Location{URI: u.uri, Range: clientRange(u.text, tokenRange(st.Name))})
|
||||
}
|
||||
case *ast.Instr:
|
||||
for _, op := range st.Operands {
|
||||
@@ -215,8 +217,8 @@ func (s *Server) references(p referenceParams) []Location {
|
||||
// identifier, `·`/package prefix included, so a
|
||||
// rename replaces the whole spelling.
|
||||
out = append(out, Location{
|
||||
URI: of.uri,
|
||||
Range: clientRange(s.docs[of.uri], Range{
|
||||
URI: u.uri,
|
||||
Range: clientRange(u.text, Range{
|
||||
Start: Position{Line: op.Pos.Line - 1, Character: op.Pos.Column - 1},
|
||||
End: Position{Line: op.Pos.Line - 1, Character: op.Pos.Column - 1 + symIdentLen(op.Addr.Sym)},
|
||||
}),
|
||||
@@ -229,7 +231,9 @@ func (s *Server) references(p referenceParams) []Location {
|
||||
return out
|
||||
}
|
||||
|
||||
// rename renames a label across the document.
|
||||
// rename renames a label across every document the references reach. Each
|
||||
// edit is grouped under the URI it belongs to: a range collected in another
|
||||
// file must never be applied to the document that started the rename.
|
||||
func (s *Server) rename(p renameParams) *WorkspaceEdit {
|
||||
text := s.docs[p.TextDocument.URI]
|
||||
word, _ := wordAt(text, p.Position)
|
||||
@@ -244,13 +248,11 @@ func (s *Server) rename(p renameParams) *WorkspaceEdit {
|
||||
if len(refs) == 0 {
|
||||
return nil
|
||||
}
|
||||
edits := make([]TextEdit, 0, len(refs))
|
||||
edits := make(map[string][]TextEdit, len(refs))
|
||||
for _, loc := range refs {
|
||||
edits = append(edits, TextEdit{Range: loc.Range, NewText: p.NewName})
|
||||
}
|
||||
return &WorkspaceEdit{
|
||||
Changes: map[string][]TextEdit{p.TextDocument.URI: edits},
|
||||
edits[loc.URI] = append(edits[loc.URI], TextEdit{Range: loc.Range, NewText: p.NewName})
|
||||
}
|
||||
return &WorkspaceEdit{Changes: edits}
|
||||
}
|
||||
|
||||
// documentFormatting returns a single edit that replaces the whole document
|
||||
@@ -385,6 +387,61 @@ func (s *Server) codeActions(p codeActionParams) []CodeAction {
|
||||
}
|
||||
}
|
||||
}
|
||||
case "missing-textflag-include":
|
||||
// Offer to #include the header that defines the flag macros:
|
||||
// after the last existing include, or at the top of the file
|
||||
// when there is none.
|
||||
insertLine := 0
|
||||
lines := strings.Split(text, "\n")
|
||||
for i, line := range lines {
|
||||
if includeRe.MatchString(line) {
|
||||
insertLine = i + 1
|
||||
}
|
||||
}
|
||||
actions = append(actions, CodeAction{
|
||||
Title: `Add #include "textflag.h"`,
|
||||
Kind: "quickfix",
|
||||
Edit: &WorkspaceEdit{
|
||||
Changes: map[string][]TextEdit{p.TextDocument.URI: {{
|
||||
Range: Range{Start: Position{Line: insertLine, Character: 0}, End: Position{Line: insertLine, Character: 0}},
|
||||
NewText: "#include \"textflag.h\"\n",
|
||||
}}},
|
||||
},
|
||||
})
|
||||
case "abi-argsize":
|
||||
// Offer to set the TEXT argument area to the size the // func
|
||||
// signature implies. The diagnostic's range covers the TEXT
|
||||
// keyword, so a line match picks the function it belongs to.
|
||||
f, _ := parser.Parse(uriPath(p.TextDocument.URI), text)
|
||||
if f == nil {
|
||||
continue
|
||||
}
|
||||
for _, d := range f.Decls {
|
||||
t, ok := d.(*ast.Text)
|
||||
if !ok || t.Keyword.Pos.Line-1 != int(diag.Range.Start.Line) {
|
||||
continue
|
||||
}
|
||||
want, ok := lint.ExpectedArgSize(t.Doc)
|
||||
if !ok || t.Args == nil || !t.Args.Imm.HasVal || t.Args.Imm.Val == want {
|
||||
continue
|
||||
}
|
||||
// The parser records the argument area with its leading
|
||||
// minus, so the replacement spans the whole `-16` shape.
|
||||
rng := clientRange(text, Range{
|
||||
Start: Position{Line: t.Args.Pos.Line - 1, Character: t.Args.Pos.Column - 1},
|
||||
End: Position{Line: t.Args.Pos.Line - 1, Character: t.Args.Pos.Column - 1 + runeLen(t.Args.Raw)},
|
||||
})
|
||||
actions = append(actions, CodeAction{
|
||||
Title: fmt.Sprintf("Set arg size to %d", want),
|
||||
Kind: "quickfix",
|
||||
Edit: &WorkspaceEdit{
|
||||
Changes: map[string][]TextEdit{p.TextDocument.URI: {{
|
||||
Range: rng,
|
||||
NewText: "-" + strconv.FormatInt(want, 10),
|
||||
}}},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return actions
|
||||
@@ -510,19 +567,16 @@ func (s *Server) documentHighlights(p documentHighlightParams) []DocumentHighlig
|
||||
return out
|
||||
}
|
||||
|
||||
// workspaceSymbols searches all open documents for symbols matching the query.
|
||||
// workspaceSymbols searches all open documents and indexed workspace files
|
||||
// for symbols matching the query.
|
||||
func (s *Server) workspaceSymbols(p workspaceSymbolParams) []WorkspaceSymbol {
|
||||
if p.Query == "" {
|
||||
return nil
|
||||
}
|
||||
query := strings.ToLower(p.Query)
|
||||
var out []WorkspaceSymbol
|
||||
for uri, text := range s.docs {
|
||||
f, _ := parser.Parse(uriPath(uri), text)
|
||||
if f == nil {
|
||||
continue
|
||||
}
|
||||
for _, d := range f.Decls {
|
||||
for _, u := range s.scanUnits() {
|
||||
for _, d := range u.file.Decls {
|
||||
switch dd := d.(type) {
|
||||
case *ast.Text:
|
||||
// A malformed TEXT line is kept in the tree under a "?"
|
||||
@@ -535,7 +589,7 @@ func (s *Server) workspaceSymbols(p workspaceSymbolParams) []WorkspaceSymbol {
|
||||
out = append(out, WorkspaceSymbol{
|
||||
Name: dd.Name.Name,
|
||||
Kind: symFunction,
|
||||
Location: Location{URI: uri, Range: clientRange(text, symRange(dd.Name))},
|
||||
Location: Location{URI: u.uri, Range: clientRange(u.text, symRange(dd.Name))},
|
||||
})
|
||||
}
|
||||
case *ast.Globl:
|
||||
@@ -543,7 +597,7 @@ func (s *Server) workspaceSymbols(p workspaceSymbolParams) []WorkspaceSymbol {
|
||||
out = append(out, WorkspaceSymbol{
|
||||
Name: dd.Name.Name,
|
||||
Kind: symConstant,
|
||||
Location: Location{URI: uri, Range: clientRange(text, symRange(dd.Name))},
|
||||
Location: Location{URI: u.uri, Range: clientRange(u.text, symRange(dd.Name))},
|
||||
})
|
||||
}
|
||||
case *ast.Data:
|
||||
@@ -551,7 +605,7 @@ func (s *Server) workspaceSymbols(p workspaceSymbolParams) []WorkspaceSymbol {
|
||||
out = append(out, WorkspaceSymbol{
|
||||
Name: dd.Name.Name,
|
||||
Kind: symConstant,
|
||||
Location: Location{URI: uri, Range: clientRange(text, symRange(dd.Name))},
|
||||
Location: Location{URI: u.uri, Range: clientRange(u.text, symRange(dd.Name))},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,6 +309,56 @@ type ServerCapabilities struct {
|
||||
DiagnosticProvider map[string]any `json:"diagnosticProvider,omitempty"`
|
||||
DocumentLinkProvider map[string]any `json:"documentLinkProvider,omitempty"`
|
||||
FoldingRangeProvider bool `json:"foldingRangeProvider,omitempty"`
|
||||
Workspace map[string]any `json:"workspace,omitempty"`
|
||||
}
|
||||
|
||||
// --- workspace ----------------------------------------------------------------
|
||||
|
||||
// initializeParams carries the workspace roots the client announces.
|
||||
type initializeParams struct {
|
||||
RootURI string `json:"rootUri"`
|
||||
WorkspaceFolders []workspaceFolder `json:"workspaceFolders"`
|
||||
}
|
||||
|
||||
// workspaceFolder is one root folder the client opened.
|
||||
type workspaceFolder struct {
|
||||
URI string `json:"uri"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// roots returns the workspace roots as filesystem paths: the workspace
|
||||
// folders when the client sends them, else the root URI.
|
||||
func (p initializeParams) roots() []string {
|
||||
var out []string
|
||||
for _, f := range p.WorkspaceFolders {
|
||||
if path := uriPath(f.URI); path != "" {
|
||||
out = append(out, path)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 && p.RootURI != "" {
|
||||
if path := uriPath(p.RootURI); path != "" {
|
||||
out = append(out, path)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// didChangeWatchedFilesParams announces on-disk changes the client watched.
|
||||
type didChangeWatchedFilesParams struct {
|
||||
Changes []fileEvent `json:"changes"`
|
||||
}
|
||||
|
||||
// File change types (LSP FileChangeType).
|
||||
const (
|
||||
fileCreated = 1
|
||||
fileChanged = 2
|
||||
fileDeleted = 3
|
||||
)
|
||||
|
||||
// fileEvent is one created, changed or deleted file.
|
||||
type fileEvent struct {
|
||||
URI string `json:"uri"`
|
||||
Type int `json:"type"`
|
||||
}
|
||||
|
||||
// --- pull diagnostics ---------------------------------------------------------
|
||||
|
||||
+16
-1
@@ -24,7 +24,8 @@ type Server struct {
|
||||
out io.Writer
|
||||
mu sync.Mutex // guards writes to out
|
||||
docs map[string]string
|
||||
version string // reported in the initialize result ("" omits it)
|
||||
ws workspaceIndex // the on-disk workspace beyond the open documents
|
||||
version string // reported in the initialize result ("" omits it)
|
||||
}
|
||||
|
||||
// New returns a server reading from in and writing to out.
|
||||
@@ -169,6 +170,11 @@ func (s *Server) dispatch(msg *rpcMessage) (exit bool) {
|
||||
|
||||
switch msg.Method {
|
||||
case "initialize":
|
||||
var ip initializeParams
|
||||
if len(msg.Params) > 0 {
|
||||
json.Unmarshal(msg.Params, &ip)
|
||||
}
|
||||
s.ws.roots = ip.roots()
|
||||
info := map[string]string{"name": "gasm"}
|
||||
if s.version != "" {
|
||||
info["version"] = s.version
|
||||
@@ -198,6 +204,7 @@ func (s *Server) dispatch(msg *rpcMessage) (exit bool) {
|
||||
DiagnosticProvider: map[string]any{"interFileDependencies": false, "workspaceDiagnostics": false},
|
||||
DocumentLinkProvider: map[string]any{},
|
||||
FoldingRangeProvider: true,
|
||||
Workspace: map[string]any{"didChangeWatchedFiles": map[string]any{}},
|
||||
},
|
||||
ServerInfo: info,
|
||||
})
|
||||
@@ -308,6 +315,14 @@ func (s *Server) dispatch(msg *rpcMessage) (exit bool) {
|
||||
json.Unmarshal(msg.Params, &p)
|
||||
s.respond(msg.ID, s.workspaceSymbols(p))
|
||||
|
||||
case "workspace/didChangeWatchedFiles":
|
||||
var p didChangeWatchedFilesParams
|
||||
if json.Unmarshal(msg.Params, &p) == nil {
|
||||
for _, ch := range p.Changes {
|
||||
s.ws.watched(uriPath(ch.URI), ch.Type == fileDeleted)
|
||||
}
|
||||
}
|
||||
|
||||
case "shutdown":
|
||||
s.respond(msg.ID, nil)
|
||||
|
||||
|
||||
@@ -1014,6 +1014,134 @@ func TestCodeActionsTargetsFlaggedFunctionOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCodeActionInsertsTextflagInclude offers the include a file must carry
|
||||
// when it uses flag macros: at the top of the file when there is no include
|
||||
// yet, after the last one otherwise.
|
||||
func TestCodeActionInsertsTextflagInclude(t *testing.T) {
|
||||
doc := "TEXT \u00b7f(SB), NOSPLIT, $0\n\tRET\n"
|
||||
withInclude := "#include \"go_asm.h\"\n\nTEXT \u00b7f(SB), NOSPLIT, $0\n\tRET\n"
|
||||
in := session("file:///f_amd64.s", doc) +
|
||||
frame(30, "textDocument/codeAction", map[string]any{
|
||||
"textDocument": map[string]any{"uri": "file:///f_amd64.s"},
|
||||
"range": map[string]any{"start": map[string]any{"line": 0, "character": 0}, "end": map[string]any{"line": 1, "character": 0}},
|
||||
"context": map[string]any{"diagnostics": []map[string]any{
|
||||
{"code": "missing-textflag-include", "range": map[string]any{"start": map[string]any{"line": 0, "character": 0}, "end": map[string]any{"line": 0, "character": 4}}},
|
||||
}},
|
||||
}) +
|
||||
frame(nil, "textDocument/didChange", map[string]any{
|
||||
"textDocument": map[string]any{"uri": "file:///f_amd64.s", "version": 2},
|
||||
"contentChanges": []map[string]any{{"text": withInclude}},
|
||||
}) +
|
||||
frame(31, "textDocument/codeAction", map[string]any{
|
||||
"textDocument": map[string]any{"uri": "file:///f_amd64.s"},
|
||||
"range": map[string]any{"start": map[string]any{"line": 0, "character": 0}, "end": map[string]any{"line": 3, "character": 0}},
|
||||
"context": map[string]any{"diagnostics": []map[string]any{
|
||||
{"code": "missing-textflag-include", "range": map[string]any{"start": map[string]any{"line": 2, "character": 0}, "end": map[string]any{"line": 2, "character": 4}}},
|
||||
}},
|
||||
}) +
|
||||
frame(nil, "exit", nil)
|
||||
msgs := run(t, in)
|
||||
|
||||
resp := findByID(msgs, 30)
|
||||
if resp == nil {
|
||||
t.Fatal("no codeAction response")
|
||||
}
|
||||
var actions []CodeAction
|
||||
if err := json.Unmarshal(mustResult(t, resp), &actions); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(actions) != 1 {
|
||||
t.Fatalf("actions = %d, want 1", len(actions))
|
||||
}
|
||||
edits := actions[0].Edit.Changes["file:///f_amd64.s"]
|
||||
if len(edits) != 1 || edits[0].Range.Start.Line != 0 || edits[0].Range.Start.Character != 0 {
|
||||
t.Fatalf("edit = %+v, want an insertion at line 0 character 0", edits)
|
||||
}
|
||||
if got := applyEdits(doc, edits); !strings.HasPrefix(got, "#include \"textflag.h\"\nTEXT \u00b7f") {
|
||||
t.Errorf("edited document = %q, want the include as the first line", got)
|
||||
}
|
||||
|
||||
resp = findByID(msgs, 31)
|
||||
if resp == nil {
|
||||
t.Fatal("no codeAction response for the with-include variant")
|
||||
}
|
||||
actions = nil
|
||||
if err := json.Unmarshal(mustResult(t, resp), &actions); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(actions) != 1 || actions[0].Edit.Changes["file:///f_amd64.s"][0].Range.Start.Line != 1 {
|
||||
t.Fatalf("actions = %+v, want one insertion after the last include (line 1)", actions)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCodeActionFixesArgSize offers the argument area the // func signature
|
||||
// implies; without a parseable signature there is nothing to offer.
|
||||
func TestCodeActionFixesArgSize(t *testing.T) {
|
||||
doc := "// func Add(x int64) int64\n" +
|
||||
"TEXT \u00b7Add(SB), NOSPLIT, $0-8\n" +
|
||||
"\tMOVQ x+0(FP), AX\n" +
|
||||
"\tADDQ y+8(FP), AX\n" +
|
||||
"\tMOVQ AX, ret+16(FP)\n" +
|
||||
"\tRET\n"
|
||||
bare := "TEXT \u00b7Bare(SB), NOSPLIT, $0-8\n\tRET\n"
|
||||
in := session("file:///f_amd64.s", doc) +
|
||||
frame(32, "textDocument/codeAction", map[string]any{
|
||||
"textDocument": map[string]any{"uri": "file:///f_amd64.s"},
|
||||
"range": map[string]any{"start": map[string]any{"line": 1, "character": 0}, "end": map[string]any{"line": 1, "character": 4}},
|
||||
"context": map[string]any{"diagnostics": []map[string]any{
|
||||
{"code": "abi-argsize", "range": map[string]any{"start": map[string]any{"line": 1, "character": 0}, "end": map[string]any{"line": 1, "character": 4}}},
|
||||
}},
|
||||
}) +
|
||||
frame(nil, "textDocument/didChange", map[string]any{
|
||||
"textDocument": map[string]any{"uri": "file:///f_amd64.s", "version": 2},
|
||||
"contentChanges": []map[string]any{{"text": bare}},
|
||||
}) +
|
||||
frame(33, "textDocument/codeAction", map[string]any{
|
||||
"textDocument": map[string]any{"uri": "file:///f_amd64.s"},
|
||||
"range": map[string]any{"start": map[string]any{"line": 0, "character": 0}, "end": map[string]any{"line": 0, "character": 4}},
|
||||
"context": map[string]any{"diagnostics": []map[string]any{
|
||||
{"code": "abi-argsize", "range": map[string]any{"start": map[string]any{"line": 0, "character": 0}, "end": map[string]any{"line": 0, "character": 4}}},
|
||||
}},
|
||||
}) +
|
||||
frame(nil, "exit", nil)
|
||||
msgs := run(t, in)
|
||||
|
||||
resp := findByID(msgs, 32)
|
||||
if resp == nil {
|
||||
t.Fatal("no codeAction response")
|
||||
}
|
||||
var actions []CodeAction
|
||||
if err := json.Unmarshal(mustResult(t, resp), &actions); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(actions) != 1 {
|
||||
t.Fatalf("actions = %d, want 1", len(actions))
|
||||
}
|
||||
if actions[0].Title != "Set arg size to 16" {
|
||||
t.Errorf("action title = %q, want Set arg size to 16", actions[0].Title)
|
||||
}
|
||||
edits := actions[0].Edit.Changes["file:///f_amd64.s"]
|
||||
if len(edits) != 1 || edits[0].NewText != "-16" || edits[0].Range.Start.Line != 1 {
|
||||
t.Fatalf("edit = %+v, want -16 on line 1", edits)
|
||||
}
|
||||
if got := applyEdits(doc, edits); !strings.Contains(got, "$0-16") {
|
||||
t.Errorf("edited document = %q, want $0-16", got)
|
||||
}
|
||||
|
||||
// No // func signature, no offer: the expected size cannot be computed.
|
||||
resp = findByID(msgs, 33)
|
||||
if resp == nil {
|
||||
t.Fatal("no codeAction response for the bare variant")
|
||||
}
|
||||
actions = nil
|
||||
if err := json.Unmarshal(mustResult(t, resp), &actions); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(actions) != 0 {
|
||||
t.Fatalf("actions = %+v, want none without a // func signature", actions)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossFileDefinitionAndReferences opens two documents: docA calls
|
||||
// ·helper(SB), docB defines it. Definition must jump to docB and references
|
||||
// must collect the call site in docA plus the definition in docB.
|
||||
@@ -1074,3 +1202,49 @@ func TestCrossFileDefinitionAndReferences(t *testing.T) {
|
||||
t.Errorf("references by uri = %v, want one in each file", byURI)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenameGroupsEditsByDocument pins the grouping contract: every edit is
|
||||
// applied to the document it was collected in, so a cross-file rename never
|
||||
// corrupts the origin buffer.
|
||||
func TestRenameGroupsEditsByDocument(t *testing.T) {
|
||||
docA := "TEXT \u00b7caller(SB), NOSPLIT, $0\n" +
|
||||
"\tCALL \u00b7helper(SB)\n" +
|
||||
"\tRET\n"
|
||||
docB := "TEXT \u00b7helper(SB), NOSPLIT, $0\n" +
|
||||
"\tRET\n"
|
||||
uriA := "file:///rename_a_amd64.s"
|
||||
uriB := "file:///rename_b_amd64.s"
|
||||
in := frame(1, "initialize", map[string]any{}) +
|
||||
frame(nil, "initialized", map[string]any{}) +
|
||||
frame(nil, "textDocument/didOpen", map[string]any{
|
||||
"textDocument": map[string]any{"uri": uriA, "languageId": "gasm", "version": 1, "text": docA},
|
||||
}) +
|
||||
frame(nil, "textDocument/didOpen", map[string]any{
|
||||
"textDocument": map[string]any{"uri": uriB, "languageId": "gasm", "version": 1, "text": docB},
|
||||
}) +
|
||||
frame(2, "textDocument/rename", map[string]any{
|
||||
"textDocument": map[string]any{"uri": uriA},
|
||||
"position": map[string]any{"line": 1, "character": 8}, // on helper in CALL ·helper(SB)
|
||||
"newName": "renamed",
|
||||
}) + frame(nil, "exit", nil)
|
||||
msgs := run(t, in)
|
||||
resp := findByID(msgs, 2)
|
||||
if resp == nil {
|
||||
t.Fatal("no rename response")
|
||||
}
|
||||
var edit WorkspaceEdit
|
||||
if err := json.Unmarshal(mustResult(t, resp), &edit); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(edit.Changes[uriA]) != 1 || len(edit.Changes[uriB]) != 1 {
|
||||
t.Fatalf("edits per uri = %d and %d, want one each", len(edit.Changes[uriA]), len(edit.Changes[uriB]))
|
||||
}
|
||||
gotA := applyEdits(docA, edit.Changes[uriA])
|
||||
gotB := applyEdits(docB, edit.Changes[uriB])
|
||||
if !strings.Contains(gotA, "CALL renamed(SB)") || strings.Contains(gotA, "renamed(SB), NOSPLIT") {
|
||||
t.Errorf("caller document rename wrong: %q", gotA)
|
||||
}
|
||||
if !strings.Contains(gotB, "TEXT renamed(SB)") {
|
||||
t.Errorf("helper document rename wrong: %q", gotB)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package lsp
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"io/fs"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sourcedock.dev/petrbalvin/gasm-devkit/ast"
|
||||
"sourcedock.dev/petrbalvin/gasm-devkit/parser"
|
||||
)
|
||||
|
||||
// workspaceIndex indexes the .s files under the workspace roots that the
|
||||
// client has not opened. Every workspace-scoped answer (definition,
|
||||
// references, workspace symbols) reads it after the open documents, so a
|
||||
// symbol resolves to and collects references from files the editor has never
|
||||
// shown. An open buffer always shadows its disk copy: the entry stays in the
|
||||
// index but is skipped at query time, because the buffer may hold unsaved
|
||||
// edits the disk has never seen.
|
||||
//
|
||||
// Freshness does not rest on the client's watched-file events alone: every
|
||||
// query re-stats the indexed files and re-reads the ones whose stat identity
|
||||
// changed. Watched-file events remain necessary for files created after the
|
||||
// build, which a stat of the known set cannot discover. The index builds on
|
||||
// first use, since the roots arrive with initialize. The server serves one
|
||||
// message at a time, so the index needs no lock.
|
||||
type workspaceIndex struct {
|
||||
roots []string
|
||||
built bool
|
||||
files map[string]*workspaceFile // keyed by absolute path
|
||||
}
|
||||
|
||||
// workspaceFile is one indexed on-disk file: its content, the parsed tree and
|
||||
// the stat identity the freshness check compares.
|
||||
type workspaceFile struct {
|
||||
text string
|
||||
modTime time.Time
|
||||
size int64
|
||||
file *ast.File
|
||||
}
|
||||
|
||||
// ensure builds the index on first use and refreshes it on every query
|
||||
// afterwards: a stat per file, a re-read only for the ones that changed.
|
||||
func (w *workspaceIndex) ensure() {
|
||||
if !w.built {
|
||||
w.built = true
|
||||
w.files = make(map[string]*workspaceFile)
|
||||
for _, root := range w.roots {
|
||||
w.walk(root)
|
||||
}
|
||||
return
|
||||
}
|
||||
for path := range w.files {
|
||||
w.load(path)
|
||||
}
|
||||
}
|
||||
|
||||
// walk adds every .s file below root, skipping the directories the go tool
|
||||
// itself ignores (hidden and underscore-prefixed names, testdata): the index
|
||||
// covers the buildable surface, not the whole tree.
|
||||
func (w *workspaceIndex) walk(root string) {
|
||||
filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil // an unreadable entry never breaks the scan
|
||||
}
|
||||
if d.IsDir() {
|
||||
if path != root && skipDir(d.Name()) {
|
||||
return fs.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if strings.HasSuffix(path, ".s") {
|
||||
w.load(path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// skipDir reports whether a directory is outside the buildable surface.
|
||||
func skipDir(name string) bool {
|
||||
return name == "testdata" || strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_")
|
||||
}
|
||||
|
||||
// load reads one path into the index, or evicts it when it has disappeared.
|
||||
// A file whose stat identity still matches is left untouched.
|
||||
func (w *workspaceIndex) load(path string) {
|
||||
st, err := os.Stat(path)
|
||||
if err != nil || st.IsDir() {
|
||||
delete(w.files, path)
|
||||
return
|
||||
}
|
||||
if ef, ok := w.files[path]; ok && ef.size == st.Size() && ef.modTime.Equal(st.ModTime()) {
|
||||
return
|
||||
}
|
||||
text, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
delete(w.files, path)
|
||||
return
|
||||
}
|
||||
// Parsing is tolerant: a file with syntax errors still contributes its
|
||||
// usable declarations, exactly as an open buffer mid-edit does.
|
||||
body := string(text)
|
||||
f, _ := parser.Parse(path, body)
|
||||
w.files[path] = &workspaceFile{text: body, modTime: st.ModTime(), size: st.Size(), file: f}
|
||||
}
|
||||
|
||||
// watched applies one client watched-file event: the named file is re-read
|
||||
// (created or changed) or evicted (deleted). Events for anything but .s
|
||||
// files are ignored, and an event arriving before the first build no-ops:
|
||||
// the build then reads the disk state that already includes it.
|
||||
func (w *workspaceIndex) watched(path string, deleted bool) {
|
||||
if !w.built || !strings.HasSuffix(path, ".s") {
|
||||
return
|
||||
}
|
||||
if deleted {
|
||||
delete(w.files, path)
|
||||
return
|
||||
}
|
||||
w.load(path)
|
||||
}
|
||||
|
||||
// scanUnit is one parseable source the workspace answers draw from: an open
|
||||
// buffer or an indexed disk file.
|
||||
type scanUnit struct {
|
||||
uri string
|
||||
text string
|
||||
file *ast.File
|
||||
}
|
||||
|
||||
// scanUnits returns every source the workspace answers cover, in URI order
|
||||
// for deterministic results: the open documents, then the indexed disk files
|
||||
// that no open buffer shadows.
|
||||
func (s *Server) scanUnits() []scanUnit {
|
||||
open := make(map[string]bool, len(s.docs))
|
||||
for uri := range s.docs {
|
||||
open[uriPath(uri)] = true
|
||||
}
|
||||
units := make([]scanUnit, 0, len(s.docs))
|
||||
for _, of := range s.openASTs() {
|
||||
units = append(units, scanUnit{uri: of.uri, text: s.docs[of.uri], file: of.file})
|
||||
}
|
||||
s.ws.ensure()
|
||||
for path, ef := range s.ws.files {
|
||||
if ef.file == nil || open[path] {
|
||||
continue
|
||||
}
|
||||
units = append(units, scanUnit{uri: fileToURI(path), text: ef.text, file: ef.file})
|
||||
}
|
||||
slices.SortFunc(units, func(a, b scanUnit) int { return cmp.Compare(a.uri, b.uri) })
|
||||
return units
|
||||
}
|
||||
|
||||
// fileToURI renders an absolute path as a file URI, percent-encoding the
|
||||
// characters the URI rules reserve.
|
||||
func fileToURI(path string) string {
|
||||
return (&url.URL{Scheme: "file", Path: path}).String()
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package lsp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// diskWorkspace writes one helper file on disk and returns the workspace root
|
||||
// and the caller document that references ·helper and ·late by symbol.
|
||||
func diskWorkspace(t *testing.T) (root, callerURI string, caller string) {
|
||||
t.Helper()
|
||||
root = t.TempDir()
|
||||
helper := "TEXT \u00b7helper(SB), NOSPLIT, $0\n\tRET\n"
|
||||
if err := os.WriteFile(filepath.Join(root, "helper_amd64.s"), []byte(helper), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
caller = "TEXT \u00b7caller(SB), NOSPLIT, $0\n" +
|
||||
"\tCALL \u00b7helper(SB)\n" +
|
||||
"\tCALL \u00b7late(SB)\n" +
|
||||
"\tRET\n"
|
||||
callerURI = "file://" + root + "/caller_amd64.s"
|
||||
return root, callerURI, caller
|
||||
}
|
||||
|
||||
// openCaller frames the initialize/didOpen scripting around the caller doc.
|
||||
func openCaller(root, callerURI, caller string) string {
|
||||
return frame(1, "initialize", map[string]any{"rootUri": "file://" + root}) +
|
||||
frame(nil, "initialized", map[string]any{}) +
|
||||
frame(nil, "textDocument/didOpen", map[string]any{
|
||||
"textDocument": map[string]any{"uri": callerURI, "languageId": "gasm", "version": 1, "text": caller},
|
||||
})
|
||||
}
|
||||
|
||||
// TestDefinitionReachesDiskFile checks that go-to-definition resolves a
|
||||
// symbol to a workspace file the editor has never opened.
|
||||
func TestDefinitionReachesDiskFile(t *testing.T) {
|
||||
root, callerURI, caller := diskWorkspace(t)
|
||||
in := openCaller(root, callerURI, caller) +
|
||||
frame(2, "textDocument/definition", map[string]any{
|
||||
"textDocument": map[string]any{"uri": callerURI},
|
||||
"position": map[string]any{"line": 1, "character": 8}, // on helper in CALL ·helper(SB)
|
||||
}) + frame(nil, "exit", nil)
|
||||
msgs := run(t, in)
|
||||
resp := findByID(msgs, 2)
|
||||
if resp == nil {
|
||||
t.Fatal("no definition response")
|
||||
}
|
||||
var locs []Location
|
||||
if err := json.Unmarshal(mustResult(t, resp), &locs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantURI := fileToURI(filepath.Join(root, "helper_amd64.s"))
|
||||
if len(locs) != 1 || locs[0].URI != wantURI || locs[0].Range.Start.Line != 0 {
|
||||
t.Fatalf("definition = %+v, want the TEXT in %s line 0", locs, wantURI)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReferencesReachDiskFiles checks that find references collects the call
|
||||
// site in the open document plus the definition in the unopened disk file.
|
||||
func TestReferencesReachDiskFiles(t *testing.T) {
|
||||
root, callerURI, caller := diskWorkspace(t)
|
||||
in := openCaller(root, callerURI, caller) +
|
||||
frame(2, "textDocument/references", map[string]any{
|
||||
"textDocument": map[string]any{"uri": callerURI},
|
||||
"position": map[string]any{"line": 1, "character": 8}, // on helper in CALL ·helper(SB)
|
||||
"context": map[string]any{"includeDeclaration": true},
|
||||
}) + frame(nil, "exit", nil)
|
||||
msgs := run(t, in)
|
||||
resp := findByID(msgs, 2)
|
||||
if resp == nil {
|
||||
t.Fatal("no references response")
|
||||
}
|
||||
var locs []Location
|
||||
if err := json.Unmarshal(mustResult(t, resp), &locs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
byURI := map[string]int{}
|
||||
for _, l := range locs {
|
||||
byURI[l.URI]++
|
||||
}
|
||||
if byURI[callerURI] != 1 || byURI[fileToURI(filepath.Join(root, "helper_amd64.s"))] != 1 {
|
||||
t.Fatalf("references by uri = %v, want the call in the open document and the definition on disk", byURI)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkspaceSymbolsReachesDiskFiles checks the workspace symbol search
|
||||
// against a file the editor has never opened.
|
||||
func TestWorkspaceSymbolsReachesDiskFiles(t *testing.T) {
|
||||
root, callerURI, caller := diskWorkspace(t)
|
||||
in := openCaller(root, callerURI, caller) +
|
||||
frame(2, "workspace/symbol", map[string]any{"query": "helper"}) +
|
||||
frame(nil, "exit", nil)
|
||||
msgs := run(t, in)
|
||||
resp := findByID(msgs, 2)
|
||||
if resp == nil {
|
||||
t.Fatal("no workspace/symbol response")
|
||||
}
|
||||
var syms []WorkspaceSymbol
|
||||
if err := json.Unmarshal(mustResult(t, resp), &syms); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(syms) != 1 || syms[0].Name != "helper" ||
|
||||
syms[0].Location.URI != fileToURI(filepath.Join(root, "helper_amd64.s")) {
|
||||
t.Fatalf("symbols = %+v, want helper from the disk file", syms)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenBufferShadowsDiskFile pins the shadowing rule: when an open buffer
|
||||
// carries unsaved edits, the answers come from the buffer, never from the
|
||||
// stale disk copy.
|
||||
func TestOpenBufferShadowsDiskFile(t *testing.T) {
|
||||
root, callerURI, caller := diskWorkspace(t)
|
||||
helperURI := "file://" + root + "/helper_amd64.s"
|
||||
// The buffer moved the definition to line 1; the disk copy has line 0.
|
||||
buffered := "\nTEXT \u00b7helper(SB), NOSPLIT, $0\n\tRET\n"
|
||||
in := openCaller(root, callerURI, caller) +
|
||||
frame(nil, "textDocument/didOpen", map[string]any{
|
||||
"textDocument": map[string]any{"uri": helperURI, "languageId": "gasm", "version": 1, "text": buffered},
|
||||
}) +
|
||||
frame(2, "textDocument/definition", map[string]any{
|
||||
"textDocument": map[string]any{"uri": callerURI},
|
||||
"position": map[string]any{"line": 1, "character": 8},
|
||||
}) +
|
||||
frame(3, "textDocument/references", map[string]any{
|
||||
"textDocument": map[string]any{"uri": callerURI},
|
||||
"position": map[string]any{"line": 1, "character": 8},
|
||||
"context": map[string]any{"includeDeclaration": true},
|
||||
}) + frame(nil, "exit", nil)
|
||||
msgs := run(t, in)
|
||||
|
||||
dresp := findByID(msgs, 2)
|
||||
if dresp == nil {
|
||||
t.Fatal("no definition response")
|
||||
}
|
||||
var locs []Location
|
||||
if err := json.Unmarshal(mustResult(t, dresp), &locs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(locs) != 1 || locs[0].URI != helperURI || locs[0].Range.Start.Line != 1 {
|
||||
t.Fatalf("definition = %+v, want the buffered TEXT on line 1", locs)
|
||||
}
|
||||
|
||||
rresp := findByID(msgs, 3)
|
||||
if rresp == nil {
|
||||
t.Fatal("no references response")
|
||||
}
|
||||
locs = nil
|
||||
if err := json.Unmarshal(mustResult(t, rresp), &locs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(locs) != 2 {
|
||||
t.Fatalf("references = %+v, want the call and the buffered definition", locs)
|
||||
}
|
||||
for _, l := range locs {
|
||||
if l.URI == helperURI && l.Range.Start.Line != 1 {
|
||||
t.Errorf("reference in the opened file points at line %d, want the buffered line 1", l.Range.Start.Line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWatchedFileEventsUpdateIndex pins the one case a per-query refresh
|
||||
// cannot cover: a file created after the build is unknown to the index until
|
||||
// the client's watched-file event names it. The steps run through dispatch
|
||||
// directly, because the file must appear on disk between two requests.
|
||||
func TestWatchedFileEventsUpdateIndex(t *testing.T) {
|
||||
root, callerURI, caller := diskWorkspace(t)
|
||||
latePath := filepath.Join(root, "late_amd64.s")
|
||||
lateURI := "file://" + latePath
|
||||
|
||||
var buf bytes.Buffer
|
||||
srv := New(strings.NewReader(""), &buf)
|
||||
request := func(id int, method string, params any) *rpcMessage {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var msgID *json.RawMessage
|
||||
if id > 0 {
|
||||
msgID = rawID(t, id)
|
||||
}
|
||||
srv.dispatch(&rpcMessage{ID: msgID, Method: method, Params: raw})
|
||||
msgs := readFrames(t, &buf)
|
||||
buf.Reset()
|
||||
if id > 0 {
|
||||
return findByID(msgs, id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
definitionOnLate := func(id int) []Location {
|
||||
t.Helper()
|
||||
resp := request(id, "textDocument/definition", map[string]any{
|
||||
"textDocument": map[string]any{"uri": callerURI},
|
||||
"position": map[string]any{"line": 2, "character": 8}, // on late in CALL ·late(SB)
|
||||
})
|
||||
var locs []Location
|
||||
if err := json.Unmarshal(mustResult(t, resp), &locs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return locs
|
||||
}
|
||||
|
||||
request(1, "initialize", map[string]any{"rootUri": "file://" + root})
|
||||
request(0, "textDocument/didOpen", map[string]any{
|
||||
"textDocument": map[string]any{"uri": callerURI, "languageId": "gasm", "version": 1, "text": caller},
|
||||
})
|
||||
|
||||
// The first request builds the index; the late file does not exist yet.
|
||||
if locs := definitionOnLate(2); len(locs) != 0 {
|
||||
t.Fatalf("definition before the file exists = %+v, want no result", locs)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(latePath, []byte("TEXT \u00b7late(SB), NOSPLIT, $0\n\tRET\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request(0, "workspace/didChangeWatchedFiles", map[string]any{
|
||||
"changes": []map[string]any{{"uri": lateURI, "type": fileCreated}},
|
||||
})
|
||||
if locs := definitionOnLate(4); len(locs) != 1 || locs[0].URI != fileToURI(latePath) {
|
||||
t.Fatalf("definition after the created event = %+v, want late_amd64.s", locs)
|
||||
}
|
||||
|
||||
if err := os.Remove(latePath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request(0, "workspace/didChangeWatchedFiles", map[string]any{
|
||||
"changes": []map[string]any{{"uri": lateURI, "type": fileDeleted}},
|
||||
})
|
||||
if locs := definitionOnLate(6); len(locs) != 0 {
|
||||
t.Fatalf("definition after the deleted event = %+v, want no result", locs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileToURIRoundTrip pins the URI encoding of the disk entries: the
|
||||
// constructed URI decodes back to the path it came from.
|
||||
func TestFileToURIRoundTrip(t *testing.T) {
|
||||
const path = "/tmp/Repositáře/kernels/helper.s"
|
||||
if got := uriPath(fileToURI(path)); got != path {
|
||||
t.Errorf("fileToURI round trip = %q, want %q", got, path)
|
||||
}
|
||||
if got := fileToURI("/plain/k.s"); got != "file:///plain/k.s" {
|
||||
t.Errorf("fileToURI plain = %q", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user