1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Path from "effect/Path";
6import * as PlatformError from "effect/PlatformError";
7import * as Schema from "effect/Schema";
8import * as Stream from "effect/Stream";
9import { ChildProcess, ChildProcessSpawner } from "effect/process";
10import * as NodeModule from "node:module";
11
12// oxlint is only a transitive dependency (via vite-plus), so its bin placement
13// varies by package manager: pnpm hoists it into the virtual store, while
14// other layouts expose vite-plus's LSP-only wrapper instead. Resolve the real
15// package through vite-plus rather than hardcoding either layout, and do it at
16const oxlintPackageJsonPath = NodeModule.createRequire(
17 NodeModule.createRequire(import.meta.url).resolve("vite-plus/package.json"),
18).resolve("oxlint/package.json");
19
20class OxlintFixtureFailure extends Schema.TaggedError<OxlintFixtureFailure>()(
21 "OxlintFixtureFailure",
22 {
23 exitCode: Schema.Number,
24 stdout: Schema.String,
25 stderr: Schema.String,
26 },
27) {}
28
29const isOxlintFixtureFailure = Schema.is(OxlintFixtureFailure);
30
31class OxlintFixtureExpectedFailure extends Schema.TaggedError<OxlintFixtureExpectedFailure>()(
32 "OxlintFixtureExpectedFailure",
33 { ruleName: Schema.String },
34) {
35 override get message() {
36 return `Expected oxlint to report a failure for rule ${this.ruleName}, but it passed.`;
37 }
38}
39
40const encodeOxlintConfig = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
41
42interface RuleHarness {
43 readonly run: (
44 source: string,
45 ) => Effect.Effect<
46 string,
47 OxlintFixtureFailure | PlatformError.PlatformError | Schema.SchemaError,
48 NodeServices.NodeServices
49 >;
50 readonly runAndExpectFailure: (
51 source: string,
52 ) => Effect.Effect<
53 string,
54 OxlintFixtureExpectedFailure | PlatformError.PlatformError | Schema.SchemaError,
55 NodeServices.NodeServices
56 >;
57 readonly valid: (name: string, source: string) => void;
58 readonly invalid: (name: string, source: string, assertion?: (output: string) => void) => void;
59}
60
61interface RuleHarnessOptions {
62 readonly filename?: string;
63 readonly ruleOptions?: ReadonlyArray<unknown>;
64}
65
66const collectStreamAsString = <E>(stream: Stream.Stream<Uint8Array, E>): Effect.Effect<string, E> =>
67 stream.pipe(
68 Stream.decodeText(),
69 Stream.runFold(
70 () => "",
71 (acc, chunk) => acc + chunk,
72 ),
73 );
74
75const spawnAndCollectOutput = Effect.fnUntraced(function* (command: ChildProcess.Command) {
76 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
77 const child = yield* spawner.spawn(command);
78
79 const [stdout, stderr, exitCode] = yield* Effect.all(
80 [
81 collectStreamAsString(child.stdout),
82 collectStreamAsString(child.stderr),
83 child.exitCode.pipe(Effect.map(Number)),
84 ],
85 { concurrency: "unbounded" },
86 );
87
88 return { exitCode, stdout, stderr };
89}, Effect.scoped);
90
91export const createOxlintRuleHarness = (
92 ruleName: string,
93 options: RuleHarnessOptions = {},
94): RuleHarness => {
95 const [pluginName, shortRuleName] = ruleName.split("/");
96 const diagnosticRuleName =
97 pluginName && shortRuleName ? `${pluginName}\\(${shortRuleName}\\)` : ruleName;
98 const test = it.layer(NodeServices.layer);
99
100 const run: RuleHarness["run"] = Effect.fnUntraced(function* (source: string) {
101 const fs = yield* FileSystem.FileSystem;
102 const path = yield* Path.Path;
103 const fixtureDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-oxlint-" });
104 const configPath = path.join(fixtureDir, ".oxlintrc.json");
105 const sourcePath = path.join(fixtureDir, options.filename ?? "fixture.ts");
106 const repoRoot = path.join(import.meta.dirname, "..", "..");
107 const oxlintBin = path.join(path.dirname(oxlintPackageJsonPath), "bin", "oxlint");
108 const pluginPath = path.join(repoRoot, "oxlint-plugin-t3code", "index.ts");
109
110 yield* fs.writeFileString(
111 configPath,
112 yield* encodeOxlintConfig({
113 jsPlugins: [{ name: "t3code", specifier: pluginPath }],
114 rules: { [ruleName]: ["error", ...(options.ruleOptions ?? [])] },
115 }),
116 );
117 yield* fs.makeDirectory(path.dirname(sourcePath), { recursive: true });
118 yield* fs.writeFileString(sourcePath, source);
119
120 // Run through the current Node binary: oxlint's bin is an extensionless
121 // shebang script, which Windows cannot spawn directly and which would
122 // otherwise pick up whatever node is first on PATH.
123 const output = yield* spawnAndCollectOutput(
124 ChildProcess.make(process.execPath, [oxlintBin, "--config", configPath, sourcePath], {
125 cwd: repoRoot,
126 }),
127 );
128
129 if (output.exitCode !== 0) {
130 return yield* new OxlintFixtureFailure({
131 exitCode: output.exitCode,
132 stdout: output.stdout,
133 stderr: output.stderr,
134 });
135 }
136
137 return `${output.stdout}${output.stderr}`;
138 }, Effect.scoped);
139
140 const runAndExpectFailure: RuleHarness["runAndExpectFailure"] = (source) =>
141 run(source).pipe(
142 Effect.matchEffect({
143 onFailure: (error) =>
144 isOxlintFixtureFailure(error)
145 ? Effect.succeed(
146 `oxlint fixture failed with exit code ${error.exitCode}\n${error.stdout}\n${error.stderr}`,
147 )
148 : Effect.fail(error),
149 onSuccess: () => Effect.fail(new OxlintFixtureExpectedFailure({ ruleName })),
150 }),
151 );
152
153 return {
154 run,
155 runAndExpectFailure,
156 valid(name, source) {
157 test(name, (it) => {
158 it.effect("passes", () => run(source));
159 });
160 },
161 invalid(name, source, assertion) {
162 test(name, (it) => {
163 it.effect("reports the rule diagnostic", () =>
164 runAndExpectFailure(source).pipe(
165 Effect.tap((output) =>
166 Effect.sync(() => {
167 assert.match(output, new RegExp(diagnosticRuleName));
168 assertion?.(output);
169 }),
170 ),
171 ),
172 );
173 });
174 },
175 };
176};