feat(lsp): 跨模块语义高亮与 project.vpj includes 生效

语义 token 通过索引解析导入符号/成员(class/function/method/property/constant/macro);采纳 project.vpj 后刷新索引 roots;语义 token 数组改为 O(1) 追加,避免大文件序列化退化。
This commit is contained in:
TermiNexus committed 2026-10-27 01:45:49 +08:00
1 parent 7f90ec984c
commit 879cf7ab9f
7 files changed
+336 -30

No files matched your search

+85 -4
View File
@@ -61,6 +61,7 @@ class LspTarget:
container: str
sel: LspSource.LspRange*
has_sel: int
is_macro: int
def __new__(self, pool: memhub.MemManager*) -> rawptr:
p: LspTarget* = (LspTarget*)(pool.alloc(LspTarget.__sizeof__()))
@@ -82,6 +83,8 @@ class LspIndex:
refs_built: int
last_path: str
last_entry: LspFileEntry*
mod_names: list[str]*
mod_paths: list[str]*
def __new__(self, owner: memhub.MemManager*) -> rawptr:
p: LspIndex* = (LspIndex*)(owner.alloc(LspIndex.__sizeof__()))
@@ -94,6 +97,8 @@ _index: LspIndex* = None
# During bulk occurrence indexing, resolve only through already-indexed files. On-demand parsing
# here would repeat work per occurrence and exhaust the scratch arena.
_bulk_resolve: int = 0
# Semantic-token classification resolves many imports; it must not parse target files on demand.
_resolve_only: int = 0
def Init(owner: memhub.MemManager*) -> int:
@@ -124,6 +129,17 @@ def Invalidate():
_index.refs_built = 0
def BeginResolveOnly():
"""Resolve imports through the built index only (no on-demand parses)."""
global _resolve_only
_resolve_only = 1
def EndResolveOnly():
global _resolve_only
_resolve_only = 0
def _Suffix(pool: memhub.MemManager*, s: str, suffix: str) -> str:
if s is None or suffix is None:
return None
@@ -233,6 +249,7 @@ def _CopySymbol(pool: memhub.MemManager*, s: LspSymbols.LspSymbol*,
return None
c.name = LspPools.DupStr(pool, s.name)
c.kind = s.kind
c.is_macro = s.is_macro
c.detail = LspSymbols._CopyStr(pool, s.detail, LspSymbols.LSP_SIGNATURE_CAP)
c.doc = LspSymbols._CopyStr(pool, s.doc, LspSymbols.LSP_DOC_CAP)
c.file = file
@@ -337,6 +354,8 @@ def Ensure(ws: LspWorkspace.LspWorkspace*, table_pool: memhub.MemManager*) -> in
idx.stale = 0
idx.last_path = None
idx.last_entry = None
idx.mod_names = list[str](idx.tree.mb, 16)
idx.mod_paths = list[str](idx.tree.mb, 16)
if idx.files is None or idx.roots is None:
return -1
if ws is not None:
@@ -617,6 +636,27 @@ def SetRoots(ws: LspWorkspace.LspWorkspace*) -> int:
return 0
def RefreshRoots(ws: LspWorkspace.LspWorkspace*) -> int:
"""Rebuild roots after the workspace adopts a project.vpj deeper in the tree."""
idx: LspIndex* = _index
if idx is None or ws is None:
return -1
if idx.roots is None:
idx.roots = list[str](idx.tree.mb, 8)
if idx.roots is None:
return -1
idx.roots.clear()
if idx.mod_names is not None:
idx.mod_names.clear()
if idx.mod_paths is not None:
idx.mod_paths.clear()
_AddRoot(idx, ws.SourceDir())
_AddRootsString(idx, ws.Includes())
idx.stale = 1
idx.refs_built = 0
return 0
def FileEntry(path: str) -> LspFileEntry*:
idx: LspIndex* = _index
if idx is None or path is None or idx.files is None:
@@ -644,7 +684,7 @@ def ResolveInFile(pool: memhub.MemManager*, table_pool: memhub.MemManager*,
s: LspSymbols.LspSymbol* = FindSymbol(path, container, name)
if s is not None:
return s
if _bulk_resolve != 0:
if _bulk_resolve != 0 or _resolve_only != 0:
return None
text: str = LspFiles.ReadSource(pool, path)
if text is None:
@@ -701,6 +741,7 @@ def _SymbolTarget(pool: memhub.MemManager*, s: LspSymbols.LspSymbol*) -> LspTarg
t.file = s.file
t.name = s.name
t.kind = s.kind
t.is_macro = s.is_macro
t.detail = s.detail
t.doc = s.doc
t.container = s.container
@@ -794,18 +835,32 @@ def _ModulePath(ws: LspWorkspace.LspWorkspace*, pool: memhub.MemManager*,
idx: LspIndex* = _index
if idx is None or module is None or idx.roots is None:
return None
if idx.mod_names is None:
idx.mod_names = list[str](idx.tree.mb, 16)
idx.mod_paths = list[str](idx.tree.mb, 16)
if idx.mod_names is not None:
i: int = 0
while i < int(len(idx.mod_names)):
if string.strcmp(idx.mod_names.get(sizet(i)), module) == 0:
return idx.mod_paths.get(sizet(i))
i += 1
rel: str = _RelPath(pool, module)
if rel is None:
return None
i: int = 0
found: str = None
i = 0
while i < int(len(idx.roots)):
root: str = idx.roots.get(sizet(i))
i += 1
p: str = None
k: int = 0
if _TryPath(pool, root, rel, &p, &k) != 0:
return p
return None
found = p
break
if idx.mod_names is not None and int(len(idx.mod_names)) < 256:
idx.mod_names.append(LspPools.DupStr(idx.tree.mb, module))
idx.mod_paths.append(LspPools.DupStr(idx.tree.mb, found))
return found
def _ChainFromModule(ws: LspWorkspace.LspWorkspace*, pool: memhub.MemManager*,
@@ -899,6 +954,32 @@ def ResolveSymbol(ws: LspWorkspace.LspWorkspace*, pool: memhub.MemManager*,
return _SymbolTarget(pool, s)
def ResolveMembers(pool: memhub.MemManager*, table_pool: memhub.MemManager*,
base: LspTarget*, rest: str) -> LspTarget*:
"""Walk class members from an already-resolved base target (no import re-resolution)."""
if base is None or rest is None:
return None
if base.kind != LspSymbols.LSP_KIND_CLASS or base.file is None or base.name is None:
return None
container: str = base.name
file: str = base.file
segs: str = rest
while segs is not None:
h: str = None
r: str = None
LspSymbols._SplitHead(pool, segs, &h, &r)
if h is None:
return None
m: LspSymbols.LspSymbol* = ResolveInFile(pool, table_pool, file, container, h)
if m is None:
return None
if r is None:
return _SymbolTarget(pool, m)
container = m.name
segs = r
return None
def ResolveChain(ws: LspWorkspace.LspWorkspace*, pool: memhub.MemManager*,
table_pool: memhub.MemManager*,
from_file: str, head: LspSymbols.LspSymbol*,
+182 -25
View File
@@ -6,6 +6,7 @@ import ast
import lib.lsp.LspDocuments as LspDocuments
import lib.lsp.LspSource as LspSource
import lib.lsp.LspSymbols as LspSymbols
import lib.lsp.LspIndex as LspIndex
import lib.lsp.LspWorkspace as LspWorkspace
import lib.lsp.LspNavigation as LspNavigation
import lib.lsp.LspTransport as LspTransport
@@ -132,30 +133,145 @@ def _ModsFor(s: LspSymbols.LspSymbol*) -> int:
return 0
def _TokenForSymbol(pool: memhub.MemManager*,
class LspSemCtx:
ws: LspWorkspace.LspWorkspace*
table_pool: memhub.MemManager*
file: str
sym_keys: list[LspSymbols.LspSymbol*]*
sym_tgts: list[LspIndex.LspTarget*]*
chain_heads: list[LspSymbols.LspSymbol*]*
chain_rests: list[str]*
chain_tgts: list[LspIndex.LspTarget*]*
def __new__(self, pool: memhub.MemManager*) -> rawptr:
p: LspSemCtx* = (LspSemCtx*)(pool.alloc(LspSemCtx.__sizeof__()))
if p:
string.memset(p, 0, LspSemCtx.__sizeof__())
return rawptr(p)
def _TypeForTarget(t: LspIndex.LspTarget*) -> int:
if t is None:
return -1
if t.kind == LspSymbols.LSP_KIND_MODULE:
return ST_NAMESPACE
if t.kind == LspSymbols.LSP_KIND_CLASS:
return ST_CLASS
if t.kind == LspSymbols.LSP_KIND_FUNCTION:
return ST_MACRO if t.is_macro != 0 else ST_FUNCTION
if t.kind == LspSymbols.LSP_KIND_METHOD:
return ST_MACRO if t.is_macro != 0 else ST_METHOD
if t.kind == LspSymbols.LSP_KIND_FIELD:
return ST_PROPERTY
return ST_VARIABLE
def _ModsForTarget(t: LspIndex.LspTarget*) -> int:
if t is not None and t.kind == LspSymbols.LSP_KIND_CONSTANT:
return 2
return 0
define LSP_SEM_CACHE_SIZE = 4096
define LSP_SEM_CACHE_MASK = 4095
def _SemHash(s: str) -> sizet:
if s is None:
return 0
h: uint64 = uint64(1469598103934665603)
i: sizet = 0
while s[i] != 0:
h = (h ^ uint64(s[i])) * uint64(1099511628211)
i += 1
return sizet(h & uint64(LSP_SEM_CACHE_MASK))
def _CachedSymbol(ctx: LspSemCtx*, pool: memhub.MemManager*,
sym: LspSymbols.LspSymbol*) -> LspIndex.LspTarget*:
if ctx is None or sym is None:
return None
slot: sizet = _SemHash(sym.name)
if sym.import_mod is not None:
slot = (slot ^ _SemHash(sym.import_mod)) & sizet(LSP_SEM_CACHE_MASK)
if ctx.sym_keys.get(slot) == sym:
return ctx.sym_tgts.get(slot)
t: LspIndex.LspTarget* = LspIndex.ResolveSymbol(
ctx.ws, pool, ctx.table_pool, ctx.file, sym, 0)
ctx.sym_keys.set(slot, sym)
ctx.sym_tgts.set(slot, t)
return t
def _CachedChain(ctx: LspSemCtx*, pool: memhub.MemManager*,
head: LspSymbols.LspSymbol*,
rest: str) -> LspIndex.LspTarget*:
if ctx is None or head is None or rest is None:
return None
slot: sizet = (_SemHash(rest) ^ _SemHash(head.name)) & sizet(LSP_SEM_CACHE_MASK)
if ctx.chain_heads.get(slot) == head:
cached: str = ctx.chain_rests.get(slot)
if cached is not None and string.strcmp(cached, rest) == 0:
return ctx.chain_tgts.get(slot)
t: LspIndex.LspTarget* = None
if head.import_kind == LspSymbols.LSP_IMPORT_FROM:
base: LspIndex.LspTarget* = _CachedSymbol(ctx, pool, head)
if base is not None:
t = LspIndex.ResolveMembers(pool, ctx.table_pool, base, rest)
else:
t = LspIndex.ResolveChain(ctx.ws, pool, ctx.table_pool, ctx.file, head, rest)
ctx.chain_heads.set(slot, head)
ctx.chain_rests.set(slot, rest)
ctx.chain_tgts.set(slot, t)
return t
def _TokenForSymbol(ctx: LspSemCtx*, pool: memhub.MemManager*,
s: LspSymbols.LspSymbol*) -> LspSemToken*:
if s is None or s.sel is None:
return None
t: LspSemToken* = _NewTok(pool, s.sel.sl, s.sel.sc, s.sel.el, s.sel.ec,
_TypeFor(s))
if t is not None:
t.mods = _ModsFor(s)
return t
typ: int = _TypeFor(s)
mods: int = _ModsFor(s)
if s.import_kind != LspSymbols.LSP_IMPORT_NONE:
# Import declarations are colored by the imported declaration, not the local alias kind.
t: LspIndex.LspTarget* = _CachedSymbol(ctx, pool, s)
if t is not None:
typ = _TypeForTarget(t)
mods = _ModsForTarget(t)
tok: LspSemToken* = _NewTok(pool, s.sel.sl, s.sel.sc, s.sel.el, s.sel.ec, typ)
if tok is not None:
tok.mods = mods
return tok
def _TokenForOcc(pool: memhub.MemManager*,
def _TokenForOcc(ctx: LspSemCtx*, pool: memhub.MemManager*,
o: LspSymbols.LspOccurrence*) -> LspSemToken*:
if o is None:
return None
if o.sym is None:
typ: int = -1
mods: int = 0
if o.sym is not None:
typ = _TypeFor(o.sym)
mods = _ModsFor(o.sym)
if o.sym.import_kind != LspSymbols.LSP_IMPORT_NONE:
t: LspIndex.LspTarget* = _CachedSymbol(ctx, pool, o.sym)
if t is not None:
typ = _TypeForTarget(t)
mods = _ModsForTarget(t)
elif o.head is not None and o.rest is not None and o.head.import_kind != LspSymbols.LSP_IMPORT_NONE:
t = _CachedChain(ctx, pool, o.head, o.rest)
if t is not None:
typ = _TypeForTarget(t)
mods = _ModsForTarget(t)
if typ < 0:
# Builtin type names have no declaration; color them like class types.
if o.builtin_type != 0:
return _NewTok(pool, o.line, o.sc, o.line, o.ec, ST_CLASS)
return None
t: LspSemToken* = _NewTok(pool, o.line, o.sc, o.line, o.ec, _TypeFor(o.sym))
if t is not None:
t.mods = _ModsFor(o.sym)
return t
if o.builtin_type == 0:
return None
typ = ST_CLASS
tok: LspSemToken* = _NewTok(pool, o.line, o.sc, o.line, o.ec, typ)
if tok is not None:
tok.mods = mods
return tok
def _Before(a: LspSemToken*, b: LspSemToken*) -> int:
@@ -188,8 +304,8 @@ def _SymbolTokenValid(pool: memhub.MemManager*, a: LspSymbols.LspAnalysis*,
return 1 if string.strcmp(txt, name) == 0 else 0
def _AstStream(pool: memhub.MemManager*,
a: LspSymbols.LspAnalysis*) -> list[LspSemToken*]*:
def _AstStream(pool: memhub.MemManager*, a: LspSymbols.LspAnalysis*,
ctx: LspSemCtx*) -> list[LspSemToken*]*:
out: list[LspSemToken*]* = list[LspSemToken*](pool, 64)
syms: list[LspSymbols.LspSymbol*]* = a.symbols
occs: list[LspSymbols.LspOccurrence*]* = a.occs
@@ -204,7 +320,7 @@ def _AstStream(pool: memhub.MemManager*,
while si < n:
s: LspSymbols.LspSymbol* = syms.get(sizet(si))
si += 1
t: LspSemToken* = _TokenForSymbol(pool, s)
t: LspSemToken* = _TokenForSymbol(ctx, pool, s)
if t is not None and s is not None:
if _SymbolTokenValid(pool, a, t, s.name) == 0:
t.typ = -1
@@ -213,7 +329,7 @@ def _AstStream(pool: memhub.MemManager*,
break
occ_t: LspSemToken* = None
while oi < m:
t: LspSemToken* = _TokenForOcc(pool, occs.get(sizet(oi)))
t: LspSemToken* = _TokenForOcc(ctx, pool, occs.get(sizet(oi)))
oi += 1
if t is not None and t.typ >= 0:
occ_t = t
@@ -361,6 +477,22 @@ def _InRange(t: LspSemToken*, start_line: int, end_line: int) -> int:
return 1
def _AppendInt(pool: memhub.MemManager*, arr: json.JsonValue*, value: int,
tail: json.JsonValue*) -> json.JsonValue*:
"""Append to a JSON array in O(1); json.array_append walks the whole list per call."""
if arr is None:
return tail
node: json.JsonValue* = json.int_val(pool, int64(value))
if node is None:
return tail
if tail is None:
arr.child = node
else:
tail.next = node
arr.child_count += 1
return node
def _Encode(pool: memhub.MemManager*, out: json.JsonValue*,
ast_tokens: list[LspSemToken*]*, lex_tokens: list[LspSemToken*]*,
has_range: int, start_line: int, end_line: int):
@@ -368,6 +500,7 @@ def _Encode(pool: memhub.MemManager*, out: json.JsonValue*,
prev_char: int = 0
count: int = 0
last: LspSemToken* = None
tail: json.JsonValue* = None
ai: int = 0
li: int = 0
an: int = int(len(ast_tokens))
@@ -401,11 +534,11 @@ def _Encode(pool: memhub.MemManager*, out: json.JsonValue*,
length: int = t.ec - t.sc
if length <= 0:
continue
json.array_append(pool, out, json.int_val(pool, int64(dl)))
json.array_append(pool, out, json.int_val(pool, int64(dc)))
json.array_append(pool, out, json.int_val(pool, int64(length)))
json.array_append(pool, out, json.int_val(pool, int64(t.typ)))
json.array_append(pool, out, json.int_val(pool, int64(t.mods)))
tail = _AppendInt(pool, out, dl, tail)
tail = _AppendInt(pool, out, dc, tail)
tail = _AppendInt(pool, out, length, tail)
tail = _AppendInt(pool, out, t.typ, tail)
tail = _AppendInt(pool, out, t.mods, tail)
prev_line = t.sl
prev_char = t.sc
last = t
@@ -426,7 +559,31 @@ def Tokens(pool: memhub.MemManager*, tr: LspTransport.LspTransport*,
return LspRpc.WriteResponse(pool, tr, id_node, result)
a: LspSymbols.LspAnalysis* = LspNavigation._Analyze(
pool, docs, doc, LspNavigation._Uri(params))
ast_tokens: list[LspSemToken*]* = _AstStream(pool, a)
# Cross-module coloring resolves imported declarations; the workspace symbol index avoids
# re-parsing the same library file for each occurrence.
LspIndex.SetRoots(ws)
LspIndex.Ensure(ws, docs.pool)
ctx: LspSemCtx* = LspSemCtx(pool)
if ctx is not None:
ctx.ws = ws
ctx.table_pool = docs.pool
ctx.file = a.file
ctx.sym_keys = list[LspSymbols.LspSymbol*](pool, 8)
ctx.sym_tgts = list[LspIndex.LspTarget*](pool, 8)
ctx.chain_heads = list[LspSymbols.LspSymbol*](pool, 8)
ctx.chain_rests = list[str](pool, 8)
ctx.chain_tgts = list[LspIndex.LspTarget*](pool, 8)
ci: int = 0
while ci < LSP_SEM_CACHE_SIZE:
ctx.sym_keys.append(None)
ctx.sym_tgts.append(None)
ctx.chain_heads.append(None)
ctx.chain_rests.append(None)
ctx.chain_tgts.append(None)
ci += 1
LspIndex.BeginResolveOnly()
ast_tokens: list[LspSemToken*]* = _AstStream(pool, a, ctx)
LspIndex.EndResolveOnly()
lex_tokens: list[LspSemToken*]* = _LexStream(pool, doc)
has_range: int = 0
start_line: int = 0
+1
View File
@@ -182,6 +182,7 @@ def _HandleDidOpen(pool: memhub.MemManager*, tr: LspTransport.LspTransport*,
file: str = LspWorkspace.DecodeUri(pool, uri)
if file is not None and ws is not None:
if ws.AdoptFromFile(file) != 0:
LspIndex.RefreshRoots(ws)
LspIndex.Invalidate()
if docs.Open(uri, text, version) is None:
LspTransport.StderrWrite("viperc-lsp: didOpen failed to store document\n")
+6
View File
@@ -46,6 +46,8 @@ class LspScope:
class LspSymbol:
name: str
kind: int
# 1 for `define` macro functions (colored as macro), 0 otherwise.
is_macro: int
detail: str
doc: str
file: str
@@ -630,6 +632,10 @@ def _Declare(pool: memhub.MemManager*, a: LspAnalysis*, scope: LspScope*,
return None
s.file = a.file
s.detail = detail
if decl is not None and decl.kind() == ast.ASTKind.FunctionDef:
fd: ast.FunctionDef* = (ast.FunctionDef*)(decl)
if (fd.flags & ast.FLAG_IS_DEFINE) != 0:
s.is_macro = 1
s.sel = _NameSpan(pool, a.doc, line, col, name)
if s.sel is None or _RangeTextIsName(pool, a, s.sel, name) == 0:
if decl is None or (decl.kind() != ast.ASTKind.Import and decl.kind() != ast.ASTKind.ImportFrom):
+2
View File
@@ -1,5 +1,7 @@
"""Gate 10 fixture: class members used through annotations and module chains."""
define DOUBLE(x): return x + x
class Widget:
value: int
+5 -1
View File
@@ -1,10 +1,14 @@
"""Gate 10 fixture: annotated members, module chains and .vpi members."""
from .gate10_types import Widget
from .gate10_types import Widget, DOUBLE
from .gate10_stub import StubThing
import gate10_types
def macro_use(n: int) -> int:
return DOUBLE(n)
def by_import(s: Widget) -> int:
v: int = s.value
return s.resize(v)
+55
View File
@@ -536,6 +536,7 @@ def gate3(exe: str) -> int:
"position": ref_pos,
"context": {"includeDeclaration": True},
},
timeout=30.0,
).get("result")
if not isinstance(references, list) or len(references) < 3:
raise AssertionError(f"references are incomplete: {references}")
@@ -866,6 +867,7 @@ def gate5(exe: str) -> int:
renamed = client.request(
"textDocument/rename",
{"textDocument": {"uri": uri}, "position": field_pos, "newName": "value"},
timeout=30.0,
).get("result")
if not isinstance(renamed, dict):
raise AssertionError(f"rename failed: {renamed}")
@@ -1048,6 +1050,39 @@ def gate7(exe: str) -> int:
if "includes" not in includes:
raise AssertionError(f"includes is wrong after adoption: {stats}")
# Project-relative imports must resolve before any index rebuild: the adopted project's
# source_dir/includes have to replace the repository-root fallback roots.
app_doc = root / "App" / "lib" / "lsp" / "LspDocuments.vp"
app_text = app_doc.read_text(encoding="utf-8")
app_uri = app_doc.as_uri()
client.notify(
"textDocument/didOpen",
{"textDocument": {"uri": app_uri, "languageId": "viper", "version": 1, "text": app_text}},
)
client.wait_notification("textDocument/publishDiagnostics")
pool_pos = _position(app_text, "LspPools.LspPool")
pool_pos["character"] += len("LspPools.")
imported_def = client.request(
"textDocument/definition", {"textDocument": {"uri": app_uri}, "position": pool_pos},
timeout=60.0,
).get("result")
if not isinstance(imported_def, list) or not imported_def:
raise AssertionError(f"project import did not resolve after adoption: {imported_def}")
if not imported_def[0]["uri"].endswith("LspPools.vp"):
raise AssertionError(f"project import resolved to the wrong file: {imported_def}")
legend = response.get("result", {}).get("capabilities", {}).get(
"semanticTokensProvider", {}).get("legend", {})
sem_types = legend.get("tokenTypes", [])
semantic = client.request(
"textDocument/semanticTokens/full", {"textDocument": {"uri": app_uri}}, timeout=60.0
).get("result")
sem_tokens = _decode_tokens(semantic.get("data", [])) if isinstance(semantic, dict) else []
if not _find_token(sem_tokens, pool_pos["line"], pool_pos["character"],
sem_types.index("class")):
raise AssertionError(f"imported class was not highlighted: {pool_pos}")
client.notify("textDocument/didClose", {"textDocument": {"uri": app_uri}})
client.wait_notification("textDocument/publishDiagnostics")
symbols = client.request(
"workspace/symbol", {"query": "LspDocuments"}, timeout=60.0
).get("result")
@@ -1412,6 +1447,26 @@ def gate10(exe: str) -> int:
if reported:
raise AssertionError(f"gate10 fixture has diagnostics: {reported}")
legend = response.get("result", {}).get("capabilities", {}).get("semanticTokensProvider", {}).get("legend", {})
sem_types = legend.get("tokenTypes", [])
semantic = client.request(
"textDocument/semanticTokens/full", {"textDocument": {"uri": uri}}, timeout=120.0
).get("result")
if not isinstance(semantic, dict):
raise AssertionError(f"semanticTokens/full failed: {semantic}")
sem_tokens = _decode_tokens(semantic.get("data", []))
for label, needle, delta, token_name in (
("imported class", "import Widget", 7, "class"),
("imported macro", "import Widget", 15, "macro"),
("macro call", "return DOUBLE", 7, "macro"),
("member field", "s.value", 2, "property"),
("member method", "s.resize", 2, "method"),
):
pos = _position(text, needle)
pos["character"] += delta
if not _find_token(sem_tokens, pos["line"], pos["character"], sem_types.index(token_name)):
raise AssertionError(f"missing cross-module semantic token for {label}: {pos}")
def at(needle: str, delta: int = 0, occurrence: int = 0) -> dict[str, int]:
pos = _position(text, needle, occurrence)
pos["character"] += delta