tokensave internals: modello dati e pipeline di estrazione del grafo

Immagine di copertina

Questo è il secondo articolo della serie sull’architettura interna di tokensave. Nel primo articolo abbiamo visto cos’è un grafo semantico del codice e come è strutturata la pipeline ad alto livello. Qui entriamo nel codice Rust: come sono definiti i dati, come vengono estratti dai file sorgente e come viene mantenuta la sincronizzazione con il filesystem.


Il modello dei dati: nodi, archi e file

Il grafo ha tre entità principali, ognuna persistita in una tabella SQLite distinta.

Nodi (nodes)

Un nodo rappresenta una entità simbolica del codice. Ogni nodo ha:

  • id: hash deterministico content-addressed (SHA-256 di path + nome + tipo), che garantisce stabilità e deduplicazione
  • kind: il tipo del simbolo, modellato dall’enum NodeKind
  • Posizione sorgente: file, riga di inizio, riga di fine, colonna
  • Metadati: firma (signature), docstring, visibilità, flag is_async
  • Metriche di complessità: branch, loop, nesting massimo, blocchi unsafe, chiamate non verificate

L’enum NodeKind conta oltre 65 varianti per coprire i costrutti di tutti i linguaggi supportati:

pub enum NodeKind {
    // Universali
    File, Module, Function, Method, Field, Const, Static, TypeAlias,

    // OOP (Java, Kotlin, C#, Scala…)
    Class, Interface, Constructor, Annotation, InnerClass, AbstractMethod,

    // Rust-specifici
    Struct, Enum, EnumVariant, Trait, Impl, Macro,

    // Go-specifici
    InterfaceType, StructMethod, GoPackage, StructTag,

    // TypeScript/JavaScript
    ArrowFunction, Decorator, Export, Namespace,

    // C/C++
    Union, Typedef, Include, PreprocessorDef, Template,

    // … e molti altri (Dart, Pascal, Protobuf, GDScript…)
}

Lo schema SQL della tabella nodes riflette esattamente questa struttura:

CREATE TABLE nodes (
    id                  TEXT PRIMARY KEY,
    kind                TEXT NOT NULL,
    name                TEXT NOT NULL,
    qualified_name      TEXT NOT NULL,
    file_path           TEXT NOT NULL,
    start_line          INTEGER NOT NULL,
    end_line            INTEGER NOT NULL,
    start_column        INTEGER NOT NULL,
    end_column          INTEGER NOT NULL,
    docstring           TEXT,
    signature           TEXT,
    visibility          TEXT NOT NULL DEFAULT 'private',
    is_async            INTEGER NOT NULL DEFAULT 0,
    branches            INTEGER NOT NULL DEFAULT 0,
    loops               INTEGER NOT NULL DEFAULT 0,
    returns             INTEGER NOT NULL DEFAULT 0,
    max_nesting         INTEGER NOT NULL DEFAULT 0,
    unsafe_blocks       INTEGER NOT NULL DEFAULT 0,
    unchecked_calls     INTEGER NOT NULL DEFAULT 0,
    assertions          INTEGER NOT NULL DEFAULT 0,
    cognitive_complexity INTEGER NOT NULL DEFAULT 0,
    parent_id           TEXT,
    -- metriche Halstead
    distinct_operators  INTEGER NOT NULL DEFAULT 0,
    distinct_operands   INTEGER NOT NULL DEFAULT 0,
    total_operators     INTEGER NOT NULL DEFAULT 0,
    total_operands      INTEGER NOT NULL DEFAULT 0,
    updated_at          INTEGER NOT NULL
);

Archi (edges)

Un arco rappresenta una relazione tra due nodi. Il tipo di relazione è modellato dall’enum EdgeKind:

pub enum EdgeKind {
    Contains,      // un file/modulo contiene una funzione/classe
    Calls,         // una funzione chiama un'altra
    Uses,          // un simbolo referenzia un altro
    Implements,    // un tipo implementa un trait/interfaccia
    TypeOf,        // un campo/parametro ha un tipo
    Returns,       // una funzione restituisce un tipo
    DerivesMacro,  // una struct usa una macro derive (Rust)
    Extends,       // una classe eredita da un'altra
    Annotates,     // un'annotazione è applicata a un simbolo
    Receives,      // un metodo riceve un tipo come parametro
}

Lo schema SQL è semplice e ottimizzato per le traversate in entrambe le direzioni:

CREATE TABLE edges (
    id      INTEGER PRIMARY KEY AUTOINCREMENT,
    source  TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
    target  TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
    kind    TEXT NOT NULL,
    line    INTEGER  -- riga della call site (opzionale)
);

-- Indici covering per traversata forward e backward
CREATE INDEX idx_edges_source_kind ON edges(source, kind);
CREATE INDEX idx_edges_target_kind ON edges(target, kind);

File (files)

La tabella files tiene traccia dello stato di indicizzazione di ogni file sorgente:

CREATE TABLE files (
    path          TEXT PRIMARY KEY,
    content_hash  TEXT NOT NULL,  -- SHA-256 del contenuto
    size          INTEGER NOT NULL,
    modified_at   INTEGER NOT NULL,
    indexed_at    INTEGER NOT NULL,
    node_count    INTEGER NOT NULL DEFAULT 0
);

Il content_hash è il meccanismo chiave per la sincronizzazione incrementale: al prossimo tokensave sync, solo i file il cui hash è cambiato vengono ri-estratti.


