change folder structure

This commit is contained in:
Jon Eugster
2023-04-26 01:31:35 +02:00
parent 2097eddb2f
commit 8246ae6eac
20 changed files with 16 additions and 10 deletions
+26
View File
@@ -0,0 +1,26 @@
import Lean
section AbstractCtx
open Lean
open Meta
structure AbstractCtxResult :=
(abstractMVarsResult : AbstractMVarsResult)
/-- Abstract LCtx and MCtx to transport an expression into different contexts -/
def abstractCtx (goal : MVarId) : MetaM AbstractCtxResult := do
let goalDecl ← goal.getDecl
let fvars := goalDecl.lctx.decls.toArray.filterMap id |> Array.map (mkFVar ·.fvarId)
let goal ← mkLambdaFVars (usedLetOnly := false) fvars goalDecl.type
let goal ← abstractMVars goal
return {
abstractMVarsResult := goal
}
def openAbstractCtxResult (res : AbstractCtxResult) (k : Array Expr → Expr → MetaM α) : MetaM α := do
let (mvars, binderInfo, expr) ← openAbstractMVarsResult res.abstractMVarsResult
lambdaLetTelescope (← instantiateMVars expr) k
-- TODO: Unfornately, lambdaLetTelescope does not allow us to provide the number of arguments.
-- If the goal is a function, this will not work.
end AbstractCtx
+564
View File
@@ -0,0 +1,564 @@
import GameServer.EnvExtensions
open Lean Meta Elab Command
set_option autoImplicit false
/-! # Game metadata -/
/-- Switch to the specified `Game` (and create it if non-existent). Example: `Game "NNG"` -/
elab "Game" n:str : command => do
let name := n.getString
setCurGameId name
if (← getGame? name).isNone then
insertGame name {name}
/-- Create a new world in the active game. Example: `World "Addition"` -/
elab "World" n:str : command => do
let name := n.getString
setCurWorldId name
if ¬ (← getCurGame).worlds.nodes.contains name then
addWorld {name}
/-- Define the current level number. Levels inside a world must be
numbered consecutive starting with `1`. Example: `Level 1` -/
elab "Level" level:num : command => do
let level := level.getNat
setCurLevelIdx level
addLevel {index := level}
/-- Define the title of the current game/world/level. -/
elab "Title" t:str : command => do
match ← getCurLayer with
| .Level => modifyCurLevel fun level => pure {level with title := t.getString}
| .World => modifyCurWorld fun world => pure {world with title := t.getString}
| .Game => modifyCurGame fun game => pure {game with title := t.getString}
/-- Define the introduction of the current game/world/level. -/
elab "Introduction" t:str : command => do
match ← getCurLayer with
| .Level => modifyCurLevel fun level => pure {level with introduction := t.getString}
| .World => modifyCurWorld fun world => pure {world with introduction := t.getString}
| .Game => modifyCurGame fun game => pure {game with introduction := t.getString}
/-- Define the conclusion of the current game or current level if some
building a level. -/
elab "Conclusion" t:str : command => do
match ← getCurLayer with
| .Level => modifyCurLevel fun level => pure {level with conclusion := t.getString}
| .World => modifyCurWorld fun world => pure {world with conclusion := t.getString}
| .Game => modifyCurGame fun game => pure {game with conclusion := t.getString}
/-! ## World Paths -/
/-- The worlds of a game are joint by paths. These are defined with the syntax
`Path World₁ → World₂ → World₃`. -/
def Parser.path := Parser.sepBy1Indent Parser.ident "→"
/-- The worlds of a game are joint by paths. These are defined with the syntax
`Path World₁ → World₂ → World₃`. -/
elab "Path" s:Parser.path : command => do
let mut last : Option Name := none
for stx in s.raw.getArgs.getEvenElems do
let some l := last
| do
last := some stx.getId
continue
modifyCurGame fun game =>
pure {game with worlds := {game.worlds with edges := game.worlds.edges.push (l, stx.getId)}}
last := some stx.getId
/-! # Inventory
The inventory contains docs for tactics, lemmas, and definitions. These are all disabled
in the first level and get enabled during the game.
-/
/-! ## Doc entries -/
/-- Throw an error if inventory doc does not exist -/
def checkInventoryDoc (type : InventoryType) (name : Name) : CommandElabM Unit := do
let some _ := (inventoryDocExt.getState (← getEnv)).find?
(fun x => x.name == name && x.type == type)
| throwError "Missing {type} Documentation: {name} (add `{type}Doc {name}` in your game's docs section)"
/-- Documentation entry of a tactic. Example:
```
TacticDoc rw "`rw` stands for rewrite, etc. "
```
* The identifier is the tactics name. Some need to be escaped like `«have»`.
* The description is a string supporting Markdown.
-/
elab "TacticDoc" name:ident content:str : command =>
modifyEnv (inventoryDocExt.addEntry · {
category := default
type := .Tactic
name := name.getId
displayName := name.getId.toString
content := content.getString })
/-- Documentation entry of a lemma. Example:
```
LemmaDoc Nat.succ_pos as "succ_pos" in "Nat" "says `0 < n.succ`, etc."
```
* The first identifier is used in the commands `[New/Only/Disabled]Lemma`.
It is preferably the true name of the lemma. However, this is not required.
* The string following `as` is the displayed name (in the Inventory).
* The identifier after `in` is the category to group lemmas by (in the Inventory).
* The description is a string supporting Markdown.
-/
elab "LemmaDoc" name:ident "as" displayName:str "in" category:str content:str : command =>
modifyEnv (inventoryDocExt.addEntry · {
name := name.getId,
type := .Lemma
displayName := displayName.getString,
category := category.getString,
content := content.getString })
/-- Documentation entry of a definition. Example:
```
DefinitionDoc Function.Bijective as "Bijective" "defined as `Injective f ∧ Surjective`, etc."
```
* The first identifier is used in the commands `[New/Only/Disabled]Definition`.
It is preferably the true name of the definition. However, this is not required.
* The string following `as` is the displayed name (in the Inventory).
* The description is a string supporting Markdown.
-/
elab "DefinitionDoc" name:ident "as" displayName:str content:str : command =>
modifyEnv (inventoryDocExt.addEntry · {
category := default
type := .Definition
name := name.getId,
displayName := displayName.getString,
content := content.getString })
/-! ## Add inventory items -/
/-- Declare tactics that are introduced by this level. -/
elab "NewTactic" args:ident* : command => do
let names := args.map (·.getId)
for name in names do checkInventoryDoc .Tactic name
modifyCurLevel fun level => pure {level with tactics := {level.tactics with new := names}}
/-- Declare lemmas that are introduced by this level. -/
elab "NewLemma" args:ident* : command => do
let names := args.map (·.getId)
for name in names do checkInventoryDoc .Lemma name
modifyCurLevel fun level => pure {level with lemmas := {level.lemmas with new := names}}
/-- Declare definitions that are introduced by this level. -/
elab "NewDefinition" args:ident* : command => do
let names := args.map (·.getId)
for name in names do checkInventoryDoc .Definition name
modifyCurLevel fun level => pure {level with definitions := {level.definitions with new := names}}
/-- Declare tactics that are temporarily disabled in this level.
This is ignored if `OnlyTactic` is set. -/
elab "DisabledTactic" args:ident* : command => do
let names := args.map (·.getId)
-- for name in names do checkInventoryDoc .Tactic name
modifyCurLevel fun level => pure {level with tactics := {level.tactics with disabled := names}}
/-- Declare lemmas that are temporarily disabled in this level.
This is ignored if `OnlyLemma` is set. -/
elab "DisabledLemma" args:ident* : command => do
let names := args.map (·.getId)
-- for name in names do checkInventoryDoc .Lemma name
modifyCurLevel fun level => pure {level with lemmas := {level.lemmas with disabled := names}}
/-- Declare definitions that are temporarily disabled in this level -/
elab "DisabledDefinition" args:ident* : command => do
let names := args.map (·.getId)
-- for name in names do checkInventoryDoc .Definition name
modifyCurLevel fun level => pure {level with definitions := {level.definitions with disabled := names}}
/-- Temporarily disable all tactics except the ones declared here -/
elab "OnlyTactic" args:ident* : command => do
let names := args.map (·.getId)
for name in names do checkInventoryDoc .Tactic name
modifyCurLevel fun level => pure {level with tactics := {level.tactics with only := names}}
/-- Temporarily disable all lemmas except the ones declared here -/
elab "OnlyLemma" args:ident* : command => do
let names := args.map (·.getId)
for name in names do checkInventoryDoc .Lemma name
modifyCurLevel fun level => pure {level with lemmas := {level.lemmas with only := names}}
/-- Temporarily disable all definitions except the ones declared here.
This is ignored if `OnlyDefinition` is set. -/
elab "OnlyDefinition" args:ident* : command => do
let names := args.map (·.getId)
for name in names do checkInventoryDoc .Definition name
modifyCurLevel fun level => pure {level with definitions := {level.definitions with only := names}}
/-- Define which tab of Lemmas is opened by default. Usage: `LemmaTab "Nat"`.
If omitted, the current tab will remain open. -/
elab "LemmaTab" category:str : command =>
modifyCurLevel fun level => pure {level with lemmaTab := category.getString}
/-! # Exercise Statement -/
-- TODO: Instead of this, it would be nice to have a proper syntax parser that enables
-- us highlighting on the client side.
partial def reprintCore : Syntax → Option Format
| Syntax.missing => none
| Syntax.atom _ val => val.trim
| Syntax.ident _ rawVal _ _ => rawVal.toString
| Syntax.node _ _ args =>
match args.toList.filterMap reprintCore with
| [] => none
| [arg] => arg
| args => Format.group <| Format.nest 2 <| Format.joinSep args " "
/-- `reprint` is used to display the Lean-statement to the user-/
def reprint (stx : Syntax) : Format :=
reprintCore stx |>.getD ""
/-- Define the statement of the current level. -/
elab "Statement" statementName:ident ? descr:str ? sig:declSig val:declVal : command => do
let lvlIdx ← getCurLevelIdx
-- Save the messages before evaluation of the proof.
let initMsgs ← modifyGet fun st => (st.messages, { st with messages := {} })
-- Check that statement has a docs entry.
match statementName with
| some name => checkInventoryDoc .Lemma name.getId
| none => pure ()
-- The default name of the statement is `[Game].[World].level[no.]`, e.g. `NNG.Addition.level1`
-- However, this should not be used when designing the game.
let defaultDeclName : Name := (← getCurGame).name ++ (← getCurWorld).name ++
("level" ++ toString lvlIdx : String)
-- Add theorem to context.
match statementName with
| some name =>
let env ← getEnv
if env.contains name.getId then
let origType := (env.constants.map₁.find! name.getId).type
-- TODO: Check if `origType` agrees with `sig` and output `logInfo` instead of `logWarning`
-- in that case.
logWarningAt name m!"Environment already contains {name.getId}!
Only the existing statement will be available in later levels:
{origType}"
-- let (binders, typeStx) := expandDeclSig sig
-- --let type ← Term.elabType typeStx
-- runTermElabM (fun vars =>
-- Term.elabBinders binders.getArgs (fun xs => do
-- let type ← Term.elabType typeStx
-- --Term.synthesizeSyntheticMVarsNoPostponing
-- --let type ← instantiateMVars type
-- --let type ← mkForallFVars xs type
-- ))
--let newType := Term.elabTerm sig.raw
--dbg_trace newType
--logInfo origType
-- dbg_trace sig
-- dbg_trace origType
--dbg_trace (env.constants.map₁.find! name.getId).value! -- that's the proof
--let newType := Lean.Elab.Term.elabTerm sig none
let thmStatement ← `(theorem $(mkIdent defaultDeclName) $sig $val)
elabCommand thmStatement
else
let thmStatement ← `(theorem $(mkIdent name.getId) $sig $val)
elabCommand thmStatement
| none =>
let thmStatement ← `(theorem $(mkIdent defaultDeclName) $sig $val)
elabCommand thmStatement
let msgs := (← get).messages
let mut hints := #[]
let mut nonHintMsgs := #[]
for msg in msgs.msgs do
-- Look for messages produced by the `Hint` tactic. They are used to pass information about the
-- intermediate goal state
if let MessageData.withNamingContext _ $ MessageData.withContext ctx $
.tagged `Hint $
.nest strict $
.nest hidden $
.compose (.ofGoal text) (.ofGoal goal) := msg.data then
let hint ← liftTermElabM $ withMCtx ctx.mctx $ withLCtx ctx.lctx #[] $ withEnv ctx.env do
return {
goal := ← abstractCtx goal
text := ← instantiateMVars (mkMVar text)
strict := strict == 1
hidden := hidden == 1
}
hints := hints.push hint
else
nonHintMsgs := nonHintMsgs.push msg
-- restore saved messages and non-hint messages
modify fun st => { st with
messages := initMsgs ++ ⟨nonHintMsgs.toPArray'⟩
}
let scope ← getScope
let env ← getEnv
modifyCurLevel fun level => pure {level with
module := env.header.mainModule
goal := sig,
scope := scope,
descrText := match descr with
| none => ""
| some s => s.getString
statementName := match statementName with
| none => default
| some name => name.getId
descrFormat := match statementName with
| none => "example " ++ (toString <| reprint sig.raw) ++ " := by"
| some name => (Format.join ["theorem ", reprint name.raw, " ", reprint sig.raw, " := by"]).pretty 10 -- "lemma " ++ (toString <| reprint name.raw) ++ " " ++ (Format.pretty (reprint sig.raw) 40) ++ " := by"
hints := hints
} -- Format.pretty <| format thmStatement.raw }
/-! # Hints -/
syntax hintArg := atomic(" (" (&"strict" <|> &"hidden") " := " withoutPosition(term) ")")
/-- Remove any spaces at the beginning of a new line -/
partial def removeIndentation (s : String) : String :=
let rec loop (i : String.Pos) (acc : String) (removeSpaces := false) : String :=
let c := s.get i
let i := s.next i
if s.atEnd i then
acc.push c
else if removeSpaces && c == ' ' then
loop i acc (removeSpaces := true)
else if c == '\n' then
loop i (acc.push c) (removeSpaces := true)
else
loop i (acc.push c)
loop ⟨0⟩ ""
/-- A tactic that can be used inside `Statement`s to indicate in which proof states players should
see hints. The tactic does not affect the goal state.
-/
elab "Hint" args:hintArg* msg:interpolatedStr(term) : tactic => do
let mut strict := false
let mut hidden := false
-- remove spaces at the beginngng of new lines
let msg := TSyntax.mk $ msg.raw.setArgs $ ← msg.raw.getArgs.mapM fun m => do
match m with
| Syntax.node info k args =>
if k == interpolatedStrLitKind && args.size == 1 then
match args.get! 0 with
| (Syntax.atom info' val) =>
let val := removeIndentation val
return Syntax.node info k #[Syntax.atom info' val]
| _ => return m
else
return m
| _ => return m
for arg in args do
match arg with
| `(hintArg| (strict := true)) => strict := true
| `(hintArg| (strict := false)) => strict := false
| `(hintArg| (hidden := true)) => hidden := true
| `(hintArg| (hidden := false)) => hidden := false
| _ => throwUnsupportedSyntax
let goal ← Tactic.getMainGoal
goal.withContext do
-- We construct an expression that can produce the hint text. The difficulty is that we
-- want the text to possibly contain quotation of the local variables which might have been
-- named differently by the player.
let varsName := `vars
let text ← withLocalDeclD varsName (mkApp (mkConst ``Array [levelZero]) (mkConst ``Expr)) fun vars => do
let mut text ← `(m! $msg)
let goalDecl ← goal.getDecl
let decls := goalDecl.lctx.decls.toArray.filterMap id
for i in [:decls.size] do
text ← `(let $(mkIdent decls[i]!.userName) := $(mkIdent varsName)[$(quote i)]!; $text)
return ← mkLambdaFVars #[vars] $ ← Term.elabTermAndSynthesize text none
let textmvar ← mkFreshExprMVar none
guard $ ← isDefEq textmvar text -- Store the text in a mvar.
-- The information about the hint is logged as a message using `logInfo` to transfer it to the
-- `Statement` command:
logInfo $
.tagged `Hint $
.nest (if strict then 1 else 0) $
.nest (if hidden then 1 else 0) $
.compose (.ofGoal textmvar.mvarId!) (.ofGoal goal)
/-- This tactic allows us to execute an alternative sequence of tactics, but without affecting the
proof state. We use it to define Hints for alternative proof methods or dead ends. -/
elab "Branch" t:tacticSeq : tactic => do
let b ← saveState
Tactic.evalTactic t
-- Show an info whether the branch proofs all remaining goals.
let gs ← Tactic.getUnsolvedGoals
if gs.isEmpty then
logInfo "This branch finishes the proof."
else
logInfo "This branch leaves open goals."
let msgs ← Core.getMessageLog
b.restore
Core.setMessageLog msgs
/-! # Make Game -/
def GameLevel.getInventory (level : GameLevel) : InventoryType → InventoryInfo
| .Tactic => level.tactics
| .Definition => level.definitions
| .Lemma => level.lemmas
def GameLevel.setComputedInventory (level : GameLevel) :
InventoryType → Array ComputedInventoryItem → GameLevel
| .Tactic, v => {level with tactics := {level.tactics with computed := v}}
| .Definition, v => {level with definitions := {level.definitions with computed := v}}
| .Lemma, v => {level with lemmas := {level.lemmas with computed := v}}
/-- Build the game. This command will precompute various things about the game, such as which
tactics are available in each level etc. -/
elab "MakeGame" : command => do
let game ← getCurGame
-- Check for loops in world graph
if game.worlds.hasLoops then
throwError "World graph must not contain loops! Check your `Path` declarations."
-- Compute which inventory items are available in which level:
for inventoryType in #[.Tactic, .Definition, .Lemma] do
let mut newItemsInWorld : HashMap Name (HashSet Name) := {}
let mut lemmaStatements : HashMap (Name × Nat) Name := {}
let mut allItems : HashSet Name := {}
for (worldId, world) in game.worlds.nodes.toArray do
let mut newItems : HashSet Name := {}
for (levelId, level) in world.levels.toArray do
let newLemmas := (level.getInventory inventoryType).new
newItems := newItems.insertMany newLemmas
allItems := allItems.insertMany newLemmas
if inventoryType == .Lemma then
-- For levels `2, 3, …` we check if the previous level was named
-- in which case we add it as available lemma.
match levelId with
| 0 => pure ()
| 1 => pure () -- level ids start with 1, so we need to skip 1, too.
| i₀ + 1 =>
-- add named statement from previous level to the available lemmas.
match (world.levels.find! (i₀)).statementName with
| .anonymous => pure ()
| .num _ _ => panic "Did not expect to get a numerical statement name!"
| .str pre s =>
let name := Name.str pre s
newItems := newItems.insert name
allItems := allItems.insert name
lemmaStatements := lemmaStatements.insert (worldId, levelId) name
if inventoryType == .Lemma then
-- if named, add the lemma from the last level of the world to the inventory
let i₀ := world.levels.size
match (world.levels.find! (i₀)).statementName with
| .anonymous => pure ()
| .num _ _ => panic "Did not expect to get a numerical statement name!"
| .str pre s =>
let name := Name.str pre s
newItems := newItems.insert name
allItems := allItems.insert name
newItemsInWorld := newItemsInWorld.insert worldId newItems
-- Basic inventory item availability: all locked.
let Availability₀ : HashMap Name ComputedInventoryItem :=
HashMap.ofList $
← allItems.toList.mapM fun item => do
let data := (← getInventoryDoc? item inventoryType).get!
return (item, {
name := item
displayName := data.displayName
category := data.category
locked := true })
-- Availability after a given world
let mut itemsInWorld : HashMap Name (HashMap Name ComputedInventoryItem) := {}
for (worldId, _) in game.worlds.nodes.toArray do
let mut items := Availability₀
let predecessors := game.worlds.predecessors worldId
for predWorldId in predecessors do
for item in newItemsInWorld.find! predWorldId do
let data := (← getInventoryDoc? item inventoryType).get!
items := items.insert item {
name := item
displayName := data.displayName
category := data.category
locked := false }
itemsInWorld := itemsInWorld.insert worldId items
for (worldId, world) in game.worlds.nodes.toArray do
let mut items := itemsInWorld.find! worldId
let levels := world.levels.toArray.insertionSort fun a b => a.1 < b.1
for (levelId, level) in levels do
let levelInfo := level.getInventory inventoryType
-- add items in `levelInfo.new` permanently as unlocked
for item in levelInfo.new do
let data := (← getInventoryDoc? item inventoryType).get!
items := items.insert item {
name := item
displayName := data.displayName
category := data.category
locked := false }
-- add the statement from the previous level permanently as unlocked
if inventoryType == .Lemma then
match lemmaStatements.find? (worldId, levelId) with
| none => pure ()
| some name =>
let data := (← getInventoryDoc? name inventoryType).get!
items := items.insert name {
name := name
displayName := data.displayName
category := data.category
locked := false }
let itemsArray := items.toArray
|>.insertionSort (fun a b => a.1.toString < b.1.toString)
|>.map (·.2)
|>.map (fun item => { item with
-- if `levelInfo.only` is set, disable everything not in there,
-- otherwise disable all in `levelInfo.disabled`
disabled := if levelInfo.only.size == 0 then
levelInfo.disabled.contains item.name
else
not (levelInfo.only.contains item.name)
})
modifyLevel ⟨← getCurGameId, worldId, levelId⟩ fun level => do
return level.setComputedInventory inventoryType itemsArray
/-! # Debugging tools -/
-- /-- Print current game for debugging purposes. -/
-- elab "PrintCurGame" : command => do
-- logInfo (toJson (← getCurGame))
/-- Print current level for debugging purposes. -/
elab "PrintCurLevel" : command => do
logInfo (repr (← getCurLevel))
/-- Print levels for debugging purposes. -/
elab "PrintLevels" : command => do
logInfo $ repr $ (← getCurWorld).levels.toArray
+344
View File
@@ -0,0 +1,344 @@
import GameServer.AbstractCtx
import GameServer.Graph
/-! # Environment extensions
The game framework stores almost all its game building data in environment extensions
defined in this file.
-/
open Lean
/-! ## Hints -/
/--
A hint to help the user with a specific goal state
-/
structure GoalHintEntry where
goal : AbstractCtxResult
/-- Text of the hint as an expression of type `Array Expr → MessageData` -/
text : Expr
/-- If true, then hint should be hidden and only be shown on player's request -/
hidden : Bool := false
/-- If true, then the goal must contain only the assumptions specified in `goal` and no others -/
strict : Bool := false
instance : Repr GoalHintEntry := {
reprPrec := fun a n => reprPrec a.text n
}
/-! ## Tactic/Definition/Lemma documentation -/
inductive InventoryType := | Tactic | Lemma | Definition
deriving ToJson, FromJson, Repr, BEq, Hashable, Inhabited
instance : ToString InventoryType := ⟨fun t => match t with
| .Tactic => "Tactic"
| .Lemma => "Lemma"
| .Definition => "Definition"
⟩
/-- An inventory item represents the documentation of a tactic/lemma/definitions. -/
structure InventoryDocEntry where
/--
The name of the item. The restrictions are:
* for Tactics: The name of the tactic.
* for Lemmas: *Fully qualified* lemma name.
* for Definitions: no restrictions.
-/
name : Name
/-- One of `Tactic`, `Lemma` and `Definition`. -/
type : InventoryType
/-- The display name shown in the inventory. This can be free-text. -/
displayName : String
/-- Category to group inventory items by. (currently only used for lemmas) -/
category : String
/-- The description (doc) of the item. (free-text) -/
content : String
deriving ToJson, Repr, Inhabited
structure ComputedInventoryItem where
/--
The name of the item. The restrictions are:
* for Tactics: The name of the tactic.
* for Lemmas: *Fully qualified* lemma name.
* for Definitions: no restrictions.
-/
name : Name
/-- The display name shown in the inventory. This can be free-text. -/
displayName : String
/-- Category to group inventory items by (currently only used for lemmas). -/
category : String
/-- If `true` then the item only gets unlocked in a later level. -/
locked : Bool
/-- If `true` then the item is blocked for this level. -/
disabled : Bool := false
deriving ToJson, FromJson, Repr, Inhabited
/-- Environment extension for inventory documentation. -/
initialize inventoryDocExt : SimplePersistentEnvExtension InventoryDocEntry (Array InventoryDocEntry) ←
registerSimplePersistentEnvExtension {
name := `inventory_doc
addEntryFn := Array.push
addImportedFn := Array.concatMap id
}
def getInventoryDoc? {m : Type → Type} [Monad m] [MonadEnv m] (n : Name) (type : InventoryType) :
m (Option InventoryDocEntry) := do
return (inventoryDocExt.getState (← getEnv)).find? (fun x => x.name == n && x.type == type)
open Elab Command in
/-- Print a registered tactic doc for debugging purposes. -/
elab "#print_doc" : command => do
for entry in inventoryDocExt.getState (← getEnv) do
dbg_trace "[{entry.type}] {entry.name} : {entry.content}"
/-! ## Environment extensions for game specification-/
/-- Register a (non-persistent) environment extension to hold the current level -/
initialize curGameExt : EnvExtension (Option Name) ← registerEnvExtension (pure none)
/-- Register a (non-persistent) environment extension to hold the current level -/
initialize curWorldExt : EnvExtension (Option Name) ← registerEnvExtension (pure none)
/-- Register a (non-persistent) environment extension to hold the current level -/
initialize curLevelExt : EnvExtension (Option Nat) ← registerEnvExtension (pure none)
inductive Layer :=
| Game | World | Level
variable {m: Type → Type} [Monad m] [MonadEnv m]
def setCurGameId (game : Name) : m Unit :=
modifyEnv (curGameExt.setState · (some game))
def setCurWorldId (world : Name) : m Unit :=
modifyEnv (curWorldExt.setState · (some world))
def setCurLevelIdx (level : Nat) : m Unit :=
modifyEnv (curLevelExt.setState · (some level))
def getCurLayer [MonadError m] : m Layer := do
match curGameExt.getState (← getEnv), curWorldExt.getState (← getEnv), curLevelExt.getState (← getEnv) with
| some _, some _, some _ => return Layer.Level
| some _, some _, none => return Layer.World
| some _, none, none => return Layer.Game
| _, _, _ => throwError "Invalid Layer"
def getCurGameId [MonadError m] : m Name := do
match curGameExt.getState (← getEnv) with
| some game => return game
| none => throwError "Current game not set"
def getCurWorldId [MonadError m] : m Name := do
match curWorldExt.getState (← getEnv) with
| some world => return world
| none => throwError "Current world not set"
def getCurLevelIdx [MonadError m] : m Nat := do
match curLevelExt.getState (← getEnv) with
| some level => return level
| none => throwError "Current level not set"
/-! ## Levels -/
structure LevelId where
game : Name
world : Name
level : Nat
deriving Inhabited
structure InventoryInfo where
-- new inventory items introduced by this level:
new : Array Name
-- inventory items exceptionally forbidden in this level:
disabled : Array Name
-- only these inventory items are allowed in this level (ignored if empty):
only : Array Name
-- inventory items in this level (computed by `MakeGame`):
computed : Array ComputedInventoryItem
deriving ToJson, FromJson, Repr, Inhabited
def getCurLevelId [MonadError m] : m LevelId := do
return { game := ← getCurGameId, world := ← getCurWorldId, level := ← getCurLevelIdx}
/-- Instance to make GameLevel Repr work -/
instance : Repr Elab.Command.Scope := ⟨fun s _ => repr s.currNamespace⟩
structure GameLevel where
index: Nat
/-- The title of the level. -/
title: String := default
/-- Introduction text shown all the time. (markdown) -/
introduction: String := default
conclusion: String := default
/-- The name of the exercise proven. If provided this lemma will be available in
future levels. -/
statementName: Name := default
hints: Array GoalHintEntry := default
/-- The statement in Lean. -/
goal : TSyntax `Lean.Parser.Command.declSig := default
scope : Elab.Command.Scope := default
/-- The mathematical statement in mathematician-readable form. (markdown) -/
descrText: String := default
descrFormat : String := default
/-- The `category` of lemmas to be open by default -/
lemmaTab: Option String := none
/-- The module to be imported when playing this level -/
module : Name := default
tactics: InventoryInfo := default
definitions: InventoryInfo := default
lemmas: InventoryInfo := default
deriving Inhabited, Repr
/-! ## World -/
/-- A world is a collection of levels, like a chapter. -/
structure World where
/- Internal name of the world. Not visible to the player. -/
name: Name
/-- Display title of the world. -/
title: String := default
/-- World introduction to be shown before the first level is loaded. (markdown) -/
introduction: String := default
/-- TODO: This is currently unused. -/
conclusion : String := default
/-- The levels of the world. -/
levels: HashMap Nat GameLevel := default
deriving Inhabited
instance : ToJson World := ⟨
fun world => Json.mkObj [
("name", toJson world.name),
("title", world.title),
("introduction", world.introduction)]
⟩
/-! ## Game -/
structure Game where
/-- Internal name of the game. -/
name : Name
/-- TODO: currently unused. -/
title : String := default
/-- Text displayed on the main landing page of the game. -/
introduction : String := default
/-- TODO: currently unused. -/
conclusion : String := default
/-- TODO: currently unused. -/
authors : List String := default
worlds : Graph Name World := default
deriving Inhabited, ToJson
/-! ## Game environment extension -/
def HashMap.merge [BEq α] [Hashable α] (old : HashMap α β) (new : HashMap α β) (merge : β → β → β) :
HashMap α β :=
new.fold (fun acc a b =>
if let some bOld := acc.find? a
then acc.insert a (merge bOld b)
else acc.insert a b) old
def GameLevel.merge (old : GameLevel) (new : GameLevel) : GameLevel :=
new -- simply override old level
def World.merge (old : World) (new : World) : World :=
{ new with
levels := HashMap.merge old.levels new.levels GameLevel.merge}
def Game.merge (old : Game) (new : Game) : Game :=
{ new with
worlds := {
nodes := HashMap.merge old.worlds.nodes new.worlds.nodes World.merge
edges := Id.run do
let mut res := old.worlds.edges
for e in new.worlds.edges do
if ¬ res.contains e then
res := res.push e
return res
} }
initialize gameExt : PersistentEnvExtension (Name × Game) (Name × Game) (HashMap Name Game) ←
do registerPersistentEnvExtension {
name := `gameExt,
mkInitial := pure {},
addImportedFn := fun ess => do
let mut games := {}
for es in ess do
for (name, game) in es do
match games.find? name with
| some oldgame =>
games := games.insert name (Game.merge oldgame game)
| none =>
games := games.insert name game
return games
addEntryFn := (λ s n => s.insert n.1 n.2),
exportEntriesFn := HashMap.toArray,
statsFn := fun s => format "number of local entries: " ++ format s.size
}
def getGame? (n : Name) : m (Option Game) := do
return (gameExt.getState (← getEnv)).find? n
def insertGame (n : Name) (g : Game) : m Unit := do
modifyEnv (gameExt.addEntry · (n, g))
def getLevel? (levelId : LevelId) : m (Option GameLevel) := do
let some game ← getGame? levelId.game
| return none
let some world := game.worlds.nodes.find? levelId.world
| return none
let some level := world.levels.find? levelId.level
| return none
return level
def getCurGame [MonadError m] : m Game := do
let some game ← getGame? (← getCurGameId)
| throwError m!"Game {← getCurGameId} does not exist"
return game
def modifyCurGame (fn : Game → m Game) [MonadError m] : m Unit := do
let game ← getCurGame
insertGame game.name (← fn game)
def addWorld (world : World) [MonadError m] : m Unit := do
modifyCurGame fun game => do
return {game with worlds := game.worlds.insertNode world.name world}
def getCurWorld [MonadError m] : m World := do
let some world := (← getCurGame).worlds.nodes.find? (← getCurWorldId)
| throwError m!"World {← getCurWorldId} does not exist"
return world
def modifyCurWorld (fn : World → m World) [MonadError m] : m Unit := do
modifyCurGame fun game => do
let world ← getCurWorld
return {game with worlds := {game.worlds with nodes := game.worlds.nodes.insert world.name (← fn world) }}
def addLevel (level : GameLevel) [MonadError m] : m Unit := do
modifyCurWorld fun world => do
return {world with levels := world.levels.insert level.index level}
def getCurLevel [MonadError m] : m GameLevel := do
let some level := (← getCurWorld).levels.find? (← getCurLevelIdx)
| throwError m!"Level {← getCurLevelIdx} does not exist"
return level
def modifyCurLevel (fn : GameLevel → m GameLevel) [MonadError m] : m Unit := do
modifyCurWorld fun world => do
let level ← getCurLevel
return {world with levels := world.levels.insert level.index (← fn level)}
def modifyLevel (levelId : LevelId) (fn : GameLevel → m GameLevel) [MonadError m] : m Unit := do
let some game ← getGame? levelId.game
| throwError m!"Game {levelId.game} does not exist"
let some world := (← getCurGame).worlds.nodes.find? levelId.world
| throwError m!"World {levelId.world} does not exist"
let some level := world.levels.find? levelId.level
| throwError m!"Level {levelId.level} does not exist"
let level' ← fn level
let world' := {world with levels := world.levels.insert levelId.level level'}
let game' := {game with worlds := game.worlds.insertNode levelId.world world'}
insertGame levelId.game game'
+561
View File
@@ -0,0 +1,561 @@
/- This file is mostly copied from `Lean/Server/FileWorker.lean`. -/
import Lean.Server.FileWorker
import GameServer.Game
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) (couldBeEndSnap : Bool) :
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 MyServer.FileWorker
open Lean
open Lean.Server
open Lean.Server.FileWorker
open Lsp
open IO
open Snapshots
open JsonRpc
section Elab
-- 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)
(levelParams : Game.DidOpenLevelParams) (stx : Syntax) :
Elab.Command.CommandElabM Unit := do
match stx with
| .missing => return ()
| .node info kind args =>
for arg in args do
findForbiddenTactics inputCtx levelParams arg
| .atom info val =>
-- ignore syntax elements that do not start with a letter
-- and ignore "with" keyword
let allowed := ["with", "fun", "at", "only"]
if 0 < val.length ∧ val.data[0]!.isAlpha ∧ not (allowed.contains val) then
let val := val.dropRightWhile (fun c => c == '!' || c == '?') -- treat `simp?` and `simp!` like `simp`
match levelParams.tactics.find? (fun t => t.name.toString == val) with
| none => addErrorMessage info s!"You have not unlocked the tactic '{val}' yet!"
| some tac =>
if tac.locked then
addErrorMessage info s!"You have not unlocked the tactic '{val}' yet!"
else if tac.disabled then
addErrorMessage info s!"The tactic '{val}' is disabled in this level!"
| .ident info rawVal val preresolved =>
let ns ←
try resolveGlobalConst (mkIdent val)
catch | _ => pure [] -- catch "unknown constant" error
for n in ns do
let some (.thmInfo ..) := (← getEnv).find? n
| return () -- not a theroem -> ignore
let lemmasAndDefs := levelParams.lemmas ++ levelParams.definitions
match lemmasAndDefs.find? (fun l => l.name == n) with
| none => addErrorMessage info s!"You have not unlocked the lemma/definition '{n}' yet!"
| some lem =>
if lem.locked then
addErrorMessage info s!"You have not unlocked the lemma/definition '{n}' yet!"
else if lem.disabled then
addErrorMessage info s!"The lemma/definition '{n}' is disabled in this level!"
where addErrorMessage (info : SourceInfo) (s : MessageData) :=
modify fun st => { st with
messages := st.messages.add {
fileName := inputCtx.fileName
severity := MessageSeverity.error
pos := inputCtx.fileMap.toPosition (info.getPos?.getD 0)
data := s
}
}
open Elab Meta Expr in
def compileProof (inputCtx : Parser.InputContext) (snap : Snapshot) (hasWidgets : Bool)
(couldBeEndSnap : Bool) (levelParams : Game.DidOpenLevelParams) (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
| throwError "Level not found: {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 couldBeEndSnap
modify (fun s => { s with messages := msgLog })
parseResultRef.set (tacticStx, cmdParserState)
-- Check for forbidden tactics
findForbiddenTactics inputCtx levelParams 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)]*)
let cmdStx ← `(command|
theorem the_theorem $(level.goal) := by {$(⟨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
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)
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
/-- 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 }
/-- Elaborates the next command after `parentSnap` and emits diagnostics into `hOut`. -/
private def nextSnap (ctx : WorkerContext) (m : DocumentMeta) (cancelTk : CancelToken)
(levelParams : Game.DidOpenLevelParams) (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
publishGameCompleted m ctx.hOut s.snaps
publishDiagnostics m lastSnap.diagnostics.toArray ctx.hOut
publishProgressDone m ctx.hOut
-- This will overwrite existing ilean info for the file, in case something
-- went wrong during the incremental updates.
publishIleanInfoFinal m ctx.hOut s.snaps
return none
publishProgressAtPos m lastSnap.endPos ctx.hOut
-- 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
levelParams initParams
set { s with snaps := s.snaps.push snap }
-- TODO(MH): check for interrupt with increased precision
cancelTk.check
/- NOTE(MH): This relies on the client discarding old diagnostics upon receiving new ones
while prefering newer versions over old ones. The former is necessary because we do
not explicitly clear older diagnostics, while the latter is necessary because we do
not guarantee that diagnostics are emitted in order. Specifically, it may happen that
we interrupted this elaboration task right at this point and a newer elaboration task
emits diagnostics, after which we emit old diagnostics because we did not yet detect
the interrupt. Explicitly clearing diagnostics is difficult for a similar reason,
because we cannot guarantee that no further diagnostics are emitted after clearing
them. -/
-- NOTE(WN): this is *not* redundent even if there are no new diagnostics in this snapshot
-- because empty diagnostics clear existing error/information squiggles. Therefore we always
-- want to publish in case there was previously a message at this position.
publishDiagnostics m snap.diagnostics.toArray ctx.hOut
publishIleanInfoUpdate m ctx.hOut #[snap]
return some snap
/-- Elaborates all commands after the last snap (at least the header snap is assumed to exist), emitting the diagnostics into `hOut`. -/
def unfoldSnaps (m : DocumentMeta) (snaps : Array Snapshot) (cancelTk : CancelToken)
(startAfterMs : UInt32) (levelParams : Game.DidOpenLevelParams)
: ReaderT WorkerContext IO (AsyncList ElabTaskError Snapshot) := do
let ctx ← read
let some headerSnap := snaps[0]? | panic! "empty snapshots"
if headerSnap.msgLog.hasErrors then
-- Treat header processing errors as fatal so users aren't swamped with
-- followup errors
publishProgressAtPos m headerSnap.beginPos ctx.hOut (kind := LeanFileProgressKind.fatalError)
publishIleanInfoFinal m ctx.hOut #[headerSnap]
return AsyncList.ofList [headerSnap]
else
-- This will overwrite existing ilean info for the file since this has a
-- higher version number.
publishIleanInfoUpdate m ctx.hOut snaps
return AsyncList.ofList snaps.toList ++ AsyncList.delayed (← EIO.asTask (ε := ElabTaskError) (prio := .dedicated) do
IO.sleep startAfterMs
AsyncList.unfoldAsync (nextSnap ctx m cancelTk levelParams ctx.initParams) { snaps })
end Elab
section Updates
/-- Given the new document, updates editable doc state. -/
def updateDocument (newMeta : DocumentMeta) (levelParams : Game.DidOpenLevelParams) : WorkerM Unit := do
let ctx ← read
let oldDoc := (←get).doc
oldDoc.cancelTk.set
let initHeaderStx := (← 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)
unfoldSnaps newMeta validSnaps.toArray cancelTk levelParams ctx
(startAfterMs := ctx.initParams.editDelay.toUInt32)
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
def compileHeader (m : DocumentMeta) (hOut : FS.Stream) (opts : Options) (hasWidgets : Bool)
(levelModule : Name) (initParams : InitializeParams): IO (Syntax × Task (Except Error (Snapshot × SearchPath))) := do
let some gameDir := GameServer.gameDirFromInitParams initParams
| throwServerError s!"Invalid rootUri: {initParams.rootUri?}"
-- 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 := levelModule : Import }] {} 0
-- return (env, paths)
-- 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 ({
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 := {}
cmdState := cmdState
interactiveDiags := ← cmdState.messages.msgs.mapM (Widget.msgToInteractiveDiagnostic m.text · hasWidgets)
tacticCache := (← IO.mkRef {})
}
publishDiagnostics m headerSnap.diagnostics.toArray hOut
return (headerSnap, srcSearchPath)
def initializeWorker (meta : DocumentMeta) (i o e : FS.Stream) (initParams : InitializeParams) (opts : Options)
(levelParams : Game.DidOpenLevelParams) : IO (WorkerContext × WorkerState) := do
let clientHasWidgets := initParams.initializationOptions?.bind (·.hasWidgets?) |>.getD false
let (headerStx, headerTask) ← compileHeader meta o opts (hasWidgets := clientHasWidgets)
levelParams.levelModule initParams
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, _) => unfoldSnaps meta #[s] cancelTk levelParams ctx (startAfterMs := 0)
| Except.error e => throw (e : ElabTaskError))
let doc : EditableDocument := { meta, cmdSnaps := AsyncList.delayed cmdSnaps, cancelTk }
return (ctx,
{ doc := doc
initHeaderStx := headerStx
pendingRequests := RBMap.empty
rpcSessions := RBMap.empty
})
end Initialization
section NotificationHandling
def handleDidChange (levelParams : Game.DidOpenLevelParams) (p : DidChangeTextDocumentParams) : WorkerM Unit := do
let docId := p.textDocument
let changes := p.contentChanges
let oldDoc := (←get).doc
let some newVersion ← pure docId.version?
| throwServerError "Expected version number"
if newVersion ≤ oldDoc.meta.version then
-- TODO(WN): This happens on restart sometimes.
IO.eprintln s!"Got outdated version number: {newVersion} ≤ {oldDoc.meta.version}"
else if ¬ changes.isEmpty then
let newDocText := foldDocumentChanges changes oldDoc.meta.text
updateDocument ⟨docId.uri, newVersion, newDocText⟩ levelParams
end NotificationHandling
section MessageHandling
def handleNotification (method : String) (params : Json) (levelParams : Game.DidOpenLevelParams) : WorkerM Unit := do
let handle := fun paramType [FromJson paramType] (handler : paramType → WorkerM Unit) =>
parseParams paramType params >>= handler
match method with
| "textDocument/didChange" => handle DidChangeTextDocumentParams (handleDidChange levelParams)
| "$/cancelRequest" => handle CancelParams handleCancelRequest
| "$/lean/rpc/release" => handle RpcReleaseParams handleRpcRelease
| "$/lean/rpc/keepAlive" => handle RpcKeepAliveParams handleRpcKeepAlive
| _ => throwServerError s!"Got unsupported notification method: {method}"
end MessageHandling
section MainLoop
partial def mainLoop (levelParams : Game.DidOpenLevelParams) : WorkerM Unit := do
let ctx ← read
let mut st ← get
let msg ← ctx.hIn.readLspMessage
let filterFinishedTasks (acc : PendingRequestMap) (id : RequestID) (task : Task (Except IO.Error Unit))
: IO PendingRequestMap := do
if (← hasFinished task) then
/- Handler tasks are constructed so that the only possible errors here
are failures of writing a response into the stream. -/
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 }
-- Opportunistically (i.e. when we wake up on messages) check if any RPC session has expired.
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
match msg with
| Message.request id method (some params) =>
if method == "Game.getInteractiveGoals" then throwServerError "HELLO"
handleRequest id method (toJson params)
mainLoop levelParams
| Message.notification "exit" none =>
let doc := st.doc
doc.cancelTk.set
return ()
| Message.notification method (some params) =>
handleNotification method (toJson params) levelParams
mainLoop levelParams
| _ => throwServerError "Got invalid JSON-RPC message"
end MainLoop
def initAndRunWorker (i o e : FS.Stream) (opts : Options) : IO UInt32 := do
let i ← maybeTee "fwIn.txt" false i
let o ← maybeTee "fwOut.txt" true o
let initParams ← i.readLspRequestAs "initialize" InitializeParams
let ⟨_, param⟩ ← i.readLspNotificationAs "textDocument/didOpen" DidOpenTextDocumentParams
let ⟨_, levelParams⟩ ← i.readLspNotificationAs "$/game/didOpenLevel" Game.DidOpenLevelParams
let doc := param.textDocument
/- NOTE(WN): `toFileMap` marks line beginnings as immediately following
"\n", which should be enough to handle both LF and CRLF correctly.
This is because LSP always refers to characters by (line, column),
so if we get the line number correct it shouldn't matter that there
is a CR there. -/
let meta : DocumentMeta := ⟨doc.uri, doc.version, doc.text.toFileMap⟩
let e := e.withPrefix s!"[{param.textDocument.uri}] "
let _ ← IO.setStderr e
try
let (ctx, st) ← initializeWorker meta i o e initParams.param opts levelParams
let _ ← StateRefT'.run (s := st) <| ReaderT.run (r := ctx) (mainLoop levelParams)
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)
def workerMain (opts : Options) : IO UInt32 := do
let i ← IO.getStdin
let o ← IO.getStdout
let e ← IO.getStderr
try
let exitCode ← initAndRunWorker i o e opts
-- HACK: all `Task`s are currently "foreground", i.e. we join on them on main thread exit, but we definitely don't
-- want to do that in the case of the worker processes, which can produce non-terminating tasks evaluating user code
o.flush
e.flush
IO.Process.exit exitCode.toUInt8
catch err =>
e.putStrLn s!"worker initialization error: {err}"
return (1 : UInt32)
end MyServer.FileWorker
+162
View File
@@ -0,0 +1,162 @@
import GameServer.RpcHandlers
open Lean
structure GameServerState :=
(env : Lean.Environment)
(game : Name)
abbrev GameServerM := StateT GameServerState Server.Watchdog.ServerM
instance : MonadEnv GameServerM := {
getEnv := do return (← get).env
modifyEnv := fun f => do
let s ← get
set {s with env := f s.env}
}
namespace Game
open Server
open Watchdog
open Lsp
open JsonRpc
open IO
def getGame (game : Name): GameServerM Json := do
let some game ← getGame? game
| throwServerError "Game not found"
let gameJson : Json := toJson game
-- Add world sizes to Json object
let worldSize := game.worlds.nodes.toList.map (fun (n, w) => (n.toString, w.levels.size))
let gameJson := gameJson.mergeObj (Json.mkObj [("worldSize", Json.mkObj worldSize)])
return gameJson
/--
Fields:
- description: Lemma in mathematical language.
- descriptionGoal: Lemma printed as Lean-Code.
-/
structure LevelInfo where
index : Nat
title : String
tactics : Array ComputedInventoryItem
lemmas : Array ComputedInventoryItem
definitions : Array ComputedInventoryItem
introduction : String
conclusion : String
descrText : String := ""
descrFormat : String := ""
lemmaTab : Option String
statementName : Option String
deriving ToJson, FromJson
structure LoadLevelParams where
world : Name
level : Nat
deriving ToJson, FromJson
structure DidOpenLevelParams where
uri : String
levelModule : Name
tactics : Array ComputedInventoryItem
lemmas : Array ComputedInventoryItem
definitions : Array ComputedInventoryItem
deriving ToJson, FromJson
structure Doc where
name: String
displayName: String
text: String
deriving ToJson
structure LoadDocParams where
name : Name
type : InventoryType
deriving ToJson, FromJson
def handleDidOpenLevel (params : Json) : GameServerM Unit := do
let p ← parseParams _ params
let m := p.textDocument
-- Execute the regular handling of the `didOpen` event
handleDidOpen p
let fw ← findFileWorker! m.uri
-- let s ← get
let c ← read
let some lvl ← GameServer.getLevelByFileName? c.initParams ((System.Uri.fileUriToPath? m.uri).getD m.uri |>.toString)
| do
c.hLog.putStr s!"Level not found: {m.uri} {c.initParams.rootUri?}"
c.hLog.flush
-- Send an extra notification to the file worker to inform it about the level data
fw.stdin.writeLspNotification {
method := "$/game/didOpenLevel"
param := {
uri := m.uri
levelModule := lvl.module
tactics := lvl.tactics.computed
lemmas := lvl.lemmas.computed
definitions := lvl.definitions.computed
: DidOpenLevelParams
}
}
partial def handleServerEvent (ev : ServerEvent) : GameServerM Bool := do
match ev with
| ServerEvent.clientMsg msg =>
match msg with
| Message.request id "info" _ =>
let s ← get
let c ← read
c.hOut.writeLspResponse ⟨id, (← getGame s.game)⟩
return true
| Message.request id "loadLevel" params =>
let p ← parseParams LoadLevelParams (toJson params)
let s ← get
let c ← read
let some lvl ← getLevel? {game := s.game, world := p.world, level := p.level}
| do
c.hOut.writeLspResponseError ⟨id, .invalidParams, s!"Level not found: world {p.world}, level {p.level}", none⟩
return true
let env ← getEnv
let levelInfo : LevelInfo :=
{ index := lvl.index,
title := lvl.title,
tactics := lvl.tactics.computed,
lemmas := lvl.lemmas.computed,
definitions := lvl.definitions.computed,
descrText := lvl.descrText,
descrFormat := lvl.descrFormat --toExpr <| format (lvl.goal.raw) --toString <| Syntax.formatStx (lvl.goal.raw) --Syntax.formatStx (lvl.goal.raw) , -- TODO
introduction := lvl.introduction
conclusion := lvl.conclusion
lemmaTab := lvl.lemmaTab
statementName := match lvl.statementName with
| .anonymous => none
| name => match (inventoryDocExt.getState env).find?
(fun x => x.name == name && x.type == .Lemma) with
| some n => n.displayName
| none => name.toString
-- Note: we could call `.find!` because we check in `Statement` that the
-- lemma doc must exist.
}
c.hOut.writeLspResponse ⟨id, ToJson.toJson levelInfo⟩
return true
| Message.request id "loadDoc" params =>
let p ← parseParams LoadDocParams (toJson params)
-- let s ← get
let c ← read
let some doc ← getInventoryDoc? p.name p.type
| do
c.hOut.writeLspResponseError ⟨id, .invalidParams, s!"Documentation not found: {p.name}", none⟩
return true
let doc : Doc :=
{ name := doc.name.toString
displayName := doc.displayName
text := doc.content }
c.hOut.writeLspResponse ⟨id, ToJson.toJson doc⟩
return true
| _ => return false
| _ => return false
end Game
+69
View File
@@ -0,0 +1,69 @@
import Lean
open Lean Meta
/-! ## Graph -/
variable [inst : BEq α] [inst : Hashable α]
structure Graph (α β : Type) [inst : BEq α] [inst : Hashable α] where
nodes: HashMap α β := {}
edges: Array (α × α) := {}
deriving Inhabited
instance [ToJson β] : ToJson (Graph Name β) := {
toJson := fun graph => Json.mkObj [
("nodes", Json.mkObj (graph.nodes.toList.map fun (a,b) => (a.toString, toJson b))),
("edges", toJson graph.edges)
]
}
instance : EmptyCollection (Graph α β) := ⟨default⟩
def Graph.insertNode (g : Graph α β) (a : α) (b : β) :=
{g with nodes := g.nodes.insert a b}
/-- Check if graph contains loops -/
partial def Graph.hasLoops (g : Graph α β) (visited0 : HashSet α := {}): Bool := Id.run do
let mut visited : HashSet α := visited0
let all : Array α := g.nodes.toArray.map (·.1)
-- find some node that we haven't visited
let some x := all.find? fun x => ¬ visited.contains x
| return false -- We have visted all nodes and found no loops
visited := visited.insert x
match visitSuccessors x x visited with -- visit all recursive successors of x
| some visited' => visited := visited'
| none => return true -- none means a loop has been found
g.hasLoops visited -- continue looking for unvisited nodes
where
visitSuccessors (x : α) (x0 : α) (visited0 : HashSet α) : Option (HashSet α) := Id.run do
let mut visited : HashSet α := visited0
let directSuccessors := (g.edges.filter (·.1 == x)).map (·.2)
for y in directSuccessors do
if y == x0 then
return none -- loop found
if visited.contains y then
continue -- no loop possible here because the visited nodes do not lead to x0
visited := visited.insert y
match visitSuccessors y x0 visited with
| some visited' => visited := visited'
| none => return none
return some visited
-- #eval Graph.hasLoops ⟨HashMap.ofList [(2,2), (1,1)], #[(2,1)]⟩
partial def Graph.predecessors (g : Graph α β) (x : α) (acc : HashSet α := {}) : HashSet α := Id.run do
let mut res := acc
let directPredecessors := (g.edges.filter (·.2 == x)).map (·.1)
for y in directPredecessors do
if ¬ res.contains y then
res := res.insert y
res := g.predecessors y res
return res
+200
View File
@@ -0,0 +1,200 @@
/- This file is mostly copied from `Lean/Widget/InteractiveGoal.lean`. -/
import Lean.Widget.InteractiveGoal
/-! Functionality related to tactic-mode and term-mode goals with embedded `CodeWithInfos`. -/
namespace GameServer
open Lean Lean.Widget Lean.Server
structure GameHint where
text : String
hidden : Bool
deriving FromJson, ToJson
/-- In the infoview, if multiple hypotheses `h₁`, `h₂` have the same type `α`, they are rendered
as `h₁ h₂ : α`. We call this a 'hypothesis bundle'. We use `none` instead of `some false` for
booleans to save space in the json encoding. -/
structure InteractiveHypothesisBundle where
/-- The user-friendly name for each hypothesis. -/
names : Array Name
/-- The ids for each variable. Should have the same length as `names`. -/
fvarIds : Array FVarId
type : CodeWithInfos
/-- The value, in the case the hypothesis is a `let`-binder. -/
val? : Option CodeWithInfos := none
/-- The hypothesis is a typeclass instance. -/
isInstance? : Option Bool := none
/-- The hypothesis is a type. -/
isType? : Option Bool := none
/-- The hypothesis's type is of type `Prop` -/
isAssumption? : Option Bool := none
/-- If true, the hypothesis was not present on the previous tactic state.
Only present in tactic-mode goals. -/
isInserted? : Option Bool := none
/-- If true, the hypothesis will be removed in the next tactic state.
Only present in tactic-mode goals. -/
isRemoved? : Option Bool := none
deriving Inhabited, RpcEncodable
/-- The shared parts of interactive term-mode and tactic-mode goals. -/
structure InteractiveGoalCore where
hyps : Array InteractiveHypothesisBundle
/-- The target type. -/
type : CodeWithInfos
/-- Metavariable context that the goal is well-typed in. -/
ctx : WithRpcRef Elab.ContextInfo
/-- An interactive tactic-mode goal. -/
structure InteractiveGoal extends InteractiveGoalCore where
/-- The name `foo` in `case foo`, if any. -/
userName? : Option String
/-- The symbol to display before the target type. Usually `⊢ ` but `conv` goals use `∣ `
and it could be extended. -/
goalPrefix : String
/-- Identifies the goal (ie with the unique name of the MVar that it is a goal for.) -/
mvarId : MVarId
/-- If true, the goal was not present on the previous tactic state. -/
isInserted? : Option Bool := none
/-- If true, the goal will be removed on the next tactic state. -/
isRemoved? : Option Bool := none
hints : Array GameHint := #[]
deriving RpcEncodable
/-- An interactive term-mode goal. -/
structure InteractiveTermGoal extends InteractiveGoalCore where
/-- Syntactic range of the term. -/
range : Lsp.Range
/-- Information about the term whose type is the term-mode goal. -/
term : WithRpcRef Elab.TermInfo
deriving RpcEncodable
def InteractiveGoalCore.pretty (g : InteractiveGoalCore) (userName? : Option String)
(goalPrefix : String) : Format := Id.run do
let indent := 2 -- Use option
let mut ret := match userName? with
| some userName => f!"case {userName}"
| none => Format.nil
for hyp in g.hyps do
ret := addLine ret
let names := hyp.names
|>.toList
|>.filter (not ∘ Name.isAnonymous)
|>.map toString
|> " ".intercalate
match names with
| "" =>
ret := ret ++ Format.group f!":{Format.nest indent (Format.line ++ hyp.type.stripTags)}"
| _ =>
match hyp.val? with
| some val =>
ret := ret ++ Format.group f!"{names} : {hyp.type.stripTags} :={Format.nest indent (Format.line ++ val.stripTags)}"
| none =>
ret := ret ++ Format.group f!"{names} :{Format.nest indent (Format.line ++ hyp.type.stripTags)}"
ret := addLine ret
ret ++ f!"{goalPrefix}{Format.nest indent g.type.stripTags}"
where
addLine (fmt : Format) : Format :=
if fmt.isNil then fmt else fmt ++ Format.line
def InteractiveGoal.pretty (g : InteractiveGoal) : Format :=
g.toInteractiveGoalCore.pretty g.userName? g.goalPrefix
def InteractiveTermGoal.pretty (g : InteractiveTermGoal) : Format :=
g.toInteractiveGoalCore.pretty none "⊢ "
structure InteractiveGoals where
goals : Array InteractiveGoal
deriving RpcEncodable
def InteractiveGoals.append (l r : InteractiveGoals) : InteractiveGoals where
goals := l.goals ++ r.goals
instance : Append InteractiveGoals := ⟨InteractiveGoals.append⟩
instance : EmptyCollection InteractiveGoals := ⟨{goals := #[]}⟩
open Meta in
/-- Extend an array of hypothesis bundles with another bundle. -/
def addInteractiveHypothesisBundle (hyps : Array InteractiveHypothesisBundle)
(ids : Array (Name × FVarId)) (type : Expr) (value? : Option Expr := none) :
MetaM (Array InteractiveHypothesisBundle) := do
if ids.size == 0 then
throwError "Can only add a nonzero number of ids as an InteractiveHypothesisBundle."
let fvarIds := ids.map Prod.snd
let names := ids.map Prod.fst
return hyps.push {
names
fvarIds
type := (← ppExprTagged type)
val? := (← value?.mapM ppExprTagged)
isInstance? := if (← isClass? type).isSome then true else none
isType? := if (← instantiateMVars type).isSort then true else none
isAssumption? := if (← inferType type).isProp then true else none
}
open Meta in
variable [MonadControlT MetaM n] [Monad n] [MonadError n] [MonadOptions n] [MonadMCtx n] in
def withGoalCtx (goal : MVarId) (action : LocalContext → MetavarDecl → n α) : n α := do
let mctx ← getMCtx
let some mvarDecl := mctx.findDecl? goal
| throwError "unknown goal {goal.name}"
let lctx := mvarDecl.lctx |>.sanitizeNames.run' {options := (← getOptions)}
withLCtx lctx mvarDecl.localInstances (action lctx mvarDecl)
open Meta in
/-- A variant of `Meta.ppGoal` which preserves subexpression information for interactivity. -/
def goalToInteractive (mvarId : MVarId) (hints : Array GameHint): MetaM InteractiveGoal := do
let ppAuxDecls := pp.auxDecls.get (← getOptions)
let ppImplDetailHyps := pp.implementationDetailHyps.get (← getOptions)
let showLetValues := pp.showLetValues.get (← getOptions)
withGoalCtx mvarId fun lctx mvarDecl => do
let pushPending (ids : Array (Name × FVarId)) (type? : Option Expr) (hyps : Array InteractiveHypothesisBundle)
: MetaM (Array InteractiveHypothesisBundle) :=
if ids.isEmpty then
pure hyps
else
match type? with
| none => pure hyps
| some type => addInteractiveHypothesisBundle hyps ids type
let mut varNames : Array (Name × FVarId) := #[]
let mut prevType? : Option Expr := none
let mut hyps : Array InteractiveHypothesisBundle := #[]
for localDecl in lctx do
if !ppAuxDecls && localDecl.isAuxDecl || !ppImplDetailHyps && localDecl.isImplementationDetail then
continue
else
match localDecl with
| LocalDecl.cdecl _index fvarId varName type _ _ =>
let varName := varName.simpMacroScopes
let type ← instantiateMVars type
if prevType? == none || prevType? == some type then
varNames := varNames.push (varName, fvarId)
else
hyps ← pushPending varNames prevType? hyps
varNames := #[(varName, fvarId)]
prevType? := some type
| LocalDecl.ldecl _index fvarId varName type val _ _ => do
let varName := varName.simpMacroScopes
hyps ← pushPending varNames prevType? hyps
let type ← instantiateMVars type
let val? ← if showLetValues then pure (some (← instantiateMVars val)) else pure none
hyps ← addInteractiveHypothesisBundle hyps #[(varName, fvarId)] type val?
varNames := #[]
prevType? := none
hyps ← pushPending varNames prevType? hyps
let goalTp ← instantiateMVars mvarDecl.type
let goalFmt ← ppExprTagged goalTp
let userName? := match mvarDecl.userName with
| Name.anonymous => none
| name => some <| toString name.eraseMacroScopes
return {
hyps
type := goalFmt
ctx := ⟨← Elab.ContextInfo.save⟩
userName?
goalPrefix := getGoalPrefix mvarDecl
mvarId
hints
}
end GameServer
+188
View File
@@ -0,0 +1,188 @@
import GameServer.EnvExtensions
import GameServer.InteractiveGoal
open Lean
open Server
open Widget
open RequestM
open Meta
/-! ## GameGoal -/
namespace GameServer
def splitRootUri (initParams : Lsp.InitializeParams) (i : Nat): Option String := Id.run do
let some rootUri := initParams.rootUri?
| return none
let rootUriParts := rootUri.splitOn "/"
if rootUriParts.length == 3 then
return rootUriParts[i]?
return none
def levelIdFromFileName? (initParams : Lsp.InitializeParams) (fileName : String) : Option LevelId := Id.run do
let fileParts := fileName.splitOn "/"
if fileParts.length == 3 then
if let (some level, some game) := (fileParts[2]!.toNat?, splitRootUri initParams 2) then
return some {game, world := fileParts[1]!, level := level}
return none
def gameDirFromInitParams (initParams : Lsp.InitializeParams) : Option String :=
(splitRootUri initParams 0).map (s!"../../{·}")
def getLevelByFileName? [Monad m] [MonadEnv m] (initParams : Lsp.InitializeParams) (fileName : String) : m (Option GameLevel) := do
let some levelId := levelIdFromFileName? initParams fileName
| return none
return ← getLevel? levelId
structure FVarBijection :=
(forward : HashMap FVarId FVarId)
(backward : HashMap FVarId FVarId)
instance : EmptyCollection FVarBijection := ⟨{},{}⟩
def FVarBijection.insert (bij : FVarBijection) (a b : FVarId) : FVarBijection :=
⟨bij.forward.insert a b, bij.backward.insert b a⟩
def FVarBijection.insert? (bij : FVarBijection) (a b : FVarId) : Option FVarBijection :=
let a' := bij.forward.find? a
let b' := bij.forward.find? b
if (a' == none || a' == some b) && (b' == none || b' == some a)
then some $ bij.insert a b
else none
/-- Checks if `pattern` and `e` are equal up to FVar identities. -/
partial def matchExpr (pattern : Expr) (e : Expr) (bij : FVarBijection := {}) : Option FVarBijection :=
match pattern, e with
| .bvar i1, .bvar i2 => if i1 == i2 then bij else none
| .fvar i1, .fvar i2 => bij.insert? i1 i2
| .mvar _, .mvar _ => bij
| .sort u1, .sort u2 => bij -- TODO?
| .const n1 ls1, .const n2 ls2 =>
if n1 == n2 then bij else none -- && (← (ls1.zip ls2).allM fun (l1, l2) => Meta.isLevelDefEq l1 l2)
| .app f1 a1, .app f2 a2 =>
some bij
|> (Option.bind · (fun bij => matchExpr f1 f2 bij))
|> (Option.bind · (fun bij => matchExpr a1 a2 bij))
| .lam _ t1 b1 _, .lam _ t2 b2 _ =>
some bij
|> (Option.bind · (fun bij => matchExpr t1 t2 bij))
|> (Option.bind · (fun bij => matchExpr b1 b2 bij))
| .forallE _ t1 b1 _, .forallE _ t2 b2 _ =>
some bij
|> (Option.bind · (fun bij => matchExpr t1 t2 bij))
|> (Option.bind · (fun bij => matchExpr b1 b2 bij))
| .letE _ t1 v1 b1 _, .letE _ t2 v2 b2 _ =>
some bij
|> (Option.bind · (fun bij => matchExpr t1 t2 bij))
|> (Option.bind · (fun bij => matchExpr v1 v2 bij))
|> (Option.bind · (fun bij => matchExpr b1 b2 bij))
| .lit l1, .lit l2 =>
if l1 == l2 then bij else none
| .proj i1 n1 e1, .proj i2 n2 e2 =>
if i1 == i2 && n1 == n2 then matchExpr e1 e2 bij else none
-- ignore mdata:
| .mdata _ pattern', _ =>
matchExpr pattern' e bij
| _, .mdata _ e' =>
matchExpr pattern e' bij
| _, _ => none
/-- Check if each fvar in `patterns` has a matching fvar in `fvars` -/
def matchDecls (patterns : Array Expr) (fvars : Array Expr) (strict := true) (initBij : FVarBijection := {}) : MetaM Bool := do
-- We iterate through the array backwards hoping that this will find us faster results
-- TODO: implement backtracking
let mut bij := initBij
for i in [:patterns.size] do
let pattern := patterns[patterns.size - i - 1]!
if bij.forward.contains pattern.fvarId! then
continue
for j in [:fvars.size] do
let fvar := fvars[fvars.size - j - 1]!
if bij.backward.contains fvar.fvarId! then
continue
if let some bij' := matchExpr
(← instantiateMVars $ ← inferType pattern)
(← instantiateMVars $ ← inferType fvar) bij then
-- usedFvars := usedFvars.set! (fvars.size - j - 1) true
bij := bij'.insert pattern.fvarId! fvar.fvarId!
break
if ! bij.forward.contains pattern.fvarId! then return false
if strict then
return fvars.all (fun fvar => bij.backward.contains fvar.fvarId!)
return true
unsafe def evalHintMessageUnsafe : Expr → MetaM (Array Expr → MessageData) :=
evalExpr (Array Expr → MessageData)
(.forallE default (mkApp (mkConst ``Array [levelZero]) (mkConst ``Expr))
(mkConst ``MessageData) .default)
@[implemented_by evalHintMessageUnsafe]
def evalHintMessage : Expr → MetaM (Array Expr → MessageData) := fun _ => pure (fun _ => "")
open Meta in
/-- Find all hints whose trigger matches the current goal -/
def findHints (goal : MVarId) (doc : FileWorker.EditableDocument) (initParams : Lsp.InitializeParams) : MetaM (Array GameHint) := do
goal.withContext do
let some level ← getLevelByFileName? initParams doc.meta.mkInputContext.fileName
| throwError "Level not found: {doc.meta.mkInputContext.fileName}"
let hints ← level.hints.filterMapM fun hint => do
openAbstractCtxResult hint.goal fun hintFVars hintGoal => do
if let some fvarBij := matchExpr (← instantiateMVars $ hintGoal) (← instantiateMVars $ ← inferType $ mkMVar goal)
then
let lctx := (← goal.getDecl).lctx
if ← matchDecls hintFVars lctx.getFVars (strict := hint.strict) (initBij := fvarBij)
then
let text := (← evalHintMessage hint.text) hintFVars
let ctx := {env := ← getEnv, mctx := ← getMCtx, lctx := ← getLCtx, opts := {}}
let text ← (MessageData.withContext ctx text).toString
return some { text := text, hidden := hint.hidden }
else return none
else
return none
return hints
open RequestM in
def getInteractiveGoals (p : Lsp.PlainGoalParams) : RequestM (RequestTask (Option InteractiveGoals)) := do
let doc ← readDoc
let rc ← readThe RequestContext
let text := doc.meta.text
let hoverPos := text.lspPosToUtf8Pos p.position
-- TODO: I couldn't find a good condition to find the correct snap. So we are looking
-- for the first snap with goals here:
withWaitFindSnap doc (fun s => ¬ (s.infoTree.goalsAt? doc.meta.text hoverPos).isEmpty)
(notFoundX := return none) fun snap => do
if let rs@(_ :: _) := snap.infoTree.goalsAt? doc.meta.text hoverPos then
let goals : List InteractiveGoals ← rs.mapM fun { ctxInfo := ci, tacticInfo := ti, useAfter := useAfter, .. } => do
let ciAfter := { ci with mctx := ti.mctxAfter }
let ci := if useAfter then ciAfter else { ci with mctx := ti.mctxBefore }
-- compute the interactive goals
let goals ← ci.runMetaM {} do
return List.toArray <| if useAfter then ti.goalsAfter else ti.goalsBefore
let goals ← ci.runMetaM {} do
goals.mapM fun goal => do
let hints ← findHints goal doc rc.initParams
return ← goalToInteractive goal hints
-- 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 {goals}
return some <| goals.foldl (· ++ ·) ⟨#[]⟩
else
return none
builtin_initialize
registerBuiltinRpcProcedure
`Game.getInteractiveGoals
Lsp.PlainGoalParams
(Option InteractiveGoals)
getInteractiveGoals
end GameServer
+153
View File
@@ -0,0 +1,153 @@
/- This file is mostly copied from `Lean/Server/Watchdog.lean`. -/
import Lean.Server.Watchdog
import GameServer.Game
namespace MyServer.Watchdog
open Lean
open Server
open Watchdog
open IO
open Lsp
open JsonRpc
open System.Uri
partial def mainLoop (clientTask : Task ServerEvent) : GameServerM Unit := do
let st ← read
let workers ← st.fileWorkersRef.get
let mut workerTasks := #[]
for (_, fw) in workers do
if let WorkerState.running := fw.state then
workerTasks := workerTasks.push <| fw.commTask.map (ServerEvent.workerEvent fw)
let ev ← IO.waitAny (clientTask :: workerTasks.toList)
if ← Game.handleServerEvent ev then -- handle Game requests
mainLoop (←runClientTask)
else
match ev with
| ServerEvent.clientMsg msg =>
match msg with
| Message.request id "shutdown" _ =>
shutdown
st.hOut.writeLspResponse ⟨id, Json.null⟩
| Message.request id method (some params) =>
handleRequest id method (toJson params)
mainLoop (←runClientTask)
| Message.response .. =>
-- TODO: handle client responses
mainLoop (←runClientTask)
| Message.responseError _ _ e .. =>
throwServerError s!"Unhandled response error: {e}"
| Message.notification method (some params) =>
if method == "textDocument/didOpen" then
-- for lean4game, we need to pass in extra information when a level is opened:
Game.handleDidOpenLevel (← parseParams _ (toJson params))
else
handleNotification method (toJson params)
mainLoop (←runClientTask)
| _ => throwServerError "Got invalid JSON-RPC message"
| ServerEvent.clientError e => throw e
| ServerEvent.workerEvent fw ev =>
match ev with
| WorkerEvent.ioError e =>
throwServerError s!"IO error while processing events for {fw.doc.uri}: {e}"
| WorkerEvent.crashed _ =>
handleCrash fw.doc.uri #[]
mainLoop clientTask
| WorkerEvent.terminated =>
throwServerError "Internal server error: got termination event for worker that should have been removed"
| .importsChanged =>
startFileWorker fw.doc
mainLoop clientTask
def initAndRunWatchdogAux : GameServerM Unit := do
let st ← read
try
discard $ st.hIn.readLspNotificationAs "initialized" InitializedParams
let clientTask ← runClientTask
mainLoop clientTask
catch err =>
shutdown
throw err
/- NOTE(WN): It looks like instead of sending the `exit` notification,
VSCode just closes the stream. In that case, pretend we got an `exit`. -/
let Message.notification "exit" none ←
try st.hIn.readLspMessage
catch _ => pure (Message.notification "exit" none)
| throwServerError "Got `shutdown` request, expected an `exit` notification"
def createEnv (gameDir : String) (module : String) : IO Environment := 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 }] {} 0
return env
def initAndRunWatchdog (args : List String) (i o e : FS.Stream) : IO Unit := do
if args.length < 4 then
throwServerError s!"Expected 3 command line arguments in addition to `--server`:
game directory, the name of the main module, and the name of the game"
let gameId := args[1]!
let gameDir := s!"../../{gameId}"
let module := args[2]!
let gameName := args[3]!
let workerPath := "./gameserver"
-- TODO: Do the following commands slow us down?
let srcSearchPath ← initSrcSearchPath (← getBuildDir)
let references ← IO.mkRef (← loadReferences)
let fileWorkersRef ← IO.mkRef (RBMap.empty : FileWorkerMap)
let i ← maybeTee "wdIn.txt" false i
let o ← maybeTee "wdOut.txt" true o
let e ← maybeTee "wdErr.txt" true e
let state := {env := ← createEnv gameDir module, game := gameName}
let initRequest ← i.readLspRequestAs "initialize" InitializeParams
-- We misuse the `rootUri` field to store gameId, module, and gameName
let rootUri? := s!"{gameId}/{module}/{gameName}"
let initRequest := {initRequest with param := {initRequest.param with rootUri?}}
o.writeLspResponse {
id := initRequest.id
result := {
capabilities := mkLeanServerCapabilities
serverInfo? := some {
name := "Lean 4 Game Server"
version? := "0.1.1"
}
: InitializeResult
}
}
let context : ServerContext := {
hIn := i
hOut := o
hLog := e
args := args
fileWorkersRef := fileWorkersRef
initParams := initRequest.param
workerPath
srcSearchPath
references
}
discard $ ReaderT.run (StateT.run initAndRunWatchdogAux state) context
def watchdogMain (args : List String) : IO UInt32 := do
let i ← IO.getStdin
let o ← IO.getStdout
let e ← IO.getStderr
try
initAndRunWatchdog args i o e
return 0
catch err =>
e.putStrLn s!"Watchdog error: {err}"
return 1
end MyServer.Watchdog