📦 Turbo Core — canonical paths: symbolic links resolved in URIs and document keys (moon-lsp on macOS)
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 @@ | ||
| 262 | 262 | - **Quality**: run #30 **PASS** 0/0/0, complexity 2308 (+2). |
| 263 | 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 | 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. | |
| @@ -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 | ||
| 125 | 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 | 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 | 130 | ## Build, test, run |
| 129 | 131 | |
| 130 | 132 | ```bash |
| @@ -156,6 +158,11 @@ python3 ~/.claude/skills/quality/scripts/quality_report.py --workspace . | ||
| 156 | 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 | 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 | 166 | ## Not yet established |
| 160 | 167 | |
| 161 | 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. |
| @@ -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, run | 130 | ## Build, test, run |
| 129 | 131 | ||
| 130 | ```bash | 132 | ```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 established | 166 | ## 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 | ||
| 179 | 179 | |
| 180 | 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 path | |
| 182 | +## 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 | 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 | |
| @@ -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 path | 182 | +## 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 ( | ||
| 8 | 8 | |
| 9 | 9 | "rickub.com/turbo-editors/turbo-core/buffer" |
| 10 | 10 | "rickub.com/turbo-editors/turbo-core/editor" |
| 11 | + "rickub.com/turbo-editors/turbo-core/lsp" | |
| 11 | 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 | 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 | 61 | 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) | |
| 58 | 63 | |
| 59 | 64 | for _, window := range a.desktop.Windows() { |
| 60 | 65 | view, ok := editorViewOf(window) |
| 61 | 66 | if !ok || view.Buffer().Path() == "" { |
| 62 | 67 | 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 | 70 | return window |
| 66 | 71 | } |
| 67 | 72 | } |
| @@ -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 | continue | 67 | 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 window | 70 | return window |
| 66 | } | 71 | } |
| 67 | } | 72 | } |
modified
app/diagnostics_test.go +69 -0 | @@ -1,6 +1,7 @@ | ||
| 1 | 1 | package app |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | + "os" | |
| 4 | 5 | "path/filepath" |
| 5 | 6 | "testing" |
| 6 | 7 | |
| @@ -81,3 +82,71 @@ func TestKnowsDoesNotDependOnTheSpellingOfThePath(t *testing.T) { | ||
| 81 | 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 | +} | |
| @@ -1,6 +1,7 @@ | |||
| 1 | package app | 1 | 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 { | ||
| 21 | 21 | stream net.Conn |
| 22 | 22 | reader *bufio.Reader |
| 23 | 23 | |
| 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 | |
| 27 | 28 | } |
| 28 | 29 | |
| 29 | 30 | // frame is one JSON-RPC message, reduced to what this fake needs to look at. |
| @@ -31,6 +32,7 @@ type frame struct { | ||
| 31 | 32 | JSONRPC string `json:"jsonrpc"` |
| 32 | 33 | ID json.RawMessage `json:"id,omitempty"` |
| 33 | 34 | Method string `json:"method,omitempty"` |
| 35 | + Params json.RawMessage `json:"params,omitempty"` | |
| 34 | 36 | Result json.RawMessage `json:"result,omitempty"` |
| 35 | 37 | } |
| 36 | 38 | |
| @@ -82,6 +84,11 @@ func (s *fakeLSP) serve() { | ||
| 82 | 84 | return |
| 83 | 85 | } |
| 84 | 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 | 93 | if len(msg.ID) == 0 || msg.Method == "" { |
| 87 | 94 | continue // a notification, or an answer to something we asked |
| @@ -131,6 +138,35 @@ func (s *fakeLSP) record(method string) { | ||
| 131 | 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, ¶ms) | |
| 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 | 170 | // methodCount returns how many times the client has sent a method. |
| 135 | 171 | func (s *fakeLSP) methodCount(method string) int { |
| 136 | 172 | s.mu.Lock() |
| @@ -21,9 +21,10 @@ type fakeLSP struct { | |||
| 21 | stream net.Conn | 21 | stream net.Conn |
| 22 | reader *bufio.Reader | 22 | reader *bufio.Reader |
| 23 | 23 | ||
| 24 | - mu sync.Mutex | 24 | + mu sync.Mutex |
| 25 | - seen map[string]int | 25 | + seen map[string]int |
| 26 | - answers map[string]json.RawMessage | 26 | + 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 | return | 84 | 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 asked | 94 | 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, ¶ms) | ||
| 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 | ||
| 3 | 3 | import ( |
| 4 | 4 | "context" |
| 5 | 5 | "errors" |
| 6 | - "path/filepath" | |
| 7 | 6 | "sort" |
| 8 | 7 | "sync" |
| 9 | 8 | |
| @@ -367,20 +366,19 @@ func (l *Language) receiveDiagnostics(path string, diagnostics []lsp.Diagnostic) | ||
| 367 | 366 | // pathKey is how a file is named in the maps of problems and of open |
| 368 | 367 | // documents. |
| 369 | 368 | // |
| 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. | |
| 375 | 377 | // |
| 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. | |
| 378 | 380 | 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) | |
| 384 | 382 | } |
| 385 | 383 | |
| 386 | 384 | // 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 open | 366 | // 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 server | 369 | +// Canonical, always — absolute, links resolved — because the two sides spell |
| 371 | -// publishes absolute URIs; a buffer opened from the command line — `turbo-go | 370 | +// 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 than | 378 | +// 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 is | 384 | // 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 | ||
| 68 | 68 | | Method | Description | |
| 69 | 69 | | --- | --- | |
| 70 | 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 | 72 | | `Status() string` | The one-line state shown on the status bar. | |
| 73 | 73 | | `Report() Report` | Status, server path, root and readiness together. | |
| 74 | 74 | | `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 | ||
| 68 | 68 | | Méthode | Description | |
| 69 | 69 | | --- | --- | |
| 70 | 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 | 72 | | `Status() string` | L'état d'une ligne affiché sur la barre d'état. | |
| 73 | 73 | | `Report() Report` | État, chemin du serveur, racine et disponibilité ensemble. | |
| 74 | 74 | | `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 | ||
| 62 | 62 | | `(*Client) DocumentSymbols / WorkspaceSymbols` | What a file declares, and what a project holds matching a query | |
| 63 | 63 | | `Symbol`, `SymbolKind` | One shape for the three the protocol has | |
| 64 | 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 | 66 | | `RuneToUTF16` / `UTF16ToRune` | Column conversion | |
| 67 | 67 | | `(CompletionItem) Insertion() string` | The text to insert, snippet placeholders stripped | |
| 68 | 68 | |
| @@ -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) { | ||
| 319 | 319 | if len(diagnostics) != 1 || diagnostics[0].Message != "undefined: foo" { |
| 320 | 320 | t.Errorf("the diagnostics are %+v", diagnostics) |
| 321 | 321 | } |
| 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)) | |
| 324 | 327 | } |
| 325 | 328 | case <-time.After(time.Second): |
| 326 | 329 | t.Fatal("no diagnostics arrived") |
| @@ -454,8 +457,11 @@ func TestPathAndURIRoundTrip(t *testing.T) { | ||
| 454 | 457 | if strings.Contains(uri, " ") { |
| 455 | 458 | t.Errorf("PathToURI() = %q, want the space percent-encoded", uri) |
| 456 | 459 | } |
| 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) | |
| 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 | +} | |
| @@ -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 ( | ||
| 9 | 9 | // PathToURI turns a file path into the file:// URI the protocol names |
| 10 | 10 | // documents by. |
| 11 | 11 | // |
| 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. | |
| 14 | 15 | // |
| 15 | 16 | // lsp.PathToURI("main.go") // file:///home/you/project/main.go |
| 16 | 17 | 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)) | |
| 22 | 19 | |
| 23 | 20 | if !strings.HasPrefix(absolute, "/") { |
| 24 | 21 | // 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 | 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 | 65 | // URIToPath turns a file:// URI back into a path. Anything that is not a file |
| 34 | 66 | // URI comes back unchanged, since there is nothing better to do with it. |
| 35 | 67 | func URIToPath(uri string) string { |
| @@ -9,16 +9,13 @@ import ( | |||
| 9 | // PathToURI turns a file path into the file:// URI the protocol names | 9 | // 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 what | 12 | +// 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.go | 16 | // 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 URI | 21 | // 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 file | 65 | // 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 { |