// 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; // include std; names the library that is part of the compiler: there is no file to read 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. // texts maps each file that exists to its text, which a server with no files // of its own is sent before it checks the document. function diagKey(stamp, text, docPath, workspace) { const tree = sourceTree(text, docPath, workspace); const deps = [...tree.files.keys()].sort(); const texts = new Map(); for (const [p, hash] of tree.files) { if (hash === 'missing') continue; try { texts.set(p, fs.readFileSync(p, 'utf8')); } catch (e) { /* gone since it was hashed */ } } if (!tree.complete) return { key: null, deps, texts }; 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, texts }; } // 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 };