aboutsummaryrefslogtreecommitdiff
path: root/editors/vscode/client/diagcache.js
diff options
context:
space:
mode:
Diffstat (limited to 'editors/vscode/client/diagcache.js')
-rw-r--r--editors/vscode/client/diagcache.js137
1 files changed, 137 insertions, 0 deletions
diff --git a/editors/vscode/client/diagcache.js b/editors/vscode/client/diagcache.js
new file mode 100644
index 0000000..6e77207
--- /dev/null
+++ b/editors/vscode/client/diagcache.js
@@ -0,0 +1,137 @@
+// the diagnostics cache. a document's diagnostics are kept under a sha256 key
+// that covers the server build, the document's path and text, and every file
+// the document reads through include, transitively. the same key means the same
+// tree, so the diagnostics kept for it are the ones the server would give, and
+// the client can show them without asking the server again.
+//
+// nothing here uses vscode, so the rules can be tested with plain node
+// (tests/drivers/diagcache_test.js).
+
+const crypto = require('crypto');
+const fs = require('fs');
+const path = require('path');
+
+const MAX_FILES = 300; // a tree larger than this is not cached: its key would not cover all of it
+const MAX_ENTRIES = 500; // tree states kept in the cache; the least recently used goes first
+
+function sha256(data) {
+ return crypto.createHash('sha256').update(data).digest('hex');
+}
+
+// the sha256 of a file's bytes, or 'missing' when there is no such file
+function fileHash(p) {
+ try {
+ return sha256(fs.readFileSync(p));
+ } catch (e) {
+ return 'missing';
+ }
+}
+
+// a short stamp for the server build: the sha256 of its file, or 'none'
+function fileStamp(p) {
+ if (!p) return 'none';
+ const h = fileHash(p);
+ return h === 'missing' ? 'none' : h;
+}
+
+// the names a source file includes, as written in it
+function includeNames(text) {
+ const names = [];
+ const tokens = /\/\/[^\n]*|\/\*[\s\S]*?\*\/|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[a-zA-Z_][a-zA-Z_0-9]*/g;
+ let include = false, m;
+ while ((m = tokens.exec(text)) !== null) {
+ const token = m[0];
+ if (token.startsWith('//') || token.startsWith('/*')) continue;
+ if (include && token.startsWith('"')) {
+ names.push(token.slice(1, -1).replace(/\\([\\"nrt0])/g, (_, c) => ({ n: '\n', r: '\r', t: '\t', 0: '\0' })[c] || c));
+ }
+ include = token === 'include';
+ }
+ return names;
+}
+
+// every file the document reads, transitively, as path -> sha256 (or 'missing').
+// an include is tried relative to the workspace folder and to the directory of
+// the file that has it, since the compiler opens it relative to the working
+// directory. both candidates are listed, existing or not, so creating one
+// later changes the key. complete is false when the tree is too large to cover.
+function sourceTree(text, docPath, workspace) {
+ const files = new Map();
+ const queue = [{ text, dir: path.dirname(docPath) }];
+ for (let index = 0; index < queue.length; index++) {
+ const cur = queue[index];
+ for (const name of includeNames(cur.text)) {
+ const bases = new Set([workspace, cur.dir].filter(Boolean));
+ for (const base of bases) {
+ const p = path.resolve(base, name);
+ if (files.has(p)) continue;
+ if (files.size >= MAX_FILES) return { files, complete: false };
+ let body = null;
+ try { body = fs.readFileSync(p); } catch (e) { body = null; }
+ if (body === null) { files.set(p, 'missing'); continue; }
+ files.set(p, sha256(body));
+ queue.push({ text: body.toString('utf8'), dir: path.dirname(p) });
+ }
+ }
+ }
+ return { files, complete: true };
+}
+
+// the key for a document and the files it reads. key is null when the tree is
+// too large to cover, and then nothing is cached. deps lists every file the
+// tree names (existing or not), so a change to any of them can be noticed.
+function diagKey(stamp, text, docPath, workspace) {
+ const tree = sourceTree(text, docPath, workspace);
+ const deps = [...tree.files.keys()].sort();
+ if (!tree.complete) return { key: null, deps };
+ const parts = [stamp, docPath, sha256(text)];
+ for (const p of deps) parts.push(p + '=' + tree.files.get(p));
+ return { key: sha256(parts.join('\n')), deps };
+}
+
+// the cache itself: key -> diagnostics, least recently used first. saved is
+// what a previous session stored (plain JSON), so entries carry over; a key
+// from another server build never matches. past max entries the least recently
+// used one is dropped.
+class DiagCache {
+ constructor(saved, max) {
+ this.max = max || MAX_ENTRIES;
+ this.map = new Map();
+ if (saved && typeof saved === 'object') {
+ for (const key of Object.keys(saved)) {
+ if (Array.isArray(saved[key])) this.map.set(key, saved[key]);
+ }
+ }
+ while (this.map.size > this.max) this.map.delete(this.map.keys().next().value);
+ }
+
+ // the diagnostics kept under key, or null; using them makes them the newest
+ get(key) {
+ if (key === null || key === undefined || !this.map.has(key)) return null;
+ const diags = this.map.get(key);
+ this.map.delete(key);
+ this.map.set(key, diags);
+ return diags;
+ }
+
+ // keeps diags under key; returns false when there is no key to keep them under
+ put(key, diags) {
+ if (key === null || key === undefined) return false;
+ this.map.delete(key);
+ this.map.set(key, diags);
+ while (this.map.size > this.max) this.map.delete(this.map.keys().next().value);
+ return true;
+ }
+
+ size() {
+ return this.map.size;
+ }
+
+ toJSON() {
+ const out = {};
+ for (const [key, diags] of this.map) out[key] = diags;
+ return out;
+ }
+}
+
+module.exports = { sha256, fileHash, fileStamp, includeNames, sourceTree, diagKey, DiagCache, MAX_FILES };