1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
|
// 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 };
|