Skip to content

Commit 2699a80

Browse files
Add Model.Class support for completions and diagnostics in Effect v4 (#659)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0154667 commit 2699a80

9 files changed

Lines changed: 182 additions & 2 deletions

File tree

.changeset/model-class-support.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@effect/language-service": patch
3+
---
4+
5+
Add support for `Model.Class` from `effect/unstable/schema` in completions and diagnostics.
6+
7+
The `classSelfMismatch` diagnostic now detects mismatched Self type parameters in `Model.Class` declarations, and the autocomplete for Self type in classes now suggests `Model.Class` when typing after `Model.`.
8+
9+
```ts
10+
import { Model } from "effect/unstable/schema"
11+
12+
// autocomplete triggers after `Model.`
13+
export class MyDataModel extends Model.Class<MyDataModel>("MyDataModel")({
14+
id: Schema.String
15+
}) {}
16+
```

packages/harness-effect-v4/__snapshots__/completions.test.ts.snap

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,22 @@ exports[`Completion effectSchemaSelfInClasses > effectSchemaSelfInClasses_dotTok
248248
]
249249
`;
250250

251+
exports[`Completion effectSchemaSelfInClasses > effectSchemaSelfInClasses_model.ts at 4:40 1`] = `
252+
[
253+
{
254+
"insertText": "Model.Class<MyDataModel>("MyDataModel")({\${0}}){}",
255+
"isSnippet": true,
256+
"kind": "const",
257+
"name": "Class<MyDataModel>",
258+
"replacementSpan": {
259+
"length": 6,
260+
"start": 156,
261+
},
262+
"sortText": "11",
263+
},
264+
]
265+
`;
266+
251267
exports[`Completion effectSchemaSelfInClasses > effectSchemaSelfInClasses_tagg.ts at 4:25 1`] = `
252268
[
253269
{
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// code fix classSelfMismatch_fix output for range 412 - 427
2+
import { Model } from "effect/unstable/schema"
3+
import {Schema} from "effect"
4+
5+
// valid usage
6+
export class ValidModelClass extends Model.Class<ValidModelClass>("ValidModelClass")({
7+
id: Schema.String
8+
}) {}
9+
10+
// invalid usage: Model.Class<ValidModelClass> should be Model.Class<InvalidModelClass> because the Self type parameter is not the same as the class name
11+
export class InvalidModelClass extends Model.Class<InvalidModelClass>("InvalidModelClass")({
12+
id2: Schema.String
13+
}) {}
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
1-
no codefixes
1+
classSelfMismatch_fix from 412 to 427
2+
classSelfMismatch_skipNextLine from 412 to 427
3+
classSelfMismatch_skipFile from 412 to 427
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1-
// no diagnostics
1+
ValidModelClass
2+
10:51 - 10:66 | 1 | Self type parameter should be 'InvalidModelClass' effect(classSelfMismatch)
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// 4:40
2+
import { Model } from "effect/unstable/schema"
3+
4+
export class MyDataModel extends Model.

packages/language-service/src/completions/effectSchemaSelfInClasses.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,39 @@ export const effectSchemaSelfInClasses = LSP.createCompletion({
155155
}
156156
}
157157

158+
// Check for Model.Class (v4 only - Model moved to effect/unstable/schema)
159+
if (typeParser.supportedEffect() === "v4") {
160+
const modelIdentifier = tsUtils.findImportedModuleIdentifierByPackageAndNameOrBarrel(
161+
sourceFile,
162+
"effect/unstable/schema",
163+
"Model"
164+
) || tsUtils.findImportedModuleIdentifierByPackageAndNameOrBarrel(
165+
sourceFile,
166+
"effect/unstable",
167+
"Model"
168+
) || "Model"
169+
170+
const isModelFullyQualified = modelIdentifier === ts.idText(accessedObject)
171+
172+
const hasModelClassCompletion = isModelFullyQualified || Option.isSome(
173+
yield* pipe(
174+
typeParser.isNodeReferenceToEffectSchemaModelModuleApi("Class")(accessedObject),
175+
Nano.option
176+
)
177+
)
178+
if (hasModelClassCompletion) {
179+
completions.push({
180+
name: `Class<${name}>`,
181+
kind: ts.ScriptElementKind.constElement,
182+
insertText: isModelFullyQualified
183+
? `${modelIdentifier}.Class<${name}>("${name}")({${"${0}"}}){}`
184+
: `Class<${name}>("${name}")({${"${0}"}}){}`,
185+
replacementSpan,
186+
isSnippet: true
187+
})
188+
}
189+
}
190+
158191
return completions
159192
})
160193
})

packages/language-service/src/core/TypeParser.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,9 @@ export interface TypeParser {
100100
isNodeReferenceToEffectSqlModelModuleApi: (
101101
memberName: string
102102
) => (node: ts.Node) => Nano.Nano<ts.SourceFile, TypeParserIssue, never>
103+
isNodeReferenceToEffectSchemaModelModuleApi: (
104+
memberName: string
105+
) => (node: ts.Node) => Nano.Nano<ts.SourceFile, TypeParserIssue, never>
103106
isNodeReferenceToEffectLayerModuleApi: (
104107
memberName: string
105108
) => (node: ts.Node) => Nano.Nano<ts.SourceFile, TypeParserIssue, never>
@@ -334,6 +337,14 @@ export interface TypeParser {
334337
TypeParserIssue,
335338
never
336339
>
340+
extendsEffectSchemaModelClass: (atLocation: ts.ClassDeclaration) => Nano.Nano<
341+
{
342+
className: ts.Identifier
343+
selfTypeNode: ts.TypeNode
344+
},
345+
TypeParserIssue,
346+
never
347+
>
337348
lazyExpression: (node: ts.Node) => Nano.Nano<
338349
ParsedLazyExpression,
339350
TypeParserIssue,
@@ -2451,6 +2462,87 @@ export function make(
24512462
(atLocation) => atLocation
24522463
)
24532464

2465+
const isEffectSchemaModelTypeSourceFile = Nano.cachedBy(
2466+
Nano.fn("TypeParser.isEffectSchemaModelTypeSourceFile")(function*(
2467+
sourceFile: ts.SourceFile
2468+
) {
2469+
const moduleSymbol = typeChecker.getSymbolAtLocation(sourceFile)
2470+
if (!moduleSymbol) return yield* typeParserIssue("Node has no symbol", undefined, sourceFile)
2471+
// check for Class
2472+
const classSymbol = typeChecker.tryGetMemberInModuleExports("Class", moduleSymbol)
2473+
if (!classSymbol) return yield* typeParserIssue("Model's Class type not found", undefined, sourceFile)
2474+
// check for Generated (unique to Model, not present in Schema)
2475+
const generatedSymbol = typeChecker.tryGetMemberInModuleExports("Generated", moduleSymbol)
2476+
if (!generatedSymbol) {
2477+
return yield* typeParserIssue("Model's Generated type not found", undefined, sourceFile)
2478+
}
2479+
// check for FieldOption (unique to v4 Model)
2480+
const fieldOptionSymbol = typeChecker.tryGetMemberInModuleExports("FieldOption", moduleSymbol)
2481+
if (!fieldOptionSymbol) {
2482+
return yield* typeParserIssue("Model's FieldOption type not found", undefined, sourceFile)
2483+
}
2484+
return sourceFile
2485+
}),
2486+
"TypeParser.isEffectSchemaModelTypeSourceFile",
2487+
(sourceFile) => sourceFile
2488+
)
2489+
2490+
const isNodeReferenceToEffectSchemaModelModuleApi = (memberName: string) =>
2491+
Nano.cachedBy(
2492+
Nano.fn("TypeParser.isNodeReferenceToEffectSchemaModelModuleApi")(function*(
2493+
node: ts.Node
2494+
) {
2495+
return yield* isNodeReferenceToExportOfPackageModule(
2496+
node,
2497+
"effect",
2498+
isEffectSchemaModelTypeSourceFile,
2499+
memberName
2500+
)
2501+
}),
2502+
`TypeParser.isNodeReferenceToEffectSchemaModelModuleApi(${memberName})`,
2503+
(node) => node
2504+
)
2505+
2506+
const extendsEffectSchemaModelClass = Nano.cachedBy(
2507+
Nano.fn("TypeParser.extendsEffectSchemaModelClass")(function*(
2508+
atLocation: ts.ClassDeclaration
2509+
) {
2510+
if (!atLocation.name) {
2511+
return yield* typeParserIssue("Class has no name", undefined, atLocation)
2512+
}
2513+
const heritageClauses = atLocation.heritageClauses
2514+
if (!heritageClauses) {
2515+
return yield* typeParserIssue("Class has no heritage clauses", undefined, atLocation)
2516+
}
2517+
for (const heritageClause of heritageClauses) {
2518+
for (const typeX of heritageClause.types) {
2519+
if (ts.isExpressionWithTypeArguments(typeX)) {
2520+
const expression = typeX.expression
2521+
if (ts.isCallExpression(expression)) {
2522+
// Model.Class<T>("name")({})
2523+
const schemaCall = expression.expression
2524+
if (ts.isCallExpression(schemaCall) && schemaCall.typeArguments && schemaCall.typeArguments.length > 0) {
2525+
const isEffectSchemaModelModuleApi = yield* pipe(
2526+
isNodeReferenceToEffectSchemaModelModuleApi("Class")(schemaCall.expression),
2527+
Nano.orUndefined
2528+
)
2529+
if (isEffectSchemaModelModuleApi) {
2530+
return {
2531+
className: atLocation.name,
2532+
selfTypeNode: schemaCall.typeArguments[0]!
2533+
}
2534+
}
2535+
}
2536+
}
2537+
}
2538+
}
2539+
}
2540+
return yield* typeParserIssue("Class does not extend effect's Model.Class", undefined, atLocation)
2541+
}),
2542+
"TypeParser.extendsEffectSchemaModelClass",
2543+
(atLocation) => atLocation
2544+
)
2545+
24542546
const isEffectLayerTypeSourceFile = Nano.cachedBy(
24552547
Nano.fn("TypeParser.isEffectLayerTypeSourceFile")(function*(
24562548
sourceFile: ts.SourceFile
@@ -2967,6 +3059,7 @@ export function make(
29673059
isNodeReferenceToEffectDataModuleApi,
29683060
isNodeReferenceToEffectContextModuleApi,
29693061
isNodeReferenceToEffectSqlModelModuleApi,
3062+
isNodeReferenceToEffectSchemaModelModuleApi,
29703063
isNodeReferenceToEffectLayerModuleApi,
29713064
isNodeReferenceToEffectSchemaParserModuleApi,
29723065
isServiceMapTypeSourceFile,
@@ -3004,6 +3097,7 @@ export function make(
30043097
extendsSchemaTaggedRequest,
30053098
extendsSchemaRequestClass,
30063099
extendsEffectSqlModelClass,
3100+
extendsEffectSchemaModelClass,
30073101
lazyExpression,
30083102
emptyFunction,
30093103
pipingFlows,

packages/language-service/src/diagnostics/classSelfMismatch.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export const classSelfMismatch = LSP.createDiagnostic({
4141
)
4242
),
4343
Nano.orElse(() => typeParser.extendsEffectSqlModelClass(node)),
44+
Nano.orElse(() => typeParser.extendsEffectSchemaModelClass(node)),
4445
Nano.orElse(() => Nano.void_)
4546
)
4647

0 commit comments

Comments
 (0)