在本文中,我们将在对 Deno的介绍的基础上创建一个命令行工具,该工具可以在文件和文件夹中搜索文本。我们将使用 Deno 提供的一系列 API 方法来读取和写入文件系统。
在上一篇文章中,我们使用 Deno构建了一个命令行工具来向第三方 API 发出请求。在本文中,我们将把网络放在一边并构建一个工具,让您可以在文件系统中搜索当前目录中文件和文件夹中的文本——类似于grep
.
注意:我们不是在构建一个像 一样优化和高效的工具,grep
我们也不打算取代它!构建这样一个工具的目的是熟悉 Deno 的文件系统 API。
安装 Deno
我们将假设您已经在本地机器上启动并运行了 Deno。您可以查看Deno 网站或上一篇文章以获取更详细的安装说明,并获取有关如何将 Deno 支持添加到您选择的编辑器的信息。
在撰写本文时,Deno 的最新稳定版本是1.10.2,所以这就是我在本文中使用的版本。详细请访问:https://deno.land/
使用 Yargs 设置我们的新命令
和上一篇文章一样,我们将使用Yargs来构建用户可以用来执行我们的工具的界面。让我们index.ts
用以下内容创建并填充它:
import yargs from "https://deno.land/x/yargs@v17.0.1-deno/deno.ts"; interface Yargs<ArgvReturnType> { describe: (param: string, description: string) => Yargs<ArgvReturnType>; demandOption: (required: string[]) => Yargs<ArgvReturnType>; argv: ArgvReturnType; } interface UserArguments { text: string; } const userArguments: UserArguments = (yargs(Deno.args) as unknown as Yargs<UserArguments>) .describe("text", "the text to search for within the current directory") .demandOption(["text"]) .argv; console.log(userArguments);
这里有一些值得指出的地方:
我们通过指向它在 Deno 存储库上的路径来安装 Yargs。我明确地使用了一个精确的版本号来确保我们总是得到那个版本,这样我们就不会在脚本运行时使用任何碰巧是最新版本的东西。
在撰写本文时,Yargs 的 Deno + TypeScript 体验并不好,所以我创建了自己的界面并用它来提供一些类型安全。
UserArguments
包含我们要求用户提供的所有输入。现在,我们只会要求text
,但将来我们可以扩展它以提供要搜索的文件列表,而不是假设当前目录。
我们可以运行它deno run index.ts
并查看我们的 Yargs 输出:
$ deno run index.ts Check file:///home/jack/git/deno-file-search/index.ts Options: --help Show help [boolean] --version Show version number [boolean] --text the text to search for within the current directory [required] Missing required argument: text
现在是时候开始实施了!
列出文件
在我们开始在给定文件中搜索文本之前,我们需要生成要在其中搜索的目录和文件列表。Deno 提供了Deno.readdir
,它是“内置”库的一部分,这意味着您不必导入它。它在全局命名空间中可供您使用。
Deno.readdir
是异步的,并返回当前目录中的文件和文件夹列表。它将这些项目作为 返回AsyncIterator
,这意味着我们必须使用for await ... of
循环来获取结果:
for await (const fileOrFolder of Deno.readDir(Deno.cwd())) { console.log(fileOrFolder); }
此代码将从当前工作目录(它Deno.cwd()
给我们)读取并记录每个结果。但是,如果您现在尝试运行该脚本,则会出现错误:
$ deno run index.ts --text='foo' error: Uncaught PermissionDenied: Requires read access to <CWD>, run again with the --allow-read flag for await (const fileOrFolder of Deno.readDir(Deno.cwd())) { ^ at deno:core/core.js:86:46 at unwrapOpResult (deno:core/core.js:106:13) at Object.opSync (deno:core/core.js:120:12) at Object.cwd (deno:runtime/js/30_fs.js:57:17) at file:///home/jack/git/deno-file-search/index.ts:19:52
请记住,Deno 要求所有脚本都明确授予从文件系统读取的权限。在我们的例子中,该--allow-read
标志将使我们的代码能够运行:
~/$ deno run --allow-read index.ts --text='foo' { name: ".git", isFile: false, isDirectory: true, isSymlink: false } { name: ".vscode", isFile: false, isDirectory: true, isSymlink: false } { name: "index.ts", isFile: true, isDirectory: false, isSymlink: false }
在本例中,我在构建工具的目录中运行脚本,因此它会找到 TS 源代码、.git
存储库和.vscode
文件夹。让我们开始编写一些函数来递归导航这个结构,因为我们需要找到目录中的所有文件,而不仅仅是顶级文件。此外,我们可以添加一些常见的忽略。我认为没有人会希望脚本搜索整个.git
文件夹!
在下面的代码中,我们创建了一个getFilesList
函数,它接受一个目录并返回该目录中的所有文件。如果遇到目录,它会递归调用自己查找任何嵌套文件,并返回结果:
const IGNORED_DIRECTORIES = new Set([".git"]); async function getFilesList( directory: string, ): Promise<string[]> { const foundFiles: string[] = []; for await (const fileOrFolder of Deno.readDir(directory)) { if (fileOrFolder.isDirectory) { if (IGNORED_DIRECTORIES.has(fileOrFolder.name)) { // Skip this folder, it's in the ignore list. continue; } // If it's not ignored, recurse and search this folder for files. const nestedFiles = await getFilesList( `${directory}/${fileOrFolder.name}`, ); foundFiles.push(...nestedFiles); } else { // We found a file, so store it. foundFiles.push(`${directory}/${fileOrFolder.name}`); } } return foundFiles; }
然后我们可以像这样使用它:
const files = await getFilesList(Deno.cwd()); console.log(files);
我们还得到了一些看起来不错的输出:
$ deno run --allow-read index.ts --text='foo' [ "/home/jack/git/deno-file-search/.vscode/settings.json", "/home/jack/git/deno-file-search/index.ts" ]
使用path
模块
我们现在可以将文件路径与模板字符串组合起来,如下所示:
`${directory}/${fileOrFolder.name}`,
但是使用 Deno 的path
模块来做到这一点会更好。该模块是 Deno 作为其标准库的一部分提供的模块之一(很像 Node 提供的path
模块),如果您使用过 Node 的path
模块,代码看起来非常相似。在撰写本文时,std
Deno 提供的库的最新版本是0.97.0
,我们path
从mod.ts
文件中导入模块:
import * as path from "https://deno.land/std@0.97.0/path/mod.ts";
mod.ts
始终是导入 Deno 标准模块时的入口点。该模块的文档位于 Deno 站点和列表中path.join
,它将采用多条路径并将它们合并为一条路径。让我们导入并使用该函数,而不是手动组合它们:
// import added to the top of our script import yargs from "https://deno.land/x/yargs@v17.0.1-deno/deno.ts"; import * as path from "https://deno.land/std@0.97.0/path/mod.ts"; // update our usages of the function: async function getFilesList( directory: string, ): Promise<string[]> { const foundFiles: string[] = []; for await (const fileOrFolder of Deno.readDir(directory)) { if (fileOrFolder.isDirectory) { if (IGNORED_DIRECTORIES.has(fileOrFolder.name)) { // Skip this folder, it's in the ignore list. continue; } // If it's not ignored, recurse and search this folder for files. const nestedFiles = await getFilesList( path.join(directory, fileOrFolder.name), ); foundFiles.push(...nestedFiles); } else { // We found a file, so store it. foundFiles.push(path.join(directory, fileOrFolder.name)); } } return foundFiles; }
使用标准库时,记住固定到特定版本至关重要。如果不这样做,您的代码将始终加载最新版本,即使其中包含会破坏您的代码的更改。标准库上的 Deno 文档对此进行了进一步的讨论,我建议阅读该页面。
读取文件内容
与 Node 不同,它允许您通过fs
模块和readFile
方法读取文件内容,Deno 提供readTextFile
开箱即用的作为其核心的一部分,这意味着在这种情况下我们不需要导入任何额外的模块。readTextFile
确实假设文件被编码为 UTF-8——对于文本文件,这通常是你想要的。如果您正在使用不同的文件编码,则可以使用更通用的readFile
,它不对编码做任何假设,并允许您传入特定的解码器。
一旦我们获得了文件列表,我们就可以遍历它们并以文本形式读取它们的内容:
const files = await getFilesList(Deno.cwd());files.forEach(async (file) => { const contents = await Deno.readTextFile(file); console.log(contents); });
因为我们想在找到匹配时知道行号,所以我们可以将内容拆分为一个新行字符 ( \n
) 并依次搜索每一行以查看是否有匹配。这样,如果有,我们将知道行号的索引,以便我们可以将其报告给用户:
files.forEach(async (file) => { const contents = await Deno.readTextFile(file); const lines = contents.split("\n"); lines.forEach((line, index) => { if (line.includes(userArguments.text)) { console.log("MATCH", line); } }); });
为了存储我们的匹配项,我们可以创建一个表示 a 的接口Match
,并在找到匹配项时将匹配项推送到数组中:
interface Match { file: string; line: number;}const matches: Match[] = [];files.forEach(async (file) => { const contents = await Deno.readTextFile(file); const lines = contents.split("\n"); lines.forEach((line, index) => { if (line.includes(userArguments.text)) { matches.push({ file, line: index + 1, }); } }); });
然后我们可以注销匹配:
matches.forEach((match) => { console.log(match.file, "line:", match.line); });
但是,如果您现在运行该脚本,并为其提供一些肯定会匹配的文本,您仍然不会看到任何匹配记录到控制台。这是人们在通话中async
和通话中常犯的错误;该不会等待回调要考虑自身完成之前完成。拿这个代码:await
forEach
forEach
files.forEach(file => { new Promise(resolve => { ... }) })
JavaScript 引擎将执行forEach
在每个文件上运行的 --- 生成一个新的承诺--- 然后继续执行其余的代码。它不会自动等待这些承诺解决,当我们使用await
.
好消息是这将在for ... of
循环中按预期工作,而不是:
files.forEach(file => {...})
我们可以交换到:
for (const file of files) { ... }
该for ... of
循环将执行的代码串的每个文件,并在看到使用的await
关键字将暂停执行,直到这个承诺已经解决。这意味着在执行循环之后,我们知道所有的 promise 都已解决,现在我们确实将匹配记录到屏幕上:
$ deno run --allow-read index.ts --text='readTextFile' Check file:///home/jack/git/deno-file-search/index.ts /home/jack/git/deno-file-search/index.ts line: 54
让我们对输出进行一些改进,使其更易于阅读。与其将匹配项存储为数组,不如让它 a Map
,其中键是文件名,值是Set
所有匹配项的 a 。这样,我们可以通过列出按文件分组的匹配项来阐明我们的输出,并拥有一个让我们更轻松地探索数据的数据结构。
首先,我们可以创建数据结构:
const matches = new Map<string, Set<Match>>();
然后我们可以通过将它们添加到Set
给定文件的a 来存储匹配项。这比以前多一些工作。我们现在不能只是将项目推送到数组上。我们首先必须找到任何现有的匹配项(或创建一个新的Set
),然后存储它们:
for (const file of files) { const contents = await Deno.readTextFile(file); const lines = contents.split("\n"); lines.forEach((line, index) => { if (line.includes(userArguments.text)) { const matchesForFile = matches.get(file) || new Set<Match>(); matchesForFile.add({ file, line: index + 1, }); matches.set(file, matchesForFile); } }); }
然后我们可以通过迭代Map
. 当您使用for ... of
on a 时Map
,每次迭代都会为您提供一个包含两个项目的数组,其中第一个是地图中的键,第二个是值:
for (const match of matches) { const fileName = match[0]; const fileMatches = match[1]; console.log(fileName); fileMatches.forEach((m) => { console.log("=>", m.line); }); }
我们可以做一些解构来让它更整洁:
for (const match of matches) { const [fileName, fileMatches] = match;
甚至:
for (const [fileName, fileMatches] of matches) {
现在,当我们运行脚本时,我们可以看到给定文件中的所有匹配项:
$ deno run --allow-read index.ts --text='Deno' /home/jack/git/deno-file-search/index.ts => 15 => 26 => 45 => 54
最后,为了让输出更清晰一些,让我们也存储匹配的实际行。首先,我将更新我的Match
界面:
interface Match { file: string; lineNumber: number; lineText: string; }
然后更新存储匹配的代码。这里关于 TypeScript 的一个真正好处是,您可以更新Match
界面,然后让编译器告诉您需要更新的代码。我会经常更新一个类型,然后等待 VS Code 突出显示任何问题。如果您不太记得代码需要更新的所有地方,这是一种非常有效的工作方式:
if (line.includes(userArguments.text)) { const matchesForFile = matches.get(file) || new Set<Match>(); matchesForFile.add({ file, lineNumber: index + 1, lineText: line, }); matches.set(file, matchesForFile); }
输出匹配的代码也需要更新:
for (const [fileName, fileMatches] of matches) { console.log(fileName); fileMatches.forEach((m) => { console.log("=>", m.lineNumber, m.lineText.trim()); }); }
我决定调用trim()
我们的,lineText
这样,如果匹配的行严重缩进,我们就不会在结果中显示它。我们将去除输出中的任何前导(和尾随)空格。
有了这个,我想说我们的第一个版本已经完成了!
$ deno run --allow-read index.ts --text='Deno' Check file:///home/jack/git/deno-file-search/index.ts /home/jack/git/deno-file-search/index.ts => 15 (yargs(Deno.args) as unknown as Yargs<UserArguments>) => 26 for await (const fileOrFolder of Deno.readDir(directory)) { => 45 const files = await getFilesList(Deno.cwd()); => 55 const contents = await Deno.readTextFile(file);
按文件扩展名过滤
让我们扩展功能,以便用户可以通过extension
标志过滤我们匹配的文件扩展名,用户可以将扩展名传递给该标志(例如--extension js
仅匹配.js
文件)。首先让我们更新 Yargs 代码和类型以告诉编译器我们正在接受(可选)扩展标志:
interface UserArguments { text: string; extension?: string;}const userArguments: UserArguments = (yargs(Deno.args) as unknown as Yargs<UserArguments>) .describe("text", "the text to search for within the current directory") .describe("extension", "a file extension to match against") .demandOption(["text"]) .argv;
然后我们可以更新,getFilesList
以便它接受一个可选的第二个参数,它可以是我们可以传递给函数的配置属性的对象。我经常喜欢函数接受配置项的对象,因为向该对象添加更多项比更新函数以需要传入更多参数要容易得多:
interface FilterOptions { extension?: string; } async function getFilesList( directory: string, options: FilterOptions = {}, ): Promise<string[]> {}
现在在函数体中,一旦我们找到了一个文件,我们现在检查:
用户没有提供
extension
过滤依据。用户确实提供了一个
extension
过滤依据,并且文件的扩展名与他们提供的匹配。我们可以使用path.extname
,它返回给定路径的文件扩展名(对于foo.ts
,它将返回.ts
,因此我们采用用户传入的扩展名并在其前面加上 a.
)。
async function getFilesList( directory: string, options: FilterOptions = {}, ): Promise<string[]> { const foundFiles: string[] = []; for await (const fileOrFolder of Deno.readDir(directory)) { if (fileOrFolder.isDirectory) { if (IGNORED_DIRECTORIES.has(fileOrFolder.name)) { // Skip this folder, it's in the ignore list. continue; } // If it's not ignored, recurse and search this folder for files. const nestedFiles = await getFilesList( path.join(directory, fileOrFolder.name), options, ); foundFiles.push(...nestedFiles); } else { // We know it's a file, and not a folder. // True if we weren't given an extension to filter, or if we were and the file's extension matches the provided filter. const shouldStoreFile = !options.extension || path.extname(fileOrFolder.name) === `.${options.extension}`; if (shouldStoreFile) { foundFiles.push(path.join(directory, fileOrFolder.name)); } } } return foundFiles; }
最后,我们需要更新对getFilesList
函数的调用,将用户输入的任何参数传递给它:
const files = await getFilesList(Deno.cwd(), userArguments);
查找和替换
最后,让我们扩展我们的工具以允许基本替换。如果用户通过--replace=foo
,我们将获取从他们的搜索中找到的任何匹配项,并用提供的词替换它们 - 在本例中为foo
,然后将该文件写入磁盘。我们可以使用它Deno.writeTextFile
来做到这一点。(就像 with 一样readTextFile
,writeFile
如果您需要更多地控制编码,也可以使用。)
再一次,我们将首先更新我们的 Yargs 代码以允许提供参数:
interface UserArguments { text: string; extension?: string; replace?: string;}const userArguments: UserArguments = (yargs(Deno.args) as unknown as Yargs<UserArguments>) .describe("text", "the text to search for within the current directory") .describe("extension", "a file extension to match against") .describe("replace", "the text to replace any matches with") .demandOption(["text"]) .argv;
我们现在可以做的是更新我们的代码,循环遍历每个单独的文件以搜索任何匹配项。一旦我们检查了每一行的匹配项,我们就可以使用该replaceAll
方法(这是一个内置于 JavaScript的相对较新的方法)来获取文件的内容并将每个匹配项替换为用户提供的替换文本:
for (const file of files) { const contents = await Deno.readTextFile(file); const lines = contents.split("\n"); lines.forEach((line, index) => { if (line.includes(userArguments.text)) { const matchesForFile = matches.get(file) || new Set<Match>(); matchesForFile.add({ file, lineNumber: index + 1, lineText: line, }); matches.set(file, matchesForFile); } }); if (userArguments.replace) { const newContents = contents.replaceAll( userArguments.text, userArguments.replace, ); // TODO: write to disk }}
写入磁盘是调用的一种情况writeTextFile
,提供文件路径和新内容:
if (userArguments.replace) { const newContents = contents.replaceAll( userArguments.text, userArguments.replace, ); await Deno.writeTextFile(file, newContents); }
但是,当运行它时,我们现在会收到权限错误。Deno 将文件读取和文件写入拆分为单独的权限,因此您需要传递--allow-write
标志以避免错误:
$ deno run --allow-read index.ts --text='readTextFile' --extension=ts --replace='jackWasHere' Check file:///home/jack/git/deno-file-search/index.ts error: Uncaught (in promise) PermissionDenied: Requires write access to "/home/jack/git/deno-file-search/index.ts", run again with the --allow-write flag await Deno.writeTextFile(file, newContents);
您可以使用 传递--allow-write
或更具体一点--allow-write=.
,这意味着该工具仅具有在当前目录中写入文件的权限:
$ deno run --allow-read --allow-write=. index.ts --text='readTextFile' --extension=ts --replace='jackWasHere' /home/jack/git/deno-file-search/index.ts => 74 const contents = await Deno.readTextFile(file);
编译为可执行文件
现在我们有了我们的脚本并准备好分享它,让我们让 Deno 将我们的工具捆绑到一个可执行文件中。这样,我们的最终用户就不必运行 Deno,也不必每次都传入所有相关的权限标志;我们可以在捆绑时做到这一点。deno compile
让我们这样做:
$ deno compile --allow-read --allow-write=. index.ts Check file:///home/jack/git/deno-file-search/index.ts Bundle file:///home/jack/git/deno-file-search/index.ts Compile file:///home/jack/git/deno-file-search/index.ts Emit deno-file-search
然后我们可以调用可执行文件:
$ ./deno-file-search index.ts --text=readTextFile --extension=ts /home/jack/git/deno-file-search/index.ts => 74 const contents = await Deno.readTextFile(file);
我真的很喜欢这种方法。我们能够捆绑该工具,这样我们的用户就不必编译任何东西,并且通过预先提供权限,我们意味着用户不必编译。当然,这是一种权衡。一些用户可能希望提供权限,以便他们完全了解我们的脚本可以做什么和不能做什么,但我认为通常将权限提供给可执行文件是好的。
总结
我在 Deno 工作真的很开心。与 Node 相比,我喜欢 TypeScript、Deno Format 和其他工具刚刚出现的事实。我不必先设置我的 Node 项目,然后设置 Prettier,然后找出将 TypeScript 添加到其中的最佳方法。
Deno(不出所料)不像 Node.js 那样抛光或充实。Node 中存在的许多第三方包都没有很好的 Deno 等效项(尽管我希望这会及时更改),并且有时文档虽然详尽,但很难找到。但这些都是您期望任何相对较新的编程环境和语言都会出现的小问题。我强烈建议您探索 Deno 并试一试。它肯定会留下来。
网友评论文明上网理性发言 已有0人参与
发表评论: