Zulip Chat Archive

Stream: lean4

Topic: User defined LSP extensions


Patrick Massot (Aug 12 2024 at 16:08):

@Marc Huisinga @Wojciech Nawrocki is there anyway to experiment adding LSP requests from user code? Is there any plan to add requests asking for the range of the current declaration or current section or current tactic? Or do they already exist? The closest thing I could find in the LSP spec is https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_selectionRange that doesnโ€™t seem to be implemented in the Lean server (and doesnโ€™t seem to allow labels to the returned ranges, so I donโ€™t know how we would distinguish declarations from tactics for instance).

Julian Berman (Aug 12 2024 at 16:13):

This would be very useful for implementing text objects in editors for editing declarations (I think there's a previous thread where this was briefly discussed).

Patrick Massot (Aug 12 2024 at 17:19):

Yes, that's why I'm asking. Because I saw you seemed to be in a lean.nvim mood and I'd like to help.

๐š ๐š˜๐š“๐šŒ๐š’๐šŽ๐šŒ๐š‘ ๐š—๐šŠ๐š ๐š›๐š˜๐šŒ๐š”๐š’ (Aug 12 2024 at 17:37):

Yes, this is possible. The best worked example I know is Mac's Alloy: https://github.com/tydeu/lean4-alloy/blob/master/Alloy/C/Server.lean

Patrick Massot (Aug 13 2024 at 07:00):

Nice, Iโ€™ll take a look.

Patrick Massot (Aug 13 2024 at 09:28):

I didnโ€™t manage to cargo-cult this. I tried

open Lean Server Lsp RequestM JsonRpc

structure CurDeclParams extends TextDocumentPositionParams
  deriving FromJson, ToJson

instance : FileSource CurDeclParams :=
  โŸจfun p => fileSource p.textDocumentโŸฉ

def handleCurDecl (p : CurDeclParams) :
    RequestM (RequestTask String) := do
  return (.pure "Yo")

initialize
  registerLspRequestHandler "textDocument/curDecl" CurDeclParams String handleCurDecl

in a file imported by a file where I send a textDocument/curDecl request, but the only answer I get from the server is Error: No request handler found for 'textDocument/curDecl' (-32601) (note that Iโ€™m able to request a definition in that file for instance).

Julian Berman (Aug 13 2024 at 14:36):

It doesn't work for me either and I'm not sure why, so just +1ing -- but a minor LSP note is that we shouldn't put non-"standard" requests on textDocument/ -- the convention in the Lean server is to use methods that are named like $/lean/currentDecl -- but yeah trying that locally doesn't change anything, and I barely know enough to even debug whether Lean.Server.Lsp.requestHandlers ends up actually containing the method added.

Julian Berman (Aug 13 2024 at 14:37):

(I'm sure you were messing around anyhow, just mentioning.)

Patrick Massot (Aug 13 2024 at 15:37):

The only way I know the handler is kind of registered is that if I try to register it twice then Lean complains the second time.

๐š ๐š˜๐š“๐šŒ๐š’๐šŽ๐šŒ๐š‘ ๐š—๐šŠ๐š ๐š›๐š˜๐šŒ๐š”๐š’ (Aug 13 2024 at 16:27):

@Patrick Massot what happens if you put that snippet in one module and then make the request in another module that imports the first one?

Patrick Massot (Aug 13 2024 at 16:30):

What do you mean by โ€œmake the requestโ€? Do you mean from within Lean or through connection to the LSP server?

Patrick Massot (Aug 13 2024 at 16:30):

I donโ€™t know how to do it from Lean.

Patrick Massot (Aug 13 2024 at 16:31):

Through the server I get the error message I posted above: No request handler found for 'textDocument/curDecl'

Patrick Massot (Aug 13 2024 at 16:32):

I will describe the way Iโ€™m testing this since it could create issues because I donโ€™t know what Iโ€™m doing.

Patrick Massot (Aug 13 2024 at 16:32):

I googled to find a python lib to write LSP clients since hacking something with python is still the fastest way for me to test anything that is not purely within Lean.

Patrick Massot (Aug 13 2024 at 16:32):

I found https://github.com/microsoft/multilspy/

Patrick Massot (Aug 13 2024 at 16:35):

And I wrote https://gist.github.com/PatrickMassot/bc647630058d27ff38105b4157d110ef on top of it. This is 99% copy-paste from https://github.com/microsoft/multilspy/blob/main/src/multilspy/language_servers/jedi_language_server/jedi_server.py

๐š ๐š˜๐š“๐šŒ๐š’๐šŽ๐šŒ๐š‘ ๐š—๐šŠ๐š ๐š›๐š˜๐šŒ๐š”๐š’ (Aug 13 2024 at 16:35):

Oh, I thought you had a way to make custom requests from within nvim or another editor

๐š ๐š˜๐š“๐šŒ๐š’๐šŽ๐šŒ๐š‘ ๐š—๐šŠ๐š ๐š›๐š˜๐šŒ๐š”๐š’ (Aug 13 2024 at 16:36):

What I was trying to suggest with my message is just that initialize blocks behave differently when imported, and you might need the intialize block to be in a different module than the one you are making the request on/to

Patrick Massot (Aug 13 2024 at 16:36):

The above gist, together with https://github.com/microsoft/multilspy/blob/main/src/multilspy/language_servers/jedi_language_server/initialize_params.json also taken from the jedi example is enough for me to require the definition of the token at a given position in a file.

Patrick Massot (Aug 13 2024 at 16:37):

Sure, the initialize is in a different Lean file.

๐š ๐š˜๐š“๐šŒ๐š’๐šŽ๐šŒ๐š‘ ๐š—๐šŠ๐š ๐š›๐š˜๐šŒ๐š”๐š’ (Aug 13 2024 at 16:37):

Ah, you even wrote that :man_facepalming:

Patrick Massot (Aug 13 2024 at 16:37):

That being said, I donโ€™t insist on using python, this is only a prototyping thing.

Patrick Massot (Aug 13 2024 at 16:38):

If you know a way to test a new LSP request from within Lean then it works for me.

Patrick Massot (Aug 13 2024 at 16:38):

I was simply trying to separate the issue of learning Lua and registering a new LSP request.

Patrick Massot (Aug 13 2024 at 16:41):

My next plan is to hack on Lean itself to add more debug information than what is provided in https://github.com/leanprover/lean4/blob/master/src/Lean/Server/Requests.lean#L61

Patrick Massot (Aug 13 2024 at 17:01):

Actually I donโ€™t know how to do this. I cloned the lean4 repo, then followed the instructions at https://lean-lang.org/lean4/doc/make/index.html and then typed elan toolchain link lean4-stage0 build/release/stage0 and created a Lean project using lake +lean4-stage0 new tmp but then lake build in the tmp folder says

error: ././lakefile.lean:2:5: error: unknown namespace 'Lake'
error: ././lakefile.lean:4:0: error: unexpected identifier; expected command
error: ././lakefile.lean:10:17: error: unexpected identifier; expected 'abbrev', 'axiom', 'binder_predicate', 'builtin_initialize', 'class', 'def', 'elab', 'elab_rules', 'example', 'inductive', 'infix', 'infixl', 'infixr', 'initialize', 'instance', 'macro', 'macro_rules', 'notation', 'opaque', 'postfix', 'prefix', 'structure', 'syntax' or 'theorem'
error: ././lakefile.lean: package configuration has errors

Julian Berman (Aug 13 2024 at 17:59):

Patrick I'm not at a comp but the way you make lsp requests in neovim is e.g. :lua vim.print(vim.lsp.get_active_clients()[1].request_sync('textDocument/curDecl', { foo = 37 })) which you run from within the buffer of a .lean file.

Julian Berman (Aug 13 2024 at 17:59):

The second arg is your params

Julian Berman (Aug 13 2024 at 18:00):

(also be aware I think there's a minor Lean bug that the server will crash if you send it nil (i.e. leave off the second arg) so if it seems like stuff is blowing up that's a reason why)

Julian Berman (Aug 13 2024 at 18:01):

But otherwise this is what I tried earlier and got the same message you did.

Julian Berman (Aug 13 2024 at 19:02):

OK and now that I'm back to a computer, I have something "working" if I hack it into the server

Julian Berman (Aug 13 2024 at 19:04):

Specifically

diff --git a/src/Lean/Server/FileWorker/RequestHandling.lean b/src/Lean/Server/FileWorker/RequestHandling.lean
index 9e5c7f42dd..2a09a363f2 100644
--- a/src/Lean/Server/FileWorker/RequestHandling.lean
+++ b/src/Lean/Server/FileWorker/RequestHandling.lean
@@ -229,6 +229,10 @@ def handleDefinition (kind : GoToKind) (p : TextDocumentPositionParams)
         locationLinksOfInfo kind infoWithCtx snap.infoTree
       else return #[]

+def handleCurrentDecl (p : TextDocumentPositionParams) :
+    RequestM (RequestTask String) := do
+      return (.pure "Yo")
+
 open RequestM in
 def getInteractiveGoals (p : Lsp.PlainGoalParams) : RequestM (RequestTask (Option Widget.InteractiveGoals)) := do
   let doc โ† readDoc
@@ -753,5 +757,10 @@ builtin_initialize
     PlainTermGoalParams
     (Option PlainTermGoal)
     handlePlainTermGoal
+  registerLspRequestHandler
+    "$/lean/currentDecl"
+    TextDocumentPositionParams
+    String
+    handleCurrentDecl

then building lean (in my case HEAD does not seem to build because of some UInt issues? But using 4.11.0-rc2 does)

and then :lua vim.print(vim.lsp.get_active_clients()[1].request_sync('$/lean/currentDecl', vim.lsp.util.make_position_params()))

gives me

{
  result = "Yo"
}

Patrick Massot (Aug 13 2024 at 20:24):

How did you manage to make a project using your custom Lean build?

Henrik Bรถving (Aug 13 2024 at 20:26):

Patrick Massot said:

How did you manage to make a project using your custom Lean build?

If you registered your local build as a toolchain with elan you can just put it into a lean-toolchain file and work with it

Matthew Ballard (Aug 13 2024 at 20:27):

Or you can use elan override set my_custom_toolchain and not touch lean-toolchain

Patrick Massot (Aug 13 2024 at 20:27):

Do you see any mistake in my description at https://leanprover.zulipchat.com/#narrow/stream/270676-lean4/topic/User.20defined.20LSP.20extensions/near/462165051?

Patrick Massot (Aug 13 2024 at 20:29):

Should I also have a stage1 somehow?

Patrick Massot (Aug 13 2024 at 20:30):

Ah yes, it seems I need to link stage1

Henrik Bรถving (Aug 13 2024 at 20:30):

https://lean-lang.org/lean4/doc/dev/index.html#dev-setup-using-elan

# in the Lean rootdir
elan toolchain link lean4 build/release/stage1
elan toolchain link lean4-stage0 build/release/stage0

Patrick Massot (Aug 13 2024 at 20:33):

I think I misread an old zulip message earlier. I can now use my custom toolchain. Thanks Henrik and Matthew!

Patrick Massot (Aug 13 2024 at 20:34):

I managed to get some extra info in the error message, but it is only telling me that, at the time the LSPโ€ฏrequest is processed, Lean indeed doesnโ€™t know my handler. But it doesnโ€™t tell me whether it forgot about it or didnโ€™t come across it.

Patrick Massot (Aug 13 2024 at 20:35):

Does anyone underdstand how alloy manages to convince Lean that its extra handlers are worth using?

Patrick Massot (Aug 13 2024 at 20:44):

And indeed I can reproduce Julianโ€™s success with adding the request handler inside the lean4 code base. At least Iโ€™ll be able to play with trying to make those request actually return something useful. Itโ€™s even semi-quick because the server file we are editing is so high in the build tree that compiling lean after modifying it is pretty fast.

Julian Berman (Aug 13 2024 at 20:45):

Patrick Massot said:

How did you manage to make a project using your custom Lean build?

(I just did PATH=$PWD/stage1/bin:$PATH and started nvim in my case)

Julian Berman (Aug 13 2024 at 20:48):

Patrick Massot said:

Does anyone underdstand how alloy manages to convince Lean that its extra handlers are worth using?

I think maybe someone should confirm alloy actually works just to be sure

Julian Berman (Aug 13 2024 at 20:48):

I didn't yet. But glad you have something temporarily useful

Henrik Bรถving (Aug 13 2024 at 20:53):

Julian Berman said:

Patrick Massot said:

Does anyone underdstand how alloy manages to convince Lean that its extra handlers are worth using?

I think maybe someone should confirm alloy actually works just to be sure

It does

Patrick Massot (Aug 14 2024 at 15:29):

New information from the FRO office hour: it looks like one cannot add a LSP request type from user space. What Alloy is doing is chaining request handlers that do their work after a builtin handler.

Julian Berman (Aug 14 2024 at 16:41):

Was there by any chance any alternative proposed (either a future ability to do this, or else any interest in the language server growing this capability)?

Patrick Massot (Aug 14 2024 at 17:27):

Yes, there is some hope. But we won't know for sure until Marc comes back from vacation since he is in charge of that corner of Lean.

Patrick Massot (Aug 14 2024 at 19:18):

Julian Berman said:

Patrick I'm not at a comp but the way you make lsp requests in neovim is e.g. :lua vim.print(vim.lsp.get_active_clients()[1].request_sync('textDocument/curDecl', { foo = 37 })) which you run from within the buffer of a .lean file.

I was not able to get this to return anything. Can you get anything if you put in your Lean4 file that we were tweaking:

def handleCurrentDecl (p : TextDocumentPositionParams) :
    RequestM (RequestTask <| Option Range) := do
  let doc โ† readDoc
  let text := doc.meta.text
  let hoverPos := text.lspPosToUtf8Pos p.position
  withWaitFindSnap doc (fun s => s.endPos > hoverPos)
    (notFoundX := pure none) fun snap => do
  let some rg := snap.stx.getRange? | return none
  return some <| rg.toLspRange text

builtin_initialize
  registerLspRequestHandler
    "$/lean/currentDecl"
    TextDocumentPositionParams
    (Option Range)
    handleCurrentDecl

Patrick Massot (Aug 14 2024 at 19:20):

I try to call it replacing your {foo = 37} with { textDocument = { uri = "file:///home/pmassot/lean/tmp/Tmp/Basic.lean" }, position = { line = 0, character = 17 } } where the file Tmp/Basic.lean indeed exists and my python code gets an answer. But vim always report an empty result.

Patrick Massot (Aug 18 2024 at 09:29):

@Julian Berman any idea here?

Julian Berman (Aug 18 2024 at 13:52):

Sorry, missed that -- yes @Patrick Massot I do get something here, if I try it on some file with def foo := 12 I get the expected response:

{
  result = {
    ["end"] = {
      character = 13,
      line = 0
    },
    start = {
      character = 0,
      line = 0
    }
  }
}

Julian Berman (Aug 18 2024 at 13:54):

You tried using vim.lsp.util.make_position_params() as the parameter instead of manually passing them just to be sure of no typos? Even though I don't see any there presuming that's the file path and it's at least 17 chars long...

Julian Berman (Aug 18 2024 at 13:55):

Oh, also use :LspInfo in your lean file and check that for you it says something similar to what it says for me, namely that my Lean is... cmd: /Users/julian/Development/lean4/build/release/stage1/bin/lake serve -- /Users/julian/Development/lean4 /Users/julian/Development/lean4/src and not that you're talking to the non-custom Lean

Patrick Massot (Aug 18 2024 at 14:17):

:LspInfo says it called elan, but I know elan its doing its job, otherwise lean would complain about an unknown request.

Patrick Massot (Aug 18 2024 at 14:17):

Oh, today it works!

Patrick Massot (Aug 18 2024 at 14:18):

I really donโ€™t understand what happened the other day.

Patrick Massot (Aug 18 2024 at 14:18):

I am still interested in learning more about your mysterious hint: โ€œusing vim.lsp.util.make_position_params() as the parameter instead of manually passing them just to be sure of no typosโ€. I donโ€™t know how to do that.

Patrick Massot (Aug 18 2024 at 14:19):

I tried :lua vim.lsp.util.make_position_params() but I get a mysterious table: 0x733ce1788760.

Patrick Massot (Aug 18 2024 at 14:20):

I would be really nice to call a lsp request with the current cursor position as argument.

Julian Berman (Aug 18 2024 at 14:44):

Do :lua vim.print(vim.lsp.util.make_position_params())

Julian Berman (Aug 18 2024 at 14:44):

You are spoiled Patrick

Julian Berman (Aug 18 2024 at 14:45):

Lua is a silly little language

Julian Berman (Aug 18 2024 at 14:45):

It does not know how to print its own tables (dictionaries)

Julian Berman (Aug 18 2024 at 14:45):

So unlike in Python when you print {"foo": 12, "bar": 37} you see what you expect to see

Julian Berman (Aug 18 2024 at 14:45):

In Lua you see nothing but that memory address, it has no way of printing that table, and you get to write it yourself if you wish by writing for k, v in pairs(yourtable) print(k, v) end and handling all the recursive cases, and etc. -- but neovim has a version of that function which does work to print anything, it's vim.print.

Julian Berman (Aug 18 2024 at 14:46):

So vim.lsp.util.make_position_params() is indeed a function which builds exactly those TextDocumentPositionParams that you wrote by hand, but it does it automatically using the current cursor position, as you want.

Julian Berman (Aug 18 2024 at 14:51):

(So even more explicitly, you can write :lua vim.print(vim.lsp.get_active_clients()[1].request_sync('$/lean/currentDecl', vim.lsp.util.make_position_params())))

Patrick Massot (Aug 18 2024 at 15:43):

This memory address thing is what I suspected. Thanks.

Patrick Massot (Aug 18 2024 at 15:43):

I'm away from my computer but I will try later.

Patrick Massot (Aug 22 2024 at 20:39):

@Julian Berman I wasnโ€™t able to use your trick because I donโ€™t know how to merge two tables in Lua (and internet seems to say there is no library function doing that)! I mean doing what the python dictionary update method does.

Patrick Massot (Aug 22 2024 at 20:39):

But anyway, I went back to this tonight, and I think Iโ€™ve reached a stage where Iโ€™ll need your help.

Patrick Massot (Aug 22 2024 at 20:41):

Here is how you can play. In your copy of Lean 4, in https://github.com/leanprover/lean4/blob/master/src/Lean/Server/FileWorker/RequestHandling.lean, right before the registrations at the end, paste:

structure TextDocumentCustomQueryAtParams extends TextDocumentPositionParams where
  /-- The kind of elements we are looking for. -/
  kind : String
  deriving ToJson, FromJson

instance : FileSource TextDocumentCustomQueryAtParams := โŸจfun p โ†ฆ fileSource p.textDocumentโŸฉ

/-- Placeholder for requesting some LSP range at the current cursor position.
This placeholder will always return `none`. It is expected to be
chained by some user-defined handler.
-/
def handleQueryRangeAt (_p : TextDocumentCustomQueryAtParams) :
    RequestM (RequestTask <| Option Range) := do
  return .pure none

/-- Placeholder for requesting some LSP position at the current cursor position.
This placeholder will always return `none`. It is expected to be
chained by some user-defined handler.
-/
def handleQueryPositionAt (_p : TextDocumentCustomQueryAtParams) :
    RequestM (RequestTask <| Option Lsp.Position) := do
  return .pure none

/-- Placeholder for requesting some string at the current cursor position.
This placeholder will always return `none`. It is expected to be
chained by some user-defined handler.
-/
def handleQueryStringAt (_p : TextDocumentCustomQueryAtParams) :
    RequestM (RequestTask <| Option String) := do
  return .pure none

and then start the builtin_initialize block at the end with

builtin_initialize
  registerLspRequestHandler
    "$/lean/rangeAt"
    TextDocumentCustomQueryAtParams
    (Option Range)
    handleQueryRangeAt
  registerLspRequestHandler
    "$/lean/positionAt"
    TextDocumentCustomQueryAtParams
    (Option Lsp.Position)
    handleQueryPositionAt
  registerLspRequestHandler
    "$/lean/stringAt"
    TextDocumentCustomQueryAtParams
    (Option String)
    handleQueryStringAt

Patrick Massot (Aug 22 2024 at 20:42):

This creates three place-holders custom LSP requests. They each take a position (that should be the cursor position) and a string which is meant to indicate what kind of element we are requesting. Then the server should return an optional range, position or string (the string case is mostly meant for debugging).

Patrick Massot (Aug 22 2024 at 20:43):

Now the nice thing is that you only need to compile Lean once. Then youโ€™ll be able to implement handlers in user space.

Patrick Massot (Aug 22 2024 at 20:44):

For instance, you can start a new Lean project using our custom toolchain and have a file there containing:

import Lean

open Lean Server Lsp FileWorker RequestM

/-- Is this the `SyntaxNodeKind` of a tactic syntax? Currently a very crude heuristic. -/
def Lean.SyntaxNodeKind.isTactic (kind : SyntaxNodeKind) : Bool :=
  Name.isPrefixOf `Lean.Parser.Tactic kind

/-- In the given syntax stack, find the first item satisfying the condition `cond`
and run `code` on it. Return `none` if no such item exists.  -/
def Lean.Syntax.Stack.find? (stack : Syntax.Stack) (cond : Lean.Syntax โ†’ Bool) {m : Type โ†’ Type} [Monad m] {ฮฑ : Type}
    (code : Lean.Syntax โ†’ m (Option ฮฑ)) : m (Option ฮฑ) := do
  for (stx, _) in stack do
    if cond stx then
      return โ† code stx
  return none

/-- In the given syntax stack, find the first item satisfying the condition `cond`
and return its LSP range (using the given `FileMap`). Return `none` if no such item exists.  -/
def Lean.Syntax.Stack.findRange? (stack : Syntax.Stack) (cond : Lean.Syntax โ†’ Bool)
    {m : Type โ†’ Type} [Monad m] (map : FileMap) : m (Option Lsp.Range) :=
  stack.find? cond fun stx โ†ฆ return stx.getRange?.map fun rg โ†ฆ rg.toLspRange map

def handleRangeAt  (p : TextDocumentCustomQueryAtParams) (_prev : RequestTask (Option Range)):
    RequestM (RequestTask <| Option Range) := do
  let doc โ† readDoc
  let text := doc.meta.text
  let hoverPos := text.lspPosToUtf8Pos p.position
  withWaitFindSnap doc (fun s => s.endPos > hoverPos) (notFoundX := pure none) fun snap => do
  let some stack := snap.stx.findStack? (ยท.getRange?.any (ยท.contains hoverPos)) | return none
  match p.kind with
  | "declaration" =>
    stack.findRange? (ยท.getKind = `Lean.Parser.Command.declaration) text
  | "tactic" =>
    stack.findRange? (ยท.getKind.isTactic) text
  | "tacticSeq"  =>
    stack.findRange? (ยท.getKind = `Lean.Parser.Tactic.tacticSeq) text
  | "binder" =>
    stack.findRange? (ยท.getKind โˆˆ [`Lean.Parser.Term.explicitBinder,`Lean.Parser.Term.implicitBinder]) text
  | _ => return none


deriving instance Repr for Lsp.Position
deriving instance Repr for Lsp.Range

def handleStringAt  (p : TextDocumentCustomQueryAtParams) (_prev : RequestTask (Option String)):
    RequestM (RequestTask <| Option String) := do
  let doc โ† readDoc
  let text := doc.meta.text
  let hoverPos := text.lspPosToUtf8Pos p.position
  withWaitFindSnap doc (fun s => s.endPos > hoverPos) (notFoundX := pure none) fun snap => do
  if p.kind == "printStack" then
    let some stack := snap.stx.findStack? (ยท.getRange?.any (ยท.contains hoverPos)) | return none
    let mut res := ""
    for (stx, _) in stack do
      let some rg := stx.getRange? | continue
      res := s!"{res}\n\n{stx.getKind}\n{stx}\n{repr <| rg.toLspRange text}"
    return some res
  else
    return none

initialize
  chainLspRequestHandler "$/lean/rangeAt"
    TextDocumentCustomQueryAtParams (Option Range) handleRangeAt

initialize
  chainLspRequestHandler "$/lean/stringAt"
    TextDocumentCustomQueryAtParams (Option String) handleStringAt

Julian Berman (Aug 22 2024 at 20:44):

vim.tbl_extend is the functional versiรณn of update -- it takes some funny options -- you use it like vim.tbl_extend('keep', t1, t2) where the keep option (which can also be 2 other values) means keep the value from t1 when there are dupes -- and it returns the merged table.
I'm away from a computer now but you've obviously got my help, so will look at your instructions later tonight my time.

Patrick Massot (Aug 22 2024 at 20:51):

Nice, it works! So you can call for instance lua vim.print(vim.lsp.get_active_clients()[1].request_sync('$/lean/rangeAt', vim.tbl_extend('keep',vim.lsp.util.make_position_params(), { kind = "tactic"})).result) to get the range of the current tactic syntax. Replace "tactic" with "declaration" to get the current declaration (I mean the declaration surrounding the cursor) or "tacticSeq" to get the current tactic sequence.

Patrick Massot (Aug 22 2024 at 20:52):

The next step needs you. Iโ€™d like to learn how to extend the lean.nvim extension to take this information and make it into a text object etc.

Patrick Massot (Aug 22 2024 at 20:53):

Note the above Lean code is really a proof of concept, it is not meant to be robust and complete.

Patrick Massot (Aug 23 2024 at 18:40):

I was starting to refine my proof of concept code from yesterday. My idea was to replace my three placeholders requests and my match on the kind parameter by a single custom LSP request taking arbitrary Json as input and outputting arbitrary json, with proper environment extension and attribute to register handlers. But as I started to think about the implementation, which means thinking about where I could see similar functionality to copy-paste code, I realized I may be reinventing the server_rpc_method attribute and surrounding framework. @Wojciech Nawrocki, is that correct? Could I simply user this existing framework and have lean.nvim directly calling the LSP request $/lean/rpc/call? @Julian Berman what do you think about this? Could you expose a Lua function that takes arbitrary Json input, add the RPC session id and call $/lean/rpc/call?

Julian Berman (Aug 23 2024 at 18:42):

I was just starting to work out similar questions myself to try to help on the next step.

Julian Berman (Aug 23 2024 at 18:42):

More specifically, lean.nvim's RPC code lives in lua/lean/rpc.lua.

Julian Berman (Aug 23 2024 at 18:42):

So yeah I am trying to recall whether your new handlers should be methods on what we call Subsession, which I think possibly yes.

Julian Berman (Aug 23 2024 at 18:44):

Here for instance is how we implemet goToLocation: https://github.com/Julian/lean.nvim/blob/7bb86aefab286c66c7f30f6ab51e7f235eea269b/lua/lean/rpc.lua#L363-L369

So I think your method will be similar to that (but yeah it's been <10 minutes since I happen to just start looking at this finally)

Julian Berman (Aug 23 2024 at 18:46):

Could you expose a Lua function that takes arbitrary Json input, add the RPC session id and call $/lean/rpc/call?

(So even more explicitly, this function exists, it's called session:call{yourstuff}!)

Patrick Massot (Aug 23 2024 at 18:53):

How would you call that session:call from the vim command mode?

Julian Berman (Aug 23 2024 at 19:03):

E.g. in a Lean buffer:

:lua local params = vim.lsp.util.make_position_params(); local sess = require'lean.rpc'.open(0, params); require'plenary.async'.void(function() vim.print(sess:call('Lean.Widget.getInteractiveTermGoal', params)) end)()

and then look at what you see in :messages for the printed response.

Julian Berman (Aug 23 2024 at 19:03):

(The async nonsense is some synchronous wrapping, I forget if we have a helper to do that more nicely) -- does that help at all as-is?

Julian Berman (Aug 23 2024 at 19:05):

I don't know anything about the Lean side of the framework (how you register new RPC calls) -- maybe you do. I'm trying to teach myself that now.

Julian Berman (Aug 23 2024 at 19:07):

Ah, it's registerRpcProcedure.

Patrick Massot (Aug 23 2024 at 19:17):

There is an attribute to register rpc methods.

Patrick Massot (Aug 23 2024 at 19:18):

Itโ€™s dinner time here, but Iโ€™ll try later.

๐š ๐š˜๐š“๐šŒ๐š’๐šŽ๐šŒ๐š‘ ๐š—๐šŠ๐š ๐š›๐š˜๐šŒ๐š”๐š’ (Aug 23 2024 at 19:31):

@Patrick Massot it would help to know what you are trying to achieve in the end, but yes, since it looks like custom LSP endpoints are not possible to register after all, using @[server_rpc_method] is another way of achieving the same effect. The one downside is that you need to have an RPC session running, but lean.nvim seems to have one already for some of its widget support.

Patrick Massot (Aug 23 2024 at 19:59):

I explained in this thread what Iโ€™m trying to do. One single example is to make a LSP request querying the range of the current declaration.

Patrick Massot (Aug 23 2024 at 20:03):

I am not able to make it work with rpc_method yet. I have

structure Params extends Lean.Lsp.TextDocumentPositionParams where
  /-- The kind of elements we are looking for. -/
  kind : String
  deriving ToJson, FromJson

@[server_rpc_method]
def rpcTest  (_p : Params) :
    RequestM (RequestTask String) := do
  return .pure "Test"

but then using Julianโ€™s magic code lua local params = vim.tbl_extend('keep',vim.lsp.util.make_position_params(), { kind = "tacticSeq"}); local sess = require'lean.rpc'.open(0, params); require'plenary.async'.void(function() vim.print(sess:call('rpcTest', params)) end)() only returns nil.

Julian Berman (Aug 23 2024 at 20:04):

It I think will always return nil because of the async.void -- but it should print something (unless you mean it doesn't do that either) -- let me try your lines now.

Patrick Massot (Aug 23 2024 at 20:10):

I think it prints nil.

Julian Berman (Aug 23 2024 at 20:11):

It was not working, and now it's working here.

Julian Berman (Aug 23 2024 at 20:12):

This works for me: lua local params = vim.lsp.util.make_position_params(); local sess = require'lean.rpc'.open(0, params); require'plenary.async'.void(function() vim.notify(sess:call('rpcTest', vim.tbl_extend('keep', params, { kind = 'range' }))) end)()

Julian Berman (Aug 23 2024 at 20:12):

(I changed to vim.notify because it's more convenient for me than vim.print, I have vim.notify show me a popup with the message -- but that part doesn't matter) -- that line shows me Test

Julian Berman (Aug 23 2024 at 20:16):

Here is a full repository in case that's useful, I'm sure you already have one but just to see what I am running myself: https://github.com/Julian/lspfun -- I am just using one of your MIL files to test and the lines you showed here

Patrick Massot (Aug 23 2024 at 20:17):

Ok, this one works!

Patrick Massot (Aug 23 2024 at 20:19):

Now I really need to learn how to define function to compress that. Editing this line forever in command mode wonโ€™t be fun.

Patrick Massot (Aug 23 2024 at 20:20):

What you I read to avoid asking you help at every step?

Patrick Massot (Aug 23 2024 at 20:20):

Should I search for a neovim extension writing tutorial?

Julian Berman (Aug 23 2024 at 20:21):

Yeah so I really need to write you out some longer instructions, but let me write you something to get you started -- first step, get lean.nvim in "develop" mode so that you can clone the plugin and make changes to it and see those live. This is functionality that lazy.nvim gives you, you just need to tell it where you store your repositories. One second, I'll write up how to do that.

Julian Berman (Aug 23 2024 at 20:24):

These are the key lines: https://github.com/Julian/dotfiles/blob/main/.config/nvim/init.lua#L33-L37 -- in particular in my case I am telling lazy.nvim "when you see a plugin called Julian/<something>, go look at $DEVELOPMENT/something -- if that exists, load the plugin from there -- otherwise, load it from GitHub" -- and my $DEVELOPMENT environment variable is set to where I keep my git repositories. Add that to where you start lazy, and then either clone or fork + clone the repository into that location.

Julian Berman (Aug 23 2024 at 20:24):

Once you have that, go to lua/lean/rpc.lua in that repo and add print("HELLO") somewhere, and open a Lean file, and you should see neovim print HELLO to :messages.

Julian Berman (Aug 23 2024 at 20:25):

At this point you can now simply copy paste what you see in that file to add more methods to the session object.

Julian Berman (Aug 23 2024 at 20:25):

(I will want to write you up some instructions on how to run tests because that is useful instead of manually testing stuff, but it may have to wait until tomorrow).

Patrick Massot (Aug 23 2024 at 20:26):

Do you manage to get any output with the actually useful function

@[server_rpc_method]
def rpcRangeAt  (p : Params) :
    RequestM (RequestTask <| Option Range) := do
  let doc โ† readDoc
  let text := doc.meta.text
  let hoverPos := text.lspPosToUtf8Pos p.position
  withWaitFindSnap doc (fun s => s.endPos > hoverPos) (notFoundX := pure none) fun snap => do
  let some stack := snap.stx.findStack? (ยท.getRange?.any (ยท.contains hoverPos)) | return none
  match p.kind with
  | "declaration" =>
    stack.findRange? (ยท.getKind = `Lean.Parser.Command.declaration) text
  | "tactic" =>
    stack.findRange? (ยท.getKind.isTactic) text
  | "tacticSeq"  =>
    stack.findRange? (ยท.getKind = `Lean.Parser.Tactic.tacticSeq) text
  | "binder" =>
    stack.findRange? (ยท.getKind โˆˆ [`Lean.Parser.Term.explicitBinder,`Lean.Parser.Term.implicitBinder]) text
  | _ => return none

I get a lua error message when I try to call it.

Patrick Massot (Aug 23 2024 at 20:26):

I try it with lua local params = vim.lsp.util.make_position_params(); local sess = require'lean.rpc'.open(0, params); require'plenary.async'.void(function() vim.notify(sess:call('rpcRangeAt', vim.tbl_extend('keep', params, { kind = 'tacticSeq' }))) end)()

Julian Berman (Aug 23 2024 at 20:27):

Can you tell me the imports

Julian Berman (Aug 23 2024 at 20:27):

Lean is confused about findRange?

Patrick Massot (Aug 23 2024 at 20:27):

Sorry about that.

import Lean

open Lean Server Lsp FileWorker RequestM

Julian Berman (Aug 23 2024 at 20:28):

Weird, that's the same imports as I had, but Lean still seems confused... one second.

Patrick Massot (Aug 23 2024 at 20:28):

/-- Is this the `SyntaxNodeKind` of a tactic syntax? Currently a very crude heuristic. -/
def Lean.SyntaxNodeKind.isTactic (kind : SyntaxNodeKind) : Bool :=
  Name.isPrefixOf `Lean.Parser.Tactic kind

/-- In the given syntax stack, find the first item satisfying the condition `cond`
and run `code` on it. Return `none` if no such item exists.  -/
def Lean.Syntax.Stack.find? (stack : Syntax.Stack) (cond : Lean.Syntax โ†’ Bool) {m : Type โ†’ Type} [Monad m] {ฮฑ : Type}
    (code : Lean.Syntax โ†’ m (Option ฮฑ)) : m (Option ฮฑ) := do
  for (stx, _) in stack do
    if cond stx then
      return โ† code stx
  return none

/-- In the given syntax stack, find the first item satisfying the condition `cond`
and return its LSP range (using the given `FileMap`). Return `none` if no such item exists.  -/
def Lean.Syntax.Stack.findRange? (stack : Syntax.Stack) (cond : Lean.Syntax โ†’ Bool)
    {m : Type โ†’ Type} [Monad m] (map : FileMap) : m (Option Lsp.Range) :=
  stack.find? cond fun stx โ†ฆ return stx.getRange?.map fun rg โ†ฆ rg.toLspRange map

Patrick Massot (Aug 23 2024 at 20:28):

Sorry, I thought you had the file I pasted yesterday.

Julian Berman (Aug 23 2024 at 20:29):

Ahh ok, yes sorry I just deleted it and replaced it all with the rpc thing but that clearly was deleting too much. OK let's see...

Julian Berman (Aug 23 2024 at 20:33):

(I see the error, trying to figure out where it's coming from.)

Patrick Massot (Aug 23 2024 at 20:42):

Julian Berman said:

These are the key lines: https://github.com/Julian/dotfiles/blob/main/.config/nvim/init.lua#L33-L37 -- in particular in my case I am telling lazy.nvim "when you see a plugin called Julian/<something>, go look at $DEVELOPMENT/something -- if that exists, load the plugin from there -- otherwise, load it from GitHub" -- and my $DEVELOPMENT environment variable is set to where I keep my git repositories. Add that to where you start lazy, and then either clone or fork + clone the repository into that location.

I was not able to make anything out of this, but I did the low-tech replacement of Julian/lean.nvim by dir=โ€ฆ and I can load my version.

Patrick Massot (Aug 23 2024 at 20:53):

I wrote in my hacked lean.nvim

function LeanRpc(method, extras)
    local params = vim.lsp.util.make_position_params();
    local sess = require'lean.rpc'.open(0, params);
    require'plenary.async'.void(function() vim.notify(sess:call(method, vim.tbl_extend('keep', params, extras))) end)()
  end

which is already useful, but of course it doesnโ€™t get rid of the error message.

Julian Berman (Aug 23 2024 at 20:54):

Nor here yet, so I'm missing something obvious.

Patrick Massot (Aug 23 2024 at 20:54):

I think this error message is simply hiding any LSP error. Trying to call a non-existent rpc method gives exactly the same message.

Julian Berman (Aug 23 2024 at 21:00):

OK and now it works again after doing random stuff to clean up the code.

Julian Berman (Aug 23 2024 at 21:00):

It's really so unfun dealing with languages with nil and craziness.

Julian Berman (Aug 23 2024 at 21:01):

This is what I ended up with:

local bufnrs = vim.iter(vim.api.nvim_tabpage_list_wins(0))
  :map(function(win)
    local bufnr = vim.api.nvim_win_get_buf(win)
    if vim.bo[bufnr].filetype == 'lean' then
      return win, bufnr
    end
  end):totable()

local win, bufnr = unpack(bufnrs[1])

local pos = vim.lsp.util.make_position_params(win)
local sess = require'lean.rpc'.open(bufnr, pos)


require'plenary.async'.void(function()
  local params = vim.tbl_extend('keep', pos, { kind = 'declaration' })
  local result = sess:call('rpcRangeAt', params)
  vim.print(result)
end)()

I put this in some random foo.lua file in my case and then was running :source foo.lua over and over again to test -- because I thought it would be easier in case you didn't get lean.nvim editable -- but if you did you can just paste it wherever you want.

Patrick Massot (Aug 23 2024 at 21:02):

Iโ€™ll try this, but let me report that

@[server_rpc_method]
def rpcTestBis  (p : Params) :
    RequestM (RequestTask $ Option String) := do
  return .pure none

fails while

@[server_rpc_method]
def rpcTestBis  (p : Params) :
    RequestM (RequestTask $ Option String) := do
  return .pure (some "TestBis")

works.

Julian Berman (Aug 23 2024 at 21:03):

(You're right that something is simply hiding errors)

Julian Berman (Aug 23 2024 at 21:03):

Ahhhhh

Julian Berman (Aug 23 2024 at 21:03):

That's silly.

Julian Berman (Aug 23 2024 at 21:04):

The issue is that vim.notify takes a string, and we were passing it a table once you returned a table from your RPC method

Julian Berman (Aug 23 2024 at 21:04):

Use vim.notify(vim.inspect(result)) -- vim.inspect is like repr().

Julian Berman (Aug 23 2024 at 21:05):

(This fixes what you just sent as well -- it's all just the silly printing mistake.)

Patrick Massot (Aug 23 2024 at 21:09):

Great!

Patrick Massot (Aug 23 2024 at 21:09):

So I can stick with

function LeanRpc(method, extras)
    local params = vim.lsp.util.make_position_params();
    local sess = require'lean.rpc'.open(0, params);
    require'plenary.async'.void(function() vim.notify(vim.inspect(sess:call(method, vim.tbl_extend('keep', params, extras)))) end)()
  end

right?

Patrick Massot (Aug 23 2024 at 21:11):

Now we are back on track. We donโ€™t even need a custom Lean toolchain. It turns out the existing extensibility through RPC method is enough for us.

Julian Berman (Aug 23 2024 at 21:11):

Yep that looks ok -- just beware that when you say 0 there you are referring to the current buffer (and same if you pass no argument to make_position_params), so you always need to run that from the Lean buffer. If you get tired of that you can use that 4 lines of filter code in my code block to "find any window that's open that's a lean buffer and run this in there".

Julian Berman (Aug 23 2024 at 21:11):

Yes, this is a very good find, and clearly I should have remembered we already had this before.

Patrick Massot (Aug 23 2024 at 21:15):

Oh, the vim.notify(vim.inspect( prints strings with \n instead of printing new lines. Thatโ€™s a bit painful.

Julian Berman (Aug 23 2024 at 21:16):

vim.notify(type(result) == "string" and result or vim.inspect(result)) perhaps

Patrick Massot (Aug 23 2024 at 21:21):

This is not very robust but good enough for now, thanks!

Patrick Massot (Aug 23 2024 at 21:21):

Now I guess Iโ€™m back to learning how to make new text objects and motions.

Julian Berman (Aug 23 2024 at 21:22):

You can also go back to vim.print, it's whatever is least painful really.

Julian Berman (Aug 23 2024 at 21:27):

Patrick Massot said:

Now I guess Iโ€™m back to learning how to make new text objects and motions.

Here is an article on how to create text objects -- https://thevaluable.dev/vim-create-text-objects/ -- but it's both super long winded and a bit out of date it seems, you should do it with vim.keymap.set({'x', 'o'}, 'D -- or whatever letter you want to put it on', function() something end, {buffer = true}))

Julian Berman (Aug 23 2024 at 21:31):

I would actually possibly start by implementing ]m and [m -- this is an (existing) mapping which is supposed to move you to the start of methods/functions/whatever -- so you could start by taking your "find me where the declaration ends" and then use that to reimplement ]m properly for Lean

Julian Berman (Aug 23 2024 at 21:31):

That would be a normal mode mapping, rather than an operator pending one (i.e. a text object)

Julian Berman (Aug 23 2024 at 21:33):

Implementing ]t (that doesn't exist but we could invent) for "move me to the next tactic" would be similar and obviously super useful.

Julian Berman (Aug 23 2024 at 21:34):

Both of those will end up calling vim.api.nvim_win_set_cursor -- that's the function that lets you move the cursor to some arbitrary position (i.e. the one you get back from the lean server!)

Patrick Massot (Aug 23 2024 at 21:52):

Thanks! I need to sleep, so that will be for another day. Before going to sleep, I made sure to reimplement all my tests in the new style. The new full Lean file is:

import Lean

open Lean Server Lsp FileWorker RequestM

/-- Is this the `SyntaxNodeKind` of a tactic syntax? Currently a very crude heuristic. -/
def Lean.SyntaxNodeKind.isTactic (kind : SyntaxNodeKind) : Bool :=
  Name.isPrefixOf `Lean.Parser.Tactic kind

/-- In the given syntax stack, find the first item satisfying the condition `cond`
and run `code` on it. Return `none` if no such item exists.  -/
def Lean.Syntax.Stack.find? (stack : Syntax.Stack) (cond : Lean.Syntax โ†’ Bool) {m : Type โ†’ Type} [Monad m] {ฮฑ : Type}
    (code : Lean.Syntax โ†’ m (Option ฮฑ)) : m (Option ฮฑ) := do
  for (stx, _) in stack do
    if cond stx then
      return โ† code stx
  return none

def mkRpcAtMethod {ฮฑ : Type} [RpcEncodable ฮฑ] [Inhabited ฮฑ]
    (cond : Lean.Syntax โ†’ Bool) (body : Syntax โ†’ (map : FileMap) โ†’ Option ฮฑ) (params : TextDocumentPositionParams) :
    RequestM (RequestTask <| Option ฮฑ) := do
  let doc โ† readDoc
  let text := doc.meta.text
  let hoverPos := text.lspPosToUtf8Pos params.position
  withWaitFindSnap doc (fun s => s.endPos > hoverPos) (notFoundX := pure none) fun snap => do
  let some stack := snap.stx.findStack? (ยท.getRange?.any (ยท.contains hoverPos)) | return none
  stack.find? cond fun stx โ†ฆ return body stx text


def mkRpcRangeAtMethod (cond : Lean.Syntax โ†’ Bool) :=
    mkRpcAtMethod cond fun stx map โ†ฆ return stx.getRange?.map fun rg โ†ฆ rg.toLspRange map

@[server_rpc_method]
def rpcDeclarationRangeAt := mkRpcRangeAtMethod (ยท.getKind = `Lean.Parser.Command.declaration)

@[server_rpc_method]
def rpcTacticRangeAt := mkRpcRangeAtMethod (ยท.getKind.isTactic)

@[server_rpc_method]
def rpcTacticSeqRangeAt := mkRpcRangeAtMethod (ยท.getKind = `Lean.Parser.Tactic.tacticSeq)


@[server_rpc_method]
def rpcBinderRangeAt :=
  mkRpcAtMethod (ยท.getKind โˆˆ [`Lean.Parser.Term.explicitBinder,`Lean.Parser.Term.implicitBinder]) fun stx map โ†ฆ return stx.getRange?.map fun rg โ†ฆ (stx.getKind, rg.toLspRange map)


deriving instance Repr for Lsp.Position
deriving instance Repr for Lsp.Range

@[server_rpc_method]
def syntaxStack  (p : TextDocumentPositionParams ) :
    RequestM (RequestTask <| Option String) := do
  let doc โ† readDoc
  let text := doc.meta.text
  let hoverPos := text.lspPosToUtf8Pos p.position
  withWaitFindSnap doc (fun s => s.endPos > hoverPos) (notFoundX := pure none) fun snap => do
  let some stack := snap.stx.findStack? (ยท.getRange?.any (ยท.contains hoverPos)) | return none
  let mut res := ""
  for (stx, _) in stack do
    let some rg := stx.getRange? | continue
    res := s!"{res}\n\n{stx.getKind}\n{stx}\n{repr <| rg.toLspRange text}"
  return some res

Patrick Massot (Aug 23 2024 at 21:53):

Something disappointing is that I donโ€™t get the expected result when the cursor is on a white space. Iโ€™ll need to investigate this.

Patrick Massot (Aug 23 2024 at 21:53):

But you can already play with those RPC methods if you want. Now that they are separated from each other, they donโ€™t take any extra argument.

Julian Berman (Aug 23 2024 at 21:59):

Very nice, ok, thanks already for pushing this forward Patrick -- I would like to probably find a beer tonight rather than fight with more computers, but I'll poke at it again at some point myself too.

Patrick Massot (Aug 23 2024 at 22:01):

No problem, finding a beer is also a nice project!

Patrick Massot (Aug 23 2024 at 22:03):

If any Lean meta-programming expert is still reading this despite the wall of Lua-related question, I can minimize my white space issue to

import Lean

open Lean Server Lsp FileWorker RequestM

deriving instance Repr for Lsp.Position
deriving instance Repr for Lsp.Range

@[server_rpc_method]
def syntaxStack  (p : TextDocumentPositionParams ) :
    RequestM (RequestTask <| Option String) := do
  let doc โ† readDoc
  let text := doc.meta.text
  let hoverPos := text.lspPosToUtf8Pos p.position
  withWaitFindSnap doc (fun s => s.endPos > hoverPos) (notFoundX := pure "No snapshot found") fun snap => do
  let some stack := snap.stx.findStack? (ยท.getRange?.any (ยท.contains hoverPos)) | return "No syntax stack found"
  let mut res := ""
  for (stx, _) in stack do
    let some rg := stx.getRange? | continue
    res := s!"{res}\n\n{stx.getKind}\n{stx}\n{repr <| rg.toLspRange text}"
  return some res

Calling the syntaxStack function with a position where there is white space in the middle of interesting Lean code returns "No syntax stack found" which is a bit disappointing.

Patrick Massot (Aug 23 2024 at 22:04):

(I know this function never return None, I just turned the None into debugging strings).

Patrick Massot (Aug 23 2024 at 22:14):

Julian, I made https://github.com/PatrickMassot/LspExperiments so that you can play with the code if you want.

Patrick Massot (Aug 23 2024 at 22:14):

The only lua code I added in the plugin is

local function ppresult(result)
  vim.notify(type(result) == "string" and result or vim.inspect(result))
end

function LeanRpc(method, extras)
    local params = vim.lsp.util.make_position_params();
    local sess = require'lean.rpc'.open(0, params);
    require'plenary.async'.void(function() ppresult(sess:call(method, vim.tbl_extend('keep', params, extras))) end)()
  end

Patrick Massot (Aug 23 2024 at 22:15):

Then I put my cursor in Test.lean and type things like lua LeanRpc("rpcDeclarationRangeAt", {})

Patrick Massot (Aug 23 2024 at 22:16):

And now I really need to go to bed.

Patrick Massot (Aug 23 2024 at 22:16):

I will probably have very little time during the week-end, but itโ€™s in a state where it will be easy to resume work.

Julian Berman (Aug 23 2024 at 22:17):

Yeah it seems like we're not far from something at least useful to start.

Julian Berman (Aug 24 2024 at 00:43):

I couldn't resist playing at least a bit. Here's a branch (of lean.nvim) with a POC of ]m and [m to move to declaration start and end -- modulo your whitespace question it should work to move to beginning or end of a declaration using ]m / [m -- https://github.com/Julian/lean.nvim/tree/lsp-experiments

It's just an exclusive motion, so the cursor will move to the beginning of the declaration then stay there rather than moving further to the last one right now.

Patrick Massot (Aug 24 2024 at 09:14):

It works great!


Last updated: May 02 2025 at 03:31 UTC