2020-04-11 21:20:45 +00:00
import { State } from '../util/state'
import {
CompletionItem ,
CompletionItemKind ,
CompletionParams ,
Range ,
MarkupKind ,
CompletionList ,
} from 'vscode-languageserver'
const dlv = require ( 'dlv' )
import removeMeta from '../util/removeMeta'
import { getColor , getColorFromString } from '../util/color'
2020-04-11 22:34:03 +00:00
import { isHtmlContext } from '../util/html'
import { isCssContext } from '../util/css'
2020-04-11 21:20:45 +00:00
import { findLast , findJsxStrings , arrFindLast } from '../util/find'
2020-04-12 14:48:38 +00:00
import { stringifyConfigValue , stringifyCss } from '../util/stringify'
2020-04-27 21:48:30 +00:00
import { stringifyScreen , Screen } from '../util/screens'
2020-04-11 21:20:45 +00:00
import isObject from '../util/isObject'
2020-04-16 21:39:16 +00:00
import * as emmetHelper from 'emmet-helper'
import { isValidLocationForEmmetAbbreviation } from '../util/isValidLocationForEmmetAbbreviation'
import { getDocumentSettings } from '../util/getDocumentSettings'
import { isJsContext } from '../util/js'
2020-04-27 22:18:28 +00:00
import { naturalExpand } from '../util/naturalExpand'
2020-04-11 21:20:45 +00:00
function completionsFromClassList (
state : State ,
classList : string ,
classListRange : Range
) : CompletionList {
let classNames = classList . split ( /[\s+]/ )
const partialClassName = classNames [ classNames . length - 1 ]
2020-04-27 21:23:22 +00:00
let sep = state . separator
2020-04-11 21:20:45 +00:00
let parts = partialClassName . split ( sep )
let subset : any
2020-04-13 00:44:43 +00:00
let subsetKey : string [ ] = [ ]
2020-04-11 21:20:45 +00:00
let isSubset : boolean = false
let replacementRange = {
. . . classListRange ,
start : {
. . . classListRange . start ,
character : classListRange.end.character - partialClassName . length ,
} ,
}
for ( let i = parts . length - 1 ; i > 0 ; i -- ) {
let keys = parts . slice ( 0 , i ) . filter ( Boolean )
subset = dlv ( state . classNames . classNames , keys )
if ( typeof subset !== 'undefined' && typeof subset . __rule === 'undefined' ) {
isSubset = true
2020-04-13 00:44:43 +00:00
subsetKey = keys
2020-04-11 21:20:45 +00:00
replacementRange = {
. . . replacementRange ,
start : {
. . . replacementRange . start ,
character :
replacementRange . start . character +
keys . join ( sep ) . length +
sep . length ,
} ,
}
break
}
}
return {
isIncomplete : false ,
items : Object.keys ( isSubset ? subset : state.classNames.classNames ) . map (
2020-04-27 22:18:28 +00:00
( className , index ) = > {
2020-04-27 21:14:47 +00:00
let label = className
2020-04-11 21:20:45 +00:00
let kind : CompletionItemKind = CompletionItemKind . Constant
let documentation : string = null
2020-04-27 21:14:47 +00:00
let command : any
2020-04-27 22:18:28 +00:00
let sortText = naturalExpand ( index )
2020-04-13 00:44:43 +00:00
if ( isContextItem ( state , [ . . . subsetKey , className ] ) ) {
2020-04-11 21:20:45 +00:00
kind = CompletionItemKind . Module
2020-04-27 21:14:47 +00:00
command = { title : '' , command : 'editor.action.triggerSuggest' }
2020-04-27 21:23:22 +00:00
label += sep
2020-04-27 22:18:28 +00:00
sortText = '-' + sortText // move to top
2020-04-11 21:20:45 +00:00
} else {
const color = getColor ( state , [ className ] )
if ( color ) {
kind = CompletionItemKind . Color
documentation = color
}
}
return {
2020-04-27 21:14:47 +00:00
label ,
2020-04-11 21:20:45 +00:00
kind ,
documentation ,
2020-04-27 21:14:47 +00:00
command ,
2020-04-27 22:18:28 +00:00
sortText ,
2020-04-13 00:44:43 +00:00
data : [ . . . subsetKey , className ] ,
2020-04-11 21:20:45 +00:00
textEdit : {
2020-04-27 21:14:47 +00:00
newText : label ,
2020-04-11 21:20:45 +00:00
range : replacementRange ,
} ,
}
}
) ,
}
}
function provideClassAttributeCompletions (
state : State ,
{ context , position , textDocument } : CompletionParams
) : CompletionList {
let doc = state . editor . documents . get ( textDocument . uri )
let str = doc . getText ( {
start : { line : Math.max ( position . line - 10 , 0 ) , character : 0 } ,
end : position ,
} )
const match = findLast ( /\bclass(?:Name)?=(?<initial>['"`{])/gi , str )
if ( match === null ) {
return null
}
const rest = str . substr ( match . index + match [ 0 ] . length )
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 ,
character : position.character - classList . length ,
} ,
end : position ,
} )
}
return null
}
if ( rest . indexOf ( match . groups . initial ) !== - 1 ) {
return null
}
return completionsFromClassList ( state , rest , {
start : {
line : position.line ,
character : position.character - rest . length ,
} ,
end : position ,
} )
}
function provideAtApplyCompletions (
state : State ,
{ context , position , textDocument } : CompletionParams
) : CompletionList {
let doc = state . editor . documents . get ( textDocument . uri )
let str = doc . getText ( {
start : { line : Math.max ( position . line - 30 , 0 ) , character : 0 } ,
end : position ,
} )
const match = findLast ( /@apply\s+(?<classList>[^;}]*)$/gi , str )
if ( match === null ) {
return null
}
const classList = match . groups . classList
return completionsFromClassList ( state , classList , {
start : {
line : position.line ,
character : position.character - classList . length ,
} ,
end : position ,
} )
}
function provideClassNameCompletions (
state : State ,
params : CompletionParams
) : CompletionList {
let doc = state . editor . documents . get ( params . textDocument . uri )
2020-04-16 21:39:16 +00:00
if (
isHtmlContext ( doc , params . position ) ||
isJsContext ( doc , params . position )
) {
2020-04-11 21:20:45 +00:00
return provideClassAttributeCompletions ( state , params )
}
2020-04-11 22:34:03 +00:00
if ( isCssContext ( doc , params . position ) ) {
2020-04-11 21:20:45 +00:00
return provideAtApplyCompletions ( state , params )
}
return null
}
function provideCssHelperCompletions (
state : State ,
{ position , textDocument } : CompletionParams
) : CompletionList {
let doc = state . editor . documents . get ( textDocument . uri )
2020-04-11 22:34:03 +00:00
if ( ! isCssContext ( doc , position ) ) {
2020-04-11 21:20:45 +00:00
return null
}
let text = doc . getText ( {
start : { line : position.line , character : 0 } ,
// read one extra character so we can see if it's a ] later
end : { line : position.line , character : position.character + 1 } ,
} )
const match = text
. substr ( 0 , text . length - 1 ) // don't include that extra character from earlier
. match ( /\b(?<helper>config|theme)\(['"](?<keys>[^'"]*)$/ )
if ( match === null ) {
return null
}
let base =
match . groups . helper === 'config'
? state . config
: dlv ( state . config , 'theme' , { } )
let parts = match . groups . keys . split ( /([\[\].]+)/ )
let keys = parts . filter ( ( _ , i ) = > i % 2 === 0 )
let separators = parts . filter ( ( _ , i ) = > i % 2 !== 0 )
// let obj =
// keys.length === 1 ? base : dlv(base, keys.slice(0, keys.length - 1), {})
// if (!isObject(obj)) return null
function totalLength ( arr : string [ ] ) : number {
return arr . reduce ( ( acc , cur ) = > acc + cur . length , 0 )
}
let obj : any
let offset : number = 0
let separator : string = separators . length
? separators [ separators . length - 1 ]
: null
if ( keys . length === 1 ) {
obj = base
} else {
for ( let i = keys . length - 1 ; i > 0 ; i -- ) {
let o = dlv ( base , keys . slice ( 0 , i ) )
if ( isObject ( o ) ) {
obj = o
offset = totalLength ( parts . slice ( i * 2 ) )
separator = separators [ i - 1 ]
break
}
}
}
if ( ! obj ) return null
return {
isIncomplete : false ,
2020-04-27 22:18:28 +00:00
items : Object.keys ( obj ) . map ( ( item , index ) = > {
2020-04-11 21:20:45 +00:00
let color = getColorFromString ( obj [ item ] )
const replaceDot : boolean =
item . indexOf ( '.' ) !== - 1 && separator && separator . endsWith ( '.' )
const insertClosingBrace : boolean =
text . charAt ( text . length - 1 ) !== ']' &&
( replaceDot || ( separator && separator . endsWith ( '[' ) ) )
return {
label : item ,
filterText : ` ${ replaceDot ? '.' : '' } ${ item } ` ,
2020-04-27 22:18:28 +00:00
sortText : naturalExpand ( index ) ,
2020-04-11 21:20:45 +00:00
kind : color
? CompletionItemKind . Color
: isObject ( obj [ item ] )
? CompletionItemKind . Module
: CompletionItemKind . Property ,
detail : stringifyConfigValue ( obj [ item ] ) ,
documentation : color ,
textEdit : {
newText : ` ${ replaceDot ? '[' : '' } ${ item } ${
insertClosingBrace ? ']' : ''
} ` ,
range : {
start : {
line : position.line ,
character :
position . character -
keys [ keys . length - 1 ] . length -
( replaceDot ? 1 : 0 ) -
offset ,
} ,
end : position ,
} ,
} ,
data : 'helper' ,
}
} ) ,
}
}
2020-04-27 22:52:31 +00:00
// TODO: vary items based on Tailwind version
function provideTailwindDirectiveCompletions (
state : State ,
{ position , textDocument } : CompletionParams
) : CompletionList {
let doc = state . editor . documents . get ( textDocument . uri )
if ( ! isCssContext ( doc , position ) ) {
return null
}
let text = doc . getText ( {
start : { line : position.line , character : 0 } ,
end : position ,
} )
const match = text . match ( /^\s*@tailwind\s+(?<partial>[^\s]*)$/i )
if ( match === null ) return null
return {
isIncomplete : false ,
items : [
{
label : 'base' ,
documentation : {
kind : MarkupKind.Markdown ,
value :
'This injects Tailwind’ s base styles and any base styles registered by plugins.\n\n[Tailwind CSS Documentation](https://tailwindcss.com/docs/functions-and-directives/#tailwind)' ,
} ,
} ,
{
label : 'components' ,
documentation : {
kind : MarkupKind.Markdown ,
value :
'This injects Tailwind’ s component classes and any component classes registered by plugins.\n\n[Tailwind CSS Documentation](https://tailwindcss.com/docs/functions-and-directives/#tailwind)' ,
} ,
} ,
{
label : 'utilities' ,
documentation : {
kind : MarkupKind.Markdown ,
value :
'This injects Tailwind’ s utility classes and any utility classes registered by plugins.\n\n[Tailwind CSS Documentation](https://tailwindcss.com/docs/functions-and-directives/#tailwind)' ,
} ,
} ,
{
label : 'screens' ,
documentation : {
kind : MarkupKind.Markdown ,
value :
'Use this directive to control where Tailwind injects the responsive variations of each utility.\n\nIf omitted, Tailwind will append these classes to the very end of your stylesheet by default.\n\n[Tailwind CSS Documentation](https://tailwindcss.com/docs/functions-and-directives/#tailwind)' ,
} ,
} ,
] . map ( ( item ) = > ( {
. . . item ,
kind : CompletionItemKind.Constant ,
textEdit : {
newText : item.label ,
range : {
start : {
line : position.line ,
character : position.character - match . groups . partial . length ,
} ,
end : position ,
} ,
} ,
} ) ) ,
}
}
2020-04-12 22:48:57 +00:00
function provideVariantsDirectiveCompletions (
state : State ,
{ position , textDocument } : CompletionParams
) : CompletionList {
let doc = state . editor . documents . get ( textDocument . uri )
if ( ! isCssContext ( doc , position ) ) {
return null
}
let text = doc . getText ( {
start : { line : position.line , character : 0 } ,
end : position ,
} )
const match = text . match ( /^\s*@variants\s+(?<partial>[^}]*)$/i )
if ( match === null ) return null
const parts = match . groups . partial . split ( /\s*,\s*/ )
if ( /\s+/ . test ( parts [ parts . length - 1 ] ) ) return null
const existingVariants = parts . slice ( 0 , parts . length - 1 )
return {
isIncomplete : false ,
2020-04-16 21:44:52 +00:00
items : state.variants
2020-04-12 22:48:57 +00:00
. filter ( ( v ) = > existingVariants . indexOf ( v ) === - 1 )
. map ( ( variant ) = > ( {
// TODO: detail
label : variant ,
kind : CompletionItemKind.Constant ,
data : 'variant' ,
textEdit : {
newText : variant ,
range : {
start : {
line : position.line ,
character : position.character - parts [ parts . length - 1 ] . length ,
} ,
end : position ,
} ,
} ,
} ) ) ,
}
}
2020-04-12 17:11:41 +00:00
function provideScreenDirectiveCompletions (
state : State ,
{ position , textDocument } : CompletionParams
) : CompletionList {
let doc = state . editor . documents . get ( textDocument . uri )
if ( ! isCssContext ( doc , position ) ) {
return null
}
let text = doc . getText ( {
start : { line : position.line , character : 0 } ,
end : position ,
} )
const match = text . match ( /^\s*@screen\s+(?<partial>[^\s]*)$/i )
if ( match === null ) return null
const screens = dlv (
state . config ,
[ 'screens' ] ,
dlv ( state . config , [ 'theme' , 'screens' ] , { } )
)
if ( ! isObject ( screens ) ) return null
return {
isIncomplete : false ,
2020-04-27 22:18:28 +00:00
items : Object.keys ( screens ) . map ( ( screen , index ) = > ( {
2020-04-12 17:11:41 +00:00
label : screen ,
kind : CompletionItemKind.Constant ,
2020-04-27 21:48:30 +00:00
data : 'screen' ,
2020-04-27 22:18:28 +00:00
sortText : naturalExpand ( index ) ,
2020-04-12 17:11:41 +00:00
textEdit : {
newText : screen ,
range : {
start : {
line : position.line ,
character : position.character - match . groups . partial . length ,
} ,
end : position ,
} ,
} ,
} ) ) ,
}
}
2020-04-12 16:55:32 +00:00
function provideCssDirectiveCompletions (
state : State ,
{ position , textDocument } : CompletionParams
) : CompletionList {
let doc = state . editor . documents . get ( textDocument . uri )
if ( ! isCssContext ( doc , position ) ) {
return null
}
let text = doc . getText ( {
start : { line : position.line , character : 0 } ,
end : position ,
} )
const match = text . match ( /^\s*@(?<partial>[a-z]*)$/i )
if ( match === null ) return null
const items : CompletionItem [ ] = [
{
label : '@tailwind' ,
documentation : {
kind : MarkupKind.Markdown ,
value :
'Use the `@tailwind` directive to insert Tailwind’ s `base`, `components`, `utilities` and `screens` styles into your CSS.\n\n[Tailwind CSS Documentation](https://tailwindcss.com/docs/functions-and-directives#tailwind)' ,
} ,
} ,
{
label : '@variants' ,
documentation : {
kind : MarkupKind.Markdown ,
value :
'You can generate `responsive`, `hover`, `focus`, `active`, and `group-hover` versions of your own utilities by wrapping their definitions in the `@variants` directive.\n\n[Tailwind CSS Documentation](https://tailwindcss.com/docs/functions-and-directives#variants)' ,
} ,
} ,
{
label : '@responsive' ,
documentation : {
kind : MarkupKind.Markdown ,
value :
'You can generate responsive variants of your own classes by wrapping their definitions in the `@responsive` directive.\n\n[Tailwind CSS Documentation](https://tailwindcss.com/docs/functions-and-directives#responsive)' ,
} ,
} ,
{
label : '@screen' ,
documentation : {
kind : MarkupKind.Markdown ,
value :
'The `@screen` directive allows you to create media queries that reference your breakpoints by name instead of duplicating their values in your own CSS.\n\n[Tailwind CSS Documentation](https://tailwindcss.com/docs/functions-and-directives#screen)' ,
} ,
} ,
{
label : '@apply' ,
documentation : {
kind : MarkupKind.Markdown ,
value :
'Use `@apply` to inline any existing utility classes into your own custom CSS.\n\n[Tailwind CSS Documentation](https://tailwindcss.com/docs/functions-and-directives#apply)' ,
} ,
} ,
]
return {
isIncomplete : false ,
items : items.map ( ( item ) = > ( {
. . . item ,
kind : CompletionItemKind.Keyword ,
data : 'directive' ,
textEdit : {
newText : item.label ,
range : {
start : {
line : position.line ,
character : position.character - match . groups . partial . length - 1 ,
} ,
end : position ,
} ,
} ,
} ) ) ,
}
}
2020-04-16 21:39:16 +00:00
async function provideEmmetCompletions (
state : State ,
{ position , textDocument } : CompletionParams
) : Promise < CompletionList > {
let settings = await getDocumentSettings ( state , textDocument . uri )
if ( settings . emmetCompletions !== true ) return null
let doc = state . editor . documents . get ( textDocument . uri )
const syntax = isHtmlContext ( doc , position )
? 'html'
: isJsContext ( doc , position )
? 'jsx'
: null
if ( syntax === null ) {
return null
}
const extractAbbreviationResults = emmetHelper . extractAbbreviation (
doc ,
position ,
true
)
if (
! extractAbbreviationResults ||
! emmetHelper . isAbbreviationValid (
syntax ,
extractAbbreviationResults . abbreviation
)
) {
return null
}
if (
! isValidLocationForEmmetAbbreviation (
doc ,
extractAbbreviationResults . abbreviationRange
)
) {
return null
}
const emmetItems = emmetHelper . doComplete ( doc , position , syntax , { } )
if ( ! emmetItems || ! emmetItems . items || emmetItems . items . length !== 1 ) {
return null
}
// https://github.com/microsoft/vscode/issues/86941
if ( emmetItems . items [ 0 ] . label === 'widows: ;' ) {
return null
}
const parts = emmetItems . items [ 0 ] . label . split ( '.' )
if ( parts . length < 2 ) return null
return completionsFromClassList ( state , parts [ parts . length - 1 ] , {
start : {
line : position.line ,
character : position.character - parts [ parts . length - 1 ] . length ,
} ,
end : position ,
} )
}
export async function provideCompletions (
2020-04-11 21:20:45 +00:00
state : State ,
params : CompletionParams
2020-04-16 21:39:16 +00:00
) : Promise < CompletionList > {
2020-04-11 21:20:45 +00:00
if ( state === null ) return { items : [ ] , isIncomplete : false }
2020-04-16 21:39:16 +00:00
const result =
2020-04-11 21:20:45 +00:00
provideClassNameCompletions ( state , params ) ||
2020-04-12 16:55:32 +00:00
provideCssHelperCompletions ( state , params ) ||
2020-04-12 17:11:41 +00:00
provideCssDirectiveCompletions ( state , params ) ||
2020-04-12 22:48:57 +00:00
provideScreenDirectiveCompletions ( state , params ) ||
2020-04-27 22:52:31 +00:00
provideVariantsDirectiveCompletions ( state , params ) ||
provideTailwindDirectiveCompletions ( state , params )
2020-04-16 21:39:16 +00:00
if ( result ) return result
return provideEmmetCompletions ( state , params )
2020-04-11 21:20:45 +00:00
}
export function resolveCompletionItem (
state : State ,
item : CompletionItem
) : CompletionItem {
2020-04-27 21:48:30 +00:00
if ( [ 'helper' , 'directive' , 'variant' ] . includes ( item . data ) ) {
return item
}
if ( item . data === 'screen' ) {
let screens = dlv (
state . config ,
[ 'theme' , 'screens' ] ,
dlv ( state . config , [ 'screens' ] , { } )
)
if ( ! isObject ( screens ) ) screens = { }
item . detail = stringifyScreen ( screens [ item . label ] as Screen )
2020-04-11 21:20:45 +00:00
return item
}
2020-04-13 00:44:43 +00:00
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 ( ', ' )
2020-04-11 21:20:45 +00:00
} else {
item . detail = getCssDetail ( state , className )
if ( ! item . documentation ) {
2020-04-13 00:44:43 +00:00
const css = stringifyCss ( item . data . join ( ':' ) , className )
if ( css ) {
item . documentation = {
kind : MarkupKind.Markdown ,
value : [ '```css' , css , '```' ] . join ( '\n' ) ,
}
2020-04-11 21:20:45 +00:00
}
}
}
return item
}
function isContextItem ( state : State , keys : string [ ] ) : boolean {
const item = dlv ( state . classNames . classNames , keys )
return Boolean (
2020-04-13 00:44:43 +00:00
isObject ( item ) &&
! item . __rule &&
2020-04-11 21:20:45 +00:00
! Array . isArray ( item ) &&
state . classNames . context [ keys [ keys . length - 1 ] ]
)
}
function stringifyDecls ( obj : any ) : string {
return Object . keys ( obj )
. map ( ( prop ) = > {
return ` ${ prop } : ${ obj [ prop ] } ; `
} )
. join ( ' ' )
}
function getCssDetail ( state : State , className : any ) : string {
if ( Array . isArray ( className ) ) {
return ` ${ className . length } rules `
}
2020-04-13 00:44:43 +00:00
if ( className . __rule === true ) {
return stringifyDecls ( removeMeta ( className ) )
2020-04-11 21:20:45 +00:00
}
2020-04-13 00:44:43 +00:00
return null
2020-04-11 21:20:45 +00:00
}