From 3b50a445a332df4c9f0f4f7cb73d5d5afa7eaa1e Mon Sep 17 00:00:00 2001 From: Brad Cornes Date: Mon, 13 Apr 2020 01:44:43 +0100 Subject: [PATCH] refactor class name extraction and stringify --- .../src/extractClassNames.mjs | 136 +++++------ .../tests/extractClassNames.test.js | 222 +++++++++++++----- .../src/providers/completionProvider.ts | 41 ++-- .../src/providers/hoverProvider.ts | 19 +- .../src/util/color.ts | 6 +- .../src/util/getClassNameAtPosition.ts | 8 +- .../src/util/stringify.ts | 69 +++--- 7 files changed, 287 insertions(+), 214 deletions(-) diff --git a/packages/tailwindcss-class-names/src/extractClassNames.mjs b/packages/tailwindcss-class-names/src/extractClassNames.mjs index c802e4e..4ccb662 100644 --- a/packages/tailwindcss-class-names/src/extractClassNames.mjs +++ b/packages/tailwindcss-class-names/src/extractClassNames.mjs @@ -18,20 +18,6 @@ function getClassNamesFromSelector(selector) { const { nodes: subSelectors } = selectorParser().astSync(selector) for (let i = 0; i < subSelectors.length; i++) { - // const final = subSelectors[i].nodes[subSelectors[i].nodes.length - 1] - - // if (final.type === 'class') { - // const scope = subSelectors[i].nodes.slice( - // 0, - // subSelectors[i].nodes.length - 1 - // ) - - // classNames.push({ - // className: String(final).trim(), - // scope: createSelectorFromNodes(scope) - // }) - // } - let scope = [] for (let j = 0; j < subSelectors[i].nodes.length; j++) { let node = subSelectors[i].nodes[j] @@ -47,39 +33,28 @@ function getClassNamesFromSelector(selector) { } classNames.push({ - className: String(node) - .trim() - .substr(1), + className: node.value.trim(), scope: createSelectorFromNodes(scope), __rule: j === subSelectors[i].nodes.length - 1, - // __pseudo: createSelectorFromNodes(pseudo) - __pseudo: pseudo.length === 0 ? null : pseudo.map(String) + __pseudo: pseudo.length === 0 ? null : pseudo.map(String), }) } scope.push(node, ...pseudo) } } - // console.log(classNames) - return classNames } -// console.log(getClassNamesFromSelector('h1, h2, h3, .foo .bar, .baz')) - -// const css = fs.readFileSync(path.resolve(__dirname, 'tailwind.css'), 'utf8') - async function process(ast) { - const start = new Date() - const tree = {} const commonContext = {} - ast.root.walkRules(rule => { + ast.root.walkRules((rule) => { const classNames = getClassNamesFromSelector(rule.selector) - const decls = { __decls: true } - rule.walkDecls(decl => { + const decls = {} + rule.walkDecls((decl) => { decls[decl.prop] = decl.value }) @@ -96,49 +71,48 @@ async function process(ast) { const context = keys.concat([]) const baseKeys = classNames[i].className.split('__TAILWIND_SEPARATOR__') const contextKeys = baseKeys.slice(0, baseKeys.length - 1) + const index = [] - if (classNames[i].scope) { - let index = [] - const existing = dlv(tree, baseKeys) - if (typeof existing !== 'undefined') { - if (Array.isArray(existing)) { - const scopeIndex = existing.findIndex( - x => x.__scope === classNames[i].scope - ) - if (scopeIndex > -1) { - keys.unshift(scopeIndex) - index.push(scopeIndex) - } else { - keys.unshift(existing.length) - index.push(existing.length) - } + const existing = dlv(tree, baseKeys) + if (typeof existing !== 'undefined') { + if (Array.isArray(existing)) { + const scopeIndex = existing.findIndex( + (x) => + x.__scope === classNames[i].scope && + arraysEqual(existing.__context, context) + ) + if (scopeIndex > -1) { + keys.unshift(scopeIndex) + index.push(scopeIndex) } else { - if (existing.__scope !== classNames[i].scope) { - dset(tree, baseKeys, [existing]) - keys.unshift(1) - index.push(1) - } + keys.unshift(existing.length) + index.push(existing.length) + } + } else { + if ( + existing.__scope !== classNames[i].scope || + !arraysEqual(existing.__context, context) + ) { + dset(tree, baseKeys, [existing]) + keys.unshift(1) + index.push(1) } } - if (classNames[i].__rule) { - dset(tree, [...baseKeys, ...index, '__rule'], true) - dsetEach(tree, [...baseKeys, ...keys], decls) - } - if (classNames[i].__pseudo) { - dset(tree, [...baseKeys, ...keys, '__pseudo'], classNames[i].__pseudo) - } - dset(tree, [...baseKeys, ...index, '__scope'], classNames[i].scope) - } else { - if (classNames[i].__rule) { - dset(tree, [...baseKeys, '__rule'], true) - dsetEach(tree, [...baseKeys, ...keys], decls) - } else { - dset(tree, [...baseKeys, ...keys], {}) - } - if (classNames[i].__pseudo) { - dset(tree, [...baseKeys, ...keys, '__pseudo'], classNames[i].__pseudo) - } } + if (classNames[i].__rule) { + dset(tree, [...baseKeys, ...index, '__rule'], true) + + dsetEach(tree, [...baseKeys, ...index], decls) + } + if (classNames[i].__pseudo) { + dset(tree, [...baseKeys, '__pseudo'], classNames[i].__pseudo) + } + dset(tree, [...baseKeys, ...index, '__scope'], classNames[i].scope) + dset( + tree, + [...baseKeys, ...index, '__context'], + context.concat([]).reverse() + ) // common context if (classNames[i].__pseudo) { @@ -157,15 +131,12 @@ async function process(ast) { } } }) - // console.log(`${new Date() - start}ms`) - // console.log(tree) - // console.log(commonContext) return { classNames: tree, context: commonContext } } function intersection(arr1, arr2) { - return arr1.filter(value => arr2.indexOf(value) !== -1) + return arr1.filter((value) => arr2.indexOf(value) !== -1) } function dsetEach(obj, keys, values) { @@ -175,14 +146,15 @@ function dsetEach(obj, keys, values) { } } -export default process +function arraysEqual(a, b) { + if (a === b) return true + if (a == null || b == null) return false + if (a.length !== b.length) return false -// process(` -// .bg-red { -// background-color: red; -// } -// .bg-red { -// color: white; -// }`).then(x => { -// console.log(x) -// }) + for (let i = 0; i < a.length; ++i) { + if (a[i] !== b[i]) return false + } + return true +} + +export default process diff --git a/packages/tailwindcss-class-names/tests/extractClassNames.test.js b/packages/tailwindcss-class-names/tests/extractClassNames.test.js index b276787..19290e6 100644 --- a/packages/tailwindcss-class-names/tests/extractClassNames.test.js +++ b/packages/tailwindcss-class-names/tests/extractClassNames.test.js @@ -3,9 +3,73 @@ const esmImport = require('esm')(module) const process = esmImport('../src/extractClassNames.mjs').default postcss = postcss([postcss.plugin('no-op', () => () => {})]) -const processCss = async css => +const processCss = async (css) => process(await postcss.process(css, { from: undefined })) +test('processes default container plugin', async () => { + const result = await processCss(` + .container { + width: 100% + } + + @media (min-width: 640px) { + .container { + max-width: 640px + } + } + + @media (min-width: 768px) { + .container { + max-width: 768px + } + } + + @media (min-width: 1024px) { + .container { + max-width: 1024px + } + } + + @media (min-width: 1280px) { + .container { + max-width: 1280px + } + } + `) + expect(result).toEqual({ + context: {}, + classNames: { + container: [ + { __context: [], __rule: true, __scope: null, width: '100%' }, + { + __rule: true, + __scope: null, + __context: ['@media (min-width: 640px)'], + 'max-width': '640px', + }, + { + __rule: true, + __scope: null, + __context: ['@media (min-width: 768px)'], + 'max-width': '768px', + }, + { + __rule: true, + __scope: null, + __context: ['@media (min-width: 1024px)'], + 'max-width': '1024px', + }, + { + __rule: true, + __scope: null, + __context: ['@media (min-width: 1280px)'], + 'max-width': '1280px', + }, + ], + }, + }) +}) + test('foo', async () => { const result = await processCss(` @media (min-width: 640px) { @@ -24,43 +88,42 @@ test('foo', async () => { expect(result).toEqual({ context: { sm: ['@media (min-width: 640px)'], - hover: [':hover'] + hover: [':hover'], }, classNames: { sm: { 'bg-red': { __rule: true, - '@media (min-width: 640px)': { - __decls: true, - 'background-color': 'red' - } + __scope: null, + __context: ['@media (min-width: 640px)'], + 'background-color': 'red', }, hover: { 'bg-red': { __rule: true, - '@media (min-width: 640px)': { - __decls: true, - __pseudo: [':hover'], - 'background-color': 'red' - } - } - } + __scope: null, + __context: ['@media (min-width: 640px)'], + __pseudo: [':hover'], + 'background-color': 'red', + }, + }, }, hover: { 'bg-red': { __rule: true, - __decls: true, + __scope: null, __pseudo: [':hover'], - 'background-color': 'red' - } - } - } + __context: [], + 'background-color': 'red', + }, + }, + }, }) }) -test('processes basic css', async () => { +test.only('processes basic css', async () => { const result = await processCss(` - .bg-red { + .bg-red\\:foo { background-color: red; } `) @@ -70,10 +133,11 @@ test('processes basic css', async () => { classNames: { 'bg-red': { __rule: true, - __decls: true, - 'background-color': 'red' - } - } + __scope: null, + __context: [], + 'background-color': 'red', + }, + }, }) }) @@ -89,11 +153,12 @@ test('processes pseudo selectors', async () => { classNames: { 'bg-red': { __rule: true, - __decls: true, + __scope: null, + __context: [], __pseudo: [':first-child', '::after'], - 'background-color': 'red' - } - } + 'background-color': 'red', + }, + }, }) }) @@ -108,15 +173,17 @@ test('processes pseudo selectors in scope', async () => { context: {}, classNames: { scope: { - __pseudo: [':hover'] + __context: [], + __pseudo: [':hover'], + __scope: null, }, 'bg-red': { + __context: [], __rule: true, - __decls: true, __scope: '.scope:hover', - 'background-color': 'red' - } - } + 'background-color': 'red', + }, + }, }) }) @@ -133,15 +200,17 @@ test('processes multiple class names in the same rule', async () => { classNames: { 'bg-red': { __rule: true, - __decls: true, - 'background-color': 'red' + __scope: null, + __context: [], + 'background-color': 'red', }, 'bg-red-again': { __rule: true, - __decls: true, - 'background-color': 'red' - } - } + __scope: null, + __context: [], + 'background-color': 'red', + }, + }, }) }) @@ -159,12 +228,35 @@ test('processes media queries', async () => { classNames: { 'bg-red': { __rule: true, - '@media (min-width: 768px)': { - __decls: true, - 'background-color': 'red' + __scope: null, + __context: ['@media (min-width: 768px)'], + 'background-color': 'red', + }, + }, + }) +}) + +test('processes nested at-rules', async () => { + const result = await processCss(` + @supports (display: grid) { + @media (min-width: 768px) { + .bg-red { + background-color: red; } } } + `) + + expect(result).toEqual({ + context: {}, + classNames: { + 'bg-red': { + __rule: true, + __scope: null, + __context: ['@supports (display: grid)', '@media (min-width: 768px)'], + 'background-color': 'red', + }, + }, }) }) @@ -183,11 +275,12 @@ test('merges declarations', async () => { classNames: { 'bg-red': { __rule: true, - __decls: true, + __scope: null, + __context: [], 'background-color': 'red', - color: 'white' - } - } + color: 'white', + }, + }, }) }) @@ -201,14 +294,17 @@ test('processes class name scope', async () => { expect(result).toEqual({ context: {}, classNames: { - scope: {}, + scope: { + __context: [], + __scope: null, + }, 'bg-red': { __rule: true, - __decls: true, + __context: [], __scope: '.scope', - 'background-color': 'red' - } - } + 'background-color': 'red', + }, + }, }) }) @@ -228,29 +324,29 @@ test('processes multiple scopes for the same class name', async () => { expect(result).toEqual({ context: {}, classNames: { - scope1: {}, - scope2: {}, - scope3: {}, + scope1: { __context: [], __scope: null }, + scope2: { __context: [], __scope: null }, + scope3: { __context: [], __scope: null }, 'bg-red': [ { __rule: true, - __decls: true, + __context: [], __scope: '.scope1', - 'background-color': 'red' + 'background-color': 'red', }, { __rule: true, - __decls: true, + __context: [], __scope: '.scope2 +', - 'background-color': 'red' + 'background-color': 'red', }, { __rule: true, - __decls: true, + __context: [], __scope: '.scope3 >', - 'background-color': 'red' - } - ] - } + 'background-color': 'red', + }, + ], + }, }) }) diff --git a/packages/tailwindcss-language-server/src/providers/completionProvider.ts b/packages/tailwindcss-language-server/src/providers/completionProvider.ts index 84c54c7..69ba6ec 100644 --- a/packages/tailwindcss-language-server/src/providers/completionProvider.ts +++ b/packages/tailwindcss-language-server/src/providers/completionProvider.ts @@ -27,6 +27,7 @@ function completionsFromClassList( let sep = ':' let parts = partialClassName.split(sep) let subset: any + let subsetKey: string[] = [] let isSubset: boolean = false let replacementRange = { @@ -42,6 +43,7 @@ function completionsFromClassList( subset = dlv(state.classNames.classNames, keys) if (typeof subset !== 'undefined' && typeof subset.__rule === 'undefined') { isSubset = true + subsetKey = keys replacementRange = { ...replacementRange, start: { @@ -62,7 +64,7 @@ function completionsFromClassList( (className) => { let kind: CompletionItemKind = CompletionItemKind.Constant let documentation: string = null - if (isContextItem(state, [className])) { + if (isContextItem(state, [...subsetKey, className])) { kind = CompletionItemKind.Module } else { const color = getColor(state, [className]) @@ -76,6 +78,7 @@ function completionsFromClassList( label: className, kind, documentation, + data: [...subsetKey, className], textEdit: { newText: className, range: replacementRange, @@ -514,20 +517,20 @@ export function resolveCompletionItem( return item } - const className = state.classNames.classNames[item.label] - if (isContextItem(state, [item.label])) { - item.detail = state.classNames.context[item.label].join(', ') + const className = dlv(state.classNames.classNames, item.data) + if (isContextItem(state, item.data)) { + item.detail = state.classNames.context[ + item.data[item.data.length - 1] + ].join(', ') } else { item.detail = getCssDetail(state, className) if (!item.documentation) { - item.documentation = stringifyCss(className) - if (item.detail === item.documentation) { - item.documentation = null - } else { - // item.documentation = { - // kind: MarkupKind.Markdown, - // value: ['```css', item.documentation, '```'].join('\n') - // } + const css = stringifyCss(item.data.join(':'), className) + if (css) { + item.documentation = { + kind: MarkupKind.Markdown, + value: ['```css', css, '```'].join('\n'), + } } } } @@ -537,7 +540,8 @@ export function resolveCompletionItem( function isContextItem(state: State, keys: string[]): boolean { const item = dlv(state.classNames.classNames, keys) return Boolean( - !item.__rule && + isObject(item) && + !item.__rule && !Array.isArray(item) && state.classNames.context[keys[keys.length - 1]] ) @@ -555,13 +559,8 @@ function getCssDetail(state: State, className: any): string { if (Array.isArray(className)) { return `${className.length} rules` } - let withoutMeta = removeMeta(className) - if (className.__decls === true) { - return stringifyDecls(withoutMeta) + if (className.__rule === true) { + return stringifyDecls(removeMeta(className)) } - let keys = Object.keys(withoutMeta) - if (keys.length === 1) { - return getCssDetail(state, className[keys[0]]) - } - return `${keys.length} rules` + return null } diff --git a/packages/tailwindcss-language-server/src/providers/hoverProvider.ts b/packages/tailwindcss-language-server/src/providers/hoverProvider.ts index 5ce7921..20147ba 100644 --- a/packages/tailwindcss-language-server/src/providers/hoverProvider.ts +++ b/packages/tailwindcss-language-server/src/providers/hoverProvider.ts @@ -6,7 +6,6 @@ import { } from '../util/getClassNameAtPosition' import { stringifyCss, stringifyConfigValue } from '../util/stringify' const dlv = require('dlv') -import escapeClassName from 'css.escape' import { isHtmlContext } from '../util/html' import { isCssContext } from '../util/css' @@ -90,21 +89,11 @@ function provideClassNameHover( return { contents: { language: 'css', - value: stringifyCss(dlv(state.classNames.classNames, parts), { - selector: augmentClassName(parts, state), - }), + value: stringifyCss( + hovered.className, + dlv(state.classNames.classNames, parts) + ), }, range: hovered.range, } } - -// TODO -function augmentClassName(className: string | string[], state: State): string { - const parts = Array.isArray(className) - ? className - : getClassNameParts(state, className) - const obj = dlv(state.classNames.classNames, parts) - const pseudo = obj.__pseudo ? obj.__pseudo.join('') : '' - const scope = obj.__scope ? `${obj.__scope} ` : '' - return `${scope}.${escapeClassName(parts.join(state.separator))}${pseudo}` -} diff --git a/packages/tailwindcss-language-server/src/util/color.ts b/packages/tailwindcss-language-server/src/util/color.ts index 0ac2f8a..bb44bba 100644 --- a/packages/tailwindcss-language-server/src/util/color.ts +++ b/packages/tailwindcss-language-server/src/util/color.ts @@ -16,7 +16,7 @@ const COLOR_PROPS = [ 'outline-color', 'stop-color', 'stroke', - 'text-decoration-color' + 'text-decoration-color', ] const COLOR_NAMES = { @@ -169,12 +169,12 @@ const COLOR_NAMES = { white: '#fff', whitesmoke: '#f5f5f5', yellow: '#ff0', - yellowgreen: '#9acd32' + yellowgreen: '#9acd32', } export function getColor(state: State, keys: string[]): string { const item = dlv(state.classNames.classNames, keys) - if (!item.__decls) return null + if (!item.__rule) return null const props = Object.keys(removeMeta(item)) if (props.length === 0 || props.length > 1) return null const prop = props[0] diff --git a/packages/tailwindcss-language-server/src/util/getClassNameAtPosition.ts b/packages/tailwindcss-language-server/src/util/getClassNameAtPosition.ts index 709ca96..e7e0cf2 100644 --- a/packages/tailwindcss-language-server/src/util/getClassNameAtPosition.ts +++ b/packages/tailwindcss-language-server/src/util/getClassNameAtPosition.ts @@ -49,7 +49,8 @@ export function getClassNameParts(state: State, className: string): string[] { let parts: string[] = className.split(separator) if (parts.length === 1) { - return dlv(state.classNames.classNames, [className, '__rule']) === true + return dlv(state.classNames.classNames, [className, '__rule']) === true || + Array.isArray(dlv(state.classNames.classNames, [className])) ? [className] : null } @@ -73,7 +74,10 @@ export function getClassNameParts(state: State, className: string): string[] { ] return possibilities.find((key) => { - if (dlv(state.classNames.classNames, [...key, '__rule']) === true) { + if ( + dlv(state.classNames.classNames, [...key, '__rule']) === true || + Array.isArray(dlv(state.classNames.classNames, [...key])) + ) { return true } return false diff --git a/packages/tailwindcss-language-server/src/util/stringify.ts b/packages/tailwindcss-language-server/src/util/stringify.ts index e8b1b7d..5433e90 100644 --- a/packages/tailwindcss-language-server/src/util/stringify.ts +++ b/packages/tailwindcss-language-server/src/util/stringify.ts @@ -1,4 +1,6 @@ import removeMeta from './removeMeta' +const dlv = require('dlv') +import escapeClassName from 'css.escape' export function stringifyConfigValue(x: any): string { if (typeof x === 'string') return x @@ -12,34 +14,45 @@ export function stringifyConfigValue(x: any): string { return null } -export function stringifyCss( - obj: any, - { indent = 0, selector }: { indent?: number; selector?: string } = {} -): string { - let indentStr = '\t'.repeat(indent) - if (obj.__decls === true) { - let before = '' - let after = '' - if (selector) { - before = `${indentStr}${selector} {\n` - after = `\n${indentStr}}` - indentStr += '\t' - } - return ( - before + - Object.keys(removeMeta(obj)).reduce((acc, curr, i) => { - return `${acc}${i === 0 ? '' : '\n'}${indentStr}${curr}: ${obj[curr]};` - }, '') + - after - ) +export function stringifyCss(className: string, obj: any): string { + if (obj.__rule !== true && !Array.isArray(obj)) return null + + if (Array.isArray(obj)) { + const rules = obj.map((x) => stringifyCss(className, x)).filter(Boolean) + if (rules.length === 0) return null + return rules.join('\n\n') } - return Object.keys(removeMeta(obj)).reduce((acc, curr, i) => { - return `${acc}${i === 0 ? '' : '\n'}${indentStr}${curr} {\n${stringifyCss( - obj[curr], - { - indent: indent + 1, - selector, - } - )}\n${indentStr}}` + + let css = `` + + const context = dlv(obj, '__context', []) + const props = Object.keys(removeMeta(obj)) + if (props.length === 0) return null + + for (let i = 0; i < context.length; i++) { + css += `${'\t'.repeat(i)}${context[i]} {\n` + } + + const indentStr = '\t'.repeat(context.length) + const decls = props.reduce((acc, curr, i) => { + return `${acc}${i === 0 ? '' : '\n'}${indentStr + '\t'}${curr}: ${ + obj[curr] + };` }, '') + css += `${indentStr}${augmentClassName( + className, + obj + )} {\n${decls}\n${indentStr}}` + + for (let i = context.length - 1; i >= 0; i--) { + css += `${'\t'.repeat(i)}\n}` + } + + return css +} + +function augmentClassName(className: string, obj: any): string { + const pseudo = obj.__pseudo ? obj.__pseudo.join('') : '' + const scope = obj.__scope ? `${obj.__scope} ` : '' + return `${scope}.${escapeClassName(className)}${pseudo}` }