diff options
Diffstat (limited to 'editors/vscode/client/diagcache.js')
| -rw-r--r-- | editors/vscode/client/diagcache.js | 137 |
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 }; |
