import * as fs from 'fs' ; import * as path from 'path' ; import { Minimatch } from 'minimatch' ; import { DidChangeConfigurationNotification , DidChangeWatchedFilesNotification , DidChangeWorkspaceFoldersNotification , InitializeParams , ProposedFeatures , TextDocumentSyncKind , TextDocuments , createConnection , PublishDiagnosticsParams , } from 'vscode-languageserver' ; import { string as isString } from 'vscode-languageserver/lib/utils/is' ; import { WorkDoneProgress } from 'vscode-languageserver/lib/progress' ; import { TextDocument } from 'vscode-languageserver-textdocument' ; import { URI } from 'vscode-uri' ; import { getDefaultRulesetFile } from '@stoplight/spectral/dist/rulesets/loader' ; import { readRuleset } from '@stoplight/spectral/dist/rulesets/reader' ; import { ExtensionSettings , TextDocumentSettings , } from './configuration' ; import { Linter } from './linter' ; import { StartWatcherNotification , ValidateNotification , } from './notifications' ; import { BufferedMessageQueue } from './queue' ; import { makePublishDiagnosticsParams } from './util' ; import { IRuleset } from '@stoplight/spectral/dist/types/ruleset' ; /**
 * The connection on which communication between the extension (client) and the
 * language server occurs.
 */ const connection = createConnection ( ProposedFeatures . all ) ; connection . console . info ( `Spectral v${ Linter . version } server running (Node.js ${ process . version })` ) ; /**
 * Cache of lint-related settings per document.
 */ const documentSettings = new Map < string , Thenable < TextDocumentSettings > > ( ) ; const seenDependencies = new Map < string , string > ( ) ; /**
 * Message queue that ensures validation doesn't occur on stale content.
 */ const messageQueue = new BufferedMessageQueue ( connection ) ; /**
 * Spectral linter.
 */ let linter ; /**
 * Document listener used to raise document-related events.
 */ let documents ; /**
 * Something in the environment changed, so clear the document cache and
 * re-validate all open documents based on whatever new settings are calculated.
 */ function environmentChanged ( ) { connection . console . info ( 'Environment changed; refreshing validation results.' ) ; documentSettings . clear ( ) ; seenDependencies . clear ( ) ; for ( const document of documents . all ( ) ) { connection . sendDiagnostics ( { uri : document . uri , diagnostics : [ ] } ) ; messageQueue . addNotificationMessage ( ValidateNotification . type , document , document . version ) ; } } param { string | TextDocument | URI | undefined } documentOrUri return { string | undefined } function getDocumentPath ( documentOrUri ) { if ( ! documentOrUri ) { return undefined ; } let uri ; if ( isString ( documentOrUri ) ) { uri = URI . parse ( documentOrUri ) ; } else if ( documentOrUri instanceof URI ) { uri = documentOrUri ; } else { uri = URI . parse ( documentOrUri . uri ) ; } if ( uri . scheme !== 'file' ) { return undefined ; } return uri . fsPath ; } param { TextDocument } document return { Thenable < TextDocumentSettings > } function resolveSettings ( document ) { const uri = document . uri ; let resultPromise = documentSettings . get ( uri ) ; if ( resultPromise ) { return resultPromise ; } resultPromise = connection . workspace . getConfiguration ( { scopeUri : uri , section : '' } ) . then ( async ( configuration ) => { const settings = { validate : configuration . enable , run : configuration . run , ruleset : undefined , } ; if ( ! settings . validate ) { return settings ; } if ( configuration . validateLanguages && ! configuration . validateLanguages . some ( ( value ) => document . languageId === value ) ) { connection . console . log ( `File ${ document . uri } (${ document . languageId }) doesn't match any of the specified language types; skipping.` ) ; settings . validate = false ; return settings ; } const docPath = getDocumentPath ( document . uri ) ; if ( ! isString ( docPath ) ) { return settings ; } if ( configuration . validateFiles && configuration . validateFiles . length > 0 && ! configuration . validateFiles . some ( ( value ) => new Minimatch ( value , { matchBase : true } ) . match ( docPath ) ) ) { connection . console . log ( `File ${ document . uri } doesn't match any of the specified file globs; skipping.` ) ; settings . validate = false ; return settings ; } let rulesetFile ; if ( configuration . rulesetFile ) { if ( configuration . workspaceFolder ) { rulesetFile = path . resolve ( getDocumentPath ( configuration . workspaceFolder . uri ) ?? '' , configuration . rulesetFile ) ; } else { rulesetFile = configuration . rulesetFile ; } } else { rulesetFile = await getDefaultRulesetFile ( path . dirname ( docPath ) ) ; } if ( rulesetFile && fs . existsSync ( rulesetFile ) ) { if ( docPath === rulesetFile ) { settings . validate = false ; return settings ; } connection . sendNotification ( StartWatcherNotification . type , { path : rulesetFile } ) ; try { settings . ruleset = await readRuleset ( rulesetFile ) ; } catch ( err ) { showErrorMessage ( docPath , `Unable to read ruleset at ${ rulesetFile }.` ) ; } } else { settings . ruleset = await readRuleset ( Linter . builtInRulesets ) ; } return settings ; } ) ; documentSettings . set ( uri , resultPromise ) ; return resultPromise ; } /**
 * Registers for document events and starts listening for those events.
 */ function setupDocumentListener ( ) { documents . listen ( connection ) ; documents . onDidOpen ( ( event ) => { resolveSettings ( event . document ) . then ( ( settings ) => { if ( ! settings . validate || settings . run !== 'onSave' ) { return ; } messageQueue . addNotificationMessage ( ValidateNotification . type , event . document , event . document . version ) ; } ) ; } ) ; documents . onDidChangeContent ( ( event ) => { resolveSettings ( event . document ) . then ( ( settings ) => { if ( ! settings . validate || settings . run !== 'onType' ) { return ; } messageQueue . addNotificationMessage ( ValidateNotification . type , event . document , event . document . version ) ; } ) ; } ) ; documents . onDidSave ( ( event ) => { resolveSettings ( event . document ) . then ( ( settings ) => { if ( ! settings . validate || settings . run !== 'onSave' ) { return ; } messageQueue . addNotificationMessage ( ValidateNotification . type , event . document , event . document . version ) ; } ) ; } ) ; documents . onDidClose ( ( event ) => { resolveSettings ( event . document ) . then ( ( settings ) => { const uri = event . document . uri ; documentSettings . delete ( uri ) ; seenDependencies . delete ( uri ) ; if ( settings . validate ) { connection . sendDiagnostics ( { uri : uri , diagnostics : [ ] } ) ; } } ) ; } ) ; } param { string } uri param { any } err function showErrorMessage ( uri , err ) { connection . window . showErrorMessage ( `Spectral: An error occurred while validating ${ uri }. Please see the 'Spectral' output channel for details.` ) ; connection . console . error ( `An error occurred while validating document ${ uri }: ${ err }` ) ; } const findRoot = ( document ) : TextDocument => { connection . console . log ( `Scan triggered file ${ document . uri }.` ) ; const rootUri = seenDependencies . get ( document . uri ) ; if ( rootUri === undefined ) { connection . console . log ( `Linting root file ${ document . uri }.` ) ; return document ; } connection . console . log ( `Found root file ${ rootUri }.` ) ; const rootDocument = documents . get ( rootUri ) ; if ( rootDocument === undefined ) { throw new Error ( `Unable to build a document from root '${ rootUri }'` ) ; } connection . console . log ( `Linting inferred root file ${ rootDocument . uri }.` ) ; return rootDocument ; } ; async function lintDocumentOrRoot ( document , ruleset , currentDependencies ) { const rootDocument = findRoot ( document ) ; const results = await linter . lint ( rootDocument , ruleset ) ; const knownDeps = new Set < string > ( ) ; if ( document . uri !== rootDocument . uri ) { knownDeps . add ( document . uri ) ; } for ( const dep of currentDependencies ) { if ( dep [ 1 ] !== rootDocument . uri ) { continue ; } knownDeps . add ( dep [ 0 ] ) ; } connection . console . log ( `[DBG] lintDocumentOrRoot. knownDeps=${ JSON . stringify ( [ ... knownDeps ] ) }` ) ; const pdps = makePublishDiagnosticsParams ( rootDocument . uri , [ ... knownDeps ] , results ) ; const deps = pdps . filter ( ( e ) => e . uri !== rootDocument . uri ) . map < [ string , string ] > ( ( e ) => [ e . uri , rootDocument . uri ] ) ; return [ pdps , deps ] ; } const dump = ( input ) : void => { for ( const [ key , value ] of input . entries ( ) ) { connection . console . log ( key + ' $reffed by ' + value ) ; } connection . console . log ( '-----' ) ; } ; param { TextDocument } document return { Thenable < void > } function validate ( document ) { return resolveSettings ( document ) . then ( async ( settings ) => { if ( ! settings . validate ) { return ; } try { connection . console . log ( `seenDependencies (before): ${ seenDependencies . size }.` ) ; dump ( seenDependencies ) ; const [ pdps , deps ] = await lintDocumentOrRoot ( document , settings . ruleset , seenDependencies ) ; connection . console . log ( `pdps: ${ JSON . stringify ( pdps , null , 2 ) }.` ) ; connection . console . log ( `deps: ${ JSON . stringify ( deps , null , 2 ) }.` ) ; for ( const [ dep , root ] of deps ) { seenDependencies . set ( dep , root ) ; } connection . console . log ( `seenDependencies (after): ${ seenDependencies . size }.` ) ; dump ( seenDependencies ) ; pdps . forEach ( ( pdp ) => connection . sendDiagnostics ( pdp ) ) ; } catch ( err ) { showErrorMessage ( document . uri , err ) ; } } ) ; } process . on ( 'uncaughtException' , ( error ) => { let message ; if ( error ) { if ( typeof error . stack === 'string' ) { message = error . stack ; } else if ( typeof error . message === 'string' ) { message = error . message ; } else if ( typeof error === 'string' ) { message = error ; } if ( message === undefined || message . length === 0 ) { try { message = JSON . stringify ( error , undefined , 4 ) ; } catch ( e ) { } } } console . error ( 'Uncaught exception received.' ) ; if ( message ) { console . error ( message ) ; } } ) ; messageQueue . onNotification ( ValidateNotification . type , ( document ) => { validate ( document ) ; } , ( document ) : number => { return document . version ; } ) ; messageQueue . registerNotification ( DidChangeConfigurationNotification . type , ( _params ) => { environmentChanged ( ) ; } ) ; messageQueue . registerNotification ( DidChangeWorkspaceFoldersNotification . type , ( _params ) => { environmentChanged ( ) ; } ) ; messageQueue . registerNotification ( DidChangeWatchedFilesNotification . type , ( _params ) => { environmentChanged ( ) ; } ) ; connection . onInitialize ( ( _params , _cancel , progress ) => { progress . begin ( 'Initializing Spectral Server' ) ; documents = new TextDocuments ( TextDocument ) ; linter = new Linter ( documents , connection . console ) ; setupDocumentListener ( ) ; progress . done ( ) ; return { capabilities : { textDocumentSync : { openClose : true , change : TextDocumentSyncKind . Full , willSaveWaitUntil : false , save : { includeText : false , } , } , } , } ; } ) ; connection . onInitialized ( ( ) => { connection . client . register ( DidChangeConfigurationNotification . type , undefined ) ; connection . client . register ( DidChangeWorkspaceFoldersNotification . type , undefined ) ; } ) ; connection . listen ( ) ;