A Language Server Protocol (LSP) implementation for Perl, built on tree-sitter-perl for fast, incremental, error-tolerant parsing.
Perl has no good LSP. The existing options (Perl::LanguageServer, PLS) are slow, incomplete, and struggle with Perl's dynamic nature. This project takes a different approach: use tree-sitter for parsing and a hand-built scope graph for name resolution — rather than trying to bolt IDE features onto the Perl interpreter.
- Rust toolchain (install via rustup)
- tree-sitter-perl cloned as a sibling directory
# Clone tree-sitter-perl next to this repo (if not already)
git clone https://github.com/tree-sitter-perl/tree-sitter-perl ../tree-sitter-perl
# Generate the parser (requires tree-sitter CLI)
cargo install tree-sitter-cli
cd ../tree-sitter-perl && tree-sitter generate && cd -
# Build
cargo build --releaseThe binary is at target/release/perl-lsp.
Add to your Neovim config (e.g. ~/.config/nvim/init.lua):
vim.lsp.config["perl-lsp"] = {
cmd = { "/absolute/path/to/perl-lsp" },
filetypes = { "perl" },
root_markers = { ".git", "Makefile", "cpanfile", "Makefile.PL", "Build.PL" },
}
vim.lsp.enable("perl-lsp")Or test with the included throwaway config:
nvim -u test_nvim_init.lua test_files/sample.pl-
Install the Generic LSP Client extension (or any generic LSP extension).
-
Add to your VS Code settings (
.vscode/settings.json):
{
"glspClient.serverConfigs": [
{
"id": "perl-lsp",
"name": "Perl LSP",
"command": "${workspaceFolder}/target/release/perl-lsp",
"languages": ["perl"]
}
]
}- documentSymbol — outline of subs, packages, variables, classes (with fields/methods as children)
- definition — go-to-def for variables (scope-aware), subs, methods (type-inferred), packages/classes, hash keys; works through expression chains (
$obj->get_foo()->bar()) - references — scope-aware for variables, file-wide for functions/packages/hash keys; resolves through expression chains
- hover — shows declaration line, inferred types, return types, class-aware for methods
- rename — scope-aware for variables, file-wide for functions/packages/hash keys
- completion — scope-aware variables (cross-sigil forms), subs, methods (type-inferred with return type detail), packages, hash keys (class-aware), deref snippets (
[$0]/{$0}/($0)for ArrayRef/HashRef/CodeRef) - signatureHelp — parameter info with inferred types for subs/methods (signature syntax + legacy
@_pattern), triggers on(and, - inlayHints — type annotations for variables (Object/HashRef/ArrayRef/CodeRef) and sub return types
- documentHighlight — highlight all occurrences with read/write distinction
- selectionRange — expand/shrink selection via tree-sitter node hierarchy
- foldingRange — blocks, subs, classes, pod sections
- formatting — shells out to perltidy (respects .perltidyrc)
- semanticTokens/full — variable tokens with modifiers: scalar/array/hash, declaration, modification
- codeAction — auto-import: adds
use Module qw(func);for unresolved functions - Diagnostics — unresolved function/method warnings (skips builtins, local subs, imported functions; method diagnostics check locally-defined classes)
- Module resolution — resolves
@EXPORT/@EXPORT_OKfrom imported modules via@INC - cpanfile pre-scan — indexes project dependencies at startup with progress reporting
- Auto-import completions — suggests exported functions from cached modules with
additionalTextEdits - Diagnostics — warns on unresolved function calls (skips builtins, local subs, imported functions); auto-import code actions for functions found in cached modules
- SQLite cache — per-project persistent cache, survives restarts, validates against
@INCchanges
src/
├── main.rs Entry point, stdio transport, --parse-exports subprocess mode
├── backend.rs LanguageServer trait impl (tower-lsp), request routing
├── document.rs Document store with tree-sitter parsing
├── file_analysis.rs Data model: scopes, symbols, refs, imports, type inference engine
├── builder.rs Single-pass CST → FileAnalysis builder
├── cursor_context.rs Cursor position analysis: completion/signature/selection context
├── symbols.rs LSP adapter: converts FileAnalysis types to LSP types
├── module_index.rs Cross-file: public API, reverse index, concurrent cache
├── module_resolver.rs Background resolver thread, subprocess isolation, export extraction
├── module_cache.rs SQLite persistence, schema migrations, mtime validation
└── cpanfile.rs cpanfile parsing via tree-sitter queries
| Crate | Purpose |
|---|---|
tower-lsp 0.20 |
LSP framework (#[tower_lsp::async_trait]) |
tree-sitter 0.25 |
Incremental parsing |
tree-sitter-perl |
Perl grammar (path dep to ../tree-sitter-perl) |
dashmap 6 |
Concurrent document store + module cache |
rusqlite 0.32 |
SQLite persistence for module index (bundled) |
- When a file is opened/edited,
usestatements trigger background module resolution - A dedicated
std::thread(not tokio) does all filesystem I/O — never blocks the async runtime - Modules are located via
@INC, exports extracted by tree-sitter parsing in isolated subprocesses - Results stored in
Arc<DashMap>(async handlers read) + SQLite (persists across restarts) - A reverse index (
function → modules) enables O(1) exporter lookup for diagnostics and completions - At startup,
cpanfileis parsed with tree-sitter queries to pre-resolve project dependencies - After module resolution, diagnostics are refreshed for all open files (no stale false positives)
The LSP server uses env_logger. Debug mode is opt-in via PERL_LSP_DEBUG:
# Normal usage — no logging
nvim --clean -u test_nvim_init.lua test_files/sample.pl
# Debug mode — full logging to /tmp/perl-lsp.log
PERL_LSP_DEBUG=1 nvim --clean -u test_nvim_init.lua test_files/sample.plThen tail the log: tail -f /tmp/perl-lsp.log
| Level | What |
|---|---|
info |
Module resolution: queued, resolved, export counts; cpanfile parsing |
warn |
Slow parses (>100ms), subprocess timeouts, skipped files |
debug |
Every did_change dumps document text to /tmp/perl-lsp-last-update.pl |
tree-sitter-perl's external scanner can occasionally enter an infinite loop. Module resolution runs in isolated child processes with a 5-second timeout — the child is SIGKILL'd and the module skipped. The main LSP process stays alive.
The file /tmp/perl-lsp-last-update.pl contains the last document text sent to the parser, useful for reproducing hangs.
- Single-file: all core LSP features (definition, references, hover, rename, completion, signature help, highlights, selection range, folding, formatting, semantic tokens)
- Scope-aware variable resolution (my, our, state, signatures, for-loops, class fields)
- Core Perl
classsupport (5.38+): class extraction, field/method symbols, type-inferred method resolution - Hash key intelligence: go-to-def, refs, rename, hover, completion (class-aware via bless patterns)
- Type inference: constructor tracking, builtin return types, expression chain resolution (
$obj->get_foo()->bar()) - Inlay hints: variable type annotations and sub return types
- Enhanced completion: method return types in detail, deref snippets for typed references
- Enhanced signature help: inferred parameter types
- Cross-file module resolution with background resolver thread
- cpanfile pre-scan with tree-sitter queries and progress reporting
- Auto-import completions and code actions
- SQLite persistence with per-project cache segregation
- Phase-aware cpanfile filtering (test deps only in
t/files) — design doc - Framework submodule whitelist (
Mojo::*,Moose::*patterns) - OOP framework stubs (Moo, Moose, Class::Accessor) — declarative DSL → class shape mapping
- Bulk
@INCscan for go-to-def into any installed module - Subprocess batching: parse multiple modules per child process to reduce spawn overhead for bulk scans
- Workspace-wide references and rename
- Method resolution across inheritance hierarchies (
@ISA/ C3)
- tree-sitter-perl
- tower-lsp
- tree-sitter
- Scope Graphs: A Theory of Name Resolution — influenced our approach to name resolution