a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
486 lines
15 KiB
Go
486 lines
15 KiB
Go
package lsp
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// wholeFileEnd returns the (line, character) position of the end of
|
|
// the file at absPath, suitable for a full-file LSP Range. Position
|
|
// is 0-based and exclusive — line N where the file has N+1 lines, or
|
|
// line N character C when the last line has C characters before EOL.
|
|
//
|
|
// gopls (and other strict servers) reject `Position{Line: 1<<30}` as
|
|
// "line number out of range"; this helper returns a real bound so the
|
|
// fix-all loop survives strict validators. On stat / read errors we
|
|
// fall back to a bounded-but-still-large sentinel — better to ask for
|
|
// "the first ~1M lines" than to fail the whole pass on a transient
|
|
// read error.
|
|
func wholeFileEnd(absPath string) (line, character int) {
|
|
data, err := os.ReadFile(absPath)
|
|
if err != nil {
|
|
return 1_000_000, 0
|
|
}
|
|
if len(data) == 0 {
|
|
return 0, 0
|
|
}
|
|
// Trailing newline → last "line" is empty. LSP positions are
|
|
// 0-based and an "End" pointing at the start of an empty line N
|
|
// is the canonical way to span everything before it.
|
|
lineCount := bytes.Count(data, []byte{'\n'})
|
|
lastNL := bytes.LastIndexByte(data, '\n')
|
|
tail := data[lastNL+1:]
|
|
if len(tail) == 0 {
|
|
return lineCount, 0
|
|
}
|
|
return lineCount, len(tail)
|
|
}
|
|
|
|
// FixAllOptions controls FixAllInFile behaviour.
|
|
type FixAllOptions struct {
|
|
// AbsPath is the absolute path to the file under repair.
|
|
AbsPath string
|
|
// Kinds restricts which CodeAction kinds are eligible. Empty
|
|
// defaults to {quickfix, source.organizeImports}.
|
|
Kinds []string
|
|
// MaxIterations bounds how many round-trips the loop performs;
|
|
// each iteration applies at most one batch of actions and
|
|
// re-collects diagnostics. Zero defaults to 5.
|
|
MaxIterations int
|
|
// MaxActionsPerIter caps the number of actions applied per
|
|
// iteration. Zero defaults to 50.
|
|
MaxActionsPerIter int
|
|
// DiagnosticTimeout is how long to wait for the server to publish
|
|
// updated diagnostics after each apply. Zero defaults to 5s.
|
|
DiagnosticTimeout time.Duration
|
|
}
|
|
|
|
// FixAllResult summarises a fix-all run.
|
|
type FixAllResult struct {
|
|
// Iterations is the number of apply/re-diagnose cycles run.
|
|
Iterations int
|
|
// AppliedActions is the total number of actions written to disk.
|
|
AppliedActions int
|
|
// FilesTouched lists the absolute paths whose contents changed.
|
|
FilesTouched []string
|
|
// FinalDiagnostics is the diagnostics list after the last apply.
|
|
FinalDiagnostics []Diagnostic
|
|
}
|
|
|
|
// FixAllInFile loops codeAction → apply → re-collect-diagnostics until
|
|
// no further changes are produced or the iteration cap is reached.
|
|
// Modelled on agent-lsp's `/lsp-fix-all` workflow.
|
|
//
|
|
// The provider must have been initialised (EnsureClient + the file
|
|
// opened — FixAllInFile will open the file itself if necessary).
|
|
//
|
|
// FixAllInFile is conservative: when a CodeAction has no Edit and
|
|
// only a Command (legacy) form, the command is *not* executed by
|
|
// default — most servers route those through workspace/applyEdit
|
|
// which Gortex doesn't follow up on by default. Enable explicit
|
|
// command execution by setting FixAllOptions.Kinds to include
|
|
// "source.fixAll" or by calling ExecuteCommand directly.
|
|
func (p *Provider) FixAllInFile(opts FixAllOptions) (*FixAllResult, error) {
|
|
if p.client == nil {
|
|
return nil, errors.New("LSP client not initialized")
|
|
}
|
|
if opts.AbsPath == "" {
|
|
return nil, errors.New("AbsPath is required")
|
|
}
|
|
if opts.MaxIterations <= 0 {
|
|
opts.MaxIterations = 5
|
|
}
|
|
if opts.MaxActionsPerIter <= 0 {
|
|
opts.MaxActionsPerIter = 50
|
|
}
|
|
if opts.DiagnosticTimeout <= 0 {
|
|
opts.DiagnosticTimeout = 5 * time.Second
|
|
}
|
|
kinds := opts.Kinds
|
|
if len(kinds) == 0 {
|
|
kinds = []string{
|
|
CodeActionKindQuickFix,
|
|
CodeActionKindSourceOrganizeImports,
|
|
}
|
|
}
|
|
|
|
// Open if needed.
|
|
repoRoot := r0(p.spec, opts.AbsPath)
|
|
if err := p.openDocument(repoRoot, relForOpen(repoRoot, opts.AbsPath)); err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
// Already-open is a no-op; only abort on non-trivial errors.
|
|
return nil, fmt.Errorf("open document: %w", err)
|
|
}
|
|
|
|
result := &FixAllResult{}
|
|
touched := make(map[string]bool)
|
|
|
|
for iter := 0; iter < opts.MaxIterations; iter++ {
|
|
result.Iterations = iter + 1
|
|
// Snapshot diagnostics for this iteration.
|
|
diags, _ := p.LastDiagnostics(opts.AbsPath)
|
|
|
|
// Build the request — full-file range, restricted to the
|
|
// caller's allowed kinds. We can't pass an unbounded sentinel
|
|
// (1 << 30 etc.) because gopls and a few other servers
|
|
// validate End.Line against the file's real line count and
|
|
// reject with `line number N out of range 0-M`. Read the
|
|
// file's actual line count once per iteration so re-edits
|
|
// during fix-all stay in sync.
|
|
endLine, endChar := wholeFileEnd(opts.AbsPath)
|
|
req := CodeActionsRequest{
|
|
AbsPath: opts.AbsPath,
|
|
Range: Range{Start: Position{Line: 0, Character: 0}, End: Position{Line: endLine, Character: endChar}},
|
|
Diagnostics: diags,
|
|
Only: kinds,
|
|
}
|
|
actions, err := p.GetCodeActions(req)
|
|
if err != nil {
|
|
return result, fmt.Errorf("get code actions: %w", err)
|
|
}
|
|
// Sort: preferred → by kind → by title — keeps results
|
|
// deterministic across servers.
|
|
sortActions(actions)
|
|
|
|
applied := 0
|
|
for _, a := range actions {
|
|
if applied >= opts.MaxActionsPerIter {
|
|
break
|
|
}
|
|
if a.Disabled != nil {
|
|
continue
|
|
}
|
|
edit := a.Edit
|
|
if edit == nil && a.Data != nil {
|
|
resolved, err := p.ResolveCodeAction(a)
|
|
if err == nil {
|
|
edit = resolved.Edit
|
|
}
|
|
}
|
|
if edit == nil {
|
|
// Legacy Command-form. Skip unless it's a source.* kind
|
|
// where the server commits the changes itself.
|
|
if a.IsCommand() && (a.Kind == CodeActionKindSourceOrganizeImports || a.Kind == CodeActionKindSourceFixAll) {
|
|
_, _ = p.ExecuteCommand(Command{Command: a.Command, Arguments: a.Arguments, Title: a.Title})
|
|
applied++
|
|
}
|
|
continue
|
|
}
|
|
files, err := WriteWorkspaceEdit(*edit)
|
|
if err != nil {
|
|
return result, fmt.Errorf("apply workspace edit: %w", err)
|
|
}
|
|
for _, f := range files {
|
|
touched[f] = true
|
|
_ = p.changeDocument(f, mustReadFile(f))
|
|
}
|
|
applied++
|
|
}
|
|
result.AppliedActions += applied
|
|
if applied == 0 {
|
|
// No work this iteration — converge.
|
|
break
|
|
}
|
|
// Wait for the server to refresh diagnostics so the next
|
|
// iteration sees the new state. Best-effort; if no waiter
|
|
// fires we move on with the cached snapshot.
|
|
_ = p.WaitForDiagnostics(opts.AbsPath, opts.DiagnosticTimeout)
|
|
}
|
|
if d, ok := p.LastDiagnostics(opts.AbsPath); ok {
|
|
result.FinalDiagnostics = d
|
|
}
|
|
for f := range touched {
|
|
result.FilesTouched = append(result.FilesTouched, f)
|
|
}
|
|
sort.Strings(result.FilesTouched)
|
|
return result, nil
|
|
}
|
|
|
|
// ApplyCodeAction is the single-action analogue of FixAllInFile.
|
|
// Resolves the action if its Edit is nil, then applies the resulting
|
|
// WorkspaceEdit. Returns the list of touched files.
|
|
func (p *Provider) ApplyCodeAction(action CodeActionOrCommand) ([]string, error) {
|
|
edit := action.Edit
|
|
if edit == nil && (action.Data != nil || action.Title != "") {
|
|
// Try to resolve.
|
|
resolved, err := p.ResolveCodeAction(action)
|
|
if err == nil && resolved.Edit != nil {
|
|
edit = resolved.Edit
|
|
}
|
|
}
|
|
if edit == nil && action.IsCommand() {
|
|
_, err := p.ExecuteCommand(Command{Command: action.Command, Arguments: action.Arguments, Title: action.Title})
|
|
return nil, err
|
|
}
|
|
if edit == nil {
|
|
return nil, errors.New("code action has no applicable edit or command")
|
|
}
|
|
files, err := WriteWorkspaceEdit(*edit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, f := range files {
|
|
_ = p.changeDocument(f, mustReadFile(f))
|
|
}
|
|
return files, nil
|
|
}
|
|
|
|
// WriteWorkspaceEdit applies a WorkspaceEdit to disk. Both the legacy
|
|
// `changes` field and the modern `documentChanges` field are
|
|
// supported. Returns the absolute paths of files whose contents
|
|
// changed.
|
|
//
|
|
// Edits within a single file are applied in reverse position order so
|
|
// earlier edits don't shift the offsets of later ones.
|
|
func WriteWorkspaceEdit(edit WorkspaceEdit) ([]string, error) {
|
|
type fileEdit struct {
|
|
path string
|
|
edits []TextEdit
|
|
}
|
|
var grouped []fileEdit
|
|
if len(edit.DocumentChanges) > 0 {
|
|
for _, dc := range edit.DocumentChanges {
|
|
path := uriToAbsPath(dc.TextDocument.URI)
|
|
if path == "" {
|
|
continue
|
|
}
|
|
grouped = append(grouped, fileEdit{path: path, edits: dc.Edits})
|
|
}
|
|
} else {
|
|
for uri, edits := range edit.Changes {
|
|
path := uriToAbsPath(uri)
|
|
if path == "" {
|
|
continue
|
|
}
|
|
grouped = append(grouped, fileEdit{path: path, edits: edits})
|
|
}
|
|
}
|
|
|
|
touched := make([]string, 0, len(grouped))
|
|
for _, fe := range grouped {
|
|
content, err := os.ReadFile(fe.path)
|
|
if err != nil {
|
|
return touched, fmt.Errorf("read %s: %w", fe.path, err)
|
|
}
|
|
newContent, err := applyEditsToContent(content, fe.edits)
|
|
if err != nil {
|
|
return touched, fmt.Errorf("apply edits to %s: %w", fe.path, err)
|
|
}
|
|
// Atomic write: temp + rename.
|
|
tmp, err := os.CreateTemp(filepath.Dir(fe.path), ".gortex-edit-*")
|
|
if err != nil {
|
|
return touched, err
|
|
}
|
|
if _, err := tmp.Write(newContent); err != nil {
|
|
_ = tmp.Close()
|
|
_ = os.Remove(tmp.Name())
|
|
return touched, err
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return touched, err
|
|
}
|
|
if err := os.Rename(tmp.Name(), fe.path); err != nil {
|
|
_ = os.Remove(tmp.Name())
|
|
return touched, err
|
|
}
|
|
touched = append(touched, fe.path)
|
|
}
|
|
return touched, nil
|
|
}
|
|
|
|
// applyEditsToContent walks the edits in reverse-position order and
|
|
// returns the resulting bytes. Position arithmetic is line/char in
|
|
// UTF-16 units per LSP — for the ASCII-only common case this matches
|
|
// byte offsets; for unicode-heavy code it underestimates UTF-16 units
|
|
// (a known LSP/Go interop wart, present across major LSP clients).
|
|
func applyEditsToContent(content []byte, edits []TextEdit) ([]byte, error) {
|
|
if len(edits) == 0 {
|
|
return content, nil
|
|
}
|
|
// Sort edits by start position descending so applying one
|
|
// doesn't invalidate the offsets of the next.
|
|
sorted := make([]TextEdit, len(edits))
|
|
copy(sorted, edits)
|
|
sort.Slice(sorted, func(i, j int) bool {
|
|
if sorted[i].Range.Start.Line != sorted[j].Range.Start.Line {
|
|
return sorted[i].Range.Start.Line > sorted[j].Range.Start.Line
|
|
}
|
|
return sorted[i].Range.Start.Character > sorted[j].Range.Start.Character
|
|
})
|
|
|
|
out := make([]byte, len(content))
|
|
copy(out, content)
|
|
for _, e := range sorted {
|
|
startOff, err := lspPositionToOffset(out, e.Range.Start)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
endOff, err := lspPositionToOffset(out, e.Range.End)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if startOff > endOff || startOff < 0 || endOff > len(out) {
|
|
return nil, fmt.Errorf("invalid edit range: start=%d end=%d len=%d", startOff, endOff, len(out))
|
|
}
|
|
newBytes := []byte(e.NewText)
|
|
merged := make([]byte, 0, len(out)-(endOff-startOff)+len(newBytes))
|
|
merged = append(merged, out[:startOff]...)
|
|
merged = append(merged, newBytes...)
|
|
merged = append(merged, out[endOff:]...)
|
|
out = merged
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// lspPositionToOffset converts an LSP Position (line, char) into a
|
|
// byte offset within content. Treats char as UTF-16 code units only
|
|
// when the line contains non-ASCII bytes; for ASCII-only lines it's
|
|
// equivalent to a byte index — the common case.
|
|
func lspPositionToOffset(content []byte, pos Position) (int, error) {
|
|
off := 0
|
|
line := 0
|
|
for off < len(content) && line < pos.Line {
|
|
if content[off] == '\n' {
|
|
line++
|
|
}
|
|
off++
|
|
}
|
|
if line < pos.Line {
|
|
// Position past EOF — clamp to len(content).
|
|
return len(content), nil
|
|
}
|
|
// Walk Character UTF-16 code units within the line.
|
|
col := 0
|
|
for off < len(content) && col < pos.Character {
|
|
b := content[off]
|
|
if b == '\n' {
|
|
break
|
|
}
|
|
// ASCII fast path.
|
|
if b < 0x80 {
|
|
off++
|
|
col++
|
|
continue
|
|
}
|
|
// UTF-8 multi-byte sequence.
|
|
size := utf8RuneSize(b)
|
|
if size <= 0 {
|
|
size = 1
|
|
}
|
|
if off+size > len(content) {
|
|
break
|
|
}
|
|
// One UTF-16 code unit for BMP, two for supplementary.
|
|
r := decodeUTF8(content[off : off+size])
|
|
if r >= 0x10000 {
|
|
col += 2
|
|
} else {
|
|
col++
|
|
}
|
|
off += size
|
|
}
|
|
return off, nil
|
|
}
|
|
|
|
func utf8RuneSize(b byte) int {
|
|
switch {
|
|
case b&0x80 == 0:
|
|
return 1
|
|
case b&0xE0 == 0xC0:
|
|
return 2
|
|
case b&0xF0 == 0xE0:
|
|
return 3
|
|
case b&0xF8 == 0xF0:
|
|
return 4
|
|
}
|
|
return 1
|
|
}
|
|
|
|
// decodeUTF8 returns the rune for the given byte slice (assumed to
|
|
// start a valid UTF-8 sequence).
|
|
func decodeUTF8(b []byte) int32 {
|
|
switch len(b) {
|
|
case 1:
|
|
return int32(b[0])
|
|
case 2:
|
|
return int32(b[0]&0x1F)<<6 | int32(b[1]&0x3F)
|
|
case 3:
|
|
return int32(b[0]&0x0F)<<12 | int32(b[1]&0x3F)<<6 | int32(b[2]&0x3F)
|
|
case 4:
|
|
return int32(b[0]&0x07)<<18 | int32(b[1]&0x3F)<<12 | int32(b[2]&0x3F)<<6 | int32(b[3]&0x3F)
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// sortActions stabilises code-action ordering across servers: prefer
|
|
// IsPreferred, then by kind specificity (quickfix first, then
|
|
// organizeImports, then refactor, then source), then by title.
|
|
func sortActions(actions []CodeActionOrCommand) {
|
|
rank := func(k string) int {
|
|
switch k {
|
|
case CodeActionKindQuickFix:
|
|
return 0
|
|
case CodeActionKindSourceOrganizeImports:
|
|
return 1
|
|
case CodeActionKindSourceFixAll:
|
|
return 2
|
|
case CodeActionKindRefactor, CodeActionKindRefactorExtract,
|
|
CodeActionKindRefactorInline, CodeActionKindRefactorRewrite:
|
|
return 3
|
|
case CodeActionKindSource:
|
|
return 4
|
|
}
|
|
return 5
|
|
}
|
|
sort.SliceStable(actions, func(i, j int) bool {
|
|
a, b := actions[i], actions[j]
|
|
if a.IsPreferred != b.IsPreferred {
|
|
return a.IsPreferred
|
|
}
|
|
ra, rb := rank(a.Kind), rank(b.Kind)
|
|
if ra != rb {
|
|
return ra < rb
|
|
}
|
|
return a.Title < b.Title
|
|
})
|
|
}
|
|
|
|
// r0 picks a sensible workspace root for an opened document — for
|
|
// most LSP servers the workspace root in the initialize call is the
|
|
// truth, but FixAllInFile may be called against a path under that
|
|
// root. We don't have access to the original workspaceRoot here, so
|
|
// fall back to the directory containing the file.
|
|
func r0(spec *ServerSpec, abs string) string {
|
|
_ = spec
|
|
return filepath.Dir(abs)
|
|
}
|
|
|
|
// relForOpen returns abs minus the leading repoRoot — so openDocument
|
|
// can join them back together. Used when the caller has only the
|
|
// absolute path. Returns the original absolute path when it isn't
|
|
// under repoRoot (openDocument tolerates absolute paths via
|
|
// filepath.Join's semantics: when relPath is absolute it overrides
|
|
// the prefix).
|
|
func relForOpen(repoRoot, abs string) string {
|
|
if rel, err := filepath.Rel(repoRoot, abs); err == nil && !strings.HasPrefix(rel, "..") {
|
|
return rel
|
|
}
|
|
return abs
|
|
}
|
|
|
|
// mustReadFile reads the entire file as a string. Returns the empty
|
|
// string on error so the caller's didChange notify still has a
|
|
// well-formed payload.
|
|
func mustReadFile(abs string) string {
|
|
b, err := os.ReadFile(abs)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return string(b)
|
|
}
|