📄 generateur_doc.py 🔒 1ba95a9d…bdc6df8d Se connecter pour télécharger ← Retour
#!/usr/bin/env python3
"""generateur-doc — génère un rapport structuré (fonctions/classes/méthodes,
signatures, résumé de docstring) à partir de code source Python ou Rust,
stdlib uniquement, zéro appel réseau.

Le contenu des fichiers analysés est toujours traité comme donnée inerte
(analyse syntaxique déterministe pour Python via `ast`, extraction par motif
texte pour Rust), jamais exécuté ni interprété comme instruction.
"""

import argparse
import ast
import json
import os
import re
import sys
from pathlib import Path

from erreurs import (
    ErreurAnalyseSyntaxique,
    ErreurCibleIntrouvable,
    ErreurCibleNonAccessible,
    ErreurEcritureRapport,
    ErreurInterne,
    ErreurLangageNonSupporte,
)

EXTENSIONS_SUPPORTEES = {".py", ".rs"}
DOSSIERS_IGNORES = {
    "target", "node_modules", ".git", "__pycache__", "dist", "build",
    "venv", ".venv", ".mypy_cache", ".pytest_cache", "vendor",
}

MOTIF_RUST_FN_DEBUT = re.compile(
    r"^\s*(?P<pub>pub(?:\([^)]*\))?\s+)?(?P<async>async\s+)?fn\s+(?P<nom>[A-Za-z_][A-Za-z0-9_]*)\s*\("
)
MOTIF_RUST_FN_SIGNATURE = re.compile(
    r"^\s*(?P<pub>pub(?:\([^)]*\))?\s+)?(?P<async>async\s+)?fn\s+(?P<nom>[A-Za-z_][A-Za-z0-9_]*)\s*"
    r"\((?P<args>.*)\)\s*(?P<retour>->\s*.+)?$"
)
MOTIF_RUST_STRUCT_ENUM = re.compile(
    r"^\s*pub\s+(?P<sorte>struct|enum)\s+(?P<nom>[A-Za-z_][A-Za-z0-9_]*)"
)
MOTIF_RUST_DOC = re.compile(r"^\s*///\s?(.*)$")
PLAFOND_LIGNES_SIGNATURE_MULTILIGNE = 30


def _position_fermeture_parenthese(texte: str) -> int:
    """Position du ')' qui referme le PREMIER '(' rencontré (profondeur 0),
    par comptage de profondeur caractère par caractère — pas une simple
    recherche de sous-chaîne, pour ne jamais confondre un ';' ou un '{'
    À L'INTÉRIEUR de la liste d'arguments (ex. `&[u8; 32]`, syntaxe de
    tableau Rust) avec le terminateur de la signature elle-même."""
    profondeur = 0
    for idx, car in enumerate(texte):
        if car == "(":
            profondeur += 1
        elif car == ")":
            profondeur -= 1
            if profondeur == 0:
                return idx
    return -1


def joindre_signature_multiligne(lignes: list, index_debut: int) -> tuple:
    """Rassemble une signature Rust étalée sur plusieurs lignes (patron très
    fréquent dans ce projet : `pub fn f(\\n    arg: T,\\n) -> R {`) en une
    seule chaîne. Deux phases distinctes et volontairement séparées :
    (1) accumuler des lignes jusqu'à ce que la liste d'arguments soit
    RÉELLEMENT fermée (comptage de profondeur), (2) chercher le `{`/`;`
    terminal UNIQUEMENT après cette fermeture — jamais avant, sinon un `;`
    interne à un type comme `&[u8; 32]` tronquerait la signature à tort
    (bug réel trouvé en validant ce skill sur security/csrf.rs de
    solivram-website). Limite explicite anti-boucle infinie
    (`PLAFOND_LIGNES_SIGNATURE_MULTILIGNE`) si jamais rien ne se referme
    (fichier tronqué/corrompu)."""
    texte = lignes[index_debut]
    i = index_debut
    pos_fermeture = _position_fermeture_parenthese(texte)
    while pos_fermeture == -1 and i + 1 < len(lignes) and (i - index_debut) < PLAFOND_LIGNES_SIGNATURE_MULTILIGNE:
        i += 1
        texte += " " + lignes[i].strip()
        pos_fermeture = _position_fermeture_parenthese(texte)

    if pos_fermeture == -1:
        return texte.strip(), i

    reste = texte[pos_fermeture + 1:]
    extra = 0
    while "{" not in reste and ";" not in reste and i + 1 < len(lignes) and extra < PLAFOND_LIGNES_SIGNATURE_MULTILIGNE:
        i += 1
        nouvelle_ligne = lignes[i].strip()
        texte += " " + nouvelle_ligne
        reste += " " + nouvelle_ligne
        extra += 1

    fin_reste = len(reste)
    for terminateur in ("{", ";"):
        idx = reste.find(terminateur)
        if idx != -1:
            fin_reste = min(fin_reste, idx)
    return texte[: pos_fermeture + 1 + fin_reste].strip(), i


