Skip to content

Commit bd8eaeb

Browse files
committed
Use tsgo for playground typechecking
1 parent 17eae04 commit bd8eaeb

3 files changed

Lines changed: 150 additions & 34 deletions

File tree

bun.lock

Lines changed: 1 addition & 1 deletion
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 & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"type": "module",
66
"scripts": {
77
"dev": "vite --host 127.0.0.1",
8-
"build": "tsc -b && vite build",
8+
"build": "tsgo -b && vite build",
99
"preview": "vite preview --host 127.0.0.1"
1010
},
1111
"dependencies": {
@@ -16,10 +16,10 @@
1616
},
1717
"devDependencies": {
1818
"@tskm/compiler": "workspace:*",
19+
"@typescript/native-preview": "7.0.0-dev.20260601.1",
1920
"@types/react": "19.2.0",
2021
"@types/react-dom": "19.2.0",
2122
"@vitejs/plugin-react": "5.1.1",
22-
"typescript": "6.0.3",
2323
"vite": "8.0.16"
2424
}
2525
}

playgrounds/tskm/vite.config.ts

Lines changed: 147 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
1+
import { execFile } from "node:child_process"
12
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
23
import type { IncomingMessage, ServerResponse } from "node:http"
34
import { tmpdir } from "node:os"
4-
import { join, relative } from "node:path"
5+
import { isAbsolute, join, relative, resolve } from "node:path"
56
import { fileURLToPath, URL } from "node:url"
6-
import { generate } from "@tskm/compiler"
7+
import { generate, resolveTsgoExecutable } from "@tskm/compiler"
78
import react from "@vitejs/plugin-react"
8-
import * as ts from "typescript"
99
import { defineConfig, type Plugin } from "vite"
1010

1111
const runtimeEntry = fileURLToPath(new URL("../../packages/tskm/src/index.ts", import.meta.url))
1212
const typegenEndpoint = "/__tskm_playground/typegen"
1313
const typecheckEndpoint = "/__tskm_playground/typecheck"
14+
const ansiEscapePattern = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g")
1415

1516
export 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+
175210
function 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

198226
function 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+TS(\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+
237311
function 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+
242322
function 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+
/"(?:\\.|[^"\\])*"|true|false|null|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|[A-Za-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+
248360
function 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+
261377
function playgroundTsconfig(root: string): string {
262378
return `${JSON.stringify(
263379
{

0 commit comments

Comments
 (0)