Разработка сайтов в Лутугино, ЛНР. Работа с файловой системой в Deno

В этой статье мы продолжим наше знакомство с Deno, создав инструмент командной строки, который может искать текст в файлах и папках. Мы будем использовать ряд методов API, которые Deno предоставляет для чтения и записи в файловую систему.

В нашем последнем выпуске мы использовали Deno для создания инструмента командной строки для выполнения запросов к стороннему API. В этой статье мы собираемся оставить сеть в стороне и создать инструмент, который позволит вам искать в файловой системе текст в файлах и папках в вашем текущем каталоге — аналогично таким инструментам, как grep.

Примечание: мы не создаем инструмент, который будет таким же оптимизированным и эффективным, как grep, и не стремимся его заменить! Целью создания подобного инструмента является знакомство с API файловой системы Deno.

Установка Дено

Мы собираемся предположить, что вы установили и запустили Deno на своей машине локально. Вы можете проверить веб- сайт Deno или предыдущую статью для получения более подробных инструкций по установке, а также для получения информации о том, как добавить поддержку Deno в выбранный вами редактор.

На момент написания последней стабильной версией Deno была 1.10.2, именно ее я и использую в этой статье.

Для справки вы можете найти полный код из этой статьи на GitHub.

Настройка нашей новой команды с помощью Yargs

Как и в предыдущей статье, мы будем использовать Yargs для создания интерфейса, который наши пользователи смогут использовать для выполнения нашего инструмента. Давайте создадим index.tsи заполним его следующим:

import yargs from «https: //deno.land/x/yargs@v17.0.1-deno/deno.ts»;

interface Yargs {

describe: (param: string, description: string) => Yargs;

demandOption: (required: string[]) => Yargs;

argv: ArgvReturnType;

}

interface UserArguments {

text: string;

}

const userArguments: UserArguments =

(yargs (Deno.args) as unknown as Yargs)

.describe («text», «the text to search for within the current directory»)

.demandOption (["text"])

.argv;

console.log (userArguments) ;

Здесь происходит немало вещей, на которые стоит обратить внимание:

Мы устанавливаем Yargs, указав его путь в репозитории Deno. Я явно использую точный номер версии, чтобы убедиться, что мы всегда получаем эту версию, чтобы мы не использовали последнюю версию при запуске скрипта.

На момент написания статьи использование Deno + TypeScript для Yargs было не очень хорошим, поэтому я создал свой собственный интерфейс и использовал его для обеспечения некоторой безопасности типов.

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 , 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 {[]>

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}`,

Но было бы лучше сделать это с помощью pathмодуля Deno. Этот модуль является одним из модулей, которые Deno предоставляет как часть своей стандартной библиотеки (так же, как Node делает со своим pathмодулем), и если вы использовали pathмодуль Node, код будет выглядеть очень похоже. На момент написания последней версии 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 {[]>

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) ;

}

}) ;

}) ;

Чтобы сохранить наши совпадения, мы можем создать интерфейс, представляющий 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ключевого слова, он приостановит выполнение до тех пор, пока это обещание не будет разрешено. Это означает, что после выполнения цикла мы знаем, что все обещания разрешены, и теперь мы получаем совпадения, зарегистрированные на экране:

$ 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все совпадения. Таким образом, мы можем уточнить наши выходные данные, перечислив совпадения, сгруппированные по файлам, и иметь структуру данных, которая позволяет нам легче исследовать данные.

Во-первых, мы можем создать структуру данных:

const matches = new Map<string, Set> () ;

Затем мы можем сохранить совпадения, добавив их в Setфайл для данного файла. Это немного больше работы, чем раньше. Теперь мы не можем просто помещать элементы в массив. Сначала нам нужно найти любые существующие совпадения (или создать новый 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 () ;

matchesForFile.add ({

file,

line: index + 1,

}) ;

matches.set (file, matchesForFile) ;

}

}) ;

}

Затем мы можем регистрировать совпадения, перебирая файл Map. Когда вы используете for... ofна 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 () ;

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)

=> 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)

.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 {}[]>

Теперь в теле функции, как только мы нашли файл, мы теперь проверяем, что:

Пользователь не предоставил extensionфильтр для фильтрации.

Пользователь предоставил extensionфильтр для фильтрации, и расширение файла соответствует тому, что он предоставил. Мы можем использовать path.extname, который возвращает расширение файла для заданного пути (для foo.ts, он вернет.ts, поэтому мы берем расширение, которое передал пользователь, и добавляем. к нему a).

async function getFilesList (

directory: string,

options: FilterOptions = {},

): Promise {[]>

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для этого. (Как и в случае с readTextFile, вы также можете использовать writeFile, если вам нужен больший контроль над кодировкой.)

Еще раз, мы сначала обновим наш код Yargs, чтобы разрешить предоставление аргумента:

interface UserArguments {

text: string;

extension?: string;

replace?: string;

}

const userArguments: UserArguments =

(yargs (Deno.args) as unknown as Yargs)

.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 () ;

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-readallow-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-readallow-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. Многие сторонние пакеты, которые существуют в Node, не имеют хорошего эквивалента Deno (хотя я ожидаю, что это изменится со временем), и иногда документацию, хотя и полную, бывает довольно трудно найти. Но это все небольшие проблемы, которые вы ожидаете от любой относительно новой среды программирования и языка. Я настоятельно рекомендую изучить Deno и попробовать. Это определенно здесь, чтобы остаться.

Делитесь нашими материалами с друзьями!

 

 

Заказать разработку сайта