Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add copy/move commands #39

Merged
merged 8 commits into from
Dec 7, 2022
Merged
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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,13 @@ Currently implemented (though not every option is supported):
- Note that shells don't export their environment by default.
- [`echo`](https://man7.org/linux/man-pages/man1/echo.1.html) - Echo command.
- [`exit`](https://man7.org/linux/man-pages/man1/exit.1p.html) - Exit command.
- [`cp`](https://man7.org/linux/man-pages/man1/cp.1.html) - Copies files.
- [`mv`](https://man7.org/linux/man-pages/man1/mv.1.html) - Moves files.
- [`rm`](https://man7.org/linux/man-pages/man1/rm.1.html) - Remove files or directories command.
- [`mkdir`](https://man7.org/linux/man-pages/man1/mkdir.1.html) - Makes
directories.
- Ex. `mkdir -p DIRECTORY...` - Commonly used to make a directory and all its
parents with no error if it exists.
- [`sleep`](https://man7.org/linux/man-pages/man1/sleep.1.html) - Sleep command.
- [`test`](https://man7.org/linux/man-pages/man1/test.1.html) - Test command.
- More to come. Will try to get a similar list as https://deno.land/manual/tools/task_runner#built-in-commands
Expand Down
105 changes: 104 additions & 1 deletion mod.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import $, { build$, CommandBuilder, CommandContext, CommandHandler } from "./mod.ts";
import { assert, assertEquals, assertRejects, assertThrows } from "./src/deps.test.ts";
import { lstat, rustJoin } from "./src/common.ts";
import { assert, assertEquals, assertRejects, assertStringIncludes, assertThrows } from "./src/deps.test.ts";
import { Buffer, colors, path } from "./src/deps.ts";

Deno.test("should get stdout when piped", async () => {
Expand Down Expand Up @@ -779,3 +780,105 @@ Deno.test("test mkdir", async () => {
assert(await $.exists(dir + "/b/c"));
});
});

Deno.test("copy test", async () => {
await withTempDir(async (dir) => {
const file1 = path.join(dir, "file1.txt");
const file2 = path.join(dir, "file2.txt");
Deno.writeTextFileSync(file1, "test");
await $`cp ${file1} ${file2}`;

assert($.existsSync(file1));
assert($.existsSync(file2));

const destDir = path.join(dir, "dest");
Deno.mkdirSync(destDir);
await $`cp ${file1} ${file2} ${destDir}`;

assert($.existsSync(file1));
assert($.existsSync(file2));
assert($.existsSync(rustJoin(destDir, file1)));
assert($.existsSync(rustJoin(destDir, file2)));

const newFile = path.join(dir, "new.txt");
Deno.writeTextFileSync(newFile, "test");
await $`cp ${newFile} ${destDir}`;

assert(await isDir(destDir));
assert($.existsSync(newFile));
assert($.existsSync(rustJoin(destDir, newFile)));

assertEquals(
await getStdErr($`cp ${file1} ${file2} non-existent`),
"cp: target 'non-existent' is not a directory\n",
);

assertEquals(await getStdErr($`cp "" ""`), "cp: missing file operand\n");
assertStringIncludes(await getStdErr($`cp ${file1} ""`), "cp: missing destination file operand after");

// recursive test
Deno.mkdirSync(path.join(destDir, "sub_dir"));
Deno.writeTextFileSync(path.join(destDir, "sub_dir", "sub.txt"), "test");
const destDir2 = path.join(dir, "dest2");

assertEquals(await getStdErr($`cp ${destDir} ${destDir2}`), "cp: source was a directory; maybe specify -r\n");
assert(!$.existsSync(destDir2));

await $`cp -r ${destDir} ${destDir2}`;
assert($.existsSync(destDir2));
assert($.existsSync(path.join(destDir2, "file1.txt")));
assert($.existsSync(path.join(destDir2, "file2.txt")));
assert($.existsSync(path.join(destDir2, "sub_dir", "sub.txt")));

// copy again
await $`cp -r ${destDir} ${destDir2}`;

// try copying to a file
assertStringIncludes(await getStdErr($`cp -r ${destDir} ${destDir2}/file1.txt`), "destination was a file");
});
});

Deno.test("move test", async () => {
await withTempDir(async (dir) => {
const file1 = path.join(dir, "file1.txt");
const file2 = path.join(dir, "file2.txt");
Deno.writeTextFileSync(file1, "test");

await $`mv ${file1} ${file2}`;
assert(!$.existsSync(file1));
assert($.existsSync(file2));

const destDir = path.join(dir, "dest");
Deno.writeTextFileSync(file1, "test"); // recreate
Deno.mkdirSync(destDir);
await $`mv ${file1} ${file2} ${destDir}`;
assert(!$.existsSync(file1));
assert(!$.existsSync(file2));
assert($.existsSync(rustJoin(destDir, file2)));
assert($.existsSync(rustJoin(destDir, file2)));

const newFile = path.join(dir, "new.txt");
Deno.writeTextFileSync(newFile, "test");
await $`mv ${newFile} ${destDir}`;
assert(await isDir(destDir));
assert(!$.existsSync(newFile));
assert($.existsSync(path.join(destDir, "new.txt")));

assertEquals(
await getStdErr($`mv ${file1} ${file2} non-existent`),
"mv: target 'non-existent' is not a directory\n",
);

assertEquals(await getStdErr($`mv "" ""`), "mv: missing operand\n");
assertStringIncludes(await getStdErr($`mv ${file1} ""`), "mv: missing destination file operand after");
});
});

async function getStdErr(cmd: CommandBuilder) {
return await cmd.noThrow().stderr("piped").then((r) => r.stderr);
}

async function isDir(path: string) {
const info = await lstat(path);
return info?.isDirectory ? true : false;
}
3 changes: 3 additions & 0 deletions src/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
ShellPipeWriterKind,
} from "./pipes.ts";
import { parseArgs, spawn } from "./shell.ts";
import { cpCommand, mvCommand } from "./commands/cp_mv.ts";

type BufferStdio = "inherit" | "null" | Buffer;

Expand Down Expand Up @@ -47,6 +48,8 @@ const builtInCommands = {
test: testCommand,
rm: rmCommand,
mkdir: mkdirCommand,
cp: cpCommand,
mv: mvCommand,
};

/**
Expand Down
4 changes: 2 additions & 2 deletions src/commands/args.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { assertEquals } from "https://deno.land/std@0.130.0/testing/asserts.ts";
import { parse_arg_kinds } from "./args.ts";
import { parseArgKinds } from "./args.ts";

Deno.test("parses", () => {
const data = [
Expand All @@ -14,7 +14,7 @@ Deno.test("parses", () => {
"--test",
"-t",
];
const args = parse_arg_kinds(data);
const args = parseArgKinds(data);

assertEquals(args, [
{ arg: "f", kind: "ShortFlag" },
Expand Down
2 changes: 1 addition & 1 deletion src/commands/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ export interface ArgKind {
arg: string;
}

export function parse_arg_kinds(flags: string[]): ArgKind[] {
export function parseArgKinds(flags: string[]): ArgKind[] {
const result: ArgKind[] = [];
let had_dash_dash = false;
for (const arg of flags) {
Expand Down
177 changes: 177 additions & 0 deletions src/commands/cp_mv.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { CommandContext } from "../command_handler.ts";
import { ExecuteResult, resultFromCode } from "../result.ts";
import { bailUnsupported, parseArgKinds } from "./args.ts";
import { lstat, resolvePath, rustJoin } from "../common.ts";

export async function cpCommand(
context: CommandContext,
): Promise<ExecuteResult> {
try {
await executeCp(context.cwd, context.args);
return resultFromCode(0);
} catch (err) {
context.stderr.writeLine(`cp: ${err?.message ?? err}`);
return resultFromCode(1);
}
}

interface PathWithSpecified {
path: string;
specified: string;
}

interface CopyFlags {
recursive: boolean;
operations: { from: PathWithSpecified; to: PathWithSpecified }[];
}

async function executeCp(cwd: string, args: string[]) {
const flags = await parseCpArgs(cwd, args);
for (const { from, to } of flags.operations) {
await doCopyOperation(flags, from, to);
}
}

export async function parseCpArgs(cwd: string, args: string[]): Promise<CopyFlags> {
const paths = [];
let recursive = false;
for (const arg of parseArgKinds(args)) {
if (arg.kind === "Arg") paths.push(arg.arg);
else if (
(arg.arg === "recursive" && arg.kind === "LongFlag") ||
(arg.arg === "r" && arg.kind == "ShortFlag") ||
(arg.arg === "R" && arg.kind === "ShortFlag")
) {
recursive = true;
} else bailUnsupported(arg);
}
if (paths.length === 0) throw Error("missing file operand");
else if (paths.length === 1) throw Error(`missing destination file operand after '${paths[0]}'`);

return { recursive, operations: await getCopyAndMoveOperations(cwd, paths) };
}

async function doCopyOperation(
flags: CopyFlags,
from: PathWithSpecified,
to: PathWithSpecified,
) {
// These are racy with the file system, but that's ok.
// They only exists to give better error messages.
const fromInfo = await lstat(from.path);
if (fromInfo?.isDirectory) {
if (flags.recursive) {
const toInfo = await lstat(to.path);
if (toInfo?.isFile) {
throw Error("destination was a file");
} else if (toInfo?.isSymlink) {
throw Error("no support for copying to symlinks");
} else if (fromInfo.isSymlink) {
throw Error("no support for copying from symlinks");
} else {
await copyDirRecursively(from.path, to.path);
}
} else {
throw Error("source was a directory; maybe specify -r");
}
} else {
await Deno.copyFile(from.path, to.path);
}
}

async function copyDirRecursively(from: string, to: string) {
await Deno.mkdir(to, { recursive: true });
const readDir = Deno.readDir(from);
for await (const entry of readDir) {
const newFrom = rustJoin(from, entry.name);
const newTo = rustJoin(to, entry.name);
if (entry.isDirectory) {
await copyDirRecursively(newFrom, newTo);
} else if (entry.isFile) {
await Deno.copyFile(newFrom, newTo);
}
}
}

export async function mvCommand(
context: CommandContext,
): Promise<ExecuteResult> {
try {
await executeMove(context.cwd, context.args);
return resultFromCode(0);
} catch (err) {
context.stderr.writeLine(`mv: ${err?.message ?? err}`);
return resultFromCode(1);
}
}

interface MoveFlags {
operations: { from: PathWithSpecified; to: PathWithSpecified }[];
}

async function executeMove(cwd: string, args: string[]) {
const flags = await parseMvArgs(cwd, args);
for (const { from, to } of flags.operations) {
await Deno.rename(from.path, to.path);
}
}

export async function parseMvArgs(cwd: string, args: string[]): Promise<MoveFlags> {
const paths = [];

for (const arg of parseArgKinds(args)) {
if (arg.kind === "Arg") paths.push(arg.arg);
else bailUnsupported(arg);
}

if (paths.length === 0) throw Error("missing operand");
else if (paths.length === 1) throw Error(`missing destination file operand after '${paths[0]}'`);

return { operations: await getCopyAndMoveOperations(cwd, paths) };
}

async function getCopyAndMoveOperations(
cwd: string,
paths: string[],
) {
// copy and move share the same logic
const specified_destination = paths.splice(paths.length - 1, 1)[0];
const destination = resolvePath(cwd, specified_destination);
const fromArgs = paths;
const operations = [];
if (fromArgs.length > 1) {
if (!await lstat(destination).then((p) => p?.isDirectory)) {
throw Error(`target '${specified_destination}' is not a directory`);
}
for (const from of fromArgs) {
const fromPath = resolvePath(cwd, from);
const toPath = rustJoin(destination, fromPath);
operations.push(
{
from: {
specified: from,
path: fromPath,
},
to: {
specified: specified_destination,
path: toPath,
},
},
);
}
} else {
const fromPath = resolvePath(cwd, fromArgs[0]);
const toPath = await lstat(destination).then((p) => p?.isDirectory) ? rustJoin(destination, fromPath) : destination;
operations.push({
from: {
specified: fromArgs[0],
path: fromPath,
},
to: {
specified: specified_destination,
path: toPath,
},
});
}
return operations;
}
11 changes: 4 additions & 7 deletions src/commands/mkdir.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { CommandContext } from "../command_handler.ts";
import { resolvePath } from "../common.ts";
import { ExecuteResult, resultFromCode } from "../result.ts";
import { lstat } from "../common.ts";
import { bailUnsupported, parse_arg_kinds } from "./args.ts";
import { bailUnsupported, parseArgKinds } from "./args.ts";

export async function mkdirCommand(
context: CommandContext,
Expand All @@ -25,11 +25,8 @@ async function executeMkdir(cwd: string, args: string[]) {
const flags = parseArgs(args);
for (const specifiedPath of flags.paths) {
const path = resolvePath(cwd, specifiedPath);
if (
await lstat(path, (info) => info.isFile) ||
(!flags.parents &&
await lstat(path, (info) => info.isDirectory))
) {
const info = await lstat(path);
if (info?.isFile || (!flags.parents && info?.isDirectory)) {
throw Error(`cannot create directory '${specifiedPath}': File exists`);
}
if (flags.parents) {
Expand All @@ -46,7 +43,7 @@ export function parseArgs(args: string[]) {
paths: [],
};

for (const arg of parse_arg_kinds(args)) {
for (const arg of parseArgKinds(args)) {
if (
(arg.arg === "parents" && arg.kind === "LongFlag") ||
(arg.arg === "p" && arg.kind == "ShortFlag")
Expand Down
4 changes: 2 additions & 2 deletions src/commands/rm.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { CommandContext } from "../command_handler.ts";
import { resolvePath } from "../common.ts";
import { ExecuteResult, resultFromCode } from "../result.ts";
import { ArgKind, parse_arg_kinds } from "./args.ts";
import { ArgKind, parseArgKinds } from "./args.ts";

export async function rmCommand(
context: CommandContext,
Expand Down Expand Up @@ -45,7 +45,7 @@ export function parseArgs(args: string[]) {
paths: [],
};

for (const arg of parse_arg_kinds(args)) {
for (const arg of parseArgKinds(args)) {
if (
(arg.arg === "recursive" && arg.kind === "LongFlag") ||
(arg.arg === "r" && arg.kind == "ShortFlag") ||
Expand Down
Loading