Extension de Catnip
Sommaire
- Ajouter une nouvelle opération
- 1. Définir l'opcode
- 2. Ajouter la règle de grammaire
- 3. Ajouter le transformer
- 4. Ajouter l'implémentation dans le Registry
- 5. Compiler et tester
- Ajouter un opcode VM
- 1. Définir l'opcode VM
- 2. Implémenter le dispatch
- 3. Ajouter au compiler
- Étendre le contexte
- Décorateurs
- @pure
- @pass_context
- Créer des passes d'optimisation
- 1. Créer le module
- 2. Enregistrer la passe
- Ajouter une commande CLI
- 1. Créer la commande
- 2. Enregistrer via entry points
- 3. Installer et utiliser
- Workflow de développement
- Structure des fichiers importants
- Écrire un module stdlib compilé
Guide pour ajouter des fonctionnalités à Catnip, sans se perdre dans les couches.
Ajouter une nouvelle opération
Pour ajouter une nouvelle opération au langage :
1. Définir l'opcode
Ajouter l'opcode dans catnip_core/src/ir/opcode.rs, la source de vérité. catnip_rs/src/ir/opcode.rs n'est qu'un
pub use : y écrire ne définit rien.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(i32)]
pub enum IROpCode {
// ... existing opcodes
MyOp = 99,
}
Puis régénérer le fichier Python :
python catnip_rs/gen_opcodes.py
2. Ajouter la règle de grammaire
Modifier catnip_grammar/grammar.js pour définir la syntaxe :
// Exemple : opérateur binaire ~~
my_op: $ => prec.left(PREC.my_op, seq(
field('left', $._expression),
'~~',
field('right', $._expression)
)),
// Ajouter dans _expression
_expression: $ => choice(
// ... existing choices
$.my_op,
),
Régénérer le parser :
make grammar-deps
3. Ajouter le transformer
Créer ou modifier un fichier dans catnip_core/src/parser/pure_transforms/, groupé par thème (operators.rs,
literals.rs, patterns.rs, ...). La transformation est du Rust pur, sans PyO3 : catnip_rs/src/parser/transforms.rs
est un wrapper qui délègue ici et convertit le résultat pour l'API Python.
pub(crate) fn transform_my_op(node: Node, source: &str) -> TransformResult {
let left = transform_node(node.child_by_field_name("left").unwrap(), source)?;
let right = transform_node(node.child_by_field_name("right").unwrap(), source)?;
Ok(IR::op(IROpCode::MyOp, vec![left, right]))
}
Enregistrer dans le dispatcher de catnip_core/src/parser/pure_transforms/mod.rs, qui aiguille sur le kind du nœud
tree-sitter :
"my_op" => transform_my_op(node, source),
4. Ajouter l'implémentation dans le Registry
Ajouter le handler dans catnip_rs/src/core/registry/ (nouveau module ou existant) :
// Dans arithmetic.rs ou nouveau fichier
impl Registry {
pub fn op_my_op(&self, py: Python, args: &Bound<PyTuple>) -> PyResult<PyObject> {
let left = self.exec_stmt(py, args.get_item(0)?)?;
let right = self.exec_stmt(py, args.get_item(1)?)?;
// Implémentation de l'opération
let result = my_implementation(left, right)?;
Ok(result.into_py(py))
}
}
Ajouter le dispatch dans try_rust_dispatch (execution.rs). La comparaison se fait contre les opcodes mis en cache à
la construction du registry (self.opcodes), pas contre l'enum : la valeur est lue une fois, pas à chaque opération.
} else if opcode == op.my_op {
return Ok(Some(self.op_my_op(py, args)?));
}
5. Compiler et tester
uv pip install -e .
make test
Ajouter un opcode VM
Pour ajouter un opcode au niveau bytecode de la VM :
1. Définir l'opcode VM
Dans catnip_core/src/vm/opcode.rs (catnip_rs/src/vm/opcode.rs en est un réexport) :
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum VMOpCode {
// ... existing opcodes
MyVMOp = 71,
}
Régénérer Python :
python catnip_rs/gen_opcodes.py
2. Implémenter le dispatch
Dans les deux moteurs : catnip_vm/src/vm/core/mod.rs (moteur pur) et catnip_rs/src/vm/core/mod.rs (moteur PyO3). Un
opcode qu'un seul des deux connaît fait échouer l'autre sur le même bytecode.
match opcode {
// ... existing cases
VMOpCode::MyVMOp => {
let arg = frame.pop()?;
let result = my_vm_operation(arg);
frame.push(result);
}
}
3. Ajouter au compiler
Dans catnip_vm/src/compiler/, découpé par construction (expr.rs, control_flow.rs, functions.rs, ...). C'est
le compilateur, pour les trois exécuteurs : catnip_rs/src/vm/compiler.rs délègue à UnifiedCompiler, qui ne sert
plus que de repli pour le littéral Decimal. Une émission écrite là-bas n'a, en pratique, aucun effet observable.
IR::Op { opcode: IROpCode::MyOp, args, .. } => {
self.compile_node(&args[0])?;
self.compile_node(&args[1])?;
self.emit(VMOpCode::MyVMOp, 0);
}
Étendre le contexte
Ajouter des fonctions ou variables globales disponibles dans Catnip :
from catnip import Catnip
from catnip.context import Context
# Créer un contexte personnalisé
ctx = Context()
# Ajouter une fonction Python
def my_func(x, y):
return x + y
ctx.globals['my_func'] = my_func
# Ajouter une constante
ctx.globals['PI'] = 3.14159
# Utiliser avec Catnip
cat = Catnip(context=ctx)
cat.parse('my_func(1, 2) + PI')
result = cat.execute() # 6.14159
Décorateurs
@pure
Marque une fonction comme pure (sans effets de bord) pour permettre des optimisations :
from catnip import pure
@pure
def square(x):
return x ** 2
ctx.globals['square'] = square
Les fonctions pures peuvent être optimisées par le broadcast et potentiellement mémoïsées.
@pass_context
Passe le contexte d'exécution comme premier argument :
from catnip import pass_context
@pass_context
def store(ctx, key, value):
ctx.globals[key] = value
return value
ctx.globals['store'] = store
Voir Étendre le contexte pour la surface complète de Context.
Créer des passes d'optimisation
Les passes vivent dans catnip_core/src/semantic/passes/ (pur Rust, sans PyO3) et opèrent directement sur l'enum IR.
Elles servent tous les pipelines (CLI Python, standalone, MCP, LSP).
1. Créer le module
Dans catnip_core/src/semantic/passes/my_pass.rs :
use super::PurePass;
use crate::ir::{IR, IROpCode};
pub struct MyPass;
impl PurePass for MyPass {
fn name(&self) -> &str {
"my_pass"
}
fn optimize(&mut self, ir: IR) -> IR {
match ir {
IR::Op { opcode: IROpCode::MyTargetOp, args, .. } => {
// Transformer le nœud (récurser sur les args d'abord)
todo!()
}
other => other,
}
}
}
Contrainte de correction : une passe ne doit réécrire que ce qu'elle peut prouver sans information de type. Dans un langage dynamique, la plupart des identités arithmétiques changent des valeurs observables (voir OPTIMIZATIONS, « Identités absentes par construction »). En cas de doute, laisser le nœud intact et écrire le test différentiel optimisé/non optimisé.
Le type ne suffit pas : une passe qui calcule doit aussi prouver que son arithmétique est celle du runtime. Deux
IR::Int sont bien deux entiers, mais la passe travaille sur i64 là où l'exécution promeut en entier long — d'où
checked_add et compagnie, et le refus de replier un décalage qui perd un bit. Le test qui le montre n'est pas un cas
de plus, c'est la comparaison des deux formes : la même expression écrite en littéraux passe par la passe, écrite
derrière des variables elle passe par le runtime, et les deux doivent rendre la même valeur.
2. Enregistrer la passe
Dans catnip_core/src/semantic/passes/mod.rs, déclarer le module et ajouter la passe à PureOptimizer::new() :
passes: vec![
// ... passes existantes
Box::new(MyPass),
],
Le PureOptimizer applique les passes en séquence jusqu'au point fixe (max 10 itérations). Les tests unitaires de la
passe vont dans le module lui-même (#[cfg(test)]), les tests end-to-end dans tests/optimization/ côté Python.
Ajouter une commande CLI
Les commandes CLI utilisent un système de plugins via entry points.
1. Créer la commande
# my_plugin/commands.py
import click
@click.command()
@click.argument('file')
def mycommand(file):
"""Ma commande personnalisée."""
click.echo(f"Processing {file}")
2. Enregistrer via entry points
Dans pyproject.toml du plugin :
[project.entry-points."catnip.commands"]
mycommand = "my_plugin.commands:mycommand"
3. Installer et utiliser
pip install my-plugin
catnip mycommand file.cat
Workflow de développement
# 1. Modifier le code Rust
vim catnip_rs/src/...
# 2. Tests Rust rapides
make test-rust-fast
# 3. Recompiler
uv pip install -e .
# 4. Tests Python complets
make test
# 5. Après modification de grammar.js
make grammar-deps
Structure des fichiers importants
catnip_core/src/ # Rust pur : ce qui décide
├── ir/opcode.rs # OpCodes IR (source de vérité)
├── vm/opcode.rs # OpCodes VM (source de vérité)
├── parser/pure_transforms/ # Transformations, groupées par thème
└── semantic/passes/ # Passes d'optimisation
catnip_vm/src/ # Moteur pur : ce qui exécute sans Python
├── compiler/ # LE compilateur bytecode
└── vm/core/mod.rs # Dispatch du moteur pur
catnip_rs/src/ # Pont PyO3 : ce qui parle à Python
├── ir/opcode.rs # Réexport de catnip_core
├── vm/opcode.rs # Réexport de catnip_core
├── parser/
│ ├── core.rs # TreeSitterParser principal
│ └── transforms.rs # Wrapper PyO3 de pure_transforms
├── vm/core/mod.rs # Dispatch du moteur PyO3
└── core/registry/
├── mod.rs # Registry struct
├── execution.rs # Dispatch principal
└── *.rs # Implémentations par catégorie
Écrire un module stdlib compilé
Pour ajouter un module Rust à la stdlib (catnip_libs/), voir le guide complet dans
catnip_libs/README.md. En bref :
catnip new-lib mylib génère le boilerplate, make gen-stdlib-registry met à jour les registres automatiquement.
Étendre Catnip revient à ajouter une pièce à un puzzle multi-couches. Il faut qu'elle s'emboîte partout : grammaire, transformation, sémantique, exécution. Si une couche refuse la pièce, tout casse.