Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions docs/content/docs/internal/language-tooling.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
6 changes: 5 additions & 1 deletion packages/language-plugin/src/language_plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ type Ts = typeof ts;

export interface GtsLanguagePluginInlineConfig extends GtsConfig {
pathModule?: PathModule;
typeCheckingOnly?: boolean;
}

export function createGtsLanguagePlugin(
Expand All @@ -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: {
Expand Down
4 changes: 2 additions & 2 deletions packages/language-plugin/src/virtual_code.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -16,7 +16,7 @@ export class GtsVirtualCode implements VirtualCode {
constructor(
filename: string,
snapshot: ts.IScriptSnapshot,
config: Required<GtsConfig>,
config: Required<VolarTranspileOption>,
) {
const source = snapshot.getText(0, snapshot.getLength());
try {
Expand Down
1 change: 1 addition & 0 deletions packages/runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
44 changes: 44 additions & 0 deletions packages/runtime/src/typing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
export type UniqueKeyProbSegment = "__gts_unique_prob_seg__";

export type UnionToIntersection<U> = (
U extends any ? (k: U) => void : never
) extends (k: infer I) => void
? I
: never;

export type WithMeta<Def, Meta> = { "~meta": Meta } & Omit<Def, "~meta">;

// Keep lookups non-distributive, matching the generated concrete-type checks.
export type Member<
Def,
AttrName extends PropertyKey,
AttrProp extends PropertyKey,
F,
> = [Def] extends [Record<AttrName, Record<AttrProp, infer V>>] ? V : F;

export type RequiredAttrs<Def extends {}> = {
[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<ErrorMsg, Constraint>(
value: [ErrorMsg] extends [Constraint] ? string : ErrorMsg,
): void {}

export type MergeMeta<Def, AttrName extends PropertyKey> = Member<
Def,
AttrName,
"mergeMeta",
<const T>(x: T, y: unknown) => T
>;
8 changes: 6 additions & 2 deletions packages/transpiler/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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,
Expand All @@ -47,6 +50,7 @@ export type {
TranspileOption,
TranspileResult,
VolarMappingResult,
VolarTranspileOption,
};
export {
resolveGtsConfig,
Expand Down
21 changes: 16 additions & 5 deletions packages/transpiler/src/transform/volar/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines +20 to +22
}

export function transformForVolar(
ast: Program,
option: TranspileOption,
option: VolarTranspileOption,
sourceInfo: Required<SourceInfo>,
): VolarMappingResult {
const state: TypingTranspileState = {
Expand All @@ -39,6 +44,7 @@ export function transformForVolar(
metaTypeIdStack: [],
finalMetaTypeIdStack: [],
attrsOfCurrentVm: [],
typeCheckingOnly: option.typeCheckingOnly,

sourceNodes: new WeakSet(),
attributeNameNodes: new WeakSet(),
Expand Down Expand Up @@ -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,
});
}
Expand Down
90 changes: 31 additions & 59 deletions packages/transpiler/src/transform/volar/replacements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,6 @@ interface MatchInfo {
}

type ReplacementPayload =
| {
type: "preface";
}
| {
type: "enterVMFromRoot";
vm: string;
Expand Down Expand Up @@ -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,
Expand All @@ -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> = (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}];
Expand All @@ -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],
Expand All @@ -138,60 +130,34 @@ 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`;
const uniqueKey = `${payload.lhs}_uniqueKey`;
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<keyof ${uniqueKeyHelperIntf} & \`\${${uniqueKey}}\${${state.utilNsId.name}.UniqueKeyProbSegment}\${string}\`> 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};
`;
Expand All @@ -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 : <const T>(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);
}
}

Expand Down
13 changes: 10 additions & 3 deletions packages/transpiler/src/transform/volar/walker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ interface ExternalizedTypedBinding extends ExternalizedBinding {
}

export interface TypingTranspileState extends TranspileState {
typeCheckingOnly: boolean;
externalizedBindings: ExternalizedTypedBinding[];
idCounter: number;
rootVmId: Identifier;
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -377,6 +382,11 @@ export const gtsToTypingsWalker: Visitors<Node, TypingTranspileState> = {
imported: { type: "Identifier", name: "createBinding" },
local: state.createBindingFnId,
},
{
type: "ImportSpecifier",
imported: { type: "Identifier", name: "TypingUtils" },
local: state.utilNsId,
},
Comment on lines +385 to +389
],
source: { type: "Literal", value: state.runtimeImportSource },
attributes: [],
Expand Down Expand Up @@ -410,9 +420,6 @@ export const gtsToTypingsWalker: Visitors<Node, TypingTranspileState> = {
},
},
lastImportDecl,
createReplacementHolder(state, {
type: "preface",
}),
);
return {
...node,
Expand Down
Loading
Loading