Syntax

Introduction

Catnip est un langage interprété pensé pour être simple, expressif et performant. Il combine une syntaxe claire avec des fonctionnalités modernes comme le pattern matching et les lambdas.

Caractéristiques principales

  • Syntaxe claire et concise : inspirée par des langages modernes
  • Typage dynamique : les types sont déterminés à l'exécution
  • Pattern matching : pour un code plus expressif et sûr
  • Fonctions de première classe : les fonctions sont des valeurs comme les autres
  • Performance : VM et JIT pour les workloads intensifs
  • REPL interactif : pour expérimenter et apprendre rapidement

Premiers pas

Premier programme

Catnip n'a jamais eu vocation à parler au monde.

Seulement à l'exécuter.

print("BORN TO SEGFAULT")

Types de données

Catnip supporte les types de données suivants :

Nombres

# Entiers
chaussette_gauche = 42
chaussette_droite = -17

# Flottants
pi = 3.14159
constante_de_cafe = 2.71828

# Notation scientifique
espace_infiniment_loin = 1.5e10

# Hexadécimal, binaire, octal
hex_val = 0xFF
bin_val = 0b1010
oct_val = 0o755

Chaînes de caractères

# Chaînes simples ou doubles guillemets
message = "BORN TO SEGFAULT"
name = 'Capitaine Whiskers'

# Chaînes multilignes
text = """
    Ceci est une chaîne
    sur plusieurs lignes
"""

Pas de concaténation implicite

Contrairement à Python, Catnip ne concatène pas automatiquement les chaînes adjacentes :

# Python : "hello" "world" → "helloworld"
# Catnip : erreur de syntaxe

# Utiliser l'opérateur + explicitement
message = "hello" + "world"

Pourquoi ce choix ?

La concaténation implicite est une source de bugs silencieux, notamment dans les listes :

# En Python, une virgule oubliée passe inaperçue :
items = [
    "foo",
    "bar"   # virgule oubliée
    "baz"
]
# items == ["foo", "barbaz"] - aucune erreur, bug silencieux

Forcer un opérateur explicite élimine cette catégorie de bugs. Une erreur de syntaxe visible vaut mieux qu'un comportement incorrect silencieux.

La concaténation implicite applique le principe du moindre effort au mauvais endroit : elle économise un caractère (+) mais coûte potentiellement des heures de débogage. Le ratio effort/bénéfice est défavorable d'un facteur d'environ 10⁴.

F-strings (chaînes interpolées)

Les f-strings permettent d'interpoler des expressions directement dans les chaînes, sans multiplier les concaténations.

Syntaxe : préfixer la chaîne avec f ou F (insensible à la casse) et utiliser {expression} pour insérer des valeurs.

# Variables simples
astronaute = "Léonie"
age = 30
f"Je m'appelle {astronaute} et j'ai {age} ans"
# → "Je m'appelle Léonie et j'ai 30 ans"

# Expressions arithmétiques
x = 10
y = 20
f"La somme de {x} et {y} est {x + y}"
# → "La somme de 10 et 20 est 30"

# Appels de fonction
double_burrito = (n) => { n * 2 }
n = 5
f"Le double de {n} est {double_burrito(n)}"
# → "Le double de 5 est 10"

# Échappements standards
f"Ligne 1\nLigne 2"  # Retour à la ligne
f"Colonne 1\tColonne 2"  # Tabulation

# Majuscules ou minuscules
F"Valeur: {42}"  # Identique à f"Valeur: {42}"

Note : les expressions dans les f-strings sont évaluées dans le scope courant. Les variables non définies ou les erreurs de syntaxe produisent des messages d'erreur clairs.

Spécificateurs de format

Les f-strings supportent les spécificateurs de format Python standard via la syntaxe {expression:format_spec}.

Nombres entiers :

