char/avril
(wip) why'd you have to go and make things so complicaaaaated?
git clone https://git.t4t.associates/char/avril
c7a9569
main
1const assert = require ( "node:assert/strict" ); 2const test = require ( "node:test" ); 3const fs = require ( "node:fs" ); 4const path = require ( "node:path" ); 5const vm = require ( "node:vm" ); 6const { setImmediate} = require ( "node:timers/promises" ); 7 8function event () { 9const listeners = new Set (); 10return { 11listen ( callback ) { listeners . add ( callback ); return { dispose :() => listeners . delete ( callback ) }; }, 12fire :( ...args ) => Promise . all ([ ...listeners ]. map (( listener ) => listener ( ...args ))), 13}; 14} 15 16function fixture ( options = {}) { 17const uri = ( file ) => ({ scheme :"file" , fsPath :file , toString :() => `file:// ${ file } ` }); 18const root = path . resolve ( "workspace" ); 19const folder = ( file ) => ({ uri :uri ( file ) }); 20const config = { backend :"/native/tsc" , executable :"avril" , options :[], autoStart :true , ...options . config }; 21const configurations = []; 22const errors = []; 23const commands = new Map (); 24const executedCommands = []; 25const contentProviders = new Map (); 26const clients = []; 27const watchers = []; 28const configs = new Set ( options . files || [ path . join ( root , "avril.conf" )]); 29const trust = event (); 30const folders = event (); 31const settings = event (); 32const State = { Stopped :1 , Starting :2 , Running :3 }; 33const controls = { failStart :false , startGate :undefined }; 34const workspace = { 35isTrusted :options . trusted ?? true , 36workspaceFolders :[ folder ( root )], 37fs :{ stat :async ( uri ) => { 38if ( ! configs . has ( uri . fsPath )) throw Object . assign ( new Error ( "Missing" ), { code :"FileNotFound" }); 39return { type :1 }; 40} }, 41getConfiguration :() => ({ get :( key , fallback ) => config [ key ] ?? fallback }), 42getWorkspaceFolder :( uri ) => workspace . workspaceFolders . filter (( folder ) => { 43const relative = path . relative ( folder . uri . fsPath , uri . fsPath ); 44return relative === "" || ( ! relative . startsWith ( ".." ) && ! path . isAbsolute ( relative )); 45}). sort (( a , b ) => b . uri . fsPath . length - a . uri . fsPath . length )[ 0 ], 46onDidGrantWorkspaceTrust :trust . listen , 47onDidChangeWorkspaceFolders :folders . listen , 48onDidChangeConfiguration :settings . listen , 49registerTextDocumentContentProvider :( scheme , provider ) => { 50contentProviders . set ( scheme , provider ); 51return { dispose :() => contentProviders . delete ( scheme ) }; 52}, 53createFileSystemWatcher :( pattern ) => { 54const created = event (); 55const deleted = event (); 56const watcher = { 57 pattern, created, deleted, disposed :false , 58onDidCreate :created . listen , onDidDelete :deleted . listen , 59dispose () { this . disposed = true ; }, 60}; 61watchers . push ( watcher ); 62return watcher ; 63}, 64}; 65const vscode = { 66 workspace, 67Uri :{ 68joinPath :( base , ...parts ) => uri ( path . join ( base . fsPath , ...parts )), 69parse :( value ) => { 70const parsed = new URL ( value ); 71return { 72scheme :parsed . protocol . slice ( 0 , - 1 ), 73fsPath :parsed . protocol === "file:" ?decodeURIComponent ( parsed . pathname ) :parsed . pathname , 74toString :() => parsed . href , 75}; 76}, 77}, 78FileType :{ File :1 }, 79RelativePattern :class { constructor ( folder , pattern ) { this . folder = folder ; this . pattern = pattern ; } }, 80extensions :{ getExtension :() => options . noTypeScript ?undefined :{ 81activate :async () => ({ getAPI :() => ({ configurePlugin :( name , configuration ) => { 82configurations . push ({ name, ...JSON . parse ( JSON . stringify ( configuration )) }); 83} }) }), 84} }, 85commands :{ 86registerCommand :( name , handler ) => { 87commands . set ( name , handler ); 88return { dispose :() => commands . delete ( name ) }; 89}, 90executeCommand :async ( ...args ) => { 91executedCommands . push ( args ); 92return "executed" ; 93}, 94}, 95window :{ showErrorMessage :( message ) => errors . push ( message ) }, 96}; 97class LanguageClient { 98constructor ( id , name , server , clientOptions ) { 99this . server = server ; 100this . options = clientOptions ; 101this . requests = []; 102this . notifications = []; 103this . sendNotification = async ( method , params ) => { this . notifications . push ({ method, params}); }; 104this . protocol2CodeConverter = { 105asUri :( value ) => vscode . Uri . parse ( value ), 106asPosition :( value ) => ({ kind :"Position" , ...value }), 107asLocation :( value ) => ({ 108kind :"Location" , 109uri :vscode . Uri . parse ( value . uri ), 110range :value . range , 111}), 112}; 113this . sendRequest = async ( method , params ) => { 114this . requests . push ({ method, params}); 115return "contents" ; 116}; 117this . state = State . Stopped ; 118this . changed = event (); 119this . onDidChangeState = this . changed . listen ; 120clients . push ( this ); 121} 122async transition ( state ) { this . state = state ; await this . changed . fire ({ newState :state }); } 123async start () { 124await this . transition ( State . Starting ); 125await controls . startGate ; 126if ( controls . failStart ) throw new Error ( "Startup failed" ); 127await this . transition ( State . Running ); 128} 129needsStop () { return this . state !== State . Stopped ; } 130async stop () { await this . transition ( State . Stopped ); } 131} 132const module = { exports :{} }; 133vm . runInNewContext ( fs . readFileSync ( path . join ( __dirname , "../extension.cjs" ), "utf8" ), { 134 module, 135require ( name ) { 136if ( name === "vscode" ) return vscode ; 137if ( name === "vscode-languageclient/node" ) return { LanguageClient, State}; 138return require ( name ); 139}, 140}); 141const context = { subscriptions :[] }; 142return { 143 ...module . exports , context, root, uri, folder, config, configs, configurations, errors, 144 commands, executedCommands, contentProviders, clients, watchers, workspace, trust, folders, settings, controls, State, 145parseUri :( value ) => vscode . Uri . parse ( value ), 146activate :() => module . exports . activate ( context ), 147changed :() => settings . fire ({ affectsConfiguration :() => true }), 148start :() => commands . get ( "avril.startLanguageServer" )(), 149stop :() => commands . get ( "avril.stopLanguageServer" )(), 150}; 151} 152 153test ( "root configuration auto-starts and mutes only the active workspace" , async ( t ) => { 154const f = fixture (); 155t . after ( f . deactivate ); 156f . workspace . workspaceFolders . push ( f . folder ( path . resolve ( "other" ))); 157await f . activate (); 158assert . equal ( f . clients . length , 1 ); 159assert . equal ( f . clients [ 0 ]. server . options . cwd , f . root ); 160assert . deepEqual ( f . configurations . at ( - 1 ). liveRoots , [ f . root ]); 161assert . deepEqual ( f . configurations . at ( - 1 ). workspaceRoots , [ f . root , path . resolve ( "other" )]); 162await f . stop (); 163assert . deepEqual ( f . configurations . at ( - 1 ). liveRoots , []); 164await f . changed (); 165assert . equal ( f . clients . length , 1 , "explicit stop must survive configuration events" ); 166await f . start (); 167assert . equal ( f . clients . length , 2 ); 168}); 169 170test ( "nested configs and configs in other workspace folders do not claim the first folder" , async ( t ) => { 171const f = fixture ({ files :[ path . resolve ( "workspace/nested/avril.conf" ), path . resolve ( "other/avril.conf" )] }); 172t . after ( f . deactivate ); 173f . workspace . workspaceFolders . push ( f . folder ( path . resolve ( "other" ))); 174await f . activate (); 175await f . watchers [ 0 ]. created . fire ( f . uri ( path . resolve ( "workspace/nested/avril.conf" ))); 176assert . equal ( f . clients . length , 0 ); 177assert . equal ( f . configurations . length , 0 ); 178}); 179 180test ( "root creation and removal start and stop an automatic server" , async ( t ) => { 181const f = fixture ({ files :[] }); 182t . after ( f . deactivate ); 183await f . activate (); 184const config = f . uri ( path . join ( f . root , "avril.conf" )); 185f . configs . add ( config . fsPath ); 186await f . watchers [ 0 ]. created . fire ( config ); 187assert . equal ( f . clients . length , 1 ); 188f . configs . delete ( config . fsPath ); 189await f . watchers [ 0 ]. deleted . fire ( config ); 190assert . equal ( f . clients [ 0 ]. state , f . State . Stopped ); 191assert . deepEqual ( f . configurations . at ( - 1 ). liveRoots , []); 192}); 193 194test ( "trust, backend configuration, and autoStart opt-out gate automatic startup" , async ( t ) => { 195const f = fixture ({ trusted :false , config :{ backend :"" , autoStart :false } }); 196t . after ( f . deactivate ); 197await f . activate (); 198await assert . rejects ( f . start (), / trusted / ); 199f . workspace . isTrusted = true ; 200await f . trust . fire (); 201assert . equal ( f . clients . length , 0 ); 202f . config . autoStart = true ; 203await f . changed (); 204assert . equal ( f . clients . length , 0 ); 205f . config . backend = "/native/tsc" ; 206await f . changed (); 207assert . equal ( f . clients . length , 1 ); 208f . config . autoStart = false ; 209await f . changed (); 210assert . equal ( f . clients [ 0 ]. state , f . State . Stopped ); 211await f . start (); 212assert . equal ( f . clients . length , 2 , "manual startup remains available with autoStart disabled" ); 213}); 214 215test ( "failed startup and unexpected termination leave built-in TypeScript usable" , async ( t ) => { 216const f = fixture (); 217t . after ( f . deactivate ); 218f . controls . failStart = true ; 219await f . activate (); 220assert . match ( f . errors [ 0 ], / Startup failed / ); 221assert . ok ( f . configurations . every (( configuration ) => configuration . liveRoots . length === 0 )); 222assert . equal ( f . watchers [ 1 ]. disposed , true ); 223f . controls . failStart = false ; 224await f . start (); 225await f . clients . at ( - 1 ). transition ( f . State . Stopped ); 226assert . deepEqual ( f . configurations . at ( - 1 ). liveRoots , []); 227await f . start (); 228assert . deepEqual ( f . configurations . at ( - 1 ). liveRoots , [ f . root ]); 229}); 230 231test ( "concurrent startup requests do not duplicate clients and stop waits for startup" , async ( t ) => { 232const f = fixture (); 233t . after ( f . deactivate ); 234let resume ; 235f . controls . startGate = new Promise (( resolve ) => { resume = resolve ; }); 236const activation = f . activate (); 237const start = f . start (); 238await setImmediate (); 239assert . equal ( f . clients . length , 1 ); 240const stop = f . stop (); 241resume (); 242await Promise . all ([ activation , start , stop ]); 243assert . equal ( f . clients . length , 1 ); 244assert . equal ( f . clients [ 0 ]. state , f . State . Stopped ); 245assert . deepEqual ( f . configurations . at ( - 1 ). liveRoots , []); 246}); 247 248test ( "nested workspace folders retain their own language service" , async ( t ) => { 249const f = fixture (); 250t . after ( f . deactivate ); 251f . workspace . workspaceFolders . push ( f . folder ( path . join ( f . root , "node" ))); 252await f . activate (); 253const middleware = f . clients [ 0 ]. options . middleware ; 254let calls = 0 ; 255const next = () => { calls ++ ; }; 256const nested = { uri :f . uri ( path . join ( f . root , "node" , "main.ts" )) }; 257middleware . didOpen ( nested , next ); 258for ( const name of [ 259"provideHover" , "provideDefinition" , "provideCompletionItem" , "provideSignatureHelp" , 260"provideReferences" , "provideDocumentHighlights" , "provideRenameEdits" , "prepareRename" , 261"provideDocumentSymbols" , "provideFoldingRanges" , "provideSelectionRanges" , 262"provideDocumentFormattingEdits" , "provideDocumentRangeFormattingEdits" , 263"provideDocumentSemanticTokens" , "provideDocumentSemanticTokensEdits" , 264"provideDocumentRangeSemanticTokens" , "provideInlayHints" , "prepareTypeHierarchy" , "prepareCallHierarchy" , 265"provideLinkedEditingRange" , "provideImplementation" , "provideTypeDefinition" , "provideCodeActions" , "provideCodeLenses" , 266]) { 267assert . equal ( typeof middleware [ name ], "function" , `Missing ownership gate for ${ name } ` ); 268middleware [ name ]( nested , {}, {}, {}, next ); 269} 270middleware . provideTypeHierarchySupertypes ({ uri :f . uri ( path . join ( f . root , "node" , "main.ts" )) }, {}, next ); 271middleware . provideTypeHierarchySubtypes ({ uri :f . uri ( path . join ( f . root , "node" , "main.ts" )) }, {}, next ); 272middleware . provideCallHierarchyIncomingCalls ({ uri :f . uri ( path . join ( f . root , "node" , "main.ts" )) }, {}, next ); 273middleware . provideCallHierarchyOutgoingCalls ({ uri :f . uri ( path . join ( f . root , "node" , "main.ts" )) }, {}, next ); 274assert . equal ( calls , 0 , "avril must not claim nested workspace documents or hierarchy items" ); 275middleware . provideWorkspaceSymbols ( "Square" , {}, next ); 276middleware . resolveCompletionItem ({}, {}, next ); 277middleware . resolveCodeAction ({}, {}, next ); 278assert . equal ( calls , 3 , "Workspace and opaque-item requests are served by the configured root project" ); 279 280const owned = { uri :f . uri ( path . join ( f . root , "main.ts" )) }; 281middleware . didOpen ( owned , next ); 282middleware . provideSignatureHelp ( owned , {}, {}, {}, next ); 283middleware . provideReferences ( owned , {}, { includeDeclaration :true }, {}, next ); 284middleware . provideRenameEdits ( owned , {}, "renamed" , {}, next ); 285middleware . prepareTypeHierarchy ( owned , {}, {}, next ); 286middleware . provideTypeHierarchySupertypes ({ uri :owned . uri }, {}, next ); 287middleware . prepareCallHierarchy ( owned , {}, {}, next ); 288middleware . provideCallHierarchyIncomingCalls ({ uri :owned . uri }, {}, next ); 289middleware . provideCallHierarchyOutgoingCalls ({ uri :owned . uri }, {}, next ); 290assert . equal ( calls , 12 , "avril should serve every owned document request and hierarchy item" ); 291}); 292 293test ( "resolved reference code lenses invoke only the converted built-in command" , async ( t ) => { 294const f = fixture ({ config :{ preferences :{ referencesCodeLensEnabled :true , referencesCodeLensShowOnAllFunctions :true } } }); 295t . after ( f . deactivate ); 296await f . activate (); 297const client = f . clients [ 0 ]; 298assert . deepEqual ( JSON . parse ( JSON . stringify ( client . options . initializationOptions )), { 299preferences :{ 300referencesCodeLensEnabled :true , 301referencesCodeLensShowOnAllFunctions :true , 302}, 303}); 304const middleware = client . options . middleware ; 305const sourceDocument = f . uri ( path . join ( f . root , "main.ts" )); 306const source = sourceDocument . toString (); 307const position = { line :2 , character :4 }; 308const locations = [{ 309uri :f . uri ( path . join ( f . root , "dep.ts" )). toString (), 310range :{ start :{ line :4 , character :1 }, end :{ line :4 , character :6 } }, 311}]; 312class CodeLens { get isResolved () { return Boolean ( this . command ); } } 313const lens = Object . assign ( new CodeLens (), { range :{}, data :{ uri :source } }); 314const [ provided ] = await middleware . provideCodeLenses ( 315{ uri :sourceDocument }, 316{}, 317async () => [ lens ], 318); 319assert . ok ( provided instanceof CodeLens , "conversion must preserve the language client's subclass" ); 320assert . equal ( provided . isResolved , false ); 321assert . equal ( provided . data . uri , source ); 322assert . equal ( provided . data . avrilDocumentUri , source ); 323const resolved = await middleware . resolveCodeLens ( 324provided , 325{}, 326async ( codeLens ) => Object . assign ( codeLens , { 327command :{ 328title :"1 reference" , 329command :"editor.action.showReferences" , 330arguments :[ source , position , locations ], 331}, 332}), 333); 334assert . ok ( resolved instanceof CodeLens ); 335assert . equal ( resolved . isResolved , true ); 336assert . equal ( resolved . command . command , "avril.showReferences" ); 337await f . commands . get ( resolved . command . command )( ...resolved . command . arguments ); 338assert . equal ( f . executedCommands . length , 1 ); 339assert . equal ( f . executedCommands [ 0 ][ 0 ], "editor.action.showReferences" ); 340assert . equal ( f . executedCommands [ 0 ][ 1 ]. toString (), source ); 341assert . equal ( f . executedCommands [ 0 ][ 2 ]. kind , "Position" ); 342assert . deepEqual ( JSON . parse ( JSON . stringify ( f . executedCommands [ 0 ][ 2 ])), { kind :"Position" , ...position }); 343assert . equal ( f . executedCommands [ 0 ][ 3 ][ 0 ]. kind , "Location" ); 344assert . equal ( f . executedCommands [ 0 ][ 3 ][ 0 ]. uri . toString (), locations [ 0 ]. uri ); 345assert . deepEqual ( 346JSON . parse ( JSON . stringify ( f . executedCommands [ 0 ][ 3 ][ 0 ]. range )), 347locations [ 0 ]. range , 348); 349 350const outside = f . uri ( path . resolve ( "outside/main.ts" )). toString (); 351let nextCalled = false ; 352assert . equal ( await middleware . resolveCodeLens ( 353{ data :{ avrilDocumentUri :outside } }, 354{}, 355() => { nextCalled = true ; }, 356), undefined ); 357assert . equal ( nextCalled , false , "unowned code lenses must not be resolved" ); 358assert . throws ( 359() => f . commands . get ( "avril.showReferences" )( outside , position , locations ), 360/ outside its workspace / , 361); 362 363const unexpected = await middleware . resolveCodeLens ( 364{ data :{ avrilDocumentUri :source } }, 365{}, 366async ( codeLens ) => ({ ...codeLens , command :{ title :"Unexpected" , command :"workbench.action.closeWindow" } }), 367); 368assert . equal ( unexpected , undefined , "native commands other than showReferences must not escape" ); 369assert . equal ( f . executedCommands . length , 1 ); 370}); 371 372test ( "native preferences preserve defaults and update the active session" , async ( t ) => { 373const f = fixture (); 374t . after ( f . deactivate ); 375await f . activate (); 376assert . deepEqual ( JSON . parse ( JSON . stringify ( f . clients [ 0 ]. options . initializationOptions )), { preferences :{} }); 377f . config . preferences = { includeInlayParameterNameHints :"all" }; 378await f . changed (); 379assert . equal ( f . clients . length , 1 ); 380assert . deepEqual ( JSON . parse ( JSON . stringify ( f . clients [ 0 ]. notifications . at ( - 1 ))), { 381method :"workspace/didChangeConfiguration" , 382params :{ settings :{ preferences :f . config . preferences } }, 383}); 384}); 385 386test ( "read-only document providers preserve encoded URI query identity" , async ( t ) => { 387const f = fixture (); 388t . after ( f . deactivate ); 389await f . activate (); 390for ( const original of [ 391"avril-https://example.test/module.ts?x%3Dy" , 392"avril-https://example.test/module.ts?x%253Dy" , 393]) { 394const uri = f . parseUri ( original ); 395const content = await f . contentProviders . get ( "avril-https" ). provideTextDocumentContent ( uri , {}); 396assert . equal ( content , "contents" ); 397assert . deepEqual ( JSON . parse ( JSON . stringify ( f . clients [ 0 ]. requests . at ( - 1 ))), { 398method :"avril/readDocument" , 399params :{ uri :original }, 400}); 401} 402}); 403 404test ( "changing the first workspace folder releases the old server" , async ( t ) => { 405const f = fixture (); 406t . after ( f . deactivate ); 407await f . activate (); 408f . workspace . workspaceFolders = [ f . folder ( path . resolve ( "other" ))]; 409await f . folders . fire (); 410assert . equal ( f . clients [ 0 ]. state , f . State . Stopped ); 411assert . deepEqual ( f . configurations . at ( - 1 ). liveRoots , []); 412}); 413 414test ( "manual startup still works without a root config or built-in TypeScript extension" , async ( t ) => { 415const f = fixture ({ files :[], noTypeScript :true }); 416t . after ( f . deactivate ); 417await f . activate (); 418await f . start (); 419assert . equal ( f . clients [ 0 ]. state , f . State . Running ); 420assert . deepEqual ( f . errors , []); 421});