Skip to content

fix(lsp): correctness bugs in positions, lookups, and session state#1366

Draft
ciaran-matthew-dunne wants to merge 6 commits intoDeducteam:masterfrom
ciaran-matthew-dunne:pr/lsp-correctness
Draft

fix(lsp): correctness bugs in positions, lookups, and session state#1366
ciaran-matthew-dunne wants to merge 6 commits intoDeducteam:masterfrom
ciaran-matthew-dunne:pr/lsp-correctness

Conversation

@ciaran-matthew-dunne
Copy link
Copy Markdown
Contributor

  1. path_of_file: a one-character typo paired the longer
    module-path prefix with the shorter filesystem prefix in the
    selected library mapping, so every URI the server emitted for a
    mapped file had its directory component doubled
    (Stdlib/Stdlib/Nat.lp).

  2. Tactic error messages: handle_tactic prepended
    Pos.popt_to_string to the error string. The diagnostic already
    carries the position as its range, so clients rendered
    file:line:col twice. Mirrors handle_command / end_proof.

  3. Go-to-definition and hover:

    • External symbols landed at line 1 of the target file (URI vs
      raw path comparison always failed).
    • M.foo resolved wrong — local foo shadowed it; require M as A; A.foo didn't resolve at all. Route through
      Sig_state.find_sym.
    • Hover returned a range shifted one column left (stale
      column - 1 from 1-based days).
    • Error-position fallback sent -1 on the wire. Add
      Pos.file_start helper and clamp in mk_range.
  4. Timed state between documents: initial_state restores
    Time and re-applies the opened file's package config, wiping
    other open docs' library mappings. A subsequent request on the
    older doc resolved paths against the newer doc's mappings. Call
    Pure.restore_time at the start of every affected handler.

  5. Session-lifetime memory leak: do_close removed the URI
    from doc_table but not completed_table, so every file the
    client ever opened kept its fully-elaborated Lp_doc.t alive
    for the process lifetime.

  6. Success hints attached to wrong range: severity-4 "OK"
    hints spanned the whole command/tactic, so the success underline
    covered symbol bodies, proofs, and rule RHSs. Narrow to the
    declared name (symbol/inductive/opaque) or the leading
    keyword.

@ciaran-matthew-dunne ciaran-matthew-dunne marked this pull request as draft April 20, 2026 21:36
@fblanqui
Copy link
Copy Markdown
Member

Hi @ciaran-matthew-dunne . Thanks for your PR! Let me know when it is ready for review.

path_of_file iterates every library mapping and keeps the one whose
filesystem prefix is the longest match. Inside the update branch, the
longer module-path prefix (mp) was being paired with the SHORTER
filesystem prefix (p) from the previous candidate instead of the new,
longer one (fp):

    | Some(_, p) ->
        if String.(length p < length fp) then mapping := Some(mp, p)
                                                              ^
                                                              should be fp

Consequence: for <root>/Stdlib/Nat.lp under [Stdlib] -> <root>/Stdlib,
the chosen mp was ["Stdlib"] but the stored fs prefix stayed <root>.
The remainder computation saw "Stdlib/Nat" and the final module path
came out as ["Stdlib"; "Stdlib"; "Nat"].

Via sym_path, this doubled every qualified reference the signature
emitted; in the LSP it appeared as go-to-def URIs with a duplicated
directory component.
`Pure.handle_tactic` prepended `Pos.popt_to_string p ^ " "` to the
error message before returning `Tac_Error(Some p, ...)`. The LSP
diagnostic already carries the position as its range, so clients
rendered the file:line:column twice — once as the underline and again
inside the message body.

Mirror `handle_command` and `end_proof`, which return the raw message
unchanged.
Four correctness bugs in the same two handlers. Three of them share a
root cause: the code carried its own hand-rolled conversions (URI vs
path, short-name vs qualified lookup, 1-based vs 0-based columns)
instead of reusing the helpers that the rest of the codebase already
uses.

1. External symbols landed at line 1 of the target file. do_definition
   compared sym_pos.fname (raw path) against doc.uri (URI). The check
   always failed for external symbols and the code fell back to a
   synthesized line-1 position. Use sym_pos directly when present, and
   normalize mk_definfo's file argument into a URI.