n = 42
f"{n:05}"      # → "00042" (zero-padding sur 5 caractères)
f"{n:>10}"     # → "        42" (aligné à droite sur 10 caractères)
f"{n:<10}"     # → "42        " (aligné à gauche sur 10 caractères)
f"{n:^10}"     # → "    42    " (centré sur 10 caractères)
f"{n:x}"       # → "2a" (hexadécimal)
f"{n:b}"       # → "101010" (binaire)

Nombres flottants :

pi = 3.14159
f"{pi:.2f}"    # → "3.14" (2 décimales)
f"{pi:.4f}"    # → "3.1416" (4 décimales, arrondi)
f"{pi:8.2f}"   # → "    3.14" (largeur 8, 2 décimales)
f"{pi:e}"      # → "3.14159e+00" (notation scientifique)

Pourcentages :

ratio = 0.856
f"{ratio:.1%}" # → "85.6%" (pourcentage avec 1 décimale)
f"{ratio:.0%}" # → "86%" (pourcentage arrondi)

Alignement et remplissage :

text = "chat"
f"{text:>10}"  # → "      chat" (aligné à droite)
f"{text:*<10}" # → "chat******" (remplissage avec *)
f"{text:_^10}" # → "___chat___" (centré avec _)

Référence complète : tous les spécificateurs de la Format Specification Mini-Language Python sont supportés.

Conversion flags

Les flags !r, !s et !a appliquent une conversion avant le formatage :

  • !r appelle repr() sur la valeur (utile pour afficher les guillemets autour des chaînes)
  • !s appelle str() (comportement par défaut)
  • !a appelle ascii()
name = "Alice"
f"{name!r}"        # → "'Alice'"
f"{name!r:>15}"    # → "        'Alice'" (repr + alignement)
f"{42!s}"          # → "42" (conversion explicite en str)

Debug syntax

La syntaxe = après une expression affiche à la fois le code source et le résultat, ce qui facilite le débogage sans dupliquer le nom de la variable :

x = 42
f"{x=}"            # → "x=42"
f"{x=:.2f}"        # → "x=42.00" (debug + format)
f"{x=!r}"          # → "x=42" (debug + conversion)

a = 5
b = 3
f"{a + b=}"        # → "a + b=8" (fonctionne avec les expressions)

Limitations

Les f-strings imbriquées (nested f-strings) ne sont pas supportées. En Python 3.12+, les f-strings peuvent contenir d'autres f-strings grâce à la réécriture du parser en PEG récursif (PEP 701). Catnip utilise un parser Tree-sitter dont le tokenizer ne supporte pas la récursion à l'intérieur des interpolations.

# OK
f"{x:.2f}"

# Pas supporté
# f"{x:{'.2f' if precise else '.0f'}}"
# f"{f'{x}'}"

L'impossibilité technique d'imbriquer des f-strings dans des f-strings empêche aussi d'imbriquer cette note dans elle-même, ce qui est probablement une bonne chose.

Chaînes de bytes

Les chaînes de bytes utilisent le préfixe b et produisent un objet bytes Python :

# Bytes simples
data = b"hello world"
print(data)  # b'hello world'

# Conversion en string
text = data.decode("utf-8")
print(text)  # hello world

# Avec séquences d'échappement
binary = b"\x48\x65\x6c\x6c\x6f"  # Hello en hexadécimal
newlines = b"line1\nline2"

# Bytes multilignes
raw = b"""
binary
data
"""

# Utile pour orjson et lecture binaire
orjson = import("orjson")
json_bytes = orjson.dumps(dict(key="value"))  # Retourne bytes

Booléens

vrai = True
faux = False
rien = None

Listes

Catnip supporte les littéraux de listes avec la syntaxe list(…) :

# Liste vide
empty = list()

# Liste de nombres
scores_de_licorne = list(1, 2, 3, 4, 5)

# Liste de chaînes
crew = list("Alice", "Bob", "Charlie")

# Liste avec expressions
computed = list(1 + 1, 2 * 3, 10 / 2)  # list(2, 6, 5.0)

# Listes imbriquées
matrix = list(
    list(1, 2, 3),
    list(4, 5, 6),
    list(7, 8, 9)
)

