Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

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)
}