smalang

smalang

Проєкт простої мови програмування та власного компілятора. Мову планується розвивати невеликими кроками, зберігаючи короткий цикл компіляції.

Цілі

  • Компілювати програми в нативні виконувані бінарники для Linux x86_64.
  • Орієнтуватися на швидкість компіляції Go; перевірити її на практиці після першого робочого зрізу й оптимізувати далі.
  • Написати власний native backend для Linux x86_64.
  • Тримати мову простою на початку й розширювати її в міру потреби.
  • Розглянути C FFI на пізнішому етапі.

Проєкт зараз має початкову конфігурацію Crystal shard у shard.yml. Файли smalang використовуватимуть розширення .py для сумісності з редакторами та підсвічуванням синтаксису; це вихідний код smalang, який потрібно буде компілювати.

Напрямок синтаксису мови

Вихідний синтаксис буде Python-подібним. Для параметрів і результатів функцій потрібні явні анотації. Компілятор не виводить типи функцій або значень; типи виразів перевіряються за правилами операцій та їхніх операндів. Числовий літерал отримує очікуваний тип із явної анотації або типізованого операнда й перевіряється на вміщення.

Для першого кроку лексера зафіксовано: він видає NEWLINE, INDENT і DEDENT; кожен рівень відступу — рівно чотири пробіли й вводиться послідовно; дедент може закривати кілька рівнів. Табуляції та #-коментарі не підтримуються.

Токени зберігають тип і байтовий діапазон у вихідному файлі; лексеми не копіюються. TokenStream утримує SourceFile, а позиції для діагностики визначаються за індексом початків рядків.

Парсер одразу будує AST. AST зберігається в індексованій arena: вузли посилаються один на одного через NodeId, а не через сирі вказівники. За синтаксичних помилок парсер повертає частковий AST із ErrorNode та діагностикою, відновлюючись усередині функцій; лексичні помилки поки зупиняють лексер. AST із діагностиками не передається далі. Парсер відповідає лише за синтаксис. Окремий семантичний прохід розв'язує імена й перевіряє типи; він не змінює AST і не генерує IR. Його результат зберігає той самий AST разом із таблицями семантичних фактів, прив'язаними до NodeId. Усі функції модуля видимі незалежно від порядку оголошення, тому рекурсивні виклики перевіряються як звичайні виклики функцій. Семантичний прохід зупиняється на першій помилці, а IR генерується лише після успішного аналізу.

Початковий зріз граматики: модуль містить лише функції; кожна функція має типізовані параметри й результат, оголошення, присвоєння та блоки unsafe:, після яких іде рівно один останній return expression. Усередині unsafe: дозволені alloc, alloc_stack і load як вирази та окремі statements store і free; загальні expression statements не підтримуються. Вирази — десяткові цілі й булеві літерали, ідентифікатори, прямі виклики, фіксовані array literals, індексація, дужки й бінарні + та - однакового пріоритету з лівою асоціативністю. Арифметика підтримується лише для цілих типів. Унарний мінус і коми наприкінці списків не підтримуються.

def increment(value: int) -> int:
    return value + 1

def main() -> int:
    return increment(41)

Frontend, IR lowering і backend підтримують тип bool, літерали True і False, а також bool-параметри, результати й виклики функцій. Літерали чутливі до регістру; true і false є звичайними ідентифікаторами. bool не має неявних перетворень, операторів чи умов у вихідній мові. У typed IR bool — окремий тип із канонічними значеннями 0 і 1.

Цілочисельні операції додавання й віднімання (+, -) доступні для всіх знакових і беззнакових цілих типів. Це лише ілюстрація стилю, а не остаточна специфікація мови.

Для виконуваної програми точкою входу буде def main() -> int:. Значення, яке повертає main, стане кодом завершення процесу.

У згенерованому ELF вихідна main матиме символ __smalang_user_main. Backend згенерує _start, який викликає її без системного startup-коду; результат стане кодом завершення процесу, а непійманий виняток буде надруковано runtime-кодом Smalang.

Цілочисельні типи:

  • int — знаковий тип розміру вказівника, аналог isize.
  • uint — беззнаковий тип розміру вказівника, аналог usize.
  • int8/uint8, int16/uint16, int32/uint32, int64/uint64 — знакові та беззнакові типи фіксованої ширини.

