First big junk of communication refactor

v4.6.0-bump
Jon Eugster 1 year ago
parent 72ffab5b46
commit 6472ef5b31

@ -1,7 +1,9 @@
import { GameHint } from "./infoview/rpc_api"; import { GameHint, InteractiveGoalsWithHints, ProofState } from "./infoview/rpc_api";
import * as React from 'react'; import * as React from 'react';
import Markdown from './markdown'; import Markdown from './markdown';
import { ProofStep } from "./infoview/context"; import { DeletedChatContext, ProofContext } from "./infoview/context";
import { lastStepHasErrors } from "./infoview/goals";
import { Button } from "./button";
export function Hint({hint, step, selected, toggleSelection, lastLevel} : {hint: GameHint, step: number, selected: number, toggleSelection: any, lastLevel?: boolean}) { export function Hint({hint, step, selected, toggleSelection, lastLevel} : {hint: GameHint, step: number, selected: number, toggleSelection: any, lastLevel?: boolean}) {
return <div className={`message information step-${step}` + (step == selected ? ' selected' : '') + (lastLevel ? ' recent' : '')} onClick={toggleSelection}> return <div className={`message information step-${step}` + (step == selected ? ' selected' : '') + (lastLevel ? ' recent' : '')} onClick={toggleSelection}>
@ -16,7 +18,7 @@ export function HiddenHint({hint, step, selected, toggleSelection, lastLevel} :
} }
export function Hints({hints, showHidden, step, selected, toggleSelection, lastLevel} : {hints: GameHint[], showHidden: boolean, step: number, selected: number, toggleSelection: any, lastLevel?: boolean}) { export function Hints({hints, showHidden, step, selected, toggleSelection, lastLevel} : {hints: GameHint[], showHidden: boolean, step: number, selected: number, toggleSelection: any, lastLevel?: boolean}) {
if (!hints) {return <></>}
const openHints = hints.filter(hint => !hint.hidden) const openHints = hints.filter(hint => !hint.hidden)
const hiddenHints = hints.filter(hint => hint.hidden) const hiddenHints = hints.filter(hint => hint.hidden)
@ -46,22 +48,52 @@ export function DeletedHints({hints} : {hints: GameHint[]}) {
} }
/** Filter hints to not show consequtive identical hints twice. /** Filter hints to not show consequtive identical hints twice.
* * Hidden hints are not filtered.
* This function takes a `ProofStep[]` and extracts the hints in form of an
* element of type `GameHint[][]` where it removes hints that are identical to hints
* appearing in the previous step. Hidden hints are not filtered.
*
* This effectively means we prevent consequtive identical hints from being shown.
*/ */
export function filterHints(proof: ProofStep[]): GameHint[][] { export function filterHints(hints: GameHint[], prevHints: GameHint[]): GameHint[] {
return proof.map((step, i) => { if (!hints) {
if (i == 0){ return []}
return step.hints else if (!prevHints) {
return hints }
else {
return hints.filter((hint) => hint.hidden ||
(prevHints.find(x => (x.text == hint.text && x.hidden == hint.hidden)) === undefined)
)
}
}
function hasHiddenHints(step: InteractiveGoalsWithHints): boolean {
return step?.goals[0]?.hints.some((hint) => hint.hidden)
}
export function MoreHelpButton() {
const {proof, setProof} = React.useContext(ProofContext)
const {deletedChat, setDeletedChat, showHelp, setShowHelp} = React.useContext(DeletedChatContext)
let k = proof.steps.length - (lastStepHasErrors(proof) ? 2 : 1)
const activateHiddenHints = (ev) => {
// If the last step (`k`) has errors, we want the hidden hints from the
// second-to-last step to be affected
if (!(proof.steps.length)) {return}
// state must not be mutated, therefore we need to clone the set
let tmp = new Set(showHelp)
if (tmp.has(k)) {
tmp.delete(k)
} else { } else {
// TODO: Writing all fields explicitely is somewhat fragile to changes, is there a tmp.add(k)
// good way to shallow-compare objects?
return step.hints.filter((hint) => hint.hidden ||
(proof[i-1].hints.find((x) => (x.text == hint.text && x.hidden == hint.hidden)) === undefined))
} }
}) setShowHelp(tmp)
console.debug(`help: ${Array.from(tmp.values())}`)
}
if (hasHiddenHints(proof.steps[k]) && !showHelp.has(k)) {
return <Button to="" onClick={activateHiddenHints}>
Show more help!
</Button>
}
} }

@ -3,8 +3,8 @@
*/ */
import * as React from 'react'; import * as React from 'react';
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js' import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'
import { InteractiveDiagnostic, InteractiveTermGoal } from '@leanprover/infoview-api'; import { InteractiveDiagnostic } from '@leanprover/infoview-api';
import { GameHint, InteractiveGoal, InteractiveGoals } from './rpc_api'; import { GameHint, InteractiveGoal, InteractiveTermGoal,InteractiveGoalsWithHints, ProofState } from './rpc_api';
import { PreferencesState } from '../../state/preferences'; import { PreferencesState } from '../../state/preferences';
export const MonacoEditorContext = React.createContext<monaco.editor.IStandaloneCodeEditor>( export const MonacoEditorContext = React.createContext<monaco.editor.IStandaloneCodeEditor>(
@ -12,37 +12,39 @@ export const MonacoEditorContext = React.createContext<monaco.editor.IStandalone
export type InfoStatus = 'updating' | 'error' | 'ready'; export type InfoStatus = 'updating' | 'error' | 'ready';
/** One step of the proof */ // /** One step of the proof */
export type ProofStep = { // export type ProofStep = {
/** The command in this step */ // /** The command in this step */
command : string // command : string
/** List of goals *after* this command */ // /** List of goals *after* this command */
goals: InteractiveGoal[] // TODO: Add correct type // goals: InteractiveGoal[] // TODO: Add correct type
/** Story relevant messages */ // /** Story relevant messages */
hints: GameHint[] // TODO: Add correct type // hints: GameHint[] // TODO: Add correct type
/** Errors and warnings */ // /** Errors and warnings */
errors: InteractiveDiagnostic[] // TODO: Add correct type // errors: InteractiveDiagnostic[] // TODO: Add correct type
} // }
/** The context storing the proof step-by-step for the command line mode */ /** The context storing the proof step-by-step for the command line mode */
export const ProofContext = React.createContext<{ export const ProofContext = React.createContext<{
/** The proof consists of multiple steps that are processed one after the other. /** The proof consists of multiple steps that are processed one after the other.
* In particular multi-line terms like `match`-statements will not be supported. * In particular multi-line terms like `match`-statements will not be supported.
* *
* Note that the first step will always have `null` as command * Note that the first step will always have "" as command
*/ */
proof: ProofStep[], proof: ProofState,
setProof: React.Dispatch<React.SetStateAction<Array<ProofStep>>> setProof: React.Dispatch<React.SetStateAction<ProofState>>
}>({ }>({
proof: [], proof: {steps: [], diagnostics: [], completed: false},
setProof: () => {} // TODO: implement me setProof: () => {}
}) })
// TODO: Do we still need that?
export interface ProofStateProps { export interface ProofStateProps {
// pos: DocumentPosition; // pos: DocumentPosition;
status: InfoStatus; status: InfoStatus;
messages: InteractiveDiagnostic[]; messages: InteractiveDiagnostic[];
goals?: InteractiveGoals; goals?: InteractiveGoalsWithHints;
termGoal?: InteractiveTermGoal; termGoal?: InteractiveTermGoal;
error?: string; error?: string;
// userWidgets: UserWidgetInstance[]; // userWidgets: UserWidgetInstance[];
@ -50,18 +52,18 @@ export interface ProofStateProps {
// triggerUpdate: () => Promise<void>; // triggerUpdate: () => Promise<void>;
} }
export const ProofStateContext = React.createContext<{ // export const ProofStateContext = React.createContext<{
proofState : ProofStateProps, // proofState : ProofStateProps,
setProofState: React.Dispatch<React.SetStateAction<ProofStateProps>> // setProofState: React.Dispatch<React.SetStateAction<ProofStateProps>>
}>({ // }>({
proofState : { // proofState : {
status: 'updating', // status: 'updating',
messages: [], // messages: [],
goals: undefined, // goals: undefined,
termGoal: undefined, // termGoal: undefined,
error: undefined}, // error: undefined},
setProofState: () => {}, // setProofState: () => {},
}) // })
export interface IPreferencesContext extends PreferencesState{ export interface IPreferencesContext extends PreferencesState{
mobile: boolean, // The variables that actually control the page 'layout' can only be changed through layout. mobile: boolean, // The variables that actually control the page 'layout' can only be changed through layout.

@ -10,7 +10,10 @@ import { Locations, LocationsContext, SelectableLocation } from '../../../../nod
import { InteractiveCode } from '../../../../node_modules/lean4-infoview/src/infoview/interactiveCode' import { InteractiveCode } from '../../../../node_modules/lean4-infoview/src/infoview/interactiveCode'
import { WithTooltipOnHover } from '../../../../node_modules/lean4-infoview/src/infoview/tooltips'; import { WithTooltipOnHover } from '../../../../node_modules/lean4-infoview/src/infoview/tooltips';
import { InputModeContext } from './context'; import { InputModeContext } from './context';
import { InteractiveGoal, InteractiveGoals, InteractiveHypothesisBundle } from './rpc_api'; import { InteractiveGoal, InteractiveGoals, InteractiveGoalsWithHints, InteractiveHypothesisBundle, ProofState } from './rpc_api';
import { RpcSessionAtPos } from '@leanprover/infoview/*';
import { DocumentPosition } from '../../../../node_modules/lean4-infoview/src/infoview/util';
import { DiagnosticSeverity } from 'vscode-languageserver-protocol';
/** Returns true if `h` is inaccessible according to Lean's default name rendering. */ /** Returns true if `h` is inaccessible according to Lean's default name rendering. */
function isInaccessibleName(h: string): boolean { function isInaccessibleName(h: string): boolean {
@ -39,7 +42,11 @@ function goalToString(g: InteractiveGoal): string {
} }
export function goalsToString(goals: InteractiveGoals): string { export function goalsToString(goals: InteractiveGoals): string {
return goals.goals.map(goalToString).join('\n\n') return goals.goals.map(g => goalToString(g)).join('\n\n')
}
export function goalsWithHintsToString(goals: InteractiveGoalsWithHints): string {
return goals.goals.map(g => goalToString(g.goal)).join('\n\n')
} }
interface GoalFilterState { interface GoalFilterState {
@ -255,7 +262,7 @@ export const ProofDisplay = React.memo((props : ProofDisplayProps) => {
}) })
interface GoalsProps { interface GoalsProps {
goals: InteractiveGoals goals: InteractiveGoalsWithHints
filter: GoalFilterState filter: GoalFilterState
} }
@ -264,7 +271,7 @@ export function Goals({ goals, filter }: GoalsProps) {
return <>No goals</> return <>No goals</>
} else { } else {
return <> return <>
{goals.goals.map((g, i) => <Goal typewriter={false} key={i} goal={g} filter={filter} />)} {goals.goals.map((g, i) => <Goal typewriter={false} key={i} goal={g.goal} filter={filter} />)}
</> </>
} }
} }
@ -276,7 +283,7 @@ interface FilteredGoalsProps {
* When this is `undefined`, the component will not appear at all but will remember its state * When this is `undefined`, the component will not appear at all but will remember its state
* by virtue of still being mounted in the React tree. When it does appear again, the filter * by virtue of still being mounted in the React tree. When it does appear again, the filter
* settings and collapsed state will be as before. */ * settings and collapsed state will be as before. */
goals?: InteractiveGoals goals?: InteractiveGoalsWithHints
} }
/** /**
@ -291,7 +298,7 @@ export const FilteredGoals = React.memo(({ headerChildren, goals }: FilteredGoal
data-id="copy-goal-to-comment" data-id="copy-goal-to-comment"
onClick={e => { onClick={e => {
e.preventDefault(); e.preventDefault();
if (goals) void ec.copyToComment(goalsToString(goals)) if (goals) void ec.copyToComment(goalsWithHintsToString(goals))
}} }}
title="copy state to comment" /> title="copy state to comment" />
@ -336,3 +343,112 @@ export const FilteredGoals = React.memo(({ headerChildren, goals }: FilteredGoal
</details> </details>
</div> </div>
}) })
export function loadGoals(
rpcSess: RpcSessionAtPos,
uri: string,
setProof: React.Dispatch<React.SetStateAction<ProofState>>) {
console.info('sending rpc request to load the proof state')
rpcSess.call('Game.getProofState', DocumentPosition.toTdpp({line: 0, character: 0, uri: uri})).then(
(proof : ProofState) => {
console.info(`received a proof state!`)
console.log(proof)
setProof(proof)
// let tmpProof : ProofStep[] = []
// let goalCount = 0
// steps.map((goals, i) => {
// // The first step has an empty command and therefore also no error messages
// // Usually there is a newline at the end of the editors content, so we need to
// // display diagnostics from potentally two lines in the last step.
// let messages = i ? (i == steps.length - 1 ? diagnostics.slice(i-1).flat() : diagnostics[i-1]) : []
// // Filter out the 'unsolved goals' message
// messages = messages.filter((msg) => {
// return !("append" in msg.message &&
// "text" in msg.message.append[0] &&
// msg.message.append[0].text === "unsolved goals")
// })
// if (typeof goals == 'undefined') {
// tmpProof.push({
// command: i ? model.getLineContent(i) : '',
// goals: [],
// hints: [],
// errors: messages
// } as ProofStep)
// console.debug('goals is undefined')
// return
// }
// // If the number of goals reduce, show a message
// if (goals.length && goalCount > goals.length) {
// messages.unshift({
// range: {
// start: {
// line: i-1,
// character: 0,
// },
// end: {
// line: i-1,
// character: 0,
// }},
// severity: DiagnosticSeverity.Information,
// message: {
// text: 'intermediate goal solved 🎉'
// }
// })
// }
// goalCount = goals.length
// // with no goals there will be no hints.
// let hints : GameHint[] = goals.length ? goals[0].hints : []
// console.debug(`Command (${i}): `, i ? model.getLineContent(i) : '')
// console.debug(`Goals: (${i}): `, goalsToString(goals)) //
// console.debug(`Hints: (${i}): `, hints)
// console.debug(`Errors: (${i}): `, messages)
// tmpProof.push({
// // the command of the line above. Note that `getLineContent` starts counting
// // at `1` instead of `zero`. The first ProofStep will have an empty command.
// command: i ? model.getLineContent(i) : '',
// // TODO: store correct data
// goals: goals.map(g => g.goal),
// // only need the hints of the active goals in chat
// hints: hints,
// // errors and messages from the server
// errors: messages
// } as ProofStep)
// })
// // Save the proof to the context
// setProof(tmpProof)
}
)
}
export function lastStepHasErrors (proof : ProofState): boolean {
if (!proof?.steps.length) {return false}
let diags = [...proof.steps[proof.steps.length - 1].diags, ...proof.diagnostics]
return diags.some(
(d) => (d.severity == DiagnosticSeverity.Error ) // || d.severity == DiagnosticSeverity.Warning
)
}
export function isLastStepWithErrors (proof : ProofState, i: number): boolean {
if (!proof?.steps.length) {return false}
return (i == proof.steps.length - 1) && lastStepHasErrors(proof)
}

@ -4,7 +4,7 @@ import * as React from 'react'
import { CircularProgress } from '@mui/material' import { CircularProgress } from '@mui/material'
import type { Location, Diagnostic } from 'vscode-languageserver-protocol' import type { Location, Diagnostic } from 'vscode-languageserver-protocol'
import { getInteractiveTermGoal, InteractiveDiagnostic, UserWidgetInstance, Widget_getWidgets, RpcSessionAtPos, isRpcError, import { getInteractiveTermGoal, InteractiveDiagnostic, UserWidgetInstance, Widget_getWidgets, RpcSessionAtPos, isRpcError,
RpcErrorCode, getInteractiveDiagnostics, InteractiveTermGoal } from '@leanprover/infoview-api' RpcErrorCode, getInteractiveDiagnostics } from '@leanprover/infoview-api'
import { basename, DocumentPosition, RangeHelpers, useEvent, usePausableState, discardMethodNotFound, import { basename, DocumentPosition, RangeHelpers, useEvent, usePausableState, discardMethodNotFound,
mapRpcError, useAsyncWithTrigger, PausableProps } from '../../../../node_modules/lean4-infoview/src/infoview/util' mapRpcError, useAsyncWithTrigger, PausableProps } from '../../../../node_modules/lean4-infoview/src/infoview/util'
import { ConfigContext, EditorContext, LspDiagnosticsContext, ProgressContext } from '../../../../node_modules/lean4-infoview/src/infoview/contexts' import { ConfigContext, EditorContext, LspDiagnosticsContext, ProgressContext } from '../../../../node_modules/lean4-infoview/src/infoview/contexts'
@ -14,7 +14,7 @@ import { GoalsLocation, Locations, LocationsContext } from '../../../../node_mod
import { AllMessages, lspDiagToInteractive } from './messages' import { AllMessages, lspDiagToInteractive } from './messages'
import { goalsToString, Goal, MainAssumptions, OtherGoals, ProofDisplay } from './goals' import { goalsToString, Goal, MainAssumptions, OtherGoals, ProofDisplay } from './goals'
import { InteractiveGoals } from './rpc_api' import { InteractiveTermGoal, InteractiveGoalsWithHints, InteractiveGoals, ProofState } from './rpc_api'
import { MonacoEditorContext, ProofStateProps, InfoStatus, ProofContext } from './context' import { MonacoEditorContext, ProofStateProps, InfoStatus, ProofContext } from './context'
// TODO: All about pinning could probably be removed // TODO: All about pinning could probably be removed
@ -83,11 +83,11 @@ interface InfoDisplayContentProps extends PausableProps {
error?: string error?: string
userWidgets: UserWidgetInstance[] userWidgets: UserWidgetInstance[]
triggerUpdate: () => Promise<void> triggerUpdate: () => Promise<void>
proof? : string proofString? : string
} }
const InfoDisplayContent = React.memo((props: InfoDisplayContentProps) => { const InfoDisplayContent = React.memo((props: InfoDisplayContentProps) => {
const {pos, messages, goals, termGoal, error, userWidgets, triggerUpdate, isPaused, setPaused, proof} = props const {pos, messages, goals, termGoal, error, userWidgets, triggerUpdate, isPaused, setPaused, proofString} = props
const hasWidget = userWidgets.length > 0 const hasWidget = userWidgets.length > 0
const hasError = !!error const hasError = !!error
@ -114,7 +114,8 @@ const InfoDisplayContent = React.memo((props: InfoDisplayContentProps) => {
const goalFilter = { reverse: false, showType: true, showInstance: true, showHiddenAssumption: true, showLetValue: true } const goalFilter = { reverse: false, showType: true, showInstance: true, showHiddenAssumption: true, showLetValue: true }
/* Adding {' '} to manage string literals properly: https://reactjs.org/docs/jsx-in-depth.html#string-literals-1 */ /* Adding {' '} to manage string literals properly: https://reactjs.org/docs/jsx-in-depth.html#string-literals-1 */
return <>
return <>
{hasError && {hasError &&
<div className='error' key='errors'> <div className='error' key='errors'>
Error updating:{' '}{error}. Error updating:{' '}{error}.
@ -129,7 +130,7 @@ const InfoDisplayContent = React.memo((props: InfoDisplayContentProps) => {
</div> </div>
<div> <div>
{ goals && (goals.goals.length > 0 { goals && (goals.goals.length > 0
? <Goal typewriter={true} filter={goalFilter} key='mainGoal' goal={goals.goals[0]} showHints={true} /> ? <Goal typewriter={true} filter={goalFilter} key='mainGoal' goal={goals[0]?.goal} showHints={true} />
: <div className="goals-section-title">No Goals</div> : <div className="goals-section-title">No Goals</div>
)} )}
</div> </div>
@ -137,7 +138,7 @@ const InfoDisplayContent = React.memo((props: InfoDisplayContentProps) => {
{userWidgets.map(widget => {userWidgets.map(widget =>
<details key={`widget::${widget.id}::${widget.range?.toString()}`} open> <details key={`widget::${widget.id}::${widget.range?.toString()}`} open>
<summary className='mv2 pointer'>{widget.name}</summary> <summary className='mv2 pointer'>{widget.name}</summary>
<PanelWidgetDisplay pos={pos} goals={goals ? goals.goals.map (goal => goal) : []} <PanelWidgetDisplay pos={pos} goals={goals ? goals.goals : []}
termGoal={termGoal} selectedLocations={selectedLocs} widget={widget}/> termGoal={termGoal} selectedLocations={selectedLocs} widget={widget}/>
</details> </details>
)} )}
@ -166,6 +167,7 @@ interface InfoDisplayProps {
pos: DocumentPosition, pos: DocumentPosition,
status: InfoStatus, status: InfoStatus,
messages: InteractiveDiagnostic[], messages: InteractiveDiagnostic[],
proof?: ProofState,
goals?: InteractiveGoals, goals?: InteractiveGoals,
termGoal?: InteractiveTermGoal, termGoal?: InteractiveTermGoal,
error?: string, error?: string,
@ -175,7 +177,7 @@ interface InfoDisplayProps {
} }
/** Displays goal state and messages. Can be paused. */ /** Displays goal state and messages. Can be paused. */
function InfoDisplay(props0: ProofStateProps & InfoDisplayProps & InfoPinnable) { function InfoDisplay(props0: InfoDisplayProps & InfoPinnable) {
// Used to update the paused state *just once* if it is paused, // Used to update the paused state *just once* if it is paused,
// but a display update is triggered // but a display update is triggered
const [shouldRefresh, setShouldRefresh] = React.useState<boolean>(false) const [shouldRefresh, setShouldRefresh] = React.useState<boolean>(false)
@ -214,7 +216,7 @@ function InfoDisplay(props0: ProofStateProps & InfoDisplayProps & InfoPinnable)
{/* <details open> */} {/* <details open> */}
{/* <InfoStatusBar {...props} triggerUpdate={triggerDisplayUpdate} isPaused={isPaused} setPaused={setPaused} /> */} {/* <InfoStatusBar {...props} triggerUpdate={triggerDisplayUpdate} isPaused={isPaused} setPaused={setPaused} /> */}
<div className="vscode-light"> <div className="vscode-light">
<InfoDisplayContent {...props} proof={editor.getValue()} triggerUpdate={triggerDisplayUpdate} isPaused={isPaused} setPaused={setPaused} /> <InfoDisplayContent {...props} proofString={editor.getValue()} triggerUpdate={triggerDisplayUpdate} isPaused={isPaused} setPaused={setPaused} />
</div> </div>
{/* </details> */} {/* </details> */}
</RpcContext.Provider> </RpcContext.Provider>
@ -290,6 +292,8 @@ function InfoAux(props: InfoProps) {
// with e.g. a new `pos`. // with e.g. a new `pos`.
type InfoRequestResult = Omit<InfoDisplayProps, 'triggerUpdate'> type InfoRequestResult = Omit<InfoDisplayProps, 'triggerUpdate'>
const [state, triggerUpdateCore] = useAsyncWithTrigger(() => new Promise<InfoRequestResult>((resolve, reject) => { const [state, triggerUpdateCore] = useAsyncWithTrigger(() => new Promise<InfoRequestResult>((resolve, reject) => {
const proofReq = rpcSess.call('Game.getProofState', DocumentPosition.toTdpp(pos))
const goalsReq = rpcSess.call('Game.getInteractiveGoals', DocumentPosition.toTdpp(pos)) const goalsReq = rpcSess.call('Game.getInteractiveGoals', DocumentPosition.toTdpp(pos))
const termGoalReq = getInteractiveTermGoal(rpcSess, DocumentPosition.toTdpp(pos)) const termGoalReq = getInteractiveTermGoal(rpcSess, DocumentPosition.toTdpp(pos))
const widgetsReq = Widget_getWidgets(rpcSess, pos).catch(discardMethodNotFound) const widgetsReq = Widget_getWidgets(rpcSess, pos).catch(discardMethodNotFound)
@ -308,6 +312,7 @@ function InfoAux(props: InfoProps) {
pos, pos,
status: 'updating', status: 'updating',
messages: lspDiagsHere.map(lspDiagToInteractive), messages: lspDiagsHere.map(lspDiagToInteractive),
proof: undefined,
goals: undefined, goals: undefined,
termGoal: undefined, termGoal: undefined,
error: undefined, error: undefined,
@ -318,11 +323,12 @@ function InfoAux(props: InfoProps) {
// NB: it is important to await await reqs at once, otherwise // NB: it is important to await await reqs at once, otherwise
// if both throw then one exception becomes unhandled. // if both throw then one exception becomes unhandled.
Promise.all([goalsReq, termGoalReq, widgetsReq, messagesReq]).then( Promise.all([proofReq, goalsReq, termGoalReq, widgetsReq, messagesReq]).then(
([goals, termGoal, userWidgets, messages]) => resolve({ ([proof, goals, termGoal, userWidgets, messages]) => resolve({
pos, pos,
status: 'ready', status: 'ready',
messages, messages,
proof : proof as any,
goals: goals as any, goals: goals as any,
termGoal, termGoal,
error: undefined, error: undefined,
@ -353,6 +359,7 @@ function InfoAux(props: InfoProps) {
pos, pos,
status: 'error', status: 'error',
messages: lspDiagsHere.map(lspDiagToInteractive), messages: lspDiagsHere.map(lspDiagToInteractive),
proof: undefined,
goals: undefined, goals: undefined,
termGoal: undefined, termGoal: undefined,
error: `Error fetching goals: ${errorString}`, error: `Error fetching goals: ${errorString}`,
@ -389,6 +396,7 @@ function InfoAux(props: InfoProps) {
pos, pos,
status: 'updating', status: 'updating',
messages: [], messages: [],
proof: undefined,
goals: undefined, goals: undefined,
termGoal: undefined, termGoal: undefined,
error: undefined, error: undefined,

@ -26,15 +26,16 @@ import Markdown from '../markdown';
import { Infos } from './infos'; import { Infos } from './infos';
import { AllMessages, Errors, WithLspDiagnosticsContext } from './messages'; import { AllMessages, Errors, WithLspDiagnosticsContext } from './messages';
import { Goal } from './goals'; import { Goal, isLastStepWithErrors, lastStepHasErrors, loadGoals } from './goals';
import { DeletedChatContext, InputModeContext, PreferencesContext, MonacoEditorContext, ProofContext, ProofStep, SelectionContext, WorldLevelIdContext } from './context'; import { DeletedChatContext, InputModeContext, PreferencesContext, MonacoEditorContext, ProofContext, SelectionContext, WorldLevelIdContext } from './context';
import { Typewriter, hasErrors, hasInteractiveErrors } from './typewriter'; import { Typewriter, getInteractiveDiagsAt, hasErrors, hasInteractiveErrors } from './typewriter';
import { InteractiveDiagnostic } from '@leanprover/infoview/*'; import { InteractiveDiagnostic } from '@leanprover/infoview/*';
import { Button } from '../button'; import { Button } from '../button';
import { CircularProgress } from '@mui/material'; import { CircularProgress } from '@mui/material';
import { GameHint } from './rpc_api'; import { GameHint, InteractiveGoalsWithHints, ProofState } from './rpc_api';
import { store } from '../../state/store'; import { store } from '../../state/store';
import { Hints, filterHints } from '../hints'; import { Hints, MoreHelpButton, filterHints } from '../hints';
import { DocumentPosition } from '../../../../node_modules/lean4-infoview/src/infoview/util';
/** Wrapper for the two editors. It is important that the `div` with `codeViewRef` is /** Wrapper for the two editors. It is important that the `div` with `codeViewRef` is
* always present, or the monaco editor cannot start. * always present, or the monaco editor cannot start.
@ -61,36 +62,35 @@ function DualEditorMain({ worldId, levelId, level, worldSize }: { worldId: strin
const gameId = React.useContext(GameIdContext) const gameId = React.useContext(GameIdContext)
const { typewriterMode } = React.useContext(InputModeContext) const { typewriterMode } = React.useContext(InputModeContext)
// Mark level as completed when server gives notification const {proof, setProof} = React.useContext(ProofContext)
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
useServerNotificationEffect(
'$/game/completed',
(params: any) => {
if (ec.events.changedCursorLocation.current &&
ec.events.changedCursorLocation.current.uri === params.uri) {
dispatch(levelCompleted({ game: gameId, world: worldId, level: levelId }))
// On completion, add the names of all new items to the local storage
let newTiles = [
...level?.tactics,
...level?.lemmas,
...level?.definitions
].filter((tile) => tile.new).map((tile) => tile.name)
// Add the proven statement to the local storage as well.
if (level?.statementName != null) {
newTiles.push(level?.statementName)
}
let inv: string[] = selectInventory(gameId)(store.getState()) React.useEffect(() => {
if (proof.completed) {
dispatch(levelCompleted({ game: gameId, world: worldId, level: levelId }))
// On completion, add the names of all new items to the local storage
let newTiles = [
...level?.tactics,
...level?.lemmas,
...level?.definitions
].filter((tile) => tile.new).map((tile) => tile.name)
// Add the proven statement to the local storage as well.
if (level?.statementName != null) {
newTiles.push(level?.statementName)
}
// add new items and remove duplicates let inv: string[] = selectInventory(gameId)(store.getState())
let newInv = [...inv, ...newTiles].filter((item, i, array) => array.indexOf(item) == i)
dispatch(changedInventory({ game: gameId, inventory: newInv })) // add new items and remove duplicates
} let newInv = [...inv, ...newTiles].filter((item, i, array) => array.indexOf(item) == i)
}, [level]
) dispatch(changedInventory({ game: gameId, inventory: newInv }))
}
}, [proof, level])
/* Set up updates to the global infoview state on editor events. */ /* Set up updates to the global infoview state on editor events. */
const config = useEventResult(ec.events.changedInfoviewConfig) ?? defaultInfoviewConfig; const config = useEventResult(ec.events.changedInfoviewConfig) ?? defaultInfoviewConfig;
@ -154,7 +154,7 @@ export function Main(props: { world: string, level: number, data: LevelInfo}) {
const gameId = React.useContext(GameIdContext) const gameId = React.useContext(GameIdContext)
const {worldId, levelId} = React.useContext(WorldLevelIdContext) const {worldId, levelId} = React.useContext(WorldLevelIdContext)
const completed = useAppSelector(selectCompleted(gameId, props.world, props.level)) const { proof, setProof } = React.useContext(ProofContext)
console.debug(`template: ${props.data?.template}`) console.debug(`template: ${props.data?.template}`)
@ -206,7 +206,7 @@ export function Main(props: { world: string, level: number, data: LevelInfo}) {
ret = <div><p>{serverStoppedResult.message}</p><p className="error">{serverStoppedResult.reason}</p></div> ret = <div><p>{serverStoppedResult.message}</p><p className="error">{serverStoppedResult.reason}</p></div>
} else { } else {
ret = <div className="infoview vscode-light"> ret = <div className="infoview vscode-light">
{completed && <div className="level-completed">Level completed! 🎉</div>} {proof.completed && <div className="level-completed">Level completed! 🎉</div>}
<Infos /> <Infos />
</div> </div>
} }
@ -223,15 +223,24 @@ const goalFilter = {
} }
/** The display of a single entered lean command */ /** The display of a single entered lean command */
function Command({ command, deleteProof }: { command: string, deleteProof: any }) { function Command({ proof, i, deleteProof }: { proof: ProofState, i: number, deleteProof: any }) {
// The first step will always have an empty command // The first step will always have an empty command
if (!command) { return <></> } if (!proof?.steps[i]?.command) { return <></> }
return <div className="command">
<div className="command-text">{command}</div> if (isLastStepWithErrors(proof, i)) {
<Button to="" className="undo-button btn btn-inverted" title="Retry proof from here" onClick={deleteProof}> // If the last step has errors, we display the command in a different style
<FontAwesomeIcon icon={faDeleteLeft} />&nbsp;Retry // indicating that it will be removed on the next try.
</Button> return <div className="failed-command">
</div> <i>Failed command</i>: {proof.steps[i].command}
</div>
} else {
return <div className="command">
<div className="command-text">{proof.steps[i].command}</div>
<Button to="" className="undo-button btn btn-inverted" title="Retry proof from here" onClick={deleteProof}>
<FontAwesomeIcon icon={faDeleteLeft} />&nbsp;Retry
</Button>
</div>
}
} }
// const MessageView = React.memo(({uri, diag}: MessageViewProps) => { // const MessageView = React.memo(({uri, diag}: MessageViewProps) => {
@ -286,10 +295,14 @@ function Command({ command, deleteProof }: { command: string, deleteProof: any }
// }, fastIsEqual) // }, fastIsEqual)
/** The tabs of goals that lean ahs after the command of this step has been processed */ /** The tabs of goals that lean ahs after the command of this step has been processed */
function GoalsTabs({ proofStep, last, onClick, onGoalChange=(n)=>{}}: { proofStep: ProofStep, last : boolean, onClick? : any, onGoalChange?: (n?: number) => void }) { function GoalsTabs({ proofStep, last, onClick, onGoalChange=(n)=>{}}: { proofStep: InteractiveGoalsWithHints, last : boolean, onClick? : any, onGoalChange?: (n?: number) => void }) {
const [selectedGoal, setSelectedGoal] = React.useState<number>(0) const [selectedGoal, setSelectedGoal] = React.useState<number>(0)
if (proofStep.goals.length == 0) {
return <></>
}
return <div className="goal-tabs" onClick={onClick}> return <div className="goal-tabs" onClick={onClick}>
<div className={`tab-bar ${last ? 'current' : ''}`}> <div className={`tab-bar ${last ? 'current' : ''}`}>
{proofStep.goals.map((goal, i) => ( {proofStep.goals.map((goal, i) => (
@ -300,7 +313,7 @@ function GoalsTabs({ proofStep, last, onClick, onGoalChange=(n)=>{}}: { proofSte
))} ))}
</div> </div>
<div className="goal-tab vscode-light"> <div className="goal-tab vscode-light">
<Goal typewriter={false} filter={goalFilter} goal={proofStep.goals[selectedGoal]} /> <Goal typewriter={false} filter={goalFilter} goal={proofStep.goals[selectedGoal]?.goal} />
</div> </div>
</div> </div>
} }
@ -350,12 +363,11 @@ export function TypewriterInterface({props}) {
const [loadingProgress, setLoadingProgress] = React.useState<number>(0) const [loadingProgress, setLoadingProgress] = React.useState<number>(0)
const { setDeletedChat, showHelp, setShowHelp } = React.useContext(DeletedChatContext) const { setDeletedChat, showHelp, setShowHelp } = React.useContext(DeletedChatContext)
const {mobile} = React.useContext(PreferencesContext) const {mobile} = React.useContext(PreferencesContext)
const { proof } = React.useContext(ProofContext) const { proof, setProof } = React.useContext(ProofContext)
const { setTypewriterInput } = React.useContext(InputModeContext) const { setTypewriterInput } = React.useContext(InputModeContext)
const { selectedStep, setSelectedStep } = React.useContext(SelectionContext) const { selectedStep, setSelectedStep } = React.useContext(SelectionContext)
const proofPanelRef = React.useRef<HTMLDivElement>(null) const proofPanelRef = React.useRef<HTMLDivElement>(null)
const completed = useAppSelector(selectCompleted(gameId, props.world, props.level))
// const config = useEventResult(ec.events.changedInfoviewConfig) ?? defaultInfoviewConfig; // const config = useEventResult(ec.events.changedInfoviewConfig) ?? defaultInfoviewConfig;
// const curUri = useEventResult(ec.events.changedCursorLocation, loc => loc?.uri); // const curUri = useEventResult(ec.events.changedCursorLocation, loc => loc?.uri);
@ -367,9 +379,11 @@ export function TypewriterInterface({props}) {
function deleteProof(line: number) { function deleteProof(line: number) {
return (ev) => { return (ev) => {
let deletedChat: Array<GameHint> = [] let deletedChat: Array<GameHint> = []
filterHints(proof).slice(line).map((hintsAtStep, i) => { proof.steps.slice(line).map((step, i) => {
let filteredHints = filterHints(step.goals[0]?.hints, proof?.steps[i-1]?.goals[0]?.hints)
// Only add these hidden hints to the deletion stack which were visible // Only add these hidden hints to the deletion stack which were visible
deletedChat = [...deletedChat, ...hintsAtStep.filter(hint => (!hint.hidden || showHelp.has(line + i)))] deletedChat = [...deletedChat, ...filteredHints.filter(hint => (!hint.hidden || showHelp.has(line + i)))]
}) })
setDeletedChat(deletedChat) setDeletedChat(deletedChat)
@ -382,7 +396,9 @@ export function TypewriterInterface({props}) {
forceMoveMarkers: false forceMoveMarkers: false
}]) }])
setSelectedStep(undefined) setSelectedStep(undefined)
setTypewriterInput(proof[line].command) setTypewriterInput(proof.steps[line].command)
// Reload proof on deleting
loadGoals(rpcSess, uri, setProof)
ev.stopPropagation() ev.stopPropagation()
} }
} }
@ -402,7 +418,7 @@ export function TypewriterInterface({props}) {
// Scroll to the end of the proof if it is updated. // Scroll to the end of the proof if it is updated.
React.useEffect(() => { React.useEffect(() => {
if (proof?.length > 1) { if (proof.steps.length > 1) {
proofPanelRef.current?.lastElementChild?.scrollIntoView() //scrollTo(0,0) proofPanelRef.current?.lastElementChild?.scrollIntoView() //scrollTo(0,0)
} else { } else {
proofPanelRef.current?.scrollTo(0,0) proofPanelRef.current?.scrollTo(0,0)
@ -423,38 +439,8 @@ export function TypewriterInterface({props}) {
} }
}, [selectedStep]) }, [selectedStep])
// TODO: This about hidden hints is all copied from `level.tsx`. Can we move that into `hints.tsx`? // TODO: superfluous, can be replaced with `withErr` from above
let lastStepErrors = proof.steps.length ? hasInteractiveErrors(getInteractiveDiagsAt(proof, proof.steps.length)) : false
// If the last step has errors, we want to treat it as if it is part of the second-to-last step
let k = proof.length - 1
let withErr = hasInteractiveErrors(proof[k]?.errors) ? 1 : 0
const activateHiddenHints = (ev) => {
// If the last step (`k`) has errors, we want the hidden hints from the
// second-to-last step to be affected
if (!(proof.length)) {return}
// state must not be mutated, therefore we need to clone the set
let tmp = new Set(showHelp)
if (tmp.has(k - withErr)) {
tmp.delete(k - withErr)
} else {
tmp.add(k - withErr)
}
setShowHelp(tmp)
console.debug(`help: ${Array.from(tmp.values())}`)
}
function hasHiddenHints(i : number): boolean {
let step = proof[i]
// For example if the proof isn't loaded yet
if(!step) {return false}
return step.hints.some((hint) => hint.hidden)
}
let lastStepErrors = proof.length ? hasInteractiveErrors(proof[proof.length - 1].errors) : false
useServerNotificationEffect("$/game/loading", (params : any) => { useServerNotificationEffect("$/game/loading", (params : any) => {
@ -474,20 +460,22 @@ export function TypewriterInterface({props}) {
</div> </div>
<div className='proof' ref={proofPanelRef}> <div className='proof' ref={proofPanelRef}>
<ExerciseStatement data={props.data} /> <ExerciseStatement data={props.data} />
{proof.length ? {proof.steps.length ?
<> <>
{proof.map((step, i) => { {proof.steps.map((step, i) => {
if (i == proof.length - 1 && lastStepErrors) { let filteredHints = filterHints(step.goals[0]?.hints, proof?.steps[i-1]?.goals[0]?.hints)
// if the last command contains an error, we only display the errors but not the
// entered command as it is still present in the command line. // if (i == proof.steps.length - 1 && hasInteractiveErrors(step.diags)) {
// TODO: Should not use index as key. // // if the last command contains an error, we only display the errors but not the
return <div key={`proof-step-${i}`}> // // entered command as it is still present in the command line.
<Errors errors={step.errors} typewriterMode={true} /> // // TODO: Should not use index as key.
</div> // return <div key={`proof-step-${i}`} className={`step step-${i}`}>
} else { // <Errors errors={step.diags} typewriterMode={true} />
// </div>
// } else {
return <div key={`proof-step-${i}`} className={`step step-${i}` + (selectedStep == i ? ' selected' : '')}> return <div key={`proof-step-${i}`} className={`step step-${i}` + (selectedStep == i ? ' selected' : '')}>
<Command command={step.command} deleteProof={deleteProof(i)} /> <Command proof={proof} i={i} deleteProof={deleteProof(i)} />
<Errors errors={step.errors} typewriterMode={true} /> <Errors errors={step.diags} typewriterMode={true} />
{mobile && i == 0 && props.data?.introduction && {mobile && i == 0 && props.data?.introduction &&
<div className={`message information step-0${selectedStep === 0 ? ' selected' : ''}`} onClick={toggleSelectStep(0)}> <div className={`message information step-0${selectedStep === 0 ? ' selected' : ''}`} onClick={toggleSelectStep(0)}>
<Markdown>{props.data?.introduction}</Markdown> <Markdown>{props.data?.introduction}</Markdown>
@ -495,22 +483,21 @@ export function TypewriterInterface({props}) {
} }
{mobile && {mobile &&
<Hints key={`hints-${i}`} <Hints key={`hints-${i}`}
hints={step.hints} showHidden={showHelp.has(i)} step={i} hints={filteredHints} showHidden={showHelp.has(i)} step={i}
selected={selectedStep} toggleSelection={toggleSelectStep(i)}/> selected={selectedStep} toggleSelection={toggleSelectStep(i)}/>
} }
<GoalsTabs proofStep={step} last={i == proof.length - (lastStepErrors ? 2 : 1)} onClick={toggleSelectStep(i)} onGoalChange={i == proof.length - 1 - withErr ? (n) => setDisableInput(n > 0) : (n) => {}}/> {/* <GoalsTabs proofStep={step} last={i == proof.steps.length - (lastStepErrors ? 2 : 1)} onClick={toggleSelectStep(i)} onGoalChange={i == proof.steps.length - 1 - withErr ? (n) => setDisableInput(n > 0) : (n) => {}}/> */}
{!(isLastStepWithErrors(proof, i)) &&
{mobile && i == proof.length - 1 && <GoalsTabs proofStep={step} last={i == proof.steps.length - (lastStepHasErrors(proof) ? 2 : 1)} onClick={toggleSelectStep(i)} onGoalChange={i == proof.steps.length - (lastStepHasErrors(proof) ? 2 : 1) ? (n) => setDisableInput(n > 0) : (n) => {}}/>
hasHiddenHints(proof.length - 1) && !showHelp.has(k - withErr) && }
<Button className="btn btn-help" to="" onClick={activateHiddenHints}> {mobile && i == proof.steps.length - 1 &&
Show more help! <MoreHelpButton />
</Button>
} }
{/* Show a message that there are no goals left */} {/* Show a message that there are no goals left */}
{!step.goals.length && ( {/* {!step.goals.length && (
<div className="message information"> <div className="message information">
{completed ? {proof.completed ?
<p>Level completed! 🎉</p> : <p>Level completed! 🎉</p> :
<p> <p>
<b>no goals left</b><br /> <b>no goals left</b><br />
@ -518,11 +505,17 @@ export function TypewriterInterface({props}) {
</p> </p>
} }
</div> </div>
)} )} */}
</div> </div>
} }
})} //}
{mobile && completed && )}
{proof.diagnostics.length > 0 &&
<div key={`proof-step-remaining`} className="step step-remaining">
<Errors errors={proof.diagnostics} typewriterMode={true} />
</div>
}
{mobile && proof.completed &&
<div className="button-row mobile"> <div className="button-row mobile">
{props.level >= props.worldSize ? {props.level >= props.worldSize ?
<Button to={`/${gameId}`}> <Button to={`/${gameId}`}>
@ -539,7 +532,7 @@ export function TypewriterInterface({props}) {
} }
</div> </div>
</div> </div>
<Typewriter hidden={!withErr && proof[proof.length - 1]?.goals.length == 0} disabled={disableInput || !proof.length}/> <Typewriter disabled={disableInput || !proof.steps.length}/>
</RpcContext.Provider> </RpcContext.Provider>
</div> </div>
} }

@ -3,46 +3,80 @@
* *
* This file is based on `vscode-lean4/vscode-lean4/src/rpcApi.ts` * This file is based on `vscode-lean4/vscode-lean4/src/rpcApi.ts`
*/ */
import { ContextInfo, FVarId, CodeWithInfos, MVarId } from '@leanprover/infoview-api'; import type { Range } from 'vscode-languageserver-protocol';
import type { ContextInfo, FVarId, CodeWithInfos, MVarId } from '@leanprover/infoview-api';
export interface GameHint { import { InteractiveDiagnostic, TermInfo } from '@leanprover/infoview/*';
text: string; import type { Diagnostic } from 'vscode-languageserver-protocol';
hidden: boolean;
}
export interface InteractiveHypothesisBundle { export interface InteractiveHypothesisBundle {
/** The pretty names of the variables in the bundle. Anonymous names are rendered /** The pretty names of the variables in the bundle. Anonymous names are rendered
* as `"[anonymous]"` whereas inaccessible ones have a `` appended at the end. * as `"[anonymous]"` whereas inaccessible ones have a `` appended at the end.
* Use `InteractiveHypothesisBundle_nonAnonymousNames` to filter anonymouse ones out. */ * Use `InteractiveHypothesisBundle_nonAnonymousNames` to filter anonymouse ones out. */
names: string[]; names: string[];
/** Present since server version 1.1.2. */
fvarIds?: FVarId[]; fvarIds?: FVarId[];
type: CodeWithInfos; type: CodeWithInfos;
val?: CodeWithInfos; val?: CodeWithInfos;
isInstance?: boolean; isInstance?: boolean;
isType?: boolean; isType?: boolean;
isAssumption?: boolean;
isInserted?: boolean; isInserted?: boolean;
isRemoved?: boolean; isRemoved?: boolean;
isAssumption?: boolean;
} }
export interface InteractiveGoalCore { export interface InteractiveGoalCore {
hyps: InteractiveHypothesisBundle[]; hyps: InteractiveHypothesisBundle[];
type: CodeWithInfos; type: CodeWithInfos;
/** Present since server version 1.1.2. */
ctx?: ContextInfo; ctx?: ContextInfo;
} }
export interface InteractiveGoal extends InteractiveGoalCore { export interface InteractiveGoal extends InteractiveGoalCore {
userName?: string; userName?: string;
goalPrefix?: string; goalPrefix?: string;
/** Present since server version 1.1.2. */
mvarId?: MVarId; mvarId?: MVarId;
isInserted?: boolean; isInserted?: boolean;
isRemoved?: boolean; isRemoved?: boolean;
}
export interface InteractiveGoals extends InteractiveGoalCore {
goals: InteractiveGoals[];
}
export interface InteractiveTermGoal extends InteractiveGoalCore {
range?: Range;
term?: TermInfo;
}
export interface GameHint {
text: string;
hidden: boolean;
}
export interface InteractiveGoalWithHints {
goal: InteractiveGoal;
hints: GameHint[]; hints: GameHint[];
} }
export interface InteractiveGoals { export interface InteractiveGoalsWithHints {
goals: InteractiveGoal[]; goals: InteractiveGoalWithHints[];
command: string;
diags: InteractiveDiagnostic[];
}
/**
* The proof state as it is received from the server.
* Per proof step of the tactic proof, there is one `InteractiveGoalWithHints[]`.
*/
export interface ProofState {
/** The proof steps. step 0 is the state at the beginning of the proof. step one
* contains the goal after the first line has been evaluated.
*
* In particular `step[i]` is the proof step at the beginning of line `i` in vscode.
*/
steps: InteractiveGoalsWithHints[];
/** The remaining diagnostics that are not in the steps. Usually this should only
* be the "unsolved goals" message, I believe.
*/
diagnostics : InteractiveDiagnostic[];
completed : Boolean;
completedWithWarnings : Boolean;
} }

@ -5,7 +5,7 @@ import { faWandMagicSparkles } from '@fortawesome/free-solid-svg-icons'
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js' import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'
import { Registry } from 'monaco-textmate' // peer dependency import { Registry } from 'monaco-textmate' // peer dependency
import { wireTmGrammars } from 'monaco-editor-textmate' import { wireTmGrammars } from 'monaco-editor-textmate'
import { DiagnosticSeverity, PublishDiagnosticsParams } from 'vscode-languageserver-protocol'; import { DiagnosticSeverity, PublishDiagnosticsParams, DocumentUri } from 'vscode-languageserver-protocol';
import { useServerNotificationEffect } from '../../../../node_modules/lean4-infoview/src/infoview/util'; import { useServerNotificationEffect } from '../../../../node_modules/lean4-infoview/src/infoview/util';
import { AbbreviationRewriter } from 'lean4web/client/src/editor/abbreviation/rewriter/AbbreviationRewriter'; import { AbbreviationRewriter } from 'lean4web/client/src/editor/abbreviation/rewriter/AbbreviationRewriter';
import { AbbreviationProvider } from 'lean4web/client/src/editor/abbreviation/AbbreviationProvider'; import { AbbreviationProvider } from 'lean4web/client/src/editor/abbreviation/AbbreviationProvider';
@ -13,13 +13,21 @@ import * as leanSyntax from 'lean4web/client/src/syntaxes/lean.json'
import * as leanMarkdownSyntax from 'lean4web/client/src/syntaxes/lean-markdown.json' import * as leanMarkdownSyntax from 'lean4web/client/src/syntaxes/lean-markdown.json'
import * as codeblockSyntax from 'lean4web/client/src/syntaxes/codeblock.json' import * as codeblockSyntax from 'lean4web/client/src/syntaxes/codeblock.json'
import languageConfig from 'lean4/language-configuration.json'; import languageConfig from 'lean4/language-configuration.json';
import { InteractiveDiagnostic, getInteractiveDiagnostics } from '@leanprover/infoview-api'; import { InteractiveDiagnostic, RpcSessionAtPos, getInteractiveDiagnostics } from '@leanprover/infoview-api';
import { Diagnostic } from 'vscode-languageserver-types'; import { Diagnostic } from 'vscode-languageserver-types';
import { DocumentPosition } from '../../../../node_modules/lean4-infoview/src/infoview/util'; import { DocumentPosition } from '../../../../node_modules/lean4-infoview/src/infoview/util';
import { RpcContext } from '../../../../node_modules/lean4-infoview/src/infoview/rpcSessions'; import { RpcContext } from '../../../../node_modules/lean4-infoview/src/infoview/rpcSessions';
import { DeletedChatContext, InputModeContext, MonacoEditorContext, ProofContext, ProofStep } from './context' import { DeletedChatContext, InputModeContext, MonacoEditorContext, ProofContext } from './context'
import { goalsToString } from './goals' import { goalsToString, lastStepHasErrors, loadGoals } from './goals'
import { GameHint, InteractiveGoals } from './rpc_api' import { GameHint, ProofState } from './rpc_api'
export interface GameDiagnosticsParams {
uri: DocumentUri;
diagnostics: Diagnostic[];
}
/* We register a new language `leancmd` that looks like lean4, but does not use the lsp server. */ /* We register a new language `leancmd` that looks like lean4, but does not use the lsp server. */
@ -64,7 +72,7 @@ config.autoClosingPairs = config.autoClosingPairs.map(
monaco.languages.setLanguageConfiguration('lean4cmd', config); monaco.languages.setLanguageConfiguration('lean4cmd', config);
/** The input field */ /** The input field */
export function Typewriter({hidden, disabled}: {hidden?: boolean, disabled?: boolean}) { export function Typewriter({disabled}: {disabled?: boolean}) {
/** Reference to the hidden multi-line editor */ /** Reference to the hidden multi-line editor */
const editor = React.useContext(MonacoEditorContext) const editor = React.useContext(MonacoEditorContext)
@ -89,98 +97,98 @@ export function Typewriter({hidden, disabled}: {hidden?: boolean, disabled?: boo
/** Load all goals an messages of the current proof (line-by-line) and save /** Load all goals an messages of the current proof (line-by-line) and save
* the retrieved information into context (`ProofContext`) * the retrieved information into context (`ProofContext`)
*/ */
const loadAllGoals = React.useCallback(() => { // const loadAllGoals = React.useCallback(() => {
let goalCalls = [] // let goalCalls = []
let msgCalls = [] // let msgCalls = []
// For each line of code ask the server for the goals and the messages on this line // // For each line of code ask the server for the goals and the messages on this line
for (let i = 0; i < model.getLineCount(); i++) { // for (let i = 0; i < model.getLineCount(); i++) {
goalCalls.push( // goalCalls.push(
rpcSess.call('Game.getInteractiveGoals', DocumentPosition.toTdpp({line: i, character: 0, uri: uri})) // rpcSess.call('Game.getInteractiveGoals', DocumentPosition.toTdpp({line: i, character: 0, uri: uri}))
) // )
msgCalls.push( // msgCalls.push(
getInteractiveDiagnostics(rpcSess, {start: i, end: i+1}).catch((error) => {console.debug("promise broken")}) // getInteractiveDiagnostics(rpcSess, {start: i, end: i+1}).catch((error) => {console.debug("promise broken")})
) // )
} // }
// Wait for all these requests to be processed before saving the results // // Wait for all these requests to be processed before saving the results
Promise.all(goalCalls).then((steps : InteractiveGoals[]) => { // Promise.all(goalCalls).then((steps : InteractiveGoalsWithHints[]) => {
Promise.all(msgCalls).then((diagnostics : [InteractiveDiagnostic[]]) => { // Promise.all(msgCalls).then((diagnostics : [InteractiveDiagnostic[]]) => {
let tmpProof : ProofStep[] = [] // let tmpProof : ProofStep[] = []
let goalCount = 0 // let goalCount = 0
steps.map((goals, i) => { // steps.map((goals, i) => {
// The first step has an empty command and therefore also no error messages // // The first step has an empty command and therefore also no error messages
// Usually there is a newline at the end of the editors content, so we need to // // Usually there is a newline at the end of the editors content, so we need to
// display diagnostics from potentally two lines in the last step. // // display diagnostics from potentally two lines in the last step.
let messages = i ? (i == steps.length - 1 ? diagnostics.slice(i-1).flat() : diagnostics[i-1]) : [] // let messages = i ? (i == steps.length - 1 ? diagnostics.slice(i-1).flat() : diagnostics[i-1]) : []
// Filter out the 'unsolved goals' message // // Filter out the 'unsolved goals' message
messages = messages.filter((msg) => { // messages = messages.filter((msg) => {
return !("append" in msg.message && // return !("append" in msg.message &&
"text" in msg.message.append[0] && // "text" in msg.message.append[0] &&
msg.message.append[0].text === "unsolved goals") // msg.message.append[0].text === "unsolved goals")
}) // })
if (typeof goals == 'undefined') { // if (typeof goals == 'undefined') {
tmpProof.push({ // tmpProof.push({
command: i ? model.getLineContent(i) : '', // command: i ? model.getLineContent(i) : '',
goals: [], // goals: [],
hints: [], // hints: [],
errors: messages // errors: messages
} as ProofStep) // } as ProofStep)
console.debug('goals is undefined') // console.debug('goals is undefined')
return // return
} // }
// If the number of goals reduce, show a message // // If the number of goals reduce, show a message
if (goals.goals.length && goalCount > goals.goals.length) { // if (goals.length && goalCount > goals.length) {
messages.unshift({ // messages.unshift({
range: { // range: {
start: { // start: {
line: i-1, // line: i-1,
character: 0, // character: 0,
}, // },
end: { // end: {
line: i-1, // line: i-1,
character: 0, // character: 0,
}}, // }},
severity: DiagnosticSeverity.Information, // severity: DiagnosticSeverity.Information,
message: { // message: {
text: 'intermediate goal solved 🎉' // text: 'intermediate goal solved 🎉'
} // }
}) // })
} // }
goalCount = goals.goals.length // goalCount = goals.length
// with no goals there will be no hints. // // with no goals there will be no hints.
let hints : GameHint[] = goals.goals.length ? goals.goals[0].hints : [] // let hints : GameHint[] = goals.length ? goals[0].hints : []
console.debug(`Command (${i}): `, i ? model.getLineContent(i) : '') // console.debug(`Command (${i}): `, i ? model.getLineContent(i) : '')
console.debug(`Goals: (${i}): `, goalsToString(goals)) // // console.debug(`Goals: (${i}): `, goalsToString(goals)) //
console.debug(`Hints: (${i}): `, hints) // console.debug(`Hints: (${i}): `, hints)
console.debug(`Errors: (${i}): `, messages) // console.debug(`Errors: (${i}): `, messages)
tmpProof.push({ // tmpProof.push({
// the command of the line above. Note that `getLineContent` starts counting // // the command of the line above. Note that `getLineContent` starts counting
// at `1` instead of `zero`. The first ProofStep will have an empty command. // // at `1` instead of `zero`. The first ProofStep will have an empty command.
command: i ? model.getLineContent(i) : '', // command: i ? model.getLineContent(i) : '',
// TODO: store correct data // // TODO: store correct data
goals: goals.goals, // goals: goals.map(g => g.goal),
// only need the hints of the active goals in chat // // only need the hints of the active goals in chat
hints: hints, // hints: hints,
// errors and messages from the server // // errors and messages from the server
errors: messages // errors: messages
} as ProofStep) // } as ProofStep)
}) // })
// Save the proof to the context // // Save the proof to the context
setProof(tmpProof) // setProof(tmpProof)
}).catch((error) => {console.debug("promise broken")}) // }).catch((error) => {console.debug("promise broken")})
}).catch((error) => {console.debug("promise broken")}) // }).catch((error) => {console.debug("promise broken")})
}, [editor, rpcSess, uri, model]) // }, [editor, rpcSess, uri, model])
// Run the command // Run the command
const runCommand = React.useCallback(() => { const runCommand = React.useCallback(() => {
@ -201,6 +209,8 @@ export function Typewriter({hidden, disabled}: {hidden?: boolean, disabled?: boo
forceMoveMarkers: false forceMoveMarkers: false
}]) }])
setTypewriterInput('') setTypewriterInput('')
// Load proof after executing edits
loadGoals(rpcSess, uri, setProof)
} }
editor.setPosition(pos) editor.setPosition(pos)
@ -212,9 +222,15 @@ export function Typewriter({hidden, disabled}: {hidden?: boolean, disabled?: boo
} }
}, [typewriterInput]) }, [typewriterInput])
/* Load proof on start/switching to typewriter */
useEffect(() => {
loadGoals(rpcSess, uri, setProof)
}, [])
/** If the last step has an error, add the command to the typewriter. */
useEffect(() => { useEffect(() => {
if (proof.length && hasInteractiveErrors(proof[proof.length - 1].errors)) { if (lastStepHasErrors(proof)) {
setTypewriterInput(proof[proof.length - 1].command) setTypewriterInput(proof.steps[proof.steps.length - 1].command)
} }
}, [proof]) }, [proof])
@ -222,7 +238,9 @@ export function Typewriter({hidden, disabled}: {hidden?: boolean, disabled?: boo
useServerNotificationEffect('textDocument/publishDiagnostics', (params: PublishDiagnosticsParams) => { useServerNotificationEffect('textDocument/publishDiagnostics', (params: PublishDiagnosticsParams) => {
if (params.uri == uri) { if (params.uri == uri) {
setProcessing(false) setProcessing(false)
loadAllGoals() //loadGoals(rpcSess, uri, setProof)
// TODO: loadAllGoals()
if (!hasErrors(params.diagnostics)) { if (!hasErrors(params.diagnostics)) {
//setTypewriterInput("") //setTypewriterInput("")
editor.setPosition(editor.getModel().getFullModelRange().getEndPosition()) editor.setPosition(editor.getModel().getFullModelRange().getEndPosition())
@ -236,6 +254,15 @@ export function Typewriter({hidden, disabled}: {hidden?: boolean, disabled?: boo
// loadAllGoals() // loadAllGoals()
}, [uri]); }, [uri]);
// React when answer from the server comes back
useServerNotificationEffect('$/game/publishDiagnostics', (params: GameDiagnosticsParams) => {
console.log('Received game diagnostics')
console.log(`diag. uri : ${params.uri}`)
console.log(params.diagnostics)
}, [uri]);
useEffect(() => { useEffect(() => {
const myEditor = monaco.editor.create(inputRef.current!, { const myEditor = monaco.editor.create(inputRef.current!, {
value: typewriterInput, value: typewriterInput,
@ -306,7 +333,8 @@ export function Typewriter({hidden, disabled}: {hidden?: boolean, disabled?: boo
useEffect(() => { useEffect(() => {
console.debug(`time to update: ${uri} \n ${rpcSess}`) console.debug(`time to update: ${uri} \n ${rpcSess}`)
console.debug(rpcSess) console.debug(rpcSess)
loadAllGoals() // console.debug('LOAD ALL GOALS')
// TODO: loadAllGoals()
}, [rpcSess]) }, [rpcSess])
/** Process the entered command */ /** Process the entered command */
@ -315,7 +343,8 @@ export function Typewriter({hidden, disabled}: {hidden?: boolean, disabled?: boo
runCommand() runCommand()
} }
return <div className={`typewriter${hidden ? ' hidden' : ''}${disabled ? ' disabled' : ''}`}> // do not display if the proof is completed (with potential warnings still present)
return <div className={`typewriter${proof.completedWithWarnings ? ' hidden' : ''}${disabled ? ' disabled' : ''}`}>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className="typewriter-input-wrapper"> <div className="typewriter-input-wrapper">
<div ref={inputRef} className="typewriter-input" /> <div ref={inputRef} className="typewriter-input" />
@ -343,3 +372,14 @@ export function hasInteractiveErrors (diags: InteractiveDiagnostic[]) {
(d) => (d.severity == DiagnosticSeverity.Error ) // || d.severity == DiagnosticSeverity.Warning (d) => (d.severity == DiagnosticSeverity.Error ) // || d.severity == DiagnosticSeverity.Warning
) )
} }
export function getInteractiveDiagsAt (proof: ProofState, k : number) {
if (k == 0) {
return []
} else if (k >= proof.steps.length-1) {
// TODO: Do we need that?
return proof.diagnostics.filter(msg => msg.range.start.line >= proof.steps.length-1)
} else {
return proof.diagnostics.filter(msg => msg.range.start.line == k-1)
}
}

@ -28,10 +28,10 @@ import Markdown from './markdown'
import {InventoryPanel} from './inventory' import {InventoryPanel} from './inventory'
import { hasInteractiveErrors } from './infoview/typewriter' import { hasInteractiveErrors } from './infoview/typewriter'
import { DeletedChatContext, InputModeContext, PreferencesContext, MonacoEditorContext, import { DeletedChatContext, InputModeContext, PreferencesContext, MonacoEditorContext,
ProofContext, ProofStep, SelectionContext, WorldLevelIdContext } from './infoview/context' ProofContext, SelectionContext, WorldLevelIdContext } from './infoview/context'
import { DualEditor } from './infoview/main' import { DualEditor } from './infoview/main'
import { GameHint } from './infoview/rpc_api' import { GameHint, InteractiveGoalsWithHints, ProofState } from './infoview/rpc_api'
import { DeletedHints, Hint, Hints, filterHints } from './hints' import { DeletedHints, Hint, Hints, MoreHelpButton, filterHints } from './hints'
import { PrivacyPolicyPopup } from './popup/privacy_policy' import { PrivacyPolicyPopup } from './popup/privacy_policy'
import path from 'path'; import path from 'path';
@ -49,6 +49,7 @@ import { WebSocketMessageWriter, toSocket } from 'vscode-ws-jsonrpc'
import { IConnectionProvider } from 'monaco-languageclient' import { IConnectionProvider } from 'monaco-languageclient'
import { monacoSetup } from 'lean4web/client/src/monacoSetup' import { monacoSetup } from 'lean4web/client/src/monacoSetup'
import { onigasmH } from 'onigasm/lib/onigasmH' import { onigasmH } from 'onigasm/lib/onigasmH'
import { isLastStepWithErrors, lastStepHasErrors } from './infoview/goals'
monacoSetup() monacoSetup()
@ -83,9 +84,7 @@ function ChatPanel({lastLevel}) {
const {selectedStep, setSelectedStep} = useContext(SelectionContext) const {selectedStep, setSelectedStep} = useContext(SelectionContext)
const completed = useAppSelector(selectCompleted(gameId, worldId, levelId)) const completed = useAppSelector(selectCompleted(gameId, worldId, levelId))
// If the last step has errors, we want to treat it as if it is part of the second-to-last step let k = proof.steps.length - (lastStepHasErrors(proof) ? 2 : 1)
let k = proof.length - 1
let withErr = hasInteractiveErrors(proof[k]?.errors) ? 1 : 0
function toggleSelection(line: number) { function toggleSelection(line: number) {
return (ev) => { return (ev) => {
@ -98,29 +97,6 @@ function ChatPanel({lastLevel}) {
} }
} }
function hasHiddenHints(i : number): boolean {
let step = proof[i]
// For example if the proof isn't loaded yet
if(!step) {return false}
return step.hints.some((hint) => hint.hidden)
}
const activateHiddenHints = (ev) => {
// If the last step (`k`) has errors, we want the hidden hints from the
// second-to-last step to be affected
if (!(proof.length)) {return}
// state must not be mutated, therefore we need to clone the set
let tmp = new Set(showHelp)
if (tmp.has(k - withErr)) {
tmp.delete(k - withErr)
} else {
tmp.add(k - withErr)
}
setShowHelp(tmp)
console.debug(`help: ${Array.from(tmp.values())}`)
}
useEffect(() => { useEffect(() => {
// TODO: For some reason this is always called twice // TODO: For some reason this is always called twice
console.debug('scroll chat') console.debug('scroll chat')
@ -146,10 +122,6 @@ function ChatPanel({lastLevel}) {
let introText: Array<string> = level?.data?.introduction.split(/\n(\s*\n)+/) let introText: Array<string> = level?.data?.introduction.split(/\n(\s*\n)+/)
// experimental: Remove all hints that appeared identically in the previous step
// This effectively prevent consequtive hints being shown.
let modifiedHints : GameHint[][] = filterHints(proof)
return <div className="chat-panel"> return <div className="chat-panel">
<div ref={chatRef} className="chat"> <div ref={chatRef} className="chat">
{introText?.filter(t => t.trim()).map(((t, i) => {introText?.filter(t => t.trim()).map(((t, i) =>
@ -157,18 +129,27 @@ function ChatPanel({lastLevel}) {
<Hint key={`intro-p-${i}`} <Hint key={`intro-p-${i}`}
hint={{text: t, hidden: false}} step={0} selected={selectedStep} toggleSelection={toggleSelection(0)} /> hint={{text: t, hidden: false}} step={0} selected={selectedStep} toggleSelection={toggleSelection(0)} />
))} ))}
{modifiedHints.map((step, i) => { {proof.steps.map((step, i) => {
let filteredHints = filterHints(step.goals[0]?.hints, proof?.steps[i-1]?.goals[0]?.hints)
if (step.goals.length > 0 && !isLastStepWithErrors(proof, i)) {
return <Hints key={`hints-${i}`}
hints={filteredHints} showHidden={showHelp.has(i)} step={i}
selected={selectedStep} toggleSelection={toggleSelection(i)} lastLevel={i == proof.steps.length - 1}/>
}
})}
{/* {modifiedHints.map((step, i) => {
// It the last step has errors, it will have the same hints // It the last step has errors, it will have the same hints
// as the second-to-last step. Therefore we should not display them. // as the second-to-last step. Therefore we should not display them.
if (!(i == proof.length - 1 && withErr)) { if (!(i == proof.steps.length - 1 && withErr)) {
// TODO: Should not use index as key. // TODO: Should not use index as key.
return <Hints key={`hints-${i}`} return <Hints key={`hints-${i}`}
hints={step} showHidden={showHelp.has(i)} step={i} hints={step} showHidden={showHelp.has(i)} step={i}
selected={selectedStep} toggleSelection={toggleSelection(i)} lastLevel={i == proof.length - 1}/> selected={selectedStep} toggleSelection={toggleSelection(i)} lastLevel={i == proof.steps.length - 1}/>
} }
})} })} */}
<DeletedHints hints={deletedChat}/> <DeletedHints hints={deletedChat}/>
{completed && {proof.completed &&
<> <>
<div className={`message information recent step-${k}${selectedStep == k ? ' selected' : ''}`} onClick={toggleSelection(k)}> <div className={`message information recent step-${k}${selectedStep == k ? ' selected' : ''}`} onClick={toggleSelection(k)}>
Level completed! 🎉 Level completed! 🎉
@ -182,7 +163,7 @@ function ChatPanel({lastLevel}) {
} }
</div> </div>
<div className="button-row"> <div className="button-row">
{completed && (lastLevel ? {proof.completed && (lastLevel ?
<Button to={`/${gameId}`}> <Button to={`/${gameId}`}>
<FontAwesomeIcon icon={faHome} />&nbsp;Leave World <FontAwesomeIcon icon={faHome} />&nbsp;Leave World
</Button> : </Button> :
@ -190,15 +171,12 @@ function ChatPanel({lastLevel}) {
Next&nbsp;<FontAwesomeIcon icon={faArrowRight} /> Next&nbsp;<FontAwesomeIcon icon={faArrowRight} />
</Button>) </Button>)
} }
{hasHiddenHints(proof.length - 1) && !showHelp.has(k - withErr) && <MoreHelpButton />
<Button to="" onClick={activateHiddenHints}>
Show more help!
</Button>
}
</div> </div>
</div> </div>
} }
function ExercisePanel({codeviewRef, visible=true}) { function ExercisePanel({codeviewRef, visible=true}) {
const gameId = React.useContext(GameIdContext) const gameId = React.useContext(GameIdContext)
const {worldId, levelId} = useContext(WorldLevelIdContext) const {worldId, levelId} = useContext(WorldLevelIdContext)
@ -229,7 +207,7 @@ function PlayableLevel({impressum, setImpressum}) {
const level = useLoadLevelQuery({game: gameId, world: worldId, level: levelId}) const level = useLoadLevelQuery({game: gameId, world: worldId, level: levelId})
// The state variables for the `ProofContext` // The state variables for the `ProofContext`
const [proof, setProof] = useState<Array<ProofStep>>([]) const [proof, setProof] = useState<ProofState>({steps: [], diagnostics: [], completed: false, completedWithWarnings: false})
// When deleting the proof, we want to keep to old messages around until // When deleting the proof, we want to keep to old messages around until
// a new proof has been entered. e.g. to consult messages coming from dead ends // a new proof has been entered. e.g. to consult messages coming from dead ends
const [deletedChat, setDeletedChat] = useState<Array<GameHint>>([]) const [deletedChat, setDeletedChat] = useState<Array<GameHint>>([])
@ -356,15 +334,15 @@ function PlayableLevel({impressum, setImpressum}) {
useEffect(() => { useEffect(() => {
// Forget whether hidden hints are displayed for steps that don't exist yet // Forget whether hidden hints are displayed for steps that don't exist yet
if (proof.length) { if (proof.steps.length) {
console.debug(Array.from(showHelp)) console.debug(Array.from(showHelp))
setShowHelp(new Set(Array.from(showHelp).filter(i => (i < proof.length)))) setShowHelp(new Set(Array.from(showHelp).filter(i => (i < proof.steps.length))))
} }
}, [proof]) }, [proof])
// save showed help in store // save showed help in store
useEffect(() => { useEffect(() => {
if (proof.length) { if (proof.steps.length) {
console.debug(`showHelp:\n ${showHelp}`) console.debug(`showHelp:\n ${showHelp}`)
dispatch(helpEdited({game: gameId, world: worldId, level: levelId, help: Array.from(showHelp)})) dispatch(helpEdited({game: gameId, world: worldId, level: levelId, help: Array.from(showHelp)}))
} }
@ -622,6 +600,8 @@ function useLevelEditor(codeviewRef, initialCode, initialSelections, onDidChange
} }
// loadRenderInfoview(imports, [infoProvider.getApi(), div], setInfoviewApi) // loadRenderInfoview(imports, [infoProvider.getApi(), div], setInfoviewApi)
setInfoProvider(infoProvider) setInfoProvider(infoProvider)
// TODO: it looks like we get errors "File Changed" here.
client.restart() client.restart()
const editorApi = infoProvider.getApi() const editorApi = infoProvider.getApi()

@ -188,6 +188,18 @@
flex-direction: row; flex-direction: row;
} }
.exercise .failed-command {
background-color: #eee;
padding: .5em;
border-radius: .2em;
/* TODO: It seems my browsers merge the margings of the proof steps,
so that it only shows once 0.5rem instead of twice. Thus have 1.5 here now.
*/
margin-bottom: 1.5rem;
display: flex;
flex-direction: row;
}
.exercise .command-text { .exercise .command-text {
flex: 1; flex: 1;
background-color: #fff; background-color: #fff;

@ -368,3 +368,8 @@ td code {
min-width: 40px; min-width: 40px;
text-align: center; text-align: center;
} }
/* DEBUG */
.proof .step {
border: 2px solid rgb(0, 123, 255);
}

@ -3,6 +3,7 @@ import Lean.Server.FileWorker
import GameServer.Game import GameServer.Game
import GameServer.ImportModules import GameServer.ImportModules
import GameServer.SaveData import GameServer.SaveData
import GameServer.EnvExtensions
namespace MyModule namespace MyModule
@ -60,8 +61,8 @@ open Snapshots
open JsonRpc open JsonRpc
/-- /--
Game-specific state to be packed on top of the `Lean.Server.FileWorker.WorkerState` Game-specific state to be packed on top of the `Server.FileWorker.WorkerState`
used by the lean server. used by the Lean server.
-/ -/
structure WorkerState := structure WorkerState :=
/-- /--
@ -84,7 +85,7 @@ structure WorkerState :=
deriving ToJson, FromJson deriving ToJson, FromJson
/-- /--
Pack the `GameServer.FileWorker.WorkerState` on top of the normal worker monad Pack the our custom `WorkerState` on top of the normal worker monad
`Server.FileWorker.WorkerM`. `Server.FileWorker.WorkerM`.
-/ -/
abbrev WorkerM := StateT WorkerState Server.FileWorker.WorkerM abbrev WorkerM := StateT WorkerState Server.FileWorker.WorkerM
@ -102,16 +103,6 @@ def addMessage (info : SourceInfo) (inputCtx : Parser.InputContext)
pos := inputCtx.fileMap.toPosition (info.getPos?.getD 0) pos := inputCtx.fileMap.toPosition (info.getPos?.getD 0)
data := s }} data := s }}
/-- Deprecated! -/
def addErrorMessage (info : SourceInfo) (inputCtx : Parser.InputContext) (s : MessageData) :
Elab.Command.CommandElabM Unit := do
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 }}
-- TODO: use HashSet for allowed tactics? -- TODO: use HashSet for allowed tactics?
/-- /--
Find all tactics in syntax object that are forbidden according to a Find all tactics in syntax object that are forbidden according to a
@ -322,6 +313,79 @@ where
uri : String uri : String
deriving ToJson, FromJson deriving ToJson, FromJson
structure GameDiagnostics where
diagnostics : List Diagnostic
deriving ToJson, FromJson
structure GameParams where
uri : String
diagnostics : GameDiagnostics
deriving ToJson, FromJson
/-- 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 errrors 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 -/ /-- 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 def publishGameCompleted (m : DocumentMeta) (hOut : FS.Stream) (snaps : Array Snapshot) : IO Unit := do
-- check if there is any error or warning -- check if there is any error or warning
@ -331,65 +395,51 @@ where
let param := { uri := m.uri : GameCompletedParams} let param := { uri := m.uri : GameCompletedParams}
hOut.writeLspNotification { method := "$/game/completed", param } hOut.writeLspNotification { method := "$/game/completed", param }
/-- Elaborates the next command after `parentSnap` and emits diagnostics into `hOut`. -/ /-- copied from `Lean.Server.FileWorker.nextCmdSnap`. -/
private def nextSnap (ctx : WorkerContext) (m : DocumentMeta) (cancelTk : CancelToken) -- @[inherit_doc Lean.Server.FileWorker.nextCmdSnap] -- cannot inherit from private
(gameWorkerState : WorkerState) (initParams : Lsp.InitializeParams) private def nextCmdSnap (ctx : WorkerContext) (m : DocumentMeta) (cancelTk : CancelToken)
: AsyncElabM (Option Snapshot) := do (gameWorkerState : WorkerState) (initParams : Lsp.InitializeParams) :
AsyncElabM (Option Snapshot) := do
cancelTk.check cancelTk.check
let s ← get let s ← get
let .some lastSnap := s.snaps.back? | panic! "empty snapshots" let .some lastSnap := s.snaps.back? | panic! "empty snapshots"
if lastSnap.isAtEnd then if lastSnap.isAtEnd then
publishGameCompleted m ctx.hOut s.snaps
publishDiagnostics m lastSnap.diagnostics.toArray ctx.hOut publishDiagnostics m lastSnap.diagnostics.toArray ctx.hOut
publishProgressDone m 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 publishIleanInfoFinal m ctx.hOut s.snaps
return none return none
publishProgressAtPos m lastSnap.endPos ctx.hOut publishProgressAtPos m lastSnap.endPos ctx.hOut
-- (modified part)
-- Make sure that there is at least one snap after the head snap, so that -- 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 -- we can see the current goal even on an empty document
let couldBeEndSnap := s.snaps.size > 1 let couldBeEndSnap := s.snaps.size > 1
let snap ← compileProof m.mkInputContext lastSnap ctx.clientHasWidgets couldBeEndSnap let snap ← compileProof m.mkInputContext lastSnap ctx.clientHasWidgets couldBeEndSnap
gameWorkerState initParams gameWorkerState initParams
set { s with snaps := s.snaps.push snap } set { s with snaps := s.snaps.push snap }
-- TODO(MH): check for interrupt with increased precision
cancelTk.check cancelTk.check
/- NOTE(MH): This relies on the client discarding old diagnostics upon receiving new ones publishProofState m snap initParams ctx.hOut
while preferring 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* redundant 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 publishDiagnostics m snap.diagnostics.toArray ctx.hOut
publishIleanInfoUpdate m ctx.hOut #[snap] publishIleanInfoUpdate m ctx.hOut #[snap]
return some 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`. -/ -- Copied from `Lean.Server.FileWorker.unfoldCmdSnaps` using our own `nextCmdSnap`.
def unfoldSnaps (m : DocumentMeta) (snaps : Array Snapshot) (cancelTk : CancelToken) @[inherit_doc Lean.Server.FileWorker.unfoldCmdSnaps]
def unfoldCmdSnaps (m : DocumentMeta) (snaps : Array Snapshot) (cancelTk : CancelToken)
(startAfterMs : UInt32) (gameWorkerState : WorkerState) (startAfterMs : UInt32) (gameWorkerState : WorkerState)
: ReaderT WorkerContext IO (AsyncList ElabTaskError Snapshot) := do : ReaderT WorkerContext IO (AsyncList ElabTaskError Snapshot) := do
let ctx ← read let ctx ← read
let some headerSnap := snaps[0]? | panic! "empty snapshots" let some headerSnap := snaps[0]? | panic! "empty snapshots"
if headerSnap.msgLog.hasErrors then 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) publishProgressAtPos m headerSnap.beginPos ctx.hOut (kind := LeanFileProgressKind.fatalError)
publishIleanInfoFinal m ctx.hOut #[headerSnap] publishIleanInfoFinal m ctx.hOut #[headerSnap]
return AsyncList.ofList [headerSnap] return AsyncList.ofList [headerSnap]
else else
-- This will overwrite existing ilean info for the file since this has a
-- higher version number.
publishIleanInfoUpdate m ctx.hOut snaps publishIleanInfoUpdate m ctx.hOut snaps
return AsyncList.ofList snaps.toList ++ AsyncList.delayed (← EIO.asTask (ε := ElabTaskError) (prio := .dedicated) do return AsyncList.ofList snaps.toList ++ AsyncList.delayed (← EIO.asTask (ε := ElabTaskError) (prio := .dedicated) do
IO.sleep startAfterMs IO.sleep startAfterMs
AsyncList.unfoldAsync (nextSnap ctx m cancelTk gameWorkerState ctx.initParams) { snaps }) AsyncList.unfoldAsync (nextCmdSnap ctx m cancelTk gameWorkerState ctx.initParams) { snaps })
end Elab end Elab
@ -439,7 +489,7 @@ def updateDocument (newMeta : DocumentMeta) : WorkerM Unit := do
validSnaps := validSnaps.dropLast validSnaps := validSnaps.dropLast
-- wait for a bit, giving the initial `cancelTk.check` in `nextCmdSnap` time to trigger -- 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) -- before kicking off any expensive elaboration (TODO: make expensive elaboration cancelable)
unfoldSnaps newMeta validSnaps.toArray cancelTk s ctx unfoldCmdSnaps newMeta validSnaps.toArray cancelTk s ctx
(startAfterMs := ctx.initParams.editDelay.toUInt32) (startAfterMs := ctx.initParams.editDelay.toUInt32)
StateT.lift <| modify fun st => { st with StateT.lift <| modify fun st => { st with
doc := { meta := newMeta, cmdSnaps := AsyncList.delayed newSnaps, cancelTk }} doc := { meta := newMeta, cmdSnaps := AsyncList.delayed newSnaps, cancelTk }}
@ -513,49 +563,52 @@ def compileHeader (m : DocumentMeta) (hOut : FS.Stream) (opts : Options) (hasWid
publishDiagnostics m headerSnap.diagnostics.toArray hOut publishDiagnostics m headerSnap.diagnostics.toArray hOut
return (headerSnap, srcSearchPath) 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) def initializeWorker (meta : DocumentMeta) (i o e : FS.Stream) (initParams : InitializeParams) (opts : Options)
(gameDir : String) (gameWorkerState : WorkerState) : IO (WorkerContext × Server.FileWorker.WorkerState) := do (gameDir : String) (gameWorkerState : WorkerState) : IO (WorkerContext × Server.FileWorker.WorkerState) := do
let clientHasWidgets := initParams.initializationOptions?.bind (·.hasWidgets?) |>.getD false let clientHasWidgets := initParams.initializationOptions?.bind (·.hasWidgets?) |>.getD false
let (headerStx, headerTask) ← compileHeader meta o opts (hasWidgets := clientHasWidgets) let (headerStx, headerTask) ← compileHeader meta o opts (hasWidgets := clientHasWidgets)
gameDir gameWorkerState.levelInfo.module (gameDir := gameDir) (module := gameWorkerState.levelInfo.module)
let cancelTk ← CancelToken.new let cancelTk ← CancelToken.new
let ctx := let ctx := {
{ hIn := i hIn := i
hOut := o hOut := o
hLog := e hLog := e
headerTask headerTask
initParams initParams
clientHasWidgets clientHasWidgets
} }
let cmdSnaps ← EIO.mapTask (t := headerTask) (match · with let cmdSnaps ← EIO.mapTask (t := headerTask) (match · with
| Except.ok (s, _) => unfoldSnaps meta #[s] cancelTk gameWorkerState ctx (startAfterMs := 0) | Except.ok (s, _) => unfoldCmdSnaps meta #[s] cancelTk gameWorkerState ctx (startAfterMs := 0)
| Except.error e => throw (e : ElabTaskError)) | Except.error e => throw (e : ElabTaskError))
let doc : EditableDocument := { meta, cmdSnaps := AsyncList.delayed cmdSnaps, cancelTk } let doc : EditableDocument := { meta, cmdSnaps := AsyncList.delayed cmdSnaps, cancelTk }
return (ctx, return (ctx, {
{ doc := doc doc := doc
initHeaderStx := headerStx initHeaderStx := headerStx
currHeaderStx := headerStx currHeaderStx := headerStx
importCachingTask? := none importCachingTask? := none
pendingRequests := RBMap.empty pendingRequests := RBMap.empty
rpcSessions := RBMap.empty rpcSessions := RBMap.empty
}) })
end Initialization end Initialization
section NotificationHandling 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 def handleDidChange (p : DidChangeTextDocumentParams) : WorkerM Unit := do
let docId := p.textDocument let docId := p.textDocument
let changes := p.contentChanges let changes := p.contentChanges
let oldDoc := (← StateT.lift get).doc let oldDoc := (← StateT.lift get).doc -- needed a lift to our custom `WorkerM`
let some newVersion ← pure docId.version? let newVersion := docId.version?.getD 0
| throwServerError "Expected version number" if ¬ changes.isEmpty then
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 let newDocText := foldDocumentChanges changes oldDoc.meta.text
-- modification: set the `DependencyBuildMode` from
-- `oldDoc.meta.dependencyBuildMode` to `.always`
updateDocument ⟨docId.uri, newVersion, newDocText, .always⟩ updateDocument ⟨docId.uri, newVersion, newDocText, .always⟩
end NotificationHandling end NotificationHandling
@ -591,39 +644,34 @@ end MessageHandling
section MainLoop section MainLoop
/-- /--
Erase finished tasks if there are no errors. The main-loop. Copied from `Lean.Server.FileWorker.mainLoop`. Use custom `WorkerM` as well
-/ as custom `handleNotification`.
private def 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
/--
The main-loop.
-/ -/
--@[inherit_doc Lean.Server.FileWorker.mainLoop]
partial def mainLoop : WorkerM Unit := do partial def mainLoop : WorkerM Unit := do
let ctx ← read let ctx ← read
let mut st ← StateT.lift get let mut st ← StateT.lift get
let msg ← ctx.hIn.readLspMessage let msg ← ctx.hIn.readLspMessage
let pendingRequests ← st.pendingRequests.foldM (fun acc id task => -- Erase finished tasks if there are no errors.
filterFinishedTasks acc id task) st.pendingRequests 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 } 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 for (id, seshRef) in st.rpcSessions do
let sesh ← seshRef.get let sesh ← seshRef.get
if (← sesh.hasExpired) then if (← sesh.hasExpired) then
st := { st with rpcSessions := st.rpcSessions.erase id } st := { st with rpcSessions := st.rpcSessions.erase id }
set st set st
-- Process the RPC-message and restart main-loop. -- Process the RPC-message and restart main-loop.
match msg with match msg with
| Message.request id "shutdown" none => | 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⟩ ctx.hOut.writeLspResponse ⟨id, Json.null⟩
mainLoop mainLoop
| Message.request id method (some params) => | Message.request id method (some params) =>
@ -633,6 +681,7 @@ partial def mainLoop : WorkerM Unit := do
| Message.notification "exit" none => | Message.notification "exit" none =>
let doc := st.doc let doc := st.doc
doc.cancelTk.set doc.cancelTk.set
doc.cmdSnaps.cancel
return () return ()
| Message.notification method (some params) => | Message.notification method (some params) =>
-- Custom notification handler -- Custom notification handler
@ -643,10 +692,15 @@ partial def mainLoop : WorkerM Unit := do
end MainLoop 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 def initAndRunWorker (i o e : FS.Stream) (opts : Options) (gameDir : String) : IO UInt32 := do
let i ← maybeTee "fwIn.txt" false i let i ← maybeTee "fwIn.txt" false i
let o ← maybeTee "fwOut.txt" true o let o ← maybeTee "fwOut.txt" true o
-- BIG MODIFICATION
let initRequest ← i.readLspRequestAs "initialize" Game.InitializeParams let initRequest ← i.readLspRequestAs "initialize" Game.InitializeParams
o.writeLspResponse { o.writeLspResponse {
id := initRequest.id id := initRequest.id
@ -662,16 +716,16 @@ def initAndRunWorker (i o e : FS.Stream) (opts : Options) (gameDir : String) : I
discard $ i.readLspNotificationAs "initialized" InitializedParams discard $ i.readLspNotificationAs "initialized" InitializedParams
let ⟨_, param⟩ ← i.readLspNotificationAs "textDocument/didOpen" DidOpenTextDocumentParams let ⟨_, param⟩ ← i.readLspNotificationAs "textDocument/didOpen" DidOpenTextDocumentParams
let doc := param.textDocument let doc := param.textDocument
/- NOTE(WN): `toFileMap` marks line beginnings as immediately following -- modification: using `.always`
"\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, .always⟩ let meta : DocumentMeta := ⟨doc.uri, doc.version, doc.text.toFileMap, .always⟩
let e := e.withPrefix s!"[{param.textDocument.uri}] " let e := e.withPrefix s!"[{param.textDocument.uri}] "
let _ ← IO.setStderr e let _ ← IO.setStderr e
try try
-- BIG MODIFICATION
let game ← loadGameData gameDir let game ← loadGameData gameDir
-- TODO: We misuse the `rootUri` field to the gameName -- TODO: We misuse the `rootUri` field to the gameName
let rootUri? : Option String := some (toString game.name) let rootUri? : Option String := some (toString game.name)
@ -691,6 +745,8 @@ def initAndRunWorker (i o e : FS.Stream) (opts : Options) (gameDir : String) : I
-- Run the main loop -- Run the main loop
let _ ← StateRefT'.run (s := st) <| ReaderT.run (r := ctx) <| let _ ← StateRefT'.run (s := st) <| ReaderT.run (r := ctx) <|
StateT.run (s := gameWorkerState) <| (mainLoop) StateT.run (s := gameWorkerState) <| (mainLoop)
return (0 : UInt32) return (0 : UInt32)
catch e => catch e =>
IO.eprintln e IO.eprintln e
@ -703,8 +759,12 @@ def initAndRunWorker (i o e : FS.Stream) (opts : Options) (gameDir : String) : I
/-- /--
The main function. Simply wrapping `initAndRunWorker`. 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. 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 def workerMain (opts : Options) (args : List String): IO UInt32 := do
let i ← IO.getStdin let i ← IO.getStdin
let o ← IO.getStdout let o ← IO.getStdout
@ -712,9 +772,6 @@ def workerMain (opts : Options) (args : List String): IO UInt32 := do
try try
let some gameDir := args[1]? | throwServerError "Expected second argument: gameDir" let some gameDir := args[1]? | throwServerError "Expected second argument: gameDir"
let exitCode ← initAndRunWorker i o e opts gameDir let exitCode ← initAndRunWorker i o e opts gameDir
-- 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 o.flush
e.flush e.flush
IO.Process.exit exitCode.toUInt8 IO.Process.exit exitCode.toUInt8

@ -1,62 +1,19 @@
import Lean.Widget.InteractiveGoal import GameServer.Structures
/-! /-!
This file is a modified copy of `Lean.Widget.InteractiveGoal`. This file is a modified copy of `Lean.Widget.InteractiveGoal`.
We add the structure `GameHint` and extend two existing structures: Note that the structures have been moved to `Structures.lean`, but most of the
functions here must be duplicated from `Lean.Widget.InteractiveGoal` in order
* `isAssumption?` in `InteractiveHypothesisBundle`: stores if a hypothesis is of type `Prop`. to use the duplicated structures.
* `hint` in `InteractiveGoal`: stores the game hints associated with the goal.
The rest of this file is simply copied to use these new modified stuctures.
-/ -/
namespace GameServer namespace GameServer
open Lean Lean.Widget Lean.Server open Lean Lean.Widget Lean.Server
/-- A hint in the game at the corresponding goal. -/
structure GameHint where
text : String
hidden : Bool
deriving FromJson, ToJson
/-- Extend the interactive hypothesis bundle with an option to distinguish
"assumptions" from "objects". "Assumptions" ate hyptheses of type `Prop`. -/
-- @[inherit_doc Lean.Widget.InteractiveHypothesisBundle]
structure InteractiveHypothesisBundle extends Lean.Widget.InteractiveHypothesisBundle where
/-- The hypothesis's type is of type `Prop` -/
isAssumption? : Option Bool := none
deriving RpcEncodable
-- duplicated but with custom `InteractiveHypothesisBundle`
@[inherit_doc Lean.Widget.InteractiveGoalCore]
structure InteractiveGoalCore where
hyps : Array InteractiveHypothesisBundle
type : CodeWithInfos
ctx : WithRpcRef Elab.ContextInfo
-- duplicated but with custom `InteractiveGoalCore` and extended by `hints`
@[inherit_doc Lean.Widget.InteractiveGoal]
structure InteractiveGoal extends InteractiveGoalCore where
userName? : Option String
goalPrefix : String
mvarId : MVarId
isInserted? : Option Bool := none
isRemoved? : Option Bool := none
/-- Extended the `InteractiveGoal` by an array of hints at that goal. -/
hints : Array GameHint := #[]
deriving RpcEncodable
-- duplicated with custom `InteractiveGoalCore`
@[inherit_doc Lean.Widget.InteractiveTermGoal]
structure InteractiveTermGoal extends InteractiveGoalCore where
range : Lsp.Range
term : WithRpcRef Elab.TermInfo
deriving RpcEncodable
-- duplicated with custom `InteractiveGoalCore` -- duplicated with custom `InteractiveGoalCore`
@[inherit_doc Lean.Widget.InteractiveGoalCore.pretty] -- @[inherit_doc Lean.Widget.InteractiveGoalCore.pretty]
def InteractiveGoalCore.pretty (g : InteractiveGoalCore) (userName? : Option String) def InteractiveGoalCore.pretty (g : InteractiveGoalCore) (userName? : Option String)
(goalPrefix : String) : Format := Id.run do (goalPrefix : String) : Format := Id.run do
let indent := 2 -- Use option let indent := 2 -- Use option
@ -140,10 +97,9 @@ def withGoalCtx (goal : MVarId) (action : LocalContext → MetavarDecl → n α)
open Meta in open Meta in
-- Copied from `Lean.Widget.goalToInteractive` but added -- Duplicated from `Lean.Widget.goalToInteractive` with custom structures
-- argument `hint` which is simply passed along.
@[inherit_doc Lean.Widget.goalToInteractive] @[inherit_doc Lean.Widget.goalToInteractive]
def goalToInteractive (mvarId : MVarId) (hints : Array GameHint): MetaM InteractiveGoal := do def goalToInteractive (mvarId : MVarId) : MetaM InteractiveGoal := do
let ppAuxDecls := pp.auxDecls.get (← getOptions) let ppAuxDecls := pp.auxDecls.get (← getOptions)
let ppImplDetailHyps := pp.implementationDetailHyps.get (← getOptions) let ppImplDetailHyps := pp.implementationDetailHyps.get (← getOptions)
let showLetValues := pp.showLetValues.get (← getOptions) let showLetValues := pp.showLetValues.get (← getOptions)
@ -165,9 +121,6 @@ def goalToInteractive (mvarId : MVarId) (hints : Array GameHint): MetaM Interact
else else
match localDecl with match localDecl with
| LocalDecl.cdecl _index fvarId varName type _ _ => | LocalDecl.cdecl _index fvarId varName type _ _ =>
-- We rely on the fact that `withGoalCtx` runs `LocalContext.sanitizeNames`,
-- so the `userName`s of local hypotheses are already pretty-printed
-- and it suffices to simply `toString` them.
let varName := toString varName let varName := toString varName
let type ← instantiateMVars type let type ← instantiateMVars type
if prevType? == none || prevType? == some type then if prevType? == none || prevType? == some type then
@ -197,8 +150,6 @@ def goalToInteractive (mvarId : MVarId) (hints : Array GameHint): MetaM Interact
userName? userName?
goalPrefix := getGoalPrefix mvarDecl goalPrefix := getGoalPrefix mvarDecl
mvarId mvarId
-- Added:
hints
} }
end GameServer end GameServer

@ -7,7 +7,6 @@ open Widget
open RequestM open RequestM
open Meta open Meta
/-! ## GameGoal -/ /-! ## GameGoal -/
namespace GameServer namespace GameServer
@ -113,10 +112,10 @@ def evalHintMessage : Expr → MetaM (Array Expr → MessageData) := fun _ => pu
open Meta in open Meta in
/-- Find all hints whose trigger matches the current goal -/ /-- Find all hints whose trigger matches the current goal -/
def findHints (goal : MVarId) (doc : FileWorker.EditableDocument) (initParams : Lsp.InitializeParams) : MetaM (Array GameHint) := do def findHints (goal : MVarId) (m : DocumentMeta) (initParams : Lsp.InitializeParams) : MetaM (Array GameHint) := do
goal.withContext do goal.withContext do
let some level ← getLevelByFileName? initParams doc.meta.mkInputContext.fileName let some level ← getLevelByFileName? initParams m.mkInputContext.fileName
| throwError "Level not found: {doc.meta.mkInputContext.fileName}" | throwError "Level not found: {m.mkInputContext.fileName}"
let hints ← level.hints.filterMapM fun hint => do let hints ← level.hints.filterMapM fun hint => do
openAbstractCtxResult hint.goal fun hintFVars hintGoal => do openAbstractCtxResult hint.goal fun hintFVars hintGoal => do
if let some fvarBij := matchExpr (← instantiateMVars $ hintGoal) (← instantiateMVars $ ← inferType $ mkMVar goal) if let some fvarBij := matchExpr (← instantiateMVars $ hintGoal) (← instantiateMVars $ ← inferType $ mkMVar goal)
@ -134,8 +133,212 @@ def findHints (goal : MVarId) (doc : FileWorker.EditableDocument) (initParams :
return none return none
return hints return hints
/-- Get the line that ends in `pos`. Note that `pos` is expected to be the
position of a `\n` but this is not enforced. -/
def _root_.Lean.FileMap.getLineBefore (fmap : FileMap) (pos : String.Pos) : String := Id.run do
match fmap.positions.findIdx? (· == pos) with
| none =>
panic s!"Position {pos} is not a newline character in " ++
s!"the following string: '{fmap.source}'!"
| some 0 =>
-- the first entry of `positions` is always `0`
return ""
| some (i + 1) =>
let line : Substring := ⟨fmap.source, fmap.positions.get! i, pos⟩
return line.toString
/-- Returns the `List` without the last element. -/
def _root_.List.dropBack {α : Type _} : List α → List α
| [] => []
| _ :: [] => []
| x :: xs => x :: xs.dropBack
/-- Trim empty lines from the file and add a single newline. -/
def _root_.Lean.FileMap.trim (fmap : FileMap) : FileMap :=
let str := match fmap.source.trim with
| "" => ""
| s => s ++ "\n"
FileMap.ofString str
/-- Returns the `Array` without the last element. -/
def _root_.Array.dropBack {α : Type _} (a : Array α) : Array α := ⟨a.data.dropBack⟩
/-- Add custom diagnostics about whether the level is completed. -/
def addCompletionDiagnostics (diag : Array InteractiveDiagnostic) (goals : Array InteractiveGoalWithHints)
(pos : Lsp.Position) (prevGoalCount : Nat) : RequestM <| Array InteractiveDiagnostic := do
let mut out : Array InteractiveDiagnostic := diag
if goals.size == 0 then
if diag.any (·.severity? == some .error) then
pure ()
else if diag.any (·.severity? == some .warning) then
out := out.push {
message := .text "level completed with warnings. 🎭"
range := {
start := pos
«end» := pos
}
severity? := Lsp.DiagnosticSeverity.information }
else
out := out.push {
message := .text "level completed! 🎉"
range := {
start := pos
«end» := pos
}
severity? := Lsp.DiagnosticSeverity.information }
else if goals.size < prevGoalCount then
out := out.push {
message := .text "intermediate goal solved! 🎉"
range := {
start := pos
«end» := pos
}
severity? := Lsp.DiagnosticSeverity.information
}
return out
-- diagsAtPos := if goalsAtPos.size < intermediateGoalCount then
-- diagsAtPos.push {
-- message := .text "intermediate goal solved 🎉"
-- range := {
-- start := lspPosAt
-- «end» := lspPosAt
-- }
-- severity? := Lsp.DiagnosticSeverity.information
-- }
-- else diagsAtPos
/-- Request that returns the goals at the end of each line of the tactic proof
plus the diagnostics (i.e. warnings/errors) for the proof.
-/
def getProofState (_ : Lsp.PlainGoalParams) : RequestM (RequestTask (Option ProofState)) := do
let doc ← readDoc
let rc ← readThe RequestContext
let text := doc.meta.text.trim
-- BUG: trimming here is a problem, since the snap might already be evaluated before
-- the trimming and then the positions don't match anymore :((
withWaitFindSnap
doc
-- TODO (Alex): I couldn't find a good condition to find the correct snap. So we are looking
-- for the first snap with goals here.
-- NOTE (Jon): The entire proof is in one snap, so hoped that Position `0` is good enough.
(fun snap => ¬ (snap.infoTree.goalsAt? doc.meta.text 0).isEmpty)
(notFoundX := return none)
fun snap => do
-- `snap` is the one snapshot containing the entire proof.
let mut steps : Array <| InteractiveGoalsWithHints := #[]
-- Question: Is there a difference between the diags of this snap and the last snap?
-- Should we get the diags from there?
-- Answer: The last snap only copied the diags from the end of this snap
let mut diag : Array InteractiveDiagnostic := snap.interactiveDiags.toArray
let mut intermediateGoalCount := 0
-- Drop the last position as we ensured that there is always a newline at the end
for pos in text.positions.dropBack do
-- iterate over all newlines in the proof and get the goals and hints at each position
-- TODO: we drop the last position because we always have a newline. Would be better
-- to trim newlines instead before submitting!
let source := text.getLineBefore pos
if let goalsAtResult@(_ :: _) := snap.infoTree.goalsAt? doc.meta.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 doc.meta rc.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 goalsAtPos : Array InteractiveGoalWithHints := ⟨goalAtPos.foldl (· ++ ·) []⟩
-- diags are labeled in Lsp-positions, which differ from the lean-internal
-- positions by `1`.
let lspPosAt := text.utf8PosToLspPos pos
let mut diagsAtPos : Array InteractiveDiagnostic :=
-- `+1` for getting the errors after the line.
diag.filter (·.range.start.line + 1 == lspPosAt.line)
diagsAtPos ← addCompletionDiagnostics diagsAtPos goalsAtPos lspPosAt intermediateGoalCount
intermediateGoalCount := goalsAtPos.size
steps := steps.push ⟨goalsAtPos, source, diagsAtPos, lspPosAt.line, lspPosAt.character⟩
else
-- No goals present
steps := steps.push default
-- // if (goals.length && goalCount > goals.length) {
-- // messages.unshift({
-- // range: {
-- // start: {
-- // line: i-1,
-- // character: 0,
-- // },
-- // end: {
-- // line: i-1,
-- // character: 0,
-- // }},
-- // severity: DiagnosticSeverity.Information,
-- // message: {
-- // text: 'intermediate goal solved 🎉'
-- // }
-- // })
-- // }
-- Level is completed if there are no errrors or warnings
let completedWithWarnings : Bool := ¬ diag.any (·.severity? == some .error)
let completed : Bool := completedWithWarnings ∧ ¬ diag.any (·.severity? == some .warning)
-- Filter out the "unsolved goals" message
diag := diag.filter (fun d => match d.message with
| .append ⟨(.text x) :: _⟩ => x != "unsolved goals"
| _ => true)
let lastPos := text.utf8PosToLspPos text.positions.back
let remainingDiags : Array InteractiveDiagnostic :=
diag.filter (fun d => d.range.start.line >= lastPos.line)
return some {
steps := steps,
diagnostics := remainingDiags,
completed := completed,
completedWithWarnings := completedWithWarnings,
lastPos := lastPos.line
}
open RequestM in open RequestM in
def getInteractiveGoals (p : Lsp.PlainGoalParams) : RequestM (RequestTask (Option InteractiveGoals)) := do
def getInteractiveGoals (p : Lsp.PlainGoalParams) : RequestM (RequestTask (Option <| InteractiveGoals)) := do
let doc ← readDoc let doc ← readDoc
let rc ← readThe RequestContext let rc ← readThe RequestContext
let text := doc.meta.text let text := doc.meta.text
@ -145,7 +348,7 @@ def getInteractiveGoals (p : Lsp.PlainGoalParams) : RequestM (RequestTask (Optio
withWaitFindSnap doc (fun s => ¬ (s.infoTree.goalsAt? doc.meta.text hoverPos).isEmpty) withWaitFindSnap doc (fun s => ¬ (s.infoTree.goalsAt? doc.meta.text hoverPos).isEmpty)
(notFoundX := return none) fun snap => do (notFoundX := return none) fun snap => do
if let rs@(_ :: _) := snap.infoTree.goalsAt? doc.meta.text hoverPos then 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 goals : List <| Array InteractiveGoal ← rs.mapM fun { ctxInfo := ci, tacticInfo := ti, useAfter := useAfter, .. } => do
let ciAfter := { ci with mctx := ti.mctxAfter } let ciAfter := { ci with mctx := ti.mctxAfter }
let ci := if useAfter then ciAfter else { ci with mctx := ti.mctxBefore } let ci := if useAfter then ciAfter else { ci with mctx := ti.mctxBefore }
-- compute the interactive goals -- compute the interactive goals
@ -153,8 +356,8 @@ def getInteractiveGoals (p : Lsp.PlainGoalParams) : RequestM (RequestTask (Optio
return List.toArray <| if useAfter then ti.goalsAfter else ti.goalsBefore return List.toArray <| if useAfter then ti.goalsAfter else ti.goalsBefore
let goals ← ci.runMetaM {} do let goals ← ci.runMetaM {} do
goals.mapM fun goal => do goals.mapM fun goal => do
let hints ← findHints goal doc rc.initParams -- let hints ← findHints goal doc.meta rc.initParams
return ← goalToInteractive goal hints return ← goalToInteractive goal
-- compute the goal diff -- compute the goal diff
-- let goals ← ciAfter.runMetaM {} (do -- let goals ← ciAfter.runMetaM {} (do
-- try -- try
@ -163,8 +366,8 @@ def getInteractiveGoals (p : Lsp.PlainGoalParams) : RequestM (RequestTask (Optio
-- -- fail silently, since this is just a bonus feature -- -- fail silently, since this is just a bonus feature
-- return goals -- return goals
-- ) -- )
return {goals} return goals
return some <| goals.foldl (· ++ ·) #[]⟩ return some <| goals.foldl (· ++ ·) #[]⟩
else else
return none return none
@ -172,7 +375,16 @@ builtin_initialize
registerBuiltinRpcProcedure registerBuiltinRpcProcedure
`Game.getInteractiveGoals `Game.getInteractiveGoals
Lsp.PlainGoalParams Lsp.PlainGoalParams
(Option InteractiveGoals) (Option <| InteractiveGoals
)
getInteractiveGoals getInteractiveGoals
builtin_initialize
registerBuiltinRpcProcedure
`Game.getProofState
Lsp.PlainGoalParams
(Option ProofState)
getProofState
end GameServer end GameServer

@ -0,0 +1,93 @@
import Lean.Widget.InteractiveGoal
import Lean.Widget.InteractiveDiagnostic
import Lean.Data.Lsp.Diagnostics
/-!
This file contains the custom data structures use by the server.
Some of them overwrite built-in structures from Lean.
In particular, the structures from `Lean.Widget.InteractiveGoal` are duplicated with
the following extension:
* `isAssumption?` in `InteractiveHypothesisBundle`: stores if a hypothesis is of type `Prop`.
NOTE: Changes here need to be reflected in the corresponding `interface` in `rcp_api.ts`
on the client-side.
-/
open Lean Server Widget
namespace GameServer
/-- Extend the interactive hypothesis bundle with an option to distinguish
"assumptions" from "objects". "Assumptions" are hypotheses of type `Prop`. -/
-- @[inherit_doc Lean.Widget.InteractiveHypothesisBundle]
structure InteractiveHypothesisBundle extends Lean.Widget.InteractiveHypothesisBundle where
/-- The hypothesis's type is of type `Prop` -/
isAssumption? : Option Bool := none
deriving RpcEncodable
-- duplicated but with custom `InteractiveHypothesisBundle`
@[inherit_doc Lean.Widget.InteractiveGoalCore]
structure InteractiveGoalCore where
hyps : Array InteractiveHypothesisBundle
type : CodeWithInfos
ctx : WithRpcRef Elab.ContextInfo
-- duplicated but with custom `InteractiveGoalCore`
@[inherit_doc Lean.Widget.InteractiveGoal]
structure InteractiveGoal extends InteractiveGoalCore where
userName? : Option String
goalPrefix : String
mvarId : MVarId
isInserted? : Option Bool := none
isRemoved? : Option Bool := none
deriving RpcEncodable
-- duplicated with custom `InteractiveGoalCore`
@[inherit_doc Lean.Widget.InteractiveTermGoal]
structure InteractiveTermGoal extends InteractiveGoalCore where
range : Lsp.Range
term : WithRpcRef Elab.TermInfo
deriving RpcEncodable
/-- A hint in the game at the corresponding goal. -/
structure GameHint where
text : String
hidden : Bool
deriving FromJson, ToJson
/-- Bundled `InteractiveGoal` together with an array of hints that apply at this stage. -/
structure InteractiveGoalWithHints where
goal : InteractiveGoal
/-- Extended the `InteractiveGoal` by an array of hints at that goal. -/
hints : Array GameHint
deriving RpcEncodable
structure InteractiveGoalsWithHints where
goals : Array InteractiveGoalWithHints
/-- The content of the line evaluated. -/
command : String
diags : Array InteractiveDiagnostic := default
line : Option Nat -- only for debugging
column : Option Nat -- only for debugging
deriving RpcEncodable
instance : Inhabited InteractiveGoalsWithHints := ⟨default, default, default, none, none⟩
/-- Collected goals throughout the proof. Used for communication with the game client. -/
structure ProofState where
/-- goals after each line. includes the hints. -/
steps : Array <| InteractiveGoalsWithHints
/-- diagnostics contains all errors and warnings.
TODO: I think they contain information about which line they belong to. Verify this.
-/
diagnostics : Array InteractiveDiagnostic := default
/-- Whether the level is considered solved. -/
completed : Bool
completedWithWarnings : Bool
lastPos : Nat -- only for debugging
deriving RpcEncodable

@ -9,6 +9,24 @@
"manifestFile": "lake-manifest.json", "manifestFile": "lake-manifest.json",
"inputRev": "v4.5.0", "inputRev": "v4.5.0",
"inherited": false, "inherited": false,
"configFile": "lakefile.lean"},
{"url": "https://github.com/leanprover/lean4-cli",
"type": "git",
"subDir": null,
"rev": "a751d21d4b68c999accb6fc5d960538af26ad5ec",
"name": "Cli",
"manifestFile": "lake-manifest.json",
"inputRev": "main",
"inherited": true,
"configFile": "lakefile.lean"},
{"url": "https://github.com/leanprover-community/import-graph",
"type": "git",
"subDir": null,
"rev": "a6b484980b41aab874fb3113ec5245bd91b625d8",
"name": "importGraph",
"manifestFile": "lake-manifest.json",
"inputRev": "v4.4.0",
"inherited": false,
"configFile": "lakefile.lean"}], "configFile": "lakefile.lean"}],
"name": "GameServer", "name": "GameServer",
"lakeDir": ".lake"} "lakeDir": ".lake"}

@ -8,6 +8,8 @@ def leanVersion : String := s!"v{Lean.versionString}"
require std from git "https://github.com/leanprover/std4.git" @ leanVersion require std from git "https://github.com/leanprover/std4.git" @ leanVersion
require importGraph from git "https://github.com/leanprover-community/import-graph" @ leanVersion
lean_lib GameServer lean_lib GameServer
@[default_target] @[default_target]

Loading…
Cancel
Save