diff --git a/App/lib/core/Gen/AssignGenMixin.vp b/App/lib/core/Gen/AssignGenMixin.vp index 488f055..2ab0e30 100644 --- a/App/lib/core/Gen/AssignGenMixin.vp +++ b/App/lib/core/Gen/AssignGenMixin.vp @@ -15,6 +15,7 @@ import lib.core.Gen.Generator as Generator import lib.core.CompilerFactory as CompilerFactory import lib.Stub.StubGen.VpiReader as VpiReader import lib.core.Gen.GenApi as GenApi +import lib.types.IntFacts as IntFacts # AssignGenMixin - assignment translation mixin novirtual class AssignGenMixin: @@ -237,22 +238,7 @@ novirtual class AssignGenMixin: # Auto-derived => 64-bit pattern; explicit type T => T's width range. def define_min_width(self, value: int64) -> int: """Return the minimal storable width (8/16/32/64) for an integer value.""" - if value < 0: - if value >= -128: return 8 - if value >= -32768: return 16 - # -2^31: use an arithmetic equivalent to avoid an i32-wrapping literal - if value >= -2147483647 - 1: return 32 - return 64 - if value <= 255: return 8 - if value <= 65535: return 16 - if value <= 2147483647: return 32 - # 2^31..2^32-1:无符号 32 位位型即可容纳 → 32 位。 - # 用 (value >> 32) == 0 判断,**不要**写成 uint64(uint32(value)) == value:uint32(...) 之后的 - # 加宽方向(zext/sext)由 should_zext_widen 按节点无符号性决定,TPC 与 VPC 自举的判定可能不一致; - # 一旦判成 sext,uint64(0x80000000) 会变成 0xFFFFFFFF80000000 ≠ value → 误判 64 位 → - # `GENERIC_READ | GENERIC_WRITE` 被放大到 i64 → 赋给 32 位目标报隐式窄化。 - if (value >> 32) == 0: return 32 - return 64 + return IntFacts.MinStorableWidth(value) # _is_static_int_expr - 该节点是否属于通用求值器一定能折叠的整型表达式(只判定,不求值) def _binop_is_foldable(self, op: int) -> int: diff --git a/App/lib/core/Gen/Expr/ExprGenConst.vp b/App/lib/core/Gen/Expr/ExprGenConst.vp index 9c8e07d..eb9be28 100644 --- a/App/lib/core/Gen/Expr/ExprGenConst.vp +++ b/App/lib/core/Gen/Expr/ExprGenConst.vp @@ -10,6 +10,7 @@ import lib.core.Gen.Translator as HT import lib.core.Gen.GenTypes as GenTypes import lib.core.Gen.Expr.ExprGenBase as ExprGenBase +import lib.types.IntFacts as IntFacts # ExprGenConst - split from ExprGenMixin (stage D): constants / global strings / list literals @@ -64,15 +65,7 @@ novirtual class ExprGenConst(ExprGenBase.ExprGenBase): # 越界编译期 fail-fast(不允许隐式截断)。w==64 时 int64 全域都合法。 def _explicit_width_int_const(self, pool: memhub.MemBuddy*, node: ast.AST*, w: int, is_unsigned: int, iv: int64) -> mlirlite.Value*: """Explicit-width integer literal: strict range check, then a constant of exactly w bits.""" - ok: int = 1 - if is_unsigned != 0: - if w < 64 and iv < 0: ok = 0 - elif w <= 62 and iv > (int64(1) << w) - 1: ok = 0 - else: - if w < 64: - lo: int64 = -(int64(1) << (w - 1)) - hi: int64 = (int64(1) << (w - 1)) - 1 - if iv < lo or iv > hi: ok = 0 + ok: int = IntFacts.FitsExplicitWidth(w, is_unsigned, iv) if ok == 0: wname: str = "int" if is_unsigned != 0: wname = "uint" diff --git a/App/lib/lsp/LspHover.vp b/App/lib/lsp/LspHover.vp index 526a8d4..c0a716f 100644 --- a/App/lib/lsp/LspHover.vp +++ b/App/lib/lsp/LspHover.vp @@ -12,6 +12,7 @@ import lib.lsp.LspWorkspace as LspWorkspace import lib.lsp.LspTransport as LspTransport import lib.lsp.LspRpc as LspRpc import lib.lsp.LspNavigation as LspNavigation +import lib.types.IntFacts as IntFacts # Longest literal text shown verbatim in a hover (a docstring can be huge). @@ -258,14 +259,7 @@ def _ParseMagnitude(text: str, overflow: int*) -> uint64: def _U64Bits(v: uint64) -> int: """Number of significant bits; zero still needs one bit.""" - if v == uint64(0): - return 1 - n: int = 0 - x: uint64 = v - while x != uint64(0): - n += 1 - x = x >> uint64(1) - return n + return IntFacts.MinUnsignedBits(v) def _TrailingZeros(v: uint64) -> int: @@ -280,16 +274,8 @@ def _TrailingZeros(v: uint64) -> int: def _PowTwoWidth(bits: int) -> int: - """Smallest power-of-two width >= bits from the language's integer sizes (8..128).""" - if bits <= 8: - return 8 - if bits <= 16: - return 16 - if bits <= 32: - return 32 - if bits <= 64: - return 64 - return 128 + """Smallest power-of-two width (the language server never recommends arbitrary iN widths).""" + return IntFacts.Pow2Width(bits) def _HexU64Digits(pool: memhub.MemManager*, v: uint64, min_digits: int) -> str: @@ -405,9 +391,9 @@ def _IntMarkdown(pool: memhub.MemManager*, tok: ast.Token*) -> str: decl = _Cat(pool, decl, _U64ToString(pool, uint64(tok.int_width))) fits: int = 0 if tok.int_unsigned != 0: - fits = 1 if ub <= tok.int_width else 0 + fits = IntFacts.FitsUnsignedValue(tok.int_width, v) else: - fits = 1 if v <= (uint64(1) << uint64(tok.int_width - 1)) - uint64(1) else 0 + fits = IntFacts.FitsSignedValue(tok.int_width, v, 0) state: str = "fits" if fits != 0 else "overflow" body = _Cat(pool, body, _Cat(pool, _Cat(pool, _Cat(pool, "\ndeclared ", decl), " ("), _Cat(pool, state, ")"))) diff --git a/App/lib/types/IntFacts.vp b/App/lib/types/IntFacts.vp new file mode 100644 index 0000000..a583023 --- /dev/null +++ b/App/lib/types/IntFacts.vp @@ -0,0 +1,118 @@ +from stdint import * +import string + + +# Shared integer-width facts for the compiler and the language server. +# Keep this module free of compiler- or LSP-specific imports: both sides import the same table so +# hover numbers, literal range checks and future diagnostics cannot drift apart. +# +# Literal suffixes (`_iN` / `_uN` / `_intN` / `_uintN`) accept any positive N. Values stored in +# the AST are still 64-bit; N only selects the result type width (LLVM/MLIR support iN for any N). + +define INTFACT_MAX_WIDTH = 2147483647 + + +def MinUnsignedBits(v: uint64) -> int: + """Significant bits of an unsigned magnitude; zero still occupies one bit.""" + if v == uint64(0): + return 1 + n: int = 0 + x: uint64 = v + while x != uint64(0): + n += 1 + x = x >> uint64(1) + return n + + +def MinSignedBits(v: uint64) -> int: + """Bit width needed by a non-negative magnitude as a two's-complement value.""" + return MinUnsignedBits(v) + 1 + + +def Pow2Width(bits: int) -> int: + """Smallest power-of-two width >= bits, starting at the language's 8-bit minimum. + + The language accepts arbitrary iN/uN types; the language server deliberately only + recommends power-of-two storage widths, so this is the single mapping used for display. + """ + if bits <= 8: + return 8 + w: int = 8 + while w < bits: + w = w * 2 + return w + + +def MinStorableWidth(value: int64) -> int: + """Minimal storable width (8/16/32/64) for an automatically typed integer constant.""" + if value < 0: + if value >= -128: + return 8 + if value >= -32768: + return 16 + if value >= -2147483647 - 1: + return 32 + return 64 + if value <= 255: + return 8 + if value <= 65535: + return 16 + if value <= 2147483647: + return 32 + # 2^31..2^32-1 fit the 32-bit bit pattern. Use `(value >> 32) == 0` here: writing + # `uint64(uint32(value)) == value` lets the widening direction (zext/sext) depend on node + # signedness, which differs between TPC bootstrap and VPC and can misclassify 0x80000000 + # as 64-bit (then `GENERIC_READ | GENERIC_WRITE` gets widened to i64 and fails narrowing). + if (value >> 32) == 0: + return 32 + return 64 + + +def FitsExplicitWidth(width: int, is_unsigned: int, value: int64) -> int: + """Whether an explicit-width literal fits its suffix width (compiler AST value domain). + + `value` is the int64 bit pattern produced by the lexer; widths above 64 always fit because + the language's literal magnitude is limited to 64 bits. + """ + if width <= 0 or width > INTFACT_MAX_WIDTH: + return 0 + if is_unsigned != 0: + if width >= 64: + return 1 + # width < 64: negative bit patterns mean the source magnitude exceeded the width. + if value < 0: + return 0 + return 1 if uint64(value) <= (uint64(1) << uint64(width)) - uint64(1) else 0 + if width >= 64: + # i64 covers the whole int64 domain; wider types cover it as well. + return 1 + lo: int64 = -(int64(1) << (width - 1)) + hi: int64 = (int64(1) << (width - 1)) - int64(1) + return 1 if value >= lo and value <= hi else 0 + + +def FitsUnsignedValue(width: int, magnitude: uint64) -> int: + """Magnitude-based unsigned range check (used by the language server).""" + if width <= 0: + return 0 + if width >= 64: + return 1 + return 1 if magnitude <= (uint64(1) << uint64(width)) - uint64(1) else 0 + + +def FitsSignedValue(width: int, magnitude: uint64, negative: int) -> int: + """Magnitude-based signed range check (used by the language server).""" + if width <= 0: + return 0 + if magnitude == uint64(0): + return 1 + if width > 64: + return 1 + if width == 64: + if negative != 0: + return 1 if magnitude <= (uint64(1) << uint64(63)) else 0 + return 1 if magnitude <= (uint64(1) << uint64(63)) - uint64(1) else 0 + limit: uint64 = uint64(1) << uint64(width - 1) + if negative != 0: + return 1 if magnitude <= limit else 0 + return 1 if magnitude <= limit - uint64(1) else 0 diff --git a/Tools/lsp/PLAN_diagnostics.md b/Tools/lsp/PLAN_diagnostics.md new file mode 100644 index 0000000..c2529f1 --- /dev/null +++ b/Tools/lsp/PLAN_diagnostics.md @@ -0,0 +1,203 @@ +# 计划:LSP 语义诊断(NameError、位宽存储等) + +状态:待评审;基础设施尚未开工。 +已完成的前置工作(2026-10-28): + +- lexer 宽度后缀修复:`_u8` / `_i7` / `_uint100` / `_int24` 等现在可被识别, + 数字分隔符 `_` 只有在后面紧跟合法数字时才被吞掉,宽度 N 不再限制在 1..64。 +- `_iN/_uN` 负字面量折叠:`-64_i7`、`-128_i8` 按负值做范围检查,不再被当成正的幅值拒绝。 +- `atoll` / `atof`:支持 `0b`/`0o` 前缀和 `_` 数字分隔符,字面量值不再被解析成 0 或截断。 +- 新增 `App/lib/types/IntFacts.vp`:编译器与 LSP 共用的宽度/范围/最小二幂宽度表; + `ExprGenConst` 的显式宽度检查、`AssignGenMixin.define_min_width`、hover 已改为调用它。 +- hover:`_u100` 等任意后缀可显示;推荐宽度仍只给二幂位宽(u8/u16/...)。 + +尚未包含:任意宽度**类型标注**(`x: i100`)仍走固定类型表;这涉及 TypeGen/TypeSpec/ +sizeof/ABI,需要单独评估。 + +## 1. 现状 + +- LSP 目前的诊断只有语法错误:`LspDiagnostics._Parse()` 调用 `ast.parse_ex`,把 + `ast.get_parse_errors()` 转成 `textDocument/publishDiagnostics` / pull diagnostics。 +- 编译器已经有成熟的报错,但都在代码生成阶段 fatal,不能直接给 LSP 复用: + - 整数宽度:`AssignGenMixin.define_min_width()`(8/16/32/64)、`require_cdefine_storable()`; + - 字面量宽度后缀:`ExprGenConst.vp` "integer literal ... does not fit the explicit width"; + - 常量窄化:`ExprGenBase.vp` "narrowing truncation overflow" / "implicit narrowing is forbidden"; + - 数组/字符串初始化长度:`AssignGenMixin.vp` 1960/1970; + - NameError:未导入模块的调用 `CallGenMixin.vp:253`、static 跨模块、未注册全局变量等。 +- `LspSymbols.Analyze()` 已经提供作用域、声明、occurrence、import/wildcard、注解详情, + hover/跳转/重命名都建立在它上面;语义诊断可以复用,不需要另写一遍解析。 +- `cif`/`celif`/`celse` 没有独立 AST 节点:parser 把它们 desugar 成普通 `If`, + 所以 LSP 会把所有分支的声明合并进同一作用域。这直接影响 NameError 的误报策略。 +- 已知缺陷:数字字面量宽度后缀(`128_u8`/`128_int8`)lexer 实际不识别—— + `_read_number` 的数字循环先把 `_` 当分隔符吃掉,`_read_width_suffix` 再看时已经没有下划线。 + 编译器里对应的宽度后缀检查因此是死代码。 + +## 2. 目标 / 非目标 + +目标: + +1. LSP 推送与拉取诊断统一包含:语法、名称解析、位宽/存储三类问题。 +2. 每条诊断有稳定 `code`、精确 range、严重级别、可选的 relatedInformation / 快速修复。 +3. 规则与编译器语义一致;不一致的地方必须是有意为之并写进文档。 +4. 对仓库自身代码(能正常编译)零误报。 +5. 大文件诊断有明确时间/数量上限,不阻塞编辑。 + +已定决策: + +- 字面量宽度后缀 `_iN/_uN/_intN/_uintN` 接受任意正整数 N(LLVM/MLIR 任意位宽), + 语言层不设 1..64 限制;仅 `int` 溢出时视为非法后缀。AST 值域仍是 64 位。 +- 编译器与 LSP 共用 `App/lib/types/IntFacts.vp`(宽度/范围/最小二幂宽度/最小可存宽度)。 +- LSP 展示层只给二幂位宽(u8/u16/.../u128/...),不推荐任意 iN; + 源里显式写出的 `_u100` 属于用户声明,原样回显。 +- 诊断级别采用 Pylance 式多模式:`off / basic / standard / strict`,默认 `standard`(普通)。 + 确定规则(字面量越界、数组长度)在 basic/standard/strict 下都是 error; + 启发式规则(NameError/import)在 basic 下关闭、standard 下 warning、strict 下 error。 + 模式来源:`initializationOptions.typeCheckingMode` 与 + `workspace/didChangeConfiguration` 的 `settings.viper.typeCheckingMode`(兼容 Pylance 命名)。 + +非目标: + +- 不实现完整类型检查器、生命周期/借用检查、泛型推断。 +- 不在每次击键时调用编译器;LSP 诊断是独立子集,不替代编译错误。 +- 不把 `Vipeye` 引入服务端。 +- 不实现大于 64 位字面量数值的大整数解析(N 只决定类型宽度,值仍限 64 位)。 + +## 3. 架构 + +新增 `ViperC/App/lib/lsp/LspChecks.vp`: + +``` +LspDiag { sl, sc, el, ec, severity, code, message, related_line, related_file } +Check(pool, doc, a, ws, out) -> int # 规则调度;每条规则 append +``` + +- `LspDiagnostics` 的 `_BuildItems` 改为「语法诊断 + 语义诊断」合并, + `publish` 与 pull 共用同一条路径。 +- 去重键 `(code, start, end)`;总量上限 500;超限追加一条提示并可配置关闭。 +- 每条诊断带 `code`,例如 `name-undefined` / `literal-width-overflow` / `decl-narrowing` / + `array-init-length` / `module-not-found` / `missing-import-member`。 +- 规则按严重级别:确定结论用 `error`,启发式先用 `warning`(见决策)。 +- 缓存:按文档 `version` 缓存 `LspAnalysis + 诊断结果`,push/pull 不重复分析。 +- 预算:规则循环内检查计数与耗时(`os.clock` 或现有计时设施),超预算只返回已完成部分。 + +宽度事实表: + +- 纯逻辑模块 `App/lib/Types/IntFacts.vp`(不依赖 lsp/core 任一侧): + - 类型名 → `{bits, signed, min, max}` + - `min_storable_width(value)`(与 `define_min_width` 对齐:8/16/32/64) + - 字面量 → 无符号幅值 + 后缀宽度 + - 浮点可表示性(与 hover 的 f16/f32/f64 判定共用) +- 由编译器和 LSP 共同 import(推荐),或 LSP 先本地实现 + parity 测试(见决策)。 +- hover 的 `uint/int/float` 行改为调用同一张表,保证 hover 与诊断数字一致。 + +## 4. 分阶段 + +### P0 诊断基础设施(0.5–1 天) + +- 定义 `LspDiag`、JSON 构造、合并、去重、code/severity/related。 +- 接入 `LspDiagnostics.Publish/Pull`;语法诊断仍走原路径。 +- 新增 `probe.py --gate13`:先放一条确定性规则(字面量宽度后缀)做端到端验证。 +- 验收:gate13 能收到带 code/range 的诊断;gate1–12 不回归。 + +### P1 位宽与存储(1–2 天) + +前置(已完成):修 `includes/ast/lexer.vp` 的宽度后缀;新增 `IntFacts` 共用表。 + +规则: + +1. `literal-width-overflow`:显式后缀 `_u8/_int8/.../_uint64` 与幅值不符 → + error;范围 `[0, 2^n-1]` / `[-2^(n-1), 2^(n-1)-1]`;消息与 `ExprGenConst.vp:79` 对齐。 +2. `decl-narrowing`:带注解的声明/字段/全局/define 用字面量初始化且越界 → error。 + 只处理内建整数类型与已知别名(`char/uchar/uint/int/long/sizet` 等);typedef、泛型、指针跳过。 +3. `assign-narrowing`:`x = 字面量`,`x` 解析到带注解符号且越界 → error。 +4. `neg-literal`:`-1` 形式;只处理一元负号 + 字面量,不做常量折叠。 +5. `array-init-length`:`[N]T` 字符串初始化 `strlen+1 > N`,或列表元素数 ≠/ > N → error + (与编译器消息一致)。 +6. `float-narrowing`:先核对编译器对 `x: float = 3.5` 是否放行,再决定是否实现;不猜想。 +7. 64 位以上字面量:lexer 已有溢出诊断,确保不重复上报。 + +验收:fixture 覆盖每个规则的正/反例;仓库自身代码零 error。 + +### P2 NameError(2–3 天) + +前置: + +- `LspSymbols` 给模块路径 occurrence 打标记(`is_module_path`),避免把模块段当未定义名。 +- 建立内置名白名单(`int/bool/str/bytes/...`、转换函数、`len/sizeof/defined` 等); + 优先从编译器内建表生成,避免手抄漂移。 +- `defined(NAME)` 的参数不算未定义。 + +规则: + +1. `name-undefined`:load 上下文的裸标识符,`sym/head` 都为空,且不是内建/类型/泛型参数/ + 模块路径/`defined` 参数 → error(或首版 warning)。 +2. `unimported-module-call`:`mod.f(...)` 中 `mod` 是工作区已知模块但本文件未 import → + error;对齐 `CallGenMixin.vp:253`。 +3. 快速修复:按工作区索引找同名导出符号,提供「添加 import」;最多 3 条 did-you-mean。 + +抑制规则(必须先实现,否则误报会失控): + +- 文件存在无法解析的 `from X import *`:该文件跳过 `name-undefined`。 +- `cif/celif/celse`:按所有分支的并集解析;任一分支绑定过就不报。 +- 无法判定的 `cif` 条件:跳过整条链。 +- 标识符位于属性链(`a.b` 的 `b`)→ 交给成员解析,不报 name-undefined。 +- 调用目标:编译器允许未知名字退化成外部 C 函数,v1 默认不报裸调用; + 只报已知模块未导入的调用(见决策 1)。 +- 泛型参数、`self/cls`、`c.` 内建调用跳过。 + +暂不做:use-before-assignment、未使用变量/导入(后续独立规则)。 + +### P3 导入/模块诊断(1–2 天) + +- `module-not-found`:`import`/`from` 解析不到文件且无 wildcard 兜底 → warning + (工作区 roots 可能不完整,先用 warning;gate10 的 `ghost_lib` 夹具改为预期 warning)。 +- `missing-import-member`:模块存在但 `from mod import name` 不存在;含 `__init__` 重导出、 + wildcard 链、`static` 跨模块规则。 +- `import-cycle`:wildcard 重导出环 → warning(现在的 hop 上限只保证不挂死)。 + +### P4 质量与交付(1–2 天) + +- `gate13` 固化:每个 code 一条正例 + 一条反例,断言 code/range/severity/message 关键字。 +- 误报语料:对 `App/main.vp`、`App/lib/lsp/*.vp`、`includes/_io.vp` 等能编译的文件跑 pull, + 断言 0 error;脚本化进 gate13。 +- 性能:2k 行文件 pull p95 ≤ 30 ms;10k 行有预算截断;重复 pull 命中缓存。 +- 文档:`Tools/lsp/README` 增加诊断 code 表、抑制条件、已知差异。 +- 可选的 code action:加 import、加宽类型、显式 cast。 + +## 5. 风险与对策 + +| 风险 | 对策 | +| --- | --- | +| 误报摧毁信任 | 启发式先 warning;语料零误报门禁;每条规则可单独关闭 | +| LSP 与编译器规则漂移 | 共享 `IntFacts`,或 parity fixture 双向对比 | +| `cif` 双分支造成 NameError 假阳/假阴 | 分支并集 + 无法判定就跳过 | +| wildcard 未知导出 | 有未解析 wildcard 时禁用 name-undefined | +| 大文件诊断慢 | 缓存 + 预算 + 上限;只在打开/变更的文档上跑 | +| 索引未就绪导致跨文件误报 | 诊断前确保索引可用;解析失败时不产生跨文件结论 | +| 编译器允许隐式 extern | 裸调用名默认不报,只报已知模块未导入 | + +## 6. 依赖与顺序 + +1. P0 基础设施 +2. P1 前置:lexer 宽度后缀修复 +3. P1 位宽规则(与 hover 共用事实表) +4. P2 NameError(先白名单/抑制,再规则) +5. P3 导入诊断 +6. P4 语料/性能/文档 + +每个阶段结束:`ast_check` → 一次 `--build-self` → 对应 gate + 现有 gate1–12 → 部署 → +单独提交。不在同一阶段内反复重建。 + +## 7. 需要拍板的点 + +已拍板: + +1. 宽度表:编译器与 LSP 共用 `IntFacts`。 +2. 后缀:任意正整数 N,语言层不限制。 +3. LSP 位宽展示:只给二幂位宽。 +4. 诊断级别:Pylance 式 off/basic/standard/strict,默认 standard。 + +仍待确认: + +1. NameError 首版是否包含裸函数调用(编译器允许未知调用退化为外部 C 函数,建议不报)。 +2. `module-not-found` 是否默认 warning(工作区 roots 可能不完整,建议 warning)。 diff --git a/Tools/lsp/fixtures/gate10_use.vp b/Tools/lsp/fixtures/gate10_use.vp index 1100cfa..9c69772 100644 --- a/Tools/lsp/fixtures/gate10_use.vp +++ b/Tools/lsp/fixtures/gate10_use.vp @@ -70,6 +70,22 @@ def binary_use() -> int: return 0b1010 +def separator_use() -> int: + return 1_000 + + +def suffix_use() -> int: + return 128_u8 + + +def wide_suffix_use() -> int: + return 1_u100 + + +def small_suffix_use() -> int: + return 128_u7 + + def float_use() -> float: return 3.5 diff --git a/Tools/lsp/probe.py b/Tools/lsp/probe.py index 6d2e1f5..e218c55 100644 --- a/Tools/lsp/probe.py +++ b/Tools/lsp/probe.py @@ -1755,6 +1755,17 @@ def gate10(exe: str) -> int: for piece in ("dec 10", "hex 0xA", "binary 0b1010"): if piece not in bin_hover: raise AssertionError(f"binary hover lacks {piece!r}: {bin_hover}") + sep_hover = hover_text(at("return 1_000", 7)) + if "dec 1000" not in sep_hover: + raise AssertionError(f"digit separator changed the value: {sep_hover}") + for needle, expected in ( + ("return 128_u8", "declared u8 (fits)"), + ("return 1_u100", "declared u100 (fits)"), + ("return 128_u7", "declared u7 (overflow)"), + ): + suffix_hover = hover_text(at(needle, 7)) + if expected not in suffix_hover: + raise AssertionError(f"suffix hover lacks {expected!r}: {suffix_hover}") float_hover = hover_text(at("return 3.5", 7)) if "hex 0x400C000000000000" not in float_hover: raise AssertionError(f"float hover has the wrong bit pattern: {float_hover}")