turbo-editors/turbo-corepublic Fork 0
3561e52
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 — a save that creates a file tells the server (workspace/didChangeWatchedFiles), so moon-lsp diagnoses a new .mbt from its first save

k33g committed 2026-09-19T13:56:45+02:00 Browse files
3561e52 parent: b91316e
modified .memory/handoffs/2026-09-19-canonical-paths.md +4 -0
@@ -19,3 +19,7 @@
1919 ## Second pass
2020
2121 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`.
22+
23+## Third pass — v1.0.2: `FileCreated`
24+
25+turbo-moonbit's pinning test went red on the Mac for the opposite reason — moon-lsp there notices a new file by itself. The limit was ours (no `workspace/didChangeWatchedFiles`); fixed, four tests, docs. Release v1.0.2 with `./01-release.tag.sh`, then re-pin every editor: `go get rickub.com/turbo-editors/turbo-core@v1.0.2 && go mod tidy && GOWORK=off make check`. turbo-moonbit's suite fails until then.
@@ -19,3 +19,7 @@
19 ## Second pass19 ## Second pass
20 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`.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`.
22+
23+## Third pass — v1.0.2: `FileCreated`
24+
25+turbo-moonbit's pinning test went red on the Mac for the opposite reason — moon-lsp there notices a new file by itself. The limit was ours (no `workspace/didChangeWatchedFiles`); fixed, four tests, docs. Release v1.0.2 with `./01-release.tag.sh`, then re-pin every editor: `go get rickub.com/turbo-editors/turbo-core@v1.0.2 && go mod tidy && GOWORK=off make check`. turbo-moonbit's suite fails until then.
modified .memory/history.md +9 -0
@@ -278,3 +278,12 @@
278278 - **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`.
279279 - **Verified**: `make check` green; whole suite green with `TMPDIR` under a symlink (`TURBO_CORE_RELEASING=1` to keep the release scripts out of it).
280280 - **Not done**: still not committed or tagged; `release.env` still says v1.0.1. Run `./01-release.tag.sh` again.
281+
282+## 2026-09-19 (later) — a save that creates a file tells the server: moon-lsp never diagnosed a new .mbt
283+
284+- **Origin**: turbo-core v1.0.1 published, turbo-moonbit re-pinned; its `make check` on the Mac then failed `TestAFileCreatedAfterTheServerStartedIsNotDiagnosed` — the test pinning "moon-lsp never diagnoses a file created after it started" — because on macOS moon-lsp *does*. Here (Linux, moon 0.1.20260915) the test still passed, so the platforms disagree; the documentation it protected had in fact already been overtaken (turbo-moonbit's `enable-completion.md` has promised error marks from the first save since 2026-09-18), and `languages.md` no longer mentions it.
285+- **Diagnosed** with an LSP probe against `moon-lsp --stdio`: a new file announced by `didOpen` (and `didSave`) is never diagnosed on Linux; the moment `workspace/didChangeWatchedFiles` names it — in any order relative to the document notifications, type 1 or 2 — its diagnostics arrive. The server advertises no watcher registration, so nothing ever asked the editor to send this. turbo-moonbit's own summary had already written the fix down as turbo-core's to make.
286+- **Changes**: `lsp/protocol.go``FileChangeType` (`FileCreated`/`FileChanged`/`FileDeleted`), `FileEvent`, `DidChangeWatchedFilesParams`. `lsp/client.go``FileCreated(path)`. `app/language.go``FileCreated`. `app/actions_file.go``save` asks `fileExists` before writing; `afterSave` and `announceSaved` take `created`; the file event goes after the document is announced or saved. `app/autosave.go``writeQuietly` the same. `app/fakelsp_test.go` — records the last file event and the order of methods (`lastFileEvents`, `methodsSeen`). Tests: `TestSavingANewFileTellsTheServerTheFileExists`, `TestSavingAnExistingFileDoesNotClaimItWasCreated`, `TestAutosaveOfANewlyNamedFileTellsTheServerTheFileExists`; the lsp notification-order test includes the new one. The `firstIndexOf` helper: `actions_view.go` owns `indexOf`.
287+- **Verified**: `make check` green; whole suite green with `TMPDIR` under a symlink. In turbo-moonbit (through `go.work`), the rewritten `TestAFileCreatedInTheEditorIsDiagnosedFromItsFirstSaveWithRealMoonLSP` passes (plain and symlinked `TMPDIR`) and **fails against the published v1.0.1** — the falsification. A first version of that test failed for a reason of its own: its `typeText` dropped `\n`, so the four-line fixture became one `///|` doc-comment line and compiled; traced with a tee wrapper around `moon-lsp`. The helper now sends Enter for a newline.
288+- **Docs**: `app/README.md`, `docs/{en,fr}/reference/app.md`, `docs/{en,fr}/how-to/talk-to-a-language-server.md`, `lsp/README.md`.
289+- **Not done**: not committed, not tagged; `release.env` says v1.0.2. Whether moon-lsp on macOS watches the directory itself is inferred from the user's run, not measured.
@@ -278,3 +278,12 @@
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`.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).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.280 - **Not done**: still not committed or tagged; `release.env` still says v1.0.1. Run `./01-release.tag.sh` again.
281+
282+## 2026-09-19 (later) — a save that creates a file tells the server: moon-lsp never diagnosed a new .mbt
283+
284+- **Origin**: turbo-core v1.0.1 published, turbo-moonbit re-pinned; its `make check` on the Mac then failed `TestAFileCreatedAfterTheServerStartedIsNotDiagnosed` — the test pinning "moon-lsp never diagnoses a file created after it started" — because on macOS moon-lsp *does*. Here (Linux, moon 0.1.20260915) the test still passed, so the platforms disagree; the documentation it protected had in fact already been overtaken (turbo-moonbit's `enable-completion.md` has promised error marks from the first save since 2026-09-18), and `languages.md` no longer mentions it.
285+- **Diagnosed** with an LSP probe against `moon-lsp --stdio`: a new file announced by `didOpen` (and `didSave`) is never diagnosed on Linux; the moment `workspace/didChangeWatchedFiles` names it — in any order relative to the document notifications, type 1 or 2 — its diagnostics arrive. The server advertises no watcher registration, so nothing ever asked the editor to send this. turbo-moonbit's own summary had already written the fix down as turbo-core's to make.
286+- **Changes**: `lsp/protocol.go``FileChangeType` (`FileCreated`/`FileChanged`/`FileDeleted`), `FileEvent`, `DidChangeWatchedFilesParams`. `lsp/client.go``FileCreated(path)`. `app/language.go``FileCreated`. `app/actions_file.go``save` asks `fileExists` before writing; `afterSave` and `announceSaved` take `created`; the file event goes after the document is announced or saved. `app/autosave.go``writeQuietly` the same. `app/fakelsp_test.go` — records the last file event and the order of methods (`lastFileEvents`, `methodsSeen`). Tests: `TestSavingANewFileTellsTheServerTheFileExists`, `TestSavingAnExistingFileDoesNotClaimItWasCreated`, `TestAutosaveOfANewlyNamedFileTellsTheServerTheFileExists`; the lsp notification-order test includes the new one. The `firstIndexOf` helper: `actions_view.go` owns `indexOf`.
287+- **Verified**: `make check` green; whole suite green with `TMPDIR` under a symlink. In turbo-moonbit (through `go.work`), the rewritten `TestAFileCreatedInTheEditorIsDiagnosedFromItsFirstSaveWithRealMoonLSP` passes (plain and symlinked `TMPDIR`) and **fails against the published v1.0.1** — the falsification. A first version of that test failed for a reason of its own: its `typeText` dropped `\n`, so the four-line fixture became one `///|` doc-comment line and compiled; traced with a tee wrapper around `moon-lsp`. The helper now sends Enter for a newline.
288+- **Docs**: `app/README.md`, `docs/{en,fr}/reference/app.md`, `docs/{en,fr}/how-to/talk-to-a-language-server.md`, `lsp/README.md`.
289+- **Not done**: not committed, not tagged; `release.env` says v1.0.2. Whether moon-lsp on macOS watches the directory itself is inferred from the user's run, not measured.
modified .memory/summary.md +4 -1
@@ -127,6 +127,8 @@ The decisions inherited from turbo-go about the *editor's behaviour* — the eve
127127
128128 - **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.
129129
130+- **A save that creates a file says so to the server (2026-09-19, v1.0.2).** `lsp.Client.FileCreated` sends `workspace/didChangeWatchedFiles` (type 1); `app.announceSaved` sends it after the document's `didOpen`/`didSave` whenever the save created the file (`fileExists` asked before the write, in both `save` and `writeQuietly`). Reason: moon-lsp lists a package's files from the directory, so a `.mbt` saved for the first time — `turbo-moonbit new.mbt`, type, save — was known as a document and never diagnosed. Verified at the protocol level (every order of didOpen/didSave/didChangeWatchedFiles gets the file diagnosed; nothing does without it) and by Turbo MoonBit's `TestAFileCreatedInTheEditorIsDiagnosedFromItsFirstSaveWithRealMoonLSP`, which fails against v1.0.1 and passes against this. Sent unasked (moon-lsp registers no watcher); harmless to the fake and, by the spec, to any server. Only `Created` is ever sent: Save As leaves the old file on disk, so no `Deleted`.
131+
130132 ## Build, test, run
131133
132134 ```bash
@@ -161,7 +163,8 @@ python3 ~/.claude/skills/quality/scripts/quality_report.py --workspace .
161163 ## State as of 2026-09-19
162164
163165 - **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.
166+- **v1.0.1 is released**: the canonical-path fix (`lsp.CanonicalPath`, `pathKey`, `windowFor`). turbo-moonbit is re-pinned to it.
167+- **v1.0.2 is ready to tag** (`release.env` says so): `FileCreated` above; four new tests (`lsp` ×1 in the notification-order test, `app` ×3 in `save_test.go`), the fake server records the last `didOpen` and the last file event and the order of every method. Turbo MoonBit's suite needs it (its new first-save test fails on v1.0.1); every editor should re-pin before releasing.
165168
166169 ## Not yet established
167170
@@ -127,6 +127,8 @@ The decisions inherited from turbo-go about the *editor's behaviour* — the eve
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.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 129
130+- **A save that creates a file says so to the server (2026-09-19, v1.0.2).** `lsp.Client.FileCreated` sends `workspace/didChangeWatchedFiles` (type 1); `app.announceSaved` sends it after the document's `didOpen`/`didSave` whenever the save created the file (`fileExists` asked before the write, in both `save` and `writeQuietly`). Reason: moon-lsp lists a package's files from the directory, so a `.mbt` saved for the first time — `turbo-moonbit new.mbt`, type, save — was known as a document and never diagnosed. Verified at the protocol level (every order of didOpen/didSave/didChangeWatchedFiles gets the file diagnosed; nothing does without it) and by Turbo MoonBit's `TestAFileCreatedInTheEditorIsDiagnosedFromItsFirstSaveWithRealMoonLSP`, which fails against v1.0.1 and passes against this. Sent unasked (moon-lsp registers no watcher); harmless to the fake and, by the spec, to any server. Only `Created` is ever sent: Save As leaves the old file on disk, so no `Deleted`.
131+
130 ## Build, test, run132 ## Build, test, run
131 133
132 ```bash134 ```bash
@@ -161,7 +163,8 @@ python3 ~/.claude/skills/quality/scripts/quality_report.py --workspace .
161 ## State as of 2026-09-19163 ## State as of 2026-09-19
162 164
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.165 - **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.166+- **v1.0.1 is released**: the canonical-path fix (`lsp.CanonicalPath`, `pathKey`, `windowFor`). turbo-moonbit is re-pinned to it.
167+- **v1.0.2 is ready to tag** (`release.env` says so): `FileCreated` above; four new tests (`lsp` ×1 in the notification-order test, `app` ×3 in `save_test.go`), the fake server records the last `didOpen` and the last file event and the order of every method. Turbo MoonBit's suite needs it (its new first-save test fails on v1.0.1); every editor should re-pin before releasing.
165 168
166 ## Not yet established169 ## Not yet established
167 170
modified app/README.md +1 -1
@@ -191,4 +191,4 @@ Absolute alone was not enough. moon-lsp canonicalises the files of a package, so
191191
192192 A window that starts Untitled is skipped by every `didOpen`: it has no path to announce. The moment Save As gave it one used to send only `didSave` — and a server ignores `didChange` and `didSave` for a document it was never told is open, so that window had no completion, hover or diagnostics until the editor was restarted and the file reopened under its name. Found by a user whose first launch "had no LSP" until they saved, quit and relaunched.
193193
194-`announceSaved` is the fix, in `afterSave` so both save paths get it: a save of a path the server `Knows` reports the write, and a save of one it does not announces the document. And because Save As can also **rename**, `save` remembers the buffer's previous path before `SaveAs` rewrites it, and `renamed` — compared through `pathKey`, so a change of spelling is not a rename — closes the old document on the server. Without that close a ghost stays open there for as long as the editor runs, keeping its diagnostics and shadowing the file if it is ever reopened.
194+`announceSaved` is the fix, in `afterSave` so both save paths get it: a save of a path the server `Knows` reports the write, and a save of one it does not announces the document. A save that **created** the file — `save` and `writeQuietly` both ask `fileExists` before writing — also sends `workspace/didChangeWatchedFiles` with the file as created, after the document has been announced. To moon-lsp a document being open and a file existing are two facts, and it works a package's files out from the directory: a `.mbt` saved for the first time got no diagnostics however loudly the document had been announced, until told the file is there. Found on macOS, the first time Turbo MoonBit's suite ran there (where moon-lsp evidently notices new files by itself, and a test pinning the old limit went red); confirmed at the protocol level on Linux, where the notification is the only thing that makes the difference. And because Save As can also **rename**, `save` remembers the buffer's previous path before `SaveAs` rewrites it, and `renamed` — compared through `pathKey`, so a change of spelling is not a rename — closes the old document on the server. Without that close a ghost stays open there for as long as the editor runs, keeping its diagnostics and shadowing the file if it is ever reopened.
@@ -191,4 +191,4 @@ Absolute alone was not enough. moon-lsp canonicalises the files of a package, so
191 191
192 A window that starts Untitled is skipped by every `didOpen`: it has no path to announce. The moment Save As gave it one used to send only `didSave` — and a server ignores `didChange` and `didSave` for a document it was never told is open, so that window had no completion, hover or diagnostics until the editor was restarted and the file reopened under its name. Found by a user whose first launch "had no LSP" until they saved, quit and relaunched.192 A window that starts Untitled is skipped by every `didOpen`: it has no path to announce. The moment Save As gave it one used to send only `didSave` — and a server ignores `didChange` and `didSave` for a document it was never told is open, so that window had no completion, hover or diagnostics until the editor was restarted and the file reopened under its name. Found by a user whose first launch "had no LSP" until they saved, quit and relaunched.
193 193
194-`announceSaved` is the fix, in `afterSave` so both save paths get it: a save of a path the server `Knows` reports the write, and a save of one it does not announces the document. And because Save As can also **rename**, `save` remembers the buffer's previous path before `SaveAs` rewrites it, and `renamed` — compared through `pathKey`, so a change of spelling is not a rename — closes the old document on the server. Without that close a ghost stays open there for as long as the editor runs, keeping its diagnostics and shadowing the file if it is ever reopened.194+`announceSaved` is the fix, in `afterSave` so both save paths get it: a save of a path the server `Knows` reports the write, and a save of one it does not announces the document. A save that **created** the file — `save` and `writeQuietly` both ask `fileExists` before writing — also sends `workspace/didChangeWatchedFiles` with the file as created, after the document has been announced. To moon-lsp a document being open and a file existing are two facts, and it works a package's files out from the directory: a `.mbt` saved for the first time got no diagnostics however loudly the document had been announced, until told the file is there. Found on macOS, the first time Turbo MoonBit's suite ran there (where moon-lsp evidently notices new files by itself, and a test pinning the old limit went red); confirmed at the protocol level on Linux, where the notification is the only thing that makes the difference. And because Save As can also **rename**, `save` remembers the buffer's previous path before `SaveAs` rewrites it, and `renamed` — compared through `pathKey`, so a change of spelling is not a rename — closes the old document on the server. Without that close a ghost stays open there for as long as the editor runs, keeping its diagnostics and shadowing the file if it is ever reopened.
modified app/actions_file.go +26 -6
@@ -4,6 +4,7 @@ package app
44
55 import (
66 "fmt"
7+ "os"
78 "path/filepath"
89
910 "rickub.com/turbo-editors/turbo-core/buffer"
@@ -169,6 +170,7 @@ func (a *App) save(view *editor.View, path string) {
169170 // Remembered before the write, because SaveAs rewrites the buffer's path;
170171 // it is the only record of which document the server had open until now.
171172 previous := view.Buffer().Path()
173+ created := !fileExists(path)
172174 if err := view.Buffer().SaveAs(path); err != nil {
173175 a.ShowMessage("Cannot save", err.Error())
174176 return
@@ -176,7 +178,14 @@ func (a *App) save(view *editor.View, path string) {
176178 if renamed(previous, path) {
177179 a.language.DidClose(previous)
178180 }
179- a.afterSave(view, path)
181+ a.afterSave(view, path, created)
182+}
183+
184+// fileExists reports whether there is already a file at a path — asked before
185+// a write, so that afterwards the save knows whether it created the file.
186+func fileExists(path string) bool {
187+ _, err := os.Stat(path)
188+ return err == nil
180189 }
181190
182191 // renamed reports whether a save gave the buffer a genuinely different file,
@@ -198,12 +207,12 @@ func renamed(previous, path string) bool {
198207 // so that a step added to one cannot go missing from the other — re-reading the
199208 // project's settings is exactly such a step, and would have been missing from
200209 // autosave.
201-func (a *App) afterSave(view *editor.View, path string) {
210+func (a *App) afterSave(view *editor.View, path string, created bool) {
202211 view.RefreshSyntax()
203212 if window := a.windowOf(view); window != nil {
204213 window.SetTitle(windowTitle(view.Buffer()))
205214 }
206- a.announceSaved(path, view.Buffer().Text())
215+ a.announceSaved(path, view.Buffer().Text(), created)
207216 a.refreshTree()
208217
209218 // The message comes before the settings are re-read, so that re-reading can
@@ -223,12 +232,23 @@ func (a *App) afterSave(view *editor.View, path string) {
223232 // window without completion, hover or diagnostics until the editor is
224233 // restarted and the file is opened with its name — which is exactly how the
225234 // defect was found.
226-func (a *App) announceSaved(path, text string) {
235+//
236+// A save that created the file also says so, after the document has been
237+// announced: to a server that works a package's files out from the directory
238+// — moon-lsp — a document being open and a file existing are different facts,
239+// and it diagnoses a file saved for the first time only once told the file is
240+// there. Whether the document was already known does not enter into it: a
241+// buffer opened as `turbo-moonbit new.mbt` was announced long before anything
242+// was on disk.
243+func (a *App) announceSaved(path, text string, created bool) {
227244 if a.language.Knows(path) {
228245 a.language.DidSave(path, text)
229- return
246+ } else {
247+ a.language.DidOpen(path, text)
248+ }
249+ if created {
250+ a.language.FileCreated(path)
230251 }
231- a.language.DidOpen(path, text)
232252 }
233253
234254 // windowOf returns the window holding a view.
@@ -4,6 +4,7 @@ package app
4 4
5 import (5 import (
6 "fmt"6 "fmt"
7+ "os"
7 "path/filepath"8 "path/filepath"
8 9
9 "rickub.com/turbo-editors/turbo-core/buffer"10 "rickub.com/turbo-editors/turbo-core/buffer"
@@ -169,6 +170,7 @@ func (a *App) save(view *editor.View, path string) {
169 // Remembered before the write, because SaveAs rewrites the buffer's path;170 // Remembered before the write, because SaveAs rewrites the buffer's path;
170 // it is the only record of which document the server had open until now.171 // it is the only record of which document the server had open until now.
171 previous := view.Buffer().Path()172 previous := view.Buffer().Path()
173+ created := !fileExists(path)
172 if err := view.Buffer().SaveAs(path); err != nil {174 if err := view.Buffer().SaveAs(path); err != nil {
173 a.ShowMessage("Cannot save", err.Error())175 a.ShowMessage("Cannot save", err.Error())
174 return176 return
@@ -176,7 +178,14 @@ func (a *App) save(view *editor.View, path string) {
176 if renamed(previous, path) {178 if renamed(previous, path) {
177 a.language.DidClose(previous)179 a.language.DidClose(previous)
178 }180 }
179- a.afterSave(view, path)181+ a.afterSave(view, path, created)
182+}
183+
184+// fileExists reports whether there is already a file at a path — asked before
185+// a write, so that afterwards the save knows whether it created the file.
186+func fileExists(path string) bool {
187+ _, err := os.Stat(path)
188+ return err == nil
180 }189 }
181 190
182 // renamed reports whether a save gave the buffer a genuinely different file,191 // renamed reports whether a save gave the buffer a genuinely different file,
@@ -198,12 +207,12 @@ func renamed(previous, path string) bool {
198 // so that a step added to one cannot go missing from the other — re-reading the207 // so that a step added to one cannot go missing from the other — re-reading the
199 // project's settings is exactly such a step, and would have been missing from208 // project's settings is exactly such a step, and would have been missing from
200 // autosave.209 // autosave.
201-func (a *App) afterSave(view *editor.View, path string) {210+func (a *App) afterSave(view *editor.View, path string, created bool) {
202 view.RefreshSyntax()211 view.RefreshSyntax()
203 if window := a.windowOf(view); window != nil {212 if window := a.windowOf(view); window != nil {
204 window.SetTitle(windowTitle(view.Buffer()))213 window.SetTitle(windowTitle(view.Buffer()))
205 }214 }
206- a.announceSaved(path, view.Buffer().Text())215+ a.announceSaved(path, view.Buffer().Text(), created)
207 a.refreshTree()216 a.refreshTree()
208 217
209 // The message comes before the settings are re-read, so that re-reading can218 // The message comes before the settings are re-read, so that re-reading can
@@ -223,12 +232,23 @@ func (a *App) afterSave(view *editor.View, path string) {
223 // window without completion, hover or diagnostics until the editor is232 // window without completion, hover or diagnostics until the editor is
224 // restarted and the file is opened with its name — which is exactly how the233 // restarted and the file is opened with its name — which is exactly how the
225 // defect was found.234 // defect was found.
226-func (a *App) announceSaved(path, text string) {235+//
236+// A save that created the file also says so, after the document has been
237+// announced: to a server that works a package's files out from the directory
238+// — moon-lsp — a document being open and a file existing are different facts,
239+// and it diagnoses a file saved for the first time only once told the file is
240+// there. Whether the document was already known does not enter into it: a
241+// buffer opened as `turbo-moonbit new.mbt` was announced long before anything
242+// was on disk.
243+func (a *App) announceSaved(path, text string, created bool) {
227 if a.language.Knows(path) {244 if a.language.Knows(path) {
228 a.language.DidSave(path, text)245 a.language.DidSave(path, text)
229- return246+ } else {
247+ a.language.DidOpen(path, text)
248+ }
249+ if created {
250+ a.language.FileCreated(path)
230 }251 }
231- a.language.DidOpen(path, text)
232 }252 }
233 253
234 // windowOf returns the window holding a view.254 // windowOf returns the window holding a view.
modified app/autosave.go +2 -1
@@ -120,12 +120,13 @@ func (a *App) autosaveOne(view *editor.View) {
120120 // than the problem it describes.
121121 func (a *App) writeQuietly(view *editor.View) bool {
122122 path := view.Buffer().Path()
123+ created := !fileExists(path)
123124 if err := view.Buffer().SaveAs(path); err != nil {
124125 a.Message("Cannot save " + filepath.Base(path) + ": " + err.Error())
125126 return false
126127 }
127128
128- a.afterSave(view, path)
129+ a.afterSave(view, path, created)
129130 return true
130131 }
131132
@@ -120,12 +120,13 @@ func (a *App) autosaveOne(view *editor.View) {
120 // than the problem it describes.120 // than the problem it describes.
121 func (a *App) writeQuietly(view *editor.View) bool {121 func (a *App) writeQuietly(view *editor.View) bool {
122 path := view.Buffer().Path()122 path := view.Buffer().Path()
123+ created := !fileExists(path)
123 if err := view.Buffer().SaveAs(path); err != nil {124 if err := view.Buffer().SaveAs(path); err != nil {
124 a.Message("Cannot save " + filepath.Base(path) + ": " + err.Error())125 a.Message("Cannot save " + filepath.Base(path) + ": " + err.Error())
125 return false126 return false
126 }127 }
127 128
128- a.afterSave(view, path)129+ a.afterSave(view, path, created)
129 return true130 return true
130 }131 }
131 132
modified app/fakelsp_test.go +26 -0
@@ -23,8 +23,10 @@ type fakeLSP struct {
2323
2424 mu sync.Mutex
2525 seen map[string]int
26+ order []string // every method, in the order it arrived
2627 answers map[string]json.RawMessage
2728 lastOpened json.RawMessage // the params of the last textDocument/didOpen
29+ lastFiles json.RawMessage // the params of the last workspace/didChangeWatchedFiles
2830 }
2931
3032 // frame is one JSON-RPC message, reduced to what this fake needs to look at.
@@ -89,6 +91,11 @@ func (s *fakeLSP) serve() {
8991 s.lastOpened = msg.Params
9092 s.mu.Unlock()
9193 }
94+ if msg.Method == "workspace/didChangeWatchedFiles" {
95+ s.mu.Lock()
96+ s.lastFiles = msg.Params
97+ s.mu.Unlock()
98+ }
9299
93100 if len(msg.ID) == 0 || msg.Method == "" {
94101 continue // a notification, or an answer to something we asked
@@ -136,6 +143,14 @@ func (s *fakeLSP) record(method string) {
136143 s.mu.Lock()
137144 defer s.mu.Unlock()
138145 s.seen[method]++
146+ s.order = append(s.order, method)
147+}
148+
149+// methodsSeen returns every method the client has sent, in order.
150+func (s *fakeLSP) methodsSeen() []string {
151+ s.mu.Lock()
152+ defer s.mu.Unlock()
153+ return append([]string(nil), s.order...)
139154 }
140155
141156 // lastOpenedURI returns the URI the client announced in its most recent
@@ -154,6 +169,17 @@ func (s *fakeLSP) lastOpenedURI() string {
154169 return params.TextDocument.URI
155170 }
156171
172+// lastFileEvents returns the changes the client reported in its most recent
173+// workspace/didChangeWatchedFiles.
174+func (s *fakeLSP) lastFileEvents() []lsp.FileEvent {
175+ waitForMethodQuietly(s, "workspace/didChangeWatchedFiles")
176+ s.mu.Lock()
177+ defer s.mu.Unlock()
178+ var params lsp.DidChangeWatchedFilesParams
179+ _ = json.Unmarshal(s.lastFiles, &params)
180+ return params.Changes
181+}
182+
157183 // waitForMethodQuietly is waitForMethod for a caller that reports its own
158184 // failure: it gives up after two seconds and returns.
159185 func waitForMethodQuietly(server *fakeLSP, method string) {
@@ -23,8 +23,10 @@ type fakeLSP struct {
23 23
24 mu sync.Mutex24 mu sync.Mutex
25 seen map[string]int25 seen map[string]int
26+ order []string // every method, in the order it arrived
26 answers map[string]json.RawMessage27 answers map[string]json.RawMessage
27 lastOpened json.RawMessage // the params of the last textDocument/didOpen28 lastOpened json.RawMessage // the params of the last textDocument/didOpen
29+ lastFiles json.RawMessage // the params of the last workspace/didChangeWatchedFiles
28 }30 }
29 31
30 // frame is one JSON-RPC message, reduced to what this fake needs to look at.32 // frame is one JSON-RPC message, reduced to what this fake needs to look at.
@@ -89,6 +91,11 @@ func (s *fakeLSP) serve() {
89 s.lastOpened = msg.Params91 s.lastOpened = msg.Params
90 s.mu.Unlock()92 s.mu.Unlock()
91 }93 }
94+ if msg.Method == "workspace/didChangeWatchedFiles" {
95+ s.mu.Lock()
96+ s.lastFiles = msg.Params
97+ s.mu.Unlock()
98+ }
92 99
93 if len(msg.ID) == 0 || msg.Method == "" {100 if len(msg.ID) == 0 || msg.Method == "" {
94 continue // a notification, or an answer to something we asked101 continue // a notification, or an answer to something we asked
@@ -136,6 +143,14 @@ func (s *fakeLSP) record(method string) {
136 s.mu.Lock()143 s.mu.Lock()
137 defer s.mu.Unlock()144 defer s.mu.Unlock()
138 s.seen[method]++145 s.seen[method]++
146+ s.order = append(s.order, method)
147+}
148+
149+// methodsSeen returns every method the client has sent, in order.
150+func (s *fakeLSP) methodsSeen() []string {
151+ s.mu.Lock()
152+ defer s.mu.Unlock()
153+ return append([]string(nil), s.order...)
139 }154 }
140 155
141 // lastOpenedURI returns the URI the client announced in its most recent156 // lastOpenedURI returns the URI the client announced in its most recent
@@ -154,6 +169,17 @@ func (s *fakeLSP) lastOpenedURI() string {
154 return params.TextDocument.URI169 return params.TextDocument.URI
155 }170 }
156 171
172+// lastFileEvents returns the changes the client reported in its most recent
173+// workspace/didChangeWatchedFiles.
174+func (s *fakeLSP) lastFileEvents() []lsp.FileEvent {
175+ waitForMethodQuietly(s, "workspace/didChangeWatchedFiles")
176+ s.mu.Lock()
177+ defer s.mu.Unlock()
178+ var params lsp.DidChangeWatchedFilesParams
179+ _ = json.Unmarshal(s.lastFiles, &params)
180+ return params.Changes
181+}
182+
157 // waitForMethodQuietly is waitForMethod for a caller that reports its own183 // waitForMethodQuietly is waitForMethod for a caller that reports its own
158 // failure: it gives up after two seconds and returns.184 // failure: it gives up after two seconds and returns.
159 func waitForMethodQuietly(server *fakeLSP, method string) {185 func waitForMethodQuietly(server *fakeLSP, method string) {
modified app/language.go +9 -0
@@ -213,6 +213,15 @@ func (l *Language) DidSave(path, text string) {
213213 }
214214 }
215215
216+// FileCreated tells the server a file has appeared on disk — what a save under
217+// a new name does, and what a server that lists a package's files from the
218+// directory needs to hear before it will diagnose the document.
219+func (l *Language) FileCreated(path string) {
220+ if client := l.connected(); client != nil && path != "" {
221+ _ = client.FileCreated(path)
222+ }
223+}
224+
216225 // DidClose tells the server a file is no longer open, and forgets its
217226 // diagnostics.
218227 func (l *Language) DidClose(path string) {
@@ -213,6 +213,15 @@ func (l *Language) DidSave(path, text string) {
213 }213 }
214 }214 }
215 215
216+// FileCreated tells the server a file has appeared on disk — what a save under
217+// a new name does, and what a server that lists a package's files from the
218+// directory needs to hear before it will diagnose the document.
219+func (l *Language) FileCreated(path string) {
220+ if client := l.connected(); client != nil && path != "" {
221+ _ = client.FileCreated(path)
222+ }
223+}
224+
216 // DidClose tells the server a file is no longer open, and forgets its225 // DidClose tells the server a file is no longer open, and forgets its
217 // diagnostics.226 // diagnostics.
218 func (l *Language) DidClose(path string) {227 func (l *Language) DidClose(path string) {
modified app/save_test.go +99 -0
@@ -3,6 +3,9 @@ package app
33 import (
44 "path/filepath"
55 "testing"
6+ "time"
7+
8+ "rickub.com/turbo-editors/turbo-core/lsp"
69 )
710
811 // These tests cover what saving says to the language server. The defect they
@@ -95,3 +98,99 @@ func TestSavingAKnownDocumentReportsTheWrite(t *testing.T) {
9598 t.Errorf("didOpen was sent %d times; saving an open document must not re-announce it", got)
9699 }
97100 }
101+
102+func TestSavingANewFileTellsTheServerTheFileExists(t *testing.T) {
103+ // To moon-lsp a document being open and a file existing are two facts,
104+ // and it works a package's files out from the directory: a .mbt saved for
105+ // the first time got no diagnostics, however loudly the document had been
106+ // announced, until the server was told the file is there. Found on macOS
107+ // the first time Turbo MoonBit's suite ran there; confirmed at the
108+ // protocol level — the same file is diagnosed the moment
109+ // workspace/didChangeWatchedFiles names it.
110+ project := t.TempDir()
111+ t.Chdir(project)
112+ a, _ := newTestApp(t)
113+ client, server := newFakeLanguage(t, a)
114+ connectLanguage(a, client)
115+ a.Tick()
116+
117+ a.NewFile()
118+ typeText(a, "package main")
119+ path := filepath.Join(project, "main.go")
120+ a.save(a.ActiveView(), path)
121+
122+ waitForMethod(t, server, "workspace/didChangeWatchedFiles")
123+ events := server.lastFileEvents()
124+ if len(events) != 1 || events[0].Type != lsp.FileCreated {
125+ t.Fatalf("the server was told %+v, want one created file", events)
126+ }
127+ if want := lsp.PathToURI(path); events[0].URI != want {
128+ t.Errorf("the created file is %q, want %q", events[0].URI, want)
129+ }
130+ // The document first, then the file: the order the fix was verified in.
131+ methods := server.methodsSeen()
132+ if open, created := firstIndexOf(methods, "textDocument/didOpen"), firstIndexOf(methods, "workspace/didChangeWatchedFiles"); open < 0 || created < open {
133+ t.Errorf("the file was reported before the document was announced: %v", methods)
134+ }
135+}
136+
137+func TestSavingAnExistingFileDoesNotClaimItWasCreated(t *testing.T) {
138+ // A file that was already on disk is not news to the server, and a save
139+ // that claimed otherwise would have moon-lsp rescan the package on every
140+ // Ctrl-S.
141+ project := t.TempDir()
142+ t.Chdir(project)
143+ t.Setenv(testProfile().SnippetDirEnvVar(), t.TempDir())
144+ a, _ := newTestApp(t)
145+ client, server := newFakeLanguage(t, a)
146+ connectLanguage(a, client)
147+ path := filepath.Join(project, "main.go")
148+ writeTestFile(t, path, "package main\n")
149+ a.Open(path)
150+ a.Tick()
151+
152+ a.save(a.ActiveView(), path)
153+
154+ waitForMethod(t, server, "textDocument/didSave")
155+ if got := server.methodCount("workspace/didChangeWatchedFiles"); got != 0 {
156+ t.Errorf("saving a file that already existed reported it created %d time(s)", got)
157+ }
158+}
159+
160+func TestAutosaveOfANewlyNamedFileTellsTheServerTheFileExists(t *testing.T) {
161+ // The other way a file gets written. `turbo-moonbit new.mbt` opens a
162+ // buffer for a file that is not on disk yet; the first automatic save
163+ // creates it, and the server has to hear that from this path too.
164+ project := t.TempDir()
165+ t.Chdir(project)
166+ t.Setenv(testProfile().SnippetDirEnvVar(), t.TempDir())
167+ a, _ := newTestApp(t)
168+ client, server := newFakeLanguage(t, a)
169+ connectLanguage(a, client)
170+ path := filepath.Join(project, "new.go")
171+ a.Open(path) // not on disk: an empty buffer with that name
172+ a.Tick()
173+ a.SetAutosave(true, time.Millisecond)
174+
175+ typeText(a, "package main")
176+ deadline := time.Now().Add(2 * time.Second)
177+ for server.methodCount("workspace/didChangeWatchedFiles") == 0 && time.Now().Before(deadline) {
178+ time.Sleep(2 * time.Millisecond)
179+ a.Tick()
180+ }
181+
182+ events := server.lastFileEvents()
183+ if len(events) != 1 || events[0].Type != lsp.FileCreated || events[0].URI != lsp.PathToURI(path) {
184+ t.Errorf("autosave reported %+v, want %s created", events, path)
185+ }
186+}
187+
188+// firstIndexOf returns where a string first appears in a list, or -1.
189+func firstIndexOf(list []string, want string) int {
190+ for i, s := range list {
191+ if s == want {
192+ return i
193+ }
194+ }
195+ return -1
196+}
@@ -3,6 +3,9 @@ package app
3 import (3 import (
4 "path/filepath"4 "path/filepath"
5 "testing"5 "testing"
6+ "time"
7+
8+ "rickub.com/turbo-editors/turbo-core/lsp"
6 )9 )
7 10
8 // These tests cover what saving says to the language server. The defect they11 // These tests cover what saving says to the language server. The defect they
@@ -95,3 +98,99 @@ func TestSavingAKnownDocumentReportsTheWrite(t *testing.T) {
95 t.Errorf("didOpen was sent %d times; saving an open document must not re-announce it", got)98 t.Errorf("didOpen was sent %d times; saving an open document must not re-announce it", got)
96 }99 }
97 }100 }
101+
102+func TestSavingANewFileTellsTheServerTheFileExists(t *testing.T) {
103+ // To moon-lsp a document being open and a file existing are two facts,
104+ // and it works a package's files out from the directory: a .mbt saved for
105+ // the first time got no diagnostics, however loudly the document had been
106+ // announced, until the server was told the file is there. Found on macOS
107+ // the first time Turbo MoonBit's suite ran there; confirmed at the
108+ // protocol level — the same file is diagnosed the moment
109+ // workspace/didChangeWatchedFiles names it.
110+ project := t.TempDir()
111+ t.Chdir(project)
112+ a, _ := newTestApp(t)
113+ client, server := newFakeLanguage(t, a)
114+ connectLanguage(a, client)
115+ a.Tick()
116+
117+ a.NewFile()
118+ typeText(a, "package main")
119+ path := filepath.Join(project, "main.go")
120+ a.save(a.ActiveView(), path)
121+
122+ waitForMethod(t, server, "workspace/didChangeWatchedFiles")
123+ events := server.lastFileEvents()
124+ if len(events) != 1 || events[0].Type != lsp.FileCreated {
125+ t.Fatalf("the server was told %+v, want one created file", events)
126+ }
127+ if want := lsp.PathToURI(path); events[0].URI != want {
128+ t.Errorf("the created file is %q, want %q", events[0].URI, want)
129+ }
130+ // The document first, then the file: the order the fix was verified in.
131+ methods := server.methodsSeen()
132+ if open, created := firstIndexOf(methods, "textDocument/didOpen"), firstIndexOf(methods, "workspace/didChangeWatchedFiles"); open < 0 || created < open {
133+ t.Errorf("the file was reported before the document was announced: %v", methods)
134+ }
135+}
136+
137+func TestSavingAnExistingFileDoesNotClaimItWasCreated(t *testing.T) {
138+ // A file that was already on disk is not news to the server, and a save
139+ // that claimed otherwise would have moon-lsp rescan the package on every
140+ // Ctrl-S.
141+ project := t.TempDir()
142+ t.Chdir(project)
143+ t.Setenv(testProfile().SnippetDirEnvVar(), t.TempDir())
144+ a, _ := newTestApp(t)
145+ client, server := newFakeLanguage(t, a)
146+ connectLanguage(a, client)
147+ path := filepath.Join(project, "main.go")
148+ writeTestFile(t, path, "package main\n")
149+ a.Open(path)
150+ a.Tick()
151+
152+ a.save(a.ActiveView(), path)
153+
154+ waitForMethod(t, server, "textDocument/didSave")
155+ if got := server.methodCount("workspace/didChangeWatchedFiles"); got != 0 {
156+ t.Errorf("saving a file that already existed reported it created %d time(s)", got)
157+ }
158+}
159+
160+func TestAutosaveOfANewlyNamedFileTellsTheServerTheFileExists(t *testing.T) {
161+ // The other way a file gets written. `turbo-moonbit new.mbt` opens a
162+ // buffer for a file that is not on disk yet; the first automatic save
163+ // creates it, and the server has to hear that from this path too.
164+ project := t.TempDir()
165+ t.Chdir(project)
166+ t.Setenv(testProfile().SnippetDirEnvVar(), t.TempDir())
167+ a, _ := newTestApp(t)
168+ client, server := newFakeLanguage(t, a)
169+ connectLanguage(a, client)
170+ path := filepath.Join(project, "new.go")
171+ a.Open(path) // not on disk: an empty buffer with that name
172+ a.Tick()
173+ a.SetAutosave(true, time.Millisecond)
174+
175+ typeText(a, "package main")
176+ deadline := time.Now().Add(2 * time.Second)
177+ for server.methodCount("workspace/didChangeWatchedFiles") == 0 && time.Now().Before(deadline) {
178+ time.Sleep(2 * time.Millisecond)
179+ a.Tick()
180+ }
181+
182+ events := server.lastFileEvents()
183+ if len(events) != 1 || events[0].Type != lsp.FileCreated || events[0].URI != lsp.PathToURI(path) {
184+ t.Errorf("autosave reported %+v, want %s created", events, path)
185+ }
186+}
187+
188+// firstIndexOf returns where a string first appears in a list, or -1.
189+func firstIndexOf(list []string, want string) int {
190+ for i, s := range list {
191+ if s == want {
192+ return i
193+ }
194+ }
195+ return -1
196+}
modified docs/en/how-to/talk-to-a-language-server.md +1 -1
@@ -45,7 +45,7 @@ Start it **after** opening the files, not before. The editor announces the docum
4545
4646 ### A file is created inside the editor
4747
48-Nothing to do. A window that begins Untitled has no path, so the server hears nothing about it — until the first save, which announces the document rather than merely reporting a write. Completion, hover and diagnostics work in that window from the moment it has a name, with no restart. A Save As under a genuinely new name also closes the old document on the server, so no ghost stays open there.
48+Nothing to do. A window that begins Untitled has no path, so the server hears nothing about it — until the first save, which announces the document rather than merely reporting a write. Completion, hover and diagnostics work in that window from the moment it has a name, with no restart. The save that creates the file also tells the server a file appeared (`workspace/didChangeWatchedFiles`): a server that lists a package's files from the directory — moon-lsp — would otherwise know the document and still never diagnose it. A Save As under a genuinely new name also closes the old document on the server, so no ghost stays open there.
4949
5050 ### The server needs configuration
5151
@@ -45,7 +45,7 @@ Start it **after** opening the files, not before. The editor announces the docum
45 45
46 ### A file is created inside the editor46 ### A file is created inside the editor
47 47
48-Nothing to do. A window that begins Untitled has no path, so the server hears nothing about it — until the first save, which announces the document rather than merely reporting a write. Completion, hover and diagnostics work in that window from the moment it has a name, with no restart. A Save As under a genuinely new name also closes the old document on the server, so no ghost stays open there.48+Nothing to do. A window that begins Untitled has no path, so the server hears nothing about it — until the first save, which announces the document rather than merely reporting a write. Completion, hover and diagnostics work in that window from the moment it has a name, with no restart. The save that creates the file also tells the server a file appeared (`workspace/didChangeWatchedFiles`): a server that lists a package's files from the directory — moon-lsp — would otherwise know the document and still never diagnose it. A Save As under a genuinely new name also closes the old document on the server, so no ghost stays open there.
49 49
50 ### The server needs configuration50 ### The server needs configuration
51 51
modified docs/en/reference/app.md +1 -1
@@ -79,7 +79,7 @@ The settings file is also re-read **after every save that writes it**, from eith
7979 | `FirstError(path string) (lsp.Diagnostic, bool)` | The first error-level diagnostic. |
8080 | `AllDiagnostics() []FileDiagnostic` | Every problem for every file, sorted by file then line. |
8181
82-Saving keeps the server's set of open documents true. Writing a file the server knows sends `didSave`; writing one it does not know — a window that began Untitled, saved for the first time — sends `didOpen`, so the document works from that save on. Save As under a genuinely different name also closes the old document on the server first; a mere change of spelling of the same file does not count as a rename.
82+Saving keeps the server's set of open documents true. Writing a file the server knows sends `didSave`; writing one it does not know — a window that began Untitled, saved for the first time — sends `didOpen`, so the document works from that save on. A save that creates the file also reports it created (`workspace/didChangeWatchedFiles`), for servers that list a package's files from the directory and would otherwise never diagnose it — moon-lsp. Save As under a genuinely different name also closes the old document on the server first; a mere change of spelling of the same file does not count as a rename.
8383
8484 ## What is on screen
8585
@@ -79,7 +79,7 @@ The settings file is also re-read **after every save that writes it**, from eith
79 | `FirstError(path string) (lsp.Diagnostic, bool)` | The first error-level diagnostic. |79 | `FirstError(path string) (lsp.Diagnostic, bool)` | The first error-level diagnostic. |
80 | `AllDiagnostics() []FileDiagnostic` | Every problem for every file, sorted by file then line. |80 | `AllDiagnostics() []FileDiagnostic` | Every problem for every file, sorted by file then line. |
81 81
82-Saving keeps the server's set of open documents true. Writing a file the server knows sends `didSave`; writing one it does not know — a window that began Untitled, saved for the first time — sends `didOpen`, so the document works from that save on. Save As under a genuinely different name also closes the old document on the server first; a mere change of spelling of the same file does not count as a rename.82+Saving keeps the server's set of open documents true. Writing a file the server knows sends `didSave`; writing one it does not know — a window that began Untitled, saved for the first time — sends `didOpen`, so the document works from that save on. A save that creates the file also reports it created (`workspace/didChangeWatchedFiles`), for servers that list a package's files from the directory and would otherwise never diagnose it — moon-lsp. Save As under a genuinely different name also closes the old document on the server first; a mere change of spelling of the same file does not count as a rename.
83 83
84 ## What is on screen84 ## What is on screen
85 85
modified docs/fr/how-to/talk-to-a-language-server.md +1 -1
@@ -45,7 +45,7 @@ Démarrez-le **après** avoir ouvert les fichiers, pas avant. L'éditeur annonce
4545
4646 ### Un fichier est créé dans l'éditeur
4747
48-Rien à faire. Une fenêtre née sans titre n'a pas de chemin, donc le serveur n'en entend pas parler — jusqu'à la première sauvegarde, qui annonce le document au lieu de simplement signaler une écriture. La complétion, le survol et les diagnostics fonctionnent dans cette fenêtre dès qu'elle a un nom, sans redémarrage. Un « Enregistrer sous » vers un nom réellement nouveau ferme aussi l'ancien document côté serveur, pour qu'aucun fantôme n'y reste ouvert.
48+Rien à faire. Une fenêtre née sans titre n'a pas de chemin, donc le serveur n'en entend pas parler — jusqu'à la première sauvegarde, qui annonce le document au lieu de simplement signaler une écriture. La complétion, le survol et les diagnostics fonctionnent dans cette fenêtre dès qu'elle a un nom, sans redémarrage. La sauvegarde qui crée le fichier dit aussi au serveur qu'un fichier est apparu (`workspace/didChangeWatchedFiles`) : un serveur qui établit la liste des fichiers d'un paquet depuis le répertoire — moon-lsp — connaîtrait sinon le document sans jamais le diagnostiquer. Un « Enregistrer sous » vers un nom réellement nouveau ferme aussi l'ancien document côté serveur, pour qu'aucun fantôme n'y reste ouvert.
4949
5050 ### Le serveur a besoin d'une configuration
5151
@@ -45,7 +45,7 @@ Démarrez-le **après** avoir ouvert les fichiers, pas avant. L'éditeur annonce
45 45
46 ### Un fichier est créé dans l'éditeur46 ### Un fichier est créé dans l'éditeur
47 47
48-Rien à faire. Une fenêtre née sans titre n'a pas de chemin, donc le serveur n'en entend pas parler — jusqu'à la première sauvegarde, qui annonce le document au lieu de simplement signaler une écriture. La complétion, le survol et les diagnostics fonctionnent dans cette fenêtre dès qu'elle a un nom, sans redémarrage. Un « Enregistrer sous » vers un nom réellement nouveau ferme aussi l'ancien document côté serveur, pour qu'aucun fantôme n'y reste ouvert.48+Rien à faire. Une fenêtre née sans titre n'a pas de chemin, donc le serveur n'en entend pas parler — jusqu'à la première sauvegarde, qui annonce le document au lieu de simplement signaler une écriture. La complétion, le survol et les diagnostics fonctionnent dans cette fenêtre dès qu'elle a un nom, sans redémarrage. La sauvegarde qui crée le fichier dit aussi au serveur qu'un fichier est apparu (`workspace/didChangeWatchedFiles`) : un serveur qui établit la liste des fichiers d'un paquet depuis le répertoire — moon-lsp — connaîtrait sinon le document sans jamais le diagnostiquer. Un « Enregistrer sous » vers un nom réellement nouveau ferme aussi l'ancien document côté serveur, pour qu'aucun fantôme n'y reste ouvert.
49 49
50 ### Le serveur a besoin d'une configuration50 ### Le serveur a besoin d'une configuration
51 51
modified docs/fr/reference/app.md +1 -1
@@ -79,7 +79,7 @@ Le fichier de réglages est aussi relu **après chaque enregistrement qui l'écr
7979 | `FirstError(path string) (lsp.Diagnostic, bool)` | Le premier diagnostic de niveau erreur. |
8080 | `AllDiagnostics() []FileDiagnostic` | Tous les problèmes de tous les fichiers, triés par fichier puis par ligne. |
8181
82-Sauvegarder maintient exact l'ensemble des documents que le serveur croit ouverts. Écrire un fichier que le serveur connaît envoie `didSave` ; écrire un fichier qu'il ne connaît pas — une fenêtre née sans titre, sauvée pour la première fois — envoie `didOpen`, si bien que le document fonctionne dès cette sauvegarde. Un « Enregistrer sous » vers un nom réellement différent ferme d'abord l'ancien document côté serveur ; un simple changement d'orthographe du même fichier ne compte pas comme un renommage.
82+Sauvegarder maintient exact l'ensemble des documents que le serveur croit ouverts. Écrire un fichier que le serveur connaît envoie `didSave` ; écrire un fichier qu'il ne connaît pas — une fenêtre née sans titre, sauvée pour la première fois — envoie `didOpen`, si bien que le document fonctionne dès cette sauvegarde. Une sauvegarde qui crée le fichier le signale aussi comme créé (`workspace/didChangeWatchedFiles`), pour les serveurs qui établissent la liste des fichiers d'un paquet depuis le répertoire et ne le diagnostiqueraient jamais sinon — moon-lsp. Un « Enregistrer sous » vers un nom réellement différent ferme d'abord l'ancien document côté serveur ; un simple changement d'orthographe du même fichier ne compte pas comme un renommage.
8383
8484 ## Ce qui est à l'écran
8585
@@ -79,7 +79,7 @@ Le fichier de réglages est aussi relu **après chaque enregistrement qui l'écr
79 | `FirstError(path string) (lsp.Diagnostic, bool)` | Le premier diagnostic de niveau erreur. |79 | `FirstError(path string) (lsp.Diagnostic, bool)` | Le premier diagnostic de niveau erreur. |
80 | `AllDiagnostics() []FileDiagnostic` | Tous les problèmes de tous les fichiers, triés par fichier puis par ligne. |80 | `AllDiagnostics() []FileDiagnostic` | Tous les problèmes de tous les fichiers, triés par fichier puis par ligne. |
81 81
82-Sauvegarder maintient exact l'ensemble des documents que le serveur croit ouverts. Écrire un fichier que le serveur connaît envoie `didSave` ; écrire un fichier qu'il ne connaît pas — une fenêtre née sans titre, sauvée pour la première fois — envoie `didOpen`, si bien que le document fonctionne dès cette sauvegarde. Un « Enregistrer sous » vers un nom réellement différent ferme d'abord l'ancien document côté serveur ; un simple changement d'orthographe du même fichier ne compte pas comme un renommage.82+Sauvegarder maintient exact l'ensemble des documents que le serveur croit ouverts. Écrire un fichier que le serveur connaît envoie `didSave` ; écrire un fichier qu'il ne connaît pas — une fenêtre née sans titre, sauvée pour la première fois — envoie `didOpen`, si bien que le document fonctionne dès cette sauvegarde. Une sauvegarde qui crée le fichier le signale aussi comme créé (`workspace/didChangeWatchedFiles`), pour les serveurs qui établissent la liste des fichiers d'un paquet depuis le répertoire et ne le diagnostiqueraient jamais sinon — moon-lsp. Un « Enregistrer sous » vers un nom réellement différent ferme d'abord l'ancien document côté serveur ; un simple changement d'orthographe du même fichier ne compte pas comme un renommage.
83 83
84 ## Ce qui est à l'écran84 ## Ce qui est à l'écran
85 85
modified lsp/README.md +1 -0
@@ -57,6 +57,7 @@ Every request is bounded: `RequestTimeout` (3 s) for the ordinary ones, `Initial
5757 | `NewClient(stream, root) *Client` | A client over any stream — what the tests use |
5858 | `(*Client) Initialize / Run / Ready` | Handshake and read loop |
5959 | `(*Client) DidOpen / DidChange / DidSave / DidClose` | Document synchronisation, whole-document |
60+| `(*Client) FileCreated` | `workspace/didChangeWatchedFiles` for a file that appeared on disk — a document being open and a file existing are two facts to moon-lsp, which diagnoses a new file only once told of the second |
6061 | `(*Client) Complete / Hover` | The two requests answered with something to read |
6162 | `(*Client) Definition / TypeDefinition / Implementation / References` | The four answered with places in the code. One decoder serves all four, because the protocol lets a server answer a single location as a bare object rather than an array of one — and several do. |
6263 | `(*Client) DocumentSymbols / WorkspaceSymbols` | What a file declares, and what a project holds matching a query |
@@ -57,6 +57,7 @@ Every request is bounded: `RequestTimeout` (3 s) for the ordinary ones, `Initial
57 | `NewClient(stream, root) *Client` | A client over any stream — what the tests use |57 | `NewClient(stream, root) *Client` | A client over any stream — what the tests use |
58 | `(*Client) Initialize / Run / Ready` | Handshake and read loop |58 | `(*Client) Initialize / Run / Ready` | Handshake and read loop |
59 | `(*Client) DidOpen / DidChange / DidSave / DidClose` | Document synchronisation, whole-document |59 | `(*Client) DidOpen / DidChange / DidSave / DidClose` | Document synchronisation, whole-document |
60+| `(*Client) FileCreated` | `workspace/didChangeWatchedFiles` for a file that appeared on disk — a document being open and a file existing are two facts to moon-lsp, which diagnoses a new file only once told of the second |
60 | `(*Client) Complete / Hover` | The two requests answered with something to read |61 | `(*Client) Complete / Hover` | The two requests answered with something to read |
61 | `(*Client) Definition / TypeDefinition / Implementation / References` | The four answered with places in the code. One decoder serves all four, because the protocol lets a server answer a single location as a bare object rather than an array of one — and several do. |62 | `(*Client) Definition / TypeDefinition / Implementation / References` | The four answered with places in the code. One decoder serves all four, because the protocol lets a server answer a single location as a bare object rather than an array of one — and several do. |
62 | `(*Client) DocumentSymbols / WorkspaceSymbols` | What a file declares, and what a project holds matching a query |63 | `(*Client) DocumentSymbols / WorkspaceSymbols` | What a file declares, and what a project holds matching a query |
modified lsp/client.go +15 -0
@@ -174,6 +174,21 @@ func (c *Client) DidSave(path, text string) error {
174174 })
175175 }
176176
177+// FileCreated tells the server a file has appeared on disk.
178+//
179+// A document being open (DidOpen) and a file existing are two different facts
180+// to a server that works out a package's files from the directory: moon-lsp
181+// diagnoses a .mbt file saved for the first time only once this has been sent,
182+// however loudly the document was announced. The spec has clients send these
183+// for files the server asked to watch; sending one unasked is harmless to the
184+// servers this library has been driven against, and is the only way to tell a
185+// server that asks for nothing.
186+func (c *Client) FileCreated(path string) error {
187+ return c.conn.Notify("workspace/didChangeWatchedFiles", DidChangeWatchedFilesParams{
188+ Changes: []FileEvent{{URI: PathToURI(path), Type: FileCreated}},
189+ })
190+}
191+
177192 // DidClose tells the server a file is no longer being edited.
178193 func (c *Client) DidClose(path string) error {
179194 c.mu.Lock()
@@ -174,6 +174,21 @@ func (c *Client) DidSave(path, text string) error {
174 })174 })
175 }175 }
176 176
177+// FileCreated tells the server a file has appeared on disk.
178+//
179+// A document being open (DidOpen) and a file existing are two different facts
180+// to a server that works out a package's files from the directory: moon-lsp
181+// diagnoses a .mbt file saved for the first time only once this has been sent,
182+// however loudly the document was announced. The spec has clients send these
183+// for files the server asked to watch; sending one unasked is harmless to the
184+// servers this library has been driven against, and is the only way to tell a
185+// server that asks for nothing.
186+func (c *Client) FileCreated(path string) error {
187+ return c.conn.Notify("workspace/didChangeWatchedFiles", DidChangeWatchedFilesParams{
188+ Changes: []FileEvent{{URI: PathToURI(path), Type: FileCreated}},
189+ })
190+}
191+
177 // DidClose tells the server a file is no longer being edited.192 // DidClose tells the server a file is no longer being edited.
178 func (c *Client) DidClose(path string) error {193 func (c *Client) DidClose(path string) error {
179 c.mu.Lock()194 c.mu.Lock()
modified lsp/lsp_test.go +4 -1
@@ -395,6 +395,9 @@ func TestDocumentSynchronisationSendsTheRightNotifications(t *testing.T) {
395395 if err := client.DidSave("main.go", "package main\n"); err != nil {
396396 t.Fatalf("DidSave() error = %v", err)
397397 }
398+ if err := client.FileCreated("main.go"); err != nil {
399+ t.Fatalf("FileCreated() error = %v", err)
400+ }
398401 if err := client.DidClose("main.go"); err != nil {
399402 t.Fatalf("DidClose() error = %v", err)
400403 }
@@ -402,7 +405,7 @@ func TestDocumentSynchronisationSendsTheRightNotifications(t *testing.T) {
402405 waitForMethod(t, server, "textDocument/didClose")
403406 want := []string{
404407 "textDocument/didOpen", "textDocument/didChange",
405- "textDocument/didSave", "textDocument/didClose",
408+ "textDocument/didSave", "workspace/didChangeWatchedFiles", "textDocument/didClose",
406409 }
407410 got := server.methods()[2:] // after initialize and initialized
408411 for i, method := range want {
@@ -395,6 +395,9 @@ func TestDocumentSynchronisationSendsTheRightNotifications(t *testing.T) {
395 if err := client.DidSave("main.go", "package main\n"); err != nil {395 if err := client.DidSave("main.go", "package main\n"); err != nil {
396 t.Fatalf("DidSave() error = %v", err)396 t.Fatalf("DidSave() error = %v", err)
397 }397 }
398+ if err := client.FileCreated("main.go"); err != nil {
399+ t.Fatalf("FileCreated() error = %v", err)
400+ }
398 if err := client.DidClose("main.go"); err != nil {401 if err := client.DidClose("main.go"); err != nil {
399 t.Fatalf("DidClose() error = %v", err)402 t.Fatalf("DidClose() error = %v", err)
400 }403 }
@@ -402,7 +405,7 @@ func TestDocumentSynchronisationSendsTheRightNotifications(t *testing.T) {
402 waitForMethod(t, server, "textDocument/didClose")405 waitForMethod(t, server, "textDocument/didClose")
403 want := []string{406 want := []string{
404 "textDocument/didOpen", "textDocument/didChange",407 "textDocument/didOpen", "textDocument/didChange",
405- "textDocument/didSave", "textDocument/didClose",408+ "textDocument/didSave", "workspace/didChangeWatchedFiles", "textDocument/didClose",
406 }409 }
407 got := server.methods()[2:] // after initialize and initialized410 got := server.methods()[2:] // after initialize and initialized
408 for i, method := range want {411 for i, method := range want {
modified lsp/protocol.go +24 -0
@@ -86,6 +86,30 @@ type DidSaveParams struct {
8686 Text string `json:"text,omitempty"`
8787 }
8888
89+// FileChangeType says what happened to a watched file.
90+type FileChangeType int
91+
92+// The three things that can happen to a file, as the protocol numbers them.
93+const (
94+ FileCreated FileChangeType = 1
95+ FileChanged FileChangeType = 2
96+ FileDeleted FileChangeType = 3
97+)
98+
99+// FileEvent is one change to a file on disk.
100+type FileEvent struct {
101+ URI string `json:"uri"`
102+ Type FileChangeType `json:"type"`
103+}
104+
105+// DidChangeWatchedFilesParams reports changes to files on disk — as opposed
106+// to documents, which are what the editor has open. A server that works a
107+// project's file list out from disk learns of a new file from this and from
108+// nothing else: moon-lsp does not diagnose a file it was not told exists.
109+type DidChangeWatchedFilesParams struct {
110+ Changes []FileEvent `json:"changes"`
111+}
112+
89113 // Severity is how serious a diagnostic is.
90114 type Severity int
91115
@@ -86,6 +86,30 @@ type DidSaveParams struct {
86 Text string `json:"text,omitempty"`86 Text string `json:"text,omitempty"`
87 }87 }
88 88
89+// FileChangeType says what happened to a watched file.
90+type FileChangeType int
91+
92+// The three things that can happen to a file, as the protocol numbers them.
93+const (
94+ FileCreated FileChangeType = 1
95+ FileChanged FileChangeType = 2
96+ FileDeleted FileChangeType = 3
97+)
98+
99+// FileEvent is one change to a file on disk.
100+type FileEvent struct {
101+ URI string `json:"uri"`
102+ Type FileChangeType `json:"type"`
103+}
104+
105+// DidChangeWatchedFilesParams reports changes to files on disk — as opposed
106+// to documents, which are what the editor has open. A server that works a
107+// project's file list out from disk learns of a new file from this and from
108+// nothing else: moon-lsp does not diagnose a file it was not told exists.
109+type DidChangeWatchedFilesParams struct {
110+ Changes []FileEvent `json:"changes"`
111+}
112+
89 // Severity is how serious a diagnostic is.113 // Severity is how serious a diagnostic is.
90 type Severity int114 type Severity int
91 115