|
1 | 1 | import * as vscode from 'vscode'; |
2 | | -import { getLanguageService, LanguageService, SymbolKind } from 'vscode-html-languageservice'; |
| 2 | +import { |
| 3 | + getLanguageService as getHTMLLanguageService, |
| 4 | + LanguageService as HTMLLanguageService, |
| 5 | + SymbolKind as HTMLSymbolKind, |
| 6 | +} from 'vscode-html-languageservice'; |
| 7 | +import * as babelParser from '@babel/parser'; |
| 8 | +import type { ParserPlugin } from '@babel/parser'; |
| 9 | +import babelTraverse from '@babel/traverse'; |
| 10 | +import type { JSXAttribute } from '@babel/types'; |
3 | 11 |
|
4 | 12 | type HTMLEndTagDecoration = vscode.DecorationOptions & { |
5 | 13 | renderOptions: { after: { contentText: string } }; |
6 | 14 | }; |
7 | 15 |
|
| 16 | +function getJSXAttributeStringValue(attr?: JSXAttribute): string | undefined { |
| 17 | + if (attr?.value) { |
| 18 | + if (attr.value.type === 'StringLiteral' && typeof attr.value.value === 'string') { |
| 19 | + return attr.value.value; |
| 20 | + } |
| 21 | + |
| 22 | + if ( |
| 23 | + attr.value.type === 'JSXExpressionContainer' && |
| 24 | + attr.value.expression.type === 'StringLiteral' && |
| 25 | + typeof attr.value.expression.value === 'string' |
| 26 | + ) { |
| 27 | + return attr.value.expression.value; |
| 28 | + } |
| 29 | + } |
| 30 | + |
| 31 | + return undefined; |
| 32 | +} |
| 33 | + |
8 | 34 | export default class ClosingLabelsDecorations implements vscode.Disposable { |
9 | 35 | private activeEditor?: vscode.TextEditor; |
10 | 36 | private subscriptions: vscode.Disposable[] = []; |
11 | | - private languageService?: LanguageService; |
| 37 | + private htmlLanguageService?: HTMLLanguageService; |
12 | 38 | private updateTimeout?: NodeJS.Timeout; |
13 | 39 |
|
14 | 40 | private decorationType = this.createTextEditorDecoration(); |
15 | 41 |
|
| 42 | + private getHTMLLanguageService() { |
| 43 | + if (!this.htmlLanguageService) { |
| 44 | + this.htmlLanguageService = getHTMLLanguageService(); |
| 45 | + } |
| 46 | + |
| 47 | + return this.htmlLanguageService; |
| 48 | + } |
| 49 | + |
16 | 50 | constructor() { |
17 | 51 | this.update = this.update.bind(this); |
18 | | - this.languageService = getLanguageService(); |
19 | 52 |
|
20 | 53 | this.subscriptions.push( |
21 | 54 | vscode.workspace.onDidChangeConfiguration((event) => { |
@@ -79,22 +112,17 @@ export default class ClosingLabelsDecorations implements vscode.Disposable { |
79 | 112 | this.updateTimeout = setTimeout(this.update, 500); |
80 | 113 | } |
81 | 114 |
|
82 | | - getDocumentDecorations(input: vscode.TextDocument) { |
83 | | - if (!this.languageService) { |
84 | | - return []; |
85 | | - } |
| 115 | + getHTMLDocumentDecorations(input: vscode.TextDocument) { |
| 116 | + const htmlLanguageService = this.getHTMLLanguageService(); |
86 | 117 |
|
87 | 118 | const document = { ...input, uri: input.uri.toString() }; |
88 | | - const symbols = this.languageService.findDocumentSymbols( |
89 | | - document, |
90 | | - this.languageService.parseHTMLDocument(document) |
91 | | - ); |
| 119 | + const symbols = htmlLanguageService.findDocumentSymbols(document, htmlLanguageService.parseHTMLDocument(document)); |
92 | 120 |
|
93 | 121 | const decorations: HTMLEndTagDecoration[] = symbols |
94 | 122 | .filter((symbol) => { |
95 | 123 | // field symbol |
96 | 124 | return ( |
97 | | - symbol.kind === SymbolKind.Field && |
| 125 | + symbol.kind === HTMLSymbolKind.Field && |
98 | 126 | // isn't html document |
99 | 127 | !symbol.name.startsWith('html') && |
100 | 128 | // isn't child of html |
@@ -171,12 +199,109 @@ export default class ClosingLabelsDecorations implements vscode.Disposable { |
171 | 199 | return decorations; |
172 | 200 | } |
173 | 201 |
|
| 202 | + getJSXDocumentDecorations(input: vscode.TextDocument, options?: { typescript?: boolean }) { |
| 203 | + const decorations: HTMLEndTagDecoration[] = []; |
| 204 | + |
| 205 | + const plugins: ParserPlugin[] = ['jsx']; |
| 206 | + |
| 207 | + if (options?.typescript) { |
| 208 | + plugins.push('typescript'); |
| 209 | + } |
| 210 | + |
| 211 | + const ast = babelParser.parse(input.getText(), { |
| 212 | + allowAwaitOutsideFunction: true, |
| 213 | + allowImportExportEverywhere: true, |
| 214 | + allowReturnOutsideFunction: true, |
| 215 | + allowSuperOutsideMethod: true, |
| 216 | + allowUndeclaredExports: true, |
| 217 | + attachComment: false, |
| 218 | + createParenthesizedExpressions: false, |
| 219 | + errorRecovery: true, |
| 220 | + ranges: true, |
| 221 | + strictMode: false, |
| 222 | + tokens: true, |
| 223 | + plugins, |
| 224 | + }); |
| 225 | + |
| 226 | + babelTraverse(ast, { |
| 227 | + JSXElement({ node }) { |
| 228 | + if ( |
| 229 | + !node.selfClosing && |
| 230 | + node.closingElement && |
| 231 | + node.closingElement.loc && |
| 232 | + node.openingElement.loc && |
| 233 | + node.openingElement.name.type === 'JSXIdentifier' && |
| 234 | + node.openingElement.name.name.toLowerCase() === node.openingElement.name.name && |
| 235 | + node.openingElement.loc.end.line !== node.closingElement.loc.start.line |
| 236 | + ) { |
| 237 | + let id: string | undefined; |
| 238 | + let className: string[] = []; |
| 239 | + const idAttr = node.openingElement.attributes.find( |
| 240 | + (attribute): attribute is JSXAttribute => attribute.type === 'JSXAttribute' && attribute.name.name === 'id' |
| 241 | + ); |
| 242 | + const classNameAttr = node.openingElement.attributes.find( |
| 243 | + (attribute): attribute is JSXAttribute => |
| 244 | + attribute.type === 'JSXAttribute' && attribute.name.name === 'className' |
| 245 | + ); |
| 246 | + const idAttrVal = getJSXAttributeStringValue(idAttr); |
| 247 | + const classNameAttrVal = getJSXAttributeStringValue(classNameAttr); |
| 248 | + |
| 249 | + if (idAttrVal) { |
| 250 | + id = idAttrVal.trim(); |
| 251 | + |
| 252 | + if (id.length < 1) { |
| 253 | + id = undefined; |
| 254 | + } |
| 255 | + } |
| 256 | + |
| 257 | + if (classNameAttrVal) { |
| 258 | + className = classNameAttrVal |
| 259 | + .trim() |
| 260 | + .split(' ') |
| 261 | + .map((item) => item.trim()) |
| 262 | + .filter((item) => item.length); |
| 263 | + } |
| 264 | + |
| 265 | + if (id || className.length) { |
| 266 | + decorations.push({ |
| 267 | + range: new vscode.Range( |
| 268 | + new vscode.Position(node.closingElement.loc.start.line - 1, node.closingElement.loc.start.column), |
| 269 | + new vscode.Position(node.closingElement.loc.end.line - 1, node.closingElement.loc.end.column) |
| 270 | + ), |
| 271 | + renderOptions: { |
| 272 | + after: { |
| 273 | + contentText: '/' + (id ? `#${id}` : '') + (className.length > 0 ? `.${className.join('.')}` : ''), |
| 274 | + }, |
| 275 | + }, |
| 276 | + }); |
| 277 | + } |
| 278 | + } |
| 279 | + }, |
| 280 | + }); |
| 281 | + |
| 282 | + return decorations; |
| 283 | + } |
| 284 | + |
174 | 285 | update() { |
175 | | - if (!this.languageService || !this.activeEditor) { |
| 286 | + if (!this.activeEditor) { |
176 | 287 | return; |
177 | 288 | } |
178 | 289 |
|
179 | | - this.activeEditor.setDecorations(this.decorationType, this.getDocumentDecorations(this.activeEditor.document)); |
| 290 | + const languageId = this.activeEditor.document.languageId.toLowerCase(); |
| 291 | + |
| 292 | + if (['javascript', 'javascriptreact'].includes(languageId)) { |
| 293 | + this.activeEditor.setDecorations(this.decorationType, this.getJSXDocumentDecorations(this.activeEditor.document)); |
| 294 | + } else if (languageId === 'typescriptreact') { |
| 295 | + this.activeEditor.setDecorations( |
| 296 | + this.decorationType, |
| 297 | + this.getJSXDocumentDecorations(this.activeEditor.document, { typescript: true }) |
| 298 | + ); |
| 299 | + } else { |
| 300 | + this.activeEditor.setDecorations( |
| 301 | + this.decorationType, |
| 302 | + this.getHTMLDocumentDecorations(this.activeEditor.document) |
| 303 | + ); |
| 304 | + } |
180 | 305 | } |
181 | 306 |
|
182 | 307 | public dispose() { |
|
0 commit comments