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/docs/content/docs/internal/language-tooling.md b/docs/content/docs/internal/language-tooling.md index d5a333b..335caa7 100644 --- a/docs/content/docs/internal/language-tooling.md +++ b/docs/content/docs/internal/language-tooling.md @@ -58,6 +58,10 @@ 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 to improve performance. + ## 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..5a7708f 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,10 @@ export function createGtsLanguagePlugin( readFileFn: (path, encoding) => ts.sys?.readFile?.(path, encoding) || "", }); - return new GtsVirtualCode(filename, snapshot, resolvedConfig); + 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 a213d81..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,7 +16,7 @@ export class GtsVirtualCode implements VirtualCode { constructor( filename: string, snapshot: ts.IScriptSnapshot, - config: Required, + config: Required, ) { const source = snapshot.getText(0, snapshot.getLength()); try { 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/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..aaf3bc9 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, sourceNodes: new WeakSet(), attributeNameNodes: new WeakSet(), @@ -92,10 +98,15 @@ export function transformForVolar( for (const extraMapping of state.extraMappings) { const genOffset = code.indexOf(extraMapping.generatedNeedle); 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 dc96f2c..a034a6d 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; @@ -95,6 +92,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, @@ -103,14 +101,7 @@ export function applyReplacements( rawPayload.replace(/\\`/g, "`"), ); let replacement: string; - if (payload.type === "preface") { - replacement = dedent` - 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; - } - `; - } else if (payload.type === "enterVMFromRoot") { + if (payload.type === "enterVMFromRoot") { replacement = dedent` type ${payload.defType} = (typeof ${payload.vm})[${NamedDefinition}]; type ${payload.metaType} = ${payload.defType}[${Meta}]; @@ -121,14 +112,15 @@ 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], @@ -138,19 +130,8 @@ export function applyReplacements( } 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<${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 +139,25 @@ 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 - ); + 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,33 +168,39 @@ 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; }, ); + // 调整替换后 mapping 的 generatedOffset + // 由于替换信息 matchInfos 的 sourceEnd 是有序的,可以二分查找到对应的 lengthOffset 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; + 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..2824218 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; @@ -283,6 +284,10 @@ const insertHintStatement = ( whiteSpaceStart: number, whiteSpaceEnd: number, ) => { + 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", @@ -377,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: [], @@ -410,9 +420,6 @@ export const gtsToTypingsWalker: Visitors = { }, }, lastImportDecl, - createReplacementHolder(state, { - type: "preface", - }), ); return { ...node, diff --git a/packages/tsc/src/index.ts b/packages/tsc/src/index.ts index f5546e7..268ce41 100644 --- a/packages/tsc/src/index.ts +++ b/packages/tsc/src/index.ts @@ -4,10 +4,14 @@ import { createRequire } from "node:module"; import path from "node:path"; const require = createRequire(import.meta.url); -const tscPath = require.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, + typeCheckingOnly: true, }); return { languagePlugins: [gtsLanguagePlugin] }; });