bool — окремий тип із літералами True і False; у typed IR він має канонічні значення 0 і 1. У вихідній мові bool-параметри, результати й виклики підтримані, але логічні оператори й умови ще не входять до мови.

Прив'язки та змінюваність

Локальні прив'язки й параметри незмінні за замовчуванням. Кожне локальне оголошення має явний тип і початкове значення: count: int = 1. Запис count: const[int] = 1 явно задає ту саму незмінність. Для повторного присвоєння потрібен mut[T]: count: mut[int] = 1. Присвоєння має форму count = count + 1; компілятор відхиляє присвоєння незмінній прив'язці.

Кваліфікатори mut[T] і const[T] застосовуються лише до параметрів і локальних прив'язок; тип результату функції залишається звичайним типом. Параметр value: mut[int] дозволяє змінювати локальне значення всередині функції, але аргументи й надалі передаються за значенням. Параметри й локальні прив'язки мають унікальні імена в межах функції; локальне ім'я доступне після оголошення. Оголошення без ініціалізатора, виведення типу й окремі expression statements не підтримуються.

def calculate(start: int, delta: mut[int]) -> int:
    base: int = start + 1
    fixed: const[int] = base + 1
    current: mut[int] = fixed
    current = current + delta
    delta = 2
    current = current + delta
    return current

Повний приклад, який компілюється і запускається, є в examples/const_mut_bindings.py.

Фіксовані масиви

Масив має тип array[T, N]: довжина N відома під час компіляції й входить до типу. Елементи зберігаються inline підряд; масиви можуть вкладатися, а array[T, 0] є порожнім масивом. unit існує лише в IR; оскільки source-level None ще немає, unit не є типом елемента.

Масив є значенням із фіксованою довжиною та змінюваними елементами. Присвоєння, передавання параметром і повернення функцією копіюють елементи. Оголошення потребує явного ініціалізатора; неявного нульового заповнення немає. Літерал має містити рівно N compile-time constant елементів. Для запису в елемент прив'язка має бути оголошена як mut[array[T, N]].

Індекс має тип uint. Відомий під час компіляції вихід за межі відхиляється компілятором; динамічний індекс перевіряється під час виконання й породжує runtime-виняток. Від'ємна індексація не входить до початкової моделі. Глобальні масиви потребуватимуть статичної пам'яті; цей зріз реалізує локальні масиви на стеку. Типи, що завеликі для x86_64 stack-frame addressing, відхиляються під час компіляції. Деталі рішення записані в ADR 0001.

Приклад вкладених масивів, зміни елемента через mut і копіювання значення є в examples/fixed_arrays.py.

Raw-вказівники та unsafe-пам'ять

ptr[T] — типізована raw-адреса, не власник пам'яті. Значення вказівника копіюється як адреса; копія не створює другого власника й не стає безпечною після free. Вказівники можна передавати параметрами й повертати з функцій. Зараз T має бути скалярним типом.

Операції alloc(value), alloc_stack(value), offset(address, index), load(address), store(address, value) і free(address) дозволені лише всередині unsafe:; ці назви зарезервовані й не можуть бути назвами функцій. alloc відразу ініціалізує одне heap-значення; alloc_stack так само ініціалізує одне значення у слоті кадру функції, який живе до повернення з цієї функції й повторно використовується при наступному виконанні того самого місця виклику. alloc_stack не передають у free. offset приймає ptr[T] та uint, рахує зміщення в елементах і повертає той самий тип вказівника; воно тільки обчислює адресу й не перевіряє її межі чи валідність. Доступ до обчисленої адреси лишається відповідальністю unsafe-коду. Невдала heap-алокація повідомляється як OutOfMemory. store та free записуються окремими рядками; загальні expression statements не підтримуються. Unsafe-блок використовує спільну пласку область імен функції, не може вкладатися й не може містити return.

