Skip to content

Commit df72d50

Browse files
committed
Proper removal of diagnostics when last project file closed
1 parent a4e2efd commit df72d50

File tree

3 files changed

+138
-67
lines changed

3 files changed

+138
-67
lines changed

CONTRIBUTING.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Thanks for your interest. Below is an informal spec of how the plugin's server c
44

55
## Editor Diagnostics
66

7-
They should be synced in from the `bsb` build. Don't take them from other places.
7+
They should be synced in from `lib/bs/.compiler.log` build. Don't take them from other places.
88

99
### `.compiler.log`
1010

@@ -29,6 +29,8 @@ After saving a file and running the build, the results stream into the log file.
2929

3030
Even this fix isn't great. Ideally, the editor's diagnostics can be greyed out while we're updating them...
3131

32+
Keep in mind that you might be tracking multiple `.compiler.log`s. You should do the above for each.
33+
3234
### Stale Diagnostics Detection
3335

3436
To check whether the artifacts are stale, do **not** check `.bsb.lock` at the project root. This is unreliable, since it's possible that `bsb` wasn't running in watcher mode. We also don't want to encourage overuse of the watcher mode, though it seems increasingly common.
@@ -43,6 +45,15 @@ The bad alternatives are:
4345
- Not show that file's project's errors. That's wrong for several reasons (looks like the file has no error, assumes an editor window has a default project, etc.).
4446
- Show only that file's error. That's just weird, the errors are already read from that project's `.compiler.log`. Might as well show all of them (?).
4547

48+
## Running `bsb` in the Editor
49+
50+
**Don't** do that unless you've prompted the user.
51+
52+
- Running an implicit `bsb -w` automatically means you've acquired the build watch mode lockfile. The user won't be able to run his/her own `bsb -w` in the terminal.
53+
- Running a one-shot `bsb` doesn't conflict, but is a waste. It's also incorrect, as there might be external file system changes you're not detecting.
54+
- The build might be a step in a bigger build. The editor running `bsb` implicitly goes against that.
55+
- If you have multiple files with different project roots open, running all of the `bsb`s is too intense.
56+
4657
## Format
4758

4859
To find the location of `bsc` to run:

server/src/server.ts

Lines changed: 125 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,108 +1,170 @@
1-
import process from "process";
1+
import process, { allowedNodeEnvironmentFlags } from "process";
22
import * as p from "vscode-languageserver-protocol";
33
import * as t from "vscode-languageserver-types";
44
import * as j from "vscode-jsonrpc";
55
import * as m from "vscode-jsonrpc/lib/messages";
66
import * as v from "vscode-languageserver";
77
import * as path from 'path';
88
import fs from 'fs';
9-
import { DidOpenTextDocumentNotification, DidChangeTextDocumentNotification, DidCloseTextDocumentNotification } from 'vscode-languageserver-protocol';
9+
// TODO: check DidChangeWatchedFilesNotification. Check DidChangeTextDocumentNotification. Do they fire on uninitialized files?
10+
import { DidOpenTextDocumentNotification, DidChangeTextDocumentNotification, DidCloseTextDocumentNotification, DidChangeWatchedFilesNotification, CompletionResolveRequest } from 'vscode-languageserver-protocol';
1011
import { uriToFsPath, URI } from 'vscode-uri';
1112
import * as utils from './utils';
1213
import * as c from './constants';
1314
import * as chokidar from 'chokidar'
15+
import { assert } from 'console';
16+
// TODO: what's this?
17+
import { fileURLToPath } from 'url';
1418

1519
// https://microsoft.github.io/language-server-protocol/specification#initialize
1620
// According to the spec, there could be requests before the 'initialize' request. Link in comment tells how to handle them.
1721
let initialized = false;
1822
// https://microsoft.github.io/language-server-protocol/specification#exit
1923
let shutdownRequestAlreadyReceived = false;
20-
// congrats. A simple UI problem is now a distributed system problem
21-
let stupidFileContentCache: { [key: string]: string } = {}
22-
let previouslyDiagnosedFiles: Set<string> = new Set()
23-
let compilerLogPaths: Set<string> = new Set()
24+
let stupidFileContentCache: Map<string, string> = new Map()
25+
/*
26+
Map {
27+
'/foo/lib/bs/.compiler.log': Map {
28+
'/foo/src/A.res': {
29+
trackedContent: 'let a = 1',
30+
hasDiagnostics: false
31+
}
32+
'/foo/src/B.res': {
33+
trackedContent: null,
34+
hasDiagnostics: true
35+
}
36+
},
37+
}
38+
*/
39+
let projectsFiles: Map<
40+
string,
41+
{
42+
openFiles: Set<string>,
43+
filesWithDiagnostics: Set<string>,
44+
}>
45+
= new Map()
46+
// ^ caching AND states AND distributed system. Why does LSP has to be stupid like this
2447

