Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/sleep-then-to-delay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@effect/tsgo": patch
---

Add `sleepThenEffectToDelay` diagnostic (`TS377133`) to suggest using `Effect.delay` instead of sequencing `Effect.sleep` before an effect.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ Some diagnostics are off by default or have a default severity of suggestion, bu
<tr><td><a href="https://github.com/Effect-TS/tsgo/blob/main/docs/rules/schema-struct-with-tag.md"><code>schemaStructWithTag</code></a></td><td>Suggests using Schema.TaggedStruct instead of Schema.Struct with _tag field</td></tr>
<tr><td><a href="https://github.com/Effect-TS/tsgo/blob/main/docs/rules/schema-union-of-literals.md"><code>schemaUnionOfLiterals</code></a></td><td>Suggests combining multiple Schema.Literal calls in Schema.Union into a single Schema.Literal</td></tr>
<tr><td><a href="https://github.com/Effect-TS/tsgo/blob/main/docs/rules/service-not-as-class.md"><code>serviceNotAsClass</code></a></td><td>Warns when Context.Service is used as a variable instead of a class declaration</td></tr>
<tr><td><a href="https://github.com/Effect-TS/tsgo/blob/main/docs/rules/sleep-then-effect-to-delay.md"><code>sleepThenEffectToDelay</code></a></td><td>Sequencing Effect.sleep before an effect re-implements Effect.delay</td></tr>
<tr><td><a href="https://github.com/Effect-TS/tsgo/blob/main/docs/rules/strict-boolean-expressions.md"><code>strictBooleanExpressions</code></a></td><td>Enforces boolean types in conditional expressions for type safety</td></tr>
<tr><td><a href="https://github.com/Effect-TS/tsgo/blob/main/docs/rules/sync-to-succeed.md"><code>syncToSucceed</code></a></td><td>Suggests using Effect.succeed instead of Effect.sync when the thunk returns a constant value</td></tr>
<tr><td><a href="https://github.com/Effect-TS/tsgo/blob/main/docs/rules/timeout-catch-tag-to-timeout-or-else.md"><code>timeoutCatchTagToTimeoutOrElse</code></a></td><td>Suggests dedicated timeout combinators instead of catching TimeoutError immediately after Effect.timeout</td></tr>
Expand Down
24 changes: 24 additions & 0 deletions _packages/tsgo/src/metadata.json
Original file line number Diff line number Diff line change
Expand Up @@ -2526,6 +2526,30 @@
]
}
},
{
"name": "sleepThenEffectToDelay",
"group": "style",
"description": "Sequencing Effect.sleep before an effect re-implements Effect.delay",
"defaultSeverity": "suggestion",
"fixable": false,
"supportedEffect": [
"v3",
"v4"
],
"codes": [
377133
],
"preview": {
"sourceText": "import { Effect } from \"effect\"\n\ndeclare const sendRequest: Effect.Effect\u003cvoid\u003e\n\nexport const throttled = Effect.sleep(\"1 second\").pipe(\n Effect.andThen(sendRequest)\n)\n",
"diagnostics": [
{
"start": 139,
"end": 153,
"text": "Sequencing Effect.sleep before an effect re-implements Effect.delay. effect(sleepThenEffectToDelay)"
}
]
}
},
{
"name": "strictBooleanExpressions",
"group": "style",
Expand Down
67 changes: 67 additions & 0 deletions docs/rules/sleep-then-effect-to-delay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<!-- This file is generated by internal/rules/rules_json_test.go. Do not edit it manually. -->

# `sleepThenEffectToDelay`

Sequencing Effect.sleep before an effect re-implements Effect.delay

| Property | Value |
| --- | --- |
| Category | Style |
| Default severity | `suggestion` |
| Fixable | No |
| Effect versions | v3, v4 |
| Diagnostic codes | `TS377133` |
| Language Service name | `sleepThenEffectToDelay` |
| Oxlint name | `effecttsgo/sleep-then-effect-to-delay` |

## Preview

```ts
import { Effect } from "effect"

declare const sendRequest: Effect.Effect<void>

export const throttled = Effect.sleep("1 second").pipe(
Effect.andThen(sendRequest)
/**
^^^^^^^^^^^^^^ effecttsgo(sleep-then-effect-to-delay): Sequencing Effect.sleep before an effect re-implements Effect.delay.
*/
)
```

## Language Service Configuration

See the [Language Service setup guide](../../README.md#installation) for installation instructions.

```jsonc
{
"$schema": "./node_modules/@effect/tsgo/schema.json",
"compilerOptions": {
"plugins": [
{
"name": "@effect/language-service",
"diagnosticSeverity": {
"sleepThenEffectToDelay": "warning"
}
}
]
}
}
```

## Oxlint Configuration

See the [Oxlint setup guide](../README.md#oxlint-setup) for installation and patching instructions.

```json
{
"$schema": "./node_modules/@effect/tsgo/oxlint-schema.json",
"options": {
"typeAware": true
},
"plugins": ["effecttsgo"],
"rules": {
"effecttsgo/sleep-then-effect-to-delay": "warn"
}
}
```
4 changes: 4 additions & 0 deletions internal/diagnostics/effectDiagnosticMessages.json
Original file line number Diff line number Diff line change
Expand Up @@ -506,5 +506,9 @@
"This module reference imports `{0}`, which is obsolete in Effect v4. In Effect v4, Schema is provided directly by `Schema` from `effect` (or `effect/Schema`). effect(obsoleteSchemaImport)": {
"category": "Warning",
"code": 377128
},
"Sequencing Effect.sleep before an effect re-implements Effect.delay. effect(sleepThenEffectToDelay)": {
"category": "Suggestion",
"code": 377133
}
}
1 change: 1 addition & 0 deletions internal/rules/rules.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,4 +119,5 @@ var All = []rule.Rule{
AcquireReleaseDisposable,
RaceFirstWithSleepToTimeout,
RunOfExitToRunExit,
SleepThenEffectToDelay,
}
234 changes: 234 additions & 0 deletions internal/rules/sleep_then_effect_to_delay.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
package rules

import (
"github.com/effect-ts/tsgo/etscore"
"github.com/effect-ts/tsgo/internal/rule"
"github.com/effect-ts/tsgo/internal/typeparser"
"github.com/microsoft/TypeScript/tsc/shim/ast"
"github.com/microsoft/TypeScript/tsc/shim/checker"
"github.com/microsoft/TypeScript/tsc/shim/core"
tsdiag "github.com/microsoft/TypeScript/tsc/shim/diagnostics"
"github.com/microsoft/TypeScript/tsc/shim/scanner"
)

// SleepThenEffectToDelay suggests using Effect.delay instead of sequencing Effect.sleep before an effect.
var SleepThenEffectToDelay = rule.Rule{
Name: "sleepThenEffectToDelay",
Group: "style",
Description: "Sequencing Effect.sleep before an effect re-implements Effect.delay",
DefaultSeverity: etscore.SeveritySuggestion,
SupportedEffect: []string{"v3", "v4"},
Codes: []int32{
tsdiag.Sequencing_Effect_sleep_before_an_effect_re_implements_Effect_delay_effect_sleepThenEffectToDelay.Code(),
},
Run: func(ctx *rule.Context) []*ast.Diagnostic {
matches := AnalyzeSleepThenEffectToDelay(ctx.TypeParser, ctx.Checker, ctx.SourceFile)
diags := make([]*ast.Diagnostic, len(matches))
for i, match := range matches {
diags[i] = ctx.NewDiagnostic(
match.SourceFile,
match.Location,
tsdiag.Sequencing_Effect_sleep_before_an_effect_re_implements_Effect_delay_effect_sleepThenEffectToDelay,
nil,
)
}
return diags
},
}

// SleepThenEffectToDelayMatch holds match details for diagnostic emission.
type SleepThenEffectToDelayMatch struct {
SourceFile *ast.SourceFile
Location core.TextRange
Callee *ast.Node
}

// AnalyzeSleepThenEffectToDelay finds sequencing calls where Effect.sleep is the first operand.
func AnalyzeSleepThenEffectToDelay(tp *typeparser.TypeParser, c *checker.Checker, sf *ast.SourceFile) []SleepThenEffectToDelayMatch {
if tp == nil || c == nil || sf == nil {
return nil
}

var matches []SleepThenEffectToDelayMatch
visitedCallees := make(map[core.TextRange]bool)

flows := tp.PipingFlows(sf, true)
for _, flow := range flows {
if flow == nil {
continue
}

var sequencer *typeparser.PipingFlowTransformation

if isDirectEffectSleepCall(tp, flow.Subject.Node) {
if len(flow.Transformations) > 0 {
sequencer = &flow.Transformations[0]
}
} else if len(flow.Transformations) > 1 && isEffectSleepTransformation(tp, &flow.Transformations[0]) {
sequencer = &flow.Transformations[1]
}

if sequencer == nil || sequencer.Callee == nil {
continue
}

if !isSequencerTransformation(tp, c, sequencer) {
continue
}

loc := scanner.GetErrorRangeForNode(sf, sequencer.Callee)
if visitedCallees[loc] {
continue
}
visitedCallees[loc] = true

matches = append(matches, SleepThenEffectToDelayMatch{
SourceFile: sf,
Location: loc,
Callee: sequencer.Callee,
})
}

return matches
}

func isDirectEffectSleepCall(tp *typeparser.TypeParser, node *ast.Node) bool {
if node == nil {
return false
}
node = ast.SkipParentheses(node)
if node.Kind != ast.KindCallExpression {
return false
}
call := node.AsCallExpression()
if call == nil || call.Expression == nil {
return false
}
return tp.IsNodeReferenceToEffectModuleApi(call.Expression, "sleep")
}

func isEffectSleepTransformation(tp *typeparser.TypeParser, transformation *typeparser.PipingFlowTransformation) bool {
if transformation == nil || transformation.Callee == nil {
return false
}
return tp.IsNodeReferenceToEffectModuleApi(transformation.Callee, "sleep")
}

func isSequencerTransformation(tp *typeparser.TypeParser, c *checker.Checker, transformation *typeparser.PipingFlowTransformation) bool {
if tp == nil || c == nil || transformation == nil || transformation.Callee == nil || len(transformation.Args) == 0 {
return false
}
callee := transformation.Callee
arg := transformation.Args[0]
if arg == nil {
return false
}

switch {
case tp.IsNodeReferenceToEffectModuleApi(callee, "andThen"):
t := tp.GetTypeAtLocation(arg)
return sleepContinuationIsEffect(tp, t)

case tp.IsNodeReferenceToEffectModuleApi(callee, "zipRight"):
t := tp.GetTypeAtLocation(arg)
return sleepContinuationIsEffect(tp, t)

case tp.IsNodeReferenceToEffectModuleApi(callee, "flatMap"):
return isCallbackIgnoringParam(tp, c, arg)

default:
return false
}
}

func isCallbackIgnoringParam(tp *typeparser.TypeParser, c *checker.Checker, callbackNode *ast.Node) bool {
if callbackNode == nil {
return false
}
callbackNode = ast.SkipParentheses(callbackNode)
var params *ast.NodeList
var body *ast.Node

switch callbackNode.Kind {
case ast.KindArrowFunction:
fn := callbackNode.AsArrowFunction()
params = fn.Parameters
body = fn.Body
case ast.KindFunctionExpression:
fn := callbackNode.AsFunctionExpression()
params = fn.Parameters
body = fn.Body
default:
return false
}

if body == nil {
return false
}

paramCount := 0
if params != nil {
paramCount = len(params.Nodes)
}

if paramCount == 0 {
return true
}

if paramCount == 1 {
paramDecl := params.Nodes[0].AsParameterDeclaration()
if paramDecl == nil || paramDecl.Name() == nil || paramDecl.DotDotDotToken != nil {
return false
}
nameNode := paramDecl.Name()
if nameNode.Kind != ast.KindIdentifier {
return false
}
paramSymbol := tp.GetSymbolAtLocation(nameNode)
if paramSymbol == nil {
return true
}
return !sleepParamIsReferenced(tp, c, paramSymbol, body)
}

return false
}

// sleepParamIsReferenced checks if the given parameter symbol is referenced in the node tree.
func sleepParamIsReferenced(tp *typeparser.TypeParser, c *checker.Checker, paramSymbol *ast.Symbol, body *ast.Node) bool {
var usesParameter func(node *ast.Node) bool
usesParameter = func(node *ast.Node) bool {
if node == nil {
return false
}
if node.Kind == ast.KindShorthandPropertyAssignment && c.GetShorthandAssignmentValueSymbol(node) == paramSymbol {
return true
}
if node.Kind == ast.KindIdentifier {
sym := tp.GetSymbolAtLocation(node)
if sym != nil && (sym == paramSymbol || checker.Checker_getSymbolIfSameReference(c, sym, paramSymbol) != nil) {
return true
}
}
return node.ForEachChild(usesParameter)
}
return usesParameter(body)
}

// sleepContinuationIsEffect checks whether all constituents of the type (unrolling unions)
// satisfy the Effect variance interface.
func sleepContinuationIsEffect(tp *typeparser.TypeParser, t *checker.Type) bool {
if tp == nil || t == nil {
return false
}
members := tp.UnrollUnionMembers(t)
if len(members) == 0 {
return false
}
for _, m := range members {
if !tp.IsEffectType(m) {
return false
}
}
return true
}
1 change: 1 addition & 0 deletions shim/diagnostics/shim.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
=== Metadata ===
Effect version: 3.19.19



==== /.src/sleepThenEffectToDelay.ts (0 errors) ====
// @effect-v3
// @effect-diagnostics *:off
// @effect-diagnostics sleepThenEffectToDelay:suggestion
import { Effect } from "effect"

declare const sendRequest: Effect.Effect<void>

// Negative control: GOOD forms only
export const goodDelayPipe = sendRequest.pipe(Effect.delay("1 second"))
export const goodDelayDataFirst = Effect.delay(sendRequest, "100 millis")

export const goodGenStandalone = Effect.gen(function* () {
yield* Effect.sleep("1 second")
return yield* sendRequest
})

export const goodParamUsingCallback = Effect.sleep("1 second").pipe(
Effect.flatMap((val) => Effect.succeed(val))
)

Loading