Сборочная утилита redo на D своими руками

В этой достаточно сложной статье мы покажем как своими руками написать утилиту, которая позволит собирать сложные проекты со множеством файлов и которая не зависит от выбранного вами языка программирования. Утилита, которую мы опишем далее, называется redo и она при скромном на первый взгляд функционале, позволяет отслеживать зависимости в сборочных файлах и запускать пересборку только в случае изменения любого из файлов «проекта» или же в случае изменения самого скрипта построения проекта. Также сами сборочные скрипты могут быть написаны на любом скриптовом языке или же языке программирования общего назначения, поскольку являются обычными файлами с командами, которые просто передаются в командную оболочку.

Если стало интересно, что именно мы будем реализовывать, то добро пожаловать под кат.

Систему для сборки целей redo придумал американско-немецкий математик, криптолог и ученый-компьютерщик Даниэль Джулиус Бернштейн (Daniel J. Bernstein). Данную систему Бернштейн задумал как максимально простую, минимальную и лишенную недостатков классических сборочных систем. Исходя из таких принципов построения, у redo есть следующие преимущества:

  • нет никакого нового языка для команд сборки, а также нового формата файла для описания процесса сборки — redo разрабатывался так, чтобы было достаточно простого использования shell;
  • нет зависимости от какого-либо языка разметки или языка программирования, redo может использовать любой язык программирования в описании процесса сборки, а также сам redo может быть реализован на любом языке программирования;
  • крайняя простота redo (будет описано далее), благодаря чему реализацию можно сделать буквально за пару дней и легко интегрировать в свои проекты

Также помимо этого, у redo есть хранимое состояние, благодаря которому происходит отслеживание изменений необходимых для сборки целей правил. Более того, нет никаких ограничений на то, как описывать само состояние — в разных реализациях используются разные подходы, неизменным остается лишь принцип работы и ее базовое описание.

В redo, как в иных системах сборки, есть сборочный файл. Данный сборочный файл представляет собой обычный shell-скрипт и имеет расширение *.do. В этом файле описываются команды для сборки целей на основании текущих файлов и скрипту передается три аргумента, которые имеют следующие имена и смыслы:

  • $1 — имя цели;
  • $2 — базовое имя цели (имя цели без расширения);
  • $3 — имя файла

При этом результатом работы redo является или собранный файл ($3 это и есть имя результирующего файла), или содержимое стандартного потока вывода, а под целью мы понимаем некоторую позицию (описание файла, папки или что-то иное) в файле сборки, которая требует применения команд для получения из нее нужного результата. Помимо регулярных файлов сборки, которые обычно совпадают по наименованию с названием файла результата, есть еще default сборочные файлы, которые выполняются для всех файлов в папке или для множества целей: сборочные файлы default.do выполняются в случае отсутствия указания конкретных целей.

Важнейшим понятием является понятие зависимостей, т.е тех файлов/папок от сборки которых зависит сборка текущей цели. Как правило, большая часть проектов имеет в сборочных файлах одну или несколько зависимостей, которые в сборочном файле указываются с помощью команды redo-ifchange. Данная команда является частью самой реализации redo и после нее указывается лишь список тех фалов/папок, от которых зависит текущая сборочная команда, и если в них имеется какое-либо изменение — то redo-ifchange запустит пересборку цели. При этом изменения в *.do файле приведут к автоматической пересборке цели вне зависимости от действия redo-ifchange.

А что если есть зависимость от файла, который еще не создан, но который может быть создан в процессе работы сборочного скрипта или в ходе самой сборки? Для таких случаев в реализациях redo есть особая процедура, которая именуется redo-ifcreate, синтаксис и принцип работы схож с процедурой redo-ifchange, но работает для еще не созданных файлов или тех единиц сборки, которые еще только предстоит создать.

Примерно таким образом описывается система redo (детальную информацию об устройстве и принципах, правда, без эталонной реализации можно подчерпнуть тут или тут), а значит для создания собственной реализации этой интересной утилиты нужно реализовать три вещи: хранение информации о зависимостях (т.е состояние), процедуру redo-ifchange и процедуру redo-ifcreate. К сожалению, референсной реализации от автора концепции нет и поэтому есть некоторые отличия в уже существующих вариантах redo, но любая из этих реализаций обязательно содержит указанные три элемента.