2548
let sendUpdatedDiagnostics = () => {
26-
let diagnosedFiles: utils.filesDiagnostics = {}
27-
compilerLogPaths.forEach(compilerLogPath => {
49+
projectsFiles.forEach(({ filesWithDiagnostics }, compilerLogPath) => {
2850
let content = fs.readFileSync(compilerLogPath, { encoding: 'utf-8' });
29-
console.log("new log content: ", content)
30-
let { done: buildDone, result: filesAndErrors } = utils.parseCompilerLogOutput(content)
31-
// A file can only belong to one .compiler.log root. So we're not overriding
32-
// any existing key here
33-
Object.assign(diagnosedFiles, filesAndErrors)
34-
});
51+
console.log("new log content: ", compilerLogPath, content)
52+
let { done, result: filesAndErrors } = utils.parseCompilerLogOutput(content)
3553

36-
// Send new diagnostic, wipe old ones
37-
let diagnosedFilePaths = Object.keys(diagnosedFiles)
38-
diagnosedFilePaths.forEach(file => {
39-
let params: p.PublishDiagnosticsParams = {
40-
uri: file,
41-
// there's a new optional version param from https://github.com/microsoft/language-server-protocol/issues/201
42-
// not using it for now, sigh
43-
diagnostics: diagnosedFiles[file],
44-
}
45-
let notification: m.NotificationMessage = {
46-
jsonrpc: c.jsonrpcVersion,
47-
method: 'textDocument/publishDiagnostics',
48-
params: params,
49-
};
50-
process.send!(notification);
54+
// diff
55+
Object.keys(filesAndErrors).forEach(file => {
56+
// send diagnostic
57+
let params: p.PublishDiagnosticsParams = {
58+
uri: file,
59+
diagnostics: filesAndErrors[file],
60+
}
61+
let notification: m.NotificationMessage = {
62+
jsonrpc: c.jsonrpcVersion,
63+
method: 'textDocument/publishDiagnostics',
64+
params: params,
65+
};
66+
process.send!(notification);
5167

52-
// this file's taken care of already now. Remove from old diagnostic files
53-
previouslyDiagnosedFiles.delete(file)
54-
})
55-
// wipe the errors from the files that are no longer erroring
56-
previouslyDiagnosedFiles.forEach(remainingPreviousFile => {
57-
let params: p.PublishDiagnosticsParams = {
58-
uri: remainingPreviousFile,
59-
diagnostics: [],
68+
filesWithDiagnostics.add(file)
69+
})
70+
if (done) {
71+
// clear old files
72+
filesWithDiagnostics.forEach(file => {
73+
if (filesAndErrors[file] == null) {
74+
// Doesn't exist in the new diagnostics. Clear this diagnostic
75+
let params: p.PublishDiagnosticsParams = {
76+
uri: file,
77+
diagnostics: [],
78+
}
79+
let notification: m.NotificationMessage = {
80+
jsonrpc: c.jsonrpcVersion,
81+
method: 'textDocument/publishDiagnostics',
82+
params: params,
83+
};
84+
process.send!(notification);
85+
filesWithDiagnostics.delete(file)
86+
}
87+
})
6088
}
61-
let notification: m.NotificationMessage = {
62-
jsonrpc: c.jsonrpcVersion,
63-
method: 'textDocument/publishDiagnostics',
64-
params: params,
65-
};
66-
process.send!(notification);
67-
})
68-
previouslyDiagnosedFiles = new Set(diagnosedFilePaths)
89+
});
90+
}
91+
let deleteProjectDiagnostics = (compilerLogPath: string) => {
92+
let compilerLog = projectsFiles.get(compilerLogPath)
93+
if (compilerLog != null) {
94+
compilerLog.filesWithDiagnostics.forEach(file => {
95+
let params: p.PublishDiagnosticsParams = {
96+
uri: file,
97+
diagnostics: [],
98+
}
99+
let notification: m.NotificationMessage = {
100+
jsonrpc: c.jsonrpcVersion,
101+
method: 'textDocument/publishDiagnostics',
102+
params: params,
103+
};
104+
process.send!(notification);
105+
})
106+
107+
projectsFiles.delete(compilerLogPath)
108+
}
69109
}
70110

71111
let compilerLogsWatcher = chokidar.watch([])
72112
.on('all', (_e, changedPath) => {
73113
console.log('new log change', changedPath, Math.random())
74114
sendUpdatedDiagnostics()
75115
})
116+
let stopWatchingCompilerLog = () => {
117+
// TODO: cleanup of compilerLogs?
118+
compilerLogsWatcher.close()
119+
}
76120

77-
let addCompilerLogToWatch = (fileUri: string) => {
121+
let openedFile = (fileUri: string, fileContent: string) => {
78122
let filePath = uriToFsPath(URI.parse(fileUri), true);
123+
124+
stupidFileContentCache.set(filePath, fileContent)
125+
79126
let compilerLogDir = utils.findDirOfFileNearFile(c.compilerLogPartialPath, filePath)
80127
if (compilerLogDir != null) {
81128
let compilerLogPath = path.join(compilerLogDir, c.compilerLogPartialPath);
82-
if (!compilerLogPaths.has(compilerLogPath)) {
83-
console.log("added new ", compilerLogPath, "from file: ", compilerLogDir)
84-
compilerLogPaths.add(compilerLogPath)
129+
if (!projectsFiles.has(compilerLogPath)) {
130+
projectsFiles.set(compilerLogPath, { openFiles: new Set(), filesWithDiagnostics: new Set() })
85131
compilerLogsWatcher.add(compilerLogPath)
86-
// no need to call sendUpdatedDiagnostics() here; the watcher add will
87-
// call the listener which calls it
88132
}
133+
let compilerLog = projectsFiles.get(compilerLogPath)!
134+
compilerLog.openFiles.add(filePath)
135+
// no need to call sendUpdatedDiagnostics() here; the watcher add will
136+
// call the listener which calls it
89137
}
90138
}
91-
let removeCompilerLogToWatch = (fileUri: string) => {
139+
let closedFile = (fileUri: string) => {
92140
let filePath = uriToFsPath(URI.parse(fileUri), true);
141+
142+
stupidFileContentCache.delete(filePath)
143+
93144
let compilerLogDir = utils.findDirOfFileNearFile(c.compilerLogPartialPath, filePath)
94145
if (compilerLogDir != null) {
95146
let compilerLogPath = path.join(compilerLogDir, c.compilerLogPartialPath);
96-
if (compilerLogPaths.has(compilerLogPath)) {
97-
console.log("remove log path ", compilerLogPath)
98-
compilerLogPaths.delete(compilerLogPath)
99-
compilerLogsWatcher.unwatch(compilerLogPath)
100-
sendUpdatedDiagnostics()
147+
let compilerLog = projectsFiles.get(compilerLogPath)
148+
if (compilerLog != null) {
149+
compilerLog.openFiles.delete(filePath)
150+
// clear diagnostics too if no open files open in said project
151+
if (compilerLog.openFiles.size === 0) {
152+
compilerLogsWatcher.unwatch(compilerLogPath)
153+
deleteProjectDiagnostics(compilerLogPath)
154+
}
101155
}
102156
}
103157
}
104-
let stopWatchingCompilerLog = () => {
105-
compilerLogsWatcher.close()
158+
let updateOpenedFile = (fileUri: string, fileContent: string) => {
159+
let filePath = uriToFsPath(URI.parse(fileUri), true)
160+
assert(stupidFileContentCache.has(filePath))
161+
stupidFileContentCache.set(filePath, fileContent)
162+
}
163+
let getOpenedFileContent = (fileUri: string) => {
164+
let filePath = uriToFsPath(URI.parse(fileUri), true)
165+
let content = stupidFileContentCache.get(filePath)!
166+
assert(content != null)
167+
return content
106168
}
107169

108170
process.on('message', (a: (m.RequestMessage | m.NotificationMessage)) => {
@@ -125,8 +187,7 @@ process.on('message', (a: (m.RequestMessage | m.NotificationMessage)) => {
125187
let extName = path.extname(params.textDocument.uri)
126188
if (extName === c.resExt || extName === c.resiExt) {
127189
console.log("new file coming", params.textDocument.uri)
128-
stupidFileContentCache[params.textDocument.uri] = params.textDocument.text;
129-
addCompilerLogToWatch(params.textDocument.uri)
190+
openedFile(params.textDocument.uri, params.textDocument.text)
130191
}
131192
} else if (aa.method === DidChangeTextDocumentNotification.method) {
132193
let params = (aa.params as p.DidChangeTextDocumentParams);
@@ -137,13 +198,12 @@ process.on('message', (a: (m.RequestMessage | m.NotificationMessage)) => {
137198
// no change?
138199
} else {
139200
// we currently only support full changes
140-
stupidFileContentCache[params.textDocument.uri] = changes[changes.length - 1].text;
201+
updateOpenedFile(params.textDocument.uri, changes[changes.length - 1].text)
141202
}
142203
}
143204
} else if (aa.method === DidCloseTextDocumentNotification.method) {
144205
let params = (aa.params as p.DidCloseTextDocumentParams);
145-
delete stupidFileContentCache[params.textDocument.uri];
146-
removeCompilerLogToWatch(params.textDocument.uri)
206+
closedFile(params.textDocument.uri)
147207
}
148208
} else {
149209
// this is a request message, aka client sent request, waits for our reply
@@ -235,7 +295,7 @@ process.on('message', (a: (m.RequestMessage | m.NotificationMessage)) => {
235295
process.send!(response);
236296
} else {
237297
// code will always be defined here, even though technically it can be undefined
238-
let code = stupidFileContentCache[params.textDocument.uri];
298+
let code = getOpenedFileContent(params.textDocument.uri)
239299
let formattedResult = utils.formatUsingValidBscPath(
240300
code,
241301
path.join(nodeModulesParentPath, c.bscPartialPath),

server/src/utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ export let parseDiagnosticLocation = (location: string): Range => {
8888
}
8989
}
9090

91-
export type filesDiagnostics = {
91+
type filesDiagnostics = {
9292
[key: string]: p.Diagnostic[];
9393
}
9494
type parsedCompilerLogResult = {

0 commit comments

Comments
 (0)