Для Linux x86-64 адреса має 64 біти; розмір цілого у пам'яті відповідає його ширині, а bool займає байт і зберігається як 0 або 1. Компілятор перевіряє типи вказівників і скалярні типи операцій, але не валідність адрес, межі, час життя чи use-after-free. Це відповідальність коду в unsafe:. Приклади: examples/unsafe_pointers.py показує скалярні heap-операції, examples/unsafe_stack_allocation.py — окремі ініціалізовані стекові слоти, examples/unsafe_stack_allocation_scalars.py — стекову пам'ять для всіх підтримуваних скалярних типів, examples/unsafe_pointer_offsets.py — обчислення та передавання вказівників зі зміщенням, examples/unsafe_pointer_alias.py — копіювання адреси й спільний доступ до пам'яті, а examples/unsafe_pointer_functions.py — передавання й повернення вказівника з функцій. Рішення та обмеження записані в ADR 0003.

Ядро вибору та зіставлення

Pattern matching є базовим механізмом вибору поведінки в Smalang. Усі конструкції, що обирають гілку або реалізацію, мають використовувати спільну внутрішню Match-модель. match є виразом: кожен case повертає значення, усі гілки мають повертати значення одного точного типу, а весь match має цей тип. Його можна використовувати всюди, де дозволений вираз, зокрема у присвоєнні, коли воно з'явиться в мові. Синтаксис натхненний Python, але не зобов'язаний копіювати обмеження Python, де match є оператором.

Початковий зріз підтримує булеві патерни True, False і _; значення кожної гілки записується після двокрапки на тому ж рядку. Семантичний аналіз перевіряє повноту й досяжність випадків, тип scrutinee та однаковий точний тип результатів. Wildcard має бути останнім і доповнює відсутній булевий випадок. Lowering обчислює scrutinee один раз і зводить вибір до наявних branch та phi в typed IR.

def choose(value: bool) -> int:
    return match value:
        case True: 1
        case False: 0

Це також стосується булевої логіки, клауз функцій та мультідиспетчу за кількома аргументами. Оголошення функцій з однаковим ім'ям утворюватимуть групу клауз, яка зіставляє впорядкований набір позиційних та іменованих аргументів із патернами; runtime-контейнери *args чи **kwargs для цього не потрібні. Модель має підтримувати правила за типами та значеннями; вибір, який можна визначити за статичними типами, виконується під час компіляції.

Некоректні Match-конструкції відхиляються під час компіляції з source-located діагностикою до IR lowering. Булеві оператори, патерни типів, guards, bindings, клаузи функцій і мультідиспетч також додаються окремо, але мають спиратися на ту саму Match-модель. Робочі приклади є в examples/bool_match_explicit.py та examples/bool_match_default.py.

Для цілі Linux x86_64 типи int і uint мають ширину 64 біти.

Фіксовані типи мають рівно зазначену ширину; int64 і uint64 є окремими типами від int та uint. Десятковий літерал отримує тип з явного контексту — типу результату функції, параметра виклику або іншого операнда арифметики — і має вміщуватися в цей тип. Без такого контексту тип літерала не вгадується, програма відхиляється. Вже типізовані цілі значення не перетворюються автоматично між ширинами чи знаковістю. Псевдонім byte відкладено.

Фіксовані типи проходять через IR lowering, typed IR і backend. У typed IR int8/uint8, int16/uint16, int32/uint32 та int64/uint64 залишаються окремими від isize/usize типами.

Архітектурний напрямок

Для масивів узгоджено наступний етап: після source semantic checks окремий AST-to-AST pass розгортає масиви в низькорівневі операції сховища, адресації, доступу й копіювання пам'яті в межах тієї самої моделі AST. Частина цих примітивів уже має явний unsafe API: alloc_stack і offset; решта додається окремими кроками. AST-to-IR lowering працюватиме з отриманими простими вузлами; source-контракт масивів, включно з копіюванням за значенням і перевірками меж, зберігається. Напрямок і каталог потрібних операцій записані в ADR 0004; AST-to-AST міграцію ще не реалізовано. Наведений далі потік описує чинний компілятор.

Потік компіляції:

код smalang → lexer → parser + AST → semantic analysis → AST-to-IR lowering → typed IR → власний backend Linux x86_64 → нативний бінарник

IR буде власним і не залежатиме від конкретної архітектури чи WebAssembly. Його зручно читати у WAT-подібній формі S-expression, але це буде лише запозичення текстового стилю: команди й семантика IR належатимуть smalang. WAT сам по собі є текстовим поданням саме WebAssembly, а не універсальним IR (специфікація).

