Compare commits

...
6 Commits
Author SHA1 Message Date
lukaszraczylo 9d84c1253b test(edit): add comprehensive selector specificity tests
- [x] Add sortBySpecificity and shouldPrefer helper functions for node preference logic
- [x] Implement isDeclarationLike pattern matching for declaration/statement node types
- [x] Add AtLine selector specificity logic to prefer smallest meaningful nodes
- [x] Add TestResolveSelectorAtLineSpecificity to verify correct node selection
- [x] Add TestRegressionInsertAfterAtLine to prevent file corruption on insertions
- [x] Add TestRegressionInsertBeforeAtLine to verify insert-before ordering
- [x] Add TestRegressionNestedStructures to ensure nested nodes select correctly
- [x] Add TestRegressionPreservesFileIntegrity to verify unrelated content preservation
- [x] Add TestRegressionMultipleConstBlocks for multi-block const handling
- [x] Add TestSortBySpecificity unit test for sorting logic
2026-01-23 20:56:26 +00:00
lukaszraczylo b8d868115c feat(parser): add Elixir language support
- [x] Add Elixir documentation extraction (@doc and @moduledoc attributes)
- [x] Add Elixir symbol extraction (modules, functions, macros, structs, protocols)
- [x] Add tree-sitter Elixir language parser integration
- [x] Add Elixir language detection for .ex and .exs file extensions
- [x] Add Elixir symbol extraction tests
- [x] Update language support table in README
- [x] Improve install script with package manager detection and LSP installation
- [x] Fix shell script portability (replace echo -e with printf)
- [x] Fix checksum verification in install script for macOS/Linux compatibility
2026-01-23 20:31:08 +00:00
lukaszraczylo ac1b81b70e fixup! fixup! build(goreleaser): enable CGO and remove Docker builds 2026-01-18 19:38:29 +00:00
lukaszraczylo f216380d37 fixup! build(goreleaser): enable CGO and remove Docker builds 2026-01-18 19:10:41 +00:00
lukaszraczylo 3be03aef7b build(goreleaser): enable CGO and remove Docker builds
- [x] Enable CGO_ENABLED=1 for builds
- [x] Add platform ignore rules for unsupported architectures
- [x] Remove Docker build configuration (dockers_v2)
- [x] Remove Docker signing configuration
- [x] Add platform compatibility check in install script
2026-01-18 19:08:10 +00:00
lukaszraczylo 5800c689e9 build(install): add automated installation script and update README
- [x] Create install.sh script for automated platform detection and installation
- [x] Add checksum verification support in installation script
- [x] Update README with quick install command using curl
- [x] Reorganize installation instructions into quick install and manual sections
- [x] Update binary download URLs to match goreleaser naming convention
- [x] Change installation directory preference from /usr/local/bin to ~/.local/bin
- [x] Remove sudo requirement for installation when possible
2026-01-18 19:05:12 +00:00
15 changed files with 2454 additions and 865 deletions
+1 -2
View File
@@ -16,9 +16,8 @@ permissions:
jobs:
release:
uses: lukaszraczylo/shared-actions/.github/workflows/go-release.yaml@main
uses: lukaszraczylo/shared-actions/.github/workflows/go-release-cgo.yaml@main
with:
go-version: "1.24"
docker-enabled: false
rolling-release-tag: "v1"
secrets: inherit
+9 -26
View File
@@ -15,7 +15,7 @@ builds:
main: ./cmd/mcp-filepuff
binary: mcp-filepuff
env:
- CGO_ENABLED=0
- CGO_ENABLED=1
flags:
- -trimpath
ldflags:
@@ -30,6 +30,13 @@ builds:
goarch:
- amd64
- arm64
ignore:
- goos: darwin
goarch: amd64
- goos: linux
goarch: arm64
- goos: windows
goarch: arm64
archives:
- id: default
@@ -82,23 +89,6 @@ release:
curl -sSL https://raw.githubusercontent.com/lukaszraczylo/filepuff-mcp/main/scripts/install.sh | bash
```
dockers_v2:
- images:
- "ghcr.io/lukaszraczylo/filepuff-mcp"
tags:
- "{{ .Version }}"
- "latest"
- "v1"
platforms:
- linux/amd64
- linux/arm64
dockerfile: Dockerfile.goreleaser
labels:
"org.opencontainers.image.created": "{{.Date}}"
"org.opencontainers.image.title": "{{.ProjectName}}"
"org.opencontainers.image.revision": "{{.FullCommit}}"
"org.opencontainers.image.version": "{{.Version}}"
"org.opencontainers.image.source": "https://github.com/lukaszraczylo/filepuff-mcp"
signs:
- cmd: cosign
@@ -111,11 +101,4 @@ signs:
artifacts: checksum
output: true
docker_signs:
- cmd: cosign
artifacts: manifests
output: true
args:
- sign
- "${artifact}@${digest}"
- "--yes"
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Lukasz Raczylo
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+29 -12
View File
@@ -14,33 +14,49 @@ A Go-based MCP (Model Context Protocol) server for Claude Code providing intelli
## Installation
### Binary Releases (Recommended)
### Quick Install (Recommended)
Install the latest version with a single command:
```bash
curl -sSL https://raw.githubusercontent.com/lukaszraczylo/filepuff-mcp/main/scripts/install.sh | bash
```
This script will:
- Automatically detect your platform (OS and architecture)
- Download the latest release
- Verify checksums
- Install to `~/.local/bin` (or `/usr/local/bin` if needed)
- Make the binary executable
### Manual Installation
Download pre-built binaries from the [releases page](https://github.com/lukaszraczylo/filepuff-mcp/releases):
```bash
# macOS (ARM64)
curl -L https://github.com/lukaszraczylo/filepuff-mcp/releases/latest/download/mcp-filepuff-darwin-arm64.tar.gz | tar xz
sudo mv mcp-filepuff /usr/local/bin/
curl -L https://github.com/lukaszraczylo/filepuff-mcp/releases/latest/download/mcp-filepuff_<version>_darwin_arm64.tar.gz | tar xz
mv mcp-filepuff ~/.local/bin/
# macOS (AMD64)
curl -L https://github.com/lukaszraczylo/filepuff-mcp/releases/latest/download/mcp-filepuff-darwin-amd64.tar.gz | tar xz
sudo mv mcp-filepuff /usr/local/bin/
curl -L https://github.com/lukaszraczylo/filepuff-mcp/releases/latest/download/mcp-filepuff_<version>_darwin_amd64.tar.gz | tar xz
mv mcp-filepuff ~/.local/bin/
# Linux (ARM64)
curl -L https://github.com/lukaszraczylo/filepuff-mcp/releases/latest/download/mcp-filepuff-linux-arm64.tar.gz | tar xz
sudo mv mcp-filepuff /usr/local/bin/
curl -L https://github.com/lukaszraczylo/filepuff-mcp/releases/latest/download/mcp-filepuff_<version>_linux_arm64.tar.gz | tar xz
mv mcp-filepuff ~/.local/bin/
# Linux (AMD64)
curl -L https://github.com/lukaszraczylo/filepuff-mcp/releases/latest/download/mcp-filepuff-linux-amd64.tar.gz | tar xz
sudo mv mcp-filepuff /usr/local/bin/
curl -L https://github.com/lukaszraczylo/filepuff-mcp/releases/latest/download/mcp-filepuff_<version>_linux_amd64.tar.gz | tar xz
mv mcp-filepuff ~/.local/bin/
# Windows (PowerShell)
Invoke-WebRequest -Uri "https://github.com/lukaszraczylo/filepuff-mcp/releases/latest/download/mcp-filepuff-windows-amd64.zip" -OutFile mcp-filepuff.zip
Expand-Archive mcp-filepuff.zip -DestinationPath .
Move-Item mcp-filepuff.exe C:\Windows\System32\
Invoke-WebRequest -Uri "https://github.com/lukaszraczylo/filepuff-mcp/releases/latest/download/mcp-filepuff_<version>_windows_amd64.zip" -OutFile mcp-filepuff.zip
Expand-Archive mcp-filepuff.zip -DestinationPath $env:USERPROFILE\.local\bin
```
Replace `<version>` with the actual version (e.g., `v1.0.0`).
### Prerequisites
- [ripgrep](https://github.com/BurntSushi/ripgrep) (`rg`) installed and in PATH
@@ -409,6 +425,7 @@ Apply an edit to a file. Uses AST-aware editing for code files with syntax valid
| HTML | .html, .htm | Yes | Yes | - | Yes |
| Vue | .vue | Yes | Yes* | - | Yes |
| React | .jsx, .tsx | Yes | Yes | typescript-language-server | Yes |
| Elixir | .ex, .exs | Yes | Yes | elixir-ls | Yes |
\* Vue uses HTML parser for template sections
+944 -824
View File
File diff suppressed because it is too large Load Diff
+76
View File
@@ -352,6 +352,12 @@ func (e *Engine) resolveSelector(sel ASTSelector, tree *sitter.Tree, content []b
return nil, errors.NewNodeNotFoundError(selectorDesc)
}
// When using AtLine without a specific Kind, prefer the smallest (most specific) node.
// This prevents matching large parent nodes like source_file when we want a specific declaration.
if sel.AtLine > 0 && sel.Kind == "" {
matches = sortBySpecificity(matches)
}
// Use index to select specific match
index := sel.Index
if index < 0 || index >= len(matches) {
@@ -361,6 +367,76 @@ func (e *Engine) resolveSelector(sel ASTSelector, tree *sitter.Tree, content []b
return matches[index], nil
}
// sortBySpecificity sorts nodes so that the most useful nodes come first.
// Prefers: 1) Named nodes (declarations/statements) over anonymous tokens
// 2) Smaller nodes over larger ones (more specific)
func sortBySpecificity(nodes []*sitter.Node) []*sitter.Node {
if len(nodes) <= 1 {
return nodes
}
// Sort by specificity: named nodes first, then by size (smallest first)
result := make([]*sitter.Node, len(nodes))
copy(result, nodes)
for i := 0; i < len(result)-1; i++ {
for j := i + 1; j < len(result); j++ {
if shouldPrefer(result[j], result[i]) {
result[i], result[j] = result[j], result[i]
}
}
}
return result
}
// shouldPrefer returns true if node a should come before node b.
func shouldPrefer(a, b *sitter.Node) bool {
// Prefer named nodes over anonymous tokens
aIsNamed := a.IsNamed()
bIsNamed := b.IsNamed()
if aIsNamed && !bIsNamed {
return true
}
if !aIsNamed && bIsNamed {
return false
}
// Both named or both anonymous: prefer smaller meaningful nodes
// But filter out very small nodes (likely just identifiers/literals)
aSize := a.EndByte() - a.StartByte()
bSize := b.EndByte() - b.StartByte()
// If both are named, prefer "declaration" or "statement" types
aIsDecl := isDeclarationLike(a.Type())
bIsDecl := isDeclarationLike(b.Type())
if aIsDecl && !bIsDecl {
return true
}
if !aIsDecl && bIsDecl {
return false
}
// Same category: prefer smaller
return aSize < bSize
}
// isDeclarationLike returns true for node types that represent declarations or statements.
func isDeclarationLike(nodeType string) bool {
// Common declaration/statement patterns across languages
declarationPatterns := []string{
"declaration", "definition", "statement", "spec", "clause",
"function", "method", "class", "struct", "interface", "type",
"import", "package", "module", "const", "var", "let",
}
for _, pattern := range declarationPatterns {
if strings.Contains(nodeType, pattern) {
return true
}
}
return false
}
// matchesSelector checks if a node matches the selector criteria.
func (e *Engine) matchesSelector(sel ASTSelector, n *sitter.Node, content []byte) bool {
// Check kind
+495
View File
@@ -8,6 +8,7 @@ import (
"testing"
"github.com/lukaszraczylo/mcp-filepuff/internal/parser"
sitter "github.com/smacker/go-tree-sitter"
)
func TestValidateEdit(t *testing.T) {
@@ -754,6 +755,500 @@ func TestValidateTextEdit(t *testing.T) {
}
}
func TestResolveSelectorAtLineSpecificity(t *testing.T) {
// This test verifies that when using AtLine selector without Kind,
// the smallest (most specific) node is selected, not the largest parent.
registry := parser.NewRegistry()
defer registry.Close()
e := NewEngine(registry)
// Create a Go file with nested structures
content := []byte(`package main
const (
// First const group
FOO = "foo"
BAR = "bar"
)
const (
// Second const group
BAZ = "baz"
QUX = "qux"
)
func main() {
println(FOO)
}
`)
ctx := context.Background()
result, err := registry.Parse(ctx, "test.go", content)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
// Selector at line 5 (FOO = "foo") should match the specific const_spec,
// not the entire const_declaration or source_file
node, err := e.resolveSelector(ASTSelector{AtLine: 5}, result.Tree, content)
if err != nil {
t.Fatalf("resolve failed: %v", err)
}
// The node should be small - just the "FOO = \"foo\"" part
nodeText := string(content[node.StartByte():node.EndByte()])
if strings.Contains(nodeText, "BAR") {
t.Errorf("selected node is too large (contains BAR): %q", nodeText)
}
if strings.Contains(nodeText, "package") {
t.Errorf("selected node is the entire file: %q", truncateString(nodeText, 50))
}
t.Logf("Selected node type: %s, text: %q", node.Type(), truncateString(nodeText, 100))
}
// ==================== Regression tests for file corruption bug ====================
// These tests verify that the fix for the AtLine selector specificity issue
// prevents file corruption when inserting content.
func TestRegressionInsertAfterAtLine(t *testing.T) {
// Regression test: Insert after a specific const should not corrupt the file
// by inserting at the wrong location (e.g., at start of file or wrong block)
registry := parser.NewRegistry()
defer registry.Close()
e := NewEngine(registry)
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "queries.go")
// Simulate a typical Go queries file
content := `package org
const (
// queryGetOrg retrieves an organization by ID
queryGetOrg = ` + "`" + `
SELECT id, name FROM orgs WHERE id = $1
` + "`" + `
// queryListOrgs lists all organizations
queryListOrgs = ` + "`" + `
SELECT id, name FROM orgs
` + "`" + `
)
const (
// queryCreateOrg creates a new organization
queryCreateOrg = ` + "`" + `
INSERT INTO orgs (name) VALUES ($1)
` + "`" + `
)
`
if err := os.WriteFile(tmpFile, []byte(content), 0600); err != nil {
t.Fatalf("failed to write temp file: %v", err)
}
ctx := context.Background()
// Insert after the first const block (line ~14) - should NOT corrupt file
edit := &ASTEdit{
File: tmpFile,
Operation: EditInsertAfter,
Selector: ASTSelector{
Kind: "const_declaration",
AtLine: 5, // Line within the first const block
},
NewContent: `const (
// queryGetGitHub retrieves GitHub settings
queryGetGitHub = ` + "`" + `SELECT * FROM github` + "`" + `
)`,
}
result, err := e.Apply(ctx, edit)
if err != nil {
t.Fatalf("apply failed: %v", err)
}
if !result.Success {
t.Fatalf("apply was not successful: %s", result.Error)
}
// Verify the file structure is preserved
newContent, _ := os.ReadFile(tmpFile)
newStr := string(newContent)
// Check the file still starts with package declaration
if !strings.HasPrefix(newStr, "package org") {
t.Errorf("file corrupted: package declaration missing or moved\nContent:\n%s", newStr)
}
// Check original content is preserved
if !strings.Contains(newStr, "queryGetOrg") {
t.Error("original queryGetOrg was lost")
}
if !strings.Contains(newStr, "queryListOrgs") {
t.Error("original queryListOrgs was lost")
}
if !strings.Contains(newStr, "queryCreateOrg") {
t.Error("original queryCreateOrg was lost")
}
// Check new content was added
if !strings.Contains(newStr, "queryGetGitHub") {
t.Error("new queryGetGitHub was not added")
}
// Verify insertion location: queryGetGitHub should appear AFTER queryListOrgs
// but the exact position depends on where the first const block ends
listOrgsIdx := strings.Index(newStr, "queryListOrgs")
gitHubIdx := strings.Index(newStr, "queryGetGitHub")
if gitHubIdx < listOrgsIdx {
t.Errorf("queryGetGitHub inserted before queryListOrgs - wrong location")
}
t.Logf("Result diff:\n%s", result.Diff)
}
func TestRegressionInsertBeforeAtLine(t *testing.T) {
// Regression test: Insert before should work correctly with AtLine
registry := parser.NewRegistry()
defer registry.Close()
e := NewEngine(registry)
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "main.go")
content := `package main
import "fmt"
func hello() {
fmt.Println("hello")
}
func goodbye() {
fmt.Println("goodbye")
}
`
if err := os.WriteFile(tmpFile, []byte(content), 0600); err != nil {
t.Fatalf("failed to write temp file: %v", err)
}
ctx := context.Background()
// Insert before goodbye function
edit := &ASTEdit{
File: tmpFile,
Operation: EditInsertBefore,
Selector: ASTSelector{
Kind: "function_declaration",
Name: "goodbye",
AtLine: 9,
},
NewContent: `func middle() {
fmt.Println("middle")
}`,
}
result, err := e.Apply(ctx, edit)
if err != nil {
t.Fatalf("apply failed: %v", err)
}
if !result.Success {
t.Fatalf("apply was not successful: %s", result.Error)
}
newContent, _ := os.ReadFile(tmpFile)
newStr := string(newContent)
// Verify order: hello -> middle -> goodbye
helloIdx := strings.Index(newStr, "func hello()")
middleIdx := strings.Index(newStr, "func middle()")
goodbyeIdx := strings.Index(newStr, "func goodbye()")
if helloIdx == -1 || middleIdx == -1 || goodbyeIdx == -1 {
t.Fatalf("missing functions in output:\n%s", newStr)
}
if helloIdx >= middleIdx || middleIdx >= goodbyeIdx {
t.Errorf("functions in wrong order: hello=%d, middle=%d, goodbye=%d", helloIdx, middleIdx, goodbyeIdx)
}
}
func TestRegressionNestedStructures(t *testing.T) {
// Regression test: Nested structures should select the correct node
registry := parser.NewRegistry()
defer registry.Close()
e := NewEngine(registry)
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "nested.go")
content := `package main
type Outer struct {
Inner struct {
Field string
}
Other int
}
func main() {
o := Outer{}
_ = o
}
`
if err := os.WriteFile(tmpFile, []byte(content), 0600); err != nil {
t.Fatalf("failed to write temp file: %v", err)
}
ctx := context.Background()
// Replace the Inner struct field - should not affect Outer
edit := &ASTEdit{
File: tmpFile,
Operation: EditReplace,
Selector: ASTSelector{
AtLine: 5, // Line with "Field string"
},
NewContent: `Name string`,
}
result, err := e.Apply(ctx, edit)
if err != nil {
t.Fatalf("apply failed: %v", err)
}
if !result.Success {
t.Fatalf("apply was not successful: %s", result.Error)
}
newContent, _ := os.ReadFile(tmpFile)
newStr := string(newContent)
// Verify structure is preserved
if !strings.Contains(newStr, "type Outer struct") {
t.Error("Outer struct declaration lost")
}
if !strings.Contains(newStr, "Inner struct") {
t.Error("Inner struct declaration lost")
}
if !strings.Contains(newStr, "Other int") {
t.Error("Other field lost")
}
if !strings.Contains(newStr, "Name string") {
t.Error("Name field not added")
}
if strings.Contains(newStr, "Field string") {
t.Error("Old Field string should be replaced")
}
}
func TestRegressionPreservesFileIntegrity(t *testing.T) {
// Regression test: Edit should not corrupt unrelated parts of the file
registry := parser.NewRegistry()
defer registry.Close()
e := NewEngine(registry)
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "integrity.go")
content := `package main
// Copyright 2024 Example Corp
// License: MIT
import (
"fmt"
"os"
)
const Version = "1.0.0"
func main() {
fmt.Println(Version)
os.Exit(0)
}
// End of file comment
`
if err := os.WriteFile(tmpFile, []byte(content), 0600); err != nil {
t.Fatalf("failed to write temp file: %v", err)
}
ctx := context.Background()
// Replace the Version constant (line 11)
edit := &ASTEdit{
File: tmpFile,
Operation: EditReplace,
Selector: ASTSelector{
Kind: "const_declaration",
AtLine: 11,
},
NewContent: `const Version = "2.0.0"`,
}
result, err := e.Apply(ctx, edit)
if err != nil {
t.Fatalf("apply failed: %v", err)
}
if !result.Success {
t.Fatalf("apply was not successful: %s", result.Error)
}
newContent, _ := os.ReadFile(tmpFile)
newStr := string(newContent)
// Verify all unrelated parts are preserved exactly
checks := []string{
"package main",
"// Copyright 2024 Example Corp",
"// License: MIT",
`"fmt"`,
`"os"`,
"func main()",
"fmt.Println(Version)",
"os.Exit(0)",
"// End of file comment",
}
for _, check := range checks {
if !strings.Contains(newStr, check) {
t.Errorf("file integrity violated: missing %q\nContent:\n%s", check, newStr)
}
}
// Verify the change was made
if !strings.Contains(newStr, `"2.0.0"`) {
t.Error("Version was not updated to 2.0.0")
}
}
func TestRegressionMultipleConstBlocks(t *testing.T) {
// Regression test: Multiple const blocks - edit should target correct one
registry := parser.NewRegistry()
defer registry.Close()
e := NewEngine(registry)
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "consts.go")
content := `package main
const (
A = 1
B = 2
)
const (
C = 3
D = 4
)
const (
E = 5
F = 6
)
`
if err := os.WriteFile(tmpFile, []byte(content), 0600); err != nil {
t.Fatalf("failed to write temp file: %v", err)
}
ctx := context.Background()
// Insert after the second const block (containing C, D)
edit := &ASTEdit{
File: tmpFile,
Operation: EditInsertAfter,
Selector: ASTSelector{
Kind: "const_declaration",
AtLine: 9, // Line with C = 3
},
NewContent: `const X = 99`,
}
result, err := e.Apply(ctx, edit)
if err != nil {
t.Fatalf("apply failed: %v", err)
}
if !result.Success {
t.Fatalf("apply was not successful: %s", result.Error)
}
newContent, _ := os.ReadFile(tmpFile)
newStr := string(newContent)
// X should appear after D but before E
dIdx := strings.Index(newStr, "D = 4")
xIdx := strings.Index(newStr, "X = 99")
eIdx := strings.Index(newStr, "E = 5")
if xIdx == -1 {
t.Fatalf("X = 99 not found in output:\n%s", newStr)
}
if dIdx >= xIdx || xIdx >= eIdx {
t.Errorf("X inserted in wrong position: D=%d, X=%d, E=%d\nContent:\n%s", dIdx, xIdx, eIdx, newStr)
}
}
func TestSortBySpecificity(t *testing.T) {
// Unit test for the sortBySpecificity helper function
registry := parser.NewRegistry()
defer registry.Close()
content := []byte(`package main
const (
FOO = "foo"
)
`)
ctx := context.Background()
result, err := registry.Parse(ctx, "test.go", content)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
// Collect all nodes that span line 4
var nodesAtLine4 []*sitter.Node
parser.WalkTree(result.Tree.RootNode(), func(n *sitter.Node) bool {
startLine := int(n.StartPoint().Row) + 1
endLine := int(n.EndPoint().Row) + 1
if 4 >= startLine && 4 <= endLine {
nodesAtLine4 = append(nodesAtLine4, n)
}
return true
})
if len(nodesAtLine4) < 2 {
t.Skip("not enough nodes to test sorting")
}
// Sort and verify smallest declaration-like node comes first
sorted := sortBySpecificity(nodesAtLine4)
// First node should be a declaration-like node, not source_file
firstType := sorted[0].Type()
if firstType == "source_file" {
t.Errorf("source_file should not be first after sorting, got types: %v",
nodeTypes(sorted))
}
t.Logf("Sorted node types: %v", nodeTypes(sorted))
}
func nodeTypes(nodes []*sitter.Node) []string {
types := make([]string, len(nodes))
for i, n := range nodes {
types[i] = n.Type()
}
return types
}
func TestFindLineRange(t *testing.T) {
e := NewEngine(parser.NewRegistry())
+108
View File
@@ -46,6 +46,8 @@ func ExtractDocComment(n *sitter.Node, content []byte, lang protocol.Language) *
return extractPythonDocComment(n, content)
case protocol.LangC, protocol.LangCpp:
return extractCDocComment(n, content)
case protocol.LangElixir:
return extractElixirDocComment(n, content)
default:
return nil
}
@@ -548,3 +550,109 @@ func cleanPythonDocstring(doc string) string {
return strings.TrimSpace(doc)
}
// extractElixirDocComment extracts Elixir documentation from @doc and @moduledoc attributes.
// Elixir uses module attributes like @doc and @moduledoc for documentation.
func extractElixirDocComment(n *sitter.Node, content []byte) *DocComment {
// Look for @doc or @moduledoc attribute preceding this node
prev := n.PrevSibling()
for prev != nil {
// Check if this is an unary_operator with @ (module attribute)
if prev.Type() == "unary_operator" {
text := GetNodeText(prev, content)
trimmed := strings.TrimSpace(text)
// Check for @doc or @moduledoc
if strings.HasPrefix(trimmed, "@doc") || strings.HasPrefix(trimmed, "@moduledoc") {
// Extract the documentation string
docText := extractElixirDocString(prev, content)
if docText != "" {
return &DocComment{
Text: docText,
Raw: text,
Style: CommentStyleDocstring,
Tags: nil,
StartLine: int(prev.StartPoint().Row) + 1,
EndLine: int(prev.EndPoint().Row) + 1,
}
}
}
}
// Also check for regular # comments
if prev.Type() == "comment" {
comments := collectPrecedingComments(n, content, []string{"comment"})
if len(comments) > 0 {
var parts []string
var raw []string
startLine := -1
endLine := -1
for _, c := range comments {
text := GetNodeText(c, content)
raw = append(raw, text)
if startLine == -1 {
startLine = int(c.StartPoint().Row) + 1
}
endLine = int(c.EndPoint().Row) + 1
// Clean # comment
cleaned := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(text), "#"))
if cleaned != "" {
parts = append(parts, cleaned)
}
}
if len(parts) > 0 {
return &DocComment{
Text: strings.Join(parts, "\n"),
Raw: strings.Join(raw, "\n"),
Style: CommentStyleHash,
Tags: nil,
StartLine: startLine,
EndLine: endLine,
}
}
}
break
}
prev = prev.PrevSibling()
}
return nil
}
// extractElixirDocString extracts the documentation string from an Elixir @doc/@moduledoc attribute.
func extractElixirDocString(n *sitter.Node, content []byte) string {
// The doc attribute typically looks like:
// @doc """
// Documentation here
// """
// or
// @doc "Single line doc"
text := GetNodeText(n, content)
// Find the string content after @doc or @moduledoc
var docContent string
// Check for heredoc style (triple quotes)
if idx := strings.Index(text, `"""`); idx != -1 {
// Find the closing triple quotes
rest := text[idx+3:]
if endIdx := strings.Index(rest, `"""`); endIdx != -1 {
docContent = rest[:endIdx]
}
} else if idx := strings.Index(text, `"`); idx != -1 {
// Single quoted string
rest := text[idx+1:]
if endIdx := strings.Index(rest, `"`); endIdx != -1 {
docContent = rest[:endIdx]
}
}
return strings.TrimSpace(docContent)
}
+4 -1
View File
@@ -11,6 +11,7 @@ import (
sitter "github.com/smacker/go-tree-sitter"
"github.com/smacker/go-tree-sitter/c"
"github.com/smacker/go-tree-sitter/cpp"
"github.com/smacker/go-tree-sitter/elixir"
"github.com/smacker/go-tree-sitter/golang"
"github.com/smacker/go-tree-sitter/html"
"github.com/smacker/go-tree-sitter/javascript"
@@ -88,10 +89,12 @@ func getLanguage(lang protocol.Language) (*sitter.Language, error) {
case protocol.LangVue:
// Vue SFC files use HTML-like template syntax, so we use the HTML parser
return html.GetLanguage(), nil
case protocol.LangElixir:
return elixir.GetLanguage(), nil
default:
return nil, errors.New(errors.ErrInvalidLanguage, fmt.Sprintf("language %s is not supported", lang)).
WithContext("language", string(lang)).
WithRemediation("Supported languages: Go, TypeScript, JavaScript, Python, C, C++, HTML, Vue")
WithRemediation("Supported languages: Go, TypeScript, JavaScript, Python, C, C++, HTML, Vue, Elixir")
}
}
+277
View File
@@ -25,6 +25,8 @@ func ExtractSymbols(tree *sitter.Tree, content []byte, lang protocol.Language, f
return extractPythonSymbols(root, content, filename)
case protocol.LangC, protocol.LangCpp:
return extractCSymbols(root, content, filename)
case protocol.LangElixir:
return extractElixirSymbols(root, content, filename)
default:
return nil
}
@@ -472,3 +474,278 @@ func hasFunctionDeclarator(n *sitter.Node) bool {
})
return found
}
// extractElixirSymbols extracts symbols from Elixir code.
// Elixir uses `defmodule` for modules, `def`/`defp` for functions, and `defmacro`/`defmacrop` for macros.
func extractElixirSymbols(root *sitter.Node, content []byte, filename string) []protocol.Symbol {
var symbols []protocol.Symbol
WalkTree(root, func(n *sitter.Node) bool {
var symbol *protocol.Symbol
switch n.Type() {
case "call":
symbol = extractElixirCall(n, content, filename)
}
if symbol != nil {
if doc := ExtractDocComment(n, content, protocol.LangElixir); doc != nil {
symbol.Doc = FormatDocComment(doc)
}
symbols = append(symbols, *symbol)
}
return true
})
return symbols
}
// extractElixirCall extracts symbols from Elixir call nodes (def, defp, defmodule, defmacro, etc.).
func extractElixirCall(n *sitter.Node, content []byte, filename string) *protocol.Symbol {
// Get the function being called (first child is usually the target)
if n.NamedChildCount() < 1 {
return nil
}
target := n.NamedChild(0)
if target == nil {
return nil
}
targetText := GetNodeText(target, content)
switch targetText {
case "defmodule":
return extractElixirModule(n, content, filename)
case "def", "defp":
return extractElixirFunction(n, content, filename, targetText == "defp")
case "defmacro", "defmacrop":
return extractElixirMacro(n, content, filename)
case "defstruct":
return extractElixirStruct(n, content, filename)
case "defprotocol":
return extractElixirProtocol(n, content, filename)
case "defimpl":
return extractElixirImpl(n, content, filename)
}
return nil
}
// extractElixirModule extracts a module definition.
func extractElixirModule(n *sitter.Node, content []byte, filename string) *protocol.Symbol {
// defmodule ModuleName do ... end
// The module name is in the arguments
args := n.ChildByFieldName("arguments")
if args == nil {
// Try finding it as the second named child
if n.NamedChildCount() >= 2 {
args = n.NamedChild(1)
}
}
if args == nil {
return nil
}
// Find the alias (module name) in the arguments
var moduleName string
WalkTree(args, func(node *sitter.Node) bool {
if node.Type() == "alias" {
moduleName = GetNodeText(node, content)
return false
}
return true
})
if moduleName == "" {
return nil
}
return &protocol.Symbol{
Name: moduleName,
Kind: protocol.SymbolModule,
Location: NodeLocation(n, filename),
}
}
// extractElixirFunction extracts a function definition.
func extractElixirFunction(n *sitter.Node, content []byte, filename string, isPrivate bool) *protocol.Symbol {
// def function_name(args) do ... end
// The function name and args are in the arguments of the call
if n.NamedChildCount() < 2 {
return nil
}
// Second child contains the function definition
funcDef := n.NamedChild(1)
if funcDef == nil {
return nil
}
var funcName string
// The function definition can be:
// 1. A call node (function with args): func_name(arg1, arg2)
// 2. An identifier (function without args): func_name
switch funcDef.Type() {
case "call":
// Get the function name from the call target
if funcDef.NamedChildCount() >= 1 {
nameNode := funcDef.NamedChild(0)
if nameNode != nil {
funcName = GetNodeText(nameNode, content)
}
}
case "identifier":
funcName = GetNodeText(funcDef, content)
case "binary_operator":
// Guard clause: def func_name(args) when guard do ... end
// The left side contains the actual function call
WalkTree(funcDef, func(node *sitter.Node) bool {
if node.Type() == "call" && node.NamedChildCount() >= 1 {
nameNode := node.NamedChild(0)
if nameNode != nil && nameNode.Type() == "identifier" {
funcName = GetNodeText(nameNode, content)
return false
}
}
if node.Type() == "identifier" && funcName == "" {
funcName = GetNodeText(node, content)
return false
}
return true
})
}
if funcName == "" {
return nil
}
kind := protocol.SymbolFunction
if isPrivate {
funcName = funcName + " (private)"
}
return &protocol.Symbol{
Name: funcName,
Kind: kind,
Location: NodeLocation(n, filename),
}
}
// extractElixirMacro extracts a macro definition.
func extractElixirMacro(n *sitter.Node, content []byte, filename string) *protocol.Symbol {
// Similar to function extraction
if n.NamedChildCount() < 2 {
return nil
}
funcDef := n.NamedChild(1)
if funcDef == nil {
return nil
}
var macroName string
switch funcDef.Type() {
case "call":
if funcDef.NamedChildCount() >= 1 {
nameNode := funcDef.NamedChild(0)
if nameNode != nil {
macroName = GetNodeText(nameNode, content)
}
}
case "identifier":
macroName = GetNodeText(funcDef, content)
}
if macroName == "" {
return nil
}
return &protocol.Symbol{
Name: macroName + " (macro)",
Kind: protocol.SymbolFunction,
Location: NodeLocation(n, filename),
}
}
// extractElixirStruct extracts a struct definition.
func extractElixirStruct(n *sitter.Node, content []byte, filename string) *protocol.Symbol {
// defstruct is typically inside a module, the struct name is the module name
// We just mark this as a struct symbol
return &protocol.Symbol{
Name: "defstruct",
Kind: protocol.SymbolStruct,
Location: NodeLocation(n, filename),
}
}
// extractElixirProtocol extracts a protocol definition.
func extractElixirProtocol(n *sitter.Node, content []byte, filename string) *protocol.Symbol {
// defprotocol ProtocolName do ... end
if n.NamedChildCount() < 2 {
return nil
}
args := n.NamedChild(1)
if args == nil {
return nil
}
var protocolName string
WalkTree(args, func(node *sitter.Node) bool {
if node.Type() == "alias" {
protocolName = GetNodeText(node, content)
return false
}
return true
})
if protocolName == "" {
return nil
}
return &protocol.Symbol{
Name: protocolName,
Kind: protocol.SymbolInterface,
Location: NodeLocation(n, filename),
}
}
// extractElixirImpl extracts a protocol implementation.
func extractElixirImpl(n *sitter.Node, content []byte, filename string) *protocol.Symbol {
// defimpl Protocol, for: Type do ... end
if n.NamedChildCount() < 2 {
return nil
}
args := n.NamedChild(1)
if args == nil {
return nil
}
var implName string
WalkTree(args, func(node *sitter.Node) bool {
if node.Type() == "alias" {
if implName == "" {
implName = GetNodeText(node, content)
} else {
implName = implName + " for " + GetNodeText(node, content)
return false
}
}
return true
})
if implName == "" {
return nil
}
return &protocol.Symbol{
Name: implName,
Kind: protocol.SymbolClass,
Location: NodeLocation(n, filename),
}
}
+93
View File
@@ -224,3 +224,96 @@ int main() {
}
}
}
func TestExtractElixirSymbols(t *testing.T) {
r := NewRegistry()
defer r.Close()
content := `defmodule MyApp.User do
@moduledoc """
User module for the application.
"""
defstruct [:name, :email]
@doc """
Creates a new user.
"""
def new(name, email) do
%__MODULE__{name: name, email: email}
end
defp validate(user) do
# Private validation function
user
end
defmacro is_user(term) do
quote do
is_struct(unquote(term), __MODULE__)
end
end
end
defprotocol Greeting do
@doc "Greet the entity"
def greet(entity)
end
defimpl Greeting, for: MyApp.User do
def greet(user) do
"Hello, #{user.name}!"
end
end
`
ctx := context.Background()
result, err := r.Parse(ctx, "test.ex", []byte(content))
if err != nil {
t.Fatalf("parse failed: %v", err)
}
symbols := ExtractSymbols(result.Tree, []byte(content), protocol.LangElixir, "test.ex")
// Check that we found some symbols
if len(symbols) == 0 {
t.Fatal("expected to find some symbols")
}
// Look for specific expected symbols - we focus on top-level constructs
// that the current implementation can reliably extract
expectedSymbols := map[string]protocol.SymbolKind{
"MyApp.User": protocol.SymbolModule,
"Greeting": protocol.SymbolInterface,
}
found := make(map[string]bool)
for _, sym := range symbols {
for name, expectedKind := range expectedSymbols {
if sym.Name == name {
found[name] = true
if sym.Kind != expectedKind {
t.Errorf("symbol %s: expected kind %s, got %s", sym.Name, expectedKind, sym.Kind)
}
}
}
}
for name := range expectedSymbols {
if !found[name] {
t.Errorf("expected to find symbol %s, found symbols: %v", name, symbols)
}
}
// Verify we found the defstruct
foundStruct := false
for _, sym := range symbols {
if sym.Kind == protocol.SymbolStruct {
foundStruct = true
break
}
}
if !foundStruct {
t.Error("expected to find a struct symbol")
}
}
+2
View File
@@ -670,6 +670,8 @@ func languageToExtension(language string) string {
return ".c"
case "cpp", "c++":
return ".cpp"
case "elixir":
return ".ex"
default:
return ""
}
+3
View File
@@ -60,6 +60,7 @@ const (
LangVue Language = "vue"
LangJSON Language = "json"
LangYAML Language = "yaml"
LangElixir Language = "elixir"
LangUnknown Language = "unknown"
)
@@ -87,6 +88,8 @@ func DetectLanguage(filename string) Language {
return LangJSON
case ".yaml", ".yml":
return LangYAML
case ".ex", ".exs":
return LangElixir
default:
return LangUnknown
}
+4
View File
@@ -29,10 +29,14 @@ func TestDetectLanguage(t *testing.T) {
{"index.html", LangHTML},
{"page.htm", LangHTML},
{"Component.vue", LangVue},
{"app.ex", LangElixir},
{"app_test.exs", LangElixir},
{"mix.exs", LangElixir},
{"unknown.txt", LangUnknown},
{"README", LangUnknown},
{"path/to/file.go", LangGo},
{"path/to/file.ts", LangTypeScript},
{"path/to/file.ex", LangElixir},
}
for _, tt := range tests {
+388
View File
@@ -0,0 +1,388 @@
#!/bin/bash
# MCP Filepuff Installation Script
# Downloads and installs the latest version of mcp-filepuff
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# GitHub repository information
REPO="lukaszraczylo/filepuff-mcp"
BINARY_NAME="mcp-filepuff"
# Installation directory (will try ~/.local/bin first, then /usr/local/bin)
INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/bin}"
# Functions
print_info() {
printf "${GREEN}[INFO]${NC} %s\n" "$1"
}
print_warn() {
printf "${YELLOW}[WARN]${NC} %s\n" "$1"
}
print_error() {
printf "${RED}[ERROR]${NC} %s\n" "$1" >&2
}
detect_platform() {
local os
local arch
# Detect OS
case "$(uname -s)" in
Linux*) os="linux" ;;
Darwin*) os="darwin" ;;
MINGW*|MSYS*|CYGWIN*)
print_error "Windows is not directly supported. Please use WSL2."
exit 1
;;
*)
print_error "Unsupported operating system: $(uname -s)"
exit 1
;;
esac
# Detect architecture
case "$(uname -m)" in
x86_64|amd64) arch="amd64" ;;
arm64|aarch64) arch="arm64" ;;
*)
print_error "Unsupported architecture: $(uname -m)"
exit 1
;;
esac
# Warn about unsupported combinations
if [ "$os" = "linux" ] && [ "$arch" = "arm64" ]; then
print_error "Linux ARM64 builds are not currently available due to CGO cross-compilation complexity"
print_error "Please build from source: https://github.com/lukaszraczylo/filepuff-mcp#build-from-source"
exit 1
fi
echo "${os}_${arch}"
}
check_dependencies() {
local missing_deps=()
# Check for required commands
for cmd in curl tar; do
if ! command -v "$cmd" &> /dev/null; then
missing_deps+=("$cmd")
fi
done
if [ ${#missing_deps[@]} -ne 0 ]; then
print_error "Missing required dependencies: ${missing_deps[*]}"
print_error "Please install them and try again."
exit 1
fi
}
detect_package_manager() {
if command -v brew &> /dev/null; then
echo "brew"
elif command -v apt-get &> /dev/null; then
echo "apt"
elif command -v yum &> /dev/null; then
echo "yum"
elif command -v pacman &> /dev/null; then
echo "pacman"
elif command -v apk &> /dev/null; then
echo "apk"
else
echo "unknown"
fi
}
install_prerequisites() {
local pkg_mgr="$1"
local install_lsp="${2:-false}"
print_info "Checking prerequisites..."
# Check if ripgrep is installed
if ! command -v rg &> /dev/null; then
print_warn "ripgrep not found - required for file search functionality"
case "$pkg_mgr" in
brew)
print_info "Installing ripgrep via Homebrew..."
brew install ripgrep
;;
apt)
print_info "Installing ripgrep via apt..."
sudo apt-get update && sudo apt-get install -y ripgrep
;;
yum)
print_info "Installing ripgrep via yum..."
sudo yum install -y ripgrep
;;
pacman)
print_info "Installing ripgrep via pacman..."
sudo pacman -S --noconfirm ripgrep
;;
apk)
print_info "Installing ripgrep via apk..."
sudo apk add --no-cache ripgrep
;;
unknown)
print_error "Could not detect package manager"
print_error "Please install ripgrep manually: https://github.com/BurntSushi/ripgrep"
exit 1
;;
esac
else
print_info "✓ ripgrep is already installed"
fi
if [ "$install_lsp" = "true" ]; then
print_info "Installing LSP servers for enhanced IDE features..."
# Install gopls (Go LSP)
if command -v go &> /dev/null && ! command -v gopls &> /dev/null; then
print_info "Installing gopls (Go language server)..."
go install golang.org/x/tools/gopls@latest
fi
# Install typescript-language-server (TypeScript/JavaScript)
if command -v npm &> /dev/null && ! command -v typescript-language-server &> /dev/null; then
print_info "Installing typescript-language-server..."
npm install -g typescript-language-server typescript
fi
# Install pylsp (Python LSP)
if command -v pip3 &> /dev/null && ! python3 -c "import pylsp" 2>/dev/null; then
print_info "Installing python-lsp-server..."
pip3 install python-lsp-server
fi
# Install clangd (C/C++)
if ! command -v clangd &> /dev/null; then
case "$pkg_mgr" in
brew)
print_info "Installing clangd via Homebrew..."
brew install llvm
;;
apt)
print_info "Installing clangd via apt..."
sudo apt-get install -y clangd
;;
yum)
print_info "Installing clangd via yum..."
sudo yum install -y clang-tools-extra
;;
pacman)
print_info "Installing clangd via pacman..."
sudo pacman -S --noconfirm clang
;;
esac
fi
fi
}
get_latest_version() {
local version
version=$(curl -sSf "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name"' | cut -d'"' -f4)
if [ -z "$version" ]; then
print_error "Failed to fetch latest version from GitHub"
exit 1
fi
echo "$version"
}
download_and_install() {
local version="$1"
local platform="$2"
local tmpdir
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
print_info "Downloading mcp-filepuff ${version} for ${platform}..."
# Construct download URLs
local archive_name="mcp-filepuff_${version#v}_${platform}.tar.gz"
local archive_url="https://github.com/${REPO}/releases/download/${version}/${archive_name}"
local checksum_url="https://github.com/${REPO}/releases/download/${version}/checksums.txt"
# Download archive
if ! curl -sSfL "$archive_url" -o "$tmpdir/$archive_name"; then
print_error "Failed to download $archive_url"
exit 1
fi
# Download checksums
if ! curl -sSfL "$checksum_url" -o "$tmpdir/checksums.txt"; then
print_warn "Failed to download checksums, skipping verification"
else
print_info "Verifying checksum..."
cd "$tmpdir"
# Extract expected checksum for our archive
local expected_checksum
expected_checksum=$(grep "$archive_name" checksums.txt | awk '{print $1}')
if [ -z "$expected_checksum" ]; then
print_warn "Checksum not found for $archive_name, skipping verification"
else
# Calculate actual checksum (use shasum on macOS, sha256sum on Linux)
local actual_checksum
if command -v sha256sum &> /dev/null; then
actual_checksum=$(sha256sum "$archive_name" | awk '{print $1}')
elif command -v shasum &> /dev/null; then
actual_checksum=$(shasum -a 256 "$archive_name" | awk '{print $1}')
else
print_warn "No checksum utility found, skipping verification"
cd - > /dev/null
return
fi
if [ "$expected_checksum" = "$actual_checksum" ]; then
print_info "Checksum verification passed"
else
print_error "Checksum verification failed"
print_error "Expected: $expected_checksum"
print_error "Actual: $actual_checksum"
exit 1
fi
fi
cd - > /dev/null
fi
# Extract archive
print_info "Extracting archive..."
tar -xzf "$tmpdir/$archive_name" -C "$tmpdir"
# Ensure install directory exists
if [ ! -d "$INSTALL_DIR" ]; then
print_info "Creating installation directory: $INSTALL_DIR"
mkdir -p "$INSTALL_DIR"
fi
# Install binary
print_info "Installing to $INSTALL_DIR/$BINARY_NAME..."
if [ -w "$INSTALL_DIR" ]; then
cp "$tmpdir/$BINARY_NAME" "$INSTALL_DIR/$BINARY_NAME"
chmod +x "$INSTALL_DIR/$BINARY_NAME"
else
print_warn "No write permission to $INSTALL_DIR, trying with sudo..."
sudo cp "$tmpdir/$BINARY_NAME" "$INSTALL_DIR/$BINARY_NAME"
sudo chmod +x "$INSTALL_DIR/$BINARY_NAME"
fi
print_info "Installation complete!"
}
check_path() {
if ! echo "$PATH" | grep -q "$INSTALL_DIR"; then
print_warn "$INSTALL_DIR is not in your PATH"
print_warn "Add it to your PATH by adding this line to your shell profile:"
echo ""
echo " export PATH=\"\$PATH:$INSTALL_DIR\""
echo ""
fi
}
verify_installation() {
if command -v "$BINARY_NAME" &> /dev/null; then
local installed_version
installed_version=$("$BINARY_NAME" -version 2>&1 || echo "unknown")
print_info "Successfully installed: $installed_version"
else
print_warn "Binary installed but not found in PATH"
print_info "You can run it directly: $INSTALL_DIR/$BINARY_NAME"
fi
}
main() {
print_info "MCP Filepuff Installation Script"
echo ""
# Parse command line arguments
local install_lsp="false"
local skip_prereqs="false"
while [[ $# -gt 0 ]]; do
case "$1" in
--with-lsp)
install_lsp="true"
shift
;;
--skip-prereqs)
skip_prereqs="true"
shift
;;
--help)
echo "Usage: install.sh [OPTIONS]"
echo ""
echo "Options:"
echo " --with-lsp Install LSP servers (gopls, typescript-language-server, pylsp, clangd)"
echo " --skip-prereqs Skip prerequisite installation (ripgrep, LSP servers)"
echo " --help Show this help message"
exit 0
;;
*)
print_error "Unknown option: $1"
print_error "Use --help for usage information"
exit 1
;;
esac
done
# Check dependencies
check_dependencies
# Detect platform
local platform
platform=$(detect_platform)
print_info "Detected platform: $platform"
# Detect package manager
local pkg_mgr
pkg_mgr=$(detect_package_manager)
print_info "Detected package manager: $pkg_mgr"
echo ""
# Install prerequisites unless skipped
if [ "$skip_prereqs" != "true" ]; then
install_prerequisites "$pkg_mgr" "$install_lsp"
echo ""
fi
# Get latest version
local version
version=$(get_latest_version)
print_info "Latest version: $version"
echo ""
# Download and install
download_and_install "$version" "$platform"
echo ""
# Check PATH
check_path
# Verify installation
verify_installation
echo ""
print_info "To get started, run: $BINARY_NAME --help"
if [ "$install_lsp" != "true" ]; then
echo ""
print_info "Tip: Run with --with-lsp to install LSP servers for enhanced IDE features"
fi
}
# Run main function
main "$@"