Skip to main content

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.

-fOutputFor
lsifLSIF 0.4.3, JSON Linescode navigation: GitLab and other LSIF consumers
scipSCIP, protobufSourcegraph and other SCIP consumers
cyclonedxCycloneDX 1.5 JSONan SBOM of the declared dependencies
csv-nodesCSV, a row per nodespreadsheets, COPY into a database
csv-edgesCSV, a row per relation, endpoints spelled outthe same
dotGraphvizlooking at calls, inheritance and type use
mermaidMermaid flowchartthe 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 graphIn the LSIF dump
a filedocument, 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 filea definition: a zero-width range at 0:0 of its file
a definition, or an external symbolresultSet
where it is defined, where it is referenceddefinitionResult, referenceResult with item edges per document
kind and qualified name, then the definition's metadata["doc"]hoverResult
an external symbolhoverResult named after the text at the site, (external, stdlib)
a named external symbolmoniker, 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 project vertices, which is valid, but not every consumer accepts it. Index per language with -l if 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 to pkg: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.