def resume_docstring(docstring: str | None, longueur_max: int = 200) -> str:
    if not docstring:
        return ""
    premiere_ligne = docstring.strip().splitlines()[0].strip()
    if len(premiere_ligne) > longueur_max:
        premiere_ligne = premiere_ligne[: longueur_max - 3] + "..."
    return premiere_ligne


def signature_fonction(node) -> str:
    est_async = isinstance(node, ast.AsyncFunctionDef)
    cls = ast.AsyncFunctionDef if est_async else ast.FunctionDef
    stub = cls(
        name=node.name,
        args=node.args,
        body=[ast.Pass()],
        decorator_list=[],
        returns=node.returns,
        type_comment=None,
    )
    ast.fix_missing_locations(stub)
    try:
        texte = ast.unparse(stub)
    except (ValueError, TypeError):
        return f"{'async ' if est_async else ''}def {node.name}(...)"
    premiere_ligne = texte.splitlines()[0]
    return premiere_ligne.rstrip(":")


def analyser_python_source(source: str, chemin_affiche: str) -> dict:
    try:
        arbre = ast.parse(source, filename=chemin_affiche)
    except SyntaxError as e:
        raise ErreurAnalyseSyntaxique(chemin_affiche, str(e)) from e

    elements = []
    docstring_module = resume_docstring(ast.get_docstring(arbre))

    for node in arbre.body:
        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
            elements.append({
                "type": "fonction",
                "nom": node.name,
                "signature": signature_fonction(node),
                "resume": resume_docstring(ast.get_docstring(node)),
                "ligne": node.lineno,
            })
        elif isinstance(node, ast.ClassDef):
            methodes = []
            for sous_node in node.body:
                if isinstance(sous_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
                    methodes.append({
                        "type": "methode",
                        "nom": sous_node.name,
                        "signature": signature_fonction(sous_node),
                        "resume": resume_docstring(ast.get_docstring(sous_node)),
                        "ligne": sous_node.lineno,
                    })
            elements.append({
                "type": "classe",
                "nom": node.name,
                "signature": f"class {node.name}",
                "resume": resume_docstring(ast.get_docstring(node)),
                "ligne": node.lineno,
                "methodes": methodes,
            })

    return {"langage": "python", "resume_module": docstring_module, "elements": elements}


def analyser_rust_source(source: str) -> dict:
    lignes = source.splitlines()
    elements = []
    doc_accumulee = []
    i = 0
    while i < len(lignes):
        ligne = lignes[i]
        m_doc = MOTIF_RUST_DOC.match(ligne)
        if m_doc:
            doc_accumulee.append(m_doc.group(1))
            i += 1
            continue

        m_fn_debut = MOTIF_RUST_FN_DEBUT.match(ligne)
        if m_fn_debut and m_fn_debut.group("pub"):
            signature_brute, index_fin = joindre_signature_multiligne(lignes, i)
            m_fn = MOTIF_RUST_FN_SIGNATURE.match(signature_brute)
            if m_fn:
                signature = f"{'async ' if m_fn.group('async') else ''}fn {m_fn.group('nom')}({m_fn.group('args').strip()})"
                if m_fn.group("retour"):
                    signature += f" {m_fn.group('retour').strip()}"
                elements.append({
                    "type": "fonction",
                    "nom": m_fn.group("nom"),
                    "signature": signature,
                    "resume": " ".join(doc_accumulee).strip(),
                    "ligne": i + 1,
                })
            doc_accumulee = []
            i = index_fin + 1
            continue

        m_struct = MOTIF_RUST_STRUCT_ENUM.match(ligne)
        if m_struct:
            elements.append({
                "type": m_struct.group("sorte"),
                "nom": m_struct.group("nom"),
                "signature": f"pub {m_struct.group('sorte')} {m_struct.group('nom')}",
                "resume": " ".join(doc_accumulee).strip(),
                "ligne": i + 1,
            })
            doc_accumulee = []
            i += 1
            continue

        if ligne.strip():
            doc_accumulee = []
        i += 1

    return {"langage": "rust", "resume_module": "", "elements": elements}


def analyser_fichier(chemin: Path) -> dict:
    try:
        source = chemin.read_text(encoding="utf-8", errors="ignore")
    except OSError as e:
        raise ErreurCibleNonAccessible(f"{chemin}: {e}") from e

    if chemin.suffix == ".py":
        return analyser_python_source(source, str(chemin))
    if chemin.suffix == ".rs":
        return analyser_rust_source(source)
    raise ErreurLangageNonSupporte(f"extension non supportée : {chemin.suffix}")


def iter_fichiers_source(racine: Path):
    for dirpath, dirnames, filenames in os.walk(racine):
        dirnames[:] = [d for d in dirnames if d not in DOSSIERS_IGNORES]
        for nom in filenames:
            chemin = Path(dirpath) / nom
            if chemin.suffix in EXTENSIONS_SUPPORTEES:
                yield chemin


def generer_rapport_markdown(resultats: dict, erreurs_fichiers: list) -> str:
    lignes = ["# Rapport generateur-doc", ""]
    lignes.append(f"{len(resultats)} fichier(s) analysé(s) — {len(erreurs_fichiers)} erreur(s) de syntaxe.")
    lignes.append("")
    for chemin, analyse in sorted(resultats.items()):
        lignes.append(f"## `{chemin}` ({analyse['langage']})")
        if analyse["resume_module"]:
            lignes.append(f"> {analyse['resume_module']}")
        lignes.append("")
        for el in analyse["elements"]:
            lignes.append(f"### {el['type']} `{el['nom']}` (ligne {el['ligne']})")
            lignes.append(f"```\n{el['signature']}\n```")
            if el["resume"]:
                lignes.append(f"{el['resume']}")
            lignes.append("")
            for methode in el.get("methodes", []):
                lignes.append(f"- **{methode['nom']}** (ligne {methode['ligne']}) : `{methode['signature']}`")
                if methode["resume"]:
                    lignes.append(f"  {methode['resume']}")
            if el.get("methodes"):
                lignes.append("")
    if erreurs_fichiers:
        lignes.append("## Fichiers en erreur de syntaxe")
        lignes.append("")
        for err in erreurs_fichiers:
            lignes.append(f"- `{err['fichier']}` : {err['detail']}")
    return "\n".join(lignes)


def executer(cible_str: str, format_sortie: str, chemin_sortie: str | None) -> str:
    cible = Path(cible_str).resolve()
    if not cible.exists():
        raise ErreurCibleIntrouvable(f"{cible} n'existe pas")
    if not os.access(cible, os.R_OK):
        raise ErreurCibleNonAccessible(f"permission de lecture refusée sur {cible}")

    resultats = {}
    erreurs_fichiers = []

    if cible.is_file():
        analyse = analyser_fichier(cible)
        resultats[str(cible)] = analyse
    else:
        try:
            fichiers = list(iter_fichiers_source(cible))
        except PermissionError as e:
            raise ErreurCibleNonAccessible(str(e)) from e
        for chemin in fichiers:
            try:
                resultats[str(chemin)] = analyser_fichier(chemin)
            except ErreurAnalyseSyntaxique as e:
                erreurs_fichiers.append({"fichier": str(chemin), "detail": e.detail})
            except ErreurCibleNonAccessible:
                continue

    if format_sortie == "json":
        sortie = json.dumps(
            {"resultats": resultats, "erreurs_fichiers": erreurs_fichiers},
            ensure_ascii=False, indent=2,
        )
    else:
        sortie = generer_rapport_markdown(resultats, erreurs_fichiers)

    if chemin_sortie:
        try:
            Path(chemin_sortie).parent.mkdir(parents=True, exist_ok=True)
            Path(chemin_sortie).write_text(sortie, encoding="utf-8")
        except OSError as e:
            raise ErreurEcritureRapport(str(e)) from e
        return f"Rapport écrit : {chemin_sortie} ({len(resultats)} fichier(s), {len(erreurs_fichiers)} erreur(s))"

    return sortie


def main() -> int:
    parser = argparse.ArgumentParser(description="Générateur de documentation structurée — analyse statique locale, stdlib uniquement.")
    parser.add_argument("cible", type=str, help="Fichier (.py/.rs) ou dossier à analyser")
    parser.add_argument("--format", choices=["md", "json"], default="md")
    parser.add_argument("--out", type=str, default=None, help="Chemin de sortie (défaut : stdout)")
    args = parser.parse_args()

    try:
        resultat = executer(args.cible, args.format, args.out)
    except ErreurCibleIntrouvable as e:
        print(f"Erreur — cible introuvable : {e}", file=sys.stderr)
        return 1
    except ErreurCibleNonAccessible as e:
        print(f"Erreur — accès refusé : {e}", file=sys.stderr)
        return 2
    except ErreurLangageNonSupporte as e:
        print(f"Erreur — langage non supporté : {e}", file=sys.stderr)
        return 3
    except ErreurAnalyseSyntaxique as e:
        print(f"Erreur — syntaxe invalide : {e}", file=sys.stderr)
        return 4
    except ErreurEcritureRapport as e:
        print(f"Erreur — écriture du rapport impossible : {e}", file=sys.stderr)
        return 5
    except Exception as e:
        erreur_nommee = ErreurInterne(e)
        print(f"Erreur interne non catégorisée : {erreur_nommee}", file=sys.stderr)
        return 99

    print(resultat)
    return 0


if __name__ == "__main__":
    sys.exit(main())
13.2 Ko BLAKE3 : 1ba95a9d…bdc6df8d