orchestrata_ultimate.refactor.ctok.node_index

Reference › orchestrata_ultimate › refactor › ctok › node_index

NodeIndex connects the ast view of a file to its byte-level tokens, so that an edit can be aimed at the exact characters of a syntax node. It is built over a TokenStream by build(tree, stream) and used by lossless_module (REQ-110-123).

Construction converts the line and column offsets that ast reports into byte offsets using a table of line starts, and indexes the tokens by start and end offset. span_for(node) returns a Token covering the node's full source range, tagged with the kind of its first token. token_for_name(node) returns the single name token that is a rename target: the identifier for a Name, the trailing attribute name for an Attribute, and for a function, async function or class the name after the leading keywords (it skips both async and def). A node without location data, or whose start does not fall on a token boundary, raises NodeSpanError; an unsupported node type raises TypeError.

At a glance

Source src/orchestrata_ultimate/refactor/ctok/node_index.py
Lines of code 253
Classes 2
Functions 2
Imports (internal) orchestrata_ultimate.core.base.traces, orchestrata_ultimate.refactor.ctok.token_stream, orchestrata_ultimate.refactor.ctok.tokenizer
Imported by orchestrata_ultimate.refactor.ctok, orchestrata_ultimate.refactor.ctok.lossless_module
Tests that import it tests/refactor/codemod/test_transactional_writeback.py, tests/refactor/ctok/test_node_index.py

Class diagram

Class diagram orchestrata_ultimate.refactor.ctok.node_index

Dependency diagram

Dependency diagram orchestrata_ultimate.refactor.ctok.node_index

API

Constants

Name Type Value
_KEYWORD_NAMED_TYPES tuple[type[ast.AST], ...] (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)

Classes

NodeSpanError

Bases: ValueError · Decorators: traces(spec='110', story='US-110-009', reqs=['REQ-110-123']) · Line 48

Raised when an ast node has no locatable token span.

NodeIndex (dataclass)

Decorators: traces(spec='110', story='US-110-009', reqs=['REQ-110-123']), dataclass(frozen=True, slots=True) · Line 107

Maps ast nodes to their token byte-spans over a TokenStream.

Attribute Type Default
stream TokenStream
line_starts tuple[int, ...]
start_index dict[int, int]
end_index dict[int, int]
Method Signature Summary
build staticmethod build(tree: ast.Module, stream: TokenStream) -> NodeIndex Build a NodeIndex over stream for tree.
_offset _offset(lineno: int, col_offset: int) -> int Absolute byte offset for an ast (lineno, col_offset) pair.
span_for span_for(node: ast.AST) -> Token The token span covering node's full source range.
token_for_name token_for_name(node: ast.AST) -> Token The exact NAME token that is node's rename target.
_name_token_at _name_token_at(index: int | None, node: ast.AST) -> Token Resolve a token index to a NAME token, or raise NodeSpanError.

Functions

Function Signature Summary
_location traces _location(node: ast.AST) -> tuple[int, int, int, int] Extract (lineno, col_offset, end_lineno, end_col_offset).
_line_starts traces _line_starts(source: bytes) -> tuple[int, ...] Byte offset of the first byte of each source line.

Source

Permalink: https://gitlab.com/MSamy/orchestrata-ultimate-architecture/-/blob/a4cbc2bdf/src/orchestrata_ultimate/refactor/ctok/node_index.py

"""NodeIndex -- ast node <-> token byte-span mapping (P2 of ctok-parser).

P2 of the ctok-parser contract (plans/contracts/ctok-parser.contract.json):
``NodeIndex`` closes the gap between P1's flat token array and CPython's
``ast`` module, so rename/edit tooling can locate the exact bytes an ast
node covers without libcst. ast node location (``lineno``/``col_offset``/
``end_lineno``/``end_col_offset``) is turned into an absolute byte offset
via a per-line offset table, then resolved against the real token array
built by :class:`~orchestrata_ultimate.refactor.ctok.tokenizer.CTokenizer`.

Two CPython quirks the offset table accounts for (both proven empirically
against ``ast.parse``, see the P2 build notes):

* ``ast`` column offsets are counted AFTER a leading UTF-8 BOM is
  stripped, while the C tokenizer's byte spans are over the RAW bytes
  (BOM included) -- the table's first line starts after the BOM so both
  line up on the same byte.
* ``ast`` treats a lone ``\\r`` exactly like ``\\r\\n``/``\\n`` as a line
  terminator (CPython applies universal-newline decoding before parsing),
  so line boundaries are found by matching any of the three, not just
  ``\\n``.
"""

