|
|
/- This file is adapted from `Lean/Server/FileWorker.lean`. -/
|
|
|
import Lean.Server.FileWorker
|
|
|
import GameServer.Game
|
|
|
import GameServer.ImportModules
|
|
|
import GameServer.SaveData
|
|
|
import GameServer.EnvExtensions
|
|
|
import GameServer.Tactic.LetIntros
|
|
|
|
|
|
namespace MyModule
|
|
|
|
|
|
open Lean
|
|
|
open Elab
|
|
|
open Parser
|
|
|
|
|
|
private def mkErrorMessage (c : InputContext) (pos : String.Pos) (errorMsg : String) : Message :=
|
|
|
let pos := c.fileMap.toPosition pos
|
|
|
{ fileName := c.fileName, pos := pos, data := errorMsg }
|
|
|
|
|
|
private def mkEOI (pos : String.Pos) : Syntax :=
|
|
|
let atom := mkAtom (SourceInfo.original "".toSubstring pos "".toSubstring pos) ""
|
|
|
mkNode ``Command.eoi #[atom]
|
|
|
|
|
|
partial def parseTactic (inputCtx : InputContext) (pmctx : ParserModuleContext)
|
|
|
(mps : ModuleParserState) (messages : MessageLog) :
|
|
|
Syntax × ModuleParserState × MessageLog × String.Pos := Id.run do
|
|
|
let mut pos := mps.pos
|
|
|
let mut recovering := mps.recovering
|
|
|
let mut messages := messages
|
|
|
let mut stx := Syntax.missing -- will always be assigned below
|
|
|
|
|
|
let tokens := getTokenTable pmctx.env
|
|
|
|
|
|
let s := whitespace.run inputCtx pmctx tokens { cache := initCacheForInput inputCtx.input, pos }
|
|
|
let endOfWhitespace := s.pos
|
|
|
|
|
|
let p := (Tactic.sepByIndentSemicolon tacticParser).fn
|
|
|
let s := p.run inputCtx pmctx tokens { cache := initCacheForInput inputCtx.input, pos }
|
|
|
|
|
|
pos := s.pos
|
|
|
match s.errorMsg with
|
|
|
| none =>
|
|
|
stx := s.stxStack.back
|
|
|
recovering := false
|
|
|
| some errorMsg =>
|
|
|
messages := messages.add <| mkErrorMessage inputCtx s.pos (toString errorMsg)
|
|
|
recovering := true
|
|
|
stx := s.stxStack.back
|
|
|
if ¬ inputCtx.input.atEnd s.pos then
|
|
|
messages := messages.add <| mkErrorMessage inputCtx s.pos "end of input"
|
|
|
return (stx, { pos := inputCtx.input.endPos, recovering }, messages, endOfWhitespace)
|
|
|
|
|
|
end MyModule
|
|
|
|
|
|
namespace GameServer.FileWorker
|
|
|
|
|
|
open Lean
|
|
|
open Lean.Server
|
|
|
open Lean.Server.FileWorker
|
|
|
open Lsp
|
|
|
open IO
|
|
|
open Snapshots
|
|
|
open JsonRpc
|
|
|
|
|
|
/--
|
|
|
Game-specific state to be packed on top of the `Server.FileWorker.WorkerState`
|
|
|
used by the Lean server.
|
|
|
-/
|
|
|
structure WorkerState :=
|
|
|
/--
|
|
|
Collection of items which are considered unlocked.
|
|
|
Tactics and theorems are mixed together.
|
|
|
-/
|
|
|
inventory : Array String
|
|
|
/--
|
|
|
Difficulty determines whether tactics/theorems can be locked.
|
|
|
* 0: do not check
|
|
|
* 1: give warnings when locked items are used
|
|
|
* 2: give errors when locked items are used
|
|
|
-/
|
|
|
difficulty : Nat
|
|
|
/--
|
|
|
`levelInfo` contains all the (static) information about the level which is not influenced
|
|
|
by the user's progress.
|
|
|
-/
|
|
|
levelInfo : LevelInfo
|
|
|
deriving ToJson, FromJson
|
|
|
|
|
|
/--
|
|
|
Pack the our custom `WorkerState` on top of the normal worker monad
|
|
|
`Server.FileWorker.WorkerM`.
|
|
|
-/
|
|
|
abbrev WorkerM := StateT WorkerState Server.FileWorker.WorkerM
|
|
|
|
|
|
section Elab
|
|
|
|
|
|
/-- Add a message. use `(severity := .warning)` to specify the severity-/
|
|
|
def addMessage (info : SourceInfo) (inputCtx : Parser.InputContext)
|
|
|
(severity := MessageSeverity.warning) (s : MessageData) :
|
|
|
Elab.Command.CommandElabM Unit := do
|
|
|
modify fun st => { st with
|
|
|
messages := st.messages.add {
|
|
|
fileName := inputCtx.fileName
|
|
|
severity := severity
|
|
|
pos := inputCtx.fileMap.toPosition (info.getPos?.getD 0)
|
|
|
data := s }}
|
|
|
|
|
|
-- TODO: use HashSet for allowed tactics?
|
|
|
/--
|
|
|
Find all tactics in syntax object that are forbidden according to a
|
|
|
set `allowed` of allowed tactics.
|
|
|
-/
|
|
|
partial def findForbiddenTactics (inputCtx : Parser.InputContext) (workerState : WorkerState)
|
|
|
(stx : Syntax) : Elab.Command.CommandElabM Unit := do
|
|
|
let levelInfo := workerState.levelInfo
|
|
|
-- Parse the syntax object and look for tactics and declarations.
|
|
|
match stx with
|
|
|
| .missing => return ()
|
|
|
| .node _info _kind args =>
|
|
|
-- Go inside a node.
|
|
|
for arg in args do
|
|
|
findForbiddenTactics inputCtx workerState arg
|
|
|
| .atom info val =>
|
|
|
-- Atoms might be tactic names or other keywords.
|
|
|
-- Note: We whitelisted known keywords because we cannot
|
|
|
-- distinguish keywords from tactic names.
|
|
|
let allowed := GameServer.ALLOWED_KEYWORDS
|
|
|
-- Ignore syntax elements that do not start with a letter or are listed above.
|
|
|
if 0 < val.length ∧ val.data[0]!.isAlpha ∧ not (allowed.contains val) then
|
|
|
-- Treat `simp?` and `simp!` like `simp`
|
|
|
let val := val.dropRightWhile (fun c => c == '!' || c == '?')
|
|
|
match levelInfo.tactics.find? (·.name.toString == val) with
|
|
|
| none =>
|
|
|
-- Tactic will never be introduced in the game.
|
|
|
match workerState.inventory.find? (· == val) with
|
|
|
| some _ =>
|
|
|
-- Tactic is in the inventory, allow it.
|
|
|
-- Note: This case shouldn't be possible...
|
|
|
pure ()
|
|
|
| none =>
|
|
|
-- Tactic is not in the inventory.
|
|
|
addMessageByDifficulty info s!"The tactic '{val}' is not available in this game!"
|
|
|
| some tac =>
|
|
|
-- Tactic is introduced at some point in the game.
|
|
|
if tac.disabled then
|
|
|
-- Tactic is disabled in this level.
|
|
|
addMessageByDifficulty info s!"The tactic '{val}' is disabled in this level!"
|
|
|
else if tac.locked then
|
|
|
match workerState.inventory.find? (· == val) with
|
|
|
| none =>
|
|
|
-- Tactic is marked as locked and not in the inventory.
|
|
|
addMessageByDifficulty info s!"You have not unlocked the tactic '{val}' yet!"
|
|
|
| some _ =>
|
|
|
-- Tactic is in the inventory, allow it.
|
|
|
pure ()
|
|
|
| .ident info _rawVal val _preresolved =>
|
|
|
-- Try to resolve the name
|
|
|
let ns ←
|
|
|
try resolveGlobalConst (mkIdent val)
|
|
|
-- Catch "unknown constant" error
|
|
|
catch | _ => pure []
|
|
|
for n in ns do
|
|
|
let some (.thmInfo ..) := (← getEnv).find? n
|
|
|
-- Not a theorem, no checks needed.
|
|
|
| return ()
|
|
|
if some n = levelInfo.statementName then
|
|
|
-- Forbid the theorem we are proving currently
|
|
|
addMessage info inputCtx (severity := .error)
|
|
|
s!"Structural recursion: you can't use '{n}' to proof itself!"
|
|
|
let theoremsAndDefs := levelInfo.lemmas ++ levelInfo.definitions
|
|
|
match theoremsAndDefs.find? (·.name == n) with
|
|
|
| none =>
|
|
|
-- Theorem will never be introduced in this game
|
|
|
addMessageByDifficulty info s!"The theorem/definition '{n}' is not available in this game!"
|
|
|
| some thm =>
|
|
|
-- Theorem is introduced at some point in the game.
|
|
|
if thm.disabled then
|
|
|
-- Theorem is disabled in this level.
|
|
|
addMessageByDifficulty info s!"The theorem/definition '{n}' is disabled in this level!"
|
|
|
else if thm.locked then
|
|
|
match workerState.inventory.find? (· == n.toString) with
|
|
|
| none =>
|
|
|
-- Theorem is still locked.
|
|
|
addMessageByDifficulty info s!"You have not unlocked the theorem/definition '{n}' yet!"
|
|
|
| some _ =>
|
|
|
-- Theorem is in the inventory, allow it.
|
|
|
pure ()
|
|
|
|
|
|
where addMessageByDifficulty (info : SourceInfo) (s : MessageData) :=
|
|
|
-- See `GameServer.FileWorker.WorkerState.difficulty`. Send nothing/warnings/errors
|
|
|
-- depending on difficulty.
|
|
|
let difficulty := workerState.difficulty
|
|
|
if difficulty > 0 then
|
|
|
addMessage info inputCtx (if difficulty > 1 then .error else .warning) s
|
|
|
else pure ()
|
|
|
|
|
|
open Elab Meta Expr in
|
|
|
|
|
|
def compileProof (inputCtx : Parser.InputContext) (snap : Snapshot) (hasWidgets : Bool)
|
|
|
(couldBeEndSnap : Bool) (gameWorkerState : WorkerState)
|
|
|
(initParams : Lsp.InitializeParams) : IO Snapshot := do
|
|
|
-- Recognize end snap
|
|
|
if inputCtx.input.atEnd snap.mpState.pos ∧ couldBeEndSnap then
|
|
|
let endSnap : Snapshot := {
|
|
|
beginPos := snap.mpState.pos
|
|
|
stx := MyModule.mkEOI snap.mpState.pos
|
|
|
mpState := snap.mpState
|
|
|
cmdState := snap.cmdState
|
|
|
interactiveDiags := ← withNewInteractiveDiags snap.msgLog
|
|
|
tacticCache := snap.tacticCache
|
|
|
}
|
|
|
return endSnap
|
|
|
|
|
|
let parseResultRef ← IO.mkRef (Syntax.missing, snap.mpState)
|
|
|
|
|
|
let cmdStateRef ← IO.mkRef snap.cmdState
|
|
|
/- The same snapshot may be executed by different tasks. So, to make sure `elabCommandTopLevel` has exclusive
|
|
|
access to the cache, we create a fresh reference here. Before this change, the
|
|
|
following `snap.tacticCache.modify` would reset the tactic post cache while another snapshot was still using it. -/
|
|
|
let tacticCacheNew ← IO.mkRef (← snap.tacticCache.get)
|
|
|
let cmdCtx : Elab.Command.Context := {
|
|
|
cmdPos := snap.endPos
|
|
|
fileName := inputCtx.fileName
|
|
|
fileMap := inputCtx.fileMap
|
|
|
tacticCache? := some tacticCacheNew
|
|
|
}
|
|
|
let (output, _) ← IO.FS.withIsolatedStreams (isolateStderr := server.stderrAsMessages.get snap.cmdState.scopes.head!.opts) <| liftM (m := BaseIO) do
|
|
|
Elab.Command.catchExceptions
|
|
|
(getResetInfoTrees *> do
|
|
|
let some level ← GameServer.getLevelByFileName? initParams inputCtx.fileName
|
|
|
| panic! s!"Level not found: {inputCtx.fileName} / {GameServer.levelIdFromFileName? initParams inputCtx.fileName}"
|
|
|
let scope := level.scope
|
|
|
|
|
|
-- use open namespaces and options as in the level file
|
|
|
Elab.Command.withScope (fun _ => scope) do
|
|
|
for od in scope.openDecls do
|
|
|
let .simple ns _ := od
|
|
|
| pure ()
|
|
|
activateScoped ns
|
|
|
activateScoped scope.currNamespace
|
|
|
|
|
|
-- parse tactics
|
|
|
let pmctx := {
|
|
|
env := ← getEnv,
|
|
|
options := scope.opts,
|
|
|
currNamespace := scope.currNamespace,
|
|
|
openDecls := scope.openDecls }
|
|
|
let (tacticStx, cmdParserState, msgLog, endOfWhitespace) :=
|
|
|
MyModule.parseTactic inputCtx pmctx snap.mpState snap.msgLog
|
|
|
modify (fun s => { s with messages := msgLog })
|
|
|
parseResultRef.set (tacticStx, cmdParserState)
|
|
|
|
|
|
-- Check for forbidden tactics
|
|
|
findForbiddenTactics inputCtx gameWorkerState tacticStx
|
|
|
|
|
|
-- Insert invisible `skip` command to make sure we always display the initial goal
|
|
|
let skip := Syntax.node (.original default 0 default endOfWhitespace) ``Lean.Parser.Tactic.skip #[]
|
|
|
-- Insert final `done` command to display unsolved goal error in the end
|
|
|
let done := Syntax.node (.synthetic cmdParserState.pos cmdParserState.pos) ``Lean.Parser.Tactic.done #[]
|
|
|
let tacticStx := (#[skip] ++ tacticStx.getArgs ++ #[done]).map (⟨.⟩)
|
|
|
let tacticStx := ← `(Lean.Parser.Tactic.tacticSeq| $[$(tacticStx)]*)
|
|
|
|
|
|
-- Always call `let_intros` to get rid `let` statements in the goal.
|
|
|
-- This makes the experience for the user much nicer and allows for local
|
|
|
-- definitions in the exercise.
|
|
|
let cmdStx ← `(command|
|
|
|
theorem the_theorem $(level.goal) := by {let_intros; $(⟨level.preamble⟩); $(⟨tacticStx⟩)} )
|
|
|
Elab.Command.elabCommandTopLevel cmdStx)
|
|
|
cmdCtx cmdStateRef
|
|
|
let postNew := (← tacticCacheNew.get).post
|
|
|
snap.tacticCache.modify fun _ => { pre := postNew, post := {} }
|
|
|
let mut postCmdState ← cmdStateRef.get
|
|
|
if !output.isEmpty then
|
|
|
postCmdState := {
|
|
|
postCmdState with
|
|
|
messages := postCmdState.messages.add {
|
|
|
fileName := inputCtx.fileName
|
|
|
severity := MessageSeverity.information
|
|
|
pos := inputCtx.fileMap.toPosition snap.endPos
|
|
|
data := output
|
|
|
}
|
|
|
}
|
|
|
|
|
|
let (tacticStx, cmdParserState) ← parseResultRef.get
|
|
|
if tacticStx.isMissing then throwServerError "Tactic execution went wrong. No stx found."
|
|
|
|
|
|
let postCmdSnap : Snapshot := {
|
|
|
beginPos := tacticStx.getPos?.getD 0
|
|
|
stx := tacticStx
|
|
|
mpState := cmdParserState
|
|
|
cmdState := postCmdState
|
|
|
interactiveDiags := ← withNewInteractiveDiags postCmdState.messages
|
|
|
tacticCache := (← IO.mkRef {})
|
|
|
}
|
|
|
return postCmdSnap
|
|
|
|
|
|
where
|
|
|
/-- Compute the current interactive diagnostics log by finding a "diff" relative to the parent
|
|
|
snapshot. We need to do this because unlike the `MessageLog` itself, interactive diags are not
|
|
|
part of the command state. -/
|
|
|
withNewInteractiveDiags (msgLog : MessageLog) : IO (PersistentArray Widget.InteractiveDiagnostic) := do
|
|
|
let newMsgCount := msgLog.msgs.size - snap.msgLog.msgs.size
|
|
|
let mut ret := snap.interactiveDiags
|
|
|
for i in List.iota newMsgCount do
|
|
|
let newMsg := msgLog.msgs.get! (msgLog.msgs.size - i)
|
|
|
ret := ret.push (← Widget.msgToInteractiveDiagnostic inputCtx.fileMap newMsg hasWidgets)
|
|
|
return ret
|
|
|
|
|
|
private def publishIleanInfo (method : String) (m : DocumentMeta) (hOut : FS.Stream)
|
|
|
(snaps : Array Snapshot) : IO Unit := do
|
|
|
let trees := snaps.map fun snap => snap.infoTree
|
|
|
let references ← findModuleRefs m.text trees (localVars := true) |>.toLspModuleRefs
|
|
|
let param := { version := m.version, references : LeanIleanInfoParams }
|
|
|
hOut.writeLspNotification { method, param }
|
|
|
|
|
|
private def publishIleanInfoUpdate : DocumentMeta → FS.Stream → Array Snapshot → IO Unit :=
|
|
|
publishIleanInfo "$/lean/ileanInfoUpdate"
|
|
|
|
|
|
private def publishIleanInfoFinal : DocumentMeta → FS.Stream → Array Snapshot → IO Unit :=
|
|
|
publishIleanInfo "$/lean/ileanInfoFinal"
|
|
|
|
|
|
structure GameCompletedParams where
|
|
|
uri : String
|
|
|
deriving ToJson, FromJson
|
|
|
|
|
|
structure GameDiagnostics where
|
|
|
diagnostics : List Diagnostic
|
|
|
deriving ToJson, FromJson
|
|
|
|
|
|
structure GameParams where
|
|
|
uri : String
|
|
|
diagnostics : GameDiagnostics
|
|
|
deriving ToJson, FromJson
|
|
|
|
|
|
-- `snap` and `initParams` are unused
|
|
|
set_option linter.unusedVariables false in
|
|
|
|
|
|
/-- WIP: publish diagnostics, all intermediate goals and if the game is completed. -/
|
|
|
def publishProofState (m : DocumentMeta) (snap : Snapshot) (initParams : Lsp.InitializeParams) (hOut : FS.Stream) :
|
|
|
IO Unit := do
|
|
|
-- let text := m.text
|
|
|
|
|
|
-- -- `snap` is the one snapshot containing the entire proof.
|
|
|
-- let mut goals : Array <| InteractiveGoalsWithHints := #[]
|
|
|
-- for pos in text.positions do
|
|
|
-- let source := text.getLineBefore pos
|
|
|
-- -- iterate over all newlines in the proof and get the goals and hints at each position
|
|
|
-- if let goalsAtResult@(_ :: _) := snap.infoTree.goalsAt? text pos then
|
|
|
-- pure ()
|
|
|
-- let goalAtPos : List <| List InteractiveGoalWithHints ← goalsAtResult.mapM
|
|
|
-- fun { ctxInfo := ci, tacticInfo := tacticInfo, useAfter := useAfter, .. } => do
|
|
|
-- -- TODO: What does this function body do?
|
|
|
-- -- let ciAfter := { ci with mctx := ti.mctxAfter }
|
|
|
-- let ci := if useAfter then
|
|
|
-- { ci with mctx := tacticInfo.mctxAfter }
|
|
|
-- else
|
|
|
-- { ci with mctx := tacticInfo.mctxBefore }
|
|
|
-- -- compute the interactive goals
|
|
|
-- let goalMvars : List MVarId ← ci.runMetaM {} do
|
|
|
-- return if useAfter then tacticInfo.goalsAfter else tacticInfo.goalsBefore
|
|
|
|
|
|
-- let interactiveGoals : List InteractiveGoalWithHints ← ci.runMetaM {} do
|
|
|
-- goalMvars.mapM fun goal => do
|
|
|
-- let hints ← findHints goal m initParams
|
|
|
-- let interactiveGoal ← goalToInteractive goal
|
|
|
-- return ⟨interactiveGoal, hints⟩
|
|
|
-- -- TODO: This code is way old, can it be deleted?
|
|
|
-- -- compute the goal diff
|
|
|
-- -- let goals ← ciAfter.runMetaM {} (do
|
|
|
-- -- try
|
|
|
-- -- Widget.diffInteractiveGoals useAfter ti goals
|
|
|
-- -- catch _ =>
|
|
|
-- -- -- fail silently, since this is just a bonus feature
|
|
|
-- -- return goals
|
|
|
-- -- )
|
|
|
-- return interactiveGoals
|
|
|
-- let goalAtPos : Array InteractiveGoalWithHints := ⟨goalAtPos.foldl (· ++ ·) []⟩
|
|
|
-- goals := goals.push ⟨goalAtPos, source⟩
|
|
|
-- else
|
|
|
-- -- No goals present
|
|
|
-- goals := goals.push default
|
|
|
|
|
|
-- -- Question: Is there a difference between the diags of this snap and the last snap?
|
|
|
-- -- Should we get the diags from there?
|
|
|
-- let diag : Array Widget.InteractiveDiagnostic := snap.interactiveDiags.toArray
|
|
|
|
|
|
-- -- Level is completed if there are no errors or warnings
|
|
|
-- let completed : Bool := ¬ diag.any (fun d =>
|
|
|
-- d.severity? == some .error ∨ d.severity? == some .warning)
|
|
|
|
|
|
-- let param : ProofState := {
|
|
|
-- steps := goals,
|
|
|
-- diagnostics := diag,
|
|
|
-- completed := completed }
|
|
|
|
|
|
-- TODO
|
|
|
let param := { uri := m.uri : GameCompletedParams}
|
|
|
|
|
|
|
|
|
hOut.writeLspNotification { method := "$/game/publishProofState", param }
|
|
|
|
|
|
/-- Checks whether game level has been completed and sends a notification to the client -/
|
|
|
def publishGameCompleted (m : DocumentMeta) (hOut : FS.Stream) (snaps : Array Snapshot) : IO Unit := do
|
|
|
-- check if there is any error or warning
|
|
|
for snap in snaps do
|
|
|
if snap.diagnostics.any fun d => d.severity? == some .error ∨ d.severity? == some .warning
|
|
|
then return
|
|
|
let param := { uri := m.uri : GameCompletedParams}
|
|
|
hOut.writeLspNotification { method := "$/game/completed", param }
|
|
|
|
|
|
/-- copied from `Lean.Server.FileWorker.nextCmdSnap`. -/
|
|
|
-- @[inherit_doc Lean.Server.FileWorker.nextCmdSnap] -- cannot inherit from private
|
|
|
private def nextCmdSnap (ctx : WorkerContext) (m : DocumentMeta) (cancelTk : CancelToken)
|
|
|
(gameWorkerState : WorkerState) (initParams : Lsp.InitializeParams) :
|
|
|
AsyncElabM (Option Snapshot) := do
|
|
|
cancelTk.check
|
|
|
let s ← get
|
|
|
let .some lastSnap := s.snaps.back? | panic! "empty snapshots"
|
|
|
if lastSnap.isAtEnd then
|
|
|
publishDiagnostics m lastSnap.diagnostics.toArray ctx.hOut
|
|
|
publishProgressDone m ctx.hOut
|
|
|
publishIleanInfoFinal m ctx.hOut s.snaps
|
|
|
return none
|
|
|
publishProgressAtPos m lastSnap.endPos ctx.hOut
|
|
|
|
|
|
-- (modified part)
|
|
|
-- Make sure that there is at least one snap after the head snap, so that
|
|
|
-- we can see the current goal even on an empty document
|
|
|
let couldBeEndSnap := s.snaps.size > 1
|
|
|
let snap ← compileProof m.mkInputContext lastSnap ctx.clientHasWidgets couldBeEndSnap
|
|
|
gameWorkerState initParams
|
|
|
|
|
|
set { s with snaps := s.snaps.push snap }
|
|
|
cancelTk.check
|
|
|
-- publishProofState m snap initParams ctx.hOut
|
|
|
publishDiagnostics m snap.diagnostics.toArray ctx.hOut
|
|
|
publishIleanInfoUpdate m ctx.hOut #[snap]
|
|
|
return some snap
|
|
|
|
|
|
-- Copied from `Lean.Server.FileWorker.unfoldCmdSnaps` using our own `nextCmdSnap`.
|
|
|
@[inherit_doc Lean.Server.FileWorker.unfoldCmdSnaps]
|
|
|
def unfoldCmdSnaps (m : DocumentMeta) (snaps : Array Snapshot) (cancelTk : CancelToken)
|
|
|
(startAfterMs : UInt32) (gameWorkerState : WorkerState)
|
|
|
: ReaderT WorkerContext IO (AsyncList ElabTaskError Snapshot) := do
|
|
|
let ctx ← read
|
|
|
let some headerSnap := snaps[0]? | panic! "empty snapshots"
|
|
|
if headerSnap.msgLog.hasErrors then
|
|
|
publishProgressAtPos m headerSnap.beginPos ctx.hOut (kind := LeanFileProgressKind.fatalError)
|
|
|
publishIleanInfoFinal m ctx.hOut #[headerSnap]
|
|
|
return AsyncList.ofList [headerSnap]
|
|
|
else
|
|
|
publishIleanInfoUpdate m ctx.hOut snaps
|
|
|
return AsyncList.ofList snaps.toList ++ AsyncList.delayed (← EIO.asTask (ε := ElabTaskError) (prio := .dedicated) do
|
|
|
IO.sleep startAfterMs
|
|
|
AsyncList.unfoldAsync (nextCmdSnap ctx m cancelTk gameWorkerState ctx.initParams) { snaps })
|
|
|
|
|
|
end Elab
|
|
|
|
|
|
section Updates
|
|
|
|
|
|
/-- Given the new document, updates editable doc state. -/
|
|
|
def updateDocument (newMeta : DocumentMeta) : WorkerM Unit := do
|
|
|
let s ← get
|
|
|
let ctx ← read
|
|
|
let oldDoc := (← StateT.lift get).doc
|
|
|
oldDoc.cancelTk.set
|
|
|
let initHeaderStx := (← StateT.lift get).initHeaderStx
|
|
|
let (newHeaderStx, newMpState, _) ← Parser.parseHeader newMeta.mkInputContext
|
|
|
let cancelTk ← CancelToken.new
|
|
|
let headSnapTask := oldDoc.cmdSnaps.waitHead?
|
|
|
let newSnaps ← if initHeaderStx != newHeaderStx then
|
|
|
EIO.asTask (ε := ElabTaskError) (prio := .dedicated) do
|
|
|
IO.sleep ctx.initParams.editDelay.toUInt32
|
|
|
cancelTk.check
|
|
|
IO.Process.exit 2
|
|
|
else EIO.mapTask (ε := ElabTaskError) (t := headSnapTask) (prio := .dedicated) fun headSnap?? => do
|
|
|
-- There is always at least one snapshot absent exceptions
|
|
|
let some headSnap ← MonadExcept.ofExcept headSnap?? | panic! "empty snapshots"
|
|
|
let newHeaderSnap := { headSnap with stx := newHeaderStx, mpState := newMpState }
|
|
|
let changePos := oldDoc.meta.text.source.firstDiffPos newMeta.text.source
|
|
|
-- Ignore exceptions, we are only interested in the successful snapshots
|
|
|
let (cmdSnaps, _) ← oldDoc.cmdSnaps.getFinishedPrefix
|
|
|
-- NOTE(WN): we invalidate eagerly as `endPos` consumes input greedily. To re-elaborate only
|
|
|
-- when really necessary, we could do a whitespace-aware `Syntax` comparison instead.
|
|
|
let mut validSnaps ← pure (cmdSnaps.takeWhile (fun s => s.endPos < changePos))
|
|
|
if h : validSnaps.length ≤ 1 then
|
|
|
validSnaps := [newHeaderSnap]
|
|
|
else
|
|
|
/- When at least one valid non-header snap exists, it may happen that a change does not fall
|
|
|
within the syntactic range of that last snap but still modifies it by appending tokens.
|
|
|
We check for this here. We do not currently handle crazy grammars in which an appended
|
|
|
token can merge two or more previous commands into one. To do so would require reparsing
|
|
|
the entire file. -/
|
|
|
have : validSnaps.length ≥ 2 := Nat.gt_of_not_le h
|
|
|
let mut lastSnap := validSnaps.getLast (by subst ·; simp at h)
|
|
|
let preLastSnap :=
|
|
|
have : 0 < validSnaps.length := Nat.lt_of_lt_of_le (by decide) this
|
|
|
have : validSnaps.length - 2 < validSnaps.length := Nat.sub_lt this (by decide)
|
|
|
validSnaps[validSnaps.length - 2]
|
|
|
let newLastStx ← parseNextCmd newMeta.mkInputContext preLastSnap
|
|
|
if newLastStx != lastSnap.stx then
|
|
|
validSnaps := validSnaps.dropLast
|
|
|
-- wait for a bit, giving the initial `cancelTk.check` in `nextCmdSnap` time to trigger
|
|
|
-- before kicking off any expensive elaboration (TODO: make expensive elaboration cancelable)
|
|
|
unfoldCmdSnaps newMeta validSnaps.toArray cancelTk s ctx
|
|
|
(startAfterMs := ctx.initParams.editDelay.toUInt32)
|
|
|
StateT.lift <| modify fun st => { st with
|
|
|
doc := { meta := newMeta, cmdSnaps := AsyncList.delayed newSnaps, cancelTk }}
|
|
|
|
|
|
end Updates
|
|
|
|
|
|
section Initialization
|
|
|
|
|
|
def DocumentMeta.mkInputContext (doc : DocumentMeta) : Parser.InputContext where
|
|
|
input := "" -- No header!
|
|
|
fileName := (System.Uri.fileUriToPath? doc.uri).getD doc.uri |>.toString
|
|
|
fileMap := default
|
|
|
|
|
|
/-- `gameDir` and `module` were added.
|
|
|
|
|
|
TODO: In general this resembles little similarity with the
|
|
|
original code, and I don't know why...
|
|
|
-/
|
|
|
-- @[inherit_doc Lean.Server.FileWorker.compileHeader]
|
|
|
def compileHeader (m : DocumentMeta) (hOut : FS.Stream) (opts : Options) (hasWidgets : Bool)
|
|
|
(gameDir : String) (module : Name):
|
|
|
IO (Syntax × Task (Except Error (Snapshot × SearchPath))) := do
|
|
|
-- Determine search paths of the game project by running `lake env printenv LEAN_PATH`.
|
|
|
let out ← IO.Process.output
|
|
|
{ cwd := gameDir, cmd := "lake", args := #["env","printenv","LEAN_PATH"] }
|
|
|
if out.exitCode != 0 then
|
|
|
throwServerError s!"Error while running Lake: {out.stderr}"
|
|
|
|
|
|
-- Make the paths relative to the current directory
|
|
|
let paths : List System.FilePath := System.SearchPath.parse out.stdout.trim
|
|
|
let currentDir ← IO.currentDir
|
|
|
let paths := paths.map fun p => currentDir / (gameDir : System.FilePath) / p
|
|
|
|
|
|
-- Set the search path
|
|
|
Lean.searchPathRef.set paths
|
|
|
|
|
|
let env ← importModules' #[{ module := `Init : Import }, { module := module : Import }]
|
|
|
|
|
|
-- use empty header
|
|
|
let (headerStx, headerParserState, msgLog) ← Parser.parseHeader
|
|
|
{m.mkInputContext with
|
|
|
input := ""
|
|
|
fileMap := FileMap.ofString ""}
|
|
|
(headerStx, ·) <$> EIO.asTask do
|
|
|
let mut srcSearchPath : SearchPath := paths --← initSrcSearchPath (← getBuildDir)
|
|
|
let headerEnv := env
|
|
|
let mut headerEnv := headerEnv
|
|
|
try
|
|
|
if let some path := System.Uri.fileUriToPath? m.uri then
|
|
|
headerEnv := headerEnv.setMainModule (← moduleNameOfFileName path none)
|
|
|
catch _ => pure ()
|
|
|
let cmdState := Elab.Command.mkState headerEnv {} opts
|
|
|
let cmdState := { cmdState with infoState := {
|
|
|
enabled := true
|
|
|
trees := #[Elab.InfoTree.context (.commandCtx {
|
|
|
env := headerEnv
|
|
|
fileMap := m.text
|
|
|
ngen := { namePrefix := `_worker }
|
|
|
}) (Elab.InfoTree.node
|
|
|
(Elab.Info.ofCommandInfo { elaborator := `header, stx := headerStx })
|
|
|
(headerStx[1].getArgs.toList.map (fun importStx =>
|
|
|
Elab.InfoTree.node (Elab.Info.ofCommandInfo {
|
|
|
elaborator := `import
|
|
|
stx := importStx
|
|
|
}) #[].toPArray'
|
|
|
)).toPArray'
|
|
|
)].toPArray'
|
|
|
}}
|
|
|
let headerSnap := {
|
|
|
beginPos := 0
|
|
|
stx := headerStx
|
|
|
mpState := {} -- was `headerParserState`
|
|
|
cmdState := cmdState
|
|
|
interactiveDiags := ← cmdState.messages.msgs.mapM (Widget.msgToInteractiveDiagnostic m.text · hasWidgets)
|
|
|
tacticCache := (← IO.mkRef {})
|
|
|
}
|
|
|
publishDiagnostics m headerSnap.diagnostics.toArray hOut
|
|
|
return (headerSnap, srcSearchPath)
|
|
|
|
|
|
/-- Copied from `Lean.Server.FileWorker.initializeWorker`. Added `gameDir` and
|
|
|
`gameWorkerState` arguments and use custom `unfoldCmdSnaps`. -/
|
|
|
-- @[inherit_doc Lean.Server.FileWorker.initializeWorker]
|
|
|
def initializeWorker (meta : DocumentMeta) (i o e : FS.Stream) (initParams : InitializeParams) (opts : Options)
|
|
|
(gameDir : String) (gameWorkerState : WorkerState) : IO (WorkerContext × Server.FileWorker.WorkerState) := do
|
|
|
let clientHasWidgets := initParams.initializationOptions?.bind (·.hasWidgets?) |>.getD false
|
|
|
let (headerStx, headerTask) ← compileHeader meta o opts (hasWidgets := clientHasWidgets)
|
|
|
(gameDir := gameDir) (module := gameWorkerState.levelInfo.module)
|
|
|
let cancelTk ← CancelToken.new
|
|
|
let ctx := {
|
|
|
hIn := i
|
|
|
hOut := o
|
|
|
hLog := e
|
|
|
headerTask
|
|
|
initParams
|
|
|
clientHasWidgets
|
|
|
}
|
|
|
let cmdSnaps ← EIO.mapTask (t := headerTask) (match · with
|
|
|
| Except.ok (s, _) => unfoldCmdSnaps meta #[s] cancelTk gameWorkerState ctx (startAfterMs := 0)
|
|
|
| Except.error e => throw (e : ElabTaskError))
|
|
|
let doc : EditableDocument := { meta, cmdSnaps := AsyncList.delayed cmdSnaps, cancelTk }
|
|
|
return (ctx, {
|
|
|
doc := doc
|
|
|
initHeaderStx := headerStx
|
|
|
currHeaderStx := headerStx
|
|
|
importCachingTask? := none
|
|
|
pendingRequests := RBMap.empty
|
|
|
rpcSessions := RBMap.empty
|
|
|
})
|
|
|
|
|
|
end Initialization
|
|
|
|
|
|
section NotificationHandling
|
|
|
|
|
|
/-- Copied from `Lean.Server.FileWorker.handleDidChange` but with our custom `WorkerM` and
|
|
|
`updateDocument` -/
|
|
|
-- @[inherit_doc Lean.Server.FileWorker.handleDidChange]
|
|
|
def handleDidChange (p : DidChangeTextDocumentParams) : WorkerM Unit := do
|
|
|
let docId := p.textDocument
|
|
|
let changes := p.contentChanges
|
|
|
let oldDoc := (← StateT.lift get).doc -- needed a lift to our custom `WorkerM`
|
|
|
let newVersion := docId.version?.getD 0
|
|
|
if ¬ changes.isEmpty then
|
|
|
let newDocText := foldDocumentChanges changes oldDoc.meta.text
|
|
|
-- modification: set the `DependencyBuildMode` from
|
|
|
-- `oldDoc.meta.dependencyBuildMode` to `.always`
|
|
|
updateDocument ⟨docId.uri, newVersion, newDocText, .always⟩
|
|
|
|
|
|
end NotificationHandling
|
|
|
|
|
|
section MessageHandling
|
|
|
|
|
|
|
|
|
/--
|
|
|
Modified notification handler.
|
|
|
|
|
|
Compare to `Lean.Server.FileWorker.handleNotification`.
|
|
|
We use the modified `WorkerM` and use our custom `handleDidChange`.
|
|
|
|
|
|
-/
|
|
|
def handleNotification (method : String) (params : Json) : WorkerM Unit := do
|
|
|
let handle := fun paramType [FromJson paramType] (handler : paramType → WorkerM Unit) =>
|
|
|
(StateT.lift <| parseParams paramType params) >>= handler
|
|
|
match method with
|
|
|
-- Modified `textDocument/didChange`, using a custom `handleDidChange`
|
|
|
| "textDocument/didChange" => handle DidChangeTextDocumentParams (handleDidChange)
|
|
|
-- unmodified
|
|
|
| "$/cancelRequest" => handle CancelParams (handleCancelRequest ·)
|
|
|
-- unmodified
|
|
|
| "$/lean/rpc/release" => handle RpcReleaseParams (handleRpcRelease ·)
|
|
|
-- unmodified
|
|
|
| "$/lean/rpc/keepAlive" => handle RpcKeepAliveParams (handleRpcKeepAlive ·)
|
|
|
-- New. TODO: What is this for?
|
|
|
| "$/setTrace" => pure ()
|
|
|
| _ => throwServerError s!"Got unsupported notification method: {method}"
|
|
|
|
|
|
end MessageHandling
|
|
|
|
|
|
section MainLoop
|
|
|
|
|
|
/--
|
|
|
The main-loop. Copied from `Lean.Server.FileWorker.mainLoop`. Use custom `WorkerM` as well
|
|
|
as custom `handleNotification`.
|
|
|
-/
|
|
|
--@[inherit_doc Lean.Server.FileWorker.mainLoop]
|
|
|
partial def mainLoop : WorkerM Unit := do
|
|
|
let ctx ← read
|
|
|
let mut st ← StateT.lift get
|
|
|
let msg ← ctx.hIn.readLspMessage
|
|
|
-- Erase finished tasks if there are no errors.
|
|
|
let filterFinishedTasks (acc : PendingRequestMap) (id : RequestID) (task : Task (Except IO.Error Unit))
|
|
|
: IO PendingRequestMap := do
|
|
|
if (← hasFinished task) then
|
|
|
if let Except.error e := task.get then
|
|
|
throwServerError s!"Failed responding to request {id}: {e}"
|
|
|
pure <| acc.erase id
|
|
|
else pure acc
|
|
|
let pendingRequests ← st.pendingRequests.foldM (fun acc id task => filterFinishedTasks acc id task) st.pendingRequests
|
|
|
st := { st with pendingRequests }
|
|
|
for (id, seshRef) in st.rpcSessions do
|
|
|
let sesh ← seshRef.get
|
|
|
if (← sesh.hasExpired) then
|
|
|
st := { st with rpcSessions := st.rpcSessions.erase id }
|
|
|
|
|
|
set st
|
|
|
-- Process the RPC-message and restart main-loop.
|
|
|
match msg with
|
|
|
| Message.request id "shutdown" none =>
|
|
|
--added. TODO: why do we need that? Or has it just removed in Lean since when we started?
|
|
|
ctx.hOut.writeLspResponse ⟨id, Json.null⟩
|
|
|
mainLoop
|
|
|
| Message.request id method (some params) =>
|
|
|
-- Requests are handled by the unmodified lean server.
|
|
|
handleRequest id method (toJson params)
|
|
|
mainLoop
|
|
|
| Message.notification "exit" none =>
|
|
|
let doc := st.doc
|
|
|
doc.cancelTk.set
|
|
|
doc.cmdSnaps.cancel
|
|
|
return ()
|
|
|
| Message.notification method (some params) =>
|
|
|
-- Custom notification handler
|
|
|
handleNotification method (toJson params)
|
|
|
mainLoop
|
|
|
| _ =>
|
|
|
throwServerError s!"Got invalid JSON-RPC message: {toJson msg}"
|
|
|
|
|
|
end MainLoop
|
|
|
|
|
|
/-- Modified from `Lean.Server.FileWorker.initAndRunWorker`.
|
|
|
Added `gameDir` argument, -/
|
|
|
-- @[inherit_doc Lean.Server.FileWorker.initAndRunWorker]
|
|
|
def initAndRunWorker (i o e : FS.Stream) (opts : Options) (gameDir : String) : IO UInt32 := do
|
|
|
let i ← maybeTee "fwIn.txt" false i
|
|
|
let o ← maybeTee "fwOut.txt" true o
|
|
|
|
|
|
|
|
|
-- BIG MODIFICATION
|
|
|
let initRequest ← i.readLspRequestAs "initialize" Game.InitializeParams
|
|
|
o.writeLspResponse {
|
|
|
id := initRequest.id
|
|
|
result := {
|
|
|
capabilities := Watchdog.mkLeanServerCapabilities
|
|
|
serverInfo? := some {
|
|
|
name := "Lean 4 Game Server"
|
|
|
version? := "0.1.1"
|
|
|
}
|
|
|
: InitializeResult
|
|
|
}
|
|
|
}
|
|
|
discard $ i.readLspNotificationAs "initialized" InitializedParams
|
|
|
let ⟨_, param⟩ ← i.readLspNotificationAs "textDocument/didOpen" DidOpenTextDocumentParams
|
|
|
|
|
|
|
|
|
let doc := param.textDocument
|
|
|
-- modification: using `.always`
|
|
|
let meta : DocumentMeta := ⟨doc.uri, doc.version, doc.text.toFileMap, .always⟩
|
|
|
let e := e.withPrefix s!"[{param.textDocument.uri}] "
|
|
|
let _ ← IO.setStderr e
|
|
|
try
|
|
|
|
|
|
|
|
|
-- BIG MODIFICATION
|
|
|
let game ← loadGameData gameDir
|
|
|
-- TODO: We misuse the `rootUri` field to the gameName
|
|
|
let rootUri? : Option String := some (toString game.name)
|
|
|
let initParams := {initRequest.param.toLeanInternal with rootUri?}
|
|
|
let some (levelId : LevelId) := GameServer.levelIdFromFileName?
|
|
|
initParams meta.mkInputContext.fileName
|
|
|
| throwServerError s!"Could not determine level ID: {meta.mkInputContext.fileName}"
|
|
|
let levelInfo ← loadLevelData gameDir levelId.world levelId.level
|
|
|
let some initializationOptions := initRequest.param.initializationOptions?
|
|
|
| throwServerError "no initialization options found"
|
|
|
let gameWorkerState : WorkerState := {
|
|
|
inventory := initializationOptions.inventory
|
|
|
difficulty := initializationOptions.difficulty
|
|
|
levelInfo
|
|
|
}
|
|
|
let (ctx, st) ← initializeWorker meta i o e initParams opts gameDir gameWorkerState
|
|
|
-- Run the main loop
|
|
|
let _ ← StateRefT'.run (s := st) <| ReaderT.run (r := ctx) <|
|
|
|
StateT.run (s := gameWorkerState) <| (mainLoop)
|
|
|
|
|
|
|
|
|
return (0 : UInt32)
|
|
|
catch e =>
|
|
|
IO.eprintln e
|
|
|
publishDiagnostics meta #[{
|
|
|
range := ⟨⟨0, 0⟩, ⟨0, 0⟩⟩,
|
|
|
severity? := DiagnosticSeverity.error,
|
|
|
message := e.toString }] o
|
|
|
return (1 : UInt32)
|
|
|
|
|
|
/--
|
|
|
The main function. Simply wrapping `initAndRunWorker`.
|
|
|
|
|
|
Copied from `Lean.Server.FileWorker.workerMain`. We add `args` as an argument to pass on
|
|
|
the `gameDir`.
|
|
|
|
|
|
TODO: The first arg `args[0]` is always expected to be `--server`. We could drop this completely.
|
|
|
-/
|
|
|
-- @[inherit_doc Lean.Server.FileWorker.workerMain]
|
|
|
def workerMain (opts : Options) (args : List String): IO UInt32 := do
|
|
|
let i ← IO.getStdin
|
|
|
let o ← IO.getStdout
|
|
|
let e ← IO.getStderr
|
|
|
try
|
|
|
let some gameDir := args[1]? | throwServerError "Expected second argument: gameDir"
|
|
|
let exitCode ← initAndRunWorker i o e opts gameDir
|
|
|
o.flush
|
|
|
e.flush
|
|
|
IO.Process.exit exitCode.toUInt8
|
|
|
catch err =>
|
|
|
e.putStrLn s!"worker initialization error: {err}"
|
|
|
return (1 : UInt32)
|
|
|
|
|
|
end GameServer.FileWorker
|