2. Qualified references M.foo resolved wrong. A local foo shadowed the
   qualified target, and aliased paths (require M as A; A.foo) never
   resolved at all. The hand-rolled lookup tried short-name first and
   didn't know about aliases. Route both handlers through a new
   Pure.find_sym that wraps Sig_state.find_sym, which already has the
   correct precedence and handles aliases.

3. Hover returned a range shifted one column to the left. hover_symInfo
   was the only handler still building its range JSON by hand, with
   its own column math that had drifted out of sync with the rest of
   the server (a stale `column - 1` from when columns were 1-based).
   Replace the hand-rolled block with a new helper
   LSP.mk_range_of_interval — the same Range.t -> LSP-JSON conversion
   that diagnostics and go-to-def already go through.

4. Error-position fallback sent -1 on the wire. When initial_state
   raised, new_doc synthesized a fallback Pos.pos with start_line = 0.
   The 1-based -> 0-based conversion turned that into -1, which strict
   clients reject (LSP positions are uinteger). Introduce
   Pos.file_start for the fallback, and clamp negative values to 0 in
   mk_range / mk_range_of_interval as a defense-in-depth measure.

Drive-by cleanups in the same handlers:
- Generalize get_symbol from qident-specific to 'a RangeMap.t.
- Wrap the hover/definition dispatch in try _ with _ -> null so a
  bogus request can't hang the handler.
- Replace process_input's fake-goal_info fallback with plain null.
- Drop the msg_fail helper (unused after the rewrite).
- Use Lplib.String.is_prefix for the file:// URI guard, matching
  Lp_doc and Common.Library.

UTF-16 note: LSP columns are UTF-16 code units; Lambdapi columns are
codepoints. For every character Lambdapi syntax actually uses (ASCII
plus BMP glyphs like ℕ → ≔ ↪) they coincide, so no conversion is
needed. Supplementary-plane characters would require negotiation, left
as a follow-up.
`Pure.initial_state` does `Time.restore t0` and then re-applies the
package config for the opened file. That installs the file's library
mappings but wipes out any mappings belonging to other open documents.

As a result, opening doc A, then opening doc B (from a different
package), caused subsequent go-to-def / hover / document-symbol
requests on A to call `Library.file_of_path` against B's mappings —
returning paths that collapse A's module path back to the lib root.
Opening Nat.lp from Binom.lp and then querying inside Binom.lp, for
example, produced URIs like `<lib_root>/Fermat/Binom.lp` instead of
the real `<repo>/benchmarks/fermat/Binom.lp`.

Expose `Pure.restore_time` and call it at the start of
`do_definition`, `hover_symInfo`, and `do_symbols` so each request
reads the timed globals from the target document's saved state rather
than whatever was most recently opened.
do_close removed the URI from doc_table but not from completed_table,
so every file the client had ever opened kept a fully-elaborated
Lp_doc.t — including the pinned signature state and every term the
file produced — alive for the lifetime of the LSP process. In a long
editing session this grows without bound: a modest file can pin tens
of MB of signature data, and nothing ever releases it.

Remove from both tables.
…ommand

Every successful top-level command and tactic emits a severity-4 "hint"
diagnostic so the client can draw a success indicator. The server was
attaching that diagnostic to the range of the WHOLE command or tactic:

  symbol foo : Nat → Nat ≔
    λ x, add x x;         <-- entire span underlined as "OK"

  opaque symbol thm : … ≔ begin
    assume h; apply h      <-- whole proof underlined per tactic
  end;

That range is wrong: the success applies to the *thing being declared*
or *the action that succeeded*, not to the body that follows. Wide
underlines over bodies and proofs also compete visually with the
tactic-error ranges a client cares about.

Attach each hint to the token that actually names the action:
- symbol / inductive / opaque: the declared name.
- rule / unif_rule / coerce_rule / notation / builtin / require /
  require … as / open: the command keyword.
- query commands (assert, compute, type, print, …): the query keyword.
- tactics (refine, assume, rewrite, reflexivity, …): the tactic
  keyword.

Mechanism: new Pure.keyword_pos (takes the start of a Pos.pos and a
length), Pure.query_keyword_len, and Command.get_focus_pos /
Tactic.get_focus_pos pure-syntax helpers. Lp_doc.process_cmd /
process_pstep feed the hint diagnostic from get_focus_pos instead of
get_pos; every other diagnostic path (errors, goal info) is unchanged.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants