Compare commits

..
6 Commits
Author SHA1 Message Date
petrbalvin 26c5008136 fix(cmd): honour //go:build in the corpus audit
Test / test (push) Successful in 2m32s
Assisted-by: GLM 5.3 Flash
2026-09-23 21:03:16 +02:00
petrbalvin 74d6b90d69 fix(asm): read the arm64 move-wide immediate as an unsigned pattern
Assisted-by: GLM 5.3 Flash
2026-09-23 21:03:03 +02:00
petrbalvin 7b11c62f53 fix(asm): resolve negative numeric PC-relative jumps
Assisted-by: GLM 5.3 Flash
2026-09-23 21:02:50 +02:00
petrbalvin 8eed54b3da feat(lsp): quick fixes for the textflag include and the argument area
Test / test (push) Successful in 2m56s
Assisted-by: GLM 5.3 Flash
2026-09-23 20:23:35 +02:00
petrbalvin 4be16dcdf5 feat(lsp): resolve symbols across workspace files
Assisted-by: GLM 5.3 Flash
2026-09-23 20:21:58 +02:00
petrbalvin ded9cabdf4 fix(lsp): apply each rename edit to its own document
Assisted-by: GLM 5.3 Flash
2026-09-23 20:19:21 +02:00
18 changed files with 1164 additions and 72 deletions
+41 -1
View File
@@ -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
+10 -5
View File
@@ -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
View File
@@ -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 ----
+35
View File
@@ -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
View File
@@ -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 {
+52
View File
@@ -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"
+46
View File
@@ -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
View File
@@ -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)
+60
View File
@@ -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")
}
}
+9 -2
View File
@@ -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
View File
@@ -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
+9
View File
@@ -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
View File
@@ -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))},
})
}
}
+50
View File
@@ -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
View File
@@ -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)
+174
View File
@@ -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)
}
}
+164
View File
@@ -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()
}
+251
View File
@@ -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)
}
}