Из-за простоты и крайнего минимализма в описании утилиты свой вариант redo можно выпустить очень быстро, а сама утилита есть и на C, C++, Haskell, Go, Python, bash и даже на такой экзотике как Inferno Shell, но … нет на D !

И именно этот момент мы сейчас исправим…

В нашей реализации мы будем использовать наименьшее число элементов из стандартной библиотеки (наименьшее, но все равно импортов получается слишком много), а также, для уменьшения размеров исполняемого файла будем использовать портированный с С вариант получения SHA256 хэша для файла, который мы описывали в одном из рецептов. Кроме того, для более красивого вывода мы добавим ряд упрощенных функций, которые позволяют дать 4 разных вида окраски — ошибка (error), отладочная информация (log), информация (info) и предупреждение (warning):

import std.algorithm;
import std.file;
import std.format;
import std.path;
import std.process;
import std.stdio;
import std.string : replace, strip;

alias error = function(string message) {
	format("\u001b[31m\u001b[49m\u001b[1mError:\u001b[0m\u001b[97m\u001b[49m\u001b[1m %s \u001b[0m", message).writeln;
};

alias info = function(string message) {
	format("\u001b[32m\u001b[49m\u001b[1mInfo:\u001b[0m\u001b[97m\u001b[49m\u001b[1m %s \u001b[0m", message).writeln;
};

alias log = function(string message) {
	format("\u001b[34m\u001b[49m\u001b[1mLog:\u001b[0m\u001b[97m\u001b[49m\u001b[1m %s \u001b[0m", message).writeln;
};

alias warning = function(string message) {
	format("\u001b[33m\u001b[49m\u001b[1mWarning:\u001b[0m\u001b[97m\u001b[49m\u001b[1m %s \u001b[0m", message).writeln;
};

Работает данная раскраска за счет использования escape-последовательностей и работает исключительно в Posix-системах, не является обязательной для работы redo и может быть выведена из состава программы.

В уже упоминавшийся блок с алгоритмом SHA256 мы добавляем в блок extern(C) ряд новых «заимствований», которые нужны для работы с файлами функциями стандартной библиотеки C, а также необходимы для работы самого SHA256. Часть которую мы добавим выглядит примерно так:

    extern (C) int open(scope const(char*) pathname, int flags) pure nothrow @nogc;
    extern (C) ssize_t pread(int fd, void* buf, size_t count, off_t offset);
    extern (C) int close(int fd);

    extern (C) void* memset(scope return void* s, int c, ulong n) pure nothrow @nogc;
    extern (C) void* memcpy(scope return void* s1, scope const(void*) s2, ulong n) pure nothrow @nogc;

Также добавляем обертку для получения SHA256 хэша для файла:

  string sha256sum(string filepath) @trusted {
        char* filename = cast(char*) filepath.dup;
        int fd = open(filename, O_RDONLY);
        
        scope (exit)
        {
            fd.close;
        }

        char* hash = hashfile(fd);

        return hash.to!string;
    };

Далее, определим ряд вспомогательных функций для работы с файлами, которые будут повсеместно использоваться далее и которые сильно облегчают восприятие кода:

alias onlyFiles = function(string directoryPath) {
	return directoryPath.dirEntries(SpanMode.shallow).filter!`a.isFile`;
};

alias getExtension = function(string filepath) {
		return filepath.extension.replace(".", "");
};

auto lineFromFile(string filePath)
{
	return File(filePath, `r`).readln.strip;
}

auto lineToFile(string line, string filePath)
{
	File(filePath, `w`).writeln(line);
}

Функция onlyFiles служит универсальным генератором списка файлов в некоторой папке и использует всем знакомый синтаксис UFCS, комбинирующий методы в цепочку выполняемых функций. Функция getExtension написана на очевидный и понятный манер и служит для извлечения расширения без его служебных компонентов (точка в расширении вырезается с помощью replace из std.string). Две функции lineFromFile и lineToFile далее будут использоваться особенно интенсивно, поскольку они позволяют прочитать/записать строку из некоторого файла и необходимы для работы с состоянием, которое будет хранить redo.

В этом варианте redo состояние (т.е данные по изменениям для файлов) будут храниться в скрытой папке под названием .redo, и эту папку мы будем называть мета-директорией или мета-папкой:

enum string metaDirectory = `.redo`;

