Extension de Catnip

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.