У компіляторі IR зберігатиметься у типізованих структурах. S-expression буде лише дампом для перегляду й налагодження, без зобов'язання підтримувати його як формат серіалізації чи вхідний синтаксис.

IR матиме окреме від AST індексоване сховище: модуль володіє функціями за FunctionId, а кожна функція — локальними значеннями, інструкціями й блоками за ID. Зв'язки зберігатимуть ID, а не сирі вказівники; достатньо звичайних growable arrays, без власного bump allocator-а.

Typed IR використовує SSA-значення: параметри функції задають початкові значення, а кожна обчислювальна інструкція визначає рівно одне нове типізоване значення. Операнди посилаються на ці значення. Функції складаються з базових блоків, що завершуються термінаторами; phi зливає значення з попередніх блоків.

Typed IR має операції div і rem, базові блоки, phi та явні виняткові переходи. Їх наявність не додає відповідний синтаксис до мови автоматично: AST і семантичний аналіз окремо визначають, які вирази дозволені у вихідному коді. Типи IR isize та usize відповідають типам smalang int і uint; їхню ширину визначає target. Для Linux x86_64 це 64 біти. Фіксовані IR-типи зберігають заявлену ширину й знаковість. Виклик має типізовану ціль: у першому зрізі це посилання на функцію модуля, надалі його можна розширити на зовнішні символи. Посилання на ціль виклику — це ідентичність рівня модуля; правило SSA «визначено один раз» стосується значень усередині функції.

Typed IR має тип unit для єдиного значення без даних. Це повноцінне SSA-значення, а не відсутність результату: його можна використовувати як параметр і результат функції, результат звичайного ребра invoke, вхід phi та операнд return. Діагностичний дамп показує його створення як (%vN unit (unit)). Це не додає синтаксису None до мови; фронтенд може відобразити своє значення на unit, коли з'явиться відповідна мовна підтримка.

У CFG branch приймає bool або ціле значення: False і нуль ведуть до хибної гілки, True і будь-яке ненульове ціле — до істинної. Арифметичні IR-операції залишаються цілочисельними й не приймають bool. Кожен виклик у IR подається як invoke: його результат визначається на звичайному ребрі, а непрозоре значення винятку — на винятковому. Винятки не передаються звичайними аргументами або результатами функцій.

Typed IR перевіряється до передачі наступному етапу: кожне SSA-значення має бути визначене до використання, типи операндів і результатів операцій мають узгоджуватися з сигнатурами, а кожен блок у початковому зрізі завершується return.

(module
  (func @multiply (param %v0 isize) (param %v1 isize) (result isize)
    (block @entry
      (%v2 isize (mul %v0 %v1))
      (return %v2)))
  (func @increment (param %v0 isize) (result isize)
    (block @entry
      (%v1 isize (const 1))
      (%v2 isize (add %v0 %v1))
      (return %v2)))
  (func @main (result isize)
    (block @entry
      (%v0 isize (const 41))
      (invoke (call @increment %v0) (normal @normal %v1) (exception @unwind %v2)))
    (block @normal
      (return %v1))
    (block @unwind
      (resume %v2))))

Це діагностичний приклад форми дампу, а не окремий синтаксис чи формат обміну. IR має залишатися придатним до представлення майбутніх типізованих зовнішніх викликів; рішення ABI належать backend-у.

Runtime-винятки та ділення

IR і backend працюють із перевіреним IR. Відоме під час компіляції ділення на нуль відхиляє семантичний етап до генерації IR; runtime-контракт стосується нуля, виявленого під час виконання. Такий випадок породжує перехоплюваний внутрішній вид помилки DivisionByZero. Для кожного знакового цілого типу div округлює частку до нуля, а rem має знак діленого. Мінімальне значення типу, поділене на -1, дає це саме мінімальне значення, а остача дорівнює 0. Для беззнакових типів div і rem використовують беззнакову арифметику.