Определяем эту папку в начале функции main и будем в дальнейшем использовать (это, кстати, позволяет если не нравится, изменить папку под состояние). В этой папке будут храниться две подпапки под названием: change и create, каждая из которых будет содержать текстовые файлы, в каждом из которых, соответственно, будет храниться только одна строка, которая представляет собой имя файла зависимости. В самом текстовом файле прописывается только имя файла зависимости, а папка в которой создан файл соответствует процедуре, которая была использована в сборочном скрипте: change — использовалась redo-ifchange, create — использовалась redo-ifcreate. Также под каждую цель заводится отдельная папка (с именем цели) в которой и содержатся указанные файлы и каталоги.

Далее поблочно опишем структурные элементы реализации redo.

Функции cleanChangeSum и cleanCreateSum служат для начальной очистки папок перед запуском утилиты и выглядят примерно так:

auto cleanChangeSum(string dependency, string target)
	{
		auto changeDirectory = format(metaDirectory ~ "/%s/change/", target);
		foreach (a; changeDirectory.onlyFiles)
		{
			if (lineFromFile(a) == dependency)
			{
				remove(a);
			}
		}
	}
	
	auto cleanCreateSum(string dependency, string target)
	{
		auto createDirectory = format(metaDirectory ~ "/%s/create/", target);
		foreach (b; createDirectory.onlyFiles)
		{
			if (lineFromFile(b) == dependency)
			{
				remove(b);
			}
		}
	}

Функция cleanAll очистит всю мета-папку:

auto cleanAll(string target)
	{
		auto targetDirectory = metaDirectory ~ "/" ~ target;
		if (targetDirectory.exists)
		{
			foreach (w; targetDirectory.onlyFiles)
			{
				remove(w);
			}
		}
	}

Функция getChangeSum также, как и первые две функции, принимает два аргумента — описание зависимости и описание цели, но возвращает в качестве значения хэш-сумму для файла зависимости. Выглядит это следующим образом:

	auto getChangeSum(string dependency, string target)
	{
		string changeSum;
		auto changeDirectory = format(metaDirectory ~ "/%s/change/", target);
		
		foreach (c; changeDirectory.onlyFiles)
		{
			if (lineFromFile(c) == dependency)
			{
				changeSum = baseName(c);
				break;
			}
		}
		
		return changeSum;
	}

Функция upToDate проверяет устарела ли имеющаяся зависимость для текущей цели через сопоставление текущего хэша (вычисляет налету) с тем. что есть в днный момент (имя файла для зависимости):

	auto upToDate(string dependency, string target)
	{
		string oldSum;
		auto changeDirectory = format(metaDirectory ~ "/%s/change/", target);
		
		foreach (d; changeDirectory.onlyFiles)
		{	
			if (lineFromFile(d) == dependency)
			{
				oldSum = baseName(d);
				break;
			}
		}
		
		return (sha256sum(dependency) == oldSum);
	}

А следующая функция doPath определяет путь для файла сборки (рецепта) — это либо *.do файл с именем точно таким же как и у цели, либо файл default, который применяется ко всем файлам:

	auto doPath(string target)
	{
		string doFilePath;
		
		if (target.getExtension != "do")
		{
			if ((target ~ ".do").exists)
			{
				doFilePath = target ~ ".do";
			}
			else
			{
				auto path = format(`%s/default.%s.do`, target.dirName, target.getExtension);
				if (path.exists)
				{
					doFilePath = path;
				}
			}
		}
		
		return doFilePath;
	}

Помимо анализа пути до сборочных файлов и прочих вышеописанных процедур требуется вычисление хэшей для зависимостей и генерации состояние для redo, что обеспечивается функциями genChangeSum и genCreateSum, который используются в одной из последующих за ней процедур:

	auto genChangeSum(string dependency, string target)
	{
		cleanChangeSum(dependency, target);
		auto path = format(metaDirectory ~ "/%s/change/%s", target, sha256sum(dependency));
		lineToFile(dependency, path);
	}
	
	auto genCreateSum(string dependency, string target)
	{
		cleanCreateSum(dependency, target);
		auto path = format(metaDirectory ~ "/%s/create/%s", target, sha256sum(dependency));
		lineToFile(dependency, path);
	}

Для работы с shell-скриптами нам потребуется функция getShebang, которая из сборочного скрипта извлекает строку команды вызова программы, которая требуется для исполнения некоего скрипта:

	auto getShebang(string filepath)
	{
		string shebang;

		foreach (line; File(filepath, `r`).byLine)
		{
			if (startsWith(cast(string) line, "#!"))
			{
				shebang = strip(cast(string) line);
				break;
			}
		}
		
		return shebang;
	}