from __future__ import annotations

import ast
import keyword
import re
from dataclasses import dataclass

from orchestrata_ultimate.core.base.traces import traces
from orchestrata_ultimate.refactor.ctok.token_stream import TokenStream
from orchestrata_ultimate.refactor.ctok.tokenizer import Token, TokenKind

_LINE_TERMINATOR_RE = re.compile(rb"\r\n|\r|\n")
_UTF8_BOM = b"\xef\xbb\xbf"

# ast node types whose rename target is an identifier introduced AFTER one
# or more leading keyword tokens ("class", "def", or "async" + "def").
_KEYWORD_NAMED_TYPES: tuple[type[ast.AST], ...] = (
    ast.FunctionDef,
    ast.AsyncFunctionDef,
    ast.ClassDef,
)


@traces(spec="110", story="US-110-009", reqs=["REQ-110-123"])
class NodeSpanError(ValueError):
    """Raised when an ast node has no locatable token span."""


@traces(spec="110", story="US-110-009", reqs=["REQ-110-123"])
def _location(node: ast.AST) -> tuple[int, int, int, int]:
    """Extract ``(lineno, col_offset, end_lineno, end_col_offset)``.

    Uses ``getattr`` rather than direct attribute access on purpose: only
    SOME ``ast.AST`` subclasses carry location (it lives in each node
    type's own ``_attributes``, not on the ``ast.AST`` base), and
    ``end_lineno``/``end_col_offset`` are typed ``int | None`` even on the
    nodes that do -- this is the one place that validates all four are
    real ints before any arithmetic touches them.

    Args:
        node: Any ast node.

    Returns:
        The four location ints.

    Raises:
        NodeSpanError: ``node`` is missing full source location.
    """
    lineno = getattr(node, "lineno", None)
    col_offset = getattr(node, "col_offset", None)
    end_lineno = getattr(node, "end_lineno", None)
    end_col_offset = getattr(node, "end_col_offset", None)
    if not (
        isinstance(lineno, int)
        and isinstance(col_offset, int)
        and isinstance(end_lineno, int)
        and isinstance(end_col_offset, int)
    ):
        raise NodeSpanError(f"{type(node).__name__} has no source location")
    return lineno, col_offset, end_lineno, end_col_offset


@traces(spec="110", story="US-110-009", reqs=["REQ-110-123"])
def _line_starts(source: bytes) -> tuple[int, ...]:
    """Byte offset of the first byte of each source line.

    Args:
        source: Raw file bytes.

    Returns:
        Tuple where ``result[lineno - 1]`` is the byte offset of the start
        of 1-indexed line ``lineno`` -- add an ast ``col_offset`` to get
        an absolute byte offset.
    """
    first = len(_UTF8_BOM) if source.startswith(_UTF8_BOM) else 0
    starts = [first]
    for match in _LINE_TERMINATOR_RE.finditer(source):
        starts.append(match.end())
    return tuple(starts)


