SE212 is a course at the University of Waterloo on formal logic, proof systems and styles, rudimentary model theory, logic-based specification, correctness proofs, and their applications in software engineering.
It has a custom language called George. Here's an example of a natural deduction proof:
1#check ND23forall x . P(x) => Q(x), exists x . P(x) |- exists x . Q(x)451) forall x . P(x) => Q(x) premise62) exists x. P(x) premise73) for some xu assume P(xu) {8 4) P(xu) => Q(xu) by forall_e on 19 5) Q(xu) by imp_e on 3,410 6) exists x . Q(x) by exists_i on 511 }127) exists x . Q(x) by exists_e on 2, 3-6
A few students over the past few years created 2 editors for George: Boole and James. They're both a great step up from plaintext editing since they come with syntax highlighting, autocomplete, a file explorer, and a few other features.
While these options are fine, I found it evident that there's a ton of improvements and features that could be added to significantly improve the experience. Thankfully I've gotten pretty familiar with editors, especially with Monaco (the same library that VSCode uses), since I built Sandbox last summer.
The frontend is a Next.js app that communicates with the backend, which runs both an HTTP server and a socket.io server. The database is SQLite.
The editor in Axiom uses the same library as VSCode, giving us familiar keyboard shortcuts and features out of the box. But how do we add rich support for a completely new language?
Luckily, the Monaco editor has a very detailed languages API.
monaco.languages.register
and setLanguageConfiguration
setMonarchTokensProvider
registerCompletionItemProvider
It'd be nice to have go-to-definition since rules reference line numbers (⌘+Click
), and Monaco has a definitions provider for this!
1 monaco.languages.registerDefinitionProvider("george", {2 provideDefinition: (model, position) => {3 // Specifically handles line references that appear after the "on" keyword4 // Supports both single line references (e.g. "1") and ranges (e.g. "6-12"),5 // returning their definitions within the current proof section6 },7 });
Let's focus specifically on line numbering. A few pain points:
For the first point, we can add a key handler for Enter
so new lines automatically have the incremented line number with correct indentation. The second point falls under the same key event, but we'll need to iterate through the lines and update them according to the change.
1 editor.onKeyDown((e) => {2 if (e.keyCode === monaco.KeyCode.Enter) {3 // Handle auto-indentation when cursor is between empty braces {}4 // Handle numbered lines (e.g., "1) some content")5 // Find section boundaries marked by #q, #u, #a, or #check6 // Search backwards for the nearest boundary7 // Search forwards for the next boundary8 // Find all sequential numbered lines that follow the current line9 // Insert new line with incremented number and update following lines10 // Update line numbers and references in all following lines11 }12 });
The third point is a little more tricky, as text deletion can be done in many ways, and handling every edge case is impractically complex. I chose to not have auto-decrementing by default, but rather making it more intentional through the shortcut ⌘+X
. This also decrements line number references in rules!
1editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyX, () => {2 // Store the selected text before deletion3 // Get current section boundaries4 // Find section boundaries5 // Update line references and numbers6 // Update line numbers for following lines7 // Update references in "on" statements89 // Push edits to execute all operations10 });
Lastly, we can add hover tooltips on rules with monaco.languages.registerHoverProvider
.
If you're interested in the full implementation, check out this file in the Github repo: frontend/lib/lang.ts
When building Sandbox, I used Liveblocks to handle real-time collaboration on the Monaco editor. This project, however, has real-time collaboration implemented from scratch with Y.js and Socket.io.
The collaboration system is based on Workspaces.
Workspaces are shared folders for multiplayer collaboration. You can create workspaces based on course assignments or projects. You can invite and manage collaborators. You can view your invitations in Settings.
The backend keeps track of active workspaces and connections in-memory, along with their collaborative documents.
1const workspaces = new Map<string, Workspace>();2const fileAwareness = new Map<string, Map<string, Map<string, AwarenessState>>>();3const clientFiles = new Map<string, { workspaceId: string; path: string }>();
The collaborative documents are from Y.js, and clients are kept in sync with a pub/sub architecture. The library has some useful utility functions for this, including encodeStateAsUpdate
and applyUpdate
. We also store awareness data (cursor position and highlights) for each file.
We're managing socket.io connections using rooms for workspaces.
1socket.on("joinRoom", async ({ workspaceId, path }) => {2 // Check if user has permission to access this workspace3 // Initialize workspace if needed4 // Load requested file5 // Send current doc state6 // Update client's current file7 // Send current awareness states for this file8});910socket.on("leaveRoom", ({ workspaceId }) => {11 // Remove client from room12 // Notify others that user left13 // Clean up awareness data & maps14 // Delete workspace in memory if no connected clients left15});
On the client side, we need to manage collaborative documents and synchronization with the server. It's important to be careful here since awareness data is linked to a document, not the parent workspace.
1export const setupCollaboration = async ({...}) => {2 // Join collaboration room for this specific file34 // Initialize Yjs document and text type56 // Synchronize initial state by:7 // 1. Getting existing document updates8 // 2. Fetching current file content910 // Apply synchronized state to the document1112 // Ensure document content matches server content13 // Set up awareness protocol for cursor positions and user presence14 // Initialize local user state with random cursor color15 // Bind Monaco editor to Yjs document with awareness16 // Handle incoming document updates from other users17 // Broadcast local document changes to other users18 // Broadcast awareness changes (cursor position, selection) to other users19 // Update cursor position in awareness when local user moves cursor20};
The settings modal has options to toggle autocomplete, accept suggestions on Enter
, intelligent line numbering features, and more. All major actions (proof checking, toggling panels, etc.) have keyboard shortcuts for maximum productivity.
There's also fully customizable theming, with support for both light and dark mode. Presets are coming soon!
The panels are fully resizable and collapsible. There's tabs to support opening multiple files, uploading, downloading, and much more.
The project is fully open source on Github. There's also a local-only deployment at se.ishaand.com.
Unfortunately, working with the University to deploy the project for my software engineering cohort took a little longer than expected, so it'll instead be ready for the next cohort and future students to come.
Thanks to those who helped with some parts along the way!