Skip to content

Commit f64b0f6

Browse files
committed
Enable production playground typecheck
1 parent bd8eaeb commit f64b0f6

6 files changed

Lines changed: 271 additions & 2 deletions

File tree

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

playgrounds/tskm/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
"@tskm/core": "workspace:*",
1313
"react": "19.2.0",
1414
"react-dom": "19.2.0",
15-
"shiki": "4.2.0"
15+
"shiki": "4.2.0",
16+
"typescript": "6.0.3"
1617
},
1718
"devDependencies": {
1819
"@tskm/compiler": "workspace:*",

playgrounds/tskm/src/lib/generated-type.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,17 @@ type TskmGenResponse = TskmGenSuccess | TskmGenFailure
2424

2525
const header = "// AUTO-GENERATED by tskm. Do not edit."
2626
const generationEndpoint = "/__tskm_playground/typegen"
27+
const hasGenerationEndpoint =
28+
import.meta.env.DEV || import.meta.env.VITE_TSKM_PLAYGROUND_API === "1"
2729

2830
export async function fetchGeneratedType(
2931
schemaSource: string,
3032
signal: AbortSignal,
3133
): Promise<GeneratedTypeState> {
34+
if (!hasGenerationEndpoint) {
35+
return renderFallbackContent(schemaSource)
36+
}
37+
3238
const response = await fetch(generationEndpoint, {
3339
method: "POST",
3440
headers: { "content-type": "application/json" },
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { expect, test } from "bun:test"
2+
import { typecheckInputInBrowser } from "./typecheck.ts"
3+
4+
test("typechecks playground input with the browser fallback compiler", async () => {
5+
const inputSource = `{
6+
"role": "guest",
7+
"count": "1"
8+
}`
9+
const result = await typecheckInputInBrowser(
10+
`object({
11+
role: picklist(["owner", "viewer"]),
12+
count: number(),
13+
})`,
14+
inputSource,
15+
)
16+
17+
expect(result.status).toBe("ready")
18+
expect(result.diagnostics).toHaveLength(2)
19+
expect(result.diagnostics[0]?.message).toContain('"guest"')
20+
expect(result.diagnostics[0]?.startOffset).toBe(inputSource.indexOf('"guest"'))
21+
expect(result.diagnostics[1]?.message).toContain("string")
22+
expect(result.diagnostics[1]?.startOffset).toBe(inputSource.indexOf('"1"'))
23+
})

playgrounds/tskm/src/lib/typecheck.ts

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import type * as ts from "typescript"
2+
import { renderFallbackContent } from "./generated-type.ts"
3+
14
export interface InputTypecheckDiagnostic {
25
readonly code: number
36
readonly category: string
@@ -32,12 +35,45 @@ interface TypecheckFailure {
3235
type TypecheckResponse = TypecheckSuccess | TypecheckFailure
3336

3437
const 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

3668
export 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+
/"(?:\\.|[^"\\])*"|true|false|null|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|[A-Za-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+
}

playgrounds/tskm/tsconfig.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,6 @@
99
"@tskm/core": ["../../packages/tskm/src/index.ts"]
1010
}
1111
},
12-
"include": ["src", "vite.config.ts"]
12+
"include": ["src", "vite.config.ts"],
13+
"exclude": ["src/**/*.test.ts"]
1314
}

0 commit comments

Comments
 (0)