Pull to refresh

Inferno Shell

Reading time 12 min
Views 18K
FAQ: Что такое OS Inferno и зачем она нужна?

Оболочка ОС Инферно много лет вызывала у меня исключительно отрицательные эмоции. И я никогда не понимал, что в Inferno sh вызывает восторг у некоторых людей. Но, как говорится, лучше поздно чем никогда — сегодня я решил таки тщательно разобраться с шеллом, и в результате меня тоже проняло — это таки действительно уникальная вещь! Невероятно элегантная и простая.

Начну всё-таки с недостатков, для большей объективности. Главный — шелл очень медленный. Неизвестно почему, но все шеллы инферно (их вообще-то несколько, но сейчас речь про /dis/sh.dis) очень медленные. Это крайне странно, т.к. обычно скорость работы приложений написанных на Limbo (в JIT-режиме) находится между скоростью C и быстрых скриптовых языков вроде Perl. Поэтому полноценные приложения на sh писать не получится. Но, тем не менее, на каждый чих расчехлять Limbo тоже неудобно, так что всяческие стартовые скрипты и прочую мелочёвку всё-равно приходится писать на sh. Второй серьёзный недостаток — неудобство использования в текстовой консоли, отсутствие истории команд, автодополнения, удобного редактирования при вводе сильно раздражает (но мне только что рассказали про утилиту rlwrap, запуск emu через rlwrap -a, похоже, способен решить эту проблему). Третий — синтаксис этого шелла необычен использованием непарных кавычек, что при попытке подсветить синтаксис его скриптов используя подсветку для абсолютно любого другого шелла (в связи с отсутствием готовой подсветки для инферновского) приводит к полному кошмару. Эту проблему я собирался решить сегодня, реализовав подсветку синтаксиса для vim, для чего и сел разбираться с шеллом… а в результате вместо подсветки синтаксиса не удержался, и пишу эту статью. :)

Содержание




Из чего же, из чего же, из чего же сделаны наши скрипты?


Функциональность, поддерживаемая /dis/sh.dis «из коробки» впечатляет! Нет даже условных операторов и циклов! Нет функций. А что тогда есть, и как в этих условиях можно существовать? Сейчас увидите. Итак, что есть:
  • запуск приложений (включая конвейеры и перенаправление ввода/вывода)
  • строки, списки строк, блоки команд
  • переменные окружения
  • шаблоны имён файлов (*, ?, […])
  • и немного встроенных команд и команд для работы со строками
Наверное, увидев последний пункт вы подумали «Ага! Какие-то специальные команды. Наверняка всё остальное делают они, всё банально.»… но нет, вы не угадали. Из встроенных команд используются обычно три: exit, run и load; а часто используемые команды для работы со строками это quote и unquote. Что касается run, то она просто выполняет указанный скрипт в текущем шелле (аналог . или source в bash).

А вот load — да, это бомба! Она позволяет подгружать в текущий шелл дополнительные модули, которые пишутся на Limbo и позволяют добавить к шеллу абсолютно любую функциональность — if, for, функции, исключения, регулярные выражения, математические операции, и т.д. и т.п. Ещё есть команда unload, позволяющая динамически эти модули выгружать. :) Тем не менее, даже без подгружаемых модулей шелл абсолютно полноценен и функционален — что я и докажу в конце статьи реализовав на «голом sh» if и for!

Ой, кое что ещё я забыл упомянуть. (Думаете, теперь-то точно «ага!»? Не-а.) Комментарии он ещё поддерживает. Начинающиеся с #. :)

Запуск приложений


