diff --git a/_tools/node_test_runner/run_test.mjs b/_tools/node_test_runner/run_test.mjs index a3ad0260b9b6..cd1cb999ff43 100644 --- a/_tools/node_test_runner/run_test.mjs +++ b/_tools/node_test_runner/run_test.mjs @@ -51,6 +51,7 @@ import "../../collections/without_all_test.ts"; import "../../collections/zip_test.ts"; import "../../fs/unstable_read_dir_test.ts"; import "../../fs/unstable_stat_test.ts"; +import "../../fs/unstable_symlink_test.ts"; import "../../fs/unstable_lstat_test.ts"; import "../../fs/unstable_chmod_test.ts"; diff --git a/fs/deno.json b/fs/deno.json index 809654b85731..cd2c9bd9732c 100644 --- a/fs/deno.json +++ b/fs/deno.json @@ -17,6 +17,7 @@ "./unstable-lstat": "./unstable_lstat.ts", "./unstable-read-dir": "./unstable_read_dir.ts", "./unstable-stat": "./unstable_stat.ts", + "./unstable-symlink": "./unstable_symlink.ts", "./unstable-types": "./unstable_types.ts", "./walk": "./walk.ts" } diff --git a/fs/unstable_symlink.ts b/fs/unstable_symlink.ts new file mode 100644 index 000000000000..fbaa201354e8 --- /dev/null +++ b/fs/unstable_symlink.ts @@ -0,0 +1,79 @@ +// Copyright 2018-2025 the Deno authors. MIT license. + +import { getNodeFs, isDeno } from "./_utils.ts"; +import { mapError } from "./_map_error.ts"; +import type { SymlinkOptions } from "./unstable_types.ts"; + +/** + * Creates `newpath` as a symbolic link to `oldpath`. + * + * The `options.type` parameter can be set to `"file"`, `"dir"` or `"junction"`. + * This argument is only available on Windows and ignored on other platforms. + * + * Requires full `allow-read` and `allow-write` permissions. + * + * @example Usage + * ```ts ignore + * import { symlink } from "@std/fs/unstable-symlink"; + * await symlink("README.md", "README.md.link"); + * ``` + * + * @tags allow-read, allow-write + * + * @param oldpath The path of the resource pointed by the symbolic link. + * @param newpath The path of the symbolic link. + */ +export async function symlink( + oldpath: string | URL, + newpath: string | URL, + options?: SymlinkOptions, +): Promise { + if (isDeno) { + return Deno.symlink(oldpath, newpath, options); + } else { + try { + return await getNodeFs().promises.symlink( + oldpath, + newpath, + options?.type, + ); + } catch (error) { + throw mapError(error); + } + } +} + +/** + * Creates `newpath` as a symbolic link to `oldpath`. + * + * The `options.type` parameter can be set to `"file"`, `"dir"` or `"junction"`. + * This argument is only available on Windows and ignored on other platforms. + * + * Requires full `allow-read` and `allow-write` permissions. + * + * @example Usage + * ```ts ignore + * import { symlinkSync } from "@std/fs/unstable-symlink"; + * symlinkSync("README.md", "README.md.link"); + * ``` + * + * @tags allow-read, allow-write + * + * @param oldpath The path of the resource pointed by the symbolic link. + * @param newpath The path of the symbolic link. + */ +export function symlinkSync( + oldpath: string | URL, + newpath: string | URL, + options?: SymlinkOptions, +): void { + if (isDeno) { + return Deno.symlinkSync(oldpath, newpath, options); + } else { + try { + return getNodeFs().symlinkSync(oldpath, newpath, options?.type); + } catch (error) { + throw mapError(error); + } + } +} diff --git a/fs/unstable_symlink_test.ts b/fs/unstable_symlink_test.ts new file mode 100644 index 000000000000..7232ff178b91 --- /dev/null +++ b/fs/unstable_symlink_test.ts @@ -0,0 +1,118 @@ +// Copyright 2018-2025 the Deno authors. MIT license. + +import { assert, assertRejects, assertThrows } from "@std/assert"; +import { symlink, symlinkSync } from "./unstable_symlink.ts"; +import { AlreadyExists } from "./unstable_errors.js"; +import { lstat, mkdir, mkdtemp, open, rm, stat } from "node:fs/promises"; +import { + closeSync, + lstatSync, + mkdirSync, + mkdtempSync, + openSync, + rmSync, + statSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const moduleDir = dirname(fileURLToPath(import.meta.url)); +const testdataDir = resolve(moduleDir, "testdata"); + +Deno.test("symlink() creates a link to a regular file", async () => { + const tempDirPath = await mkdtemp(resolve(tmpdir(), "symlink_")); + const testFile = join(tempDirPath, "testFile.txt"); + const symlinkPath = join(tempDirPath, "testFile.txt.link"); + + const tempFh = await open(testFile, "w"); + await symlink(testFile, symlinkPath); + + const symlinkLstat = await lstat(symlinkPath); + const fileStat = await stat(testFile); + + assert(symlinkLstat.isSymbolicLink); + assert(fileStat.isFile); + + await tempFh.close(); + await rm(tempDirPath, { recursive: true, force: true }); +}); + +Deno.test("symlink() creates a link to a directory", async () => { + const tempDirPath = await mkdtemp(resolve(tmpdir(), "symlink_")); + const testDir = join(tempDirPath, "testDir"); + const symlinkPath = join(tempDirPath, "testDir.link"); + + await mkdir(testDir); + await symlink(testDir, symlinkPath); + + const symlinkLstat = await lstat(symlinkPath); + const dirStat = await stat(testDir); + + assert(symlinkLstat.isSymbolicLink); + assert(dirStat.isDirectory); + + await rm(tempDirPath, { recursive: true, force: true }); +}); + +Deno.test( + "symlink() rejects with AlreadyExists for creating the same link path to the same file path", + async () => { + const existingFile = join(testdataDir, "0.ts"); + const existingSymlink = join(testdataDir, "0-link"); + + await assertRejects(async () => { + await symlink(existingFile, existingSymlink); + }, AlreadyExists); + }, +); + +Deno.test( + "symlinkSync() creates a link to a regular file", + () => { + const tempDirPath = mkdtempSync(resolve(tmpdir(), "symlinkSync_")); + const filePath = join(tempDirPath, "testFile.txt"); + const symlinkPath = join(tempDirPath, "testFile.txt.link"); + + const tempFd = openSync(filePath, "w"); + symlinkSync(filePath, symlinkPath); + + const symlinkLstat = lstatSync(symlinkPath); + const fileStat = statSync(filePath); + + assert(symlinkLstat.isSymbolicLink); + assert(fileStat.isFile); + + closeSync(tempFd); + rmSync(tempDirPath, { recursive: true, force: true }); + }, +); + +Deno.test("symlinkSync() creates a link to a directory", () => { + const tempDirPath = mkdtempSync(resolve(tmpdir(), "symlinkSync_")); + const testDir = join(tempDirPath, "testDir"); + const symlinkPath = join(tempDirPath, "testDir.link"); + + mkdirSync(testDir); + symlinkSync(testDir, symlinkPath); + + const symlinkLstat = lstatSync(symlinkPath); + const dirStat = statSync(testDir); + + assert(symlinkLstat.isSymbolicLink); + assert(dirStat.isDirectory); + + rmSync(tempDirPath, { recursive: true, force: true }); +}); + +Deno.test( + "symlinkSync() throws with AlreadyExists for creating the same link path to the same file path", + () => { + const existingFile = join(testdataDir, "0.ts"); + const existingSymlink = join(testdataDir, "0-link"); + + assertThrows(() => { + symlinkSync(existingFile, existingSymlink); + }, AlreadyExists); + }, +); diff --git a/fs/unstable_types.ts b/fs/unstable_types.ts index b86cba1c0123..60d61e8a8a3e 100644 --- a/fs/unstable_types.ts +++ b/fs/unstable_types.ts @@ -107,3 +107,13 @@ export interface DirEntry { * `FileInfo.isFile` and `FileInfo.isDirectory`. */ isSymlink: boolean; } + +/** + * Options that can be used with {@linkcode symlink} and + * {@linkcode symlinkSync}. + */ +export interface SymlinkOptions { + /** Specify the symbolic link type as file, directory or NTFS junction. This + * option only applies to Windows and is ignored on other operating systems. */ + type: "file" | "dir" | "junction"; +}