import fetch from 'node-fetch' ; import { parse , walk } from 'css-tree' ; import { basename , dirname , extname , isAbsolute , join } from 'path' ; import { CompletionItem , CompletionItemKind , CompletionItemProvider , Diagnostic , DiagnosticSeverity , Disposable , Position , ProviderResult , Range , TextDocument , Uri , workspace , } from 'coc.nvim' ; export type Context = { ids : Map < string , CompletionItem > ; classes : Map < string , CompletionItem > ; } ; export class SelectorCompletionItemProvider implements CompletionItemProvider , Disposable { readonly start = Position . create ( 0 , 0 ) ; readonly cache = new Map < string , CompletionItem [ ] > ( ) ; readonly files = new Map < string , string > ( ) ; readonly watchers = new Map < string , Disposable > ( ) ; readonly isRemote = /^https?:\/\//i ; readonly canComplete = /(id|class|className)\s*=\s*("|')(?:(?!\2).)*$/is ; readonly findLinkRel = /rel\s*=\s*("|')((?:(?!\1).)+)\1/is ; readonly findLinkHref = /href\s*=\s*("|')((?:(?!\1).)+)\1/is ; readonly findExtended = /(?:{{<|{{>|{%\s*extends|@extends\s*\()\s*("|')?([./A-Za-z_0-9\\\-]+)\1\s*(?:\)|%}|}})/i ; dispose ( ) { this . watchers . forEach ( ( e ) => e . dispose ( ) ) ; this . watchers . clear ( ) ; this . cache . clear ( ) ; this . files . clear ( ) ; } watchFile ( path , listener ) { if ( this . watchers . has ( path ) ) { return ; } const watcher = workspace . createFileSystemWatcher ( path ) ; watcher . onDidCreate ( listener ) ; watcher . onDidChange ( listener ) ; watcher . onDidDelete ( listener ) ; this . watchers . set ( path , watcher ) ; } getStyleSheets ( uri ) { return workspace . getConfiguration ( 'html-css-support' , uri . toString ( ) ) . get < string [ ] > ( 'styleSheets' , [ ] ) ; } getPath ( uri , path , ext ? ) { const folder = workspace . getWorkspaceFolder ( uri . toString ( ) ) ; const name = ext ? join ( dirname ( path ) , basename ( path , ext ) + ext ) : path ; return folder ? join ( isAbsolute ( path ) ? folder . uri : dirname ( uri . fsPath ) , name ) : join ( dirname ( uri . fsPath ) , name ) ; } parseTextToItems ( text , items ) { walk ( parse ( text ) , ( node ) => { let kind ; switch ( node . type ) { case 'ClassSelector' : kind = CompletionItemKind . Enum ; break ; case 'IdSelector' : kind = CompletionItemKind . Value ; break ; default : return ; } const resultCompletionItem = { label : node . name , kind , } ; items . push ( resultCompletionItem ) ; } ) ; } async fetchLocal ( path ) { if ( this . cache . has ( path ) ) { return ; } const items = [ ] ; try { const content = await workspace . readFile ( path ) ; this . parseTextToItems ( content . toString ( ) , items ) ; } catch ( error ) { } this . cache . set ( path , items ) ; this . watchFile ( path , ( ) => this . cache . delete ( path ) ) ; } async fetchRemote ( path ) { if ( this . cache . has ( path ) ) { return ; } const items = [ ] ; try { const res = await fetch ( path ) ; if ( res . ok ) { const text = await res . text ( ) ; this . parseTextToItems ( text , items ) ; } } catch ( error ) { } this . cache . set ( path , items ) ; } async fetch ( uri , path ) { if ( this . isRemote . test ( path ) ) { await this . fetchRemote ( path ) ; } else { const base = basename ( uri . fsPath , extname ( uri . fsPath ) ) ; path = this . getPath ( uri , path . replace ( /\${\s*fileBasenameNoExtension\s*}/ , base ) ) ; await this . fetchLocal ( path ) ; } return path ; } findEmbedded ( uri , keys , text ) { const key = uri . toString ( ) ; const items = [ ] ; const findStyles = /<style[^>]*>([^<]+)<\/style>/gi ; let style ; while ( ( style = findStyles . exec ( text ) ) !== null ) { this . parseTextToItems ( style [ 1 ] , items ) ; } this . cache . set ( key , items ) ; keys . add ( key ) ; } async findFixed ( uri , keys ) { for ( const key of this . getStyleSheets ( uri ) ) { keys . add ( await this . fetch ( uri , key ) ) ; } } async findLinks ( uri , keys , text ) { const findLinks = /<link([^>]+)>/gi ; let link , rel , href ; while ( ( link = findLinks . exec ( text ) ) !== null ) { rel = this . findLinkRel . exec ( link [ 1 ] ) ; if ( rel && rel [ 2 ] === 'stylesheet' ) { href = this . findLinkHref . exec ( link [ 1 ] ) ; if ( href ) { keys . add ( await this . fetch ( uri , href [ 2 ] ) ) ; } } } } async findInherited ( uri , keys , text , level = 0 ) { const extended = this . findExtended . exec ( text ) ; if ( extended && level < 3 ) { level ++ ; const name = extended [ 2 ] ; const ext = extname ( name ) || extname ( uri . fsPath ) ; const path = this . getPath ( uri , name , ext ) ; const file = Uri . file ( path ) ; let text = this . files . get ( path ) ; if ( ! text ) { try { text = ( await workspace . readFile ( file . toString ( ) ) ) . toString ( ) ; } catch ( error ) { text = 'n/a' ; } this . files . set ( path , text ) ; this . watchFile ( path , ( ) => this . files . delete ( path ) ) ; } this . findEmbedded ( file , keys , text ) ; await this . findLinks ( file , keys , text ) ; await this . findInherited ( file , keys , text , level ) ; } } async findAll ( document ) { const keys = new Set < string > ( ) ; const uri = document . uri ; const text = document . getText ( ) ; this . findEmbedded ( Uri . parse ( uri ) , keys , text ) ; await this . findFixed ( Uri . parse ( uri ) , keys ) ; await this . findLinks ( Uri . parse ( uri ) , keys , text ) ; await this . findInherited ( Uri . parse ( uri ) , keys , text ) ; const ids = new Map < string , CompletionItem > ( ) ; const classes = new Map < string , CompletionItem > ( ) ; keys . forEach ( ( key ) => this . cache . get ( key ) ?. forEach ( ( e ) => { const res = e . kind === CompletionItemKind . Value ? ids : classes ; e . label = e . label . replace ( '\\' , '' ) ; e . insertText = e . label . replace ( '\\' , '' ) ; res . set ( e . label , e ) ; } ) ) ; return { ids , classes } ; } async validate ( document ) { const context = await this . findAll ( document ) ; const text = document . getText ( ) ; const diagnostics = [ ] ; const findAttribute = /(id|class|className)\s*=\s*("|')(.*?)\2/gis ; let attribute , offset , findSelector , value , anchor , end , start ; while ( ( attribute = findAttribute . exec ( text ) ) !== null ) { offset = findAttribute . lastIndex - attribute [ 3 ] . length + attribute [ 3 ] . indexOf ( attribute [ 2 ] ) ; findSelector = /([^(\[{}\])\s]+)(?![^(\[{]*[}\])])/gi ; while ( ( value = findSelector . exec ( attribute [ 3 ] ) ) !== null ) { if ( ! ( attribute [ 1 ] === 'id' ? context . ids : context . classes ) . has ( value [ 1 ] ) ) { anchor = findSelector . lastIndex + offset ; end = document . positionAt ( anchor ) ; start = document . positionAt ( anchor - value [ 1 ] . length ) ; diagnostics . push ( Diagnostic . create ( Range . create ( start , end ) , `CSS selector '${ value [ 1 ] }' not found.` , DiagnosticSeverity . Information ) ) ; } } } return diagnostics ; } provideCompletionItems ( document , position ) { return new Promise ( ( resolve ) => { const range = Range . create ( this . start , position ) ; const text = document . getText ( range ) ; const canComplete = this . canComplete . exec ( text ) ; if ( canComplete ) { this . findAll ( document ) . then ( ( context ) => resolve ( [ ... ( canComplete [ 1 ] === 'id' ? context . ids : context . classes ) . values ( ) ] ) ) ; } else { resolve ( [ ] ) ; } } ) ; } }