'use strict' import * as vscode from 'vscode' import { dirname } from 'path' const htmlElements = require('./htmlElements.js') const tailwindClassNames = require('tailwind-class-names') const dlv = require('dlv') const Color = require('color') const CONFIG_GLOB = '**/{tailwind,tailwind.config,tailwind-config,.tailwindrc}.js' const JS_TYPES = ['typescriptreact', 'javascript', 'javascriptreact'] const HTML_TYPES = [ 'html', 'jade', 'razor', 'php', 'blade', 'twig', 'markdown', 'erb', 'handlebars', 'ejs', 'nunjucks', 'haml' ] const CSS_TYPES = ['css', 'sass', 'scss', 'less', 'stylus'] export async function activate(context: vscode.ExtensionContext) { let tw try { tw = await getTailwind() } catch (err) {} let intellisense = new TailwindIntellisense(tw) context.subscriptions.push(intellisense) let watcher = vscode.workspace.createFileSystemWatcher(CONFIG_GLOB) watcher.onDidChange(onFileChange) watcher.onDidCreate(onFileChange) watcher.onDidDelete(onFileChange) async function onFileChange(event) { try { tw = await getTailwind() } catch (err) { intellisense.dispose() return } if (!tw) { intellisense.dispose() return } intellisense.reload(tw) } } async function getTailwind() { if (!vscode.workspace.name) return let files = await vscode.workspace.findFiles( CONFIG_GLOB, '**/node_modules/**', 1 ) if (!files.length) return null let configPath = files[0].fsPath delete require.cache[configPath] let tailwindPackage = await vscode.workspace.findFiles( '**/node_modules/tailwindcss/package.json', null, 1 ) if (!tailwindPackage.length) return null let pluginPath = dirname(tailwindPackage[0].fsPath) let tw try { tw = await tailwindClassNames({ configPath, pluginPath, tree: true, strings: true }) } catch (err) { return null } return tw } export function deactivate() {} function createCompletionItemProvider({ items, prefixedItems, languages, regex, triggerCharacters, config, enable = () => true, emmet = false }: { items? prefixedItems? languages?: string[] regex?: RegExp triggerCharacters?: string[] config? prefix?: string enable?: (text: string) => boolean emmet?: boolean } = {}): vscode.Disposable { return vscode.languages.registerCompletionItemProvider( languages, { provideCompletionItems( document: vscode.TextDocument, position: vscode.Position ): vscode.CompletionItem[] { const separator = config.options.separator || ':' let str const range: vscode.Range = new vscode.Range( new vscode.Position(0, 0), position ) const text: string = document.getText(range) if (!enable(text)) return [] let lines = text.split(/[\n\r]/) let matches = lines .slice(-5) .join('\n') .match(regex) if (matches) { let parts = matches[matches.length - 1].split(' ') str = parts[parts.length - 1] } else if (emmet) { // match emmet style syntax // e.g. .flex.items-center let currentLine = lines[lines.length - 1] let currentWord = currentLine.split(' ').pop() matches = currentWord.match(/^\.([^.()#>*^ \[\]=$@{}]*)$/) if (!matches) { matches = currentWord.match( new RegExp( `^([A-Z][a-zA-Z0-9]*|[a-z][a-z0-9]*-[a-z0-9-]+|${htmlElements.join( '|' )}).*?\\.([^.()#>*^ \\[\\]=$@{}]*)$` ) ) } if (matches) { let parts = matches[matches.length - 1].split('.') str = parts[parts.length - 1] } } if (typeof str !== 'undefined') { const pth = str .replace(new RegExp(`${separator}`, 'g'), '.') .replace(/\.$/, '') .replace(/^\./, '') .replace(/\./g, '.children.') if (pth !== '') { const itms = prefixedItems && str.indexOf('.') === 0 && str.indexOf(separator) === -1 ? dlv(prefixedItems, pth) : dlv(items, pth) if (itms) { return Object.keys(itms.children).map(x => itms.children[x].item) } } if (str.indexOf(separator) === -1) { return prefixedItems && str.indexOf('.') === 0 ? Object.keys(prefixedItems).map(x => prefixedItems[x].item) : Object.keys(items).map(x => items[x].item) } return [] } return [] } }, ...triggerCharacters ) } function createConfigItemProvider({ languages, items, enable = () => true }: { languages?: string[] items?: vscode.CompletionItem[] enable?: (text: string) => boolean } = {}) { return vscode.languages.registerCompletionItemProvider( languages, { provideCompletionItems: ( document: vscode.TextDocument, position: vscode.Position ): vscode.CompletionItem[] => { const range: vscode.Range = new vscode.Range( new vscode.Position(0, 0), position ) const text: string = document.getText(range) if (!enable(text)) return [] let lines = text.split(/[\n\r]/) let matches = lines .slice(-5) .join('\n') .match(/config\(["']([^"']*)$/) if (!matches) return [] let objPath = matches[1] .replace(/\.[^.]*$/, '') .replace('.', '.children.') .trim() + '.children' let foo = dlv(items, objPath) if (foo) { return Object.keys(foo).map(x => foo[x].item) } return Object.keys(items).map(x => items[x].item) } }, "'", '"', '.' ) } function prefixItems(items, str, prefix) { const addPrefix = typeof prefix !== 'undefined' && prefix !== '' && str === prefix return Object.keys(items).map(x => { const item = items[x].item if (addPrefix) { item.filterText = item.insertText = `${prefix}${item.label}` } else { item.filterText = item.insertText = item.label } return item }) } function depthOf(obj) { if (typeof obj !== 'object' || Array.isArray(obj)) return 0 let level = 1 for (let key in obj) { if (!obj.hasOwnProperty(key)) continue if (typeof obj[key] === 'object') { const depth = depthOf(obj[key]) + 1 level = Math.max(depth, level) } } return level } function createItems(classNames, separator, config, prefix = '', parent = '') { let items = {} let i = 0 Object.keys(classNames).forEach(key => { if (depthOf(classNames[key]) === 0) { const item = new vscode.CompletionItem( key, vscode.CompletionItemKind.Constant ) item.filterText = item.insertText = `${prefix}${key}` item.sortText = naturalExpand(i.toString()) if (key !== 'container' && key !== 'group') { if (parent) { item.detail = classNames[key].replace( new RegExp(`:${parent} \{(.*?)\}`), '$1' ) } else { item.detail = classNames[key] } let color = getColorFromDecl(item.detail) if (color) { item.kind = vscode.CompletionItemKind.Color item.documentation = color } } items[key] = { item } i++ } else { const item = new vscode.CompletionItem( `${key}${separator}`, vscode.CompletionItemKind.Constant ) item.filterText = item.insertText = `${prefix}${key}${separator}` item.sortText = naturalExpand(i.toString()) item.command = { title: '', command: 'editor.action.triggerSuggest' } if (key === 'hover' || key === 'focus' || key === 'active') { item.detail = `:${key}` item.sortText = `a${item.sortText}` } else if (key === 'group-hover') { item.detail = '.group:hover &' item.sortText = `a${item.sortText}` } else if ( config.screens && Object.keys(config.screens).indexOf(key) !== -1 ) { item.detail = `@media (min-width: ${config.screens[key]})` item.sortText = `m${item.sortText}` } items[key] = { item, children: createItems(classNames[key], separator, config, prefix, key) } i++ } }) return items } function createConfigItems(config, prefix = '') { let items = {} let i = 0 Object.keys(config).forEach(key => { let item = new vscode.CompletionItem( key, vscode.CompletionItemKind.Constant ) if (depthOf(config[key]) === 0) { if (key === 'plugins') return item.filterText = item.insertText = `${prefix}${key}` item.sortText = naturalExpand(i.toString()) if (typeof config[key] === 'string' || typeof config[key] === 'number') { item.detail = config[key] let color = getColorFromValue(item.detail) if (color) { item.kind = vscode.CompletionItemKind.Color item.documentation = color } } else if (Array.isArray(config[key])) { item.detail = stringifyArray(config[key]) } items[key] = { item } } else { if (key === 'modules' || key === 'options') return item.filterText = item.insertText = `${key}.` item.sortText = naturalExpand(i.toString()) item.command = { title: '', command: 'editor.action.triggerSuggest' } items[key] = { item, children: createConfigItems(config[key], prefix) } } i++ }) return items } class TailwindIntellisense { private _providers: vscode.Disposable[] private _disposable: vscode.Disposable private _tailwind private _items private _prefixedItems private _configItems private _prefixedConfigItems constructor(tailwind) { if (tailwind) { this._tailwind = tailwind this.reload(tailwind) } } public reload(tailwind) { this.dispose() const separator = dlv(tailwind.config, 'options.separator', ':') if (separator !== ':') return this._items = createItems(tailwind.classNames, separator, tailwind.config) this._prefixedItems = createItems( tailwind.classNames, separator, tailwind.config, '.' ) this._configItems = createConfigItems(tailwind.config) this._prefixedConfigItems = createConfigItems(tailwind.config, '.') this._providers = [] this._providers.push( createCompletionItemProvider({ items: this._items, languages: JS_TYPES, regex: /\btw`([^`]*)$/, triggerCharacters: ['`', ' ', separator], config: tailwind.config }) ) this._providers.push( createCompletionItemProvider({ items: this._items, prefixedItems: this._prefixedItems, languages: CSS_TYPES, regex: /@apply ([^;}]*)$/, triggerCharacters: ['.', separator], config: tailwind.config }) ) this._providers.push( createCompletionItemProvider({ items: this._items, prefixedItems: this._items, languages: ['postcss'], regex: /@apply ([^;}]*)$/, triggerCharacters: ['.', separator], config: tailwind.config }) ) this._providers.push( createCompletionItemProvider({ items: this._items, languages: HTML_TYPES, regex: /\bclass=["']([^"']*)$/, // /\bclass(Name)?=(["'])(?!.*?\2)/ triggerCharacters: ["'", '"', ' ', '.', separator], config: tailwind.config, emmet: true }) ) this._providers.push( createCompletionItemProvider({ items: this._items, languages: JS_TYPES, regex: /\bclass(Name)?=["']([^"']*)$/, // /\bclass(Name)?=(["'])(?!.*?\2)/ triggerCharacters: ["'", '"', ' ', separator] .concat([ Object.keys( vscode.workspace.getConfiguration('emmet.includeLanguages') ).indexOf('javascript') !== -1 && '.' ]) .filter(Boolean), config: tailwind.config, emmet: Object.keys( vscode.workspace.getConfiguration('emmet.includeLanguages') ).indexOf('javascript') !== -1 }) ) // Vue.js this._providers.push( createCompletionItemProvider({ items: this._items, languages: ['vue'], regex: /\bclass=["']([^"']*)$/, enable: text => { if ( text.indexOf('') === -1 ) { return true } return false }, triggerCharacters: ["'", '"', ' ', separator] .concat([ Object.keys( vscode.workspace.getConfiguration('emmet.includeLanguages') ).indexOf('vue-html') !== -1 && '.' ]) .filter(Boolean), config: tailwind.config, emmet: Object.keys( vscode.workspace.getConfiguration('emmet.includeLanguages') ).indexOf('vue-html') !== -1 }) ) this._providers.push( createCompletionItemProvider({ items: this._items, languages: ['vue'], regex: /\bclass=["']([^"']*)$/, enable: text => { if ( text.indexOf('') === -1 ) { return true } return false }, triggerCharacters: ["'", '"', ' ', separator], config: tailwind.config }) ) this._providers.push( createCompletionItemProvider({ items: this._items, languages: ['vue'], regex: /@apply ([^;}]*)$/, triggerCharacters: ['.', separator], config: tailwind.config, enable: text => { if ( text.indexOf('') === -1 ) { return true } return false } }) ) this._providers.push( createConfigItemProvider({ languages: CSS_TYPES, items: this._prefixedConfigItems }) ) this._providers.push( createConfigItemProvider({ languages: ['postcss'], items: this._configItems }) ) this._providers.push( createConfigItemProvider({ languages: ['vue'], items: this._configItems, enable: text => { if ( text.indexOf('') === -1 ) { return true } return false } }) ) this._providers.push( vscode.languages.registerHoverProvider( [...HTML_TYPES, ...JS_TYPES, 'vue'], { provideHover: (document, position, token) => { const range1: vscode.Range = new vscode.Range( new vscode.Position(Math.max(position.line - 5, 0), 0), position ) const text1: string = document.getText(range1) if (!/\bclass(Name)?=['"][^'"]*$/.test(text1)) return const range2: vscode.Range = new vscode.Range( new vscode.Position(Math.max(position.line - 5, 0), 0), position.with({ line: position.line + 1 }) ) const text2: string = document.getText(range2) let str = text1 + text2.substr(text1.length).match(/^([^"' ]*)/)[0] let matches = str.match(/\bclass(Name)?=["']([^"']*)$/) if (matches && matches[2]) { let className = matches[2].split(' ').pop() let parts = className.split(':') if (typeof dlv(this._tailwind.classNames, parts) === 'string') { let base = parts.pop() let selector = `.${escapeClassName(className)}` if (parts.indexOf('hover') !== -1) { selector += ':hover' } else if (parts.indexOf('focus') !== -1) { selector += ':focus' } else if (parts.indexOf('active') !== -1) { selector += ':active' } else if (parts.indexOf('group-hover') !== -1) { selector = `.group:hover ${selector}` } let hoverStr = new vscode.MarkdownString() let css = this._tailwind.classNames[base] let m = css.match(/^(::?[a-z-]+) {(.*?)}/) if (m) { selector += m[1] css = m[2].trim() } css = css.replace(/([;{]) /g, '$1\n').replace(/^/gm, ' ') let code = `${selector} {\n${css}\n}` let screens = dlv(this._tailwind.config, 'screens', {}) Object.keys(screens).some(screen => { if (parts.indexOf(screen) !== -1) { code = `@media (min-width: ${ screens[screen] }) {\n${code.replace(/^/gm, ' ')}\n}` return true } return false }) hoverStr.appendCodeblock(code, 'css') let hoverRange = new vscode.Range( new vscode.Position( position.line, position.character + str.length - text1.length - className.length ), new vscode.Position( position.line, position.character + str.length - text1.length ) ) return new vscode.Hover(hoverStr, hoverRange) } } return null } } ) ) // @screen this._providers.push( createScreenCompletionItemProvider({ config: tailwind.config, languages: [...CSS_TYPES, 'postcss', 'vue'] }) ) this._disposable = vscode.Disposable.from(...this._providers) } dispose() { if (this._disposable) { this._disposable.dispose() } } } function pad(n) { return ('00000000' + n).substr(-8) } function naturalExpand(a: string) { return a.replace(/\d+/g, pad) } function stringifyArray(arr: Array): string { return arr .reduce((acc, curr) => { let str = curr.toString() if (str.includes(' ')) { acc.push(`"${str.replace(/\s\s+/g, ' ')}"`) } else { acc.push(str) } return acc }, []) .join(', ') } function escapeClassName(className) { return className.replace(/([^A-Za-z0-9\-])/g, '\\$1') } function getColorFromDecl(cssStr: string) { let matches = cssStr.match(/: ([^;]+);/g) if (matches === null || matches.length > 1) return let color = matches[0].slice(2, -1).trim() return getColorFromValue(color) } function getColorFromValue(value: string) { if (value === 'transparent') { return 'rgba(0, 0, 0, 0.01)' } try { let parsed = Color(value) if (parsed.valpha === 0) return 'rgba(0, 0, 0, 0.01)' return parsed.rgb().string() } catch (err) { return } } function createScreenCompletionItemProvider({ languages, config }): vscode.Disposable { return vscode.languages.registerCompletionItemProvider( languages, { provideCompletionItems: ( document: vscode.TextDocument, position: vscode.Position ): vscode.CompletionItem[] => { let range: vscode.Range = new vscode.Range( new vscode.Position(0, 0), position ) let text: string = document.getText(range) if ( document.languageId === 'vue' && !(text.indexOf('') === -1) ) return [] let line = text.split(/[\n\r]/).pop() if (/@screen $/.test(line)) { return Object.keys(dlv(config, 'screens', {})).map((screen, i) => { let item = new vscode.CompletionItem( screen, vscode.CompletionItemKind.Constant ) item.insertText = new vscode.SnippetString(`${screen} {\n\t$0\n}`) item.detail = typeof config.screens[screen] === 'string' ? config.screens[screen] : '' item.sortText = naturalExpand(i.toString()) return item }) } return [] } }, ' ' ) }