# Avec virgule finale (optionnel)
items = list(1, 2, 3,)

# Accès aux éléments (via __getitem__)
first = scores_de_licorne[0]  # 1, comme scores_de_licorne.__getitem__(0)
last = scores_de_licorne[2]   # 3
last = scores_de_licorne[-1]  # 5

# Itération
for n in scores_de_licorne {
    print(n)
}

# Avec fonctions Python
total = sum(list(1, 2, 3, 4, 5))   # 15
length = len(crew)                 # 3

Note : La syntaxe list(…) évite la confusion avec la notation de broadcast .[…].

Sets

Les sets sont des collections non ordonnées sans répétition, ils utilisent la syntaxe set(…) :

# Set vide
empty = set()

# Set avec valeurs
numbers = set(1, 2, 3, 4, 5)

# Les doublons sont automatiquement supprimés
unique = set(1, 2, 2, 3, 3, 3)  # {1, 2, 3}

# Opérations sur les sets (via Python)
a = set(1, 2, 3, 4)
b = set(3, 4, 5, 6)
union = a.union(b)           # {1, 2, 3, 4, 5, 6}
intersection = a.intersection(b)  # {3, 4}
difference = a.difference(b)      # {1, 2}

Dictionnaires

Les dictionnaires supportent deux notations : paires (clé, valeur) et kwargs clé=valeur.

# Dictionnaire vide
empty = dict()

# Notation kwargs (clés string implicites)
pirate = dict(name="Capitaine Whiskers", age=7, city="Paris")

# Notation paires (clés arbitraires)
mapping = dict((1, "un"), (2, "deux"), (3, "trois"))

# Mixte : paires et kwargs dans le même appel
mixed = dict((1, "un"), name="Alice", (2, "deux"))

# Valeurs calculées
stats = dict(sum=1 + 2 + 3, product=2 * 3 * 4)

# Structures imbriquées
data = dict(
    numbers=list(1, 2, 3),
    info=dict(x=10, y=20)
)

# Accès aux valeurs (via __getitem__)
nom_capitaine = pirate.__getitem__("name")  # "Capitaine Whiskers"

# Avec virgule finale (optionnel)
config = dict(debug=True, port=8080,)

Note : La syntaxe dict(...) utilise des paires ou des kwargs car {…} est réservé pour les blocs de code. Les kwargs convertissent l'identifiant en clé string au parse time.

Autres collections (via Python)

# Tuples
coords_lune = tuple(10, 20)

# Sets
unique = set(1, 2, 2, 3, 3, 3)

# Ranges
numbers = list(range(1, 10))

Topos ND (@[])

@[] est un singleton vide utilisé par les opérateurs ND. Il est falsy, itérable vide, et sa longueur vaut 0.

empty = @[]
len(empty)         # 0
list(empty)        # list()
if empty { 1 } else { 2 }  # 2

Variables et assignation

Assignation simple

x = 10
nom = "Alice"
actif = True

Assignation en chaîne

# Assigner la même valeur à plusieurs variables
a = b = c = 42

Référence à la dernière valeur

x = 10
y = 20
x + y        # Résultat: 30
print(_)     # _ contient la dernière valeur calculée: 30

Affectation d'attributs

types = import("types")

obj = types.SimpleNamespace()
obj.x = 10
obj.y = 20

# Chaînes d'attributs
obj.inner = types.SimpleNamespace()
obj.inner.value = 100

Affectation par index

# Dictionnaires
d = dict()
d["name"] = "Alice"
d["age"] = 30

# Listes
items = list(0, 0, 0)
items[0] = 1
items[2] = 3

L'affectation par index et par attribut transforme obj.attr = v en setattr(obj, "attr", v) et obj[k] = v en obj.__setitem__(k, v). Ce qui signifie que tout objet exposant ces méthodes devient automatiquement mutable depuis Catnip.


Séparateurs de Statements

Catnip supporte deux types de séparateurs pour délimiter les statements :

Newlines (retours à la ligne)

