Вам нужно пройтись по всем файлам в папке, найти те, в которых есть определённая строка, и переместить их в другую директорию.
Казалось бы — 30 секунд работы. Вы открываете терминал, начинаете писать… и через пять минут уже гуглите “bash check if file contains string”, потому что grep -q vs grep -l vs grep -c — это три разных мира с тремя разными интерфейсами. А ещё нужно не забыть кавычки вокруг "$file", потому что в имени файла может быть пробел. И [ -f ] vs [[ -f ]] — два разных синтаксиса, оба валидных, оба с подводными камнями.
Или другая задача: посчитать суммарное количество строк во всех .rs файлах проекта. В голове это простая цепочка: “найди файлы → прочитай каждый → посчитай строки → сложи”. Но чтобы выразить эту мысль на bash, нужно собрать конвейер из find, xargs, wc и awk — четырёх разных утилит, каждая со своим набором флагов.
Я сталкивался с этим постоянно, изучал разные языки и оболочки — но всё не то.
Проблема не в инструментах — а в их фокусе
Bash — хороший командный интерпретатор. Fish — приятная интерактивная оболочка. Python — мощный универсальный язык.
Но ни один из них не спроектирован для задачи “быстро написать цепочку действий в терминале, в формате полёта мысли”.
У каждого инструмента есть своя основная задача, и скриптинг повседневных действий — не она:
Bash — создавался как оболочка для запуска программ. Скрипты — побочный эффект. Отсюда
[[ ]]vs[ ],$()vs обратные кавычки, и ворох несовместимых утилит с непредсказуемыми флагами.Fish — попытался исправить ситуацию — и во многом преуспел в плане интерактивности. Но он остаётся оболочкой, и его скриптовые конструкции всё ещё строятся вокруг запуска внешних команд, а не обработки данных.
Python — огромный язык с мощной стандартной библиотекой. Но для работы с файлами нужно выбирать между
os,shutilиpathlib— тремя модулями с разными API. А сам язык постоянно обрастает специальным синтаксисом: list comprehensions, generator expressions, walrus operator, match/case — каждая конструкция со своими правилами.Nushell, Elvish — переосмысленные оболочки со структурированными данными. Они решают проблему шире — заменяют собой bash/zsh целиком. Мне хватает bash/fish, мне лишь нужно иногда воспроизвести некий сценарий в удобном и лаконичном виде.
Все эти инструменты можно использовать для терминальных скриптов. Но ни один из них не проектировался специально для этого и только для этого. Каждый раз вы боретесь с языком и платформой, а не решаете задачу.
Shik — попытка создать язык, спроектированный именно для REPL сессий и автоматизированных сценариев.
Покажи, а не рассказывай
Задача: найти все файлы в текущей директории, содержащие строку “- links”, и переместить их в папку topics/.
Bash:
for file in *; do if [ -f "$file" ] && grep -q -- "- links" "$file"; then mv "$file" topics/ fi done
Кавычки вокруг "$file" — обязательны (пробелы в именах). grep -q — тихий режим, не -s (это подавление ошибок). -- — чтобы дефис в строке не стал флагом. Пять строк, четыре ловушки.
Fish:
for file in * if test -f $file; and grep -q "\- links" $file mv $file topics end end
Чище, но со своими занозами: императивный цикл, переменную объявляем без $ а используем с $, неочевидный ; перед and — и всё ещё вызов внешних программ (grep, mv), каждая со своими правилами.
Python:
# Вариант "в лоб" — с os и shutil import os, shutil for f in os.listdir('.'): if os.path.isfile(f): with open(f) as fh: if '- links' in fh.read(): shutil.move(f, 'topics/')
Три уровня вложенности, shutil.move вместо os.move — перемещение почему-то в другом модуле.
# Вариант "модный" — с pathlib from pathlib import Path for f in Path('.').iterdir(): if f.is_file() and '- links' in f.read_text(): f.rename(Path('topics') / f.name)
Короче, но попробуйте написать это в REPL по памяти: Path('.').iterdir() — а может iterfiles()?, f.read_text() — а может f.read()? Path('topics') / f.name — деление как конкатенация путей? Опять мета-синтаксис? Это API, которое надо читать в доке и писать в IDE, а не угадать наощупь в терминале.
Shik:
file.glob :./* $> list.filter file.is-file $> list.filter (fn [f] file.read f $> string.has "- links") $> list.iterate (file.move :topics)
Четыре строки. Каждая — один шаг пайплайна. Никаких импортов, никаких кавычек вокруг переменных, никаких внешних утилит. Данные текут сверху вниз, слева направо.
Опытный возразит: grep -l "- links" * | xargs mv -t topics/ — одна строка. И будет прав. Но вопрос не в количестве символов, а в том, сможете ли вы написать эту строку по памяти и без опечаток? Какой флаг у grep для вывода имён файлов — -l или -L? xargs mv — в каком порядке аргументы? -t — это target? А если в именах файлов пробелы?
Эту проблему невозможно исправить полностью — любой язык надо изучать. Но Shik проектировался так, чтобы каждая конструкция работала по одним и тем же правилам. Чтобы каждое правило было универсальным. Чтобы после получаса использования максимум, за чем придётся заглянуть в мануал — «как называется функция» и «в каком порядке аргументы».
Ещё одна: посчитать суммарное количество строк во всех .rs файлах проекта:
file.glob :./src/**/*.rs $> list.map (file.read #> string.lines #> list.len) $> list.sum $> print
Если хочется понять, как это работает — давайте разберёмся.
Всё — функция
Дизайн Shik вырос из двух миров: Lisp и Haskell.
От Lisp — главный принцип: в языке нет ничего, кроме применения функций. + 1 2 — это не оператор сложения. Это вызов функции с именем +, которой переданы аргументы 1 и 2. Точно так же list.map, file.glob, string.upper — всё это просто имена функций. Точка в list.map — часть имени, а не обращение к модулю. Нет арифметических операторов, нет операторов сравнения, нет логических операторов — только вызовы функций.
От Haskell — синтаксис аппликации: пробел — это вызов функции.
file.glob :./src/**/*.rs
Это не специальная конструкция. Это буквально: “применить значение :./src/**/*.rs к функции file.glob”. Как f(x), но без скобок. Несколько аргументов — через пробел: + 1 2, list.at 0 lst, file.copy :dest :source.
И от Haskell же — автоматическое каррирование: если функция ожидает два аргумента, а вы передали один — результат не ошибка, а новая функция, ожидающая оставшийся аргумент. (+ 1) — готовая функция “прибавить один”. (file.write :out.txt) — функция “записать в out.txt”.
По сути, Shik — Lisp-подобный язык с правилами аппликации Haskell, адаптированный для набора в терминале. В нём ровно четыре “настоящих” оператора — и все они про аппликацию и композицию функций, каждый со своим приоритетом, от большего (самый липкий) к меньшему (самый скромный):
пробел — базовая аппликация:
f x#>— композиция:f #> gсоздаёт новую функциюfn [x] g (f x)$— аппликация с пониженным приоритетом:print $ + 1 2вместоprint (+ 1 2)$>— пайп, аппликация слева направо:x $> fвместоf x
Всё остальное в языке — включая if, let, while, < — это применение функций по тем же правилам.
Слева направо
Когда пробел — это аппликация, а вложенные вызовы требуют скобок, цепочки быстро превращаются в кашу:
print (list.sum (list.map (fn [f] list.len (string.lines (file.read f))) (file.glob :./src/**/*.rs)))
Это тот самый пример подсчёта строк — но записанный “в лоб”, без пайпов. Чтобы понять что тут происходит, нужно найти самое глубокое вложение и разворачивать наружу. Типичная проблема Lisp-подобных языков.
Оператор $> решает это. Он берёт результат слева и передаёт последним аргументом в функцию справа — данные текут слева направо.
Ещё пара вещей, формирующих эргономику:
Всё — выражение: нет разделения на statements и expressions. Если выражение возвращает значение — его можно передать дальше.
Только данные и функции: Никаких методов, классов, скрытых свойств. Есть только данные и глобальные функции для работы с этими данными.
Сразу готов к бою:
file.,string.,list.,object.,shell.— доступны без импортов. Открыл REPL — и работаешь.Встроенная документация:
help list.покажет все функции для списков.help list.map— описание с примером.Удобство в абсолюте: каждая фича, что есть в языке в первую очередь должна быть удобной для своей цели, даже если для этого придётся нарушить общие конвенции (возможно даже Женевские), о чём позже.
Почти всё тестирование языка я проводил в примитивном REPL без поддержки стрелочек — они были добавлены лишь в самом конце. Если код удобно писать в таких спартанских условиях — синтаксис работает.
Синтаксис за пять минут
Краткая справочная карточка — минимум, чтобы читать и писать код на Shik.
Литералы:
; комментарий {* блочный комментарий *} 42 ; число "hello world" ; строка :hello ; тоже строка — для слов без пробелов (не нужны кавычки!) [1 2 3] ; список {:name :Alice :age 30} ; объект (словарь) true ; bool fn [arg] body ; функция — да, объявление функции выглядит как аппликация с двумя параметрами, но на деле это единая конструкция литерала
:symbol — сокращение для строки без пробелов. Мелочь, но набирать :src вместо "src" в терминале — заметно быстрее.
Переменные, функции, интерполяция:
let name :Alice let greet fn [name] "Hello, {name}!" print $ greet name ; Hello, Alice! let x 10 set x (+ x 5) ; мутация через set ;; string.+, file.glob - это не функции из родительского модуля, а одна функция. Точка - часть имени let string.dup fn [str] string.+ str str ;; Почти любые символы могут быть частью имени let $->евро (* 0.87) $->евро 100
let — привязка, где первый параметр это имя, а второй - значение. {выражение} — интерполяция прямо в строке.
Каррирование и порядок аргументов:
Каждая функция поддерживает частичное применение:
let lst [1 2 3 4] lst $> list.map (+ 1) ; [2 3 4 5] — прибавить 1 lst $> list.map (- 1) ; [0 1 2 3] — вычесть 1 lst $> list.map (* 2) ; [2 4 6 8] — умножить на 2 lst $> list.map (^ 2) ; [1 4 9 16] — возвести в квадрат
(+ 1), (- 1), (* 2), (^ 2) — единообразный паттерн. Не нужно писать fn [x] + x 1 — каррирование делает это за вас.
Но почему (- 1) означает “вычесть один”, а не “отнять от одного”? Да, это ересь, с которой я долго не мог согласиться сам. Но в Shik порядок аргументов — часть дизайна: первый аргумент — тот, который вы фиксируете при частичном применении. Для арифметики: первый — модификатор, второй — база. Все четыре строки выше читаются одинаково. Если бы - работал как “первый минус второй”, (- 1) означало бы “единица минус что-то”, и пришлось бы писать fn [x] - x 1 или вводить отдельный flip. Этот принцип работает единообразно для всех функций языка. Подробнее — в документации.
Абсолютность правил:
Каррирование и пайпинг работают с почти любой функцией, в том числе let:
let my-name-is (let me) my-name-is :Max print me ; Max my-name-is :Xam print me ; Xam ; Каждый вызов my-name-is - создаёт новую переменную с тем же именем (в Shik shadowing переменных повсеместен). Вот так вот, а что вы мне сделаете. Правила есть правила, и они - абсолютны ; Но куда более интересная фича - пайпинг в let ; Пишешь, пишешь такую длинную цепочку, и думаешь - вот бы сохранить в переменную. И пожалуйста: file.glob :./src/**/*.rs $> list.filter (file.read-lines #> list.len #> < 100) $> let big-files print big-files ; [ src/eval/error.rs src/eval/evaluator.rs src/eval/native_functions/bool.rs ]
Про “почти” - нельзя каррировать функции с динамическим числом аргументов: if, while, help, list.range, number.rand.
Композиция — склейка функций через #>:
let read-lines (file.read #> string.lines) read-lines :.gitignore ; ["target" "docs" "releases"]
file.read #> string.lines — новая функция: “прочитай, затем разбей на строки”. Записано в порядке выполнения.
Много-строчные функции и выражения
Тело функции может содержать не только одно выражение. Её можно расширить с помощью блока '():
let reverse fn [str] '( let reversed "" let append (string.push reversed) str $> string.iterate-backward append reversed ; последнее выражение - результат функции ) reverse :hello $> print ; olleh
Паттерн-матчинг:
let platform $ match "{shell.os}-{shell.arch}" { :Darwin-arm64 :macos-aarch64 :Darwin-x86_64 :macos-x86_64 :Linux-x86_64 :linux-x86_64 _ :unknown } match [1 2 3 4] { [] :empty [x y #rest] "first: {x}, rest: {rest}" } ; "first: 1, rest: [3 4]"
_ — wildcard. [x y #rest] — деструктуризация списка на голову и хвост.
Вызов внешних команд:
Shik — не оболочка, но умеет вызывать внешние программы через shell функции:
shell "git log --oneline -5" $> print ; Построчный вывод — для обработки в пайпе shell.lines "git branch" $> list.filter (string.has :feature) $> list.iterate print ; Проверки окружения shell.has :docker $> print ; true shell.env :HOME $> print ; /Users/max
shell возвращает stdout как строку, shell.lines — как список строк. Можно встраивать в любой пайплайн.
Подробнее можно узнать в документации к языку на GitHub.
Собираем вместе
До сих пор примеры были короткими. Вот задача побольше — из тех, что реально встречаются в работе.
Найти все файлы в проекте, содержащие TODO, и вывести отчёт — сколько в каком файле, отсортировано по убыванию:
let has-todo (file.read #> string.has :TODO) let count-todos (file.read #> string.lines #> list.filter (string.has :TODO) #> list.len) file.glob :./src/**/*.rs $> list.filter has-todo $> list.map (fn [f] [f (count-todos f)]) $> list.sort (fn [[_ a] [_ b]] - a b) $> list.iterate (fn [[file n]] print "{file}: {n} TODOs")
Семь строк. Разберём:
has-todoиcount-todos— две функции, собранные из композиции (#>) существующих. Никаких лямбд, просто склейка: “прочитай файл → проверь есть ли TODO” и “прочитай → разбей на строки → отфильтруй с TODO → посчитай”.list.filter has-todo— отсеиваем файлы без TODO. Каррирование:has-todoуже полностью готовая функция, не нужно оборачивать.list.map (fn [f] [f (count-todos f)])— для каждого файла создаём пару[имя, количество].list.sort (fn [[_ a] [_ b]] - a b)— сортируем по второму элементу пары. Деструктуризация прямо в аргументах лямбды.list.iterate— печатаем каждую строку отчёта.
Бекап утилита:
А вот скрипт, на котором я отлаживал весь язык — бекап и развёртывание конфигурационных файлов между системами:
let home shell.home let make-path fn [dir] "{home}/.config/{dir}" ;; let$ — вариант `let` с деструктуризацией. let$ [KITTY-PATH FISH-PATH] [(make-path :kitty) (make-path :fish)] let HOME-FILES [:.ghci :.gitconfig] let FISH-FILES [ :fish_plugins :functions/start.fish :functions/setup-tabs.fish :functions/gr.fish :functions/set-theme.fish ] let KITTY-FILES $ file.list KITTY-PATH let make-copier fn [files from dest] fn [] '( files $> list.iterate fn [file] '( print "Copy: {from}/{file} -> {dest}/{file}" file.copy "{dest}/{file}" "{from}/{file}" ) ) ;; Sync let sync-fish $ make-copier FISH-FILES FISH-PATH :fish let sync-kitty $ make-copier KITTY-FILES KITTY-PATH :kitty let sync-home $ make-copier HOME-FILES home :home ;; Install let install-fish $ make-copier FISH-FILES :fish FISH-PATH let install-kitty $ make-copier KITTY-FILES :kitty KITTY-PATH let install-home $ make-copier HOME-FILES :home home ;; Run ; [:shik :backup.shk :sync :home] -> [:sync :home] let options $ list.drop 2 shell.args let mode $ list.head options let options $ list.tail options ; sync/install по-умолчанию let targets (if (list.empty? options) [:fish :home :kitty] options) targets $> list.map (+ "{mode}-" #> var.get) $> list.iterate fn.invoke
Самый интересный момент — последние три строки. Разберём по шагам:
list.map (+ "{mode}-")— еслиmode="sync", аtargets=[:fish :home], получаем["sync-fish" "sync-home"]. Каррированная функция+приклеивает префикс к каждому имени.list.map var.get— берём строку"sync-fish"и получаем значение переменнойsync-fish, с помощьюvar.get. Это обращение к текущему пространству имён как к объекту!list.iterate fn.invoke— теперь наш список это набор функций.fn.invoke- это просто вызов функции без передачи в неё аргументов.
Строки превращаются в имена переменных, имена — в функции, функции — вызываются. Shik сознательно жертвует строгостью и плодит анти-паттерны ради гибкости — это компромисс, и весьма весёлый.
Как это выглядит в жизни:

Как попробовать
Shik — проект в активной разработке (v0.7.1). Он уже пригоден для использования, но будьте готовы к шероховатостям. Это не production-ready инструмент — это рабочий прототип, которым я пользуюсь каждый день. Tree-walk интерпретатор написан на Rust — startup мгновенный, все функции написаны на самом Rust и оптимизированы, большое внимание уделено недопущению RAM бомб в замыканиях, ибо сборщика мусора нет — а для скриптов он, скорее, и не нужен.
Установка:
# Через cargo (нужен Rust toolchain) cargo install shik # macOS / Linux curl --proto '=https' --tlsv1.2 -LsSf https://github.com/pungy/shik/releases/latest/download/shik-installer.sh | sh # Windows (PowerShell) powershell -ExecutionPolicy ByPass -c "irm https://github.com/pungy/shik/releases/latest/download/shik-installer.ps1 | iex"
shik # REPL shik script.shk # запуск файла
В REPL работает help — встроенная документация по всем модулям и функциям.
В разработке и будет реализовано, по приоритету:
Shebang (
#!/usr/bin/env shik) для запуска скриптов напрямую;Регулярные выражения;
Разделитель
,для нескольких выражений в одной строке;Работа с сетью;
Шорткат функции (ленивые функции) -
list.sort #(- #1 #2)вместоlist.sort (fn [a b] - a b);Парсинг JSON;
Пользовательская обработка ошибок:
try/catchили аналог, чтобы вы могли перехватить ошибку в скрипте и обработать её сами;Многопоточность/асинхронность - как минимум запуск не-блокирующих операций;
Вместо заключения
Shik не заменит вам bash — он не оболочка и не пытается ей быть. Не заменит python для создания telegram ботов.
Но если вам раз в пару дней надо написать скрипт для файловой, текстовой или сценарной рутины — переместить, отфильтровать, переименовать, собрать отчёт — и каждый раз вы тратите больше времени на гугление флагов find и xargs, чем на саму задачу; или же вам кажется, что для этого подойдёт декларативный язык — попробуйте.
Shik на GitHub, с подробной документацией: github.com/pungy/shik