Початкова модель генерує винятки лише з runtime-перевірок; явного користувацького raise і нових мовних enum/class/struct для винятків немає. Види помилок є внутрішніми мітками runtime; IR може зіставляти їх за видом або приймати будь-який виняток. Потенційно виняткова операція, зокрема кожен виклик функції в першій версії, завершує блок через invoke із звичайним та винятковим наступниками. Обробник отримує непрозоре SSA-значення винятку; злиття значень задаються явними phi. Cleanup-блоки є частиною моделі IR. Якщо cleanup породжує новий виняток під час розкручування, він стає активним, а попередній зберігається як suppressed error.

Переповнення IR-операцій add, sub і mul визначене для всіх цілих типів. Результат обчислюється modulo 2^n, де n — ширина типу; для знакових типів молодші n біт інтерпретуються у двійковому доповняльному коді. Переповнення не породжує виняток. Для isize та usize ширину визначає target; на Linux x86_64 це 64 біти.

Винятки поширюються через приватний ABI згенерованих функцій: RAX повертає звичайне значення, RDX — exception pointer або нуль. invoke перевіряє RDX і переходить до звичайного чи виняткового наступника; resume повертає exception caller-у. IR явно передає ownership винятку через phi, dispatch і suppression; спійманий виняток звільняється через exception.release.

Backend генерує власні runtime-функції та ланцюг кадрів. Під час створення винятку runtime копіює до 64 кадрів із назвами функцій і source locations. Непійманий виняток друкується через Linux write; пам'ять exception records виділяється та звільняється через mmap/munmap. _start викликає __smalang_user_main і завершує процес через syscall exit. Згенерований об'єкт не потребує C runtime, libc, libgcc_s чи LLVM libunwind; .eh_frame і DWARF залишаються для звичайних налагоджувачів. Виняткова модель, IR і backend не додають синтаксис try/catch чи finally до мови.

Архітектурні рішення

Поки не визначено

  • Повний обсяг Python-подібного синтаксису поза початковою граматикою вище.
  • Оптимізація хвостових викликів і гарантія сталого використання стека для рекурсії.
  • C FFI: синтаксис, відповідність типів smalang типам C, оголошення зовнішніх символів і компонування бібліотек.

Backend формує ELF64 relocatable object files із власним _start і runtime-кодом. Системний ELF linker ld компонуватиме об'єкт без C compiler driver та runtime-бібліотек. Звичайні аргументи функцій для Linux x86_64 використовують System V AMD64 calling convention; приватний ABI Smalang додає результат винятку в RDX. Для майбутнього FFI IR має залишатися придатним до опису типізованих зовнішніх викликів, а ABI-логіка належатиме backend-у.

У приватному ABI Smalang цілі значення передаються у 64-бітних регістрах і слотах стека: знакові типи розширюються зі знаком, беззнакові — нулями. Результат повертається через RAX, а RDX зберігає домовленість про виняток. bool передається канонічним 0 або 1. Цей внутрішній ABI не є C ABI. C FFI, включно з адаптацією _Bool, залишається окремою майбутньою фічею.

У приватному ABI x86-64 значення unit має нульовий розмір: воно не займає регістр, стековий аргументний слот чи місце для результату. Такі параметри й аргументи опускаються, а решта зберігає відносний порядок. Для функції з результатом unit значення RAX не визначене; RDX і далі містить нуль при успіху або exception pointer. Точка входу main не може повертати unit, бо _start використовує результат main як код завершення процесу.

Backend приймає перевірений typed IR і сам формує ELF64 relocatable object; assembly для зовнішнього assembler-а не генерується. Розподіл SSA-значень використовує linear scan: значення отримують регістр або spill slot, а значення, живі через виклик, тимчасово зберігаються у стеку. Backend також відповідає за System V AMD64 аргументи, повернення, вирівнювання стеку й relocations для внутрішніх викликів. AST-to-IR lowering є окремим проходом; compiler CLI залишається окремою майбутньою фічею.

Roadmap

  • Develop the compiler in separate features: lexer → parser + AST → semantic analysis → typed IR → native backend.
Repository

smalang

Owner
Statistic
  • 0
  • 0
  • 0
  • 0
  • 0
  • about 3 hours ago
  • November 10, 2026
License

Links
Synced at

Tue, 10 Nov 2026 21:31:54 GMT

Languages