turbo-editors/turbo-corepublic Fork 0
b91316e
Commits
Clone
git clone https://git.rickub.com/turbo-editors/turbo-core.git
git clone ssh://git@rickub.com/turbo-editors/turbo-core.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

📦 Turbo Core — canonical paths: symbolic links resolved in URIs and document keys (moon-lsp on macOS)

k33g committed 2026-09-19T13:28:52+02:00 Browse files
b91316e parent: f3ade8d
added .memory/handoffs/2026-09-19-canonical-paths.md +21 -0
new file mode 100644
@@ -0,0 +1,21 @@
1+# Handoff — 2026-09-19 — canonical paths (v1.0.1)
2+
3+## State
4+
5+- Fix in place and green: `lsp.CanonicalPath` used by `PathToURI` and `app.pathKey`. Three new tests, one falsified against the old code. `make check` green.
6+- `release.env` says `TAG="v1.0.1"`. **Not committed, not tagged.**
7+
8+## Next steps
9+
10+1. `./01-release.tag.sh` here (turbo-core) — from a machine that reaches Rickub.
11+2. In each editor: `go get rickub.com/turbo-editors/turbo-core@v1.0.1 && go mod tidy && GOWORK=off make check`, then its `./01-release.tag.sh`. turbo-moonbit needs it to pass `make check` on macOS; the others gain the same fix for files under symbolic links.
12+3. turbo-go v1.0.0 is already published against v1.0.0; re-pin it too and it picks the fix up at its next release.
13+
14+## Traps
15+
16+- The reproduction is Linux + `TMPDIR` under a symlink (`ln -s $(mktemp -d) /tmp/symroot/tmp; TMPDIR=/tmp/symroot/tmp go test …`). Nobody has run the fixed code on macOS yet.
17+- Do not "fix" this in an editor's test by resolving `t.TempDir()`: the defect is the library's and hits any user whose files sit under a link.
18+
19+## Second pass
20+
21+The Mac found three more tests comparing paths as spelt; fixed (`windowFor` canonical, two `lsp` tests expect the canonical spelling, one new test). The whole suite is green here with `TMPDIR=/tmp/symroot/tmp TURBO_CORE_RELEASING=1 go test ./...` — run that before every release from now on, it is the closest Linux gets to a Mac's `/var/folders`.
new file mode 100644
@@ -0,0 +1,21 @@
1+# Handoff — 2026-09-19 — canonical paths (v1.0.1)
2+
3+## State
4+
5+- Fix in place and green: `lsp.CanonicalPath` used by `PathToURI` and `app.pathKey`. Three new tests, one falsified against the old code. `make check` green.
6+- `release.env` says `TAG="v1.0.1"`. **Not committed, not tagged.**
7+
8+## Next steps
9+
10+1. `./01-release.tag.sh` here (turbo-core) — from a machine that reaches Rickub.
11+2. In each editor: `go get rickub.com/turbo-editors/turbo-core@v1.0.1 && go mod tidy && GOWORK=off make check`, then its `./01-release.tag.sh`. turbo-moonbit needs it to pass `make check` on macOS; the others gain the same fix for files under symbolic links.
12+3. turbo-go v1.0.0 is already published against v1.0.0; re-pin it too and it picks the fix up at its next release.
13+
14+## Traps
15+
16+- The reproduction is Linux + `TMPDIR` under a symlink (`ln -s $(mktemp -d) /tmp/symroot/tmp; TMPDIR=/tmp/symroot/tmp go test …`). Nobody has run the fixed code on macOS yet.
17+- Do not "fix" this in an editor's test by resolving `t.TempDir()`: the defect is the library's and hits any user whose files sit under a link.
18+
19+## Second pass
20+
21+The Mac found three more tests comparing paths as spelt; fixed (`windowFor` canonical, two `lsp` tests expect the canonical spelling, one new test). The whole suite is green here with `TMPDIR=/tmp/symroot/tmp TURBO_CORE_RELEASING=1 go test ./...` — run that before every release from now on, it is the closest Linux gets to a Mac's `/var/folders`.
modified .memory/history.md +16 -0
@@ -262,3 +262,19 @@
262262 - **Quality**: run #30 **PASS** 0/0/0, complexity 2308 (+2).
263263 - **Docs**: EN + FR — `reference/app.md` (a `Knows` row and a paragraph on what saving sends) and `how-to/talk-to-a-language-server.md` (a "file created inside the editor" variant). Diagram untouched: no package added or re-wired.
264264 - **Not done**: not committed; not driven against a real gopls; the six editors inherit the fix only when turbo-core is tagged and re-pinned.
265+
266+## 2026-09-19 — canonical paths: moon-lsp on macOS knew nothing about a file opened as /var/…
267+
268+- **Origin**: the user ran turbo-moonbit's `./01-release.tag.sh` on their Mac; `make check` failed on `TestCompletionEndToEndWithRealMoonLSP` ("No completions here", after 60 s) and `TestDiagnosticsForAFileThatDoesNotCompileWithRealMoonLSP` (nothing in 30 s), the other real-server tests passing. turbo-moonbit's memory said "never run on macOS" — this was the first time.
269+- **Diagnosed** in the sandbox with the MoonBit toolchain of 2026-09-15 installed (`moon 0.1.20260915`, `moonc v0.10.13`): the fixture and manifests are fine (`moon check` reports the expected error); the tests pass under `/tmp`; they fail identically with `TMPDIR` pointing through a symbolic link. An LSP probe against `moon-lsp --stdio` showed why: opened through a link, `publishDiagnostics` carries the *real* path's URI, and a completion asked after a `didChange` that types `text.` is never answered (40 requests) — through the real path it is answered at once. macOS's `/var/folders` is a link to `/private/var`, so every `t.TempDir()` there is the linked spelling.
270+- **Changes**: `lsp/uri.go``CanonicalPath` (new, exported): `Abs`, then `EvalSymlinks`, else the deepest existing directory resolved and the rest re-appended; `PathToURI` uses it. `app/language.go``pathKey` is `lsp.CanonicalPath` (the `path/filepath` import went). `app/fakelsp_test.go` — the fake records the params of the last `textDocument/didOpen`; `lastOpenedURI()`. Tests: `TestPathToURIResolvesSymbolicLinks`, `TestCanonicalPathOfAFileNotYetOnDiskResolvesItsDirectory` (lsp), `TestDiagnosticsPublishedUnderTheRealPathReachAFileOpenedThroughALink` (app) — all three skip where a symlink cannot be created. **Falsified**: with `app/language.go` and `lsp/uri.go` stashed, the app test fails on all three assertions.
271+- **Verified**: `make check` green (whole suite). In turbo-moonbit through `go.work` (local core), all six `…WithRealMoonLSP` tests pass with `TMPDIR` under a symlink, and the whole suite passes.
272+- **Docs**: `app/README.md` (section renamed *canonical*, a paragraph on moon-lsp and macOS), `docs/{en,fr}/reference/app.md` (`Knows` row), `lsp/README.md` (`CanonicalPath` row).
273+- **Not done**: not committed, not tagged — `release.env` is set to `TAG="v1.0.1"`; the user runs `./01-release.tag.sh`, then re-pins the editors (`go get rickub.com/turbo-editors/turbo-core@v1.0.1 && go mod tidy && GOWORK=off make check`). Not run on macOS by anyone yet; the reproduction is a Linux symlink.
274+
275+## 2026-09-19 (later) — the canonical-path fix, second pass: three tests that compared as spelt
276+
277+- **Origin**: the user's `./01-release.tag.sh` for v1.0.1 stopped in `make check` on the Mac: `TestTheListPrefersAnOpenWindowOverTheDisk` (app), `TestDiagnosticsReachTheEditor` and `TestPathAndURIRoundTrip` (lsp). All three compared a path as spelt (`/var/folders/…`) with what now comes back canonical (`/private/var/…`); green on Linux because `/tmp` is no link. Reproduced with `TMPDIR` under a symlink.
278+- **Changes**: `app/actions_file.go``windowFor` compares `lsp.CanonicalPath` of both sides, not `filepath.Abs`; this is the real defect the first test caught — a location the server sends back could not find the window it was about, so the references list quoted the disk and a jump would have opened the file twice. `lsp/lsp_test.go` — the two tests now expect `CanonicalPath(path)`, with the reason in a comment. New `TestWindowForFindsAFileOpenedThroughALink` (app), **falsified** against the old `windowFor`.
279+- **Verified**: `make check` green; whole suite green with `TMPDIR` under a symlink (`TURBO_CORE_RELEASING=1` to keep the release scripts out of it).
280+- **Not done**: still not committed or tagged; `release.env` still says v1.0.1. Run `./01-release.tag.sh` again.
@@ -262,3 +262,19 @@
262 - **Quality**: run #30 **PASS** 0/0/0, complexity 2308 (+2).262 - **Quality**: run #30 **PASS** 0/0/0, complexity 2308 (+2).
263 - **Docs**: EN + FR — `reference/app.md` (a `Knows` row and a paragraph on what saving sends) and `how-to/talk-to-a-language-server.md` (a "file created inside the editor" variant). Diagram untouched: no package added or re-wired.263 - **Docs**: EN + FR — `reference/app.md` (a `Knows` row and a paragraph on what saving sends) and `how-to/talk-to-a-language-server.md` (a "file created inside the editor" variant). Diagram untouched: no package added or re-wired.
264 - **Not done**: not committed; not driven against a real gopls; the six editors inherit the fix only when turbo-core is tagged and re-pinned.264 - **Not done**: not committed; not driven against a real gopls; the six editors inherit the fix only when turbo-core is tagged and re-pinned.
265+
266+## 2026-09-19 — canonical paths: moon-lsp on macOS knew nothing about a file opened as /var/…
267+
268+- **Origin**: the user ran turbo-moonbit's `./01-release.tag.sh` on their Mac; `make check` failed on `TestCompletionEndToEndWithRealMoonLSP` ("No completions here", after 60 s) and `TestDiagnosticsForAFileThatDoesNotCompileWithRealMoonLSP` (nothing in 30 s), the other real-server tests passing. turbo-moonbit's memory said "never run on macOS" — this was the first time.
269+- **Diagnosed** in the sandbox with the MoonBit toolchain of 2026-09-15 installed (`moon 0.1.20260915`, `moonc v0.10.13`): the fixture and manifests are fine (`moon check` reports the expected error); the tests pass under `/tmp`; they fail identically with `TMPDIR` pointing through a symbolic link. An LSP probe against `moon-lsp --stdio` showed why: opened through a link, `publishDiagnostics` carries the *real* path's URI, and a completion asked after a `didChange` that types `text.` is never answered (40 requests) — through the real path it is answered at once. macOS's `/var/folders` is a link to `/private/var`, so every `t.TempDir()` there is the linked spelling.
270+- **Changes**: `lsp/uri.go``CanonicalPath` (new, exported): `Abs`, then `EvalSymlinks`, else the deepest existing directory resolved and the rest re-appended; `PathToURI` uses it. `app/language.go``pathKey` is `lsp.CanonicalPath` (the `path/filepath` import went). `app/fakelsp_test.go` — the fake records the params of the last `textDocument/didOpen`; `lastOpenedURI()`. Tests: `TestPathToURIResolvesSymbolicLinks`, `TestCanonicalPathOfAFileNotYetOnDiskResolvesItsDirectory` (lsp), `TestDiagnosticsPublishedUnderTheRealPathReachAFileOpenedThroughALink` (app) — all three skip where a symlink cannot be created. **Falsified**: with `app/language.go` and `lsp/uri.go` stashed, the app test fails on all three assertions.
271+- **Verified**: `make check` green (whole suite). In turbo-moonbit through `go.work` (local core), all six `…WithRealMoonLSP` tests pass with `TMPDIR` under a symlink, and the whole suite passes.
272+- **Docs**: `app/README.md` (section renamed *canonical*, a paragraph on moon-lsp and macOS), `docs/{en,fr}/reference/app.md` (`Knows` row), `lsp/README.md` (`CanonicalPath` row).
273+- **Not done**: not committed, not tagged — `release.env` is set to `TAG="v1.0.1"`; the user runs `./01-release.tag.sh`, then re-pins the editors (`go get rickub.com/turbo-editors/turbo-core@v1.0.1 && go mod tidy && GOWORK=off make check`). Not run on macOS by anyone yet; the reproduction is a Linux symlink.
274+
275+## 2026-09-19 (later) — the canonical-path fix, second pass: three tests that compared as spelt
276+
277+- **Origin**: the user's `./01-release.tag.sh` for v1.0.1 stopped in `make check` on the Mac: `TestTheListPrefersAnOpenWindowOverTheDisk` (app), `TestDiagnosticsReachTheEditor` and `TestPathAndURIRoundTrip` (lsp). All three compared a path as spelt (`/var/folders/…`) with what now comes back canonical (`/private/var/…`); green on Linux because `/tmp` is no link. Reproduced with `TMPDIR` under a symlink.
278+- **Changes**: `app/actions_file.go``windowFor` compares `lsp.CanonicalPath` of both sides, not `filepath.Abs`; this is the real defect the first test caught — a location the server sends back could not find the window it was about, so the references list quoted the disk and a jump would have opened the file twice. `lsp/lsp_test.go` — the two tests now expect `CanonicalPath(path)`, with the reason in a comment. New `TestWindowForFindsAFileOpenedThroughALink` (app), **falsified** against the old `windowFor`.
279+- **Verified**: `make check` green; whole suite green with `TMPDIR` under a symlink (`TURBO_CORE_RELEASING=1` to keep the release scripts out of it).
280+- **Not done**: still not committed or tagged; `release.env` still says v1.0.1. Run `./01-release.tag.sh` again.
modified .memory/summary.md +7 -0
@@ -125,6 +125,8 @@ The decisions inherited from turbo-go about the *editor's behaviour* — the eve
125125 - **Copying goes to two clipboards.** The editor's own (`Shift-Ins` pastes it into a file here) and the system's, through the terminal's OSC 52 (`Ctrl-V` pastes it anywhere else). Nothing verifies the second: the sequence has no reply, a terminal may refuse it for security, and a message promising something that did not happen is worse than one that stays quiet. The status bar says how many lines were copied, which is true either way.
126126 - **Selection in a conversation is by whole lines, and with none, `Ctrl-C` copies the region under the cursor.** Nothing in a conversation is edited, so half a line is never what somebody means, and whole lines keep a code block's indentation. A `Line` carries a `Region` — one fenced block, one passage of prose, one tool's output — and a speaker's label and a tool call's heading are regions of their own, which is what keeps `‣ Bob (llama.cpp)` out of a block pasted into a source file. **That last part was found, not designed**: the first version copied the label, caught by copying from the real binary and reading the OSC 52 payload back off the wire.
127127
128+- **Paths are canonical, not merely absolute (2026-09-19, v1.0.1).** `lsp.CanonicalPath``filepath.Abs` then `filepath.EvalSymlinks`, falling back to the deepest existing directory for a file not yet on disk — is what `lsp.PathToURI` sends the server, what `app.pathKey` keys documents and diagnostics by, and what `app.windowFor` compares when a location the server sent back has to land on a window already open. Reason: moon-lsp canonicalises a package's files, so a document announced as `/var/folders/…/main.mbt` (macOS: `/var``/private/var`) belonged to no package — no completion about the buffer's types, diagnostics published under `/private/var/…` that matched no open buffer. Turbo MoonBit's suite, green on Linux, failed both ways on its first run on a Mac; reproduced here with `TMPDIR` under a symlink, and with an LSP probe. Falsified: `TestDiagnosticsPublishedUnderTheRealPathReachAFileOpenedThroughALink` fails on the old `pathKey`. Rejected: fixing it in the editor's test by resolving `t.TempDir()` — that would hide the defect from every user whose files sit under a link.
129+
128130 ## Build, test, run
129131
130132 ```bash
@@ -156,6 +158,11 @@ python3 ~/.claude/skills/quality/scripts/quality_report.py --workspace .
156158 - **Documentation**: 12 pages × EN + FR under `docs/`, a `README.md` per package, and a drawio diagram generated from `go list` and verified against it.
157159 - **The tutorial has been run start to finish**, verbatim, in a throwaway module: it builds, the menu bar comes up with the new editor's own menu on it, and the colours land. Both its intermediate states compile.
158160
161+## State as of 2026-09-19
162+
163+- **v1.0.0 is released** under the new module path `rickub.com/turbo-editors/turbo-core`, by `01-release.tag.sh` and the Release workflow; turbo-go v1.0.0 is published against it and the five other editors are re-pinned to it, waiting for their first Rickub release.
164+- **v1.0.1 is ready to tag** (`release.env` says so): the canonical-path fix above, `lsp.CanonicalPath` exported, three new tests (`lsp` ×2, `app` ×1), `app/fakelsp_test.go` records the last `didOpen`. Every editor should re-pin to it before releasing; Turbo MoonBit *must*, or its suite fails on macOS.
165+
159166 ## Not yet established
160167
161168 - **Agent windows have been driven, but only from a script.** The whole path was exercised against a real `docker agent` v1.139.0 and a real llama.cpp serving JetBrains Mellum2 — the menu, the window, a prompt, a streamed reply, a shell tool call, the permission dialog answered, and Go code in a fence read back off the wire with the right colours per span. It was done by driving the binary through a pty from a script; **nobody has typed into one with their hands**. Untried: the mouse, `Tab` between the panes on a real keyboard, resizing the window mid-turn, and two agents side by side.
@@ -125,6 +125,8 @@ The decisions inherited from turbo-go about the *editor's behaviour* — the eve
125 - **Copying goes to two clipboards.** The editor's own (`Shift-Ins` pastes it into a file here) and the system's, through the terminal's OSC 52 (`Ctrl-V` pastes it anywhere else). Nothing verifies the second: the sequence has no reply, a terminal may refuse it for security, and a message promising something that did not happen is worse than one that stays quiet. The status bar says how many lines were copied, which is true either way.125 - **Copying goes to two clipboards.** The editor's own (`Shift-Ins` pastes it into a file here) and the system's, through the terminal's OSC 52 (`Ctrl-V` pastes it anywhere else). Nothing verifies the second: the sequence has no reply, a terminal may refuse it for security, and a message promising something that did not happen is worse than one that stays quiet. The status bar says how many lines were copied, which is true either way.
126 - **Selection in a conversation is by whole lines, and with none, `Ctrl-C` copies the region under the cursor.** Nothing in a conversation is edited, so half a line is never what somebody means, and whole lines keep a code block's indentation. A `Line` carries a `Region` — one fenced block, one passage of prose, one tool's output — and a speaker's label and a tool call's heading are regions of their own, which is what keeps `‣ Bob (llama.cpp)` out of a block pasted into a source file. **That last part was found, not designed**: the first version copied the label, caught by copying from the real binary and reading the OSC 52 payload back off the wire.126 - **Selection in a conversation is by whole lines, and with none, `Ctrl-C` copies the region under the cursor.** Nothing in a conversation is edited, so half a line is never what somebody means, and whole lines keep a code block's indentation. A `Line` carries a `Region` — one fenced block, one passage of prose, one tool's output — and a speaker's label and a tool call's heading are regions of their own, which is what keeps `‣ Bob (llama.cpp)` out of a block pasted into a source file. **That last part was found, not designed**: the first version copied the label, caught by copying from the real binary and reading the OSC 52 payload back off the wire.
127 127
128+- **Paths are canonical, not merely absolute (2026-09-19, v1.0.1).** `lsp.CanonicalPath``filepath.Abs` then `filepath.EvalSymlinks`, falling back to the deepest existing directory for a file not yet on disk — is what `lsp.PathToURI` sends the server, what `app.pathKey` keys documents and diagnostics by, and what `app.windowFor` compares when a location the server sent back has to land on a window already open. Reason: moon-lsp canonicalises a package's files, so a document announced as `/var/folders/…/main.mbt` (macOS: `/var``/private/var`) belonged to no package — no completion about the buffer's types, diagnostics published under `/private/var/…` that matched no open buffer. Turbo MoonBit's suite, green on Linux, failed both ways on its first run on a Mac; reproduced here with `TMPDIR` under a symlink, and with an LSP probe. Falsified: `TestDiagnosticsPublishedUnderTheRealPathReachAFileOpenedThroughALink` fails on the old `pathKey`. Rejected: fixing it in the editor's test by resolving `t.TempDir()` — that would hide the defect from every user whose files sit under a link.
129+
128 ## Build, test, run130 ## Build, test, run
129 131
130 ```bash132 ```bash
@@ -156,6 +158,11 @@ python3 ~/.claude/skills/quality/scripts/quality_report.py --workspace .
156 - **Documentation**: 12 pages × EN + FR under `docs/`, a `README.md` per package, and a drawio diagram generated from `go list` and verified against it.158 - **Documentation**: 12 pages × EN + FR under `docs/`, a `README.md` per package, and a drawio diagram generated from `go list` and verified against it.
157 - **The tutorial has been run start to finish**, verbatim, in a throwaway module: it builds, the menu bar comes up with the new editor's own menu on it, and the colours land. Both its intermediate states compile.159 - **The tutorial has been run start to finish**, verbatim, in a throwaway module: it builds, the menu bar comes up with the new editor's own menu on it, and the colours land. Both its intermediate states compile.
158 160
161+## State as of 2026-09-19
162+
163+- **v1.0.0 is released** under the new module path `rickub.com/turbo-editors/turbo-core`, by `01-release.tag.sh` and the Release workflow; turbo-go v1.0.0 is published against it and the five other editors are re-pinned to it, waiting for their first Rickub release.
164+- **v1.0.1 is ready to tag** (`release.env` says so): the canonical-path fix above, `lsp.CanonicalPath` exported, three new tests (`lsp` ×2, `app` ×1), `app/fakelsp_test.go` records the last `didOpen`. Every editor should re-pin to it before releasing; Turbo MoonBit *must*, or its suite fails on macOS.
165+
159 ## Not yet established166 ## Not yet established
160 167
161 - **Agent windows have been driven, but only from a script.** The whole path was exercised against a real `docker agent` v1.139.0 and a real llama.cpp serving JetBrains Mellum2 — the menu, the window, a prompt, a streamed reply, a shell tool call, the permission dialog answered, and Go code in a fence read back off the wire with the right colours per span. It was done by driving the binary through a pty from a script; **nobody has typed into one with their hands**. Untried: the mouse, `Tab` between the panes on a real keyboard, resizing the window mid-turn, and two agents side by side.168 - **Agent windows have been driven, but only from a script.** The whole path was exercised against a real `docker agent` v1.139.0 and a real llama.cpp serving JetBrains Mellum2 — the menu, the window, a prompt, a streamed reply, a shell tool call, the permission dialog answered, and Go code in a fence read back off the wire with the right colours per span. It was done by driving the binary through a pty from a script; **nobody has typed into one with their hands**. Untried: the mouse, `Tab` between the panes on a real keyboard, resizing the window mid-turn, and two agents side by side.
modified app/README.md +4 -2
@@ -179,9 +179,11 @@ Every item acts on the **cursor**, not a selection. Almost every request in the
179179
180180 A list of places shows the text of each line, read from an open window when there is one and from the disk otherwise. The window first because it is the truth: a file edited and not saved would otherwise be listed with text it no longer has, beside line numbers that follow the edits.
181181
182-## Diagnostics are keyed by absolute path
182+## Diagnostics are keyed by canonical path
183183
184-They arrive from the read loop with an absolute URI; a buffer opened as `turbo-go main.go` holds the relative path the command line gave it. Keyed by whatever arrived, the two never meet — and the failure is invisible, because an editor with no error to show and an editor that cannot find the error are the same blank gutter. `pathKey` makes both sides absolute — and the map of open documents is keyed the same way, so `Knows` does not depend on how a path is spelt. The status bar had been failing this way since long before the gutter marks existed, and every unit test passed because every unit test opened its file by an absolute path.
184+They arrive from the read loop with an absolute URI; a buffer opened as `turbo-go main.go` holds the relative path the command line gave it. Keyed by whatever arrived, the two never meet — and the failure is invisible, because an editor with no error to show and an editor that cannot find the error are the same blank gutter. `pathKey` makes both sides canonical — `lsp.CanonicalPath`: absolute, symbolic links resolved — and the map of open documents is keyed the same way, so `Knows` does not depend on how a path is spelt. The status bar had been failing this way since long before the gutter marks existed, and every unit test passed because every unit test opened its file by an absolute path.
185+
186+Absolute alone was not enough. moon-lsp canonicalises the files of a package, so a document announced under a linked spelling of its path is, to it, a file that belongs to no package: it answers no completion about the buffer's types, and publishes the diagnostics under the spelling it knows. On macOS every temporary directory is such a spelling — `/var/folders/…` is a link to `/private/var/…` — and Turbo MoonBit's suite, green on Linux, failed both ways the first time it ran on a Mac. The URIs sent to the server (`lsp.PathToURI`) go through the same function, so a diagnostic comes back under the spelling it was announced by.
185187
186188 `refreshMarks` runs on each turn of the loop, translating each file's diagnostics into `editor.Severity` marks. A line with several problems is marked with its **worst**: the gutter has one column, and a line that is both an error and a hint is a line you want to know is an error.
187189
@@ -179,9 +179,11 @@ Every item acts on the **cursor**, not a selection. Almost every request in the
179 179
180 A list of places shows the text of each line, read from an open window when there is one and from the disk otherwise. The window first because it is the truth: a file edited and not saved would otherwise be listed with text it no longer has, beside line numbers that follow the edits.180 A list of places shows the text of each line, read from an open window when there is one and from the disk otherwise. The window first because it is the truth: a file edited and not saved would otherwise be listed with text it no longer has, beside line numbers that follow the edits.
181 181
182-## Diagnostics are keyed by absolute path182+## Diagnostics are keyed by canonical path
183 183
184-They arrive from the read loop with an absolute URI; a buffer opened as `turbo-go main.go` holds the relative path the command line gave it. Keyed by whatever arrived, the two never meet — and the failure is invisible, because an editor with no error to show and an editor that cannot find the error are the same blank gutter. `pathKey` makes both sides absolute — and the map of open documents is keyed the same way, so `Knows` does not depend on how a path is spelt. The status bar had been failing this way since long before the gutter marks existed, and every unit test passed because every unit test opened its file by an absolute path.184+They arrive from the read loop with an absolute URI; a buffer opened as `turbo-go main.go` holds the relative path the command line gave it. Keyed by whatever arrived, the two never meet — and the failure is invisible, because an editor with no error to show and an editor that cannot find the error are the same blank gutter. `pathKey` makes both sides canonical — `lsp.CanonicalPath`: absolute, symbolic links resolved — and the map of open documents is keyed the same way, so `Knows` does not depend on how a path is spelt. The status bar had been failing this way since long before the gutter marks existed, and every unit test passed because every unit test opened its file by an absolute path.
185+
186+Absolute alone was not enough. moon-lsp canonicalises the files of a package, so a document announced under a linked spelling of its path is, to it, a file that belongs to no package: it answers no completion about the buffer's types, and publishes the diagnostics under the spelling it knows. On macOS every temporary directory is such a spelling — `/var/folders/…` is a link to `/private/var/…` — and Turbo MoonBit's suite, green on Linux, failed both ways the first time it ran on a Mac. The URIs sent to the server (`lsp.PathToURI`) go through the same function, so a diagnostic comes back under the spelling it was announced by.
185 187
186 `refreshMarks` runs on each turn of the loop, translating each file's diagnostics into `editor.Severity` marks. A line with several problems is marked with its **worst**: the gutter has one column, and a line that is both an error and a hint is a line you want to know is an error.188 `refreshMarks` runs on each turn of the loop, translating each file's diagnostics into `editor.Severity` marks. A line with several problems is marked with its **worst**: the gutter has one column, and a line that is both an error and a hint is a line you want to know is an error.
187 189
modified app/actions_file.go +10 -5
@@ -8,6 +8,7 @@ import (
88
99 "rickub.com/turbo-editors/turbo-core/buffer"
1010 "rickub.com/turbo-editors/turbo-core/editor"
11+ "rickub.com/turbo-editors/turbo-core/lsp"
1112 "rickub.com/turbo-editors/turbo-core/ui"
1213 )
1314
@@ -50,18 +51,22 @@ func (a *App) Open(path string) {
5051 }
5152
5253 // windowFor returns the window already editing a path, if there is one.
54+//
55+// Paths are compared canonically — absolute, links resolved — for the same
56+// reason diagnostics are keyed that way: a location the server sends back
57+// names the file as the server spells it, which for a server that resolves
58+// symbolic links is not how the window was opened. Compared as spelt, a jump
59+// to a definition in a file already on screen opened it a second time, and a
60+// list of references quoted the disk instead of the unsaved window.
5361 func (a *App) windowFor(path string) *ui.Window {
54- wanted, err := filepath.Abs(path)
55- if err != nil {
56- wanted = path
57- }
62+ wanted := lsp.CanonicalPath(path)
5863
5964 for _, window := range a.desktop.Windows() {
6065 view, ok := editorViewOf(window)
6166 if !ok || view.Buffer().Path() == "" {
6267 continue
6368 }
64- if existing, err := filepath.Abs(view.Buffer().Path()); err == nil && existing == wanted {
69+ if lsp.CanonicalPath(view.Buffer().Path()) == wanted {
6570 return window
6671 }
6772 }
@@ -8,6 +8,7 @@ import (
8 8
9 "rickub.com/turbo-editors/turbo-core/buffer"9 "rickub.com/turbo-editors/turbo-core/buffer"
10 "rickub.com/turbo-editors/turbo-core/editor"10 "rickub.com/turbo-editors/turbo-core/editor"
11+ "rickub.com/turbo-editors/turbo-core/lsp"
11 "rickub.com/turbo-editors/turbo-core/ui"12 "rickub.com/turbo-editors/turbo-core/ui"
12 )13 )
13 14
@@ -50,18 +51,22 @@ func (a *App) Open(path string) {
50 }51 }
51 52
52 // windowFor returns the window already editing a path, if there is one.53 // windowFor returns the window already editing a path, if there is one.
54+//
55+// Paths are compared canonically — absolute, links resolved — for the same
56+// reason diagnostics are keyed that way: a location the server sends back
57+// names the file as the server spells it, which for a server that resolves
58+// symbolic links is not how the window was opened. Compared as spelt, a jump
59+// to a definition in a file already on screen opened it a second time, and a
60+// list of references quoted the disk instead of the unsaved window.
53 func (a *App) windowFor(path string) *ui.Window {61 func (a *App) windowFor(path string) *ui.Window {
54- wanted, err := filepath.Abs(path)62+ wanted := lsp.CanonicalPath(path)
55- if err != nil {
56- wanted = path
57- }
58 63
59 for _, window := range a.desktop.Windows() {64 for _, window := range a.desktop.Windows() {
60 view, ok := editorViewOf(window)65 view, ok := editorViewOf(window)
61 if !ok || view.Buffer().Path() == "" {66 if !ok || view.Buffer().Path() == "" {
62 continue67 continue
63 }68 }
64- if existing, err := filepath.Abs(view.Buffer().Path()); err == nil && existing == wanted {69+ if lsp.CanonicalPath(view.Buffer().Path()) == wanted {
65 return window70 return window
66 }71 }
67 }72 }
modified app/diagnostics_test.go +69 -0
@@ -1,6 +1,7 @@
11 package app
22
33 import (
4+ "os"
45 "path/filepath"
56 "testing"
67
@@ -81,3 +82,71 @@ func TestKnowsDoesNotDependOnTheSpellingOfThePath(t *testing.T) {
8182 t.Errorf("closed as %q, still known as %q", absolute, "main.go")
8283 }
8384 }
85+
86+func TestDiagnosticsPublishedUnderTheRealPathReachAFileOpenedThroughALink(t *testing.T) {
87+ // Found on macOS, the first time Turbo MoonBit's suite ran there: every
88+ // temporary directory is /var/folders/…, a link to /private/var/…, and
89+ // moon-lsp canonicalises paths. It published the file's problems under
90+ // /private/var and answered no completion about a buffer announced as
91+ // /var — to it, a file that belonged to no package. Absolute was not
92+ // enough; the key has to be canonical, and so has the URI the server is
93+ // sent, or the two sides spell the same file differently again.
94+ real := t.TempDir()
95+ link := filepath.Join(t.TempDir(), "link")
96+ if err := os.Symlink(real, link); err != nil {
97+ t.Skipf("cannot create a symbolic link here: %v", err)
98+ }
99+ a, _ := newTestApp(t)
100+ client, fake := newFakeLanguage(t, a)
101+ connectLanguage(a, client)
102+
103+ throughLink := filepath.Join(link, "main.mbt")
104+ a.language.DidOpen(throughLink, "fn main {}\n")
105+
106+ resolvedReal, err := filepath.EvalSymlinks(real)
107+ if err != nil {
108+ t.Fatal(err)
109+ }
110+ if !a.language.Knows(filepath.Join(resolvedReal, "main.mbt")) {
111+ t.Errorf("opened through the link, not known under the real path")
112+ }
113+ if got, want := fake.lastOpenedURI(), lsp.PathToURI(filepath.Join(resolvedReal, "main.mbt")); got != want {
114+ t.Errorf("the server was told %q, want the real path's %q", got, want)
115+ }
116+
117+ a.language.receiveDiagnostics(filepath.Join(resolvedReal, "main.mbt"), []lsp.Diagnostic{
118+ {Message: "published under the real path", Severity: lsp.SeverityError},
119+ })
120+ if got := a.language.Diagnostics(throughLink); len(got) != 1 {
121+ t.Errorf("published under the real path, read through the link: got %d diagnostics", len(got))
122+ }
123+}
124+
125+func TestWindowForFindsAFileOpenedThroughALink(t *testing.T) {
126+ // A location the server sends back names the file as the server spells
127+ // it. Compared as spelt, a jump to a definition in a file already on
128+ // screen opened it a second time, and the references list quoted the disk
129+ // instead of the unsaved window — both seen on macOS, where every
130+ // temporary directory is a link.
131+ real := t.TempDir()
132+ link := filepath.Join(t.TempDir(), "link")
133+ if err := os.Symlink(real, link); err != nil {
134+ t.Skipf("cannot create a symbolic link here: %v", err)
135+ }
136+ t.Setenv(testProfile().SnippetDirEnvVar(), t.TempDir())
137+ a, _ := newTestApp(t)
138+ writeTestFile(t, filepath.Join(real, "main.go"), "package main\n")
139+
140+ a.Open(filepath.Join(link, "main.go"))
141+
142+ resolvedReal, err := filepath.EvalSymlinks(real)
143+ if err != nil {
144+ t.Fatal(err)
145+ }
146+ if a.windowFor(filepath.Join(resolvedReal, "main.go")) == nil {
147+ t.Errorf("opened through the link, the window is not found under the real path")
148+ }
149+ if a.windowFor(filepath.Join(link, "main.go")) == nil {
150+ t.Errorf("opened through the link, the window is not found under the link either")
151+ }
152+}
@@ -1,6 +1,7 @@
1 package app1 package app
2 2
3 import (3 import (
4+ "os"
4 "path/filepath"5 "path/filepath"
5 "testing"6 "testing"
6 7
@@ -81,3 +82,71 @@ func TestKnowsDoesNotDependOnTheSpellingOfThePath(t *testing.T) {
81 t.Errorf("closed as %q, still known as %q", absolute, "main.go")82 t.Errorf("closed as %q, still known as %q", absolute, "main.go")
82 }83 }
83 }84 }
85+
86+func TestDiagnosticsPublishedUnderTheRealPathReachAFileOpenedThroughALink(t *testing.T) {
87+ // Found on macOS, the first time Turbo MoonBit's suite ran there: every
88+ // temporary directory is /var/folders/…, a link to /private/var/…, and
89+ // moon-lsp canonicalises paths. It published the file's problems under
90+ // /private/var and answered no completion about a buffer announced as
91+ // /var — to it, a file that belonged to no package. Absolute was not
92+ // enough; the key has to be canonical, and so has the URI the server is
93+ // sent, or the two sides spell the same file differently again.
94+ real := t.TempDir()
95+ link := filepath.Join(t.TempDir(), "link")
96+ if err := os.Symlink(real, link); err != nil {
97+ t.Skipf("cannot create a symbolic link here: %v", err)
98+ }
99+ a, _ := newTestApp(t)
100+ client, fake := newFakeLanguage(t, a)
101+ connectLanguage(a, client)
102+
103+ throughLink := filepath.Join(link, "main.mbt")
104+ a.language.DidOpen(throughLink, "fn main {}\n")
105+
106+ resolvedReal, err := filepath.EvalSymlinks(real)
107+ if err != nil {
108+ t.Fatal(err)
109+ }
110+ if !a.language.Knows(filepath.Join(resolvedReal, "main.mbt")) {
111+ t.Errorf("opened through the link, not known under the real path")
112+ }
113+ if got, want := fake.lastOpenedURI(), lsp.PathToURI(filepath.Join(resolvedReal, "main.mbt")); got != want {
114+ t.Errorf("the server was told %q, want the real path's %q", got, want)
115+ }
116+
117+ a.language.receiveDiagnostics(filepath.Join(resolvedReal, "main.mbt"), []lsp.Diagnostic{
118+ {Message: "published under the real path", Severity: lsp.SeverityError},
119+ })
120+ if got := a.language.Diagnostics(throughLink); len(got) != 1 {
121+ t.Errorf("published under the real path, read through the link: got %d diagnostics", len(got))
122+ }
123+}
124+
125+func TestWindowForFindsAFileOpenedThroughALink(t *testing.T) {
126+ // A location the server sends back names the file as the server spells
127+ // it. Compared as spelt, a jump to a definition in a file already on
128+ // screen opened it a second time, and the references list quoted the disk
129+ // instead of the unsaved window — both seen on macOS, where every
130+ // temporary directory is a link.
131+ real := t.TempDir()
132+ link := filepath.Join(t.TempDir(), "link")
133+ if err := os.Symlink(real, link); err != nil {
134+ t.Skipf("cannot create a symbolic link here: %v", err)
135+ }
136+ t.Setenv(testProfile().SnippetDirEnvVar(), t.TempDir())
137+ a, _ := newTestApp(t)
138+ writeTestFile(t, filepath.Join(real, "main.go"), "package main\n")
139+
140+ a.Open(filepath.Join(link, "main.go"))
141+
142+ resolvedReal, err := filepath.EvalSymlinks(real)
143+ if err != nil {
144+ t.Fatal(err)
145+ }
146+ if a.windowFor(filepath.Join(resolvedReal, "main.go")) == nil {
147+ t.Errorf("opened through the link, the window is not found under the real path")
148+ }
149+ if a.windowFor(filepath.Join(link, "main.go")) == nil {
150+ t.Errorf("opened through the link, the window is not found under the link either")
151+ }
152+}
modified app/fakelsp_test.go +39 -3
@@ -21,9 +21,10 @@ type fakeLSP struct {
2121 stream net.Conn
2222 reader *bufio.Reader
2323
24- mu sync.Mutex
25- seen map[string]int
26- answers map[string]json.RawMessage
24+ mu sync.Mutex
25+ seen map[string]int
26+ answers map[string]json.RawMessage
27+ lastOpened json.RawMessage // the params of the last textDocument/didOpen
2728 }
2829
2930 // frame is one JSON-RPC message, reduced to what this fake needs to look at.
@@ -31,6 +32,7 @@ type frame struct {
3132 JSONRPC string `json:"jsonrpc"`
3233 ID json.RawMessage `json:"id,omitempty"`
3334 Method string `json:"method,omitempty"`
35+ Params json.RawMessage `json:"params,omitempty"`
3436 Result json.RawMessage `json:"result,omitempty"`
3537 }
3638
@@ -82,6 +84,11 @@ func (s *fakeLSP) serve() {
8284 return
8385 }
8486 s.record(msg.Method)
87+ if msg.Method == "textDocument/didOpen" {
88+ s.mu.Lock()
89+ s.lastOpened = msg.Params
90+ s.mu.Unlock()
91+ }
8592
8693 if len(msg.ID) == 0 || msg.Method == "" {
8794 continue // a notification, or an answer to something we asked
@@ -131,6 +138,35 @@ func (s *fakeLSP) record(method string) {
131138 s.seen[method]++
132139 }
133140
141+// lastOpenedURI returns the URI the client announced in its most recent
142+// textDocument/didOpen — the spelling the server was actually given, which is
143+// what a server that resolves symbolic links cares about.
144+func (s *fakeLSP) lastOpenedURI() string {
145+ waitForMethodQuietly(s, "textDocument/didOpen")
146+ s.mu.Lock()
147+ defer s.mu.Unlock()
148+ var params struct {
149+ TextDocument struct {
150+ URI string `json:"uri"`
151+ } `json:"textDocument"`
152+ }
153+ _ = json.Unmarshal(s.lastOpened, &params)
154+ return params.TextDocument.URI
155+}
156+
157+// waitForMethodQuietly is waitForMethod for a caller that reports its own
158+// failure: it gives up after two seconds and returns.
159+func waitForMethodQuietly(server *fakeLSP, method string) {
160+ deadline := time.After(2 * time.Second)
161+ for server.methodCount(method) == 0 {
162+ select {
163+ case <-deadline:
164+ return
165+ case <-time.After(time.Millisecond):
166+ }
167+ }
168+}
169+
134170 // methodCount returns how many times the client has sent a method.
135171 func (s *fakeLSP) methodCount(method string) int {
136172 s.mu.Lock()
@@ -21,9 +21,10 @@ type fakeLSP struct {
21 stream net.Conn21 stream net.Conn
22 reader *bufio.Reader22 reader *bufio.Reader
23 23
24- mu sync.Mutex24+ mu sync.Mutex
25- seen map[string]int25+ seen map[string]int
26- answers map[string]json.RawMessage26+ answers map[string]json.RawMessage
27+ lastOpened json.RawMessage // the params of the last textDocument/didOpen
27 }28 }
28 29
29 // frame is one JSON-RPC message, reduced to what this fake needs to look at.30 // frame is one JSON-RPC message, reduced to what this fake needs to look at.
@@ -31,6 +32,7 @@ type frame struct {
31 JSONRPC string `json:"jsonrpc"`32 JSONRPC string `json:"jsonrpc"`
32 ID json.RawMessage `json:"id,omitempty"`33 ID json.RawMessage `json:"id,omitempty"`
33 Method string `json:"method,omitempty"`34 Method string `json:"method,omitempty"`
35+ Params json.RawMessage `json:"params,omitempty"`
34 Result json.RawMessage `json:"result,omitempty"`36 Result json.RawMessage `json:"result,omitempty"`
35 }37 }
36 38
@@ -82,6 +84,11 @@ func (s *fakeLSP) serve() {
82 return84 return
83 }85 }
84 s.record(msg.Method)86 s.record(msg.Method)
87+ if msg.Method == "textDocument/didOpen" {
88+ s.mu.Lock()
89+ s.lastOpened = msg.Params
90+ s.mu.Unlock()
91+ }
85 92
86 if len(msg.ID) == 0 || msg.Method == "" {93 if len(msg.ID) == 0 || msg.Method == "" {
87 continue // a notification, or an answer to something we asked94 continue // a notification, or an answer to something we asked
@@ -131,6 +138,35 @@ func (s *fakeLSP) record(method string) {
131 s.seen[method]++138 s.seen[method]++
132 }139 }
133 140
141+// lastOpenedURI returns the URI the client announced in its most recent
142+// textDocument/didOpen — the spelling the server was actually given, which is
143+// what a server that resolves symbolic links cares about.
144+func (s *fakeLSP) lastOpenedURI() string {
145+ waitForMethodQuietly(s, "textDocument/didOpen")
146+ s.mu.Lock()
147+ defer s.mu.Unlock()
148+ var params struct {
149+ TextDocument struct {
150+ URI string `json:"uri"`
151+ } `json:"textDocument"`
152+ }
153+ _ = json.Unmarshal(s.lastOpened, &params)
154+ return params.TextDocument.URI
155+}
156+
157+// waitForMethodQuietly is waitForMethod for a caller that reports its own
158+// failure: it gives up after two seconds and returns.
159+func waitForMethodQuietly(server *fakeLSP, method string) {
160+ deadline := time.After(2 * time.Second)
161+ for server.methodCount(method) == 0 {
162+ select {
163+ case <-deadline:
164+ return
165+ case <-time.After(time.Millisecond):
166+ }
167+ }
168+}
169+
134 // methodCount returns how many times the client has sent a method.170 // methodCount returns how many times the client has sent a method.
135 func (s *fakeLSP) methodCount(method string) int {171 func (s *fakeLSP) methodCount(method string) int {
136 s.mu.Lock()172 s.mu.Lock()
modified app/language.go +11 -13
@@ -3,7 +3,6 @@ package app
33 import (
44 "context"
55 "errors"
6- "path/filepath"
76 "sort"
87 "sync"
98
@@ -367,20 +366,19 @@ func (l *Language) receiveDiagnostics(path string, diagnostics []lsp.Diagnostic)
367366 // pathKey is how a file is named in the maps of problems and of open
368367 // documents.
369368 //
370-// Absolute, always, because the two sides spell it differently. A server
371-// publishes absolute URIs; a buffer opened from the command line — `turbo-go
372-// main.go` — holds the relative path it was given. Keyed by whatever arrived,
373-// the two never meet: the status bar showed no error, and the gutter no mark,
374-// for a file the server had plenty to say about.
369+// Canonical, always — absolute, links resolved — because the two sides spell
370+// it differently. A server publishes absolute URIs; a buffer opened from the
371+// command line — `turbo-go main.go` — holds the relative path it was given.
372+// Keyed by whatever arrived, the two never meet: the status bar showed no
373+// error, and the gutter no mark, for a file the server had plenty to say
374+// about. And a server that resolves symbolic links — moon-lsp does — publishes
375+// under /private/var/… what the editor opened as /var/…, which on macOS is
376+// every temporary directory; absolute alone left those two apart as well.
375377 //
376-// A path that cannot be made absolute is used as it is. That is better than
377-// dropping the diagnostic, and it degrades to the behaviour there was before.
378+// The same function builds the URIs the server is sent (lsp.PathToURI), so a
379+// diagnostic comes back under the spelling it was announced by.
378380 func pathKey(path string) string {
379- absolute, err := filepath.Abs(path)
380- if err != nil {
381- return path
382- }
383- return absolute
381+ return lsp.CanonicalPath(path)
384382 }
385383
386384 // FirstError returns the first error-level diagnostic for a file, which is
@@ -3,7 +3,6 @@ package app
3 import (3 import (
4 "context"4 "context"
5 "errors"5 "errors"
6- "path/filepath"
7 "sort"6 "sort"
8 "sync"7 "sync"
9 8
@@ -367,20 +366,19 @@ func (l *Language) receiveDiagnostics(path string, diagnostics []lsp.Diagnostic)
367 // pathKey is how a file is named in the maps of problems and of open366 // pathKey is how a file is named in the maps of problems and of open
368 // documents.367 // documents.
369 //368 //
370-// Absolute, always, because the two sides spell it differently. A server369+// Canonical, always — absolute, links resolved — because the two sides spell
371-// publishes absolute URIs; a buffer opened from the command line — `turbo-go370+// it differently. A server publishes absolute URIs; a buffer opened from the
372-// main.go` — holds the relative path it was given. Keyed by whatever arrived,371+// command line — `turbo-go main.go` — holds the relative path it was given.
373-// the two never meet: the status bar showed no error, and the gutter no mark,372+// Keyed by whatever arrived, the two never meet: the status bar showed no
374-// for a file the server had plenty to say about.373+// error, and the gutter no mark, for a file the server had plenty to say
374+// about. And a server that resolves symbolic links — moon-lsp does — publishes
375+// under /private/var/… what the editor opened as /var/…, which on macOS is
376+// every temporary directory; absolute alone left those two apart as well.
375 //377 //
376-// A path that cannot be made absolute is used as it is. That is better than378+// The same function builds the URIs the server is sent (lsp.PathToURI), so a
377-// dropping the diagnostic, and it degrades to the behaviour there was before.379+// diagnostic comes back under the spelling it was announced by.
378 func pathKey(path string) string {380 func pathKey(path string) string {
379- absolute, err := filepath.Abs(path)381+ return lsp.CanonicalPath(path)
380- if err != nil {
381- return path
382- }
383- return absolute
384 }382 }
385 383
386 // FirstError returns the first error-level diagnostic for a file, which is384 // FirstError returns the first error-level diagnostic for a file, which is
modified docs/en/reference/app.md +1 -1
@@ -68,7 +68,7 @@ The settings file is also re-read **after every save that writes it**, from eith
6868 | Method | Description |
6969 | --- | --- |
7070 | `Ready() bool` | Whether a server is connected and initialised. |
71-| `Knows(path string) bool` | Whether the server has been told the document is open. Spelling-independent: a file opened by a relative path is known by its absolute one too. |
71+| `Knows(path string) bool` | Whether the server has been told the document is open. Spelling-independent: a file opened by a relative path is known by its absolute one too, and one opened through a symbolic link by its real path. |
7272 | `Status() string` | The one-line state shown on the status bar. |
7373 | `Report() Report` | Status, server path, root and readiness together. |
7474 | `Stop(ctx context.Context)` | Shuts the server down. |
@@ -68,7 +68,7 @@ The settings file is also re-read **after every save that writes it**, from eith
68 | Method | Description |68 | Method | Description |
69 | --- | --- |69 | --- | --- |
70 | `Ready() bool` | Whether a server is connected and initialised. |70 | `Ready() bool` | Whether a server is connected and initialised. |
71-| `Knows(path string) bool` | Whether the server has been told the document is open. Spelling-independent: a file opened by a relative path is known by its absolute one too. |71+| `Knows(path string) bool` | Whether the server has been told the document is open. Spelling-independent: a file opened by a relative path is known by its absolute one too, and one opened through a symbolic link by its real path. |
72 | `Status() string` | The one-line state shown on the status bar. |72 | `Status() string` | The one-line state shown on the status bar. |
73 | `Report() Report` | Status, server path, root and readiness together. |73 | `Report() Report` | Status, server path, root and readiness together. |
74 | `Stop(ctx context.Context)` | Shuts the server down. |74 | `Stop(ctx context.Context)` | Shuts the server down. |
modified docs/fr/reference/app.md +1 -1
@@ -68,7 +68,7 @@ Le fichier de réglages est aussi relu **après chaque enregistrement qui l'écr
6868 | Méthode | Description |
6969 | --- | --- |
7070 | `Ready() bool` | Si un serveur est connecté et initialisé. |
71-| `Knows(path string) bool` | Si le serveur a été prévenu que le document est ouvert. Indépendant de l'orthographe : un fichier ouvert par un chemin relatif est aussi connu par son chemin absolu. |
71+| `Knows(path string) bool` | Si le serveur a été prévenu que le document est ouvert. Indépendant de l'orthographe : un fichier ouvert par un chemin relatif est aussi connu par son chemin absolu, et un fichier ouvert à travers un lien symbolique par son chemin réel. |
7272 | `Status() string` | L'état d'une ligne affiché sur la barre d'état. |
7373 | `Report() Report` | État, chemin du serveur, racine et disponibilité ensemble. |
7474 | `Stop(ctx context.Context)` | Arrête le serveur. |
@@ -68,7 +68,7 @@ Le fichier de réglages est aussi relu **après chaque enregistrement qui l'écr
68 | Méthode | Description |68 | Méthode | Description |
69 | --- | --- |69 | --- | --- |
70 | `Ready() bool` | Si un serveur est connecté et initialisé. |70 | `Ready() bool` | Si un serveur est connecté et initialisé. |
71-| `Knows(path string) bool` | Si le serveur a été prévenu que le document est ouvert. Indépendant de l'orthographe : un fichier ouvert par un chemin relatif est aussi connu par son chemin absolu. |71+| `Knows(path string) bool` | Si le serveur a été prévenu que le document est ouvert. Indépendant de l'orthographe : un fichier ouvert par un chemin relatif est aussi connu par son chemin absolu, et un fichier ouvert à travers un lien symbolique par son chemin réel. |
72 | `Status() string` | L'état d'une ligne affiché sur la barre d'état. |72 | `Status() string` | L'état d'une ligne affiché sur la barre d'état. |
73 | `Report() Report` | État, chemin du serveur, racine et disponibilité ensemble. |73 | `Report() Report` | État, chemin du serveur, racine et disponibilité ensemble. |
74 | `Stop(ctx context.Context)` | Arrête le serveur. |74 | `Stop(ctx context.Context)` | Arrête le serveur. |
modified lsp/README.md +1 -1
@@ -62,7 +62,7 @@ Every request is bounded: `RequestTimeout` (3 s) for the ordinary ones, `Initial
6262 | `(*Client) DocumentSymbols / WorkspaceSymbols` | What a file declares, and what a project holds matching a query |
6363 | `Symbol`, `SymbolKind` | One shape for the three the protocol has |
6464 | `(*Client) OnDiagnostics`, `OnLog` | What the server says unprompted |
65-| `PathToURI` / `URIToPath` | `file://` conversion, Windows drive letters included |
65+| `PathToURI` / `URIToPath` / `CanonicalPath` | `file://` conversion, Windows drive letters included; the path is made canonical first — absolute, symbolic links resolved — because moon-lsp resolves them itself and would not recognise a document announced under another spelling (macOS's `/var` → `/private/var`) |
6666 | `RuneToUTF16` / `UTF16ToRune` | Column conversion |
6767 | `(CompletionItem) Insertion() string` | The text to insert, snippet placeholders stripped |
6868
@@ -62,7 +62,7 @@ Every request is bounded: `RequestTimeout` (3 s) for the ordinary ones, `Initial
62 | `(*Client) DocumentSymbols / WorkspaceSymbols` | What a file declares, and what a project holds matching a query |62 | `(*Client) DocumentSymbols / WorkspaceSymbols` | What a file declares, and what a project holds matching a query |
63 | `Symbol`, `SymbolKind` | One shape for the three the protocol has |63 | `Symbol`, `SymbolKind` | One shape for the three the protocol has |
64 | `(*Client) OnDiagnostics`, `OnLog` | What the server says unprompted |64 | `(*Client) OnDiagnostics`, `OnLog` | What the server says unprompted |
65-| `PathToURI` / `URIToPath` | `file://` conversion, Windows drive letters included |65+| `PathToURI` / `URIToPath` / `CanonicalPath` | `file://` conversion, Windows drive letters included; the path is made canonical first — absolute, symbolic links resolved — because moon-lsp resolves them itself and would not recognise a document announced under another spelling (macOS's `/var` → `/private/var`) |
66 | `RuneToUTF16` / `UTF16ToRune` | Column conversion |66 | `RuneToUTF16` / `UTF16ToRune` | Column conversion |
67 | `(CompletionItem) Insertion() string` | The text to insert, snippet placeholders stripped |67 | `(CompletionItem) Insertion() string` | The text to insert, snippet placeholders stripped |
68 68
modified lsp/lsp_test.go +56 -4
@@ -319,8 +319,11 @@ func TestDiagnosticsReachTheEditor(t *testing.T) {
319319 if len(diagnostics) != 1 || diagnostics[0].Message != "undefined: foo" {
320320 t.Errorf("the diagnostics are %+v", diagnostics)
321321 }
322- if receivedPath != path {
323- t.Errorf("the path is %q, want %q — the URI must be converted back", receivedPath, path)
322+ // Canonical, not as spelt: the URI was built from the canonical path
323+ // (macOS's temporary directories are under a symbolic link), and what
324+ // comes back is that spelling, which is the one the editor keys by.
325+ if receivedPath != CanonicalPath(path) {
326+ t.Errorf("the path is %q, want %q — the URI must be converted back", receivedPath, CanonicalPath(path))
324327 }
325328 case <-time.After(time.Second):
326329 t.Fatal("no diagnostics arrived")
@@ -454,8 +457,11 @@ func TestPathAndURIRoundTrip(t *testing.T) {
454457 if strings.Contains(uri, " ") {
455458 t.Errorf("PathToURI() = %q, want the space percent-encoded", uri)
456459 }
457- if got := URIToPath(uri); got != path {
458- t.Errorf("URIToPath(PathToURI(%q)) = %q", path, got)
460+ // The round trip lands on the canonical spelling of the path — on macOS a
461+ // temporary directory is /var/…, a link to /private/var/…, and the URI
462+ // names the latter on purpose.
463+ if got, want := URIToPath(uri), CanonicalPath(path); got != want {
464+ t.Errorf("URIToPath(PathToURI(%q)) = %q, want %q", path, got, want)
459465 }
460466 }
461467
@@ -753,3 +759,49 @@ func TestTheClientAsksForTheCapabilitiesItUses(t *testing.T) {
753759 }
754760 }
755761 }
762+
763+func TestPathToURIResolvesSymbolicLinks(t *testing.T) {
764+ // moon-lsp canonicalises the files of a package, so a document announced
765+ // under a linked spelling of its path is one it knows nothing about. On
766+ // macOS every temporary directory is such a spelling: /var → /private/var.
767+ real := t.TempDir()
768+ link := filepath.Join(t.TempDir(), "link")
769+ if err := os.Symlink(real, link); err != nil {
770+ t.Skipf("cannot create a symbolic link here: %v", err)
771+ }
772+ if err := os.WriteFile(filepath.Join(real, "main.mbt"), []byte("fn main {}\n"), 0o644); err != nil {
773+ t.Fatal(err)
774+ }
775+
776+ got := PathToURI(filepath.Join(link, "main.mbt"))
777+
778+ want := PathToURI(filepath.Join(real, "main.mbt"))
779+ if got != want {
780+ t.Errorf("PathToURI through the link = %q, want the real path's %q", got, want)
781+ }
782+ if strings.Contains(got, "/link/") {
783+ t.Errorf("the URI still spells the link: %q", got)
784+ }
785+}
786+
787+func TestCanonicalPathOfAFileNotYetOnDiskResolvesItsDirectory(t *testing.T) {
788+ // A buffer being saved under a new name has nothing to resolve, and must
789+ // still be keyed the way its diagnostics will arrive once it exists.
790+ real := t.TempDir()
791+ link := filepath.Join(t.TempDir(), "link")
792+ if err := os.Symlink(real, link); err != nil {
793+ t.Skipf("cannot create a symbolic link here: %v", err)
794+ }
795+
796+ got := CanonicalPath(filepath.Join(link, "new", "file.mbt"))
797+
798+ // t.TempDir itself may sit under a link (macOS again), so compare with
799+ // the resolved real directory rather than with real as spelt.
800+ resolvedReal, err := filepath.EvalSymlinks(real)
801+ if err != nil {
802+ t.Fatal(err)
803+ }
804+ if want := filepath.Join(resolvedReal, "new", "file.mbt"); got != want {
805+ t.Errorf("CanonicalPath() = %q, want %q", got, want)
806+ }
807+}
@@ -319,8 +319,11 @@ func TestDiagnosticsReachTheEditor(t *testing.T) {
319 if len(diagnostics) != 1 || diagnostics[0].Message != "undefined: foo" {319 if len(diagnostics) != 1 || diagnostics[0].Message != "undefined: foo" {
320 t.Errorf("the diagnostics are %+v", diagnostics)320 t.Errorf("the diagnostics are %+v", diagnostics)
321 }321 }
322- if receivedPath != path {322+ // Canonical, not as spelt: the URI was built from the canonical path
323- t.Errorf("the path is %q, want %q — the URI must be converted back", receivedPath, path)323+ // (macOS's temporary directories are under a symbolic link), and what
324+ // comes back is that spelling, which is the one the editor keys by.
325+ if receivedPath != CanonicalPath(path) {
326+ t.Errorf("the path is %q, want %q — the URI must be converted back", receivedPath, CanonicalPath(path))
324 }327 }
325 case <-time.After(time.Second):328 case <-time.After(time.Second):
326 t.Fatal("no diagnostics arrived")329 t.Fatal("no diagnostics arrived")
@@ -454,8 +457,11 @@ func TestPathAndURIRoundTrip(t *testing.T) {
454 if strings.Contains(uri, " ") {457 if strings.Contains(uri, " ") {
455 t.Errorf("PathToURI() = %q, want the space percent-encoded", uri)458 t.Errorf("PathToURI() = %q, want the space percent-encoded", uri)
456 }459 }
457- if got := URIToPath(uri); got != path {460+ // The round trip lands on the canonical spelling of the path — on macOS a
458- t.Errorf("URIToPath(PathToURI(%q)) = %q", path, got)461+ // temporary directory is /var/…, a link to /private/var/…, and the URI
462+ // names the latter on purpose.
463+ if got, want := URIToPath(uri), CanonicalPath(path); got != want {
464+ t.Errorf("URIToPath(PathToURI(%q)) = %q, want %q", path, got, want)
459 }465 }
460 }466 }
461 467
@@ -753,3 +759,49 @@ func TestTheClientAsksForTheCapabilitiesItUses(t *testing.T) {
753 }759 }
754 }760 }
755 }761 }
762+
763+func TestPathToURIResolvesSymbolicLinks(t *testing.T) {
764+ // moon-lsp canonicalises the files of a package, so a document announced
765+ // under a linked spelling of its path is one it knows nothing about. On
766+ // macOS every temporary directory is such a spelling: /var → /private/var.
767+ real := t.TempDir()
768+ link := filepath.Join(t.TempDir(), "link")
769+ if err := os.Symlink(real, link); err != nil {
770+ t.Skipf("cannot create a symbolic link here: %v", err)
771+ }
772+ if err := os.WriteFile(filepath.Join(real, "main.mbt"), []byte("fn main {}\n"), 0o644); err != nil {
773+ t.Fatal(err)
774+ }
775+
776+ got := PathToURI(filepath.Join(link, "main.mbt"))
777+
778+ want := PathToURI(filepath.Join(real, "main.mbt"))
779+ if got != want {
780+ t.Errorf("PathToURI through the link = %q, want the real path's %q", got, want)
781+ }
782+ if strings.Contains(got, "/link/") {
783+ t.Errorf("the URI still spells the link: %q", got)
784+ }
785+}
786+
787+func TestCanonicalPathOfAFileNotYetOnDiskResolvesItsDirectory(t *testing.T) {
788+ // A buffer being saved under a new name has nothing to resolve, and must
789+ // still be keyed the way its diagnostics will arrive once it exists.
790+ real := t.TempDir()
791+ link := filepath.Join(t.TempDir(), "link")
792+ if err := os.Symlink(real, link); err != nil {
793+ t.Skipf("cannot create a symbolic link here: %v", err)
794+ }
795+
796+ got := CanonicalPath(filepath.Join(link, "new", "file.mbt"))
797+
798+ // t.TempDir itself may sit under a link (macOS again), so compare with
799+ // the resolved real directory rather than with real as spelt.
800+ resolvedReal, err := filepath.EvalSymlinks(real)
801+ if err != nil {
802+ t.Fatal(err)
803+ }
804+ if want := filepath.Join(resolvedReal, "new", "file.mbt"); got != want {
805+ t.Errorf("CanonicalPath() = %q, want %q", got, want)
806+ }
807+}
modified lsp/uri.go +39 -7
@@ -9,16 +9,13 @@ import (
99 // PathToURI turns a file path into the file:// URI the protocol names
1010 // documents by.
1111 //
12-// The path is made absolute first, since a language server has no idea what
13-// the editor's working directory is.
12+// The path is made canonical first — absolute, with symbolic links resolved —
13+// since a language server has no idea what the editor's working directory is,
14+// and some resolve links themselves: see CanonicalPath.
1415 //
1516 // lsp.PathToURI("main.go") // file:///home/you/project/main.go
1617 func PathToURI(path string) string {
17- absolute, err := filepath.Abs(path)
18- if err != nil {
19- absolute = path
20- }
21- absolute = filepath.ToSlash(absolute)
18+ absolute := filepath.ToSlash(CanonicalPath(path))
2219
2320 if !strings.HasPrefix(absolute, "/") {
2421 // A Windows path such as C:/x becomes /C:/x, which is what the URI
@@ -30,6 +27,41 @@ func PathToURI(path string) string {
3027 return uri.String()
3128 }
3229
30+// CanonicalPath is the one spelling of a file this package names it by:
31+// absolute, with every symbolic link resolved.
32+//
33+// Resolving the links is not tidiness. moon-lsp works a package's files out
34+// from disk and canonicalises what it finds, so a document announced under
35+// another spelling of the same path is, to it, a file that belongs to no
36+// package: it answers no completion about the buffer's types, and it publishes
37+// the file's diagnostics under the spelling it knows — which the editor then
38+// cannot match to any open buffer. On macOS every temporary directory is such
39+// a spelling: /var is a link to /private/var, and the Turbo MoonBit suite,
40+// green on Linux, failed both of those ways the first time it ran on a Mac.
41+//
42+// A file that is not on disk yet — a buffer being saved under a new name — has
43+// nothing to resolve, so its deepest existing directory is resolved instead and
44+// the rest of the path put back on. A path that cannot be made absolute is
45+// returned as it is, which degrades to the behaviour there was before.
46+func CanonicalPath(path string) string {
47+ absolute, err := filepath.Abs(path)
48+ if err != nil {
49+ return path
50+ }
51+ if resolved, err := filepath.EvalSymlinks(absolute); err == nil {
52+ return resolved
53+ }
54+
55+ dir, rest := filepath.Dir(absolute), filepath.Base(absolute)
56+ for dir != filepath.Dir(dir) {
57+ if resolved, err := filepath.EvalSymlinks(dir); err == nil {
58+ return filepath.Join(resolved, rest)
59+ }
60+ dir, rest = filepath.Dir(dir), filepath.Join(filepath.Base(dir), rest)
61+ }
62+ return absolute
63+}
64+
3365 // URIToPath turns a file:// URI back into a path. Anything that is not a file
3466 // URI comes back unchanged, since there is nothing better to do with it.
3567 func URIToPath(uri string) string {
@@ -9,16 +9,13 @@ import (
9 // PathToURI turns a file path into the file:// URI the protocol names9 // PathToURI turns a file path into the file:// URI the protocol names
10 // documents by.10 // documents by.
11 //11 //
12-// The path is made absolute first, since a language server has no idea what12+// The path is made canonical first — absolute, with symbolic links resolved —
13-// the editor's working directory is.13+// since a language server has no idea what the editor's working directory is,
14+// and some resolve links themselves: see CanonicalPath.
14 //15 //
15 // lsp.PathToURI("main.go") // file:///home/you/project/main.go16 // lsp.PathToURI("main.go") // file:///home/you/project/main.go
16 func PathToURI(path string) string {17 func PathToURI(path string) string {
17- absolute, err := filepath.Abs(path)18+ absolute := filepath.ToSlash(CanonicalPath(path))
18- if err != nil {
19- absolute = path
20- }
21- absolute = filepath.ToSlash(absolute)
22 19
23 if !strings.HasPrefix(absolute, "/") {20 if !strings.HasPrefix(absolute, "/") {
24 // A Windows path such as C:/x becomes /C:/x, which is what the URI21 // A Windows path such as C:/x becomes /C:/x, which is what the URI
@@ -30,6 +27,41 @@ func PathToURI(path string) string {
30 return uri.String()27 return uri.String()
31 }28 }
32 29
30+// CanonicalPath is the one spelling of a file this package names it by:
31+// absolute, with every symbolic link resolved.
32+//
33+// Resolving the links is not tidiness. moon-lsp works a package's files out
34+// from disk and canonicalises what it finds, so a document announced under
35+// another spelling of the same path is, to it, a file that belongs to no
36+// package: it answers no completion about the buffer's types, and it publishes
37+// the file's diagnostics under the spelling it knows — which the editor then
38+// cannot match to any open buffer. On macOS every temporary directory is such
39+// a spelling: /var is a link to /private/var, and the Turbo MoonBit suite,
40+// green on Linux, failed both of those ways the first time it ran on a Mac.
41+//
42+// A file that is not on disk yet — a buffer being saved under a new name — has
43+// nothing to resolve, so its deepest existing directory is resolved instead and
44+// the rest of the path put back on. A path that cannot be made absolute is
45+// returned as it is, which degrades to the behaviour there was before.
46+func CanonicalPath(path string) string {
47+ absolute, err := filepath.Abs(path)
48+ if err != nil {
49+ return path
50+ }
51+ if resolved, err := filepath.EvalSymlinks(absolute); err == nil {
52+ return resolved
53+ }
54+
55+ dir, rest := filepath.Dir(absolute), filepath.Base(absolute)
56+ for dir != filepath.Dir(dir) {
57+ if resolved, err := filepath.EvalSymlinks(dir); err == nil {
58+ return filepath.Join(resolved, rest)
59+ }
60+ dir, rest = filepath.Dir(dir), filepath.Join(filepath.Base(dir), rest)
61+ }
62+ return absolute
63+}
64+
33 // URIToPath turns a file:// URI back into a path. Anything that is not a file65 // URIToPath turns a file:// URI back into a path. Anything that is not a file
34 // URI comes back unchanged, since there is nothing better to do with it.66 // URI comes back unchanged, since there is nothing better to do with it.
35 func URIToPath(uri string) string {67 func URIToPath(uri string) string {