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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 171 additions & 0 deletions claudetool/lsp/format.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
package lsp

import (
"fmt"
"os"
"path/filepath"
"strings"
)

const maxReferences = 50

// formatDefinition formats definition locations for display.
func formatDefinition(locations []Location, wd string) string {
if len(locations) == 0 {
return "No definition found."
}

var sb strings.Builder
for i, loc := range locations {
if i > 0 {
sb.WriteString("\n\n")
}
path := filePathFromURI(loc.URI)
relPath := relativePath(path, wd)
line := loc.Range.Start.Line + 1 // convert 0-based to 1-based for display
sb.WriteString(fmt.Sprintf("**%s:%d**\n", relPath, line))

// Read source context around the definition
context := readSourceContext(path, loc.Range.Start.Line, 5)
if context != "" {
sb.WriteString("```\n")
sb.WriteString(context)
sb.WriteString("\n```")
}
}
return sb.String()
}

// formatReferences formats reference locations for display, grouped by file.
func formatReferences(locations []Location, wd string) string {
if len(locations) == 0 {
return "No references found."
}

// Group by file
type fileRef struct {
path string
refs []Location
}
fileOrder := []string{}
byFile := make(map[string][]Location)
for _, loc := range locations {
path := filePathFromURI(loc.URI)
if _, exists := byFile[path]; !exists {
fileOrder = append(fileOrder, path)
}
byFile[path] = append(byFile[path], loc)
}

var sb strings.Builder
total := len(locations)
truncated := total > maxReferences
if truncated {
sb.WriteString(fmt.Sprintf("Found %d references (showing first %d):\n\n", total, maxReferences))
} else {
sb.WriteString(fmt.Sprintf("Found %d reference(s):\n\n", total))
}

shown := 0
for _, path := range fileOrder {
if shown >= maxReferences {
break
}
refs := byFile[path]
relPath := relativePath(path, wd)
sb.WriteString(fmt.Sprintf("**%s**\n", relPath))
for _, ref := range refs {
if shown >= maxReferences {
break
}
line := ref.Range.Start.Line + 1
context := readSourceLine(path, ref.Range.Start.Line)
if context != "" {
sb.WriteString(fmt.Sprintf(" L%d: %s\n", line, strings.TrimSpace(context)))
} else {
sb.WriteString(fmt.Sprintf(" L%d\n", line))
}
shown++
}
sb.WriteString("\n")
}
return strings.TrimRight(sb.String(), "\n")
}

// formatHover formats hover information for display.
func formatHover(hover *Hover) string {
if hover == nil || hover.Contents.Value == "" {
return "No hover information available."
}
return hover.Contents.Value
}

// formatSymbols formats workspace symbol results for display.
func formatSymbols(symbols []SymbolInformation, wd string) string {
if len(symbols) == 0 {
return "No symbols found."
}

var sb strings.Builder
sb.WriteString(fmt.Sprintf("Found %d symbol(s):\n\n", len(symbols)))
for _, sym := range symbols {
path := filePathFromURI(sym.Location.URI)
relPath := relativePath(path, wd)
line := sym.Location.Range.Start.Line + 1
kind := SymbolKindName(sym.Kind)
if sym.ContainerName != "" {
sb.WriteString(fmt.Sprintf("- %s (%s) in %s — %s:%d\n", sym.Name, kind, sym.ContainerName, relPath, line))
} else {
sb.WriteString(fmt.Sprintf("- %s (%s) — %s:%d\n", sym.Name, kind, relPath, line))
}
}
return strings.TrimRight(sb.String(), "\n")
}

// readSourceContext reads a few lines around the given 0-based line from a file.
func readSourceContext(path string, line int, contextLines int) string {
data, err := os.ReadFile(path)
if err != nil {
return ""
}
lines := strings.Split(string(data), "\n")
start := line - contextLines
if start < 0 {
start = 0
}
end := line + contextLines + 1
if end > len(lines) {
end = len(lines)
}
var sb strings.Builder
for i := start; i < end; i++ {
marker := " "
if i == line {
marker = "> "
}
sb.WriteString(fmt.Sprintf("%s%4d | %s\n", marker, i+1, lines[i]))
}
return strings.TrimRight(sb.String(), "\n")
}

