1+ import { execFile } from "node:child_process"
12import { mkdir , mkdtemp , readFile , rm , writeFile } from "node:fs/promises"
23import type { IncomingMessage , ServerResponse } from "node:http"
34import { tmpdir } from "node:os"
4- import { join , relative } from "node:path"
5+ import { isAbsolute , join , relative , resolve } from "node:path"
56import { fileURLToPath , URL } from "node:url"
6- import { generate } from "@tskm/compiler"
7+ import { generate , resolveTsgoExecutable } from "@tskm/compiler"
78import react from "@vitejs/plugin-react"
8- import * as ts from "typescript"
99import { defineConfig , type Plugin } from "vite"
1010
1111const runtimeEntry = fileURLToPath ( new URL ( "../../packages/tskm/src/index.ts" , import . meta. url ) )
1212const typegenEndpoint = "/__tskm_playground/typegen"
1313const typecheckEndpoint = "/__tskm_playground/typecheck"
14+ const ansiEscapePattern = new RegExp ( `${ String . fromCharCode ( 27 ) } \\[[0-9;]*m` , "g" )
1415
1516export default defineConfig ( {
1617 plugins : [ react ( ) , tskmPlaygroundTypegen ( ) ] ,
@@ -156,10 +157,13 @@ async function typecheckPlaygroundInput(schemaSource: string, inputSource: strin
156157 const inputText = `${ inputPrefix } ${ inputSource } ${ inputSuffix } `
157158 await writeFile ( inputFile , inputText )
158159
160+ const typecheckOutput = await runTsgoNoEmit ( root )
161+
159162 return {
160163 ok : true ,
161164 diagnostics : collectTypecheckDiagnostics (
162165 root ,
166+ typecheckOutput ,
163167 inputFile ,
164168 inputText ,
165169 inputPrefix . length ,
@@ -172,79 +176,187 @@ async function typecheckPlaygroundInput(schemaSource: string, inputSource: strin
172176 }
173177}
174178
179+ interface CliDiagnostic {
180+ readonly fileName : string
181+ readonly line : number
182+ readonly column : number
183+ readonly code : number
184+ readonly category : string
185+ readonly message : string
186+ }
187+
188+ function runTsgoNoEmit ( root : string ) : Promise < string > {
189+ return new Promise ( ( resolveOutput , reject ) => {
190+ execFile (
191+ resolveTsgoExecutable ( ) ,
192+ [ "--noEmit" , "--pretty" , "false" , "-p" , "tsconfig.json" ] ,
193+ {
194+ cwd : root ,
195+ encoding : "utf8" ,
196+ maxBuffer : 1024 * 1024 ,
197+ } ,
198+ ( error , stdout , stderr ) => {
199+ const output = [ stdout , stderr ] . filter ( Boolean ) . join ( "\n" )
200+ if ( ! error || typeof error . code === "number" ) {
201+ resolveOutput ( output )
202+ return
203+ }
204+ reject ( error )
205+ } ,
206+ )
207+ } )
208+ }
209+
175210function collectTypecheckDiagnostics (
176211 root : string ,
212+ output : string ,
177213 inputFile : string ,
178214 inputText : string ,
179215 inputStart : number ,
180216 inputSource : string ,
181217) {
182- const configPath = join ( root , "tsconfig.json" )
183- const config = ts . readConfigFile ( configPath , ts . sys . readFile )
184- const parsed = ts . parseJsonConfigFileContent ( config . config , ts . sys , root )
185- const program = ts . createProgram ( parsed . fileNames , parsed . options )
186- const sourceFile = program . getSourceFile ( inputFile )
187- if ( ! sourceFile ) return [ ]
188-
189218 const inputEnd = inputStart + inputSource . length
190- return ts
191- . getPreEmitDiagnostics ( program , sourceFile )
192- . filter ( ( diagnostic ) => diagnostic . file ?. fileName === inputFile )
219+ return parseTsgoDiagnostics ( output )
220+ . filter ( ( diagnostic ) => sameFile ( root , diagnostic . fileName , inputFile ) )
193221 . map ( ( diagnostic ) =>
194- toEditorDiagnostic ( diagnostic , sourceFile , inputText , inputSource , inputStart , inputEnd ) ,
222+ toEditorDiagnostic ( diagnostic , inputText , inputSource , inputStart , inputEnd ) ,
195223 )
196224}
197225
198226function toEditorDiagnostic (
199- diagnostic : ts . Diagnostic ,
200- sourceFile : ts . SourceFile ,
227+ diagnostic : CliDiagnostic ,
201228 inputText : string ,
202229 inputSource : string ,
203230 inputStart : number ,
204231 inputEnd : number ,
205232) {
206233 const fallbackStart = firstNonWhitespaceOffset ( inputSource )
207- const rawStart = diagnostic . start ?? inputStart
208- const rawEnd = rawStart + Math . max ( diagnostic . length ?? 1 , 1 )
209- const startOffset =
234+ const rawStart = positionToOffset ( inputText , diagnostic . line - 1 , diagnostic . column - 1 )
235+ const mappedStartOffset =
210236 rawStart >= inputStart && rawStart <= inputEnd ? rawStart - inputStart : fallbackStart
211- const endOffset =
212- rawEnd >= inputStart && rawEnd <= inputEnd
213- ? Math . max ( rawEnd - inputStart , startOffset + 1 )
214- : expandFallbackEnd ( inputSource , startOffset )
237+ const startOffset = valueStartForTypeMismatch ( inputSource , mappedStartOffset , diagnostic . message )
238+ const endOffset = expandDiagnosticEnd ( inputSource , startOffset )
215239 const start = offsetToPosition ( inputSource , startOffset )
216240 const end = offsetToPosition ( inputSource , Math . min ( endOffset , inputSource . length ) )
217- const sourcePosition =
218- diagnostic . start === undefined
219- ? undefined
220- : sourceFile . getLineAndCharacterOfPosition ( Math . min ( diagnostic . start , inputText . length ) )
221241
222242 return {
223243 code : diagnostic . code ,
224- category : ts . DiagnosticCategory [ diagnostic . category ] . toLowerCase ( ) ,
225- message : ts . flattenDiagnosticMessageText ( diagnostic . messageText , "\n" ) ,
244+ category : diagnostic . category ,
245+ message : diagnostic . message ,
226246 startOffset,
227247 endOffset : Math . min ( endOffset , inputSource . length ) ,
228248 line : start . line ,
229249 column : start . column ,
230250 endLine : end . line ,
231251 endColumn : end . column ,
232- sourceLine : sourcePosition ? sourcePosition . line : undefined ,
233- sourceColumn : sourcePosition ? sourcePosition . character : undefined ,
234252 }
235253}
236254
255+ function valueStartForTypeMismatch ( text : string , startOffset : number , message : string ) : number {
256+ if ( ! message . startsWith ( "Type " ) || ! message . includes ( " is not assignable to type " ) ) {
257+ return startOffset
258+ }
259+
260+ const keyMatch = / " (?: \\ .| [ ^ " \\ ] ) * " / y
261+ keyMatch . lastIndex = startOffset
262+ const match = keyMatch . exec ( text )
263+ if ( ! match ) return startOffset
264+
265+ let index = startOffset + match [ 0 ] . length
266+ index = skipWhitespace ( text , index )
267+ if ( text [ index ] !== ":" ) return startOffset
268+ index = skipWhitespace ( text , index + 1 )
269+ return index < text . length ? index : startOffset
270+ }
271+
272+ function parseTsgoDiagnostics ( output : string ) : readonly CliDiagnostic [ ] {
273+ const diagnostics : CliDiagnostic [ ] = [ ]
274+ let current : CliDiagnostic | undefined
275+
276+ for ( const rawLine of output . split ( / \r ? \n / ) ) {
277+ const line = stripAnsi ( rawLine )
278+ const match = / ^ ( .* ) \( ( \d + ) , ( \d + ) \) : \s + ( \w + ) \s + T S ( \d + ) : \s + ( .* ) $ / . exec ( line )
279+ if ( match ) {
280+ if ( current ) diagnostics . push ( current )
281+ current = {
282+ fileName : match [ 1 ] ?? "" ,
283+ line : Number ( match [ 2 ] ) ,
284+ column : Number ( match [ 3 ] ) ,
285+ category : match [ 4 ] ?? "error" ,
286+ code : Number ( match [ 5 ] ) ,
287+ message : match [ 6 ] ?? "" ,
288+ }
289+ continue
290+ }
291+
292+ if ( current && line . trim ( ) ) {
293+ current = {
294+ ...current ,
295+ message : `${ current . message } \n${ line . trim ( ) } ` ,
296+ }
297+ }
298+ }
299+
300+ if ( current ) diagnostics . push ( current )
301+ return diagnostics
302+ }
303+
304+ function sameFile ( root : string , diagnosticFileName : string , inputFile : string ) : boolean {
305+ const absoluteDiagnosticFile = isAbsolute ( diagnosticFileName )
306+ ? diagnosticFileName
307+ : resolve ( root , diagnosticFileName )
308+ return absoluteDiagnosticFile === inputFile
309+ }
310+
237311function firstNonWhitespaceOffset ( text : string ) : number {
238312 const match = / \S / . exec ( text )
239313 return match ?. index ?? 0
240314}
241315
316+ function expandDiagnosticEnd ( text : string , startOffset : number ) : number {
317+ const tokenEnd = endOfToken ( text , startOffset )
318+ if ( tokenEnd !== null ) return tokenEnd
319+ return expandFallbackEnd ( text , startOffset )
320+ }
321+
242322function expandFallbackEnd ( text : string , startOffset : number ) : number {
243323 const lineEnd = text . indexOf ( "\n" , startOffset )
244324 const end = lineEnd === - 1 ? text . length : lineEnd
245325 return Math . max ( startOffset + 1 , end )
246326}
247327
328+ function endOfToken ( text : string , offset : number ) : number | null {
329+ const start = Math . max ( 0 , Math . min ( offset , text . length ) )
330+ const tokenPattern =
331+ / " (?: \\ .| [ ^ " \\ ] ) * " | t r u e | f a l s e | n u l l | - ? \d + (?: \. \d + ) ? (?: [ e E ] [ + - ] ? \d + ) ? | [ A - Z a - z _ $ ] [ \w $ ] * / g
332+ for ( const match of text . matchAll ( tokenPattern ) ) {
333+ const tokenStart = match . index
334+ if ( tokenStart === undefined ) continue
335+ const tokenEnd = tokenStart + match [ 0 ] . length
336+ if ( tokenStart <= start && start < tokenEnd ) return tokenEnd
337+ if ( start < tokenStart ) return tokenEnd
338+ }
339+ return null
340+ }
341+
342+ function skipWhitespace ( text : string , offset : number ) : number {
343+ let index = offset
344+ while ( / \s / . test ( text [ index ] ?? "" ) ) {
345+ index += 1
346+ }
347+ return index
348+ }
349+
350+ function positionToOffset ( text : string , line : number , column : number ) : number {
351+ const lines = text . split ( "\n" )
352+ const safeLine = Math . max ( 0 , Math . min ( line , lines . length - 1 ) )
353+ let offset = 0
354+ for ( let index = 0 ; index < safeLine ; index += 1 ) {
355+ offset += ( lines [ index ] ?. length ?? 0 ) + 1
356+ }
357+ return offset + Math . max ( 0 , Math . min ( column , lines [ safeLine ] ?. length ?? 0 ) )
358+ }
359+
248360function offsetToPosition (
249361 text : string ,
250362 offset : number ,
@@ -258,6 +370,10 @@ function offsetToPosition(
258370 }
259371}
260372
373+ function stripAnsi ( text : string ) : string {
374+ return text . replace ( ansiEscapePattern , "" )
375+ }
376+
261377function playgroundTsconfig ( root : string ) : string {
262378 return `${ JSON . stringify (
263379 {
0 commit comments