Rework language boundary detection (#502)

* Fix `classRegex` error

* Rework language boundary detection
master
Brad Cornes 2022-03-02 17:16:35 +00:00 committed by GitHub
parent a082bb3fd7
commit 86497bb380
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 16804 additions and 1043 deletions

17537
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -29,6 +29,7 @@
"semver": "7.3.2", "semver": "7.3.2",
"sift-string": "0.0.2", "sift-string": "0.0.2",
"stringify-object": "3.3.0", "stringify-object": "3.3.0",
"tmp-cache": "1.1.0",
"vscode-emmet-helper-bundled": "0.0.1", "vscode-emmet-helper-bundled": "0.0.1",
"vscode-languageclient": "7.0.0", "vscode-languageclient": "7.0.0",
"vscode-languageserver": "7.0.0", "vscode-languageserver": "7.0.0",

View File

@ -43,7 +43,9 @@ export async function provideInvalidApplyCodeActions(
if (!isCssDoc(state, document)) { if (!isCssDoc(state, document)) {
let languageBoundaries = getLanguageBoundaries(state, document) let languageBoundaries = getLanguageBoundaries(state, document)
if (!languageBoundaries) return [] if (!languageBoundaries) return []
cssRange = languageBoundaries.css.find((range) => isWithinRange(diagnostic.range.start, range)) cssRange = languageBoundaries
.filter((b) => b.type === 'css')
.find(({ range }) => isWithinRange(diagnostic.range.start, range))?.range
if (!cssRange) return [] if (!cssRange) return []
cssText = document.getText(cssRange) cssText = document.getText(cssRange)
} }

View File

@ -20,7 +20,7 @@ import { stringifyScreen, Screen } from './util/screens'
import isObject from './util/isObject' import isObject from './util/isObject'
import * as emmetHelper from 'vscode-emmet-helper-bundled' import * as emmetHelper from 'vscode-emmet-helper-bundled'
import { isValidLocationForEmmetAbbreviation } from './util/isValidLocationForEmmetAbbreviation' import { isValidLocationForEmmetAbbreviation } from './util/isValidLocationForEmmetAbbreviation'
import { isJsContext } from './util/js' import { isJsDoc, isJsxContext } from './util/js'
import { naturalExpand } from './util/naturalExpand' import { naturalExpand } from './util/naturalExpand'
import semver from 'semver' import semver from 'semver'
import { docsUrl } from './util/docsUrl' import { docsUrl } from './util/docsUrl'
@ -511,7 +511,7 @@ async function provideClassNameCompletions(
return provideAtApplyCompletions(state, document, position) return provideAtApplyCompletions(state, document, position)
} }
if (isHtmlContext(state, document, position) || isJsContext(state, document, position)) { if (isHtmlContext(state, document, position) || isJsxContext(state, document, position)) {
return provideClassAttributeCompletions(state, document, position, context) return provideClassAttributeCompletions(state, document, position, context)
} }
@ -973,8 +973,8 @@ async function provideEmmetCompletions(
let settings = await state.editor.getConfiguration(document.uri) let settings = await state.editor.getConfiguration(document.uri)
if (settings.tailwindCSS.emmetCompletions !== true) return null if (settings.tailwindCSS.emmetCompletions !== true) return null
const isHtml = isHtmlContext(state, document, position) const isHtml = !isJsDoc(state, document) && isHtmlContext(state, document, position)
const isJs = !isHtml && isJsContext(state, document, position) const isJs = isJsDoc(state, document) || isJsxContext(state, document, position)
const syntax = isHtml ? 'html' : isJs ? 'jsx' : null const syntax = isHtml ? 'html' : isJs ? 'jsx' : null

View File

@ -173,7 +173,7 @@ export function getInvalidConfigPathDiagnostics(
} else { } else {
let boundaries = getLanguageBoundaries(state, document) let boundaries = getLanguageBoundaries(state, document)
if (!boundaries) return [] if (!boundaries) return []
ranges.push(...boundaries.css) ranges.push(...boundaries.filter((b) => b.type === 'css').map(({ range }) => range))
} }
ranges.forEach((range) => { ranges.forEach((range) => {

View File

@ -24,7 +24,7 @@ export function getInvalidScreenDiagnostics(
} else { } else {
let boundaries = getLanguageBoundaries(state, document) let boundaries = getLanguageBoundaries(state, document)
if (!boundaries) return [] if (!boundaries) return []
ranges.push(...boundaries.css) ranges.push(...boundaries.filter((b) => b.type === 'css').map(({ range }) => range))
} }
ranges.forEach((range) => { ranges.forEach((range) => {

View File

@ -24,7 +24,7 @@ export function getInvalidTailwindDirectiveDiagnostics(
} else { } else {
let boundaries = getLanguageBoundaries(state, document) let boundaries = getLanguageBoundaries(state, document)
if (!boundaries) return [] if (!boundaries) return []
ranges.push(...boundaries.css) ranges.push(...boundaries.filter((b) => b.type === 'css').map(({ range }) => range))
} }
let notSemicolonLanguages = ['sass', 'sugarss', 'stylus'] let notSemicolonLanguages = ['sass', 'sugarss', 'stylus']

View File

@ -28,7 +28,7 @@ export function getInvalidVariantDiagnostics(
} else { } else {
let boundaries = getLanguageBoundaries(state, document) let boundaries = getLanguageBoundaries(state, document)
if (!boundaries) return [] if (!boundaries) return []
ranges.push(...boundaries.css) ranges.push(...boundaries.filter((b) => b.type === 'css').map(({ range }) => range))
} }
let possibleVariants = Object.keys(state.variants) let possibleVariants = Object.keys(state.variants)

View File

@ -1,8 +1,9 @@
import type { TextDocument, Position } from 'vscode-languageserver' import type { TextDocument, Position } from 'vscode-languageserver'
import { isInsideTag, isVueDoc, isSvelteDoc, isHtmlDoc } from './html' import { isVueDoc, isSvelteDoc, isHtmlDoc } from './html'
import { isJsDoc } from './js' import { isJsDoc } from './js'
import { State } from './state' import { State } from './state'
import { cssLanguages } from './languages' import { cssLanguages } from './languages'
import { getLanguageBoundaries } from './getLanguageBoundaries'
export function isCssDoc(state: State, doc: TextDocument): boolean { export function isCssDoc(state: State, doc: TextDocument): boolean {
const userCssLanguages = Object.keys(state.editor.userLanguages).filter((lang) => const userCssLanguages = Object.keys(state.editor.userLanguages).filter((lang) =>
@ -23,7 +24,9 @@ export function isCssContext(state: State, doc: TextDocument, position: Position
end: position, end: position,
}) })
return isInsideTag(str, ['style']) let boundaries = getLanguageBoundaries(state, doc, str)
return boundaries ? boundaries[boundaries.length - 1].type === 'css' : false
} }
return false return false

View File

@ -4,7 +4,7 @@ import lineColumn from 'line-column'
import { isCssContext, isCssDoc } from './css' import { isCssContext, isCssDoc } from './css'
import { isHtmlContext } from './html' import { isHtmlContext } from './html'
import { isWithinRange } from './isWithinRange' import { isWithinRange } from './isWithinRange'
import { isJsContext } from './js' import { isJsxContext } from './js'
import { flatten } from './array' import { flatten } from './array'
import { getClassAttributeLexer, getComputedClassAttributeLexer } from './lexers' import { getClassAttributeLexer, getComputedClassAttributeLexer } from './lexers'
import { getLanguageBoundaries } from './getLanguageBoundaries' import { getLanguageBoundaries } from './getLanguageBoundaries'
@ -306,9 +306,13 @@ export async function findClassListsInDocument(
return flatten([ return flatten([
...(await Promise.all( ...(await Promise.all(
boundaries.html.map((range) => findClassListsInHtmlRange(state, doc, range)) boundaries
.filter((b) => b.type === 'html' || b.type === 'jsx')
.map(({ range }) => findClassListsInHtmlRange(state, doc, range))
)), )),
...boundaries.css.map((range) => findClassListsInCssRange(doc, range)), ...boundaries
.filter((b) => b.type === 'css')
.map(({ range }) => findClassListsInCssRange(doc, range)),
await findCustomClassLists(state, doc), await findCustomClassLists(state, doc),
]) ])
} }
@ -324,7 +328,11 @@ export function findHelperFunctionsInDocument(
let boundaries = getLanguageBoundaries(state, doc) let boundaries = getLanguageBoundaries(state, doc)
if (!boundaries) return [] if (!boundaries) return []
return flatten(boundaries.css.map((range) => findHelperFunctionsInRange(doc, range))) return flatten(
boundaries
.filter((b) => b.type === 'css')
.map(({ range }) => findHelperFunctionsInRange(doc, range))
)
} }
export function findHelperFunctionsInRange( export function findHelperFunctionsInRange(
@ -385,7 +393,7 @@ export async function findClassNameAtPosition(
if (isCssContext(state, doc, position)) { if (isCssContext(state, doc, position)) {
classNames = await findClassNamesInRange(state, doc, searchRange, 'css') classNames = await findClassNamesInRange(state, doc, searchRange, 'css')
} else if (isHtmlContext(state, doc, position) || isJsContext(state, doc, position)) { } else if (isHtmlContext(state, doc, position) || isJsxContext(state, doc, position)) {
classNames = await findClassNamesInRange(state, doc, searchRange, 'html') classNames = await findClassNamesInRange(state, doc, searchRange, 'html')
} }

View File

@ -1,78 +1,153 @@
import type { TextDocument, Range } from 'vscode-languageserver' import type { TextDocument, Range } from 'vscode-languageserver'
import { isVueDoc, isHtmlDoc, isSvelteDoc } from './html' import { isVueDoc, isHtmlDoc, isSvelteDoc } from './html'
import { State } from './state' import { State } from './state'
import { findAll, indexToPosition } from './find' import { indexToPosition } from './find'
import { isJsDoc } from './js' import { isJsDoc } from './js'
import moo from 'moo'
import Cache from 'tmp-cache'
export interface LanguageBoundaries { export type LanguageBoundary = { type: 'html' | 'js' | 'css' | string; range: Range }
html: Range[]
css: Range[] let text = { text: { match: /[^]/, lineBreaks: true } }
let states = {
main: {
cssBlockStart: { match: '<style', push: 'cssBlock' },
jsBlockStart: { match: '<script', push: 'jsBlock' },
...text,
},
cssBlock: {
styleStart: { match: '>', next: 'style' },
cssBlockEnd: { match: '/>', pop: 1 },
attrStartDouble: { match: '"', push: 'attrDouble' },
attrStartSingle: { match: "'", push: 'attrSingle' },
interp: { match: '{', push: 'interp' },
...text,
},
jsBlock: {
scriptStart: { match: '>', next: 'script' },
jsBlockEnd: { match: '/>', pop: 1 },
langAttrStartDouble: { match: 'lang="', push: 'langAttrDouble' },
langAttrStartSingle: { match: "lang='", push: 'langAttrSingle' },
attrStartDouble: { match: '"', push: 'attrDouble' },
attrStartSingle: { match: "'", push: 'attrSingle' },
interp: { match: '{', push: 'interp' },
...text,
},
interp: {
interp: { match: '{', push: 'interp' },
end: { match: '}', pop: 1 },
...text,
},
langAttrDouble: {
langAttrEnd: { match: '"', pop: 1 },
lang: { match: /[^"]+/, lineBreaks: true },
},
langAttrSingle: {
langAttrEnd: { match: "'", pop: 1 },
lang: { match: /[^']+/, lineBreaks: true },
},
attrDouble: {
attrEnd: { match: '"', pop: 1 },
...text,
},
attrSingle: {
attrEnd: { match: "'", pop: 1 },
...text,
},
style: {
cssBlockEnd: { match: '</style>', pop: 1 },
...text,
},
script: {
jsBlockEnd: { match: '</script>', pop: 1 },
...text,
},
} }
export function getLanguageBoundaries(state: State, doc: TextDocument): LanguageBoundaries | null { let vueStates = {
if (isVueDoc(doc)) { ...states,
let text = doc.getText() main: {
let blocks = findAll( htmlBlockStart: { match: '<template', push: 'htmlBlock' },
/(?<open><(?<type>template|style|script)\b[^>]*>).*?(?<close><\/\k<type>>|$)/gis, ...states.main,
text },
) htmlBlock: {
let htmlRanges: Range[] = [] htmlStart: { match: '>', next: 'html' },
let cssRanges: Range[] = [] htmlBlockEnd: { match: '/>', pop: 1 },
for (let i = 0; i < blocks.length; i++) { attrStartDouble: { match: '"', push: 'attrDouble' },
let range = { attrStartSingle: { match: "'", push: 'attrSingle' },
start: indexToPosition(text, blocks[i].index + blocks[i].groups.open.length), interp: { match: '{', push: 'interp' },
end: indexToPosition( ...text,
text, },
blocks[i].index + blocks[i][0].length - blocks[i].groups.close.length html: {
), htmlBlockEnd: { match: '</template>', pop: 1 },
} ...text,
if (blocks[i].groups.type === 'style') { },
cssRanges.push(range) }
} else {
htmlRanges.push(range) let defaultLexer = moo.states(states)
} let vueLexer = moo.states(vueStates)
let cache = new Cache<string, LanguageBoundary[] | null>({ max: 25, maxAge: 1000 })
export function getLanguageBoundaries(
state: State,
doc: TextDocument,
text: string = doc.getText()
): LanguageBoundary[] | null {
let cacheKey = `${doc.languageId}:${text}`
if (cache.has(cacheKey)) {
return cache.get(cacheKey)
} }
return { let defaultType = isVueDoc(doc)
html: htmlRanges, ? 'none'
css: cssRanges, : isHtmlDoc(state, doc) || isJsDoc(state, doc) || isSvelteDoc(doc)
} ? 'html'
} : null
if (isHtmlDoc(state, doc) || isJsDoc(state, doc) || isSvelteDoc(doc)) {
let text = doc.getText()
let styleBlocks = findAll(
/(?<open><style(?:\s[^>]*[^\/]>|\s*>)).*?(?<close><\/style>|$)/gis,
text
)
let htmlRanges: Range[] = []
let cssRanges: Range[] = []
let currentIndex = 0
for (let i = 0; i < styleBlocks.length; i++) {
htmlRanges.push({
start: indexToPosition(text, currentIndex),
end: indexToPosition(text, styleBlocks[i].index),
})
cssRanges.push({
start: indexToPosition(text, styleBlocks[i].index + styleBlocks[i].groups.open.length),
end: indexToPosition(
text,
styleBlocks[i].index + styleBlocks[i][0].length - styleBlocks[i].groups.close.length
),
})
currentIndex = styleBlocks[i].index + styleBlocks[i][0].length
}
htmlRanges.push({
start: indexToPosition(text, currentIndex),
end: indexToPosition(text, text.length),
})
return {
html: htmlRanges,
css: cssRanges,
}
}
if (defaultType === null) {
cache.set(cacheKey, null)
return null return null
}
let lexer = defaultType === 'none' ? vueLexer : defaultLexer
lexer.reset(text)
let type = defaultType
let boundaries: LanguageBoundary[] = [
{ type: defaultType, range: { start: { line: 0, character: 0 }, end: undefined } },
]
let offset = 0
try {
for (let token of lexer) {
if (token.type.endsWith('BlockStart')) {
let position = indexToPosition(text, offset)
if (!boundaries[boundaries.length - 1].range.end) {
boundaries[boundaries.length - 1].range.end = position
}
type = token.type.replace(/BlockStart$/, '')
boundaries.push({ type, range: { start: position, end: undefined } })
} else if (token.type.endsWith('BlockEnd')) {
let position = indexToPosition(text, offset)
boundaries[boundaries.length - 1].range.end = position
boundaries.push({ type: defaultType, range: { start: position, end: undefined } })
} else if (token.type === 'lang') {
boundaries[boundaries.length - 1].type = token.text
}
offset += token.text.length
}
} catch {
cache.set(cacheKey, null)
return null
}
if (!boundaries[boundaries.length - 1].range.end) {
boundaries[boundaries.length - 1].range.end = indexToPosition(text, offset)
}
cache.set(cacheKey, boundaries)
return boundaries
} }

View File

@ -1,6 +1,7 @@
import type { TextDocument, Position } from 'vscode-languageserver' import type { TextDocument, Position } from 'vscode-languageserver'
import { State } from './state' import { State } from './state'
import { htmlLanguages } from './languages' import { htmlLanguages } from './languages'
import { getLanguageBoundaries } from './getLanguageBoundaries'
export function isHtmlDoc(state: State, doc: TextDocument): boolean { export function isHtmlDoc(state: State, doc: TextDocument): boolean {
const userHtmlLanguages = Object.keys(state.editor.userLanguages).filter((lang) => const userHtmlLanguages = Object.keys(state.editor.userLanguages).filter((lang) =>
@ -24,33 +25,7 @@ export function isHtmlContext(state: State, doc: TextDocument, position: Positio
end: position, end: position,
}) })
if (isHtmlDoc(state, doc) && !isInsideTag(str, ['script', 'style'])) { let boundaries = getLanguageBoundaries(state, doc, str)
return true
}
if (isVueDoc(doc)) { return boundaries ? boundaries[boundaries.length - 1].type === 'html' : false
return isInsideTag(str, ['template'])
}
if (isSvelteDoc(doc)) {
return !isInsideTag(str, ['script', 'style'])
}
return false
}
export function isInsideTag(str: string, tag: string | string[]): boolean {
let open = 0
let close = 0
let match: RegExpExecArray
let tags = Array.isArray(tag) ? tag : [tag]
let regex = new RegExp(`<(?<slash>/?)(?:${tags.join('|')})(?:\\s[^>]*[^\/]>|\\s*>)`, 'ig')
while ((match = regex.exec(str)) !== null) {
if (match.groups.slash) {
close += 1
} else {
open += 1
}
}
return open > 0 && open > close
} }

View File

@ -1,7 +1,7 @@
import type { TextDocument, Position } from 'vscode-languageserver' import type { TextDocument, Position } from 'vscode-languageserver'
import { isHtmlDoc, isInsideTag, isVueDoc, isSvelteDoc } from './html'
import { State } from './state' import { State } from './state'
import { jsLanguages } from './languages' import { jsLanguages } from './languages'
import { getLanguageBoundaries } from './getLanguageBoundaries'
export function isJsDoc(state: State, doc: TextDocument): boolean { export function isJsDoc(state: State, doc: TextDocument): boolean {
const userJsLanguages = Object.keys(state.editor.userLanguages).filter((lang) => const userJsLanguages = Object.keys(state.editor.userLanguages).filter((lang) =>
@ -11,23 +11,13 @@ export function isJsDoc(state: State, doc: TextDocument): boolean {
return [...jsLanguages, ...userJsLanguages].indexOf(doc.languageId) !== -1 return [...jsLanguages, ...userJsLanguages].indexOf(doc.languageId) !== -1
} }
export function isJsContext(state: State, doc: TextDocument, position: Position): boolean { export function isJsxContext(state: State, doc: TextDocument, position: Position): boolean {
if (isJsDoc(state, doc)) {
return true
}
let str = doc.getText({ let str = doc.getText({
start: { line: 0, character: 0 }, start: { line: 0, character: 0 },
end: position, end: position,
}) })
if (isHtmlDoc(state, doc) && isInsideTag(str, ['script'])) { let boundaries = getLanguageBoundaries(state, doc, str)
return true
}
if (isVueDoc(doc) || isSvelteDoc(doc)) { return boundaries ? boundaries[boundaries.length - 1].type === 'jsx' : false
return isInsideTag(str, ['script'])
}
return false
} }