Вам нужно пройтись по всем файлам в папке, найти те, в которых есть определённая строка, и переместить их в другую директорию.

Казалось бы — 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, адаптированный для набора в терминале. В нём ровно четыре “настоящих” оператора — и все они про аппликацию и композицию функций, каждый со своим приоритетом, от большего (самый липкий) к меньшему (самый скромный):

  1. пробел — базовая аппликация: f x

  2. #> — композиция: f #> g создаёт новую функцию fn [x] g (f x)

  3. $ — аппликация с пониженным приоритетом: print $ + 1 2 вместо print (+ 1 2)

  4. $> — пайп, аппликация слева направо: 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

Самый интересный момент — последние три строки. Разберём по шагам:

  1. list.map (+ "{mode}-") — если mode = "sync", а targets = [:fish :home], получаем ["sync-fish" "sync-home"]. Каррированная функция + приклеивает префикс к каждому имени.

  2. list.map var.get — берём строку "sync-fish" и получаем значение переменной sync-fish, с помощью var.get. Это обращение к текущему пространству имён как к объекту!

  3. 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