Les newlines sont significatifs et séparent automatiquement les statements :

# ✓ Chaque ligne est un statement séparé
x = 1
y = 2
z = x + y

Cas spéciaux : Les newlines ne sont PAS significatifs dans :

# Arguments de fonction
result = max(10,
    20,
    30)  # ✓ OK - newlines ignorés

# Listes et collections
values = list(1,
    2,
    3)  # ✓ OK - newlines ignorés

# Blocs
x = {
    a = 1
    b = 2
    a + b
}  # ✓ OK - newlines significatifs DANS le bloc, ignorés autour des {}

# if/else multilignes
result = if condition {
    42
}
else {
    0
}  # ✓ OK - newline avant 'else' non significatif

Semicolons (;)

Les semicolons permettent de séparer explicitement les statements sur une même ligne :

# ✓ Plusieurs statements sur une ligne
x = 1; y = 2; z = x + y

Combinaison : On peut mélanger semicolons et newlines :

# ✓ Mix semicolons et newlines
x = 1; y = 2
z = x + y
result = z * 2; print(result)

Séparateurs multiples : Les séparateurs consécutifs sont autorisés :

# ✓ OK - semicolon suivi de newline
x = { 42 };
y = 1

# ✓ OK - newlines multiples
x = 1

y = 2

Les semicolons sont des points de suture syntaxique. On peut en mettre plusieurs d'affilée si on aime vraiment la redondance, un peu comme mettre deux pansements sur la même coupure. Ça ne fait pas de mal, c'est juste une preuve de prudence excessive.


Structures

Le mot-clé struct permet de déclarer une structure nommée avec des champs :

struct Point { x, y }

Les structures créent des types de données personnalisés avec des champs nommés. Une fois déclarées, elles peuvent être instanciées comme des fonctions :

# Déclaration
struct Point { x, y }

# Instanciation avec arguments positionnels
p1 = Point(10, 20)

# Instanciation avec arguments nommés
p2 = Point(x=5, y=15)

# Accès aux attributs
print(p1.x)  # 10
print(p2.y)  # 15

Caractéristiques

Les structures sont implémentées en utilisant les dataclasses Python, ce qui leur confère plusieurs propriétés :

  • Attributs mutables : les champs peuvent être modifiés après création
  • Représentation automatique : str() et repr() affichent la structure avec ses valeurs
  • Égalité structurelle : deux instances avec les mêmes valeurs sont considérées égales
  • Validation des arguments : erreurs claires si arguments manquants ou en trop
struct Color { r, g, b }

# Mutation
c = Color(255, 0, 0)
c.g = 128
print(c)  # Color(r=255, g=128, b=0)

# Égalité
c1 = Color(100, 100, 100)
c2 = Color(100, 100, 100)
print(c1 == c2)  # True

Valeurs par défaut

Les champs de structure supportent des valeurs par défaut, avec la même syntaxe que les paramètres de fonctions :

struct Point { x, y = 0 }

Point(5)        # ⇒ Point(x=5, y=0)
Point(1, 2)     # ⇒ Point(x=1, y=2)
Point(x=3)      # ⇒ Point(x=3, y=0)

Les champs sans défaut doivent précéder ceux avec défaut :

struct Config { host, port = 8080, debug = False }

Config("localhost")              # ⇒ Config(host="localhost", port=8080, debug=False)
Config("0.0.0.0", 3000, True)   # ⇒ Config(host="0.0.0.0", port=3000, debug=True)

Si tous les champs ont un défaut, l'instanciation sans argument est possible :

struct Opts { verbose = False, retries = 3 }
Opts()  # ⇒ Opts(verbose=False, retries=3)

Un champ sans défaut placé après un champ avec défaut provoque une erreur de parsing. Le formulaire d'inscription des champs est strict sur l'ordre de passage.

Structures complexes

Les champs peuvent contenir n'importe quel type de valeur :

struct Container { data, metadata }

c = Container(
    list(1, 2, 3),
    dict(name="test", version=1)
)

