From 13f7708ad88aa6cc33ab46849a5a8a557d79ccba Mon Sep 17 00:00:00 2001 From: guyutongxue Date: Sat, 19 Sep 2026 12:11:44 +0800 Subject: [PATCH 1/6] feat: imrpove perf by Codex --- .../content/docs/internal/language-tooling.md | 34 +++ .../language-plugin/src/language_plugin.ts | 8 +- packages/language-plugin/src/virtual_code.ts | 6 +- .../__tests__/fixtures/typing_provider.ts | 76 +++++++ .../__tests__/typing_semantics.test.ts | 203 ++++++++++++++++++ packages/transpiler/src/index.ts | 8 +- .../transpiler/src/transform/volar/index.ts | 23 +- .../src/transform/volar/replacements.ts | 97 +++++---- .../transpiler/src/transform/volar/walker.ts | 8 + packages/tsc/BENCHMARK.md | 66 ++++++ packages/tsc/scripts/benchmark.mjs | 53 +++++ packages/tsc/src/index.ts | 7 +- 12 files changed, 534 insertions(+), 55 deletions(-) create mode 100644 packages/transpiler/__tests__/fixtures/typing_provider.ts create mode 100644 packages/transpiler/__tests__/typing_semantics.test.ts create mode 100644 packages/tsc/BENCHMARK.md create mode 100644 packages/tsc/scripts/benchmark.mjs diff --git a/docs/content/docs/internal/language-tooling.md b/docs/content/docs/internal/language-tooling.md index d5a333b..53d79b8 100644 --- a/docs/content/docs/internal/language-tooling.md +++ b/docs/content/docs/internal/language-tooling.md @@ -58,6 +58,40 @@ class GtsVirtualCode implements VirtualCode { **Error recovery:** When transpilation fails, the virtual code returns a snapshot filled with spaces (matching the source line lengths). This prevents the language server from crashing while still providing the source location for error diagnostics. +### Type-checking code generation + +`gtsc` passes `typeCheckingOnly: true` to the language plugin. This omits +attribute-name completion receivers and expressions. It retains the hint +attributes' uniqueness probes and collected names, since these can affect a +provider's validation. Editors use the default output with completion mappings. + +The generated file shares helpers for member lookup, required attributes, and +Meta merging. Conditional lookups remain non-distributive. The `~meta` fields on +`uniqueKey()` and `as()` receivers deliberately stay inline: passing Meta as a +generic argument can evaluate it too early and introduce circular inference. +The zero-key guard must likewise precede evaluation of the merged uniqueness +probe interface. + +Required-attribute diagnostics map the start and end of their generated +argument to the original block's endpoints. This preserves full-block +diagnostics without padding each diagnostic string to the block's length. + +To measure a project with the local toolchain: + +```sh +pnpm --filter @gi-tcg/gtsc... build +node --max-old-space-size=4096 packages/tsc/scripts/benchmark.mjs \ + --noEmit --incremental false --extendedDiagnostics --pretty false \ + -p ../genius-invokation/packages/data/tsconfig.json +``` + +The script adds source/generated byte counts, mapping counts, and peak RSS to +TypeScript's diagnostics. Compare the same compiler, dependencies, project +options, and heap limit; peak RSS and TypeScript's reported heap usage measure +different things. If a production checkout has stale declaration output, use +a separate benchmark tsconfig with `paths` pointing at its sources, and compare +the complete diagnostics before interpreting performance results. + ## Language Server (`@gi-tcg/gts-language-server`) The language server implements the Language Server Protocol (LSP). It has two entry points **Node.js Server** (`node.ts`) and ****Browser Server** (`browser.ts`). diff --git a/packages/language-plugin/src/language_plugin.ts b/packages/language-plugin/src/language_plugin.ts index 8f5ceff..76a29a2 100644 --- a/packages/language-plugin/src/language_plugin.ts +++ b/packages/language-plugin/src/language_plugin.ts @@ -13,6 +13,7 @@ type Ts = typeof ts; export interface GtsLanguagePluginInlineConfig extends GtsConfig { pathModule?: PathModule; + typeCheckingOnly?: boolean; } export function createGtsLanguagePlugin( @@ -35,7 +36,12 @@ export function createGtsLanguagePlugin( readFileFn: (path, encoding) => ts.sys?.readFile?.(path, encoding) || "", }); - return new GtsVirtualCode(filename, snapshot, resolvedConfig); + return new GtsVirtualCode( + filename, + snapshot, + resolvedConfig, + inlineConfig.typeCheckingOnly, + ); } }, typescript: { diff --git a/packages/language-plugin/src/virtual_code.ts b/packages/language-plugin/src/virtual_code.ts index a213d81..dee3eae 100644 --- a/packages/language-plugin/src/virtual_code.ts +++ b/packages/language-plugin/src/virtual_code.ts @@ -17,10 +17,14 @@ export class GtsVirtualCode implements VirtualCode { filename: string, snapshot: ts.IScriptSnapshot, config: Required, + typeCheckingOnly = false, ) { const source = snapshot.getText(0, snapshot.getLength()); try { - const { code, mappings } = transpileForVolar(source, filename, config); + const { code, mappings } = transpileForVolar(source, filename, { + ...config, + typeCheckingOnly, + }); this.errors = []; this.mappings = mappings; this.snapshot = { diff --git a/packages/transpiler/__tests__/fixtures/typing_provider.ts b/packages/transpiler/__tests__/fixtures/typing_provider.ts new file mode 100644 index 0000000..6fcfa09 --- /dev/null +++ b/packages/transpiler/__tests__/fixtures/typing_provider.ts @@ -0,0 +1,76 @@ +type This = { "~meta": M }; +type Done = { namedDefinition: { "~meta": void } }; +type With = { namedDefinition: D }; +type Rewrite = Done & { rewriteMeta: M }; +type Meta = { names: string; mode: boolean }; +interface Context { + get(name: M["names"]): number; +} +interface Definition { + "~meta": { names: never; mode: false }; + id: { + (value: number): Done; + required(): true; + uniqueKey(): "id"; + as(this: This): M; + }; + plainId: { (id: number): Done; as(): number }; + fromId( + id: Id, + ): Rewrite<{ names: never; mode: false; id: Id }>; + variable: { + ( + this: This, + name: N, + ): Rewrite<{ names: M["names"] | N; mode: M["mode"] }>; + }; + since: { (version: number): Done; uniqueKey(): "version" }; + until: { (version: number): Done; uniqueKey(): "version" }; + enable: { (): Rewrite<{ names: never; mode: true }> }; + conditional: { (): Done; required(this: This): M["mode"] }; + child: { + (): With; + mergeMeta( + meta: M, + child: N, + ): { names: M["names"] | N["names"]; mode: M["mode"] }; + }; + when(this: This, fn: (ctx: Context) => boolean): Done; + overloaded: { + (value: number): Rewrite<{ names: "number"; mode: false }>; + (value: string): Rewrite<{ names: "string"; mode: false }>; + }; + "~action"( + this: This, + action: (ctx: Context) => void, + ): Done; +} +declare const root: { + "~namedDefinition": { + "~meta": unknown; + item(): With; + scoped(): With<{ + "~meta": { first: "a"; second: "b" }; + first: { + (): Done; + uniqueKey(this: This): M["first"]; + }; + second: { + (): Done; + uniqueKey(this: This): M["second"]; + }; + }>; + union(): + | With<{ "~meta": { kind: "a" }; value(value: number): Done }> + | With<{ "~meta": { kind: "b" }; value(value: number): Done }>; + anything(): any; + hintProbe(): With<{ + "~meta": void; + "~attrNameHint": { (): Done; uniqueKey(): "shared" }; + first: { (): Done; uniqueKey(): "shared" }; + }>; + }; +}; +export default root; +export declare function createBinding(): void; +export declare function createDefine(): void; diff --git a/packages/transpiler/__tests__/typing_semantics.test.ts b/packages/transpiler/__tests__/typing_semantics.test.ts new file mode 100644 index 0000000..ff0b921 --- /dev/null +++ b/packages/transpiler/__tests__/typing_semantics.test.ts @@ -0,0 +1,203 @@ +/// + +import { describe, expect, test } from "vitest"; +import ts from "typescript"; +import { SourceMap } from "@volar/language-core"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { transpileForVolar } from "../src/index.ts"; + +const provider = readFileSync( + new URL("./fixtures/typing_provider.ts", import.meta.url), + "utf8", +); +const assertions = ` + type Equal = (() => T extends A ? 1 : 2) extends + (() => T extends B ? 1 : 2) ? true : false; + type Assert = T; +`; + +function check(source: string, typeCheckingOnly: boolean) { + const filename = path.resolve("/virtual/typing.gts.ts"); + const providerFilename = path.resolve("/virtual/provider.ts"); + const result = transpileForVolar(source, filename, { + providerImportSource: "provider", + runtimeImportSource: "provider", + typeCheckingOnly, + }); + const options: ts.CompilerOptions = { + strict: true, + erasableSyntaxOnly: true, + noEmit: true, + types: [], + target: ts.ScriptTarget.ESNext, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + skipLibCheck: true, + }; + const host = ts.createCompilerHost(options); + const original = host.getSourceFile.bind(host); + host.getSourceFile = (name, languageVersion, onError, createNew) => { + if (name === filename || name === providerFilename) { + return ts.createSourceFile( + name, + name === filename ? result.code : provider, + languageVersion, + true, + ); + } + return original(name, languageVersion, onError, createNew); + }; + host.resolveModuleNames = (names) => + names.map(() => ({ + resolvedFileName: providerFilename, + extension: ts.Extension.Ts, + })); + const program = ts.createProgram([filename], options, host); + const diagnostics = ts.getPreEmitDiagnostics(program); + const map = new SourceMap(result.mappings); + const mapped = diagnostics.flatMap((diagnostic) => { + if ( + diagnostic.file?.fileName !== filename || + diagnostic.start === undefined + ) { + return []; + } + const range = map + .toSourceRange( + diagnostic.start, + diagnostic.start + (diagnostic.length ?? 0), + true, + (data) => !!data.verification, + ) + .next().value; + return range + ? [ + { + code: diagnostic.code, + text: source.slice(range[0], range[1]), + message: ts.flattenDiagnosticMessageText( + diagnostic.messageText, + "\n", + ), + }, + ] + : []; + }); + return { + diagnostics: diagnostics.map((d) => + ts.flattenDiagnosticMessageText(d.messageText, "\n"), + ), + mapped, + ...result, + }; +} + +describe.each([false, true])("typeCheckingOnly: %s", (typeCheckingOnly) => { + test.each( + Object.entries({ + "Meta rewriting, merging, and final bindings": ` + define item { + id 1 as Item; + variable foo; + child { id 2; variable bar; }; + when :( :get("foo") > 0 ); + :get("bar"); + }; + type Names = Assert>; + type Mode = Assert>; + `, + "overload resolution": ` + define item { id 1 as Item; overloaded "a"; :get("string"); }; + type Names = Assert>; + `, + "bindings referenced by later Meta updates": ` + define item { id 1; plainId 1 as Id; fromId Id; }; + type IdType = Assert>; + `, + "conditional required attributes": ` + define item { id 1; enable; conditional; }; + `, + "independent definition scopes": ` + define item { id 1; since 1; }; + define item { id 2; until 2; }; + `, + "Meta-dependent unique keys": ` + define scoped { first; second; }; + `, + "union return types": ` + define union { value 1; }; + `, + }), + )("preserves %s", (_name, source) => { + expect(check(assertions + source, typeCheckingOnly).diagnostics).toEqual( + [], + ); + }); + + test.each([ + [ + "duplicate keys", + "define item { id 1; since 1; until 2; };", + ["since", "until"], + 2339, + ], + [ + "duplicate attributes", + "define item { id 1; id 2; };", + ["id", "id"], + 2339, + ], + ["required attribute", "define item {};", ["{}"], 2345], + ["omitted block", "define item;", ["item"], 2345], + [ + "conditional requirement", + "define item { id 1; enable; };", + ["{ id 1; enable; }"], + 2345, + ], + ["argument type", 'define item { id "wrong"; };', ['"wrong"'], 2345], + [ + "action context", + 'define item { id 1; variable foo; :get("wrong"); };', + ['"wrong"'], + 2345, + ], + ["attribute name", "define item { id 1; unknown; };", ["unknown"], 2339], + ["untyped definition", "define anything { value 1; };", ["value"], 2339], + ["hint probes", "define hintProbe { first; };", ["first"], 2339], + ] as const)( + "reports %s at the original source range", + (_name, source, texts, code) => { + const { mapped } = check(source, typeCheckingOnly); + expect(mapped.map(({ code, text }) => ({ code, text }))).toEqual( + texts.map((text) => ({ code, text })), + ); + }, + ); + + test("maps a required-attribute error across a long block without padding", () => { + const block = `{\n${" // comment\n".repeat(200)} enable;\n}`; + const { mapped, code } = check(`define item ${block};`, typeCheckingOnly); + expect(mapped).toHaveLength(1); + expect(mapped[0].text).toBe(block); + expect(mapped[0].message).toContain( + "'id' is a required attribute but not provided", + ); + expect(code).not.toContain("0".repeat(100)); + }); +}); + +test("keeps completion mappings in editor output only", () => { + const source = "define item {\n id 1;\n \n};"; + const editor = transpileForVolar(source, "test.gts", {}); + const tsc = transpileForVolar(source, "test.gts", { typeCheckingOnly: true }); + expect(editor.code).toContain("ωAttrNameHint"); + expect(tsc.code).not.toContain("ωAttrNameHint"); + expect(tsc.code.length).toBeLessThan(editor.code.length); + const offset = source.indexOf(" \n") + 1; + const map = new SourceMap(editor.mappings); + expect([ + ...map.toGeneratedLocation(offset, (data) => !!data.completion), + ]).not.toEqual([]); +}); diff --git a/packages/transpiler/src/index.ts b/packages/transpiler/src/index.ts index a7b2ded..3829696 100644 --- a/packages/transpiler/src/index.ts +++ b/packages/transpiler/src/index.ts @@ -10,7 +10,10 @@ import { type TranspileResult, } from "./transform/index.ts"; import type { TranspileOption } from "./transform/gts.ts"; -import type { VolarMappingResult } from "./transform/volar/index.ts"; +import type { + VolarMappingResult, + VolarTranspileOption, +} from "./transform/volar/index.ts"; export { GtsTranspilerError } from "./error.ts"; export type { AST } from "./types.ts"; @@ -29,7 +32,7 @@ export function transpile( export function transpileForVolar( source: string, filename: string, - option: TranspileOption, + option: VolarTranspileOption, ): VolarMappingResult { const ast = parseLoose(source, { recordCallLParens: true, @@ -47,6 +50,7 @@ export type { TranspileOption, TranspileResult, VolarMappingResult, + VolarTranspileOption, }; export { resolveGtsConfig, diff --git a/packages/transpiler/src/transform/volar/index.ts b/packages/transpiler/src/transform/volar/index.ts index 0c2192c..5b8a0f9 100644 --- a/packages/transpiler/src/transform/volar/index.ts +++ b/packages/transpiler/src/transform/volar/index.ts @@ -17,9 +17,14 @@ import { import { getPrintOptions } from "./printer.ts"; import { getContentStartOffset } from "./content_start.ts"; +export interface VolarTranspileOption extends TranspileOption { + /** Omit editor-only expressions, retaining all type validation. */ + typeCheckingOnly?: boolean; +} + export function transformForVolar( ast: Program, - option: TranspileOption, + option: VolarTranspileOption, sourceInfo: Required, ): VolarMappingResult { const state: TypingTranspileState = { @@ -39,6 +44,7 @@ export function transformForVolar( metaTypeIdStack: [], finalMetaTypeIdStack: [], attrsOfCurrentVm: [], + typeCheckingOnly: option.typeCheckingOnly ?? false, sourceNodes: new WeakSet(), attributeNameNodes: new WeakSet(), @@ -91,6 +97,21 @@ export function transformForVolar( code = applyReplacements(state, code, mappings); for (const extraMapping of state.extraMappings) { const genOffset = code.indexOf(extraMapping.generatedNeedle); + if (extraMapping.mapRangeEnds) { + mappings.push({ + sourceOffsets: [ + extraMapping.sourceOffset, + extraMapping.sourceOffset + extraMapping.length, + ], + lengths: [0, 0], + generatedOffsets: [ + genOffset, + genOffset + extraMapping.generatedNeedle.length, + ], + data: VERIFICATION_ONLY_MAPPING_DATA, + }); + continue; + } mappings.push({ sourceOffsets: [extraMapping.sourceOffset], lengths: [extraMapping.length], diff --git a/packages/transpiler/src/transform/volar/replacements.ts b/packages/transpiler/src/transform/volar/replacements.ts index dc96f2c..5f8fb45 100644 --- a/packages/transpiler/src/transform/volar/replacements.ts +++ b/packages/transpiler/src/transform/volar/replacements.ts @@ -95,6 +95,7 @@ export function applyReplacements( const NamedDefinition = JSON.stringify(NamedDefinitionLit.value); const Meta = JSON.stringify(MetaLit.value); const matchInfos: MatchInfo[] = []; + let cumulativeOffset = 0; const result = code.replace( replacementRegex, @@ -104,10 +105,18 @@ export function applyReplacements( ); let replacement: string; if (payload.type === "preface") { + // Tuple checks preserve the non-distributive conditionals that were + // previously emitted separately for each concrete definition. replacement = dedent` - namespace ${state.utilNsId.name} { + declare namespace ${state.utilNsId.name} { export type UniqueKeyProbSegment = "__gts_unique_prob_seg__"; export type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never; + export type WithMeta = { ${Meta}: M } & Omit; + export type Member = [D] extends [Record>] ? V : F; + export type RequiredAttrs = { [K in keyof D]: L[K] extends { required(this: L): true } ? K : never }[keyof D]; + export type RequiredMessage = { [K in E]: K extends C ? never : \`'\${K & (string | number)}' is a required attribute but not provided\` }[E]; + export function checkRequired(value: [E] extends [C] ? string : E): void; + export type MergeMeta = Member(x: T, y: unknown) => T>; } `; } else if (payload.type === "enterVMFromRoot") { @@ -121,36 +130,27 @@ export function applyReplacements( type ${payload.metaType} = ${payload.defType}[${Meta}]; `; } else if (payload.type === "exitVM") { - const lhs = `${payload.finalMetaType}_lhs`; const requiredAttrsNs = `${payload.finalMetaType}_rans`; - const collectedAttrsExpr = `${payload.collectedAttrs.join(" | ") || "never"}`; + const collectedAttrsExpr = + [...new Set(payload.collectedAttrs)].join(" | ") || "never"; const length = payload.errorRange ? payload.errorRange[1] - payload.errorRange[0] : 0; - // Ensure that generated needle string is longer than error range so that error squiggle can cover all - const needleString = `"${requiredAttrsNs}_NeedleString${"0".repeat(length)}" as string as ${requiredAttrsNs}.DiagMsg`; + // Map both ends explicitly; nested blocks no longer need a copy of + // their entire source length as padding in every diagnostic string. + const needleString = `"${requiredAttrsNs}" as string as ${state.utilNsId.name}.RequiredMessage<${requiredAttrsNs}, ${collectedAttrsExpr}>`; if (payload.errorRange) { state.extraMappings.push({ sourceOffset: payload.errorRange[0], length, generatedNeedle: needleString, + mapRangeEnds: true, }); } replacement = dedent` type ${payload.finalMetaType} = ${payload.metaType}; - let ${lhs}!: { ${Meta}: ${payload.metaType} } & Omit<${payload.defType}, ${Meta}>; - type ${lhs} = typeof ${lhs}; - namespace ${requiredAttrsNs} { - export type Collected = ${collectedAttrsExpr}; - export type Expected = { - [K in keyof ${payload.defType}]: ${lhs}[K] extends { required(this: ${lhs}): true } ? K : never; - }[keyof ${payload.defType}]; - type DiagObj = { - [K in Expected]: K extends Collected ? never : \`'\${K}' is a required attribute but not provided\`; - } - export type DiagMsg = DiagObj[Expected]; - }; - ((_: ${requiredAttrsNs}.Expected extends ${requiredAttrsNs}.Collected ? string : ${requiredAttrsNs}.Expected) => 0)(${needleString}); + type ${requiredAttrsNs} = ${state.utilNsId.name}.RequiredAttrs<${payload.defType}, ${state.utilNsId.name}.WithMeta<${payload.defType}, ${payload.metaType}>>; + ${state.utilNsId.name}.checkRequired<${requiredAttrsNs}, ${collectedAttrsExpr}>(${needleString}); `; } else if (payload.type === "enterAttr") { const uniqueKeyLhs = `${payload.lhs}_uniqueKey_lhs`; @@ -158,40 +158,33 @@ export function applyReplacements( const uniqueKeyForThis = `${payload.lhs}_uniqueKeyFor_${payload.lhs}`; const uniqueKeyHelperIntf = `${payload.defType}_uniqueKeyProbeHelper`; const omittedKeys = `${payload.lhs}_omittedKeys`; + // Keep Meta inside the receiver's property. Passing it to a generic + // receiver alias eagerly resolves later probes and can form a cycle. replacement = dedent` - type ${uniqueKeyLhs} = { - ${Meta}: ${payload.metaType}; - uniqueKey: ${payload.defType} extends { [${payload.attrName}]: { uniqueKey: infer UniqueKey } } ? UniqueKey : () => 0; - }; - - let ${uniqueKeyLhs}!: ${uniqueKeyLhs}; + declare const ${uniqueKeyLhs}: { ${Meta}: ${payload.metaType}; uniqueKey: ${state.utilNsId.name}.Member<${payload.defType}, ${payload.attrName}, "uniqueKey", () => 0> }; let ${uniqueKey} = ${uniqueKeyLhs}.uniqueKey(); type ${uniqueKey} = typeof ${uniqueKey}; let ${uniqueKeyForThis}!: \`\${${uniqueKey}}\${${state.utilNsId.name}.UniqueKeyProbSegment}${payload.lhs}\`; interface ${uniqueKeyHelperIntf} { [${uniqueKeyForThis}]: 1; } - type ${omittedKeys} = ${Meta} | ( - ${uniqueKey} extends 0 - ? never /* no unique requirement */ - : string extends keyof ${uniqueKeyHelperIntf} - ? keyof ${payload.defType} /* too loose, disable all */ - : ${state.utilNsId.name}.UnionToIntersection< - keyof ${uniqueKeyHelperIntf} & \`\${${uniqueKey}}\${${state.utilNsId.name}.UniqueKeyProbSegment}\${string}\` - > extends never - ? ${payload.attrName} /* have duplicate, disable this */ - : never - ); + `; + // The probe above can affect other attributes. Only the completion + // receiver itself is unused by command-line type checking. + if (!payload.hintOnly || !state.typeCheckingOnly) { + // This guard must stay outside a generic helper: evaluating keyof + // the merged probe interface before the zero-key check is recursive. + replacement += dedent` + type ${omittedKeys} = ${Meta} | (${uniqueKey} extends 0 ? never : string extends keyof ${uniqueKeyHelperIntf} ? keyof ${payload.defType} : ${state.utilNsId.name}.UnionToIntersection extends never ? ${payload.attrName} : never); let ${payload.lhs}!: ${payload.hintOnly ? `{}` : `{ ${Meta}: ${payload.metaType} }`} & Omit<${payload.defType}, ${omittedKeys}>; `; + } } else if (payload.type === "createBindingTyping") { const typingIdLhs = `${payload.typingId}_lhs`; + // As with uniqueKey, an as() without a Meta-aware this parameter must + // not force final Meta (which may itself depend on this binding). replacement = dedent` - type ${typingIdLhs} = { - ${Meta}: ${payload.finalMetaType}; - as: ${payload.defType} extends { [${payload.attrName}]: { as: infer As } } ? As : unknown; - }; - let ${typingIdLhs}!: ${typingIdLhs}; + declare const ${typingIdLhs}: { ${Meta}: ${payload.finalMetaType}; as: ${state.utilNsId.name}.Member<${payload.defType}, ${payload.attrName}, "as", unknown> }; let ${payload.typingId} = ${typingIdLhs}.as(); type ${payload.typingId} = typeof ${payload.typingId}; `; @@ -202,18 +195,17 @@ export function applyReplacements( replacement = dedent` type ${payload.returnType} = typeof ${payload.returnType}; type ${rewrittenMeta} = ${payload.returnType} extends { rewriteMeta: infer NewMeta extends {} } ? NewMeta : ${payload.oldMetaType}; - let ${mergeFn}!: ${payload.defType} extends { - [${payload.attrName}]: { mergeMeta: infer M } - } ? M : (x: T, y: unknown) => T; + declare const ${mergeFn}: ${state.utilNsId.name}.MergeMeta<${payload.defType}, ${payload.attrName}>; let ${mergeFnRet} = ${mergeFn}(null! as ${rewrittenMeta}, null! as ${payload.innerMetaType}); type ${payload.newMetaType} = [typeof ${mergeFn}] extends [null] ? ${rewrittenMeta} : typeof ${mergeFnRet}; `; } else { replacement = ""; } + cumulativeOffset += replacement.length - match.length; matchInfos.push({ sourceEnd: offset + match.length, - lengthOffset: replacement.length - match.length, + lengthOffset: cumulativeOffset, }); return replacement; }, @@ -222,13 +214,20 @@ export function applyReplacements( for (const mapping of mappings) { for (let i = 0; i < mapping.generatedOffsets.length; i++) { const orig = mapping.generatedOffsets[i]; - let shift = 0; - for (const info of matchInfos) { - if (orig >= info.sourceEnd) { - shift += info.lengthOffset; + // Replacements are ordered, but source mappings need not be. Find the + // last preceding replacement instead of rescanning every replacement. + let low = 0; + let high = matchInfos.length; + while (low < high) { + const mid = (low + high) >>> 1; + if (matchInfos[mid].sourceEnd <= orig) { + low = mid + 1; + } else { + high = mid; } } - mapping.generatedOffsets[i] = orig + shift; + mapping.generatedOffsets[i] = + orig + (matchInfos[low - 1]?.lengthOffset ?? 0); } } diff --git a/packages/transpiler/src/transform/volar/walker.ts b/packages/transpiler/src/transform/volar/walker.ts index d78b750..0658074 100644 --- a/packages/transpiler/src/transform/volar/walker.ts +++ b/packages/transpiler/src/transform/volar/walker.ts @@ -32,6 +32,7 @@ interface ExternalizedTypedBinding extends ExternalizedBinding { } export interface TypingTranspileState extends TranspileState { + typeCheckingOnly: boolean; externalizedBindings: ExternalizedTypedBinding[]; idCounter: number; rootVmId: Identifier; @@ -87,6 +88,8 @@ export interface TypingTranspileState extends TranspileState { sourceOffset: number; length: number; generatedNeedle: string; + /** Map diagnostic endpoints without padding the generated expression. */ + mapRangeEnds?: boolean; }[]; /** Character offset after hashbang and file-scope leading comments */ contentStartOffset: number; @@ -284,6 +287,11 @@ const insertHintStatement = ( whiteSpaceEnd: number, ) => { const { lhsId } = enterAttr(state, ATTR_HINT_ATTR_NAME); + // Retain the probe and collected name: providers can define the hint key, + // so removing it altogether could change uniqueness/required checks. + if (state.typeCheckingOnly) { + return; + } state.typingPendingStatements.push({ type: "GTSAttributeNameHintStatement", object: lhsId, diff --git a/packages/tsc/BENCHMARK.md b/packages/tsc/BENCHMARK.md new file mode 100644 index 0000000..5ee0da6 --- /dev/null +++ b/packages/tsc/BENCHMARK.md @@ -0,0 +1,66 @@ +# GTS type-checking benchmark + +Measured with Node 26.3.1 and TypeScript 6.0.3 on the 195 GTS files in +`genius-invokation` at `ab1960bd`. Their total source size is 1,251,902 bytes. +The baseline transpiler is GTS `384c9e0`. Both runs use the same TypeScript, +Volar, project options, and dependency sources, with incremental checking off. + +With an 8 GiB V8 old-space limit: + +| Metric | Baseline | Optimized | Reduction | +| --- | ---: | ---: | ---: | +| Generated TypeScript bytes | 82,675,400 | 50,289,570 | 39.2% | +| TypeScript reported memory | 4,841,860 KiB | 4,110,999 KiB | 15.1% | +| Peak process RSS | 5,656,144 KiB | 4,329,256 KiB | 23.5% | +| Type-checking time | 48.00 s | 38.90 s | 19.0% | +| Total compiler time | 60.94 s | 47.62 s | 21.9% | +| Type instantiations | 9,997,859 | 8,849,363 | 11.5% | + +With a 4 GiB old-space limit, the baseline runs out of memory. The optimized +compiler completes checking, with the same diagnostics as the 8 GiB baseline. +A 3 GiB limit is still insufficient. Times and memory usage are individual +measurements and vary with garbage collection; the heap-limit comparison is +separate from the table above. + +The production checkout had stale declaration output. The benchmark therefore +used a temporary tsconfig extending `packages/data/tsconfig.json`, with `paths` +pointing `@gi-tcg/core`, `@gi-tcg/core/*`, `@gi-tcg/utils`, and `@gi-tcg/typings` +to their source entries, and `@gi-tcg/gts-runtime` to this repository's runtime +source. Both runs completed with the same 91 diagnostics in dependency sources +and **no GTS diagnostics**. This measures completion of checking, not a clean +exit for the whole production project. No files in the production checkout +were changed. + +## Reproduction + +Build the local compiler, then use the included script with ordinary tsc +arguments. It also prints the generated byte count, mapping count, and peak +RSS: + +```sh +pnpm --filter @gi-tcg/gtsc... build +node --max-old-space-size=8192 packages/tsc/scripts/benchmark.mjs \ + --noEmit --incremental false --extendedDiagnostics --pretty false \ + -p ../genius-invokation/packages/data/tsconfig.json +``` + +Use the same temporary source-path configuration for both runs if the production +declarations are stale. Compare full diagnostic output, rather than only exit +codes or error counts. Repeat with `--max-old-space-size=4096` to test the memory +limit. + +## Semantic checks + +`packages/transpiler/__tests__/typing_semantics.test.ts` checks exact inferred +types and mapped diagnostics for Meta rewriting/merging, bindings, overloads, +required and duplicate attributes, Meta-dependent unique keys, union returns, +action contexts, and long diagnostic ranges. The same cases run in editor and +command-line modes. The 36 semantic cases were also run against the baseline +transpiler; the additional test checks preservation of editor completions. + +```sh +pnpm vitest --run packages +pnpm --filter @example/local exec vitest --run +``` + +These pass 125 package tests and the local GTS import test, respectively. diff --git a/packages/tsc/scripts/benchmark.mjs b/packages/tsc/scripts/benchmark.mjs new file mode 100644 index 0000000..41d70c6 --- /dev/null +++ b/packages/tsc/scripts/benchmark.mjs @@ -0,0 +1,53 @@ +#!/usr/bin/env node + +// Run after building @gi-tcg/gtsc and its dependencies. Accepts tsc arguments. +import { createRequire } from "node:module"; +import path from "node:path"; +import { runTsc } from "@volar/typescript/lib/quickstart/runTsc.js"; +import { createGtsLanguagePlugin } from "@gi-tcg/gts-language-plugin"; + +const require = createRequire(import.meta.url); +const tscPath = createRequire(require.resolve("typescript")).resolve( + "typescript/lib/tsc", +); +const files = new Map(); + +process.on("exit", () => { + const totals = { + files: files.size, + sourceBytes: 0, + generatedBytes: 0, + mappings: 0, + }; + for (const file of files.values()) { + totals.sourceBytes += file.sourceBytes; + totals.generatedBytes += file.generatedBytes; + totals.mappings += file.mappings; + } + console.log("GTS generation:", JSON.stringify(totals)); + console.log("Peak RSS (KiB):", process.resourceUsage().maxRSS); +}); + +runTsc(tscPath, [".gts"], (ts) => { + const plugin = createGtsLanguagePlugin(ts, { + pathModule: path, + typeCheckingOnly: true, + }); + const createVirtualCode = plugin.createVirtualCode.bind(plugin); + plugin.createVirtualCode = (id, languageId, snapshot, context) => { + const code = createVirtualCode(id, languageId, snapshot, context); + if (code) { + files.set(id, { + sourceBytes: Buffer.byteLength( + snapshot.getText(0, snapshot.getLength()), + ), + generatedBytes: Buffer.byteLength( + code.snapshot.getText(0, code.snapshot.getLength()), + ), + mappings: code.mappings.length, + }); + } + return code; + }; + return { languagePlugins: [plugin] }; +}); diff --git a/packages/tsc/src/index.ts b/packages/tsc/src/index.ts index f5546e7..31d04f7 100644 --- a/packages/tsc/src/index.ts +++ b/packages/tsc/src/index.ts @@ -4,10 +4,15 @@ import { createRequire } from "node:module"; import path from "node:path"; const require = createRequire(import.meta.url); -const tscPath = require.resolve("typescript/lib/tsc"); +// Resolve from the compiler API so aliases that wrap another TypeScript +// package (such as @typescript/typescript6) reach the actual compiler. +const tscPath = createRequire(require.resolve("typescript")).resolve( + "typescript/lib/tsc", +); runTsc(tscPath, [".gts"], (ts, options) => { const gtsLanguagePlugin = createGtsLanguagePlugin(ts, { pathModule: path, + typeCheckingOnly: true, }); return { languagePlugins: [gtsLanguagePlugin] }; }); From 19095cc33abebff07f60a2eff8384c0bdd3a07e2 Mon Sep 17 00:00:00 2001 From: guyutongxue Date: Fri, 25 Sep 2026 20:19:47 +0800 Subject: [PATCH 2/6] removing slops --- .../content/docs/internal/language-tooling.md | 32 +-- .../language-plugin/src/language_plugin.ts | 10 +- packages/language-plugin/src/virtual_code.ts | 10 +- .../__tests__/typing_semantics.test.ts | 203 ------------------ .../transpiler/src/transform/volar/index.ts | 4 +- .../transpiler/src/transform/volar/walker.ts | 3 +- packages/tsc/scripts/benchmark.mjs | 53 ----- packages/tsc/src/index.ts | 9 +- 8 files changed, 15 insertions(+), 309 deletions(-) delete mode 100644 packages/transpiler/__tests__/typing_semantics.test.ts delete mode 100644 packages/tsc/scripts/benchmark.mjs diff --git a/docs/content/docs/internal/language-tooling.md b/docs/content/docs/internal/language-tooling.md index 53d79b8..335caa7 100644 --- a/docs/content/docs/internal/language-tooling.md +++ b/docs/content/docs/internal/language-tooling.md @@ -60,37 +60,7 @@ class GtsVirtualCode implements VirtualCode { ### Type-checking code generation -`gtsc` passes `typeCheckingOnly: true` to the language plugin. This omits -attribute-name completion receivers and expressions. It retains the hint -attributes' uniqueness probes and collected names, since these can affect a -provider's validation. Editors use the default output with completion mappings. - -The generated file shares helpers for member lookup, required attributes, and -Meta merging. Conditional lookups remain non-distributive. The `~meta` fields on -`uniqueKey()` and `as()` receivers deliberately stay inline: passing Meta as a -generic argument can evaluate it too early and introduce circular inference. -The zero-key guard must likewise precede evaluation of the merged uniqueness -probe interface. - -Required-attribute diagnostics map the start and end of their generated -argument to the original block's endpoints. This preserves full-block -diagnostics without padding each diagnostic string to the block's length. - -To measure a project with the local toolchain: - -```sh -pnpm --filter @gi-tcg/gtsc... build -node --max-old-space-size=4096 packages/tsc/scripts/benchmark.mjs \ - --noEmit --incremental false --extendedDiagnostics --pretty false \ - -p ../genius-invokation/packages/data/tsconfig.json -``` - -The script adds source/generated byte counts, mapping counts, and peak RSS to -TypeScript's diagnostics. Compare the same compiler, dependencies, project -options, and heap limit; peak RSS and TypeScript's reported heap usage measure -different things. If a production checkout has stale declaration output, use -a separate benchmark tsconfig with `paths` pointing at its sources, and compare -the complete diagnostics before interpreting performance results. +`gtsc` passes `typeCheckingOnly: true` to the language plugin. This omits attribute-name completion receivers and expressions to improve performance. ## Language Server (`@gi-tcg/gts-language-server`) diff --git a/packages/language-plugin/src/language_plugin.ts b/packages/language-plugin/src/language_plugin.ts index 76a29a2..5a7708f 100644 --- a/packages/language-plugin/src/language_plugin.ts +++ b/packages/language-plugin/src/language_plugin.ts @@ -36,12 +36,10 @@ export function createGtsLanguagePlugin( readFileFn: (path, encoding) => ts.sys?.readFile?.(path, encoding) || "", }); - return new GtsVirtualCode( - filename, - snapshot, - resolvedConfig, - inlineConfig.typeCheckingOnly, - ); + return new GtsVirtualCode(filename, snapshot, { + ...resolvedConfig, + typeCheckingOnly: !!inlineConfig.typeCheckingOnly, + }); } }, typescript: { diff --git a/packages/language-plugin/src/virtual_code.ts b/packages/language-plugin/src/virtual_code.ts index dee3eae..d755620 100644 --- a/packages/language-plugin/src/virtual_code.ts +++ b/packages/language-plugin/src/virtual_code.ts @@ -1,7 +1,7 @@ import { GtsTranspilerError, transpileForVolar, - type GtsConfig, + type VolarTranspileOption, } from "@gi-tcg/gts-transpiler"; import { type CodeMapping, type VirtualCode } from "@volar/language-core"; import type * as ts from "typescript"; @@ -16,15 +16,11 @@ export class GtsVirtualCode implements VirtualCode { constructor( filename: string, snapshot: ts.IScriptSnapshot, - config: Required, - typeCheckingOnly = false, + config: Required, ) { const source = snapshot.getText(0, snapshot.getLength()); try { - const { code, mappings } = transpileForVolar(source, filename, { - ...config, - typeCheckingOnly, - }); + const { code, mappings } = transpileForVolar(source, filename, config); this.errors = []; this.mappings = mappings; this.snapshot = { diff --git a/packages/transpiler/__tests__/typing_semantics.test.ts b/packages/transpiler/__tests__/typing_semantics.test.ts deleted file mode 100644 index ff0b921..0000000 --- a/packages/transpiler/__tests__/typing_semantics.test.ts +++ /dev/null @@ -1,203 +0,0 @@ -/// - -import { describe, expect, test } from "vitest"; -import ts from "typescript"; -import { SourceMap } from "@volar/language-core"; -import { readFileSync } from "node:fs"; -import path from "node:path"; -import { transpileForVolar } from "../src/index.ts"; - -const provider = readFileSync( - new URL("./fixtures/typing_provider.ts", import.meta.url), - "utf8", -); -const assertions = ` - type Equal = (() => T extends A ? 1 : 2) extends - (() => T extends B ? 1 : 2) ? true : false; - type Assert = T; -`; - -function check(source: string, typeCheckingOnly: boolean) { - const filename = path.resolve("/virtual/typing.gts.ts"); - const providerFilename = path.resolve("/virtual/provider.ts"); - const result = transpileForVolar(source, filename, { - providerImportSource: "provider", - runtimeImportSource: "provider", - typeCheckingOnly, - }); - const options: ts.CompilerOptions = { - strict: true, - erasableSyntaxOnly: true, - noEmit: true, - types: [], - target: ts.ScriptTarget.ESNext, - module: ts.ModuleKind.ESNext, - moduleResolution: ts.ModuleResolutionKind.Bundler, - skipLibCheck: true, - }; - const host = ts.createCompilerHost(options); - const original = host.getSourceFile.bind(host); - host.getSourceFile = (name, languageVersion, onError, createNew) => { - if (name === filename || name === providerFilename) { - return ts.createSourceFile( - name, - name === filename ? result.code : provider, - languageVersion, - true, - ); - } - return original(name, languageVersion, onError, createNew); - }; - host.resolveModuleNames = (names) => - names.map(() => ({ - resolvedFileName: providerFilename, - extension: ts.Extension.Ts, - })); - const program = ts.createProgram([filename], options, host); - const diagnostics = ts.getPreEmitDiagnostics(program); - const map = new SourceMap(result.mappings); - const mapped = diagnostics.flatMap((diagnostic) => { - if ( - diagnostic.file?.fileName !== filename || - diagnostic.start === undefined - ) { - return []; - } - const range = map - .toSourceRange( - diagnostic.start, - diagnostic.start + (diagnostic.length ?? 0), - true, - (data) => !!data.verification, - ) - .next().value; - return range - ? [ - { - code: diagnostic.code, - text: source.slice(range[0], range[1]), - message: ts.flattenDiagnosticMessageText( - diagnostic.messageText, - "\n", - ), - }, - ] - : []; - }); - return { - diagnostics: diagnostics.map((d) => - ts.flattenDiagnosticMessageText(d.messageText, "\n"), - ), - mapped, - ...result, - }; -} - -describe.each([false, true])("typeCheckingOnly: %s", (typeCheckingOnly) => { - test.each( - Object.entries({ - "Meta rewriting, merging, and final bindings": ` - define item { - id 1 as Item; - variable foo; - child { id 2; variable bar; }; - when :( :get("foo") > 0 ); - :get("bar"); - }; - type Names = Assert>; - type Mode = Assert>; - `, - "overload resolution": ` - define item { id 1 as Item; overloaded "a"; :get("string"); }; - type Names = Assert>; - `, - "bindings referenced by later Meta updates": ` - define item { id 1; plainId 1 as Id; fromId Id; }; - type IdType = Assert>; - `, - "conditional required attributes": ` - define item { id 1; enable; conditional; }; - `, - "independent definition scopes": ` - define item { id 1; since 1; }; - define item { id 2; until 2; }; - `, - "Meta-dependent unique keys": ` - define scoped { first; second; }; - `, - "union return types": ` - define union { value 1; }; - `, - }), - )("preserves %s", (_name, source) => { - expect(check(assertions + source, typeCheckingOnly).diagnostics).toEqual( - [], - ); - }); - - test.each([ - [ - "duplicate keys", - "define item { id 1; since 1; until 2; };", - ["since", "until"], - 2339, - ], - [ - "duplicate attributes", - "define item { id 1; id 2; };", - ["id", "id"], - 2339, - ], - ["required attribute", "define item {};", ["{}"], 2345], - ["omitted block", "define item;", ["item"], 2345], - [ - "conditional requirement", - "define item { id 1; enable; };", - ["{ id 1; enable; }"], - 2345, - ], - ["argument type", 'define item { id "wrong"; };', ['"wrong"'], 2345], - [ - "action context", - 'define item { id 1; variable foo; :get("wrong"); };', - ['"wrong"'], - 2345, - ], - ["attribute name", "define item { id 1; unknown; };", ["unknown"], 2339], - ["untyped definition", "define anything { value 1; };", ["value"], 2339], - ["hint probes", "define hintProbe { first; };", ["first"], 2339], - ] as const)( - "reports %s at the original source range", - (_name, source, texts, code) => { - const { mapped } = check(source, typeCheckingOnly); - expect(mapped.map(({ code, text }) => ({ code, text }))).toEqual( - texts.map((text) => ({ code, text })), - ); - }, - ); - - test("maps a required-attribute error across a long block without padding", () => { - const block = `{\n${" // comment\n".repeat(200)} enable;\n}`; - const { mapped, code } = check(`define item ${block};`, typeCheckingOnly); - expect(mapped).toHaveLength(1); - expect(mapped[0].text).toBe(block); - expect(mapped[0].message).toContain( - "'id' is a required attribute but not provided", - ); - expect(code).not.toContain("0".repeat(100)); - }); -}); - -test("keeps completion mappings in editor output only", () => { - const source = "define item {\n id 1;\n \n};"; - const editor = transpileForVolar(source, "test.gts", {}); - const tsc = transpileForVolar(source, "test.gts", { typeCheckingOnly: true }); - expect(editor.code).toContain("ωAttrNameHint"); - expect(tsc.code).not.toContain("ωAttrNameHint"); - expect(tsc.code.length).toBeLessThan(editor.code.length); - const offset = source.indexOf(" \n") + 1; - const map = new SourceMap(editor.mappings); - expect([ - ...map.toGeneratedLocation(offset, (data) => !!data.completion), - ]).not.toEqual([]); -}); diff --git a/packages/transpiler/src/transform/volar/index.ts b/packages/transpiler/src/transform/volar/index.ts index 5b8a0f9..d747541 100644 --- a/packages/transpiler/src/transform/volar/index.ts +++ b/packages/transpiler/src/transform/volar/index.ts @@ -19,7 +19,7 @@ import { getContentStartOffset } from "./content_start.ts"; export interface VolarTranspileOption extends TranspileOption { /** Omit editor-only expressions, retaining all type validation. */ - typeCheckingOnly?: boolean; + typeCheckingOnly: boolean; } export function transformForVolar( @@ -44,7 +44,7 @@ export function transformForVolar( metaTypeIdStack: [], finalMetaTypeIdStack: [], attrsOfCurrentVm: [], - typeCheckingOnly: option.typeCheckingOnly ?? false, + typeCheckingOnly: option.typeCheckingOnly, sourceNodes: new WeakSet(), attributeNameNodes: new WeakSet(), diff --git a/packages/transpiler/src/transform/volar/walker.ts b/packages/transpiler/src/transform/volar/walker.ts index 0658074..977baf3 100644 --- a/packages/transpiler/src/transform/volar/walker.ts +++ b/packages/transpiler/src/transform/volar/walker.ts @@ -287,9 +287,8 @@ const insertHintStatement = ( whiteSpaceEnd: number, ) => { const { lhsId } = enterAttr(state, ATTR_HINT_ATTR_NAME); - // Retain the probe and collected name: providers can define the hint key, - // so removing it altogether could change uniqueness/required checks. if (state.typeCheckingOnly) { + // type-checking do not need insert hint statement return; } state.typingPendingStatements.push({ diff --git a/packages/tsc/scripts/benchmark.mjs b/packages/tsc/scripts/benchmark.mjs deleted file mode 100644 index 41d70c6..0000000 --- a/packages/tsc/scripts/benchmark.mjs +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env node - -// Run after building @gi-tcg/gtsc and its dependencies. Accepts tsc arguments. -import { createRequire } from "node:module"; -import path from "node:path"; -import { runTsc } from "@volar/typescript/lib/quickstart/runTsc.js"; -import { createGtsLanguagePlugin } from "@gi-tcg/gts-language-plugin"; - -const require = createRequire(import.meta.url); -const tscPath = createRequire(require.resolve("typescript")).resolve( - "typescript/lib/tsc", -); -const files = new Map(); - -process.on("exit", () => { - const totals = { - files: files.size, - sourceBytes: 0, - generatedBytes: 0, - mappings: 0, - }; - for (const file of files.values()) { - totals.sourceBytes += file.sourceBytes; - totals.generatedBytes += file.generatedBytes; - totals.mappings += file.mappings; - } - console.log("GTS generation:", JSON.stringify(totals)); - console.log("Peak RSS (KiB):", process.resourceUsage().maxRSS); -}); - -runTsc(tscPath, [".gts"], (ts) => { - const plugin = createGtsLanguagePlugin(ts, { - pathModule: path, - typeCheckingOnly: true, - }); - const createVirtualCode = plugin.createVirtualCode.bind(plugin); - plugin.createVirtualCode = (id, languageId, snapshot, context) => { - const code = createVirtualCode(id, languageId, snapshot, context); - if (code) { - files.set(id, { - sourceBytes: Buffer.byteLength( - snapshot.getText(0, snapshot.getLength()), - ), - generatedBytes: Buffer.byteLength( - code.snapshot.getText(0, code.snapshot.getLength()), - ), - mappings: code.mappings.length, - }); - } - return code; - }; - return { languagePlugins: [plugin] }; -}); diff --git a/packages/tsc/src/index.ts b/packages/tsc/src/index.ts index 31d04f7..268ce41 100644 --- a/packages/tsc/src/index.ts +++ b/packages/tsc/src/index.ts @@ -4,11 +4,10 @@ import { createRequire } from "node:module"; import path from "node:path"; const require = createRequire(import.meta.url); -// Resolve from the compiler API so aliases that wrap another TypeScript -// package (such as @typescript/typescript6) reach the actual compiler. -const tscPath = createRequire(require.resolve("typescript")).resolve( - "typescript/lib/tsc", -); +const tsdk = require.resolve("typescript"); +const tscPath = require.resolve("typescript/lib/tsc", { + paths: [tsdk], +}); runTsc(tscPath, [".gts"], (ts, options) => { const gtsLanguagePlugin = createGtsLanguagePlugin(ts, { pathModule: path, From 6ba4a5f6f793ab89f373726b7db3bb5bcc4c9ee7 Mon Sep 17 00:00:00 2001 From: guyutongxue Date: Fri, 25 Sep 2026 21:03:18 +0800 Subject: [PATCH 3/6] removing slops --- .vscode/launch.json | 2 +- .../__tests__/fixtures/typing_provider.ts | 76 ------------------- .../transpiler/src/transform/volar/index.ts | 28 +++---- .../src/transform/volar/replacements.ts | 1 - .../transpiler/src/transform/volar/walker.ts | 4 +- 5 files changed, 11 insertions(+), 100 deletions(-) delete mode 100644 packages/transpiler/__tests__/fixtures/typing_provider.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index fc281f5..a67f4d1 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -9,7 +9,7 @@ "runtimeExecutable": "${execPath}", "args": [ "--extensionDevelopmentPath=${workspaceRoot}/packages/vscode", - "--folder-uri=${workspaceRoot}/examples/local" + "--folder-uri=${workspaceRoot}/../genius-invokation" ], "outFiles": [ "${workspaceRoot}/packages/vscode/dist/*.js" diff --git a/packages/transpiler/__tests__/fixtures/typing_provider.ts b/packages/transpiler/__tests__/fixtures/typing_provider.ts deleted file mode 100644 index 6fcfa09..0000000 --- a/packages/transpiler/__tests__/fixtures/typing_provider.ts +++ /dev/null @@ -1,76 +0,0 @@ -type This = { "~meta": M }; -type Done = { namedDefinition: { "~meta": void } }; -type With = { namedDefinition: D }; -type Rewrite = Done & { rewriteMeta: M }; -type Meta = { names: string; mode: boolean }; -interface Context { - get(name: M["names"]): number; -} -interface Definition { - "~meta": { names: never; mode: false }; - id: { - (value: number): Done; - required(): true; - uniqueKey(): "id"; - as(this: This): M; - }; - plainId: { (id: number): Done; as(): number }; - fromId( - id: Id, - ): Rewrite<{ names: never; mode: false; id: Id }>; - variable: { - ( - this: This, - name: N, - ): Rewrite<{ names: M["names"] | N; mode: M["mode"] }>; - }; - since: { (version: number): Done; uniqueKey(): "version" }; - until: { (version: number): Done; uniqueKey(): "version" }; - enable: { (): Rewrite<{ names: never; mode: true }> }; - conditional: { (): Done; required(this: This): M["mode"] }; - child: { - (): With; - mergeMeta( - meta: M, - child: N, - ): { names: M["names"] | N["names"]; mode: M["mode"] }; - }; - when(this: This, fn: (ctx: Context) => boolean): Done; - overloaded: { - (value: number): Rewrite<{ names: "number"; mode: false }>; - (value: string): Rewrite<{ names: "string"; mode: false }>; - }; - "~action"( - this: This, - action: (ctx: Context) => void, - ): Done; -} -declare const root: { - "~namedDefinition": { - "~meta": unknown; - item(): With; - scoped(): With<{ - "~meta": { first: "a"; second: "b" }; - first: { - (): Done; - uniqueKey(this: This): M["first"]; - }; - second: { - (): Done; - uniqueKey(this: This): M["second"]; - }; - }>; - union(): - | With<{ "~meta": { kind: "a" }; value(value: number): Done }> - | With<{ "~meta": { kind: "b" }; value(value: number): Done }>; - anything(): any; - hintProbe(): With<{ - "~meta": void; - "~attrNameHint": { (): Done; uniqueKey(): "shared" }; - first: { (): Done; uniqueKey(): "shared" }; - }>; - }; -}; -export default root; -export declare function createBinding(): void; -export declare function createDefine(): void; diff --git a/packages/transpiler/src/transform/volar/index.ts b/packages/transpiler/src/transform/volar/index.ts index d747541..aaf3bc9 100644 --- a/packages/transpiler/src/transform/volar/index.ts +++ b/packages/transpiler/src/transform/volar/index.ts @@ -97,26 +97,16 @@ export function transformForVolar( code = applyReplacements(state, code, mappings); for (const extraMapping of state.extraMappings) { const genOffset = code.indexOf(extraMapping.generatedNeedle); - if (extraMapping.mapRangeEnds) { - mappings.push({ - sourceOffsets: [ - extraMapping.sourceOffset, - extraMapping.sourceOffset + extraMapping.length, - ], - lengths: [0, 0], - generatedOffsets: [ - genOffset, - genOffset + extraMapping.generatedNeedle.length, - ], - data: VERIFICATION_ONLY_MAPPING_DATA, - }); - continue; - } mappings.push({ - sourceOffsets: [extraMapping.sourceOffset], - lengths: [extraMapping.length], - generatedOffsets: [genOffset], - generatedLengths: [extraMapping.generatedNeedle.length], + sourceOffsets: [ + extraMapping.sourceOffset, + extraMapping.sourceOffset + extraMapping.length, + ], + lengths: [0, 0], + generatedOffsets: [ + genOffset, + genOffset + extraMapping.generatedNeedle.length, + ], data: VERIFICATION_ONLY_MAPPING_DATA, }); } diff --git a/packages/transpiler/src/transform/volar/replacements.ts b/packages/transpiler/src/transform/volar/replacements.ts index 5f8fb45..7cf501e 100644 --- a/packages/transpiler/src/transform/volar/replacements.ts +++ b/packages/transpiler/src/transform/volar/replacements.ts @@ -144,7 +144,6 @@ export function applyReplacements( sourceOffset: payload.errorRange[0], length, generatedNeedle: needleString, - mapRangeEnds: true, }); } replacement = dedent` diff --git a/packages/transpiler/src/transform/volar/walker.ts b/packages/transpiler/src/transform/volar/walker.ts index 977baf3..87bc5ac 100644 --- a/packages/transpiler/src/transform/volar/walker.ts +++ b/packages/transpiler/src/transform/volar/walker.ts @@ -88,8 +88,6 @@ export interface TypingTranspileState extends TranspileState { sourceOffset: number; length: number; generatedNeedle: string; - /** Map diagnostic endpoints without padding the generated expression. */ - mapRangeEnds?: boolean; }[]; /** Character offset after hashbang and file-scope leading comments */ contentStartOffset: number; @@ -286,11 +284,11 @@ const insertHintStatement = ( whiteSpaceStart: number, whiteSpaceEnd: number, ) => { - const { lhsId } = enterAttr(state, ATTR_HINT_ATTR_NAME); if (state.typeCheckingOnly) { // type-checking do not need insert hint statement return; } + const { lhsId } = enterAttr(state, ATTR_HINT_ATTR_NAME); state.typingPendingStatements.push({ type: "GTSAttributeNameHintStatement", object: lhsId, From 429860f39e51f789f67e2b1467b42182135ca26e Mon Sep 17 00:00:00 2001 From: guyutongxue Date: Fri, 25 Sep 2026 22:19:39 +0800 Subject: [PATCH 4/6] docs --- .../src/transform/volar/replacements.ts | 4 +- packages/tsc/BENCHMARK.md | 66 ------------------- 2 files changed, 2 insertions(+), 68 deletions(-) delete mode 100644 packages/tsc/BENCHMARK.md diff --git a/packages/transpiler/src/transform/volar/replacements.ts b/packages/transpiler/src/transform/volar/replacements.ts index 7cf501e..7ee3d7d 100644 --- a/packages/transpiler/src/transform/volar/replacements.ts +++ b/packages/transpiler/src/transform/volar/replacements.ts @@ -210,11 +210,11 @@ export function applyReplacements( }, ); + // 调整替换后 mapping 的 generatedOffset + // 由于替换信息 matchInfos 的 sourceEnd 是有序的,可以二分查找到对应的 lengthOffset for (const mapping of mappings) { for (let i = 0; i < mapping.generatedOffsets.length; i++) { const orig = mapping.generatedOffsets[i]; - // Replacements are ordered, but source mappings need not be. Find the - // last preceding replacement instead of rescanning every replacement. let low = 0; let high = matchInfos.length; while (low < high) { diff --git a/packages/tsc/BENCHMARK.md b/packages/tsc/BENCHMARK.md deleted file mode 100644 index 5ee0da6..0000000 --- a/packages/tsc/BENCHMARK.md +++ /dev/null @@ -1,66 +0,0 @@ -# GTS type-checking benchmark - -Measured with Node 26.3.1 and TypeScript 6.0.3 on the 195 GTS files in -`genius-invokation` at `ab1960bd`. Their total source size is 1,251,902 bytes. -The baseline transpiler is GTS `384c9e0`. Both runs use the same TypeScript, -Volar, project options, and dependency sources, with incremental checking off. - -With an 8 GiB V8 old-space limit: - -| Metric | Baseline | Optimized | Reduction | -| --- | ---: | ---: | ---: | -| Generated TypeScript bytes | 82,675,400 | 50,289,570 | 39.2% | -| TypeScript reported memory | 4,841,860 KiB | 4,110,999 KiB | 15.1% | -| Peak process RSS | 5,656,144 KiB | 4,329,256 KiB | 23.5% | -| Type-checking time | 48.00 s | 38.90 s | 19.0% | -| Total compiler time | 60.94 s | 47.62 s | 21.9% | -| Type instantiations | 9,997,859 | 8,849,363 | 11.5% | - -With a 4 GiB old-space limit, the baseline runs out of memory. The optimized -compiler completes checking, with the same diagnostics as the 8 GiB baseline. -A 3 GiB limit is still insufficient. Times and memory usage are individual -measurements and vary with garbage collection; the heap-limit comparison is -separate from the table above. - -The production checkout had stale declaration output. The benchmark therefore -used a temporary tsconfig extending `packages/data/tsconfig.json`, with `paths` -pointing `@gi-tcg/core`, `@gi-tcg/core/*`, `@gi-tcg/utils`, and `@gi-tcg/typings` -to their source entries, and `@gi-tcg/gts-runtime` to this repository's runtime -source. Both runs completed with the same 91 diagnostics in dependency sources -and **no GTS diagnostics**. This measures completion of checking, not a clean -exit for the whole production project. No files in the production checkout -were changed. - -## Reproduction - -Build the local compiler, then use the included script with ordinary tsc -arguments. It also prints the generated byte count, mapping count, and peak -RSS: - -```sh -pnpm --filter @gi-tcg/gtsc... build -node --max-old-space-size=8192 packages/tsc/scripts/benchmark.mjs \ - --noEmit --incremental false --extendedDiagnostics --pretty false \ - -p ../genius-invokation/packages/data/tsconfig.json -``` - -Use the same temporary source-path configuration for both runs if the production -declarations are stale. Compare full diagnostic output, rather than only exit -codes or error counts. Repeat with `--max-old-space-size=4096` to test the memory -limit. - -## Semantic checks - -`packages/transpiler/__tests__/typing_semantics.test.ts` checks exact inferred -types and mapped diagnostics for Meta rewriting/merging, bindings, overloads, -required and duplicate attributes, Meta-dependent unique keys, union returns, -action contexts, and long diagnostic ranges. The same cases run in editor and -command-line modes. The 36 semantic cases were also run against the baseline -transpiler; the additional test checks preservation of editor completions. - -```sh -pnpm vitest --run packages -pnpm --filter @example/local exec vitest --run -``` - -These pass 125 package tests and the local GTS import test, respectively. From 6b1066499aa1b93c3c08033204636f671c8dfc8a Mon Sep 17 00:00:00 2001 From: guyutongxue Date: Fri, 25 Sep 2026 22:38:13 +0800 Subject: [PATCH 5/6] move typing utils to runtime --- packages/runtime/src/index.ts | 1 + packages/runtime/src/typing.ts | 44 +++++++++++++++++++ .../src/transform/volar/replacements.ts | 22 +--------- .../transpiler/src/transform/volar/walker.ts | 8 ++-- 4 files changed, 52 insertions(+), 23 deletions(-) create mode 100644 packages/runtime/src/typing.ts diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 9884cad..7f42223 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -22,6 +22,7 @@ export { } from "./simple_view_model.ts"; export { defineActionViewModel, ActionModel } from "./action_view_model.ts"; export type { AttributeReturn, AR } from "./attribute_return.ts"; +export * as TypingUtils from "./typing.ts"; export { createBinding, diff --git a/packages/runtime/src/typing.ts b/packages/runtime/src/typing.ts new file mode 100644 index 0000000..42019b0 --- /dev/null +++ b/packages/runtime/src/typing.ts @@ -0,0 +1,44 @@ +export type UniqueKeyProbSegment = "__gts_unique_prob_seg__"; + +export type UnionToIntersection = ( + U extends any ? (k: U) => void : never +) extends (k: infer I) => void + ? I + : never; + +export type WithMeta = { "~meta": Meta } & Omit; + +// Keep lookups non-distributive, matching the generated concrete-type checks. +export type Member< + Def, + AttrName extends PropertyKey, + AttrProp extends PropertyKey, + F, +> = [Def] extends [Record>] ? V : F; + +export type RequiredAttrs = { + [AttrName in keyof Def]: Def[AttrName] extends { required(this: Def): true } + ? AttrName + : never; +}[keyof Def]; + +export type RequiredMessage< + ExpectedAttributes extends PropertyKey, + ProvidedAttributes, +> = { + [K in ExpectedAttributes]: K extends ProvidedAttributes + ? never + : `'${K & (string | number)}' is a required attribute but not provided`; +}[ExpectedAttributes]; + +/** Used by generated virtual TypeScript to report missing required attributes. */ +export function checkRequired( + value: [ErrorMsg] extends [Constraint] ? string : ErrorMsg, +): void {} + +export type MergeMeta = Member< + Def, + AttrName, + "mergeMeta", + (x: T, y: unknown) => T +>; diff --git a/packages/transpiler/src/transform/volar/replacements.ts b/packages/transpiler/src/transform/volar/replacements.ts index 7ee3d7d..61f77d7 100644 --- a/packages/transpiler/src/transform/volar/replacements.ts +++ b/packages/transpiler/src/transform/volar/replacements.ts @@ -9,9 +9,6 @@ interface MatchInfo { } type ReplacementPayload = - | { - type: "preface"; - } | { type: "enterVMFromRoot"; vm: string; @@ -104,22 +101,7 @@ export function applyReplacements( rawPayload.replace(/\\`/g, "`"), ); let replacement: string; - if (payload.type === "preface") { - // Tuple checks preserve the non-distributive conditionals that were - // previously emitted separately for each concrete definition. - replacement = dedent` - declare namespace ${state.utilNsId.name} { - export type UniqueKeyProbSegment = "__gts_unique_prob_seg__"; - export type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never; - export type WithMeta = { ${Meta}: M } & Omit; - export type Member = [D] extends [Record>] ? V : F; - export type RequiredAttrs = { [K in keyof D]: L[K] extends { required(this: L): true } ? K : never }[keyof D]; - export type RequiredMessage = { [K in E]: K extends C ? never : \`'\${K & (string | number)}' is a required attribute but not provided\` }[E]; - export function checkRequired(value: [E] extends [C] ? string : E): void; - export type MergeMeta = Member(x: T, y: unknown) => T>; - } - `; - } else if (payload.type === "enterVMFromRoot") { + if (payload.type === "enterVMFromRoot") { replacement = dedent` type ${payload.defType} = (typeof ${payload.vm})[${NamedDefinition}]; type ${payload.metaType} = ${payload.defType}[${Meta}]; @@ -148,7 +130,7 @@ export function applyReplacements( } replacement = dedent` type ${payload.finalMetaType} = ${payload.metaType}; - type ${requiredAttrsNs} = ${state.utilNsId.name}.RequiredAttrs<${payload.defType}, ${state.utilNsId.name}.WithMeta<${payload.defType}, ${payload.metaType}>>; + type ${requiredAttrsNs} = ${state.utilNsId.name}.RequiredAttrs<${state.utilNsId.name}.WithMeta<${payload.defType}, ${payload.metaType}>>; ${state.utilNsId.name}.checkRequired<${requiredAttrsNs}, ${collectedAttrsExpr}>(${needleString}); `; } else if (payload.type === "enterAttr") { diff --git a/packages/transpiler/src/transform/volar/walker.ts b/packages/transpiler/src/transform/volar/walker.ts index 87bc5ac..2824218 100644 --- a/packages/transpiler/src/transform/volar/walker.ts +++ b/packages/transpiler/src/transform/volar/walker.ts @@ -382,6 +382,11 @@ export const gtsToTypingsWalker: Visitors = { imported: { type: "Identifier", name: "createBinding" }, local: state.createBindingFnId, }, + { + type: "ImportSpecifier", + imported: { type: "Identifier", name: "TypingUtils" }, + local: state.utilNsId, + }, ], source: { type: "Literal", value: state.runtimeImportSource }, attributes: [], @@ -415,9 +420,6 @@ export const gtsToTypingsWalker: Visitors = { }, }, lastImportDecl, - createReplacementHolder(state, { - type: "preface", - }), ); return { ...node, From 94a4098ab0a01ca042c16ca736af781a867df4d2 Mon Sep 17 00:00:00 2001 From: guyutongxue Date: Sat, 26 Sep 2026 00:41:42 +0800 Subject: [PATCH 6/6] simplify logic --- packages/transpiler/src/transform/volar/replacements.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/packages/transpiler/src/transform/volar/replacements.ts b/packages/transpiler/src/transform/volar/replacements.ts index 61f77d7..a034a6d 100644 --- a/packages/transpiler/src/transform/volar/replacements.ts +++ b/packages/transpiler/src/transform/volar/replacements.ts @@ -149,17 +149,9 @@ export function applyReplacements( interface ${uniqueKeyHelperIntf} { [${uniqueKeyForThis}]: 1; } - `; - // The probe above can affect other attributes. Only the completion - // receiver itself is unused by command-line type checking. - if (!payload.hintOnly || !state.typeCheckingOnly) { - // This guard must stay outside a generic helper: evaluating keyof - // the merged probe interface before the zero-key check is recursive. - replacement += dedent` type ${omittedKeys} = ${Meta} | (${uniqueKey} extends 0 ? never : string extends keyof ${uniqueKeyHelperIntf} ? keyof ${payload.defType} : ${state.utilNsId.name}.UnionToIntersection extends never ? ${payload.attrName} : never); let ${payload.lhs}!: ${payload.hintOnly ? `{}` : `{ ${Meta}: ${payload.metaType} }`} & Omit<${payload.defType}, ${omittedKeys}>; `; - } } else if (payload.type === "createBindingTyping") { const typingIdLhs = `${payload.typingId}_lhs`; // As with uniqueKey, an as() without a Meta-aware this parameter must