|
| 1 | +import fetch from "node-fetch"; |
| 2 | +import { parse, walk } from "css-tree"; |
| 3 | +import { basename, dirname, extname, isAbsolute, join } from "path"; |
| 4 | +import { |
| 5 | + CancellationToken, |
| 6 | + CompletionContext, |
| 7 | + CompletionItem, |
| 8 | + CompletionItemKind, |
| 9 | + CompletionItemProvider, |
| 10 | + CompletionList, |
| 11 | + Disposable, |
| 12 | + Position, |
| 13 | + ProviderResult, |
| 14 | + Range, |
| 15 | + TextDocument, |
| 16 | + Uri, |
| 17 | + workspace, |
| 18 | +} from "vscode"; |
| 19 | + |
| 20 | +export class ClassCompletionItemProvider implements CompletionItemProvider, Disposable { |
| 21 | + |
| 22 | + readonly none = "__!NONE!__"; |
| 23 | + readonly start = new Position(0, 0); |
| 24 | + readonly cache = new Map<string, Map<string, CompletionItem>>(); |
| 25 | + readonly empty = new Set<string>(); |
| 26 | + readonly extends = new Map<string, Set<string>>(); |
| 27 | + readonly disposables: Disposable[] = []; |
| 28 | + readonly isRemote = /^https?:\/\//i; |
| 29 | + readonly canComplete = /(id|class|className)\s*=\s*(["'])(?:(?!\2).)*$/si; |
| 30 | + readonly findLinkRel = /rel\s*=\s*(["'])((?:(?!\1).)+)\1/si; |
| 31 | + readonly findLinkHref = /href\s*=\s*(["'])((?:(?!\1).)+)\1/si; |
| 32 | + readonly findExtends = /(?:{{<|{{>|{%)\s*(?:extends)?\s*"?([\.0-9_a-z-A-Z]+)"?\s*(?:%}|}})/i; |
| 33 | + |
| 34 | + dispose() { |
| 35 | + let e; |
| 36 | + |
| 37 | + while (e = this.disposables.pop()) { |
| 38 | + e.dispose(); |
| 39 | + } |
| 40 | + |
| 41 | + this.cache.clear(); |
| 42 | + this.extends.clear(); |
| 43 | + } |
| 44 | + |
| 45 | + watchFile(uri: Uri, listener: (e: Uri) => any) { |
| 46 | + const watcher = workspace.createFileSystemWatcher(uri.fsPath, true); |
| 47 | + |
| 48 | + this.disposables.push( |
| 49 | + watcher.onDidChange(listener), |
| 50 | + watcher.onDidDelete(listener), |
| 51 | + watcher); |
| 52 | + } |
| 53 | + |
| 54 | + getStyleSheets(uri: Uri): string[] { |
| 55 | + return workspace.getConfiguration("css", uri).get<string[]>("styleSheets", []); |
| 56 | + } |
| 57 | + |
| 58 | + parseTextToItems(text: string, items: Map<string, CompletionItem>) { |
| 59 | + walk(parse(text), node => { |
| 60 | + |
| 61 | + let kind: CompletionItemKind; |
| 62 | + |
| 63 | + switch (node.type) { |
| 64 | + case "ClassSelector": |
| 65 | + kind = CompletionItemKind.Enum; |
| 66 | + break; |
| 67 | + case "IdSelector": |
| 68 | + kind = CompletionItemKind.Value; |
| 69 | + break; |
| 70 | + default: |
| 71 | + return; |
| 72 | + } |
| 73 | + |
| 74 | + items.set(node.name, new CompletionItem(node.name, kind)); |
| 75 | + }); |
| 76 | + } |
| 77 | + |
| 78 | + fetchLocal(key: string, uri: Uri): Thenable<string> { |
| 79 | + return new Promise(resolve => { |
| 80 | + if (this.cache.has(key)) { |
| 81 | + resolve(key); |
| 82 | + } else { |
| 83 | + const folder = workspace.getWorkspaceFolder(uri); |
| 84 | + |
| 85 | + const file = Uri.file(folder |
| 86 | + ? join(isAbsolute(key) |
| 87 | + ? folder.uri.fsPath |
| 88 | + : dirname(uri.fsPath), key) |
| 89 | + : join(dirname(uri.fsPath), key)); |
| 90 | + |
| 91 | + workspace.fs.readFile(file).then(content => { |
| 92 | + const items = new Map<string, CompletionItem>(); |
| 93 | + |
| 94 | + this.parseTextToItems(content.toString(), items); |
| 95 | + this.watchFile(file, e => this.cache.delete(key)); |
| 96 | + this.cache.set(key, items); |
| 97 | + resolve(key); |
| 98 | + }, () => resolve(this.none)); |
| 99 | + } |
| 100 | + }); |
| 101 | + } |
| 102 | + |
| 103 | + fetchRemote(key: string): Thenable<string> { |
| 104 | + return new Promise(resolve => { |
| 105 | + if (this.cache.has(key)) { |
| 106 | + resolve(key); |
| 107 | + } else { |
| 108 | + fetch(key).then(res => { |
| 109 | + const items = new Map<string, CompletionItem>(); |
| 110 | + |
| 111 | + if (res.ok) { |
| 112 | + res.text().then(text => { |
| 113 | + this.parseTextToItems(text, items); |
| 114 | + this.cache.set(key, items); |
| 115 | + resolve(key); |
| 116 | + }, () => resolve(this.none)); |
| 117 | + } else { |
| 118 | + this.cache.set(key, items); |
| 119 | + resolve(key); |
| 120 | + } |
| 121 | + }, () => resolve(this.none)); |
| 122 | + } |
| 123 | + }); |
| 124 | + } |
| 125 | + |
| 126 | + findStyleSheets(uri: Uri): Thenable<Set<string>> { |
| 127 | + return new Promise(resolve => { |
| 128 | + const keys = new Set<string>(); |
| 129 | + const styleSheets = this.getStyleSheets(uri); |
| 130 | + const promises = []; |
| 131 | + |
| 132 | + for (const key of styleSheets) { |
| 133 | + promises.push(this.isRemote.test(key) |
| 134 | + ? this.fetchRemote(key).then(k => keys.add(k)) |
| 135 | + : this.fetchLocal(key, uri).then(k => keys.add(k))); |
| 136 | + } |
| 137 | + |
| 138 | + Promise.all(promises).then(() => resolve(keys)); |
| 139 | + }); |
| 140 | + } |
| 141 | + |
| 142 | + findDocumentLinks(uri: Uri, text: string): Thenable<Set<string>> { |
| 143 | + return new Promise(resolve => { |
| 144 | + const findLinks = /<link([^>]+)>/gi; |
| 145 | + const keys = new Set<string>(); |
| 146 | + const promises = []; |
| 147 | + |
| 148 | + let link; |
| 149 | + |
| 150 | + while ((link = findLinks.exec(text)) !== null) { |
| 151 | + const rel = this.findLinkRel.exec(link[1]); |
| 152 | + |
| 153 | + if (rel && rel[2] === "stylesheet") { |
| 154 | + const href = this.findLinkHref.exec(link[1]); |
| 155 | + |
| 156 | + if (href) { |
| 157 | + const key = href[2]; |
| 158 | + |
| 159 | + promises.push(this.isRemote.test(key) |
| 160 | + ? this.fetchRemote(key).then(k => keys.add(k)) |
| 161 | + : this.fetchLocal(key, uri).then(k => keys.add(k))); |
| 162 | + } |
| 163 | + } |
| 164 | + } |
| 165 | + |
| 166 | + Promise.all(promises).then(() => resolve(keys)); |
| 167 | + }); |
| 168 | + } |
| 169 | + |
| 170 | + findDocumentStyles(uri: Uri, text: string): Thenable<Set<string>> { |
| 171 | + return new Promise(resolve => { |
| 172 | + const key = uri.toString(); |
| 173 | + const keys = new Set<string>([key]); |
| 174 | + const items = new Map<string, CompletionItem>(); |
| 175 | + const findStyles = /<style[^>]*>([^<]+)<\/style>/gi; |
| 176 | + |
| 177 | + let style; |
| 178 | + |
| 179 | + while ((style = findStyles.exec(text)) !== null) { |
| 180 | + this.parseTextToItems(style[1], items); |
| 181 | + } |
| 182 | + |
| 183 | + this.cache.set(key, items); |
| 184 | + resolve(keys); |
| 185 | + }); |
| 186 | + } |
| 187 | + |
| 188 | + findExtendedStyles(uri: Uri, text: string): Thenable<Set<string>> { |
| 189 | + return new Promise(resolve => { |
| 190 | + const parent = this.findExtends.exec(text); |
| 191 | + |
| 192 | + if (parent) { |
| 193 | + const path = uri.fsPath; |
| 194 | + const ext = extname(path); |
| 195 | + const key = join(dirname(path), basename(parent[1], ext) + ext); |
| 196 | + const extend = this.extends.get(key); |
| 197 | + |
| 198 | + if (extend) { |
| 199 | + resolve(extend); |
| 200 | + } else { |
| 201 | + const file = Uri.file(key); |
| 202 | + |
| 203 | + workspace.fs.readFile(file).then(content => { |
| 204 | + const text = content.toString(); |
| 205 | + |
| 206 | + Promise.all([ |
| 207 | + this.findDocumentLinks(file, text), |
| 208 | + this.findDocumentStyles(file, text), |
| 209 | + this.findExtendedStyles(file, text) |
| 210 | + ]).then(sets => { |
| 211 | + const keys = new Set<string>(); |
| 212 | + |
| 213 | + sets.forEach(set => set.forEach(k => keys.add(k))); |
| 214 | + this.watchFile(file, e => this.extends.delete(key)); |
| 215 | + this.extends.set(key, keys); |
| 216 | + resolve(keys); |
| 217 | + }); |
| 218 | + |
| 219 | + }, () => resolve(this.empty)); |
| 220 | + } |
| 221 | + } else { |
| 222 | + resolve(this.empty); |
| 223 | + } |
| 224 | + }); |
| 225 | + } |
| 226 | + |
| 227 | + buildItems(sets: Set<string>[], kind: CompletionItemKind): CompletionItem[] { |
| 228 | + const items = new Map<string, CompletionItem>(); |
| 229 | + const keys = new Set<string>(); |
| 230 | + |
| 231 | + sets.forEach(v => v.forEach(v => keys.add(v))); |
| 232 | + |
| 233 | + keys.forEach(k => this.cache.get(k)?.forEach((v, k) => { |
| 234 | + if (kind === v.kind) { |
| 235 | + items.set(k, v); |
| 236 | + } |
| 237 | + })); |
| 238 | + |
| 239 | + return [...items.values()]; |
| 240 | + } |
| 241 | + |
| 242 | + provideCompletionItems( |
| 243 | + document: TextDocument, |
| 244 | + position: Position, |
| 245 | + token: CancellationToken, |
| 246 | + context: CompletionContext): ProviderResult<CompletionItem[] | CompletionList<CompletionItem>> { |
| 247 | + |
| 248 | + return new Promise((resolve, reject) => { |
| 249 | + if (token.isCancellationRequested) { |
| 250 | + reject(); |
| 251 | + } else { |
| 252 | + const range = new Range(this.start, position); |
| 253 | + const text = document.getText(range); |
| 254 | + const canComplete = this.canComplete.exec(text); |
| 255 | + |
| 256 | + if (canComplete) { |
| 257 | + const type = canComplete[1] === "id" |
| 258 | + ? CompletionItemKind.Value |
| 259 | + : CompletionItemKind.Enum; |
| 260 | + |
| 261 | + const uri = document.uri; |
| 262 | + |
| 263 | + Promise.all([ |
| 264 | + this.findStyleSheets(uri), |
| 265 | + this.findDocumentLinks(uri, text), |
| 266 | + this.findDocumentStyles(uri, text), |
| 267 | + this.findExtendedStyles(uri, text) |
| 268 | + ]).then(keys => resolve(this.buildItems(keys, type))); |
| 269 | + } else { |
| 270 | + reject(); |
| 271 | + } |
| 272 | + } |
| 273 | + }); |
| 274 | + } |
| 275 | +} |
0 commit comments