@traces(spec="110", story="US-110-009", reqs=["REQ-110-123"])
@dataclass(frozen=True, slots=True)
class NodeIndex:
    """Maps ast nodes to their token byte-spans over a TokenStream.

    Attributes:
        stream: The TokenStream this index was built from.
        line_starts: Byte offset of each source line's first byte
            (``line_starts[lineno - 1]``).
        start_index: ``token.start -> index into stream.tokens``, LAST
            token at that offset wins. Zero-width structural tokens
            (INDENT/DEDENT) are emitted immediately BEFORE the real token
            they announce and share its start offset (e.g. a DEDENT right
            before a dedented ``def`` after a blank line) -- taking the
            last token at a start offset skips past them to the real one.
        end_index: ``token.end -> index into stream.tokens``, FIRST token
            ending there wins (the mirror case: a real token's end can
            coincide with a zero-width DEDENT/NEWLINE emitted right after
            it, and the real token -- emitted first -- is the one wanted).
    """

    stream: TokenStream
    line_starts: tuple[int, ...]
    start_index: dict[int, int]
    end_index: dict[int, int]

    @traces(spec="110", story="US-110-009", reqs=["REQ-110-123"])
    @staticmethod
    def build(tree: ast.Module, stream: TokenStream) -> NodeIndex:
        """Build a NodeIndex over ``stream`` for ``tree``.

        Args:
            tree: The ``ast.Module`` parsed from the SAME source as
                ``stream``. Not walked eagerly -- queries resolve each
                node's span lazily from its own location fields, so this
                is only accepted to keep the ast/token pairing explicit
                at the call site (mirrors ``LosslessModule.parse``).
            stream: The lossless TokenStream for that source.

        Returns:
            A NodeIndex ready for ``span_for``/``token_for_name`` queries.
        """
        del tree
        start_index: dict[int, int] = {}
        end_index: dict[int, int] = {}
        for i, token in enumerate(stream.tokens):
            start_index[token.start] = i  # last wins -- see start_index docstring
            end_index.setdefault(token.end, i)  # first wins -- see end_index docstring
        return NodeIndex(
            stream=stream,
            line_starts=_line_starts(stream.source),
            start_index=start_index,
            end_index=end_index,
        )

    @traces(spec="110", story="US-110-009", reqs=["REQ-110-123"])
    def _offset(self, lineno: int, col_offset: int) -> int:
        """Absolute byte offset for an ast ``(lineno, col_offset)`` pair."""
        return self.line_starts[lineno - 1] + col_offset

    @traces(spec="110", story="US-110-009", reqs=["REQ-110-123"])
    def span_for(self, node: ast.AST) -> Token:
        """The token span covering ``node``'s full source range.

        Args:
            node: Any ast node carrying source location (``lineno``/
                ``col_offset``/``end_lineno``/``end_col_offset``).

        Returns:
            A ``Token`` spanning ``node``'s bytes. ``.kind`` is the kind
            of the FIRST token in that span -- a representative tag, not
            a claim that the whole span is one lexical token (most ast
            nodes span many tokens).

        Raises:
            NodeSpanError: ``node`` has no location info, or its start
                byte offset does not align with any token boundary.
        """
        lineno, col_offset, end_lineno, end_col_offset = _location(node)
        start = self._offset(lineno, col_offset)
        end = self._offset(end_lineno, end_col_offset)
        index = self.start_index.get(start)
        if index is None:
            raise NodeSpanError(
                f"{type(node).__name__} span start {start} does not align with "
                "any token boundary"
            )
        kind = self.stream.tokens[index].kind
        return Token(kind=kind, start=start, end=end)

    def token_for_name(self, node: ast.AST) -> Token:
        """The exact NAME token that is ``node``'s rename target.

        Args:
            node: An ``ast.Name``, ``ast.Attribute``, ``ast.FunctionDef``,
                ``ast.AsyncFunctionDef``, or ``ast.ClassDef``.

        Returns:
            The single NAME token spanning the identifier itself: for
            ``Name``, the identifier token; for ``Attribute``, the
            trailing ``.attr`` name (NOT the value expression); for a
            def/class, the name after the ``def``/``class``/``async def``
            keyword(s) -- ast def-names have no span of their own, so
            this walks forward from the keyword and skips every leading
            keyword token via ``keyword.iskeyword`` (handles ``async
            def`` needing to skip TWO keyword tokens, not just one).

        Raises:
            NodeSpanError: the identifier token cannot be located.
            TypeError: ``node`` is not one of the supported node types.
        """
        if isinstance(node, ast.Name):
            lineno, col_offset, _, _ = _location(node)
            start = self._offset(lineno, col_offset)
            return self._name_token_at(self.start_index.get(start), node)
        if isinstance(node, ast.Attribute):
            _, _, end_lineno, end_col_offset = _location(node)
            end = self._offset(end_lineno, end_col_offset)
            return self._name_token_at(self.end_index.get(end), node)
        if isinstance(node, _KEYWORD_NAMED_TYPES):
            lineno, col_offset, _, _ = _location(node)
            start = self._offset(lineno, col_offset)
            index = self.start_index.get(start)
            if index is None:
                raise NodeSpanError(
                    f"{type(node).__name__} keyword start {start} does not align "
                    "with any token boundary"
                )
            for token in self.stream.tokens[index:]:
                if token.kind is TokenKind.NAME:
                    text = token.text(self.stream.source).decode("utf-8")
                    if not keyword.iskeyword(text):
                        return token
            raise NodeSpanError(
                f"no identifier NAME token found after {type(node).__name__} "
                "keyword(s)"
            )
        raise TypeError(f"token_for_name does not support {type(node).__name__}")

    def _name_token_at(self, index: int | None, node: ast.AST) -> Token:
        """Resolve a token index to a NAME token, or raise NodeSpanError."""
        if index is None:
            raise NodeSpanError(f"{type(node).__name__} has no aligned token")
        token = self.stream.tokens[index]
        if token.kind is not TokenKind.NAME:
            raise NodeSpanError(
                f"{type(node).__name__} resolved to a {token.kind} token, not NAME"
            )
        return token