Основную работу в утилите redo делает процедура doRedo, которая выглядит примерно так:

	auto doRedo(string target)
	{
		string tmp = target ~ `---redoing`;
		string doFilePath = doPath(target);
		
		auto createDirectory = format(metaDirectory ~ `/%s/create/`, target);
		auto changeDirectory = format(metaDirectory ~ `/%s/change/`, target);
		
		if (!createDirectory.exists)
		{
			mkdirRecurse(createDirectory);
		}
		
		if (!changeDirectory.exists)
		{
			mkdirRecurse(changeDirectory);
		}
		
		if (doFilePath == "")
		{
			if (!target.exists)
			{
				error(format(`No .do file found for target: %s`, target));
				return;
			}
		}
		else
		{
			bool trigger;
			
			bool isPrepared = (upToDate(doFilePath, target) || (target.exists));
			
			if (!isPrepared)
			{
				trigger = true;
			}
			
			if (!trigger)
			{
				foreach (e; createDirectory.onlyFiles)
				{
					auto dependency = lineFromFile(e);
					
					if (dependency.exists)
					{
						warning(format(`%s exists but should be created`, dependency));
						return;
					}
					else
					{
						trigger = true;
					}
				}
			}
			
			if (!trigger)
			{
				foreach (f; changeDirectory.onlyFiles)
				{
						auto dependency = lineFromFile(f);
						auto shell = executeShell(`REDO_TARGET="%s" redo-ifchange "%s"`.format(target, dependency));
						
						if (baseName(f) != getChangeSum(dependency, target))
						{
							trigger = true;
						}
				}
			}
		
			if (trigger)
			{
				info(format(`redo %s`, target));
				cleanAll(target);
				genChangeSum(doFilePath, target);
				
				string cmd = getShebang(doFilePath);
				string rcmd;
			
				if (cmd == "")
				{
					rcmd = format(
						`PATH=.:$PATH REDO_TARGET="%s" sh -e "%s" 0 "%s" "%s" > "%s"`, target, doFilePath, baseName(target), tmp, tmp
					);
				}
				else
				{
					rcmd = format(
						`PATH=.:$PATH REDO_TARGET="%s" sh -c "%s" "%s" 0 "%s" "%s" > "%s"`, target, cmd, doFilePath, baseName(target), tmp, tmp
					);
				}
				info(format(`[build command]: %s`, rcmd));
				auto rc = executeShell(rcmd);
				
				if (rc.status != 0)
				{
					error(format(`Redo script exited with a non-zero exit code: %d`, rc.status));
					error(rc.output);
					remove(tmp);
					info(format(`[removing temporary file]: %s`, tmp));
				}
				else
				{
					if (tmp.exists)
					{
						if (tmp.getSize == 0)
						{
							info(format(`[removing]: %s`, tmp));
							remove(tmp);
						}
						else
						{
							info(format(`[copying]: from %s to %s`, tmp, target));
							copy(tmp, target);
						}
					}
				}
			}
		}
	}
	

В самом начале процедуры мы определяем наименование временного файла для результата сборки, которое собирается из имени файла цели и постикса ---redoing . После чего формируются пути для папок create и change и проверяется созданы ли они ранее или нет, также проверяется существует ли файл сборки для цели — папки в случае отсутствия будут созданы, а вот отсутствие сборочного файла вызовет ошибку. Если файл существует, то проверяется свежесть зависимостей и наличие файла цели, и если хотя бы одно из этих условий неверно, то устанавливается триггер для запуска дальнейших действий. Если триггер не был установлен, то происходит следующее: проверяется наличие несуществующих файлов, которые указаны в качестве зависимостей, и в случае если такие файлы есть, то выдается ошибка. Если таковых файлов нет, но они должны быть созданы, то триггер будет установлен. Аналогичная проверка производится для существующих файлов зависимостей, и если файл зависимости изменился (достигается запуском redo-ifchange в отдельном shell), то устанавливается триггер и срабатывает запись изменения в мета-директорию.

После того, как прошел ряд необходимых проверок на свежесть и качество изменений, в случае если триггер был установлен, начинается выполнение основной работы, а именно сборки. Сначала выводится наименование цели для сборки, после чего очищаются данные в мета-папке для цели посредством вызова cleanAll. Затем, за очисткой идет генерация хэшей для файлов задействованных в сборке целей и определяется строка с описанием пути до интерпретатора.

