diff --git a/.gitignore b/.gitignore index e5227f3..f047aed 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .cache build +build-* coverage dist elm-stuff diff --git a/.prettierignore b/.prettierignore index 65bd80e..547bf30 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,5 +1,6 @@ .cache build +build-* coverage dist elm-stuff diff --git a/elm-watch-lib.d.ts b/elm-watch-lib.d.ts new file mode 100644 index 0000000..09bb647 --- /dev/null +++ b/elm-watch-lib.d.ts @@ -0,0 +1,197 @@ +// This file contains manually copy-pasted-and-adjusted types for `elm-watch-lib.ts`. + +import type { DecoderError } from "tiny-decoders"; + +export function readSourceDirectories(elmJsonPath: string): ElmJsonParseResult; + +export function walkImports( + sourceDirectories: NonEmptyArray, + inputRealPaths: NonEmptyArray, +): WalkImportsResult; + +export function inject(compilationMode: CompilationMode, code: string): string; + +export function elmMake(options: { + elmJsonPath: string; + compilationMode: CompilationMode; + inputs: NonEmptyArray; + outputPath: "/dev/null" | string; + env: Env; +}): { + promise: Promise; + kill: (options: { force: boolean }) => void; +}; + +export type NonEmptyArray = [T, ...Array]; + +export type Env = Record; + +export type CompilationMode = "debug" | "standard" | "optimize"; + +export type ElmJsonParseResult = + | ElmJsonParseError + | { + tag: "Parsed"; + sourceDirectories: NonEmptyArray; + }; + +export type ElmJsonParseError = + | { + tag: "ElmJsonDecodeError"; + elmJsonPath: string; + error: DecoderError; + } + | { + tag: "ElmJsonReadError"; + elmJsonPath: string; + error: Error; + }; + +export type WalkImportsResult = + | WalkImportsError + | { + tag: "Success"; + allRelatedElmFilePaths: Set; + }; + +export type WalkImportsError = { + tag: "ImportWalkerFileSystemError"; + error: NodeJS.ErrnoException; + relatedElmFilePathsUntilError: Set; +}; + +export type RunElmMakeResult = + | RunElmMakeError + | { tag: "Killed" } + | { tag: "Success" }; + +export type RunElmMakeError = + | { + tag: "ElmMakeCrashError"; + beforeError: ElmMakeCrashBeforeError; + error: string; + command: Command; + } + | { + tag: "ElmMakeError"; + error: ElmMakeError; + extraError: string | undefined; + } + | { + tag: "ElmMakeJsonParseError"; + error: DecoderError; + errorFilePath: ErrorFilePath; + command: Command; + } + | { + tag: "ElmNotFoundError"; + command: Command; + } + | { + tag: "OtherSpawnError"; + error: Error; + command: Command; + } + | { + tag: "UnexpectedElmMakeOutput"; + exitReason: ExitReason; + stdout: string; + stderr: string; + command: Command; + }; + +export type ElmMakeCrashBeforeError = + | { + tag: "Json"; + length: number; + } + | { + tag: "Text"; + text: string; + }; + +export type ErrorFilePath = + | { + tag: "AbsolutePath"; + theAbsolutePath: string; + } + | { + tag: "ErrorFileBadContent"; + content: string; + } + | { + tag: "WritingErrorFileFailed"; + error: Error; + attemptedPath: string; + }; + +export type ExitReason = + | { + tag: "ExitCode"; + exitCode: number; + } + | { + tag: "Signal"; + signal: NodeJS.Signals; + } + | { + tag: "Unknown"; + }; + +export type Command = { + command: string; + args: Array; + options: { + cwd: string; + env: Env; + }; + stdin?: Buffer | string; +}; + +type ElmMakeError = + | { + type: "error"; + path: "elm.json" | null; + title: string; + message: Array; + } + | { + type: "compile-errors"; + errors: NonEmptyArray<{ + path: string; + name: string; + problems: NonEmptyArray<{ + title: string; + region: { + start: { line: number; column: number }; + end: { line: number; column: number }; + }; + message: Array; + }>; + }>; + }; + +export type StyledText = { + bold: boolean; + underline: boolean; + color: Color | null; + string: string; +}; + +export type Color = + | "red" + | "RED" + | "magenta" + | "MAGENTA" + | "yellow" + | "YELLOW" + | "green" + | "GREEN" + | "cyan" + | "CYAN" + | "blue" + | "BLUE" + | "black" + | "BLACK" + | "white" + | "WHITE"; diff --git a/eslint.config.js b/eslint.config.js index 45e14a3..da707f1 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -269,6 +269,7 @@ export default typescriptEslint.config( { ignores: [ "build", + "build-*", "coverage", "dist", "example", diff --git a/package-elm-watch-lib.json b/package-elm-watch-lib.json new file mode 100644 index 0000000..bbde53c --- /dev/null +++ b/package-elm-watch-lib.json @@ -0,0 +1,14 @@ +{ + "name": "elm-watch-lib", + "version": "1.0.0", + "author": "Simon Lydell", + "license": "MIT", + "description": "Parts of elm-watch as a library for use by other tools.", + "repository": "lydell/elm-watch", + "type": "module", + "exports": "./index.js", + "dependencies": { + "cross-spawn": "*", + "tiny-decoders": "*" + } +} diff --git a/package.json b/package.json index b39981d..6e077c8 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "install-test-elm-dependencies": "tsx scripts/InstallTestDependencies.ts", "pretest": "run-pty --auto-exit % prettier --check . % eslint . --report-unused-disable-directives % tsc % tsx scripts/EnsureTestDependencies.ts % npm run build", "test": "vitest run --coverage", - "build": "tsx scripts/Build.ts" + "build": "tsx scripts/Build.ts && tsx scripts/BuildElmWatchLib.ts" }, "devDependencies": { "@types/cross-spawn": "6.0.6", diff --git a/scripts/BenchmarkImportWalker.ts b/scripts/BenchmarkImportWalker.ts index 6ca6b44..42e97d9 100644 --- a/scripts/BenchmarkImportWalker.ts +++ b/scripts/BenchmarkImportWalker.ts @@ -19,7 +19,6 @@ import { AbsolutePath, Cwd, ElmJsonPath, - InputPath, markAsAbsolutePath, markAsCwd, markAsElmJsonPath, @@ -33,22 +32,17 @@ function run(args: Array): void { const cwd: Cwd = markAsCwd(markAsAbsolutePath(process.cwd())); - const inputPaths: NonEmptyArray = mapNonEmptyArray( + const inputRealPaths: NonEmptyArray = mapNonEmptyArray( args, - (elmFilePathRaw) => ({ - tag: "InputPath", - theInputPath: absolutePathFromString(cwd, elmFilePathRaw), - originalString: elmFilePathRaw, - realpath: absolutePathFromString(cwd, elmFilePathRaw), - }), + (elmFilePathRaw) => absolutePathFromString(cwd, elmFilePathRaw), ); const elmJsonPathsRaw = new Set( mapNonEmptyArray( - inputPaths, - (inputPath) => - findClosest("elm.json", absoluteDirname(inputPath.theInputPath)) ?? - inputPath.theInputPath, + inputRealPaths, + (inputRealPath) => + findClosest("elm.json", absoluteDirname(inputRealPath)) ?? + inputRealPath, ), ); @@ -57,7 +51,7 @@ function run(args: Array): void { if (uniqueElmJsonPathRaw === undefined) { console.error( "Could not find (a unique) elm.json for all of the input paths:", - inputPaths, + inputRealPaths, ); process.exit(1); } @@ -81,13 +75,10 @@ function run(args: Array): void { // Keep going. } - console.log( - "Elm file(s):", - mapNonEmptyArray(inputPaths, (inputPath) => inputPath.theInputPath), - ); + console.log("Elm file(s):", inputRealPaths); console.log("elm.json:", elmJsonPath); console.time("Run"); - const result = walkImports(elmJsonResult.sourceDirectories, inputPaths); + const result = walkImports(elmJsonResult.sourceDirectories, inputRealPaths); console.timeEnd("Run"); switch (result.tag) { case "Success": diff --git a/scripts/Build.ts b/scripts/Build.ts index 7f5563b..c7a7bed 100644 --- a/scripts/Build.ts +++ b/scripts/Build.ts @@ -7,7 +7,7 @@ const DIR = path.dirname(import.meta.dirname); const BUILD = path.join(DIR, "build"); const CLIENT_DIR = path.join(DIR, "client"); -function readPackage>( +export function readPackage>( name: string, codec: Codec.Codec, ): T & { raw: Record } { @@ -35,7 +35,7 @@ const PACKAGE_REAL = readPackage( Codec.fields({ version: Codec.string }), ); -type FileToCopy = { +export type FileToCopy = { src: string; dest?: string; transform?: (content: string) => string; diff --git a/scripts/BuildElmWatchLib.ts b/scripts/BuildElmWatchLib.ts new file mode 100644 index 0000000..a0cb362 --- /dev/null +++ b/scripts/BuildElmWatchLib.ts @@ -0,0 +1,125 @@ +import * as esbuild from "esbuild"; +import * as fs from "fs"; +import * as path from "path"; +import * as Codec from "tiny-decoders"; + +import { FileToCopy, readPackage } from "./Build"; + +const DIR = path.dirname(import.meta.dirname); +const BUILD = path.join(DIR, "build-elm-watch-lib"); + +const PACKAGE = readPackage( + "package.json", + Codec.fields({ dependencies: Codec.record(Codec.string) }), +); + +const PACKAGE_REAL = readPackage( + "package-elm-watch-lib.json", + Codec.fields({ + description: Codec.string, + dependencies: Codec.record(Codec.string), + }), +); + +const FILES_TO_COPY: Array = [ + { src: "LICENSE" }, + { src: "elm-watch-lib.d.ts", dest: "index.d.ts" }, +]; + +async function run(): Promise { + fs.rmSync(BUILD, { recursive: true, force: true }); + fs.mkdirSync(BUILD); + + for (const { src, dest = src, transform } of FILES_TO_COPY) { + if (transform !== undefined) { + fs.writeFileSync( + path.join(BUILD, dest), + transform(fs.readFileSync(path.join(DIR, src), "utf8")), + ); + } else { + fs.copyFileSync(path.join(DIR, src), path.join(BUILD, dest)); + } + } + + fs.writeFileSync( + path.join(BUILD, "package.json"), + Codec.JSON.stringify( + Codec.unknown, + { + ...PACKAGE_REAL.raw, + dependencies: Object.fromEntries( + Object.entries(PACKAGE_REAL.dependencies).map(([name, version]) => { + if (version !== "*") { + throw new Error( + `${name}: Expected version to be * but got: ${version}`, + ); + } + const actualVersion = PACKAGE.dependencies[name]; + if (actualVersion === undefined) { + throw new Error( + `${name}: Expected the main package.json to have this dependency too, but it does not.`, + ); + } + return [name, actualVersion]; + }), + ), + }, + 2, + ), + ); + + fs.writeFileSync( + path.join(BUILD, "README.md"), + ` +# elm-watch-lib + +${PACKAGE_REAL.description} + `.trim(), + ); + + const result = await esbuild.build({ + bundle: true, + legalComments: "inline", + entryPoints: [path.join(DIR, "src", "elm-watch-lib.ts")], + packages: "external", + format: "esm", + outdir: BUILD, + platform: "node", + write: false, + plugins: [ + { + // I didn’t manage to do this with the `external` option, so using a plugin instead. + name: "Ignore ClientCode.ts", + setup(build) { + build.onResolve( + { + filter: /^\.\/ClientCode$/, + }, + (args) => ({ path: args.path, external: true }), + ); + }, + }, + ], + }); + + for (const output of result.outputFiles) { + switch (path.basename(output.path)) { + case "elm-watch-lib.js": { + const code = output.text.replace( + `import * as ClientCode from "./ClientCode";\n`, + "", + ); + fs.writeFileSync(path.join(BUILD, "index.js"), code); + break; + } + + default: + throw new Error(`Unexpected output: ${output.path}`); + } + } +} + +run().catch((error: Error) => { + process.stderr.write(`${error.message}\n`); + process.exit(1); +}); diff --git a/src/Compile.ts b/src/Compile.ts index 6fed7dc..152843f 100644 --- a/src/Compile.ts +++ b/src/Compile.ts @@ -2262,8 +2262,12 @@ export function renderOutputErrors( } } -type GetAllRelatedElmFilePathsResult = ElmJson.ParseError | WalkImportsResult; -type GetAllRelatedElmFilePathsError = ElmJson.ParseError | WalkImportsError; +type GetAllRelatedElmFilePathsResult = + | ElmJson.ElmJsonParseError + | WalkImportsResult; +type GetAllRelatedElmFilePathsError = + | ElmJson.ElmJsonParseError + | WalkImportsError; function getAllRelatedElmFilePaths( elmJsonPath: ElmJsonPath, @@ -2273,7 +2277,10 @@ function getAllRelatedElmFilePaths( switch (result.tag) { case "Parsed": - return walkImports(result.sourceDirectories, inputs); + return walkImports( + result.sourceDirectories, + mapNonEmptyArray(inputs, (input) => input.realpath), + ); default: return result; diff --git a/src/ElmJson.ts b/src/ElmJson.ts index e1751ba..8026337 100644 --- a/src/ElmJson.ts +++ b/src/ElmJson.ts @@ -19,14 +19,14 @@ export const ElmJson = Codec.taggedUnion("type", [ }, ]); -type ParseResult = - | ParseError +type ElmJsonParseResult = + | ElmJsonParseError | { tag: "Parsed"; sourceDirectories: NonEmptyArray; }; -export type ParseError = +export type ElmJsonParseError = | { tag: "ElmJsonDecodeError"; elmJsonPath: ElmJsonPath; @@ -38,7 +38,9 @@ export type ParseError = error: Error; }; -export function readSourceDirectories(elmJsonPath: ElmJsonPath): ParseResult { +export function readSourceDirectories( + elmJsonPath: ElmJsonPath, +): ElmJsonParseResult { const parsed = readJsonFile(elmJsonPath, ElmJson); switch (parsed.tag) { case "DecoderError": diff --git a/src/ImportWalker.ts b/src/ImportWalker.ts index 57d297b..f0c1833 100644 --- a/src/ImportWalker.ts +++ b/src/ImportWalker.ts @@ -5,12 +5,7 @@ import * as path from "path"; import { toError } from "./Helpers"; import { mapNonEmptyArray, NonEmptyArray } from "./NonEmptyArray"; import * as Parser from "./Parser"; -import { - AbsolutePath, - InputPath, - markAsAbsolutePath, - SourceDirectory, -} from "./Types"; +import { AbsolutePath, markAsAbsolutePath, SourceDirectory } from "./Types"; export type WalkImportsResult = | WalkImportsError @@ -25,15 +20,15 @@ export type WalkImportsError = { relatedElmFilePathsUntilError: Set; }; -// Returns Elm file paths that if created, deleted or changed, `inputPath` needs +// Returns Elm file paths that if created, deleted or changed, `inputRealPath` needs // to be recompiled. export function walkImports( sourceDirectories: NonEmptyArray, - inputPaths: NonEmptyArray, + inputRealPaths: NonEmptyArray, ): WalkImportsResult { const allRelatedElmFilePaths = new Set( - inputPaths.flatMap((inputPath) => - initialRelatedElmFilePaths(sourceDirectories, inputPath), + inputRealPaths.flatMap((inputRealPath) => + initialRelatedElmFilePaths(sourceDirectories, inputRealPath), ), ); @@ -41,13 +36,13 @@ export function walkImports( const visitedModules = new Set(); try { - for (const inputPath of inputPaths) { + for (const inputRealPath of inputRealPaths) { walkImportsHelper( mapNonEmptyArray(sourceDirectories, (sourceDirectory) => ({ sourceDirectory, children: new Set(readdirSync(sourceDirectory)), })), - inputPath.realpath, + inputRealPath, allRelatedElmFilePaths, visitedModules, ); @@ -143,15 +138,14 @@ function parse(elmFilePath: AbsolutePath): Array { // alternative paths in other source directories using all possible module names // (valid or not). (Note: The `module` line cannot be trusted – it might contain // a name not matching the file name (which of course is invalid, but still).) +// +// Inputs are allowed to be symlinks. If there’s an error in the input, Elm +// shows the resolved path in the error message rather than the original path +// (the path where the symlink is located). That’s why we work with the realpath. function initialRelatedElmFilePaths( sourceDirectories: NonEmptyArray, - inputPath: InputPath, + inputRealPath: AbsolutePath, ): NonEmptyArray { - // Inputs are allowed to be symlinks. If there’s an error in the input, Elm - // shows the resolved path in the error message rather than the original path - // (the path where the symlink is located). - const inputRealPath = inputPath.realpath; - return [ inputRealPath, ...sourceDirectories.flatMap((sourceDirectory) => { diff --git a/src/Project.ts b/src/Project.ts index 09dfe01..6f81e0d 100644 --- a/src/Project.ts +++ b/src/Project.ts @@ -192,7 +192,7 @@ export type OutputStatus = }; export type OutputError = - | ElmJson.ParseError + | ElmJson.ElmJsonParseError | OutputFsError | PostprocessError | RunElmMakeError diff --git a/src/SpawnElm.ts b/src/SpawnElm.ts index cdd6fa0..42a75e9 100644 --- a/src/SpawnElm.ts +++ b/src/SpawnElm.ts @@ -60,7 +60,11 @@ export type RunElmMakeError = command: Command; }; -type NullOutputPath = { tag: "NullOutputPath" }; +type LocalOutputPath = + | { tag: "NullOutputPath" } + | (Pick & { + writeToTemporaryDir: boolean; + }); export function make({ elmJsonPath, @@ -72,8 +76,8 @@ export function make({ }: { elmJsonPath: ElmJsonPath; compilationMode: CompilationMode; - inputs: NonEmptyArray; - outputPath: NullOutputPath | (OutputPath & { writeToTemporaryDir: boolean }); + inputs: NonEmptyArray>; + outputPath: LocalOutputPath; env: Env; getNow: GetNow; }): { @@ -198,7 +202,7 @@ export function compilationModeToArg( function outputPathToAbsoluteString( cwd: AbsolutePath, - outputPath: NullOutputPath | (OutputPath & { writeToTemporaryDir: boolean }), + outputPath: LocalOutputPath, ): string { switch (outputPath.tag) { case "OutputPath": diff --git a/src/elm-watch-lib.ts b/src/elm-watch-lib.ts new file mode 100644 index 0000000..89b25f2 --- /dev/null +++ b/src/elm-watch-lib.ts @@ -0,0 +1,57 @@ +// This file is available as `elm-watch-lib` on npm and contains exactly the things needed by: +// +// - https://github.com/ryan-haskell/vite-plugin-elm-watch +// +// No more, no less. There is no documentation of the library – you’ll need to read the types and source code. +// +// If you use `elm-watch-lib` in a project not listed above, please let me know! + +import { Env } from "./Env"; +import { mapNonEmptyArray, type NonEmptyArray } from "./NonEmptyArray"; +import { make, type RunElmMakeResult } from "./SpawnElm"; +import { + type CompilationMode, + markAsAbsolutePath, + markAsElmJsonPath, +} from "./Types"; + +export { readSourceDirectories } from "./ElmJson"; +export { walkImports } from "./ImportWalker"; +export { inject } from "./Inject"; + +export function elmMake({ + elmJsonPath, + compilationMode, + inputs, + outputPath, + env, +}: { + elmJsonPath: string; + compilationMode: CompilationMode; + inputs: NonEmptyArray; + outputPath: string; + env: Env; +}): { + promise: Promise; + kill: (options: { force: boolean }) => void; +} { + return make({ + elmJsonPath: markAsElmJsonPath(markAsAbsolutePath(elmJsonPath)), + compilationMode, + inputs: mapNonEmptyArray(inputs, (rawInput) => ({ + tag: "InputPath", + theInputPath: markAsAbsolutePath(rawInput), + })), + outputPath: + outputPath === "/dev/null" + ? { tag: "NullOutputPath" } + : { + tag: "OutputPath", + theOutputPath: markAsAbsolutePath(outputPath), + temporaryOutputPath: markAsAbsolutePath(outputPath), + writeToTemporaryDir: false, + }, + env, + getNow: () => new Date(), + }); +} diff --git a/tests/ImportWalker.test.ts b/tests/ImportWalker.test.ts index 9a3603d..98cb8fb 100644 --- a/tests/ImportWalker.test.ts +++ b/tests/ImportWalker.test.ts @@ -6,7 +6,6 @@ import { mapNonEmptyArray, NonEmptyArray } from "../src/NonEmptyArray"; import { absolutePathFromString, absoluteRealpath } from "../src/PathHelpers"; import { AbsolutePath, - InputPath, markAsAbsolutePath, markAsSourceDirectory, SourceDirectory, @@ -25,18 +24,11 @@ function walkImportsHelper( ): string { const dir = absolutePathFromString(FIXTURES_DIR, fixture); - const inputPaths: NonEmptyArray = mapNonEmptyArray( + const inputRealPaths: NonEmptyArray = mapNonEmptyArray( inputFiles, (inputFile) => { - const theInputPath = absolutePathFromString(dir, inputFile); - return { - tag: "InputPath", - theInputPath, - originalString: inputFile, - realpath: resolveSymlinks - ? absoluteRealpath(theInputPath) - : theInputPath, - }; + const inputPath = absolutePathFromString(dir, inputFile); + return resolveSymlinks ? absoluteRealpath(inputPath) : inputPath; }, ); @@ -46,7 +38,7 @@ function walkImportsHelper( (sourceDirectory): SourceDirectory => markAsSourceDirectory(absolutePathFromString(dir, sourceDirectory)), ), - inputPaths, + inputRealPaths, ); switch (result.tag) { diff --git a/vitest.config.ts b/vitest.config.ts index 8900b1c..e8f4425 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -26,8 +26,13 @@ export default defineConfig({ }, coverage: { include: ["src/**/*.ts"], - // Vitest reports 0 % coverage for this file, while in reality it should be 100 %. - exclude: ["src/PostprocessWorker.ts"], + exclude: [ + // Vitest reports 0 % coverage for this file, while in reality it should be 100 %. + "src/PostprocessWorker.ts", + // There is no need for tests for this file – it’s basically just re-exports. + // It needs to be tested manually against the projects that use it anyway. + "src/elm-watch-lib.ts", + ], thresholds: { ...(process.platform === "win32" ? windowsCoverage : requireCoverage), },