@@ -14,6 +14,10 @@ import { withTimeout } from "../util/timeout"
1414import { Filesystem } from "../util"
1515
1616const DIAGNOSTICS_DEBOUNCE_MS = 150
17+ const DIAGNOSTICS_POLL_MS = 500
18+ const DIAGNOSTICS_REQUEST_TIMEOUT_MS = 2_000
19+ const DIAGNOSTICS_WAIT_TIMEOUT_MS = 10_000
20+ const DIAGNOSTICS_SETTLE_MS = 1_500
1721
1822const log = Log . create ( { service : "lsp.client" } )
1923
@@ -38,6 +42,65 @@ export const Event = {
3842 ) ,
3943}
4044
45+ type DocumentDiagnosticReport = {
46+ items ?: Diagnostic [ ]
47+ relatedDocuments ?: Record < string , DocumentDiagnosticReport >
48+ }
49+
50+ type CapabilityRegistration = {
51+ id : string
52+ method : string
53+ registerOptions ?: {
54+ identifier ?: string
55+ workspaceDiagnostics ?: boolean
56+ }
57+ }
58+
59+ type ServerCapabilities = {
60+ textDocumentSync ?:
61+ | number
62+ | {
63+ change ?: number
64+ }
65+ [ key : string ] : unknown
66+ }
67+
68+ function getFilePath ( uri : string ) {
69+ if ( ! uri . startsWith ( "file://" ) ) return
70+ return Filesystem . normalizePath ( fileURLToPath ( uri ) )
71+ }
72+
73+ function getSyncKind ( capabilities ?: ServerCapabilities ) {
74+ if ( ! capabilities ) return
75+ const sync = capabilities . textDocumentSync
76+ if ( typeof sync === "number" ) return sync
77+ return sync ?. change
78+ }
79+
80+ function endPosition ( text : string ) {
81+ const lines = text . split ( / \r \n | \r | \n / )
82+ return {
83+ line : lines . length - 1 ,
84+ character : lines . at ( - 1 ) ?. length ?? 0 ,
85+ }
86+ }
87+
88+ function dedupeDiagnostics ( items : Diagnostic [ ] ) {
89+ const seen = new Set < string > ( )
90+ return items . filter ( ( item ) => {
91+ const key = JSON . stringify ( {
92+ code : item . code ,
93+ severity : item . severity ,
94+ message : item . message ,
95+ source : item . source ,
96+ range : item . range ,
97+ } )
98+ if ( seen . has ( key ) ) return false
99+ seen . add ( key )
100+ return true
101+ } )
102+ }
103+
41104export async function create ( input : { serverID : string ; server : LSPServer . Handle ; root : string ; directory : string } ) {
42105 const l = log . clone ( ) . tag ( "serverID" , input . serverID )
43106 l . info ( "starting client" )
@@ -48,16 +111,21 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
48111 )
49112
50113 const diagnostics = new Map < string , Diagnostic [ ] > ( )
114+ const diagnosticRegistrations = new Map < string , CapabilityRegistration > ( )
115+ function updateDiagnostics ( filePath : string , next : Diagnostic [ ] ) {
116+ const exists = diagnostics . has ( filePath )
117+ diagnostics . set ( filePath , next )
118+ if ( ! exists && input . serverID === "typescript" ) return
119+ Bus . publish ( Event . Diagnostics , { path : filePath , serverID : input . serverID } )
120+ }
51121 connection . onNotification ( "textDocument/publishDiagnostics" , ( params ) => {
52- const filePath = Filesystem . normalizePath ( fileURLToPath ( params . uri ) )
122+ const filePath = getFilePath ( params . uri )
123+ if ( ! filePath ) return
53124 l . info ( "textDocument/publishDiagnostics" , {
54125 path : filePath ,
55126 count : params . diagnostics . length ,
56127 } )
57- const exists = diagnostics . has ( filePath )
58- diagnostics . set ( filePath , params . diagnostics )
59- if ( ! exists && input . serverID === "typescript" ) return
60- Bus . publish ( Event . Diagnostics , { path : filePath , serverID : input . serverID } )
128+ updateDiagnostics ( filePath , params . diagnostics )
61129 } )
62130 connection . onRequest ( "window/workDoneProgress/create" , ( params ) => {
63131 l . info ( "window/workDoneProgress/create" , params )
@@ -67,8 +135,20 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
67135 // Return server initialization options
68136 return [ input . server . initialization ?? { } ]
69137 } )
70- connection . onRequest ( "client/registerCapability" , async ( ) => { } )
71- connection . onRequest ( "client/unregisterCapability" , async ( ) => { } )
138+ connection . onRequest ( "client/registerCapability" , async ( params ) => {
139+ const registrations = ( params as { registrations ?: CapabilityRegistration [ ] } ) . registrations ?? [ ]
140+ for ( const registration of registrations ) {
141+ if ( registration . method !== "textDocument/diagnostic" ) continue
142+ diagnosticRegistrations . set ( registration . id , registration )
143+ }
144+ } )
145+ connection . onRequest ( "client/unregisterCapability" , async ( params ) => {
146+ const registrations = ( params as { unregisterations ?: { id : string ; method : string } [ ] } ) . unregisterations ?? [ ]
147+ for ( const registration of registrations ) {
148+ if ( registration . method !== "textDocument/diagnostic" ) continue
149+ diagnosticRegistrations . delete ( registration . id )
150+ }
151+ } )
72152 connection . onRequest ( "workspace/workspaceFolders" , async ( ) => [
73153 {
74154 name : "workspace" ,
@@ -78,8 +158,8 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
78158 connection . listen ( )
79159
80160 l . info ( "sending initialize" )
81- await withTimeout (
82- connection . sendRequest ( "initialize" , {
161+ const initialized = await withTimeout (
162+ connection . sendRequest < { capabilities ?: ServerCapabilities } > ( "initialize" , {
83163 rootUri : pathToFileURL ( input . root ) . href ,
84164 processId : input . server . process . pid ,
85165 workspaceFolders : [
@@ -100,12 +180,19 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
100180 didChangeWatchedFiles : {
101181 dynamicRegistration : true ,
102182 } ,
183+ diagnostics : {
184+ refreshSupport : true ,
185+ } ,
103186 } ,
104187 textDocument : {
105188 synchronization : {
106189 didOpen : true ,
107190 didChange : true ,
108191 } ,
192+ diagnostic : {
193+ dynamicRegistration : true ,
194+ relatedDocumentSupport : true ,
195+ } ,
109196 publishDiagnostics : {
110197 versionSupport : true ,
111198 } ,
@@ -122,6 +209,7 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
122209 } ,
123210 )
124211 } )
212+ const syncKind = getSyncKind ( initialized . capabilities )
125213
126214 await connection . sendNotification ( "initialized" , { } )
127215
@@ -132,9 +220,73 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
132220 }
133221
134222 const files : {
135- [ path : string ] : number
223+ [ path : string ] : { version : number ; text : string }
136224 } = { }
137225
226+ async function requestDiagnosticReport ( filePath : string , identifier ?: string ) {
227+ const report = await withTimeout (
228+ connection . sendRequest < DocumentDiagnosticReport | null > ( "textDocument/diagnostic" , {
229+ ...( identifier ? { identifier } : { } ) ,
230+ textDocument : {
231+ uri : pathToFileURL ( filePath ) . href ,
232+ } ,
233+ } ) ,
234+ DIAGNOSTICS_REQUEST_TIMEOUT_MS ,
235+ ) . catch ( ( ) => null )
236+ if ( ! report ) return { handled : false , byFile : new Map < string , Diagnostic [ ] > ( ) }
237+
238+ const byFile = new Map < string , Diagnostic [ ] > ( )
239+ const push = ( target : string , items : Diagnostic [ ] ) => {
240+ const existing = byFile . get ( target ) ?? [ ]
241+ byFile . set ( target , existing . concat ( items ) )
242+ }
243+
244+ let handled = false
245+ if ( Array . isArray ( report . items ) ) {
246+ push ( filePath , report . items )
247+ handled = true
248+ }
249+ for ( const [ uri , related ] of Object . entries ( report . relatedDocuments ?? { } ) ) {
250+ const relatedPath = getFilePath ( uri )
251+ if ( ! relatedPath || ! Array . isArray ( related . items ) ) continue
252+ push ( relatedPath , related . items )
253+ handled = true
254+ }
255+
256+ return { handled, byFile }
257+ }
258+
259+ async function requestDiagnostics ( filePath : string ) {
260+ const results = [ await requestDiagnosticReport ( filePath ) ]
261+ const identifiers = new Set (
262+ [ ...diagnosticRegistrations . values ( ) ]
263+ . filter ( ( registration ) => registration . registerOptions ?. workspaceDiagnostics !== true )
264+ . map ( ( registration ) => registration . registerOptions ?. identifier )
265+ . filter ( ( identifier ) : identifier is string => Boolean ( identifier ) ) ,
266+ )
267+ for ( const identifier of identifiers ) {
268+ results . push ( await requestDiagnosticReport ( filePath , identifier ) )
269+ }
270+
271+ const handled = results . some ( ( result ) => result . handled )
272+ if ( ! handled ) return false
273+
274+ const merged = new Map < string , Diagnostic [ ] > ( )
275+ for ( const result of results ) {
276+ for ( const [ target , items ] of result . byFile . entries ( ) ) {
277+ const existing = merged . get ( target ) ?? [ ]
278+ merged . set ( target , existing . concat ( items ) )
279+ }
280+ }
281+
282+ if ( ! merged . has ( filePath ) ) merged . set ( filePath , [ ] )
283+ for ( const [ target , items ] of merged . entries ( ) ) {
284+ updateDiagnostics ( target , dedupeDiagnostics ( items ) )
285+ }
286+
287+ return true
288+ }
289+
138290 const result = {
139291 root : input . root ,
140292 get serverID ( ) {
@@ -150,8 +302,8 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
150302 const extension = path . extname ( request . path )
151303 const languageId = LANGUAGE_EXTENSIONS [ extension ] ?? "plaintext"
152304
153- const version = files [ request . path ]
154- if ( version !== undefined ) {
305+ const document = files [ request . path ]
306+ if ( document !== undefined ) {
155307 log . info ( "workspace/didChangeWatchedFiles" , request )
156308 await connection . sendNotification ( "workspace/didChangeWatchedFiles" , {
157309 changes : [
@@ -162,8 +314,8 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
162314 ] ,
163315 } )
164316
165- const next = version + 1
166- files [ request . path ] = next
317+ const next = document . version + 1
318+ files [ request . path ] = { version : next , text }
167319 log . info ( "textDocument/didChange" , {
168320 path : request . path ,
169321 version : next ,
@@ -173,7 +325,19 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
173325 uri : pathToFileURL ( request . path ) . href ,
174326 version : next ,
175327 } ,
176- contentChanges : [ { text } ] ,
328+ contentChanges :
329+ syncKind === 2
330+ ? [
331+ {
332+ range : {
333+ start : { line : 0 , character : 0 } ,
334+ end : endPosition ( document . text ) ,
335+ } ,
336+ rangeLength : document . text . length ,
337+ text,
338+ } ,
339+ ]
340+ : [ { text } ] ,
177341 } )
178342 return
179343 }
@@ -198,7 +362,7 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
198362 text,
199363 } ,
200364 } )
201- files [ request . path ] = 0
365+ files [ request . path ] = { version : 0 , text }
202366 return
203367 } ,
204368 } ,
@@ -212,21 +376,35 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
212376 log . info ( "waiting for diagnostics" , { path : normalizedPath } )
213377 let unsub : ( ) => void
214378 let debounceTimer : ReturnType < typeof setTimeout > | undefined
379+ let pushed = false
380+ let firstHandledAt : number | undefined
215381 return await withTimeout (
216- new Promise < void > ( ( resolve ) => {
382+ ( async ( ) => {
217383 unsub = Bus . subscribe ( Event . Diagnostics , ( event ) => {
218- if ( event . properties . path === normalizedPath && event . properties . serverID === result . serverID ) {
219- // Debounce to allow LSP to send follow-up diagnostics (e.g., semantic after syntax)
220- if ( debounceTimer ) clearTimeout ( debounceTimer )
221- debounceTimer = setTimeout ( ( ) => {
384+ if ( event . properties . path !== normalizedPath || event . properties . serverID !== result . serverID ) return
385+ if ( debounceTimer ) clearTimeout ( debounceTimer )
386+ debounceTimer = setTimeout ( ( ) => {
387+ pushed = true
388+ } , DIAGNOSTICS_DEBOUNCE_MS )
389+ } )
390+ await new Promise ( ( resolve ) => setTimeout ( resolve , DIAGNOSTICS_DEBOUNCE_MS ) )
391+ while ( true ) {
392+ if ( pushed ) {
393+ log . info ( "got diagnostics" , { path : normalizedPath } )
394+ return
395+ }
396+ const handled = await requestDiagnostics ( normalizedPath )
397+ if ( handled ) {
398+ firstHandledAt = firstHandledAt ?? Date . now ( )
399+ if ( Date . now ( ) - firstHandledAt >= DIAGNOSTICS_SETTLE_MS ) {
222400 log . info ( "got diagnostics" , { path : normalizedPath } )
223- unsub ?.( )
224- resolve ( )
225- } , DIAGNOSTICS_DEBOUNCE_MS )
401+ return
402+ }
226403 }
227- } )
228- } ) ,
229- 3000 ,
404+ await new Promise ( ( resolve ) => setTimeout ( resolve , DIAGNOSTICS_POLL_MS ) )
405+ }
406+ } ) ( ) ,
407+ DIAGNOSTICS_WAIT_TIMEOUT_MS ,
230408 )
231409 . catch ( ( ) => { } )
232410 . finally ( ( ) => {
0 commit comments