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:
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.