Если путь до интерпретатора оказывается пустым, значит, для вызова сборочного скрипта используется обычный shell, которому просто передается на выполнение do-файл; если путь до интерпретатора непустой, значит был использован какой-то иной скриптовый язык и происходит передача do-файла интерпретатору этого языка. Данная возможность реализована путем формирования строки нужной команды в переменной rcmd, которая затем используется в функции executeShell.

Эта функция возвращает кортеж, который содержит два значения: status — код возврата в ходе исполнения команды в вызванном shell и output — перехваченный программой выход со стандатного вывода shell. Если код в status не равен нулю, это значит, что произошел сбой в выполнении команды сборки и в этом случае выдается сообщение об ошибке, а также удаляется временный файл. Если же выполнение команды прошло успешно, то проверяется существование временного файла, и если его размер равен нулю (файл по какой-то причине не сформировался в ходе работы redo), то он удаляется; в противном случае — происходит копирование временного файла в файл результа. Весь процесс сопровождается выводом информации о результатах с красивой подсветкой.

На этом реализация не окончена и требьуется определить функционал, который соответствует командам redo-ifchange и redo-ifcreate. В нашей реализации не создано отдельных функций для этих процедур, поскольку мы воспользуемся свообразным трюком: в этой программе будут совмещены обе этих процедуры, а какая именно будет использована в работе пользователем будет зависеть от того, как программа будет вызвана. Ключевым моментом здесь будут не опции командной строки, а наименование самой программы — именно оно будет отпределять какую из процедур требуется осуществить.

Делается это через получение аргументов командной сроки, отсечением необходимых данных от массива, а также чтением переменных окружения:

	string programName = arguments[0];
	string[] targets = arguments[1..$];

switch (programName)
	{
		case "redo-ifchange":
			if (environment.get("REDO_TARGET", "") == "")
			{
				error(`REDO_TARGET not set`);
				return;
			}
			foreach (target; targets)
			{
				doRedo(target);
				string redoTarget = environment.get("REDO_TARGET", "");
				
				if (!upToDate(target, redoTarget))
				{
					genChangeSum(target, redoTarget);
				}
			}
			break;
		case "redo-ifcreate":
			if (environment.get("REDO_TARGET", "") == "")
			{
				error(`REDO_TARGET not set`);
				return;
			}
			foreach (target; targets)
			{
				string redoTarget = environment.get("REDO_TARGET", "");
				if (target.exists)
				{
					warning(format(`%s exists but should be created`, target));
				}
				doRedo(target);
				if (target.exists)
				{
					genCreateSum(target, redoTarget);
				}
			}
			break;
		default:
			foreach (target; targets)
			{
				environment["REDO_TARGET"] = target;
				doRedo(target);
			}
			break;
	}

Что тут происходит ? Если, допустим, файл этой программы переименовать в redo-ifchange, и этот файл будет запущен в ходе работы точно такого же файла программы, но с именем redo, то произойдет считывание переменных окружения и если они не содержат упоминание цели, то произойдет ошибка; в противном случае, цели будут прочитаны из переменных окружения и если зависимости для цели устарели, то они будут обновлены. Аналогично работает и redo-ifcreate, но вместо обновления зависимостей происходит их создание.

Как же с утилитой работать ?

Очень просто. Компилируем исходный текст в исполняем файл и называем его redo, а потом создаем на него символические ссылки с именами redo-ifchange и redo-ifcreate:

ldc2 -release redo.d
ln -s redo redo-ifchange
ln -s redo redo-ifcreate

главное, чтобы сама redo и ссылки на нее находились по пути который есть в текущей переменной окружения PATH. А далее создаем некоторый проект в некоторой папке и в ней создаем do-файл с именем проекта, после чего запускаем redo. К примеру, сама утилита в нашей реализации собиралась вот таким do-файлом с именем redo.do:

redo-ifchange redo.d
ldc2 -release -Os --boundscheck=off redo.d -of $3
strip $3

После чего запускалась команда redo с именем проекта (в нашем случае — redo):

redo redo

И выглядело это вот так:

Полный код утилиты доступен здесь, а если вас заинтересовала сама система redo, то вот небольшой список материалов для ознакомления:

P.S: Код нашей версии основан на коде, взятом из реализации redo на bash, а первоначальная версия порта уже на D (от нашей команды), находится тут.

aquaratixc

Программист-самоучка и программист-любитель

Добавить комментарий