Skip to content

Writing a plugin

Language support in codaviz is a pluggy plugin system with exactly one extension point. A plugin returns language analyzers; an analyzer turns one source file into module metrics plus a list of functions.

Everything downstream (the entity model, aggregation, treemap, drill-down, exports) is language-agnostic and consumes only Entity and Metrics. The analyzer is the whole language-specific surface.

The hook

# codaviz/plugins/hookspecs.py
hookspec = pluggy.HookspecMarker("codaviz")


@hookspec
def codaviz_register_analyzers() -> list[Analyzer] | None:
    """Return the language analyzer instances this plugin provides."""

It is called once at startup. Results from every plugin are aggregated and mapped by file extension. A plugin that contributes nothing may return None.

Registering

Add an entry point in the codaviz group:

# your plugin's pyproject.toml
[project.entry-points.codaviz]
my-plugin = "my_pkg.plugin"

and decorate the hook implementation:

# my_pkg/plugin.py
from codaviz.plugins import hookimpl   # or pluggy.HookimplMarker("codaviz")

@hookimpl
def codaviz_register_analyzers():
    return [MyAnalyzer()]

First-party analyzers register in-tree instead of through entry points. The round trip is pure indirection for something shipped in the same package, and it turns editable installs into a reinstall footgun. They also register last, so pluggy's LIFO ordering calls them first and they win extension ties against third-party plugins.

To claim an extension a first-party analyzer already owns, ship your own build.

The Analyzer contract

Subclass codaviz.analyzers.Analyzer and override analyze:

from codaviz.analysis.complexity import FunctionInfo
from codaviz.analyzers import Analyzer
from codaviz.model import Metrics


class MyAnalyzer(Analyzer):
    name = "mylang"
    extensions = frozenset({".ml"})

    def analyze(self, source: str, file: str) -> tuple[Metrics, list[FunctionInfo]]:
        ...
Member Purpose
name Identifier, used in diagnostics.
extensions The file suffixes this analyzer claims.
protocol_version Inherited. Leave it alone unless you pin it on purpose.
analyze(source, file) The method everything else calls.
hierarchy(rel, source) How a file maps onto the package/module tree. Defaulted.

source is the already-decoded text. file is the absolute path, kept for analyzers that need it (tree-sitter grammar selection by extension, for instance) even though the Python analyzer reads only source.

What you return

Metrics has four optional fields: sloc, cc, cognitive, mi. Leaving one None is how an analyzer says "I do not compute this"; there is no capability flag. Unset metrics render as "no data" in the report instead of as zero.

FunctionInfo is a frozen dataclass with fields name (qualified, e.g. Widget.render), lineno, endline, complexity (cyclomatic), and cognitive (or None).

Module-level metrics go in the returned Metrics, per-function ones in each FunctionInfo.

The hierarchy

The default hierarchy reproduces the path-based scheme: one package per ancestor directory, module id = the source-root-relative POSIX path.

Override it for languages whose namespace is declared in source and not implied by the filesystem:

from codaviz.analyzers import Container, Hierarchy

def hierarchy(self, rel: Path, source: str) -> Hierarchy:
    return Hierarchy(
        module_id="...",     # unique id for this file's module entity
        module_name="...",   # short display name
        parent_id="...",     # id of the direct container, or None
        containers=[         # ancestor chain, outermost first
            Container(id="a", name="a", parent_id=None),
            Container(id="a/b", name="b", parent_id="a"),
        ],
    )

The analyzer owns id construction because the cross-root merge keys on those ids: two distributions contributing to the same namespace package must produce the same container id, or they will not combine.

Tree-sitter analyzers

If your language has a tree-sitter grammar in tree-sitter-language-pack, you write no parsing code at all. Subclass TreeSitterAnalyzer, declare which node kinds count, and the base class does the rest:

from codaviz.analyzers.treesitter import TreeSitterAnalyzer
from codaviz.plugins import hookimpl


class LuaAnalyzer(TreeSitterAnalyzer):
    name = "lua"
    grammar_by_ext = {".lua": "lua"}
    function_kinds = frozenset({
        "function_declaration",
        "function_definition",
    })
    decision_kinds = frozenset({
        "if_statement",
        "elseif_statement",
        "for_statement",
        "while_statement",
        "repeat_statement",
    })
    bool_operators = frozenset({"and", "or"})


@hookimpl
def codaviz_register_analyzers():
    return [LuaAnalyzer()]

The base class computes SLOC and per-function cyclomatic (1 + decision points, without descending into nested functions) and leaves MI and cognitive unset. extensions is derived from grammar_by_ext, so you do not set it.

Configuration attributes

Attribute Meaning
grammar_by_ext File suffix → grammar name, e.g. {".ts": "typescript"}. One analyzer can span several grammars.
function_kinds Node kinds that are function-like. These become leaf entities.
decision_kinds Node kinds worth +1 cyclomatic.
bool_operators Operator strings worth +1, e.g. {"&&", "||"}.
binary_kind Kind of the binary-expression node whose operator field is matched against bool_operators. Default "binary_expression"; Ruby uses "binary".
name_parent_fields Parent kind → field holding an anonymous function's inferred name, e.g. {"variable_declarator": "name"}. Falls back to (anonymous).
require_named_decisions Default True. See below.

require_named_decisions

Some grammars reuse a keyword's text as the kind of both the construct node and its leading keyword token: Ruby's if is both an if construct and an if token. Only the construct is is_named, so requiring named nodes stops the token double-counting.

Other grammars go the other way: Java's case decision node is an unnamed token. Those analyzers set require_named_decisions = False.

Get this wrong and every branch counts twice, or none of them count. Write a test with a hand-computed expected value; every first-party tree-sitter analyzer has one in tests/a_unit/.

Picking node kinds

The reliable method is to dump the tree for a small file and read the kinds:

import tree_sitter_language_pack as tlp

tree = tlp.get_parser("lua").parse(open("sample.lua").read())

def dump(node, depth=0):
    print("  " * depth, node.kind(), node.is_named())
    for i in range(node.child_count()):
        dump(node.child(i), depth + 1)

dump(tree.root_node())

Watch the is_named() column: it decides require_named_decisions.

Failure handling

The plugin manager forgives at load time. An analyzer whose protocol_version does not match codaviz's, or that raises while being mapped, prints a notice on stderr and is skipped. One broken plugin does not abort the run.

Per-file failures are contained the same way: a file that cannot be decoded as UTF-8, or an analyze call that raises, is reported on stderr and skipped. The module is kept, its metrics empty.

PROTOCOL_VERSION is bumped when the Analyzer contract changes. A third-party analyzer declaring a different value is skipped instead of silently corrupting the output, so pin your dependency on codaviz, and bump on purpose.

What a plugin cannot do

The boundary is file-granular by design: one file in, metrics plus leaves out. A plugin does not build the treemap, aggregate packages, format exports, attach source snippets, or run its own cycle detection. Those stay shared.

Circular-import detection is currently Python-specific and outside the analyzer contract; a per-language cycle hook is a plausible extension, but there is none today.