diff --git a/package-lock.json b/package-lock.json index d3461d1..f844afb 100755 --- a/package-lock.json +++ b/package-lock.json @@ -1033,6 +1033,12 @@ "integrity": "sha512-dOrgprHnkDaj1pmrwdcMAf0QRNQzqTB5rxJph+iIQshSmIvtgRqJ0nim8u1vvXU8iOXZrH96+M46JDFTPLingA==", "dev": true }, + "@types/moo": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@types/moo/-/moo-0.5.3.tgz", + "integrity": "sha512-PJJ/jvb5Gor8DWvXN3e75njfQyYNRz0PaFSZ3br9GfHM9N2FxvuJ/E/ytcQePJOLzHlvgFSsIJIvfUMUxWTbnA==", + "dev": true + }, "@types/node": { "version": "13.13.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-13.13.4.tgz", @@ -4943,6 +4949,12 @@ "integrity": "sha512-6uCP4Qc0sWsgMLy1EOqqS/3rjDHOEnsStVr/4vtAIK2Y5i2kA7lFFejYrpIyiN9w0pYf4ckeCYT9f1r1P9KX5g==", "dev": true }, + "moo": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.1.tgz", + "integrity": "sha512-I1mnb5xn4fO80BH9BLcF0yLypy2UKl+Cb01Fu0hJRkJjlCRtxZMWkTdAtDd5ZqCOxtCkhmRwyI57vWT+1iZ67w==", + "dev": true + }, "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", diff --git a/package.json b/package.json index 69d62d9..0b515ca 100755 --- a/package.json +++ b/package.json @@ -44,7 +44,8 @@ "source.css.less", "source.css.postcss", "source.vue", - "source.svelte" + "source.svelte", + "text.html" ] } ], @@ -75,6 +76,7 @@ "devDependencies": { "@ctrl/tinycolor": "^3.1.0", "@types/mocha": "^5.2.0", + "@types/moo": "^0.5.3", "@types/node": "^13.9.3", "@types/vscode": "^1.32.0", "@zeit/ncc": "^0.22.0", @@ -93,6 +95,7 @@ "line-column": "^1.0.2", "mitt": "^1.2.0", "mkdirp": "^1.0.3", + "moo": "^0.5.1", "pkg-up": "^3.1.0", "postcss": "^7.0.27", "postcss-selector-parser": "^6.0.2", diff --git a/src/lib/languages.ts b/src/lib/languages.ts index b9238ed..777f588 100644 --- a/src/lib/languages.ts +++ b/src/lib/languages.ts @@ -31,10 +31,12 @@ export const DEFAULT_LANGUAGES = [ 'sass', 'scss', 'stylus', + 'sugarss', // js 'javascript', 'javascriptreact', 'reason', + 'typescript', 'typescriptreact', // mixed 'vue', diff --git a/src/lsp/providers/completionProvider.ts b/src/lsp/providers/completionProvider.ts index 8f2c43d..eae7546 100644 --- a/src/lsp/providers/completionProvider.ts +++ b/src/lsp/providers/completionProvider.ts @@ -12,7 +12,7 @@ import removeMeta from '../util/removeMeta' import { getColor, getColorFromValue } from '../util/color' import { isHtmlContext } from '../util/html' import { isCssContext } from '../util/css' -import { findLast, findJsxStrings, arrFindLast } from '../util/find' +import { findLast } from '../util/find' import { stringifyConfigValue, stringifyCss } from '../util/stringify' import { stringifyScreen, Screen } from '../util/screens' import isObject from '../../util/isObject' @@ -24,6 +24,10 @@ import { naturalExpand } from '../util/naturalExpand' import semver from 'semver' import { docsUrl } from '../util/docsUrl' import { ensureArray } from '../../util/array' +import { + getClassAttributeLexer, + getComputedClassAttributeLexer, +} from '../util/lexers' function completionsFromClassList( state: State, @@ -122,24 +126,31 @@ function provideClassAttributeCompletions( end: position, }) - const match = findLast(/\bclass(?:Name)?=(?['"`{])/gi, str) + const match = findLast(/[\s:]class(?:Name)?=['"`{]/gi, str) if (match === null) { return null } - const rest = str.substr(match.index + match[0].length) + const lexer = + match[0][0] === ':' + ? getComputedClassAttributeLexer() + : getClassAttributeLexer() + lexer.reset(str.substr(match.index + match[0].length - 1)) + + try { + let tokens = Array.from(lexer) + let last = tokens[tokens.length - 1] + if (last.type.startsWith('start') || last.type === 'classlist') { + let classList = '' + for (let i = tokens.length - 1; i >= 0; i--) { + if (tokens[i].type === 'classlist') { + classList = tokens[i].value + classList + } else { + break + } + } - if (match.groups.initial === '{') { - const strings = findJsxStrings('{' + rest) - const lastOpenString = arrFindLast( - strings, - (string) => typeof string.end === 'undefined' - ) - if (lastOpenString) { - const classList = str.substr( - str.length - rest.length + lastOpenString.start - 1 - ) return completionsFromClassList(state, classList, { start: { line: position.line, @@ -148,20 +159,9 @@ function provideClassAttributeCompletions( end: position, }) } - return null - } + } catch (_) {} - if (rest.indexOf(match.groups.initial) !== -1) { - return null - } - - return completionsFromClassList(state, rest, { - start: { - line: position.line, - character: position.character - rest.length, - }, - end: position, - }) + return null } function provideAtApplyCompletions( diff --git a/src/lsp/providers/diagnosticsProvider.ts b/src/lsp/providers/diagnosticsProvider.ts index 66e0f4c..152f3b7 100644 --- a/src/lsp/providers/diagnosticsProvider.ts +++ b/src/lsp/providers/diagnosticsProvider.ts @@ -10,7 +10,7 @@ import { getClassNameParts } from '../util/getClassNameAtPosition' const dlv = require('dlv') function provideCssDiagnostics(state: State, document: TextDocument): void { - const classNames = findClassNamesInRange(document) + const classNames = findClassNamesInRange(document, undefined, 'css') let diagnostics: Diagnostic[] = classNames .map(({ className, range }) => { diff --git a/src/lsp/providers/hoverProvider.ts b/src/lsp/providers/hoverProvider.ts index 7e27d9a..a9010a3 100644 --- a/src/lsp/providers/hoverProvider.ts +++ b/src/lsp/providers/hoverProvider.ts @@ -1,16 +1,10 @@ -import { State, DocumentClassName } from '../util/state' +import { State } from '../util/state' import { Hover, TextDocumentPositionParams } from 'vscode-languageserver' -import { - getClassNameAtPosition, - getClassNameParts, -} from '../util/getClassNameAtPosition' +import { getClassNameParts } from '../util/getClassNameAtPosition' import { stringifyCss, stringifyConfigValue } from '../util/stringify' const dlv = require('dlv') -import { isHtmlContext } from '../util/html' import { isCssContext } from '../util/css' -import { isJsContext } from '../util/js' -import { isWithinRange } from '../util/isWithinRange' -import { findClassNamesInRange } from '../util/find' +import { findClassNameAtPosition } from '../util/find' export function provideHover( state: State, @@ -75,68 +69,26 @@ function provideCssHelperHover( } } -function provideClassAttributeHover( +function provideClassNameHover( state: State, { textDocument, position }: TextDocumentPositionParams ): Hover { let doc = state.editor.documents.get(textDocument.uri) - if ( - !isHtmlContext(state, doc, position) && - !isJsContext(state, doc, position) - ) - return null + let className = findClassNameAtPosition(state, doc, position) + if (className === null) return null - let hovered = getClassNameAtPosition(doc, position) - if (!hovered) return null - - return classNameToHover(state, hovered) -} - -function classNameToHover( - state: State, - { className, range }: DocumentClassName -): Hover { - const parts = getClassNameParts(state, className) + const parts = getClassNameParts(state, className.className) if (!parts) return null return { contents: { language: 'css', - value: stringifyCss(className, dlv(state.classNames.classNames, parts)), + value: stringifyCss( + className.className, + dlv(state.classNames.classNames, parts) + ), }, - range, + range: className.range, } } - -function provideAtApplyHover( - state: State, - { textDocument, position }: TextDocumentPositionParams -): Hover { - let doc = state.editor.documents.get(textDocument.uri) - - if (!isCssContext(state, doc, position)) return null - - const classNames = findClassNamesInRange(doc, { - start: { line: Math.max(position.line - 10, 0), character: 0 }, - end: { line: position.line + 10, character: 0 }, - }) - - const className = classNames.find(({ range }) => - isWithinRange(position, range) - ) - - if (!className) return null - - return classNameToHover(state, className) -} - -function provideClassNameHover( - state: State, - params: TextDocumentPositionParams -): Hover { - return ( - provideClassAttributeHover(state, params) || - provideAtApplyHover(state, params) - ) -} diff --git a/src/lsp/util/css.ts b/src/lsp/util/css.ts index d1acbea..e6dbd09 100644 --- a/src/lsp/util/css.ts +++ b/src/lsp/util/css.ts @@ -1,5 +1,5 @@ import { TextDocument, Position } from 'vscode-languageserver' -import { isInsideTag, isVueDoc, isSvelteDoc } from './html' +import { isInsideTag, isVueDoc, isSvelteDoc, isHtmlDoc } from './html' import { State } from './state' export const CSS_LANGUAGES = [ @@ -9,6 +9,7 @@ export const CSS_LANGUAGES = [ 'sass', 'scss', 'stylus', + 'sugarss', ] export function isCssDoc(state: State, doc: TextDocument): boolean { @@ -28,7 +29,7 @@ export function isCssContext( return true } - if (isVueDoc(doc) || isSvelteDoc(doc)) { + if (isHtmlDoc(state, doc) || isVueDoc(doc) || isSvelteDoc(doc)) { let str = doc.getText({ start: { line: 0, character: 0 }, end: position, diff --git a/src/lsp/util/find.ts b/src/lsp/util/find.ts index 17b6a12..800d0a3 100644 --- a/src/lsp/util/find.ts +++ b/src/lsp/util/find.ts @@ -1,6 +1,11 @@ import { TextDocument, Range, Position } from 'vscode-languageserver' -import { DocumentClassName, DocumentClassList } from './state' +import { DocumentClassName, DocumentClassList, State } from './state' import lineColumn from 'line-column' +import { isCssContext } from './css' +import { isHtmlContext } from './html' +import { isWithinRange } from './isWithinRange' +import { isJsContext } from './js' +import { getClassAttributeLexer } from './lexers' export function findAll(re: RegExp, str: string): RegExpMatchArray[] { let match: RegExpMatchArray @@ -19,62 +24,12 @@ export function findLast(re: RegExp, str: string): RegExpMatchArray { return matches[matches.length - 1] } -export function arrFindLast(arr: T[], predicate: (item: T) => boolean): T { - for (let i = arr.length - 1; i >= 0; --i) { - const x = arr[i] - if (predicate(x)) { - return x - } - } - return null -} - -enum Quote { - SINGLE = "'", - DOUBLE = '"', - TICK = '`', -} -type StringInfo = { - start: number - end?: number - char: Quote -} - -export function findJsxStrings(str: string): StringInfo[] { - const chars = str.split('') - const strings: StringInfo[] = [] - let bracketCount = 0 - for (let i = 0; i < chars.length; i++) { - const char = chars[i] - if (char === '{') { - bracketCount += 1 - } else if (char === '}') { - bracketCount -= 1 - } else if ( - char === Quote.SINGLE || - char === Quote.DOUBLE || - char === Quote.TICK - ) { - let open = arrFindLast(strings, (string) => string.char === char) - if (strings.length === 0 || !open || (open && open.end)) { - strings.push({ start: i + 1, char }) - } else { - open.end = i - } - } - if (i !== 0 && bracketCount === 0) { - // end - break - } - } - return strings -} - export function findClassNamesInRange( doc: TextDocument, - range?: Range + range?: Range, + mode?: 'html' | 'css' ): DocumentClassName[] { - const classLists = findClassListsInRange(doc, range) + const classLists = findClassListsInRange(doc, range, mode) return [].concat.apply( [], classLists.map(({ classList, range }) => { @@ -109,7 +64,7 @@ export function findClassNamesInRange( ) } -export function findClassListsInRange( +export function findClassListsInCssRange( doc: TextDocument, range?: Range ): DocumentClassList[] { @@ -139,7 +94,146 @@ export function findClassListsInRange( }) } +export function findClassListsInHtmlRange( + doc: TextDocument, + range: Range +): DocumentClassList[] { + const text = doc.getText(range) + const matches = findAll(/[\s:]class(?:Name)?=['"`{]/g, text) + const result: DocumentClassList[] = [] + + matches.forEach((match) => { + const subtext = text.substr(match.index + match[0].length - 1, 200) + + let lexer = getClassAttributeLexer() + lexer.reset(subtext) + + let classLists: { value: string; offset: number }[] = [] + let token: moo.Token + let currentClassList: { value: string; offset: number } + + try { + for (let token of lexer) { + if (token.type === 'classlist') { + if (currentClassList) { + currentClassList.value += token.value + } else { + currentClassList = { + value: token.value, + offset: token.offset, + } + } + } else { + if (currentClassList) { + classLists.push({ + value: currentClassList.value, + offset: currentClassList.offset, + }) + } + currentClassList = undefined + } + } + } catch (_) {} + + if (currentClassList) { + classLists.push({ + value: currentClassList.value, + offset: currentClassList.offset, + }) + } + + result.push( + ...classLists + .map(({ value, offset }) => { + if (value.trim() === '') { + return null + } + + const before = value.match(/^\s*/) + const beforeOffset = before === null ? 0 : before[0].length + const after = value.match(/\s*$/) + const afterOffset = after === null ? 0 : -after[0].length + + const start = indexToPosition( + text, + match.index + match[0].length - 1 + offset + beforeOffset + ) + const end = indexToPosition( + text, + match.index + + match[0].length - + 1 + + offset + + value.length + + afterOffset + ) + + return { + classList: value, + range: { + start: { + line: range.start.line + start.line, + character: range.start.character + start.character, + }, + end: { + line: range.start.line + end.line, + character: range.start.character + end.character, + }, + }, + } + }) + .filter((x) => x !== null) + ) + }) + + return result +} + +export function findClassListsInRange( + doc: TextDocument, + range: Range, + mode: 'html' | 'css' +): DocumentClassList[] { + if (mode === 'css') { + return findClassListsInCssRange(doc, range) + } + return findClassListsInHtmlRange(doc, range) +} + function indexToPosition(str: string, index: number): Position { const { line, col } = lineColumn(str + '\n', index) return { line: line - 1, character: col - 1 } } + +export function findClassNameAtPosition( + state: State, + doc: TextDocument, + position: Position +): DocumentClassName { + let classNames = [] + const searchRange = { + start: { line: Math.max(position.line - 10, 0), character: 0 }, + end: { line: position.line + 10, character: 0 }, + } + + if (isCssContext(state, doc, position)) { + classNames = findClassNamesInRange(doc, searchRange, 'css') + } else if ( + isHtmlContext(state, doc, position) || + isJsContext(state, doc, position) + ) { + classNames = findClassNamesInRange(doc, searchRange, 'html') + } + + if (classNames.length === 0) { + return null + } + + const className = classNames.find(({ range }) => + isWithinRange(position, range) + ) + + if (!className) return null + + return className +} diff --git a/src/lsp/util/js.ts b/src/lsp/util/js.ts index 8a62a5f..495ec5d 100644 --- a/src/lsp/util/js.ts +++ b/src/lsp/util/js.ts @@ -6,6 +6,7 @@ export const JS_LANGUAGES = [ 'javascript', 'javascriptreact', 'reason', + 'typescript', 'typescriptreact', ] diff --git a/src/lsp/util/lazy.ts b/src/lsp/util/lazy.ts new file mode 100644 index 0000000..858dac5 --- /dev/null +++ b/src/lsp/util/lazy.ts @@ -0,0 +1,19 @@ +// https://www.codementor.io/@agustinchiappeberrini/lazy-evaluation-and-javascript-a5m7g8gs3 + +export interface Lazy { + (): T + isLazy: boolean +} + +export const lazy = (getter: () => T): Lazy => { + let evaluated: boolean = false + let _res: T = null + const res = >function (): T { + if (evaluated) return _res + _res = getter.apply(this, arguments) + evaluated = true + return _res + } + res.isLazy = true + return res +} diff --git a/src/lsp/util/lexers.ts b/src/lsp/util/lexers.ts new file mode 100644 index 0000000..65197b9 --- /dev/null +++ b/src/lsp/util/lexers.ts @@ -0,0 +1,53 @@ +import moo from 'moo' +import { lazy } from './lazy' + +const classAttributeStates: { [x: string]: moo.Rules } = { + doubleClassList: { + lbrace: { match: /(? + moo.states({ + main: { + start1: { match: '"', push: 'doubleClassList' }, + start2: { match: "'", push: 'singleClassList' }, + start3: { match: '{', push: 'interp' }, + }, + ...classAttributeStates, + }) +) + +export const getComputedClassAttributeLexer = lazy(() => + moo.states({ + main: { + quote: { match: /['"{]/, push: 'interp' }, + }, + // TODO: really this should use a different interp definition that is + // terminated correctly based on the initial quote type + ...classAttributeStates, + }) +)