// readSourceLine reads a single 0-based line from a file.
func readSourceLine(path string, line int) string {
data, err := os.ReadFile(path)
if err != nil {
return ""
}
lines := strings.Split(string(data), "\n")
if line < 0 || line >= len(lines) {
return ""
}
return lines[line]
}

// relativePath returns the path relative to wd, or the original path if it can't be made relative.
func relativePath(path, wd string) string {
rel, err := filepath.Rel(wd, path)
if err != nil {
return path
}
return rel
}
227 changes: 227 additions & 0 deletions claudetool/lsp/format_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
package lsp

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

func TestFormatDefinitionEmpty(t *testing.T) {
result := formatDefinition(nil, "/tmp")
if result != "No definition found." {
t.Errorf("got %q, want %q", result, "No definition found.")
}
}

func TestFormatDefinitionSingle(t *testing.T) {
dir := t.TempDir()
testFile := filepath.Join(dir, "main.go")
os.WriteFile(testFile, []byte("package main\n\nfunc main() {\n\tprintln(\"hello\")\n}\n"), 0o644)

locations := []Location{
{
URI: fileURI(testFile),
Range: Range{
Start: Position{Line: 2, Character: 5},
End: Position{Line: 2, Character: 9},
},
},
}

result := formatDefinition(locations, dir)
if !strings.Contains(result, "main.go:3") {
t.Errorf("expected result to contain 'main.go:3' (1-based line), got:\n%s", result)
}
if !strings.Contains(result, "func main()") {
t.Errorf("expected result to contain source context, got:\n%s", result)
}
}

func TestFormatReferencesEmpty(t *testing.T) {
result := formatReferences(nil, "/tmp")
if result != "No references found." {
t.Errorf("got %q, want %q", result, "No references found.")
}
}

func TestFormatReferencesGroupedByFile(t *testing.T) {
dir := t.TempDir()
file1 := filepath.Join(dir, "a.go")
file2 := filepath.Join(dir, "b.go")
os.WriteFile(file1, []byte("package main\n\nvar x = 1\n"), 0o644)
os.WriteFile(file2, []byte("package main\n\nvar y = x\n"), 0o644)

locations := []Location{
{URI: fileURI(file1), Range: Range{Start: Position{Line: 2, Character: 4}}},
{URI: fileURI(file2), Range: Range{Start: Position{Line: 2, Character: 8}}},
}

result := formatReferences(locations, dir)
if !strings.Contains(result, "a.go") {
t.Errorf("expected result to contain a.go, got:\n%s", result)
}
if !strings.Contains(result, "b.go") {
t.Errorf("expected result to contain b.go, got:\n%s", result)
}
if !strings.Contains(result, "2 reference") {
t.Errorf("expected result to mention 2 references, got:\n%s", result)
}
}

func TestFormatReferenceTruncation(t *testing.T) {
dir := t.TempDir()
testFile := filepath.Join(dir, "test.go")
os.WriteFile(testFile, []byte("package main\n"), 0o644)

// Create more than maxReferences locations
var locations []Location
for i := 0; i < maxReferences+10; i++ {
locations = append(locations, Location{
URI: fileURI(testFile),
Range: Range{
Start: Position{Line: 0, Character: 0},
},
})
}

result := formatReferences(locations, dir)
if !strings.Contains(result, "showing first 50") {
t.Errorf("expected truncation message, got:\n%s", result)
}
}

func TestFormatHoverEmpty(t *testing.T) {
result := formatHover(nil)
if result != "No hover information available." {
t.Errorf("got %q", result)
}

result = formatHover(&Hover{Contents: MarkupContent{}})
if result != "No hover information available." {
t.Errorf("got %q", result)
}
}

