1+ import type * as ts from "typescript"
2+ import { renderFallbackContent } from "./generated-type.ts"
3+
14export interface InputTypecheckDiagnostic {
25 readonly code : number
36 readonly category : string
@@ -32,12 +35,45 @@ interface TypecheckFailure {
3235type TypecheckResponse = TypecheckSuccess | TypecheckFailure
3336
3437const endpoint = "/__tskm_playground/typecheck"
38+ const hasTypecheckEndpoint = import . meta. env . DEV || import . meta. env . VITE_TSKM_PLAYGROUND_API === "1"
39+ const inputFileName = "/playground.input.ts"
40+ const generatedFileName = "/playground.schema.gen.ts"
41+ const libFileName = "/lib.d.ts"
42+ const inputPrefix = `import type { PlaygroundOutput } from "./playground.schema.gen"\n\nconst playgroundInput: PlaygroundOutput = `
43+ const inputSuffix = "\n"
44+ const clientLibDts = `
45+ interface Array<T> {
46+ length: number
47+ [n: number]: T
48+ }
49+ interface Boolean {}
50+ interface CallableFunction extends Function {}
51+ interface Date {}
52+ interface Function {}
53+ interface IArguments {}
54+ interface NewableFunction extends Function {}
55+ interface Number {}
56+ interface Object {}
57+ interface ReadonlyArray<T> {
58+ readonly length: number
59+ readonly [n: number]: T
60+ }
61+ interface RegExp {}
62+ interface String {}
63+ type Readonly<T> = {
64+ readonly [P in keyof T]: T[P]
65+ }
66+ `
3567
3668export async function fetchInputTypecheck (
3769 schemaSource : string ,
3870 inputSource : string ,
3971 signal : AbortSignal ,
4072) : Promise < InputTypecheckState > {
73+ if ( ! hasTypecheckEndpoint ) {
74+ return typecheckInputInBrowser ( schemaSource , inputSource )
75+ }
76+
4177 const response = await fetch ( endpoint , {
4278 method : "POST" ,
4379 headers : { "content-type" : "application/json" } ,
@@ -63,3 +99,204 @@ export async function fetchInputTypecheck(
6399 message : result . message ,
64100 }
65101}
102+
103+ export async function typecheckInputInBrowser (
104+ schemaSource : string ,
105+ inputSource : string ,
106+ ) : Promise < InputTypecheckState > {
107+ const generatedType = renderFallbackContent ( schemaSource )
108+ if ( generatedType . status === "error" ) {
109+ return {
110+ status : "error" ,
111+ diagnostics : [ ] ,
112+ message : generatedType . message ,
113+ }
114+ }
115+
116+ const typescript = await import ( "typescript" )
117+ const inputText = `${ inputPrefix } ${ inputSource } ${ inputSuffix } `
118+ const program = createVirtualProgram ( typescript , inputText , generatedType . content )
119+ const sourceFile = program . getSourceFile ( inputFileName )
120+ if ( ! sourceFile ) {
121+ return {
122+ status : "error" ,
123+ diagnostics : [ ] ,
124+ message : "Unable to prepare playground input for typechecking." ,
125+ }
126+ }
127+
128+ const diagnostics = [
129+ ...program . getSyntacticDiagnostics ( sourceFile ) ,
130+ ...program . getSemanticDiagnostics ( sourceFile ) ,
131+ ]
132+
133+ return {
134+ status : "ready" ,
135+ diagnostics : diagnostics . map ( ( diagnostic ) =>
136+ toEditorDiagnostic ( typescript , diagnostic , inputSource ) ,
137+ ) ,
138+ }
139+ }
140+
141+ function createVirtualProgram (
142+ typescript : typeof ts ,
143+ inputText : string ,
144+ generatedText : string ,
145+ ) : ts . Program {
146+ const files = new Map ( [
147+ [ inputFileName , inputText ] ,
148+ [ generatedFileName , generatedText ] ,
149+ [ libFileName , clientLibDts ] ,
150+ ] )
151+ const options : ts . CompilerOptions = {
152+ target : typescript . ScriptTarget . ESNext ,
153+ module : typescript . ModuleKind . ESNext ,
154+ moduleResolution : typescript . ModuleResolutionKind . Bundler ,
155+ strict : false ,
156+ skipLibCheck : true ,
157+ noEmit : true ,
158+ noLib : true ,
159+ }
160+ const host = typescript . createCompilerHost ( options , true )
161+
162+ host . getSourceFile = ( fileName , languageVersion ) => {
163+ const text = files . get ( normalizeFileName ( fileName ) )
164+ return text === undefined
165+ ? undefined
166+ : typescript . createSourceFile ( fileName , text , languageVersion , true )
167+ }
168+ host . fileExists = ( fileName ) => files . has ( normalizeFileName ( fileName ) )
169+ host . readFile = ( fileName ) => files . get ( normalizeFileName ( fileName ) )
170+ host . writeFile = ( ) => { }
171+ host . getDefaultLibFileName = ( ) => libFileName
172+ host . getCurrentDirectory = ( ) => "/"
173+ host . getCanonicalFileName = normalizeFileName
174+ host . useCaseSensitiveFileNames = ( ) => true
175+ host . getNewLine = ( ) => "\n"
176+ host . resolveModuleNames = ( moduleNames ) =>
177+ moduleNames . map ( ( moduleName ) =>
178+ moduleName === "./playground.schema.gen"
179+ ? {
180+ resolvedFileName : generatedFileName ,
181+ extension : typescript . Extension . Ts ,
182+ isExternalLibraryImport : false ,
183+ }
184+ : undefined ,
185+ )
186+
187+ return typescript . createProgram ( [ inputFileName ] , options , host )
188+ }
189+
190+ function toEditorDiagnostic (
191+ typescript : typeof ts ,
192+ diagnostic : ts . Diagnostic ,
193+ inputSource : string ,
194+ ) : InputTypecheckDiagnostic {
195+ const inputStart = inputPrefix . length
196+ const inputEnd = inputStart + inputSource . length
197+ const fallbackStart = firstNonWhitespaceOffset ( inputSource )
198+ const rawStart = diagnostic . start ?? inputStart + fallbackStart
199+ const mappedStartOffset =
200+ rawStart >= inputStart && rawStart <= inputEnd ? rawStart - inputStart : fallbackStart
201+ const startOffset = valueStartForTypeMismatch (
202+ inputSource ,
203+ mappedStartOffset ,
204+ flattenDiagnosticMessage ( typescript , diagnostic ) ,
205+ )
206+ const endOffset = Math . min ( expandDiagnosticEnd ( inputSource , startOffset ) , inputSource . length )
207+ const start = offsetToPosition ( inputSource , startOffset )
208+ const end = offsetToPosition ( inputSource , endOffset )
209+
210+ return {
211+ code : diagnostic . code ,
212+ category : diagnosticCategory ( typescript , diagnostic . category ) ,
213+ message : flattenDiagnosticMessage ( typescript , diagnostic ) ,
214+ startOffset,
215+ endOffset,
216+ line : start . line ,
217+ column : start . column ,
218+ endLine : end . line ,
219+ endColumn : end . column ,
220+ }
221+ }
222+
223+ function diagnosticCategory ( typescript : typeof ts , category : ts . DiagnosticCategory ) : string {
224+ return typescript . DiagnosticCategory [ category ] ?. toLowerCase ( ) ?? "error"
225+ }
226+
227+ function flattenDiagnosticMessage ( typescript : typeof ts , diagnostic : ts . Diagnostic ) : string {
228+ return typescript . flattenDiagnosticMessageText ( diagnostic . messageText , "\n" )
229+ }
230+
231+ function valueStartForTypeMismatch ( text : string , startOffset : number , message : string ) : number {
232+ if ( ! message . startsWith ( "Type " ) || ! message . includes ( " is not assignable to type " ) ) {
233+ return startOffset
234+ }
235+
236+ const keyMatch = / " (?: \\ .| [ ^ " \\ ] ) * " / y
237+ keyMatch . lastIndex = startOffset
238+ const match = keyMatch . exec ( text )
239+ if ( ! match ) return startOffset
240+
241+ let index = startOffset + match [ 0 ] . length
242+ index = skipWhitespace ( text , index )
243+ if ( text [ index ] !== ":" ) return startOffset
244+ index = skipWhitespace ( text , index + 1 )
245+ return index < text . length ? index : startOffset
246+ }
247+
248+ function firstNonWhitespaceOffset ( text : string ) : number {
249+ const match = / \S / . exec ( text )
250+ return match ?. index ?? 0
251+ }
252+
253+ function expandDiagnosticEnd ( text : string , startOffset : number ) : number {
254+ const tokenEnd = endOfToken ( text , startOffset )
255+ if ( tokenEnd !== null ) return tokenEnd
256+ return expandFallbackEnd ( text , startOffset )
257+ }
258+
259+ function expandFallbackEnd ( text : string , startOffset : number ) : number {
260+ const lineEnd = text . indexOf ( "\n" , startOffset )
261+ const end = lineEnd === - 1 ? text . length : lineEnd
262+ return Math . max ( startOffset + 1 , end )
263+ }
264+
265+ function endOfToken ( text : string , offset : number ) : number | null {
266+ const start = Math . max ( 0 , Math . min ( offset , text . length ) )
267+ const tokenPattern =
268+ / " (?: \\ .| [ ^ " \\ ] ) * " | t r u e | f a l s e | n u l l | - ? \d + (?: \. \d + ) ? (?: [ e E ] [ + - ] ? \d + ) ? | [ A - Z a - z _ $ ] [ \w $ ] * / g
269+ for ( const match of text . matchAll ( tokenPattern ) ) {
270+ const tokenStart = match . index
271+ if ( tokenStart === undefined ) continue
272+ const tokenEnd = tokenStart + match [ 0 ] . length
273+ if ( tokenStart <= start && start < tokenEnd ) return tokenEnd
274+ if ( start < tokenStart ) return tokenEnd
275+ }
276+ return null
277+ }
278+
279+ function skipWhitespace ( text : string , offset : number ) {
280+ let index = offset
281+ while ( / \s / . test ( text [ index ] ?? "" ) ) {
282+ index += 1
283+ }
284+ return index
285+ }
286+
287+ function offsetToPosition (
288+ text : string ,
289+ offset : number ,
290+ ) : { readonly line : number ; readonly column : number } {
291+ const safeOffset = Math . max ( 0 , Math . min ( offset , text . length ) )
292+ const before = text . slice ( 0 , safeOffset )
293+ const lines = before . split ( "\n" )
294+ return {
295+ line : lines . length - 1 ,
296+ column : lines [ lines . length - 1 ] ?. length ?? 0 ,
297+ }
298+ }
299+
300+ function normalizeFileName ( fileName : string ) : string {
301+ return fileName . startsWith ( "/" ) ? fileName : `/${ fileName } `
302+ }
0 commit comments