Il parsing: tree-sitter come fondamenta

Il primo stadio della pipeline è il parsing del codice sorgente. tokensave delega questo lavoro a tree-sitter, una libreria di parsing incrementale che genera un Concrete Syntax Tree (CST) per oltre 100 linguaggi.

tree-sitter è stato scelto per diverse ragioni:

  1. Velocità: parser C generati a partire da grammatiche formali, compilati nell’eseguibile
  2. Robustezza agli errori: produce alberi parziali anche su codice sintaticamente incompleto
  3. Copertura linguistica: grammatiche disponibili per praticamente ogni linguaggio moderno
  4. Incrementalità: può aggiornare solo le parti dell’albero modificate tra una versione e l’altra del file

tokensave mantiene un registry degli estrattori linguistici, selezionando il parser corretto in base all’estensione del file:

src/extraction/
    rust_extractor.rs
    typescript_extractor.rs
    python_extractor.rs
    java_extractor.rs
    go_extractor.rs
    cpp_extractor.rs
    … (50+ estrattori)

Alcune grammatiche (COBOL e Protobuf) sono vendorate direttamente come sorgente C nella directory vendor/, compilate tramite build.rs, per via di incompatibilità di versione con i crate disponibili su crates.io.


Gli estrattori linguistici

Ogni estrattore ha la stessa firma:

XxxExtractor::extract_source(path: &str, source: &str) -> ExtractionResult

Internamente, l’estrattore:

  1. Costruisce il CST dal sorgente tramite tree-sitter
  2. Visita i nodi dell’albero con un walker ricorsivo
  3. Accumula simboli, relazioni e riferimenti non risolti in un ExtractionState
  4. Restituisce un ExtractionResult con nodi, archi e riferimenti da risolvere

L’architettura degli estrattori è stateless: ogni chiamata è indipendente e non ha effetti collaterali sul database, il che li rende facilmente parallelizzabili.

Durante l’estrazione vengono calcolate anche le metriche di complessità:

  • Complessità ciclomatica: branches + 1
  • Complessità cognitiva (stile SonarSource): pesata per profondità di annidamento
  • Metriche di Halstead: operatori e operandi distinti/totali, da cui si derivano lunghezza, vocabolario e sforzo del programma

La risoluzione dei riferimenti cross-file

Un estrattore può rilevare che la funzione foo() chiama bar(), ma se bar è definita in un altro file, l’arco Calls non può essere creato immediatamente: non si conosce ancora l’ID del nodo target.

Questo è il problema che risolve il modulo resolution/. Dopo che tutti i file sono stati estratti, il ReferenceResolver:

  1. Carica tutti i nodi in memoria
  2. Costruisce un indice nome → nodo
  3. Itera su tutti i riferimenti non risolti (unresolved_refs)
  4. Per ciascuno, cerca il nodo corrispondente nell’indice
  5. Se trovato, crea il corretto arco tipizzato (Calls, Uses, TypeOf, ecc.)

Questo secondo passaggio crea le connessioni cross-file che rendono il grafo davvero utile per analisi a livello di progetto.


La sincronizzazione incrementale

tokensave sync è ottimizzato per non ri-estrarre file non modificati. Il flusso è:

  1. Scansiona il filesystem (rispettando .gitignore)
  2. Calcola SHA-256 di ogni file trovato
  3. Confronta con gli hash in files
  4. Classifica ogni file in uno di tre insiemi:
    • New: presenti su disco ma non nel DB → estrazione completa
    • Modified: hash diverso → re-estrazione e sostituzione
    • Removed: nel DB ma non su disco → eliminazione di nodi e archi in cascade

Grazie al ON DELETE CASCADE sulle foreign key degli archi, eliminare un nodo rimuove automaticamente tutti i suoi archi.


Prossimo articolo

Ora che il grafo è costruito e popolato, il passo successivo è interrogarlo. Nel terzo articolo della serie vediamo come tokensave implementa gli algoritmi di traversata (BFS, Tarjan SCC), espone i tool via server MCP e costruisce il contesto per gli agenti AI.


Riferimenti

Conosci meglio chi ha scritto questo articolo

Serena Sensini

Ciao! Mi chiamo Serena Sensini e sono la creatrice di @ TheRedCode.it. Ho aperto questo blog nel 2021 per raccontare il mio lavoro e il mondo dell’informatica a parole semplici, in piccole pillole e alla portata di tutte le persone.

Sono un’ingegnera informatica specializzata in ambito AI & NLP. Di giorno lavoro come CTO @ Welyk e come Innovation & Emerging Technologies Leader @ Dedalus, mentre di notte scrivo e sono autrice di 5 libri -per ora-. 🖊️

Foto di Serena Sensini

Partners

Community, aziende e persone che supportano attivamente il blog

Vuoi diventare partner?

Collaboriamo con community, aziende e strumenti del mondo tech che vogliono raggiungere la nostra community di dev ed engineer italiani. Scrivici per proporre una collaborazione.

Vuoi diventare tech content creator? 🖊️

Se ti va di raccontare la tua esperienza nel mondo tech, questo è il posto giusto.

Cerchiamo voci autentiche, esempi pratici e punti di vista utili per chi legge.

Scrivici a collaborazioni[at]theredcode.it con una proposta: idea, taglio del contenuto e una breve presentazione. Non vediamo l'ora di leggere la tua esperienza!

Invia la tua idea