Skip to content

Commit b41f66a

Browse files
committed
feat(schema): add v2 Span model + line/col→byte-offset helper
1 parent efcec4c commit b41f66a

2 files changed

Lines changed: 38 additions & 1 deletion

File tree

codeanalyzer/schema/py_schema.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
from __future__ import annotations
2323
import inspect
2424
from pathlib import Path
25-
from typing import Any, Dict, List, Optional
25+
from typing import Any, Dict, List, Optional, Tuple
2626
import gzip
2727

2828
from pydantic import BaseModel
@@ -168,6 +168,29 @@ def build(self):
168168
return cls
169169

170170

171+
def byte_offsets(source: str, start_line: int, start_col: int,
172+
end_line: int, end_col: int) -> Tuple[int, int]:
173+
"""Convert (1-based line, 0-based col) ast positions to utf-8 byte offsets
174+
into `source`. `col` is a character offset within the line (ast semantics);
175+
we re-encode the line prefix to bytes so multibyte chars are handled."""
176+
lines = source.splitlines(keepends=True)
177+
def offset(line: int, col: int) -> int:
178+
prefix_bytes = len("".join(lines[: line - 1]).encode("utf-8"))
179+
col_bytes = len(lines[line - 1][:col].encode("utf-8")) if line - 1 < len(lines) else 0
180+
return prefix_bytes + col_bytes
181+
return offset(start_line, start_col), offset(end_line, end_col)
182+
183+
184+
@builder
185+
@msgpk
186+
class Span(BaseModel):
187+
"""Where a node lives in source. `start`/`end` are [line, col] (1-based line,
188+
0-based col, ast semantics); `bytes` are utf-8 offsets into module.source."""
189+
start: Tuple[int, int]
190+
end: Tuple[int, int]
191+
bytes: Tuple[int, int]
192+
193+
171194
@builder
172195
@msgpk
173196
class PyImport(BaseModel):

test/test_v2_source_spans.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from codeanalyzer.schema.py_schema import byte_offsets
2+
3+
4+
def test_byte_offsets_slice_source_exactly():
5+
source = "def f():\n return 1\n"
6+
# `return 1` is line 2, cols 4..12 (0-based, end exclusive per ast end_col_offset)
7+
lo, hi = byte_offsets(source, 2, 4, 2, 12)
8+
assert source.encode("utf-8")[lo:hi].decode("utf-8") == "return 1"
9+
10+
11+
def test_byte_offsets_multibyte_safe():
12+
source = "x = 'é'\ny = 2\n" # 'é' is 2 bytes in utf-8
13+
lo, hi = byte_offsets(source, 2, 0, 2, 5)
14+
assert source.encode("utf-8")[lo:hi].decode("utf-8") == "y = 2"

0 commit comments

Comments
 (0)