Многое идентично любому *nix шеллу:
  • запускает приложения (.dis файлы) и скрипты (файлы, в которых первая строка это shebang #!)
  • команды разделяются ;
  • запуск команд в фоне через &
  • конвейеры команд через |
  • перенаправления stdin/stdout через >, >> и <

; echo one two | wc
    1       2       8

Но есть и отличия.

Дополнительные перенаправления ввода-вывода команд

  • указание файлового дескриптора (для перенаправления stderr или открытия дополнительных дескрипторов плюс к 0, 1 и 2 доступным по умолчанию)
    • cmd <stdin.txt >stdout.txt >[2]stderr.txt
    • cmd >[1=2] (перенаправление stdin в stderr)
    • cmd <[3]file.txt (команда запускается с дополнительным файловым дескриптором 3 открытым на чтение из файла)
    • cmda |[2] cmdb (вместо stdout первой команды на вход конвейера отправляется её stderr, а stdout выводится просто на экран)
    • cmda |[1=2] cmdb (в конвейер снова отправляется stderr, а не stdout cmda, но подключается он не к stdin, а к stdout cmdb — который открывается на чтение, а не запись, разумеется)

  • открытие одновременно на чтение и запись
    • cmd <>in_pipe <>[1]out_pipe (stdin и stdout открыты одновременно на чтение и запись в разные файлы)

  • нелинейные конвейеры
    • cmd <{первый;блок;команд} >{второй;блок;команд} (оба блока команд запускаются параллельно с cmd, при этом cmd получает два параметра — имена файлов а-ля /fd/номер, которые подключены через pipe к stdout первого блока команд и stdin второго блока команд)

Последняя фича особо интересна. Например, стандартная команда сравнения двух файлов cmp с её помощью может сравнить не файлы, а результаты работы двух других команд: cmp <{ ls /dir1 } <{ ls /dir2 }.

$status

В инферно необычный подход к реализации кода завершения (exit status) приложения. В традиционных ОС любая программа завершается числом: 0 если всё в порядке, и любым другим при ошибке. В инферно любая программа либо просто завершается (если всё в порядке), либо генерирует исключение, значение которого это строка с текстом ошибки.

Принято соглашение, по которому эта строка должна начинаться на «fail:» если это ошибка штатная — т.е. приложение не «упало», а просто хочет выйти вернув эту ошибку тому, кто это приложение запустил (обычно это шелл). Eсли ошибка не начинается на «fail:», то после завершения приложения его процесс останется в памяти (в состоянии «broken»), чтобы его можно было исследовать отладчиками и выяснить, почему возникло это исключение (аналог core dump-ов, только вместо сохранения на диск они висят в памяти).

Так вот, после завершения запущенной команды её «код выхода» — т.е. текст ошибки-исключения будет находится в переменной окружения $status (префикс «fail:» из него будет автоматически удалён, если текст исключения кроме «fail:» больше ничего не содержал, то в $status будет строка «failed»).

Строки, списки строк, блоки команд


Инферновский шелл оперирует только строками и списками строк.

Строка

Строка это либо слово не содержащее пробелов и некоторых спец-символов, либо любые символы заключённые в одинарные кавычки. Единственный «экранируемый» символ в такой строке — сама одинарная кавычка, и записывается он двумя такими кавычками:
; echo 'quote   ''   <-- here'
quote   '   <-- here

Никаких \n, \t, интерполяции переменных и т.п. в строках не поддерживается. Если в строку нужно вставить символ перевода строки — либо просто вставляете его as is (нажав Enter), либо конкатенируете строку с переменной, содержащей этот символ. В инферно есть утилитка unicode(1) которая умеет выводить любой символ по его коду, вот с её помощью это и делается (конструкция "{команда} описана ниже, а пока считайте это аналогом `команда` bash-а):
; cr="{unicode -t 0D}
; lf="{unicode -t 0A}
; tab="{unicode -t 09}
; echo -n 'a'$cr$lf'b'$tab'c' | xd -1x
0000000 61 0d 0a 62 09 63
0000006

Двойные кавычки для строк не используются (кстати, парные двойные кавычки в инферновском шелле не используются ни для чего вообще).

Блок команд

Блок команд записывается внутри фигурных скобок, и весь блок обрабатывается шеллом как одна строка (включающая символы фигурных скобок).
; { echo one; echo two }
one
two
; '{ echo one; echo two }'
one
two

Единственное отличие от строки в одинарных кавычках заключается в том, что sh проверяет синтаксис блоков команд и переформатирует их в более компактные строки.
; echo { cmd | }
sh: stdin: parse error: syntax error
; echo { cmd | cmd }
{cmd|cmd}
; echo {
echo     one
echo two
}
{echo one;echo two}


Список строк

Списки строк это просто ноль и более строк разделённых пробельными символами. Их можно заключать в круглые скобки, но в абсолютном большинстве случаев это не обязательно.

Любая команда шелла это просто список строк, где первая строка содержит команду либо блок кода, а остальные строки параметры для них.
; echo one two three
one two three
; echo (one two) (three)
one two three
; (echo () (one (two three)))
one two three
; ({echo Hello, $1!} World)
Hello, World!

Для конкатенации списков строк используется оператор ^. Его можно применять двум спискам содержащим либо одинаковое количество элементов (тогда элементы конкатенируются попарно), либо один из списков должен содержать только один элемент, тогда он будет конкатенирован с каждым элементом второго списка. Как частный случай, если оба списка содержат только по одному элементу — получается обычная конкатенация двух строк.
; echo (a b c) ^ (1 2 3)
a1 b2 c3
; echo (a b) ^ 1
a1 b1
; echo 1 ^ (a b)
1a 1b
; echo a ^ b
ab

Обычно одна команда шелла должна быть записана в одну строку — никаких спец.символов для обозначения «продолжения команды на следующей строке» нет. Но разбить команду на несколько строк очень просто и без этого — достаточно на предыдущей строке открыть и не закрыть строку (одинарной кавычкой) или блок команд (фигурной скобкой) или список строк (круглой скобкой), либо завершить предыдущую строку оператором конкатенации списков.

Переменные окружения


Работа с переменными в инферновском шелле выглядит обманчиво похоже на традиционные шеллы, но не дайте себя этим обмануть!
; a = 'World'
; echo Hello, $a!
Hello, World!


/env

Переменные окружения в ОС Инферно реализованы иначе, чем в традиционных ОС. Вместо введения дополнительного POSIX API для работы с переменными окружения и передачи их каждому процессу через кошмар вроде int execvpe(const char *file, char *const argv[], char *const envp[]); переменные окружения это просто файлы в каталоге /env. Это даёт любопытные возможности, например приложение может предоставить доступ к своим переменным окружения другим приложениями по сети — просто экспортировав (через listen(1) и export(4)) свой /env.

Можно создавать/удалять/изменять переменные окружения создавая, удаляя и изменяя файлы в /env. Но стоит учитывать, что текущий sh держит в памяти копию всех переменных окружения, поэтому изменения сделанные через файлы в /env увидят запускаемые приложения, но не текущий sh. С другой стороны, изменяя переменные обычным для шелла способом (через оператор =) вы автоматически обновляете файлы в /env.

Имена переменных шелла могут содержать любые символы, в т.ч. такие, которые недопустимы для имён файлов (например имена ., .. и содержащие /). Переменные с такими именами будут доступны только в текущем sh, и запускаемые приложения их не увидят.

Списки

Ещё одно важное отличие — все переменные содержат только списки строк. Вы не можете положить в переменную одну строку — это всегда будет список из одной строки.

Сохранение в переменную пустого списка (явно через присваивание a=() или неявно через a=) — это удаление переменной.
  • $var это получение списка строк из переменной var, и в ней этих строк может быть ноль, одна или несколько
  • $#var это получение количества элементов в списке строк $var
  • $"var это конкатенация всех элементов списка строк из $var в одну строку (через пробел)

; var = 'first   str'  second
; echo $var
first   str second
; echo $#var
2
; echo $"var
first   str second

Как видите, результат вывода $var и $"var визуально не отличается, т.к. echo выводит все свои параметры через пробел, и $" тоже объединяет все элементы переменной через пробел. Но первая команда echo получила два параметра, а последняя — один.

Всегда, когда вы как-бы неявно конкатенируете значение переменной с чем либо — шелл вставляет оператор конкатенации ^ (который, как вы помните, работает со списками строк, а не строками). Это удобно, хотя с непривычки может оказаться неожиданным:
; flags = a b c
; files = file1 file2
; echo -$flags $files.b
-a -b -c file1.b file2.b
; echo { echo -$flags $files.b }
{echo -^$flags $files^.b}

Присваивание в список переменных тоже работает. Если в правой части список из большего количества элементов, чем переменных в левой части — последняя переменная получит остаток списка, а в предыдущих переменных будет в каждой по одному элементу.
; list = a b c d
; (head tail) = $list
; echo $head
a
; echo $tail
b c d
; (x y) = (3 5)
; (x y) = ($y $x)
; echo 'x='^$x 'y='^$y
x=5 y=3

Список всех параметров скрипта или любого блока кода находится в переменной $*, плюс отдельные параметры находятся в переменных $1, $2, ….

Области видимости

Каждый блок кода образует свою область видимости. Значения переменным можно присваивать операторами = и :=. Первый изменит значение существующей переменной либо создаст новую переменную в текущей области видимости. Второй всегда создаёт новую переменную, перекрывая старое значение переменной с этим именем (если она существовала) до конца текущего блока.
; a = 1
; { a = 2 ; echo $a }
2
; echo $a
2
; { a := 3; echo $a }
3
; echo $a
2
; { b := 4; echo $b }
4
; echo b is $b
b is


Ссылки на переменные

Имя переменной — это просто строка после символа $. И она может быть любой строкой — в одинарных кавычках или другой переменной.
; 'var' = 10
; echo $var
10
; ref = 'var'
; echo $$ref
10
; echo $'var'
10
; echo $('va' ^ r)
10


Перехватываем вывод команд


Вообще-то эта тема относится скорее к разделу описывающему запуск команд, но, чтобы было понятно о чём речь, нужно было сначала описать работу со списками строк, поэтому пришлось её отложить до текущего момента.

Встречайте те самые непарные кавычки, которые вечно ломают подсветку синтаксиса.
  • `{команда} (выполняет команду, и возвращает то, что она вывела на stdout в виде списка строк; элементы списка разделяются используя значение переменной $ifs; если она не определена, то считается, что в ней строка из трёх символов: пробел, табуляция и перевод строки)
  • "{команда} (выполняет команду, и возвращает то, что она вывела на stdout в виде одной строки — конкатенации всех строк выведенных командой)
В качестве примера загрузим список файлов через вызов ls. Учитывая, что имена файлов могут содержать пробелы, стандартное поведение `{} нам не подойдёт — нам необходимо разделять элементы списка только по переводу строки.
; ifs := "{unicode -t 0A}
; files := `{ ls / }
; echo $#files
82
; ls / | wc -l
	82

Но вообще-то это делается проще и надёжнее через шаблоны имён файлов, которые разворачиваются шеллом в список имён файлов:
; files2 := /*
; echo $#files2
82


Встроенные команды


Встроенные команды бывают двух типов: обычные и для работы со строками.

Обычные команды вызываются так же, как запускаются приложения/скрипты:
; run /lib/sh/profile
; load std
; unload std
; exit

Команды для работы со строками вызываются через ${команда параметры} и возвращают (не выводят на stdout, а именно возвращают — как это делает например обращение к значению переменной) обычную строку — т.е. их нужно использовать в параметрах обычных команд или в правой части присваивания значений в переменные. Например, команда ${quote} экранирует переданный ей список строк в одну строку, а ${unquote} выполняет обратную операцию превращая одну строку в список строк.
; list = 'a  b'  c d
; echo $list
a  b c d
; echo ${quote $list}
'a  b' c d
; echo ${unquote ${quote $list}}
a  b c d


Добавляем if, for, функции


Как я и обещал, показываю как сделать свои реализации этих крайне нужных вещей на «голом sh». Конечно, в реальной жизни этого делать не требуется, подгружаемый модуль std предоставляет всё, что необходимо, и пользоваться этим намного удобнее. Но, тем не менее, эта реализация представляет интерес как демонстрация возможностей «голого sh».

Всё реализуется используя исключительно:
  • переменные
  • строки и списки строк
  • блоки кода и их параметры


Делаем свои «функции»

Как я уже упоминал, любая команда шелла это просто список строк, где первая строка это команда, а остальные строки её параметры. А блок команд шелла это просто строка, и её можно сохранить в переменной. И параметры любой блок команд получает в $*, $1, etc.
; hello = { echo Hello, $1! }
; $hello World
Hello, World!

Более того, мы даже можем сделать каррирование функций в лучшем духе функционального программирования. :)
; greet = {echo $1, $2!}
; hi    = $greet Hi
; hello = $greet Hello
; $hi World
Hi, World!
; $hello World
Hello, World!

Ещё один пример — можно использовать параметры блока чтобы получить нужный элемент списка по номеру:
; list = a b c d e f
; { echo $3 } $list
c


Делаем свой for

Полноценный удобный if я делать не пытался, мне было интересно реализовать for, а if я сделал минимально функциональный т.к. он был необходим для for (цикл ведь надо когда-то остановить, и без if это сделать проблематично).
; do = { $* }
; dont = { }
; if = {
    (cmd cond) := $*
    { $2 $cmd } $cond $do $dont
}
; for = {
    (var in list) := $*
    code := $$#*
    $iter $var $code $list
}
; iter = { 
    (var code list) := $*
    (cur list) := $list
    (next unused) := $list
    $if {$var=$cur; $code; $iter $var $code $list} $next
}

; $for i in 10 20 30 { echo i is $i }
i is 10
i is 20
i is 30
;


Интересные мелочи


По умолчанию шелл при запуске скрипта форкает namespace, таким образом скрипт не может изменить namespace процесса, который его запустил. Это не подходит скриптам, чья задача как раз настройка namespace своего родителя. Такие скрипты должны начинаться на #!/dis/sh -n.

Встроенная команда loaded выводит список всех встроенных команд из всех загруженных модулей. А встроенная команда whatis выводит информацию по переменным, функциям, командам, etc.

Если создать переменную $autoload со списком модулей шелла, то эти модули будут автоматически загружаться в каждый запускаемый шелл.

В шелле есть поддержка синтаксического сахара: && и ||. Эти операторы доступны в «голом sh», но преобразуются в вызов встроенных команд and и or, которых в «голом sh» нет, они из модуля std (так что использовать && и || можно только после load std).
; echo { cmd && cmd }
{and {cmd} {cmd}}
; echo { cmd || cmd }
{or {cmd} {cmd}}

Приложения написанные на Limbo получив, например, при запуске параметром командной строки строку с блоком команд sh могут очень просто её выполнить используя модуль sh(2).

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

Резюме


Эта небольшая статья — практически полный reference guide по инферновскому шеллу. В смысле, описана вся функциональность базового шелла, и довольно подробно — со всеми нюансами и примерами. Если вы прочитаете sh(1), то увидите, что я не упомянул разве что переменные $apid, $prompt, пару-тройку встроенных команд, опции самого sh да полный список спец.символов, которые нельзя использовать в строках вне одинарных кавычек.

Если не брать довольно продвинутые возможности по перенаправлению ввода/вывода, то используя всего лишь:
  • строки с тривиальнейшими правилами экранирования
  • блоки команд (а по сути это просто те же строки)
  • списки строк с одним оператором ^
  • переменные с одним оператором =
  • обращение к переменным через $var, $#var и $"var
реализован вполне полноценный шелл! Полноценный даже в «голом» виде, что убедительно доказано возможностью реализовать на нём функции, if и for (и ещё я придумал, как сделать аналог команды raise из модуля std — т.е. аналог традиционной /bin/false :) но это хак через run и в статью я его включать не стал).

А когда мы к нему начинаем подгружать модули, возможности и удобство использования шелла повышается на порядок. К примеру, тот же модуль sh-std(1) добавляет:
  • несколько условных операторов (and, or, if)
  • команды для сравнения и проверки условий (!, ~, no)
  • несколько операторов цикла (apply, for, while, getlines)
  • функции обоих видов (fn, subfn)
  • работу с исключениями и статусом (raise, rescue, status)
  • работу со строками и списками (${hd}, ${tl}, ${index}, ${split}, ${join})
  • etc.
Но все эти дополнительные команды никак не усложняют синтаксис самого шелла, он остаётся таким же тривиальным и элегантным!
Tags:
Hubs:
+22
Comments 30
Comments Comments 30

Articles