Skip to content

Commit fcd3dcf

Browse files
committed
init
0 parents  commit fcd3dcf

21 files changed

Lines changed: 1150 additions & 0 deletions

.github/workflows/ci.yml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
jobs:
9+
test:
10+
runs-on: ubuntu-latest
11+
steps:
12+
- uses: actions/checkout@v4
13+
- uses: rhysd/action-setup-vim@v1
14+
with:
15+
neovim: true
16+
version: nightly
17+
- run: make test
18+
19+
style:
20+
runs-on: ubuntu-latest
21+
steps:
22+
- uses: actions/checkout@v4
23+
- uses: JohnnyMorganz/stylua-action@v4
24+
with:
25+
token: ${{ github.token }}
26+
version: latest
27+
args: --check .

.luarc.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"runtime": {
3+
"version": "LuaJIT"
4+
},
5+
"workspace": {
6+
"library": [
7+
"$VIMRUNTIME",
8+
"${3rd}/luv/library"
9+
],
10+
"checkThirdParty": false
11+
}
12+
}

.styluaignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
test/fixtures/

Makefile

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
test:
2+
nvim --headless -l test/resolve_test.lua
3+
nvim --headless -l test/compute_edits_test.lua
4+
nvim --headless -l test/format_test.lua
5+
nvim --headless -l test/proxy_test.lua
6+
nvim --headless -l test/pipeline_test.lua
7+
8+
style:
9+
stylua --check .
10+
11+
.PHONY: test style

README.md

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
# formatls.nvim
2+
3+
An in-process LSP formatting server for Neovim. Runs as a native LSP client — no external binary needed.
4+
5+
Supports CLI formatters (biome, prettier, stylua), LSP code actions (organize imports), and chaining multiple steps in a pipeline. Automatically resolves local `node_modules/.bin` binaries and detects config files.
6+
7+
Requires Neovim 0.12+.
8+
9+
## Install
10+
11+
With vim.pack:
12+
13+
```lua
14+
vim.pack.add("https://github.com/sindrip/formatls.nvim")
15+
```
16+
17+
With lazy.nvim:
18+
19+
```lua
20+
{ "sindrip/formatls.nvim" }
21+
```
22+
23+
## Setup
24+
25+
```lua
26+
vim.lsp.config.formatls = {
27+
init_options = {
28+
formatters_by_ft = {
29+
typescript = {
30+
{ "biome" }, -- try biome first
31+
{ "source.organizeImports", "prettier" }, -- fall back to organize imports + prettier
32+
},
33+
go = {
34+
{ "source.organizeImports", "source.format" }, -- code actions via gopls
35+
},
36+
lua = {
37+
{ "stylua" },
38+
},
39+
},
40+
},
41+
}
42+
43+
vim.lsp.enable("formatls")
44+
```
45+
46+
Then format with `vim.lsp.buf.format()` or your preferred keymap.
47+
48+
## How it works
49+
50+
formatls registers as an LSP server that advertises `documentFormattingProvider`. It proxies formatting capabilities from other LSP servers on the buffer so that all format requests go through formatls.
51+
52+
### `formatters_by_ft`
53+
54+
Each filetype maps to a list of **groups**. Groups are tried in order — the first group where all CLI formatters are available is used.
55+
56+
A group is a list of steps, executed sequentially:
57+
58+
| Step | Description |
59+
|---|---|
60+
| `"biome"`, `"prettier"`, `"stylua"` | CLI formatter — must have a spec in `lua/formatls/formatters/` |
61+
| `"source.organizeImports"` | LSP code action — sent to servers that support `textDocument/codeAction` |
62+
| `"source.format"` | LSP formatting — delegated to the original server's formatter |
63+
64+
### CLI formatter resolution
65+
66+
For each CLI formatter, formatls:
67+
68+
1. Looks up the formatter spec by name (e.g. `require("formatls.formatters.biome")`)
69+
2. Searches for a local binary in `node_modules/.bin/` (walking up the directory tree)
70+
3. Falls back to a global binary on `$PATH`
71+
4. Checks for required config files (e.g. `biome.json` for biome)
72+
73+
If any step fails, the group is skipped and the next group is tried.
74+
75+
### Fallback
76+
77+
When no groups are configured for a filetype (or none are viable), formatls delegates directly to the original LSP server's formatter.
78+
79+
## Custom formatters
80+
81+
Define custom formatter specs inline:
82+
83+
```lua
84+
vim.lsp.config.formatls = {
85+
init_options = {
86+
formatters = {
87+
my_formatter = {
88+
cmd = "my-formatter",
89+
args = function(path)
90+
return { "--stdin-filepath", path }
91+
end,
92+
-- optional: required config files (skip if none found)
93+
config_files = { ".my-formatter.json" },
94+
},
95+
},
96+
formatters_by_ft = {
97+
python = {
98+
{ "my_formatter" },
99+
},
100+
},
101+
},
102+
}
103+
```

