Merge branch 'next' into diagnostics

master
Brad Cornes 2020-05-17 19:21:18 +01:00
commit 8bfb6d9b67
11 changed files with 282 additions and 145 deletions

12
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -31,10 +31,12 @@ export const DEFAULT_LANGUAGES = [
'sass',
'scss',
'stylus',
'sugarss',
// js
'javascript',
'javascriptreact',
'reason',
'typescript',
'typescriptreact',
// mixed
'vue',

View File

@ -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)?=(?<initial>['"`{])/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(

View File

@ -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 }) => {

View File

@ -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)
)
}

View File

@ -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,

View File

@ -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<T>(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
}

View File

@ -6,6 +6,7 @@ export const JS_LANGUAGES = [
'javascript',
'javascriptreact',
'reason',
'typescript',
'typescriptreact',
]

View File

@ -0,0 +1,19 @@
// https://www.codementor.io/@agustinchiappeberrini/lazy-evaluation-and-javascript-a5m7g8gs3
export interface Lazy<T> {
(): T
isLazy: boolean
}
export const lazy = <T>(getter: () => T): Lazy<T> => {
let evaluated: boolean = false
let _res: T = null
const res = <Lazy<T>>function (): T {
if (evaluated) return _res
_res = getter.apply(this, arguments)
evaluated = true
return _res
}
res.isLazy = true
return res
}

View File

@ -0,0 +1,53 @@
import moo from 'moo'
import { lazy } from './lazy'
const classAttributeStates: { [x: string]: moo.Rules } = {
doubleClassList: {
lbrace: { match: /(?<!\\)\{/, push: 'interp' },
rbrace: { match: /(?<!\\)\}/, pop: 1 },
end: { match: /(?<!\\)"/, pop: 1 },
classlist: { match: /[\s\S]/, lineBreaks: true },
},
singleClassList: {
lbrace: { match: /(?<!\\)\{/, push: 'interp' },
rbrace: { match: /(?<!\\)\}/, pop: 1 },
end: { match: /(?<!\\)'/, pop: 1 },
classlist: { match: /[\s\S]/, lineBreaks: true },
},
tickClassList: {
lbrace: { match: /(?<=(?<!\\)\$)\{/, push: 'interp' },
rbrace: { match: /(?<!\\)\}/, pop: 1 },
end: { match: /(?<!\\)`/, pop: 1 },
classlist: { match: /[\s\S]/, lineBreaks: true },
},
interp: {
startSingle: { match: /(?<!\\)'/, push: 'singleClassList' },
startDouble: { match: /(?<!\\)"/, push: 'doubleClassList' },
startTick: { match: /(?<!\\)`/, push: 'tickClassList' },
lbrace: { match: /(?<!\\)\{/, push: 'interp' },
rbrace: { match: /(?<!\\)\}/, pop: 1 },
text: { match: /[\s\S]/, lineBreaks: true },
},
}
export const getClassAttributeLexer = lazy(() =>
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,
})
)