Export formats
An exporter is a function of the graph's JSON — the same document
Graph.to_json() writes — and nothing else. Every one streams to its output as
it goes, so a graph with a million nodes never exists a second time as a string,
and every one returns warnings: what the target format could not carry, or a
reason to doubt the result. callix formats lists them.
-f | Output | For |
|---|---|---|
lsif | LSIF 0.4.3, JSON Lines | code navigation: GitLab and other LSIF consumers |
scip | SCIP, protobuf | Sourcegraph and other SCIP consumers |
cyclonedx | CycloneDX 1.5 JSON | an SBOM of the declared dependencies |
csv-nodes | CSV, a row per node | spreadsheets, COPY into a database |
csv-edges | CSV, a row per relation, endpoints spelled out | the same |
dot | Graphviz | looking at calls, inheritance and type use |
mermaid | Mermaid flowchart | the same, in a Markdown page |
dot and mermaid draw the behavioural graph only — CALLS, INHERITS_FROM,
HAS_TYPE, REFERENCES — because CONTAINS and DECLARES turn any real
project into a hairball. --symbol draws only the edges touching one node.
Mermaid stops rendering past about 500 edges, and the exporter says so.
LSIF
LSIF is a graph of documents, ranges and results that answers LSP requests — go to definition, find references, hover — without a language server running. GitLab reads it as a code-navigation report.
| In the callix graph | In the LSIF dump |
|---|---|
| a file | document, with its languageId |
a definition's metadata["name_span"] | range — the identifier, not the whole extent |
a reference edge's metadata["span"] | range at the use-site |
| a Python or TypeScript module with a file | a definition: a zero-width range at 0:0 of its file |
| a definition, or an external symbol | resultSet |
| where it is defined, where it is referenced | definitionResult, referenceResult with item edges per document |
kind and qualified name, then the definition's metadata["doc"] | hoverResult |
| an external symbol | hoverResult named after the text at the site, (external, stdlib) |
| a named external symbol | moniker, kind import |
a Go package (MODULE with a name_span) | a definition at its package clause; qualifiers and import paths are references to it |
Declaration sites and includes (C and C++) are placed too. Each {file, span} entry of a node's metadata["declared_at"] becomes a reference range in its own file (in SCIP, an occurrence with the ForwardDefinition role, 0x40). A RESOLVES_TO that carries a span is placed like a reference, and a FILE it points at is defined at the first position of its document, so an #include operand navigates to the header.
Positions are converted properly: callix counts bytes from 1, LSIF wants UTF-16
units from 0. Give the exporter the sources (the directory you analysed, or
--source-root for a saved graph) and every column is exact; without them
columns are emitted as byte offsets, which is wrong on lines holding non-ASCII
text, and the exporter warns. A Python or TypeScript module with a file is a
definition at 0:0 of that file, and a RESOLVES_TO edge that records a position
is a reference.
What an LSIF dump cannot hold
These are limits of the graph, not bugs in the exporter, and each one produces a warning:
- No references without resolution. A reference is a resolver's answer. A
graph built with
--no-resolve, or whose toolchain was missing, exports its definitions only. - Hover is as good as the comments. A hover shows the symbol's kind and qualified name and, when the definition has one, the docstring or the comment block above it. Signatures are not kept, and a definition nobody documented has only its kind and name.
- One project per language. A polyglot tree is written as several LSIF
projectvertices, which is valid, but not every consumer accepts it. Index per language with-lif yours does not. - Degraded languages. PHP, C and C++ are resolved by a symbol table and the dump says so in its warnings; treat their references as approximate.
The structural rules a consumer relies on — ascending ids, edges that only point
backwards, item edges inside their document — are checked by the test suite
with callix_export::lsif::check.
SCIP
The same index in SCIP's shape — the protobuf successor of LSIF. One document per
file; each definition is an occurrence with its name range and its enclosing
extent, each reference an occurrence with a read or write role, and every symbol
defined in a file has a SymbolInformation there.
Positions are 0-based lines and UTF-8 byte offsets from the start of the line, which are callix's own columns, so no source files are needed and every column is exact.
A RESOLVES_TO edge that records a position (the name an import brings in) is exported as a reference, so a file that only re-exports still gets a document. The structural RESOLVES_TO of an import has no position and yields nothing.
A symbol reads callix . <project> . . <descriptor>: the qualified name as one
backtick-escaped descriptor, suffixed by what it is — # for a type, (). for a
callable, . for a term, (…) for a parameter. <project> is the sub-root
(PROJECT) the definition belongs to, so a workspace with several sub-roots keeps
same-named definitions apart; a definition no PROJECT contains uses the first one.
When two definitions would still share a symbol (C typedef struct S S is a class
and a type alias, both #), the class keeps the plain symbol and the type alias —
more generally the non-primary kind — is written under a <kind>/ namespace, e.g.
type_alias/S#. rust-analyzer's own symbols,
which the graph keeps verbatim for Rust's external references, are used as they
are, and the definitions callix indexes for the Rust fixture sit at exactly the
ranges rust-analyzer's index puts them (a test checks it against a real index).
A Go package is a definition too, at the identifier of its package clause in its
first non-test file; a package qualifier (util in util.Double) and an import
path are references to it.
Left out, with a warning: a reference whose target the resolver could not name
(an external symbol keyed by the call site it was met at) — it has no identity to
index. Documentation is the symbol's kind and qualified name, since the graph
keeps no docstrings. The output is binary; callix refuses to write it to a
terminal.
A Python or TypeScript module that has a file is a definition at 0:0 of that file
(the convention scip-python uses), so an empty __init__.py still gets its document.
A reference to a module, and the name an import brings in (a RESOLVES_TO edge that
records a position), are occurrences. A module without a file (a namespace package, a
directory without an index file) is not a definition, and a reference to it is left out.
TypeScript relative module strings and import and export specifiers appear as
occurrences too.
CycloneDX
The graph knows the dependencies a project declares: a name each, as its manifests list them. It records neither a version nor an ecosystem, so:
- components carry no
version, and the exporter warns; - the package-URL type comes from the source files when they are all one
language (
pkg:pypi/…,pkg:npm/…,pkg:golang/…,pkg:cargo/…,pkg:composer/…), and falls back topkg:generic/…with a warning when the tree mixes languages.
That makes it a faithful inventory of what is declared, not a vulnerability scan's input on its own. For one, point your scanner at the lockfile.