lsp/formatls.lua

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
local Server = require("formatls.server")
2+
local Proxy = require("formatls.proxy")
3+
local pipeline = require("formatls.pipeline")
4+
5+
local function exec_action(bufnr, action_kind)
6+
local params = {
7+
textDocument = { uri = vim.uri_from_bufnr(bufnr) },
8+
range = {
9+
start = { line = 0, character = 0 },
10+
["end"] = { line = vim.api.nvim_buf_line_count(bufnr), character = 0 },
11+
},
12+
context = { only = { action_kind }, diagnostics = {} },
13+
}
14+
15+
local clients = vim.lsp.get_clients({ bufnr = bufnr, method = "textDocument/codeAction" })
16+
17+
for _, client in ipairs(clients) do
18+
local res = client:request_sync("textDocument/codeAction", params, 5000, bufnr)
19+
if res and not res.err then
20+
for _, action in ipairs(res.result or {}) do
21+
if not action.edit then
22+
local resolved = client:request_sync("codeAction/resolve", action, 5000, bufnr)
23+
if resolved and resolved.result then
24+
action = resolved.result
25+
end
26+
end
27+
28+
if action.edit then
29+
vim.lsp.util.apply_workspace_edit(action.edit, "utf-16")
30+
end
31+
if action.command then
32+
client:request_sync("workspace/executeCommand", action.command, 5000, bufnr)
33+
end
34+
end
35+
end
36+
end
37+
end
38+
39+
local function get_lsp_edits(proxy, bufnr, method, params)
40+
for _, entry in ipairs(proxy:get_formatters(bufnr)) do
41+
local client = vim.lsp.get_client_by_id(entry.client_id)
42+
if client then
43+
local result = client:request_sync(method, params, 5000, bufnr)
44+
if result and result.result and #result.result > 0 then
45+
return result.result
46+
end
47+
end
48+
end
49+
return nil
50+
end
51+
52+
local function handle_format(self, method, params)
53+
local bufnr = vim.uri_to_bufnr(params.textDocument.uri)
54+
local filepath = vim.uri_to_fname(params.textDocument.uri)
55+
local dirname = vim.fn.fnamemodify(filepath, ":h")
56+
57+
local steps = pipeline.resolve_group(self.formatters_by_ft[vim.bo[bufnr].filetype] or {}, dirname)
58+
59+
local lsp_edits = function()
60+
return get_lsp_edits(self.proxy, bufnr, method, params)
61+
end
62+
63+
if not steps then
64+
return lsp_edits() or {}
65+
end
66+
67+
local original = table.concat(vim.api.nvim_buf_get_lines(bufnr, 0, -1, false), "\n") .. "\n"
68+
69+
local final = pipeline.run_pipeline(steps, {
70+
filepath = filepath,
71+
dirname = dirname,
72+
get_lsp_edits = lsp_edits,
73+
exec_action = function(action_kind, content)
74+
local lines = vim.split(content, "\n", { plain = true })
75+
if #lines > 0 and lines[#lines] == "" then
76+
table.remove(lines)
77+
end
78+
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
79+
exec_action(bufnr, action_kind)
80+
return table.concat(vim.api.nvim_buf_get_lines(bufnr, 0, -1, false), "\n") .. "\n"
81+
end,
82+
}, original)
83+
84+
if not final then
85+
-- Rollback buffer to original on pipeline failure
86+
local orig_lines = vim.split(original, "\n", { plain = true })
87+
if #orig_lines > 0 and orig_lines[#orig_lines] == "" then
88+
table.remove(orig_lines)
89+
end
90+
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, orig_lines)
91+
return {}
92+
end
93+
94+
-- Compute edits from the buffer's current state (which includes any action changes)
95+
local current = table.concat(vim.api.nvim_buf_get_lines(bufnr, 0, -1, false), "\n") .. "\n"
96+
if final == current then
97+
return {}
98+
end
99+
100+
return pipeline.compute_edits(current, final)
101+
end
102+
103+
local M = Server.new("formatls")
104+
105+
M.capabilities = {
106+
documentFormattingProvider = true,
107+
documentRangeFormattingProvider = true,
108+
textDocumentSync = { openClose = true },
109+
}
110+
111+
function M:on_init(params)
112+
local opts = params.initializationOptions or {}
113+
self.formatters_by_ft = opts.formatters_by_ft or {}
114+
self.notified_fts = {}
115+
self.proxy = Proxy.start(self.name)
116+
117+
for name, spec in pairs(opts.formatters or {}) do
118+
pipeline.add_spec(name, spec)
119+
end
120+
end
121+
122+
function M:on_shutdown()
123+
self.proxy:stop()
124+
end
125+
126+
M.notifications["textDocument/didOpen"] = function(self, params)
127+
local bufnr = vim.uri_to_bufnr(params.textDocument.uri)
128+
local ft = vim.bo[bufnr].filetype
129+
130+
if self.notified_fts[ft] then
131+
return
132+
end
133+
self.notified_fts[ft] = true
134+
135+
local dirname = vim.fn.fnamemodify(vim.uri_to_fname(params.textDocument.uri), ":h")
136+
local steps = pipeline.resolve_group(self.formatters_by_ft[ft] or {}, dirname)
137+
if not steps then
138+
return
139+
end
140+
141+
local names = {}
142+
for _, step in ipairs(steps) do
143+
names[#names + 1] = step.action or step.cmd or "lsp"
144+
end
145+
146+
local title = table.concat(names, " | ")
147+
self.dispatchers.server_request("window/workDoneProgress/create", { token = self.name }, function() end)
148+
self.dispatchers.notification("$/progress", { token = self.name, value = { kind = "begin", title = title } })
149+
self.dispatchers.notification("$/progress", { token = self.name, value = { kind = "end" } })
150+
end
151+
152+
M.requests["textDocument/formatting"] = function(self, params)
153+
return handle_format(self, "textDocument/formatting", params)
154+
end
155+
156+
M.requests["textDocument/rangeFormatting"] = function(self, params)
157+
return handle_format(self, "textDocument/rangeFormatting", params)
158+
end
159+
160+
return M:build()

lua/formatls/formatters/biome.lua

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
return {
2+
cmd = "biome",
3+
args = function(path)
4+
return {
5+
"format",
6+
"--stdin-file-path",
7+
path,
8+
}
9+
end,
10+
range_args = function(path, range_start, range_end)
11+
return {
12+
"format",
13+
"--stdin-file-path",
14+
path,
15+
"--range",
16+
range_start .. "-" .. range_end,
17+
}
18+
end,
19+
config_files = {
20+
"biome.json",
21+
"biome.jsonc",
22+
},
23+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
return {
2+
cmd = "prettier",
3+
args = function(path)
4+
return {
5+
"--stdin-filepath",
6+
path,
7+
}
8+
end,
9+
range_args = function(path, range_start, range_end)
10+
return {
11+
"--stdin-filepath",
12+
path,
13+
"--range-start",
14+
tostring(range_start),
15+
"--range-end",
16+
tostring(range_end),
17+
}
18+
end,
19+
config_files = {
20+
".prettierrc",
21+
".prettierrc.json",
22+
".prettierrc.yml",
23+
".prettierrc.yaml",
24+
".prettierrc.js",
25+
".prettierrc.cjs",
26+
".prettierrc.mjs",
27+
".prettierrc.toml",
28+
"prettier.config.js",
29+
"prettier.config.cjs",
30+
"prettier.config.mjs",
31+
},
32+
}

0 commit comments

Comments
 (0)