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 :
!rappellerepr()sur la valeur (utile pour afficher les guillemets autour des chaînes)!sappellestr()(comportement par défaut)!aappelleascii()
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 = vensetattr(obj, "attr", v)etobj[k] = venobj.__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()etrepr()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
selfest 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 |