func TestFormatHoverWithContent(t *testing.T) {
hover := &Hover{
Contents: MarkupContent{
Kind: "markdown",
Value: "```go\nfunc Println(a ...any) (n int, err error)\n```\nPrintln formats using the default formats...",
},
}
result := formatHover(hover)
if !strings.Contains(result, "func Println") {
t.Errorf("expected hover content, got:\n%s", result)
}
}

func TestFormatSymbolsEmpty(t *testing.T) {
result := formatSymbols(nil, "/tmp")
if result != "No symbols found." {
t.Errorf("got %q", result)
}
}

func TestFormatSymbols(t *testing.T) {
symbols := []SymbolInformation{
{
Name: "ProcessOneTurn",
Kind: SymbolKindFunction,
Location: Location{URI: fileURI("/project/loop/loop.go"), Range: Range{Start: Position{Line: 99}}},
},
{
Name: "Run",
Kind: SymbolKindMethod,
ContainerName: "Server",
Location: Location{URI: fileURI("/project/server/server.go"), Range: Range{Start: Position{Line: 49}}},
},
}

result := formatSymbols(symbols, "/project")
if !strings.Contains(result, "ProcessOneTurn") {
t.Errorf("expected ProcessOneTurn in output, got:\n%s", result)
}
if !strings.Contains(result, "Function") {
t.Errorf("expected Function kind, got:\n%s", result)
}
if !strings.Contains(result, "loop/loop.go:100") {
t.Errorf("expected 1-based line number, got:\n%s", result)
}
if !strings.Contains(result, "in Server") {
t.Errorf("expected container name, got:\n%s", result)
}
}

func TestRelativePath(t *testing.T) {
tests := []struct {
path string
wd string
want string
}{
{"/project/src/main.go", "/project", "src/main.go"},
{"/other/file.go", "/project", "../other/file.go"},
{"/project/file.go", "/project", "file.go"},
}
for _, tt := range tests {
got := relativePath(tt.path, tt.wd)
if got != tt.want {
t.Errorf("relativePath(%q, %q) = %q, want %q", tt.path, tt.wd, got, tt.want)
}
}
}

func TestFileURI(t *testing.T) {
got := fileURI("/home/user/file.go")
if !strings.HasPrefix(got, "file://") {
t.Errorf("fileURI should start with file://, got %q", got)
}
if !strings.Contains(got, "file.go") {
t.Errorf("fileURI should contain filename, got %q", got)
}
}

func TestFilePathFromURI(t *testing.T) {
// Round-trip test
original := "/home/user/project/main.go"
uri := fileURI(original)
back := filePathFromURI(uri)
if back != original {
t.Errorf("round trip: %q -> %q -> %q", original, uri, back)
}
}

func TestLanguageID(t *testing.T) {
tests := []struct {
path string
want string
}{
{"main.go", "go"},
{"app.ts", "typescript"},
{"app.tsx", "typescriptreact"},
{"app.js", "javascript"},
{"app.jsx", "javascriptreact"},
{"script.py", "python"},
{"main.rs", "rust"},
{"Main.java", "java"},
{"readme.txt", "plaintext"},
}
for _, tt := range tests {
got := languageID(tt.path)
if got != tt.want {
t.Errorf("languageID(%q) = %q, want %q", tt.path, got, tt.want)
}
}
}

func TestSymbolKindName(t *testing.T) {
if got := SymbolKindName(SymbolKindFunction); got != "Function" {
t.Errorf("SymbolKindName(Function) = %q", got)
}
if got := SymbolKindName(SymbolKindStruct); got != "Struct" {
t.Errorf("SymbolKindName(Struct) = %q", got)
}
if got := SymbolKindName(SymbolKind(999)); got != "Unknown" {
t.Errorf("SymbolKindName(999) = %q", got)
}
}
Loading
Loading