print(c.data[0])           # 1
print(c.metadata["name"])  # "test"

Structures multiples

On peut définir plusieurs structures dans le même programme :

struct Vector2D { x, y }
struct Particle { position, velocity, mass }

v = Vector2D(10, 20)
p = Particle(
    Vector2D(0, 0),
    Vector2D(5, 10),
    1.5
)

print(p.velocity.x)  # 5

Méthodes

Les structures peuvent définir des méthodes inline avec un paramètre self explicite :

struct Point {
    x, y

    distance(self, other) => {
        sqrt((self.x - other.x) ** 2 + (self.y - other.y) ** 2)
    }

    translate(self, dx, dy) => {
        Point(self.x + dx, self.y + dy)
    }
}

a = Point(0, 0)
b = Point(3, 4)
print(a.distance(b))       # 5.0
print(a.translate(1, 2))   # Point(x=1, y=2)

Les méthodes sont déclarées après les champs, avec la syntaxe nom(self, ...) => { corps }. Le premier paramètre (self) est lié automatiquement à l'instance lors de l'appel, via le protocole descripteur Python (__get__).

Les méthodes respectent la portée lexicale: elles peuvent capturer des variables locales du scope englobant.

make_point_type = () => {
    offset = 10
    struct Point {
        x
        shifted(self) => { self.x + offset }
    }
    Point
}

P = make_point_type()
P(3).shifted()   # 13

Les méthodes sont techniquement des lambdas qui ont réussi à se faire embaucher comme employés permanents de la structure. Le self est leur badge d'accès.

Héritage

Les structures supportent l'héritage simple via extends(Base). L'enfant hérite des champs et méthodes du parent :

struct Point {
    x, y
    sum(self) => { self.x + self.y }
}

struct Point3D extends(Point) {
    z
    volume(self) => { self.x * self.y * self.z }
}

p = Point3D(1, 2, 3)
p.x         # 1 (hérité de Point)
p.z         # 3 (défini dans Point3D)
p.sum()     # 3 (méthode héritée de Point)
p.volume()  # 6 (méthode de Point3D)

Règles d'héritage :

  • Les champs de l'enfant sont ajoutés après ceux du parent
  • Redéfinir un champ hérité provoque une erreur
  • Les méthodes de l'enfant peuvent remplacer (override) celles du parent
  • L'ordre des paramètres au constructeur suit l'ordre des champs : parent puis enfant
struct Base {
    x
    value(self) => { self.x }
}

struct Child extends(Base) {
    value(self) => { self.x * 10 }  # override
}

Base(5).value()   # 5
Child(5).value()  # 50

L'héritage fonctionne avec les valeurs par défaut. Les champs avec défaut du parent sont conservés :

struct Config {
    host, port = 8080
}

struct SecureConfig extends(Config) {
    ssl = True
}

SecureConfig("localhost")  # host="localhost", port=8080, ssl=True

Tenter d'hériter d'une structure inexistante provoque une erreur à l'exécution :

struct Child extends(Unknown) { x }  # RuntimeError: unknown base struct 'Unknown'

L'héritage suit le principe de subsidiarité administrative : l'enfant hérite de tout le dossier du parent, peut y ajouter des pièces, mais ne peut pas modifier les formulaires existants. Seules les annotations (méthodes) peuvent être révisées.


Exemples complets

Calcul de factorielle

fn factorielle(n) {
    match n {
        0 | 1 => { 1 }
        n => {
            resultat = 1
            i = 2
            while i <= n {
                resultat = resultat * i
                i = i + 1
            }
            resultat
        }
    }
}

print("10! =", factorielle(10))

Nombres de Fibonacci

fn fibonacci(n) {
    match n {
        0 => { 0 }
        1 => { 1 }
        n => {
            a = 0
            b = 1
            i = 2
            while i <= n {
                temp = a + b
                a = b
                b = temp
                i = i + 1
            }
            b
        }
    }
}

# Afficher les 10 premiers nombres de Fibonacci
for i in range(10) {
    print("fib(", i, ") =", fibonacci(i))
}

