diff --git a/.changeset/service-not-as-class-diagnostic.md b/.changeset/service-not-as-class-diagnostic.md new file mode 100644 index 00000000..80aa0308 --- /dev/null +++ b/.changeset/service-not-as-class-diagnostic.md @@ -0,0 +1,7 @@ +--- +"@effect/language-service": minor +--- + +Add the `serviceNotAsClass` diagnostic to warn when `ServiceMap.Service` is used as a variable assignment instead of in a class declaration. + +Includes an auto-fix that converts `const Config = ServiceMap.Service("Config")` to `class Config extends ServiceMap.Service()("Config") {}`. diff --git a/README.md b/README.md index 78756040..313c0bf6 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ And you're done! You'll now be able to use a set of refactors and diagnostics th - Suggest using `Schema.is` instead of `instanceof` for Effect Schema types - Suggest using `Effect.void` instead of `Effect.succeed(undefined)` or `Effect.succeed(void 0)` - Warn when using outdated Effect v3 APIs in an Effect v4 project, with guidance on the correct v4 replacement (renamed, changed, or removed APIs) +- Warn when `ServiceMap.Service` is used as a variable instead of a class declaration ### Completions diff --git a/packages/harness-effect-v3/__snapshots__/completions.test.ts.snap b/packages/harness-effect-v3/__snapshots__/completions.test.ts.snap index b2147575..f8da2e52 100644 --- a/packages/harness-effect-v3/__snapshots__/completions.test.ts.snap +++ b/packages/harness-effect-v3/__snapshots__/completions.test.ts.snap @@ -248,7 +248,7 @@ exports[`Completion effectDataClasses > effectDataClasses_directImportTaggedErro exports[`Completion effectDiagnosticsComment > effectDiagnosticsComment.ts at 2:5 1`] = ` [ { - "insertText": "@effect-diagnostics \${1|anyUnknownInErrorContext,catchAllToMapError,catchUnfailableEffect,classSelfMismatch,deterministicKeys,duplicatePackage,effectFnIife,effectFnOpportunity,effectGenUsesAdapter,effectInFailure,effectInVoidSuccess,effectMapVoid,effectSucceedWithVoid,extendsNativeError,floatingEffect,genericEffectServices,globalErrorInEffectCatch,globalErrorInEffectFailure,importFromBarrel,instanceOfSchema,layerMergeAllWithDependencies,leakingRequirements,missedPipeableOpportunity,missingEffectContext,missingEffectError,missingEffectServiceDependency,missingLayerContext,missingReturnYieldStar,missingStarInYieldEffectGen,multipleEffectProvide,nonObjectEffectServiceType,outdatedApi,outdatedEffectCodegen,overriddenSchemaConstructor,preferSchemaOverJson,redundantSchemaTagIdentifier,returnEffectInGen,runEffectInsideEffect,schemaStructWithTag,schemaSyncInEffect,schemaUnionOfLiterals,scopeInLayerEffect,strictBooleanExpressions,strictEffectProvide,tryCatchInEffectGen,unknownInEffectCatch,unnecessaryEffectGen,unnecessaryFailYieldableError,unnecessaryPipe,unnecessaryPipeChain,unsupportedServiceAccessors|}:\${2|off,warning,error,message,suggestion|}$0", + "insertText": "@effect-diagnostics \${1|anyUnknownInErrorContext,catchAllToMapError,catchUnfailableEffect,classSelfMismatch,deterministicKeys,duplicatePackage,effectFnIife,effectFnOpportunity,effectGenUsesAdapter,effectInFailure,effectInVoidSuccess,effectMapVoid,effectSucceedWithVoid,extendsNativeError,floatingEffect,genericEffectServices,globalErrorInEffectCatch,globalErrorInEffectFailure,importFromBarrel,instanceOfSchema,layerMergeAllWithDependencies,leakingRequirements,missedPipeableOpportunity,missingEffectContext,missingEffectError,missingEffectServiceDependency,missingLayerContext,missingReturnYieldStar,missingStarInYieldEffectGen,multipleEffectProvide,nonObjectEffectServiceType,outdatedApi,outdatedEffectCodegen,overriddenSchemaConstructor,preferSchemaOverJson,redundantSchemaTagIdentifier,returnEffectInGen,runEffectInsideEffect,schemaStructWithTag,schemaSyncInEffect,schemaUnionOfLiterals,scopeInLayerEffect,serviceNotAsClass,strictBooleanExpressions,strictEffectProvide,tryCatchInEffectGen,unknownInEffectCatch,unnecessaryEffectGen,unnecessaryFailYieldableError,unnecessaryPipe,unnecessaryPipeChain,unsupportedServiceAccessors|}:\${2|off,warning,error,message,suggestion|}$0", "isSnippet": true, "kind": "string", "name": "@effect-diagnostics", @@ -259,7 +259,7 @@ exports[`Completion effectDiagnosticsComment > effectDiagnosticsComment.ts at 2: "sortText": "11", }, { - "insertText": "@effect-diagnostics-next-line \${1|anyUnknownInErrorContext,catchAllToMapError,catchUnfailableEffect,classSelfMismatch,deterministicKeys,duplicatePackage,effectFnIife,effectFnOpportunity,effectGenUsesAdapter,effectInFailure,effectInVoidSuccess,effectMapVoid,effectSucceedWithVoid,extendsNativeError,floatingEffect,genericEffectServices,globalErrorInEffectCatch,globalErrorInEffectFailure,importFromBarrel,instanceOfSchema,layerMergeAllWithDependencies,leakingRequirements,missedPipeableOpportunity,missingEffectContext,missingEffectError,missingEffectServiceDependency,missingLayerContext,missingReturnYieldStar,missingStarInYieldEffectGen,multipleEffectProvide,nonObjectEffectServiceType,outdatedApi,outdatedEffectCodegen,overriddenSchemaConstructor,preferSchemaOverJson,redundantSchemaTagIdentifier,returnEffectInGen,runEffectInsideEffect,schemaStructWithTag,schemaSyncInEffect,schemaUnionOfLiterals,scopeInLayerEffect,strictBooleanExpressions,strictEffectProvide,tryCatchInEffectGen,unknownInEffectCatch,unnecessaryEffectGen,unnecessaryFailYieldableError,unnecessaryPipe,unnecessaryPipeChain,unsupportedServiceAccessors|}:\${2|off,warning,error,message,suggestion|}$0", + "insertText": "@effect-diagnostics-next-line \${1|anyUnknownInErrorContext,catchAllToMapError,catchUnfailableEffect,classSelfMismatch,deterministicKeys,duplicatePackage,effectFnIife,effectFnOpportunity,effectGenUsesAdapter,effectInFailure,effectInVoidSuccess,effectMapVoid,effectSucceedWithVoid,extendsNativeError,floatingEffect,genericEffectServices,globalErrorInEffectCatch,globalErrorInEffectFailure,importFromBarrel,instanceOfSchema,layerMergeAllWithDependencies,leakingRequirements,missedPipeableOpportunity,missingEffectContext,missingEffectError,missingEffectServiceDependency,missingLayerContext,missingReturnYieldStar,missingStarInYieldEffectGen,multipleEffectProvide,nonObjectEffectServiceType,outdatedApi,outdatedEffectCodegen,overriddenSchemaConstructor,preferSchemaOverJson,redundantSchemaTagIdentifier,returnEffectInGen,runEffectInsideEffect,schemaStructWithTag,schemaSyncInEffect,schemaUnionOfLiterals,scopeInLayerEffect,serviceNotAsClass,strictBooleanExpressions,strictEffectProvide,tryCatchInEffectGen,unknownInEffectCatch,unnecessaryEffectGen,unnecessaryFailYieldableError,unnecessaryPipe,unnecessaryPipeChain,unsupportedServiceAccessors|}:\${2|off,warning,error,message,suggestion|}$0", "isSnippet": true, "kind": "string", "name": "@effect-diagnostics-next-line", diff --git a/packages/harness-effect-v4/__snapshots__/completions.test.ts.snap b/packages/harness-effect-v4/__snapshots__/completions.test.ts.snap index e4224a7c..372fc49a 100644 --- a/packages/harness-effect-v4/__snapshots__/completions.test.ts.snap +++ b/packages/harness-effect-v4/__snapshots__/completions.test.ts.snap @@ -143,7 +143,7 @@ exports[`Completion effectDataClasses > effectDataClasses.ts at 4:35 1`] = ` exports[`Completion effectDiagnosticsComment > effectDiagnosticsComment.ts at 2:5 1`] = ` [ { - "insertText": "@effect-diagnostics \${1|anyUnknownInErrorContext,catchAllToMapError,catchUnfailableEffect,classSelfMismatch,deterministicKeys,duplicatePackage,effectFnIife,effectFnOpportunity,effectGenUsesAdapter,effectInFailure,effectInVoidSuccess,effectMapVoid,effectSucceedWithVoid,extendsNativeError,floatingEffect,genericEffectServices,globalErrorInEffectCatch,globalErrorInEffectFailure,importFromBarrel,instanceOfSchema,layerMergeAllWithDependencies,leakingRequirements,missedPipeableOpportunity,missingEffectContext,missingEffectError,missingEffectServiceDependency,missingLayerContext,missingReturnYieldStar,missingStarInYieldEffectGen,multipleEffectProvide,nonObjectEffectServiceType,outdatedApi,outdatedEffectCodegen,overriddenSchemaConstructor,preferSchemaOverJson,redundantSchemaTagIdentifier,returnEffectInGen,runEffectInsideEffect,schemaStructWithTag,schemaSyncInEffect,schemaUnionOfLiterals,scopeInLayerEffect,strictBooleanExpressions,strictEffectProvide,tryCatchInEffectGen,unknownInEffectCatch,unnecessaryEffectGen,unnecessaryFailYieldableError,unnecessaryPipe,unnecessaryPipeChain,unsupportedServiceAccessors|}:\${2|off,warning,error,message,suggestion|}$0", + "insertText": "@effect-diagnostics \${1|anyUnknownInErrorContext,catchAllToMapError,catchUnfailableEffect,classSelfMismatch,deterministicKeys,duplicatePackage,effectFnIife,effectFnOpportunity,effectGenUsesAdapter,effectInFailure,effectInVoidSuccess,effectMapVoid,effectSucceedWithVoid,extendsNativeError,floatingEffect,genericEffectServices,globalErrorInEffectCatch,globalErrorInEffectFailure,importFromBarrel,instanceOfSchema,layerMergeAllWithDependencies,leakingRequirements,missedPipeableOpportunity,missingEffectContext,missingEffectError,missingEffectServiceDependency,missingLayerContext,missingReturnYieldStar,missingStarInYieldEffectGen,multipleEffectProvide,nonObjectEffectServiceType,outdatedApi,outdatedEffectCodegen,overriddenSchemaConstructor,preferSchemaOverJson,redundantSchemaTagIdentifier,returnEffectInGen,runEffectInsideEffect,schemaStructWithTag,schemaSyncInEffect,schemaUnionOfLiterals,scopeInLayerEffect,serviceNotAsClass,strictBooleanExpressions,strictEffectProvide,tryCatchInEffectGen,unknownInEffectCatch,unnecessaryEffectGen,unnecessaryFailYieldableError,unnecessaryPipe,unnecessaryPipeChain,unsupportedServiceAccessors|}:\${2|off,warning,error,message,suggestion|}$0", "isSnippet": true, "kind": "string", "name": "@effect-diagnostics", @@ -154,7 +154,7 @@ exports[`Completion effectDiagnosticsComment > effectDiagnosticsComment.ts at 2: "sortText": "11", }, { - "insertText": "@effect-diagnostics-next-line \${1|anyUnknownInErrorContext,catchAllToMapError,catchUnfailableEffect,classSelfMismatch,deterministicKeys,duplicatePackage,effectFnIife,effectFnOpportunity,effectGenUsesAdapter,effectInFailure,effectInVoidSuccess,effectMapVoid,effectSucceedWithVoid,extendsNativeError,floatingEffect,genericEffectServices,globalErrorInEffectCatch,globalErrorInEffectFailure,importFromBarrel,instanceOfSchema,layerMergeAllWithDependencies,leakingRequirements,missedPipeableOpportunity,missingEffectContext,missingEffectError,missingEffectServiceDependency,missingLayerContext,missingReturnYieldStar,missingStarInYieldEffectGen,multipleEffectProvide,nonObjectEffectServiceType,outdatedApi,outdatedEffectCodegen,overriddenSchemaConstructor,preferSchemaOverJson,redundantSchemaTagIdentifier,returnEffectInGen,runEffectInsideEffect,schemaStructWithTag,schemaSyncInEffect,schemaUnionOfLiterals,scopeInLayerEffect,strictBooleanExpressions,strictEffectProvide,tryCatchInEffectGen,unknownInEffectCatch,unnecessaryEffectGen,unnecessaryFailYieldableError,unnecessaryPipe,unnecessaryPipeChain,unsupportedServiceAccessors|}:\${2|off,warning,error,message,suggestion|}$0", + "insertText": "@effect-diagnostics-next-line \${1|anyUnknownInErrorContext,catchAllToMapError,catchUnfailableEffect,classSelfMismatch,deterministicKeys,duplicatePackage,effectFnIife,effectFnOpportunity,effectGenUsesAdapter,effectInFailure,effectInVoidSuccess,effectMapVoid,effectSucceedWithVoid,extendsNativeError,floatingEffect,genericEffectServices,globalErrorInEffectCatch,globalErrorInEffectFailure,importFromBarrel,instanceOfSchema,layerMergeAllWithDependencies,leakingRequirements,missedPipeableOpportunity,missingEffectContext,missingEffectError,missingEffectServiceDependency,missingLayerContext,missingReturnYieldStar,missingStarInYieldEffectGen,multipleEffectProvide,nonObjectEffectServiceType,outdatedApi,outdatedEffectCodegen,overriddenSchemaConstructor,preferSchemaOverJson,redundantSchemaTagIdentifier,returnEffectInGen,runEffectInsideEffect,schemaStructWithTag,schemaSyncInEffect,schemaUnionOfLiterals,scopeInLayerEffect,serviceNotAsClass,strictBooleanExpressions,strictEffectProvide,tryCatchInEffectGen,unknownInEffectCatch,unnecessaryEffectGen,unnecessaryFailYieldableError,unnecessaryPipe,unnecessaryPipeChain,unsupportedServiceAccessors|}:\${2|off,warning,error,message,suggestion|}$0", "isSnippet": true, "kind": "string", "name": "@effect-diagnostics-next-line", diff --git a/packages/harness-effect-v4/__snapshots__/diagnostics/serviceNotAsClass.ts.codefixes b/packages/harness-effect-v4/__snapshots__/diagnostics/serviceNotAsClass.ts.codefixes new file mode 100644 index 00000000..57319b20 --- /dev/null +++ b/packages/harness-effect-v4/__snapshots__/diagnostics/serviceNotAsClass.ts.codefixes @@ -0,0 +1,6 @@ +serviceNotAsClass from 180 to 223 +serviceNotAsClass_skipNextLine from 180 to 223 +serviceNotAsClass_skipFile from 180 to 223 +serviceNotAsClass from 348 to 413 +serviceNotAsClass_skipNextLine from 348 to 413 +serviceNotAsClass_skipFile from 348 to 413 \ No newline at end of file diff --git a/packages/harness-effect-v4/__snapshots__/diagnostics/serviceNotAsClass.ts.output b/packages/harness-effect-v4/__snapshots__/diagnostics/serviceNotAsClass.ts.output new file mode 100644 index 00000000..24f73efc --- /dev/null +++ b/packages/harness-effect-v4/__snapshots__/diagnostics/serviceNotAsClass.ts.output @@ -0,0 +1,5 @@ +ServiceMap.Service("Config") +7:15 - 7:58 | 0 | ServiceMap.Service should be used in a class declaration instead of as a variable. Use: class Config extends ServiceMap.Service()("Config") {} effect(serviceNotAsClass) + +ServiceMap.Service("@my-app/ArtifactStore") +11:29 - 11:94 | 0 | ServiceMap.Service should be used in a class declaration instead of as a variable. Use: class ArtifactStore extends ServiceMap.Service()("@my-app/ArtifactStore") {} effect(serviceNotAsClass) \ No newline at end of file diff --git a/packages/harness-effect-v4/__snapshots__/diagnostics/serviceNotAsClass.ts.serviceNotAsClass.from180to223.output b/packages/harness-effect-v4/__snapshots__/diagnostics/serviceNotAsClass.ts.serviceNotAsClass.from180to223.output new file mode 100644 index 00000000..961f5c32 --- /dev/null +++ b/packages/harness-effect-v4/__snapshots__/diagnostics/serviceNotAsClass.ts.serviceNotAsClass.from180to223.output @@ -0,0 +1,18 @@ +// code fix serviceNotAsClass output for range 180 - 223 +// @effect-diagnostics serviceNotAsClass:warning +import { ServiceMap } from "effect" + +interface ConfigService {} + +// Flagged: const variable with ServiceMap.Service +class Config extends ServiceMap.Service()("Config") { } + +// Flagged: exported const variable with ServiceMap.Service +interface ArtifactStoreService {} +export const ArtifactStore = ServiceMap.Service("@my-app/ArtifactStore") + +// Not flagged: correct class extends form +class MyService extends ServiceMap.Service()("MyService") {} + +// Not flagged: non-ServiceMap.Service const +const x = 42 diff --git a/packages/harness-effect-v4/__snapshots__/diagnostics/serviceNotAsClass.ts.serviceNotAsClass.from348to413.output b/packages/harness-effect-v4/__snapshots__/diagnostics/serviceNotAsClass.ts.serviceNotAsClass.from348to413.output new file mode 100644 index 00000000..deff9eee --- /dev/null +++ b/packages/harness-effect-v4/__snapshots__/diagnostics/serviceNotAsClass.ts.serviceNotAsClass.from348to413.output @@ -0,0 +1,18 @@ +// code fix serviceNotAsClass output for range 348 - 413 +// @effect-diagnostics serviceNotAsClass:warning +import { ServiceMap } from "effect" + +interface ConfigService {} + +// Flagged: const variable with ServiceMap.Service +const Config = ServiceMap.Service("Config") + +// Flagged: exported const variable with ServiceMap.Service +interface ArtifactStoreService {} +export class ArtifactStore extends ServiceMap.Service()("@my-app/ArtifactStore") { } + +// Not flagged: correct class extends form +class MyService extends ServiceMap.Service()("MyService") {} + +// Not flagged: non-ServiceMap.Service const +const x = 42 diff --git a/packages/harness-effect-v4/examples/diagnostics/serviceNotAsClass.ts b/packages/harness-effect-v4/examples/diagnostics/serviceNotAsClass.ts new file mode 100644 index 00000000..08a05cc9 --- /dev/null +++ b/packages/harness-effect-v4/examples/diagnostics/serviceNotAsClass.ts @@ -0,0 +1,17 @@ +// @effect-diagnostics serviceNotAsClass:warning +import { ServiceMap } from "effect" + +interface ConfigService {} + +// Flagged: const variable with ServiceMap.Service +const Config = ServiceMap.Service("Config") + +// Flagged: exported const variable with ServiceMap.Service +interface ArtifactStoreService {} +export const ArtifactStore = ServiceMap.Service("@my-app/ArtifactStore") + +// Not flagged: correct class extends form +class MyService extends ServiceMap.Service()("MyService") {} + +// Not flagged: non-ServiceMap.Service const +const x = 42 diff --git a/packages/language-service/src/cli/layerinfo.ts b/packages/language-service/src/cli/layerinfo.ts index cfad6fdc..2be7381e 100644 --- a/packages/language-service/src/cli/layerinfo.ts +++ b/packages/language-service/src/cli/layerinfo.ts @@ -319,7 +319,8 @@ function getExpressionName(tsApi: TypeScriptApi.TypeScriptApi, expr: ts.Expressi return getExpressionName(tsApi, expr.expression) } // Fallback: truncate the text representation - const text = expr.getText().replace(/\s+/g, " ") + const sourceFile = expr.getSourceFile() + const text = sourceFile.text.substring(tsApi.getTokenPosOfNode(expr, sourceFile), expr.end).replace(/\s+/g, " ") return text.length > 30 ? text.slice(0, 27) + "..." : text } diff --git a/packages/language-service/src/cli/utils/ExportedSymbols.ts b/packages/language-service/src/cli/utils/ExportedSymbols.ts index 525539b6..78a1debd 100644 --- a/packages/language-service/src/cli/utils/ExportedSymbols.ts +++ b/packages/language-service/src/cli/utils/ExportedSymbols.ts @@ -29,7 +29,10 @@ const getLocationFromDeclaration = ( ): SymbolLocation | undefined => { const sourceFile = declaration.getSourceFile() if (!sourceFile) return undefined - const { character, line } = tsInstance.getLineAndCharacterOfPosition(sourceFile, declaration.getStart()) + const { character, line } = tsInstance.getLineAndCharacterOfPosition( + sourceFile, + tsInstance.getTokenPosOfNode(declaration, sourceFile) + ) return { filePath: sourceFile.fileName, line: line + 1, diff --git a/packages/language-service/src/core/TypeScriptApi.ts b/packages/language-service/src/core/TypeScriptApi.ts index b9ca1e41..6282850b 100644 --- a/packages/language-service/src/core/TypeScriptApi.ts +++ b/packages/language-service/src/core/TypeScriptApi.ts @@ -183,6 +183,13 @@ declare module "typescript" { /** @deprecated Use typeChecker.getSignaturesOfType(type, ts.SignatureKind.Construct) instead */ getConstructSignatures(): ReadonlyArray } + + export interface Node { + /** @deprecated Use ts.getTokenPosOfNode(node, sourceFile) instead */ + getStart(sourceFile?: ts.SourceFile, includeJsDocComment?: boolean): number + /** @deprecated Use sourceFile.text.substring(node.pos, node.end) instead */ + getText(sourceFile?: ts.SourceFile): string + } } type _TypeScriptApi = typeof ts diff --git a/packages/language-service/src/diagnostics.ts b/packages/language-service/src/diagnostics.ts index 1f1cc51c..af03d975 100644 --- a/packages/language-service/src/diagnostics.ts +++ b/packages/language-service/src/diagnostics.ts @@ -40,6 +40,7 @@ import { schemaStructWithTag } from "./diagnostics/schemaStructWithTag.js" import { schemaSyncInEffect } from "./diagnostics/schemaSyncInEffect.js" import { schemaUnionOfLiterals } from "./diagnostics/schemaUnionOfLiterals.js" import { scopeInLayerEffect } from "./diagnostics/scopeInLayerEffect.js" +import { serviceNotAsClass } from "./diagnostics/serviceNotAsClass.js" import { strictBooleanExpressions } from "./diagnostics/strictBooleanExpressions.js" import { strictEffectProvide } from "./diagnostics/strictEffectProvide.js" import { tryCatchInEffectGen } from "./diagnostics/tryCatchInEffectGen.js" @@ -101,5 +102,6 @@ export const diagnostics = [ redundantSchemaTagIdentifier, schemaSyncInEffect, preferSchemaOverJson, - extendsNativeError + extendsNativeError, + serviceNotAsClass ] diff --git a/packages/language-service/src/diagnostics/serviceNotAsClass.ts b/packages/language-service/src/diagnostics/serviceNotAsClass.ts new file mode 100644 index 00000000..b32f2a70 --- /dev/null +++ b/packages/language-service/src/diagnostics/serviceNotAsClass.ts @@ -0,0 +1,114 @@ +import { pipe } from "effect" +import type ts from "typescript" +import * as LSP from "../core/LSP.js" +import * as Nano from "../core/Nano.js" +import * as TypeParser from "../core/TypeParser.js" +import * as TypeScriptApi from "../core/TypeScriptApi.js" + +export const serviceNotAsClass = LSP.createDiagnostic({ + name: "serviceNotAsClass", + code: 51, + description: "Warns when ServiceMap.Service is used as a variable instead of a class declaration", + severity: "off", + apply: Nano.fn("serviceNotAsClass.apply")(function*(sourceFile, report) { + const ts = yield* Nano.service(TypeScriptApi.TypeScriptApi) + const typeParser = yield* Nano.service(TypeParser.TypeParser) + + if (typeParser.supportedEffect() === "v3") return + + const nodeToVisit: Array = [] + const appendNodeToVisit = (node: ts.Node) => { + nodeToVisit.push(node) + return undefined + } + ts.forEachChild(sourceFile, appendNodeToVisit) + + while (nodeToVisit.length > 0) { + const node = nodeToVisit.shift()! + ts.forEachChild(node, appendNodeToVisit) + + if (!ts.isVariableDeclaration(node)) continue + if (!node.initializer || !ts.isCallExpression(node.initializer)) continue + + const callExpr = node.initializer + if (!callExpr.typeArguments || callExpr.typeArguments.length === 0) continue + const typeArgs = callExpr.typeArguments + + // Check parent VariableDeclarationList uses const + const declList = node.parent + if (!ts.isVariableDeclarationList(declList)) continue + if (!(declList.flags & ts.NodeFlags.Const)) continue + + const isServiceMapService = yield* pipe( + typeParser.isNodeReferenceToServiceMapModuleApi("Service")(callExpr.expression), + Nano.orUndefined + ) + if (!isServiceMapService) continue + + const variableName = ts.isIdentifier(node.name) + ? ts.idText(node.name) + : sourceFile.text.substring(ts.getTokenPosOfNode(node.name, sourceFile), node.name.end) + const variableStatement = declList.parent + + const argsText = callExpr.arguments.length > 0 + ? callExpr.arguments.map((a) => sourceFile.text.substring(ts.getTokenPosOfNode(a, sourceFile), a.end)) + .join(", ") + : "" + + const shapeText = typeArgs.length > 0 + ? typeArgs.map((t) => sourceFile.text.substring(ts.getTokenPosOfNode(t, sourceFile), t.end)).join(", ") + : "Shape" + + report({ + location: callExpr, + messageText: + `ServiceMap.Service should be used in a class declaration instead of as a variable. Use: class ${variableName} extends ServiceMap.Service<${variableName}, ${shapeText}>()("${ + argsText.replace(/['"]/g, "") + }") {}`, + fixes: [{ + fixName: "serviceNotAsClass", + description: `Convert to class declaration`, + apply: Nano.gen(function*() { + const changeTracker = yield* Nano.service(TypeScriptApi.ChangeTracker) + const targetNode = ts.isVariableStatement(variableStatement) ? variableStatement : declList + + // Build inner call: ServiceMap.Service() + const innerCall = ts.factory.createCallExpression( + callExpr.expression, + [ts.factory.createTypeReferenceNode(variableName), ...typeArgs], + [] + ) + + // Build outer call: ServiceMap.Service()(args...) + const outerCall = ts.factory.createCallExpression( + innerCall, + undefined, + [...callExpr.arguments] + ) + + // Build heritage clause: extends ServiceMap.Service()(args...) + const heritageClause = ts.factory.createHeritageClause( + ts.SyntaxKind.ExtendsKeyword, + [ts.factory.createExpressionWithTypeArguments(outerCall, undefined)] + ) + + // Build class declaration — reuse existing modifiers from the variable statement + const modifiers = ts.isVariableStatement(variableStatement) + ? variableStatement.modifiers + : undefined + + const classDeclaration = ts.factory.createClassDeclaration( + modifiers, + ts.isIdentifier(node.name) ? node.name : ts.factory.createIdentifier(variableName), + undefined, + [heritageClause], + [] + ) + + changeTracker.replaceNode(sourceFile, targetNode, classDeclaration) + }) + }] + }) + } + }) +}) diff --git a/packages/language-service/src/goto/effectRpcDefinition.ts b/packages/language-service/src/goto/effectRpcDefinition.ts index b393aedd..2886fb3a 100644 --- a/packages/language-service/src/goto/effectRpcDefinition.ts +++ b/packages/language-service/src/goto/effectRpcDefinition.ts @@ -140,7 +140,10 @@ export function effectRpcDefinition( // create the result entry for the definitions const effectRpcResult = result.map(([node]) => ({ fileName: node.getSourceFile().fileName, - textSpan: ts.createTextSpan(node.getStart(), node.end - node.getStart()), + textSpan: ts.createTextSpan( + ts.getTokenPosOfNode(node, node.getSourceFile()), + node.end - ts.getTokenPosOfNode(node, node.getSourceFile()) + ), kind: ts.ScriptElementKind.constElement, name: rpcName, containerKind: ts.ScriptElementKind.constElement, @@ -155,7 +158,10 @@ export function effectRpcDefinition( } return ({ - textSpan: ts.createTextSpan(callNode.getStart(), callNode.end - callNode.getStart()), + textSpan: ts.createTextSpan( + ts.getTokenPosOfNode(callNode, callNode.getSourceFile()), + callNode.end - ts.getTokenPosOfNode(callNode, callNode.getSourceFile()) + ), definitions: effectRpcResult }) }) diff --git a/packages/language-service/src/inlays/middlewareGenLike.ts b/packages/language-service/src/inlays/middlewareGenLike.ts index 6e68b7e4..662c7fe7 100644 --- a/packages/language-service/src/inlays/middlewareGenLike.ts +++ b/packages/language-service/src/inlays/middlewareGenLike.ts @@ -42,7 +42,7 @@ export const middlewareGenLike = Nano.fn("middlewareGenLike")(function*( const argsCloseParen = ts.findChildOfKind(_.generatorFunction, ts.SyntaxKind.CloseParenToken, sourceFile) if ( argsCloseParen && _.body && inlayHint.position >= argsCloseParen.end && - inlayHint.position <= _.body.getStart(sourceFile) + inlayHint.position <= ts.getTokenPosOfNode(_.body, sourceFile) ) { shouldOmit = true } diff --git a/packages/language-service/src/utils/SchemaGen.ts b/packages/language-service/src/utils/SchemaGen.ts index 77264510..995a0d67 100644 --- a/packages/language-service/src/utils/SchemaGen.ts +++ b/packages/language-service/src/utils/SchemaGen.ts @@ -29,7 +29,10 @@ export class OnlyLiteralPropertiesSupportedError { } toString() { - return `Could not process ${this.node.getText()} as only literal properties are supported.` + const sourceFile = this.node.getSourceFile() + return `Could not process ${ + sourceFile.text.substring(this.node.pos, this.node.end) + } as only literal properties are supported.` } } @@ -40,7 +43,10 @@ export class RequiredExplicitTypesError { ) { } toString() { - return `Could not process ${this.node.getText()} as only explicit types are supported.` + const sourceFile = this.node.getSourceFile() + return `Could not process ${ + sourceFile.text.substring(this.node.pos, this.node.end) + } as only explicit types are supported.` } } @@ -51,7 +57,10 @@ export class IndexSignatureWithMoreThanOneParameterError { ) { } toString() { - return `Could not process ${this.node.getText()} as only index signatures with one parameter are supported.` + const sourceFile = this.node.getSourceFile() + return `Could not process ${ + sourceFile.text.substring(this.node.pos, this.node.end) + } as only index signatures with one parameter are supported.` } } @@ -186,7 +195,7 @@ const createUnsupportedNodeComment = ( ts.addSyntheticTrailingComment( ts.factory.createIdentifier(""), ts.SyntaxKind.MultiLineCommentTrivia, - " Not supported conversion: " + node.getText(sourceFile) + " " + " Not supported conversion: " + sourceFile.text.substring(ts.getTokenPosOfNode(node, sourceFile), node.end) + " " ) export const processNode: ( @@ -302,7 +311,14 @@ export const processNode: ( ts.isIndexedAccessTypeNode(node) && ts.isParenthesizedTypeNode(node.objectType) && ts.isTypeQueryNode(node.objectType.type) && ts.isTypeOperatorNode(node.indexType) && node.indexType.operator === ts.SyntaxKind.KeyOfKeyword && ts.isTypeQueryNode(node.indexType.type) && - node.indexType.type.exprName.getText().trim() === node.objectType.type.exprName.getText().trim() + sourceFile.text.substring( + ts.getTokenPosOfNode(node.indexType.type.exprName, sourceFile), + node.indexType.type.exprName.end + ).trim() === + sourceFile.text.substring( + ts.getTokenPosOfNode(node.objectType.type.exprName, sourceFile), + node.objectType.type.exprName.end + ).trim() ) { const typeChecker = yield* Nano.service(TypeCheckerApi.TypeCheckerApi) const typeCheckerUtils = yield* Nano.service(TypeCheckerUtils.TypeCheckerUtils) diff --git a/packages/language-service/test/piping-flows.test.ts b/packages/language-service/test/piping-flows.test.ts index bb9894e2..d3e7c766 100644 --- a/packages/language-service/test/piping-flows.test.ts +++ b/packages/language-service/test/piping-flows.test.ts @@ -31,25 +31,48 @@ function formatPipingFlow( const lines: Array = [] // Get position info for the outer node - const startPos = flow.node.getStart() + const startPos = ts.getTokenPosOfNode(flow.node, sourceFile) const endPos = flow.node.getEnd() const start = ts.getLineAndCharacterOfPosition(sourceFile, startPos) const end = ts.getLineAndCharacterOfPosition(sourceFile, endPos) lines.push(`=== Piping Flow ===`) lines.push(`Location: ${start.line + 1}:${start.character + 1} - ${end.line + 1}:${end.character + 1}`) - lines.push(`Node: ${flow.node.getText().replace(/\n/g, "\\n")}`) + lines.push( + `Node: ${ + sourceFile.text.substring(ts.getTokenPosOfNode(flow.node, sourceFile), flow.node.end).replace( + /\n/g, + "\\n" + ) + }` + ) lines.push(`Node Kind: ${ts.SyntaxKind[flow.node.kind]}`) lines.push(``) - lines.push(`Subject: ${flow.subject.node.getText().replace(/\n/g, "\\n")}`) + lines.push( + `Subject: ${ + sourceFile.text.substring(ts.getTokenPosOfNode(flow.subject.node, sourceFile), flow.subject.node.end).replace( + /\n/g, + "\\n" + ) + }` + ) lines.push(`Subject Type: ${flow.subject.outType ? typeChecker.typeToString(flow.subject.outType) : "unknown"}`) lines.push(``) lines.push(`Transformations (${flow.transformations.length}):`) for (let i = 0; i < flow.transformations.length; i++) { const t = flow.transformations[i] - const calleeText = t.callee.getText() - const argsText = t.args ? t.args.map((a) => a.getText().replace(/\n/g, "\\n")).join(", ") : undefined + const calleeText = sourceFile.text.substring(ts.getTokenPosOfNode(t.callee, sourceFile), t.callee.end) + const argsText = t.args + ? t.args + .map((a) => + sourceFile.text.substring(ts.getTokenPosOfNode(a, sourceFile), a.end).replace( + /\n/g, + "\\n" + ) + ) + .join(", ") + : undefined const typeText = t.outType ? typeChecker.typeToString(t.outType) : "unknown" lines.push(` [${i}] kind: ${t.kind}`) @@ -114,7 +137,7 @@ function testPipingFlowsOnExample( ), Nano.map(({ flows, reconstructPipingFlow }) => { // Sort flows by position - flows.sort((a, b) => a.node.getStart() - b.node.getStart()) + flows.sort((a, b) => ts.getTokenPosOfNode(a.node, sourceFile) - ts.getTokenPosOfNode(b.node, sourceFile)) // Format flows return flows.length === 0 ? "// no piping flows found"