Tri à bulles

fn trier_bulles(liste) {
    n = len(liste)
    i = 0
    while i < n - 1 {
        j = 0
        while j < n - i - 1 {
            if liste.get(j) > liste.get(j + 1) {
                # Échanger
                temp = liste.get(j)
                liste.set(j, liste.get(j + 1))
                liste.set(j + 1, temp)
            }
            j = j + 1
        }
        i = i + 1
    }
    liste
}

nombres = list([64, 34, 25, 12, 22, 11, 90])
trie = trier_bulles(nombres)
print("Liste triée:", trie)

Calculatrice simple

fn calculer(operation, a, b) {
    match operation {
        "+" => { a + b }
        "-" => { a - b }
        "*" => { a * b }
        "/" => {
            match b {
                0 => { print("Erreur: division par zéro"); None }
                _ => { a / b }
            }
        }
        op => {
            print("Opération inconnue:", op)
            None
        }
    }
}

resultat = calculer("+", 10, 5)   # 15
resultat = calculer("*", 7, 6)    # 42
resultat = calculer("/", 10, 0)   # Erreur: division par zéro

FizzBuzz

fn fizzbuzz(n) {
    for i in range(1, n + 1) {
        match i {
            i if i % 15 == 0 => { print("FizzBuzz") }
            i if i % 3 == 0 => { print("Fizz") }
            i if i % 5 == 0 => { print("Buzz") }
            i => { print(i) }
        }
    }
}

fizzbuzz(20)

Conventions et bonnes pratiques

Nommage

# Variables et fonctions : snake_case
ma_variable = 42
ma_fonction = (x) => { x * 2 }

# Constantes : MAJUSCULES (convention, pas imposé)
PI = 3.14159
MAX_VALEUR = 100

Commentaires

# Commentaire sur une ligne

# Commentaires
# sur plusieurs
# lignes

Organisation du code

# 1. Constantes en haut
MAX_ITERATIONS = 1000
SEUIL = 0.001

# 2. Définitions de fonctions
fn fonction_helper() {
    # 
}

fn fonction_principale() {
    # 
}

# 3. Code principal
resultat = fonction_principale()
print(resultat)

Astuces et pièges à éviter

Portée des variables

Les variables dans les blocs ne sont pas accessibles à l'extérieur.

{
    x = 10
}
print(x)
 Error: Unknown identifier 'x'

Si tu veux qu'une variable survive, déclare-la avant:

x = 0
{
    x = 10  # réassigne la variable du scope parent
}
print(x)  # → 10

Toutes les structures de contrôle créent un scope local (if, while, for, {}):

for i in range(5) {
    # i appartient au scope interne créé par le for
    
}
# i n'existe plus ici
print(i)
 Error: Unknown identifier 'i'

Note technique: Les blocs et boucles appellent ctx.push_scope() avant d'exécuter le corps, ce qui isole les variables locales. Contrairement à Python où les variables "fuient" dans le scope parent (comportement historique controversé), Catnip applique le principe de moindre surprise: une variable locale reste locale.

Checkpoint atteint. Les variables locales ne te suivront pas.

Évaluation court-circuit

# AND s'arrête au premier False
resultat = False and fonction_couteuse()  # fonction_couteuse() n'est PAS appelée

# OR s'arrête au premier True
resultat = True or fonction_couteuse()    # fonction_couteuse() n'est PAS appelée

Match exhaustif

# Toujours prévoir un cas par défaut
match valeur {
    1 => { "un" }
    2 => { "deux" }
    _ => { "autre" }  # IMPORTANT : évite les cas non gérés
}

Annexes

Priorité des opérateurs

du plus fort au plus faible

Opérateur Description
() Parenthèses
** Exponentiation
+x, -x, ~x Unaires
*, /, //, % Multiplication, division
+, - Addition, soustraction
& AND binaire
^ XOR binaire
` `
<, <=, >, >=, ==, != Comparaisons
not NOT logique
and AND logique
or OR logique