[web] Added Web REPL server and frontend demo

This commit is contained in:
Sidi Liang 2024-11-05 01:25:09 +08:00
parent 068da2fb41
commit e6b88c9dec
No known key found for this signature in database
GPG Key ID: 9785F5EECFFA5311
3 changed files with 665 additions and 0 deletions

View File

@ -0,0 +1,240 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nasal Interpreter Web REPL</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/codemirror.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/theme/monokai.min.css" rel="stylesheet">
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
margin: 0;
padding: 20px;
background-color: #2c3e50;
color: #ecf0f1;
}
.container {
max-width: 1000px;
margin: 0 auto;
}
.header {
text-align: center;
margin-bottom: 20px;
}
.header h1 {
font-size: 2.5em;
margin-bottom: 10px;
}
.repl-container {
background: #34495e;
padding: 20px;
border-radius: 8px;
height: 600px;
display: flex;
flex-direction: column;
}
.repl-output {
flex-grow: 1;
background: #21252b;
color: #abb2bf;
padding: 10px;
border-radius: 4px;
margin-bottom: 10px;
overflow-y: auto;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
line-height: 1.5;
white-space: pre-wrap;
}
.repl-input-container {
display: flex;
align-items: flex-start;
}
.repl-prompt {
color: #3498db;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
padding: 5px;
user-select: none;
}
.repl-input {
flex-grow: 1;
background: #21252b;
border: none;
color: #abb2bf;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 14px;
padding: 5px;
outline: none;
resize: none;
min-height: 24px;
overflow-y: hidden;
}
.controls {
text-align: center;
margin-top: 20px;
}
button {
background-color: #3498db;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
transition: background-color 0.3s;
margin-right: 10px;
}
button:hover {
background-color: #2980b9;
}
button:disabled {
background-color: #bdc3c7;
cursor: not-allowed;
}
.examples {
margin-top: 20px;
text-align: center;
}
.example-btn {
background-color: #27ae60;
margin: 0 5px;
}
.example-btn:hover {
background-color: #219a52;
}
.output-line {
margin: 2px 0;
min-height: 1.2em;
}
.input-line {
color: #3498db;
white-space: pre;
}
.error-line {
color: #e74c3c;
white-space: pre;
}
.result-line {
color: #2ecc71;
white-space: pre;
margin-left: 4px; /* Slight indent for results */
}
.system-message {
color: #7f8c8d;
font-style: italic;
}
.help-text {
color: #95a5a6;
white-space: pre;
font-family: monospace;
}
.error-type {
color: #ff5f5f;
font-weight: bold;
}
.error-desc {
color: #abb2bf;
font-weight: bold;
}
.error-arrow, .error-line-number {
color: #56b6c2;
font-weight: bold;
}
.error-file {
color: #ff5f5f;
font-weight: bold;
}
.error-code {
color: #abb2bf;
}
.error-pointer-space {
white-space: pre;
}
.error-pointer {
color: #ff5f5f;
font-weight: bold;
}
.error-red-bold {
color: #e06c75;
font-weight: bold;
}
.error-cyan-bold {
color: #56b6c2;
font-weight: bold;
}
.error-bold {
font-weight: bold;
}
@media (max-width: 768px) {
.repl-container {
height: 400px;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Nasal Interpreter Web REPL</h1>
<p>Interactive Read-Eval-Print Loop for Nasal</p>
<p>Powered by ValKmjolnir's <a href="https://www.fgprc.org.cn/nasal_interpreter.html">Nasal Interpreter</a>, Web App by <a href="https://sidi762.github.io">LIANG Sidi</a></p>
</div>
<div class="repl-container">
<div id="repl-output" class="repl-output">
<div class="output-line">Welcome to Nasal Web REPL Demo!</div>
</div>
<div class="repl-input-container">
<span class="repl-prompt">>>></span>
<textarea id="repl-input" class="repl-input" rows="1"
placeholder="Enter Nasal code here..."></textarea>
</div>
</div>
<div class="controls">
<button onclick="clearREPL()">Clear REPL</button>
</div>
<div class="examples">
<h3>Example Commands:</h3>
<button class="example-btn" onclick="insertExample('basic')">Basic Math</button>
<button class="example-btn" onclick="insertExample('loops')">Loops</button>
<button class="example-btn" onclick="insertExample('functions')">Functions</button>
</div>
</div>
<script src="repl.js"></script>
</body>
</html>

View File

@ -0,0 +1,249 @@
let replSessionId = null;
let multilineInput = [];
let historyIndex = -1;
let commandHistory = [];
let multilineBuffer = [];
let isMultilineMode = false;
// Initialize REPL
async function initRepl() {
try {
const response = await fetch('/repl/init', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const data = await response.json();
if (data.sessionId) {
replSessionId = data.sessionId;
console.log('REPL session initialized:', replSessionId);
// Display version info
appendOutput('Nasal REPL interpreter version ' + data.version);
appendOutput('Type your code below and press Enter to execute.');
appendOutput('Use Shift+Enter for multiline input.\n');
showPrompt();
} else {
throw new Error('Failed to initialize REPL session');
}
} catch (err) {
appendOutput(`Error: ${err.message}`, 'error-line');
}
}
// Format error messages to match command-line REPL
function formatError(error) {
// Split the error message into lines
const lines = error.split('\n');
return lines.map(line => {
// Add appropriate indentation for the error pointer
if (line.includes('-->')) {
return ' ' + line;
} else if (line.includes('^')) {
return ' ' + line;
}
return line;
}).join('\n');
}
// Handle input
const input = document.getElementById('repl-input');
input.addEventListener('keydown', async (e) => {
if (e.key === 'Enter') {
if (e.shiftKey) {
// Shift+Enter: add newline
const pos = input.selectionStart;
const value = input.value;
input.value = value.substring(0, pos) + '\n' + value.substring(pos);
input.selectionStart = input.selectionEnd = pos + 1;
input.style.height = 'auto';
input.style.height = input.scrollHeight + 'px';
e.preventDefault();
return;
}
e.preventDefault();
const code = input.value.trim();
// Skip empty lines but still show prompt
if (!code) {
showPrompt(isMultilineMode ? '... ' : '>>> ');
return;
}
try {
// First check if input is complete
const checkResponse = await fetch('/repl/eval', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sessionId: replSessionId,
line: code,
check: true,
buffer: multilineBuffer // Send existing buffer
})
});
const checkData = await checkResponse.json();
if (checkData.needsMore) {
// Add to multiline buffer and show continuation prompt
multilineBuffer.push(code);
isMultilineMode = true;
// Display the input with continuation prompt
appendOutput(code, 'input-line', multilineBuffer.length === 1 ? '>>> ' : '... ');
input.value = '';
showPrompt('... ');
return;
}
// If we were in multiline mode, add the final line
if (isMultilineMode) {
multilineBuffer.push(code);
}
// Get the complete code to evaluate
const fullCode = isMultilineMode ?
multilineBuffer.join('\n') : code;
// Display the input
appendOutput(code, 'input-line', isMultilineMode ? '... ' : '>>> ');
// Evaluate the code
const response = await fetch('/repl/eval', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sessionId: replSessionId,
line: fullCode
})
});
const data = await response.json();
if (data.error) {
appendOutput(formatError(data.error.trim()), 'error-line');
} else if (data.result) {
handleResult(data.result);
}
// Reset multiline state
multilineBuffer = [];
isMultilineMode = false;
input.value = '';
showPrompt('>>> ');
} catch (err) {
appendOutput(`Error: ${err.message}`, 'error-line');
multilineBuffer = [];
isMultilineMode = false;
input.value = '';
showPrompt('>>> ');
}
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (historyIndex > 0) {
historyIndex--;
input.value = commandHistory[historyIndex];
}
} else if (e.key === 'ArrowDown') {
e.preventDefault();
if (historyIndex < commandHistory.length - 1) {
historyIndex++;
input.value = commandHistory[historyIndex];
} else {
historyIndex = commandHistory.length;
input.value = '';
}
}
});
// Auto-resize input
input.addEventListener('input', () => {
input.style.height = 'auto';
input.style.height = input.scrollHeight + 'px';
});
// Show prompt and scroll to bottom
function showPrompt(prompt = '>>> ') {
const promptSpan = document.querySelector('.repl-prompt');
if (promptSpan) {
promptSpan.textContent = prompt;
}
}
// Append output to REPL
function appendOutput(text, className = '', prefix = '') {
const output = document.getElementById('repl-output');
const line = document.createElement('div');
line.className = `output-line ${className}`;
line.innerHTML = prefix + formatErrorMessage(text);
output.appendChild(line);
output.scrollTop = output.scrollHeight;
}
// Clear REPL
function clearREPL() {
const output = document.getElementById('repl-output');
output.innerHTML = '';
appendOutput('Screen cleared', 'system-message');
showPrompt();
}
// Example snippets
const examples = {
basic: `var x = 1011 + 1013;
println("x = ", x);`,
loops: `var sum = 0;
for(var i = 1; i <= 5; i += 1) {
sum += i;
}
println("Sum:", sum);`,
functions: `var factorial = func(n) {
if (n <= 1) return 1;
return n * factorial(n - 1);
};
factorial(5);`
};
// Insert example into input
function insertExample(type) {
input.value = examples[type];
input.style.height = 'auto';
input.style.height = input.scrollHeight + 'px';
input.focus();
}
// Initialize REPL on page load
window.addEventListener('load', initRepl);
// Add these utility functions
function formatErrorMessage(text) {
// Replace ANSI escape codes with CSS classes
return text
// Remove any existing formatting first
.replace(/\u001b\[\d+(?:;\d+)*m/g, '')
// Format the error line
.replace(/^parse: (.+)$/m, '<span class="error-type">parse:</span> <span class="error-desc">$1</span>')
// Format the file location
.replace(/^\s*--> (.+?)(\d+):(\d+)$/m, '<span class="error-arrow">--></span> <span class="error-file">$1</span><span class="error-loc">$2:$3</span>')
// Format the code line
.replace(/^(\d+ \|)(.*)$/m, '<span class="error-line-number">$1</span><span class="error-code">$2</span>')
// Format the error pointer
.replace(/^(\s*\|)(\s*)(\^+)$/m, '<span class="error-line-number">$1</span><span class="error-pointer-space">$2</span><span class="error-pointer">$3</span>');
}
function handleResult(result) {
const lines = result.split('\n');
lines.forEach(line => {
if (line.trim()) {
appendOutput(line.trim(), 'result-line');
}
});
}

View File

@ -0,0 +1,176 @@
const express = require('express');
const ffi = require('ffi-napi');
const path = require('path');
const yargs = require('yargs/yargs');
const { hideBin } = require('yargs/helpers');
// Parse command line arguments
const argv = yargs(hideBin(process.argv))
.usage('Usage: $0 [options]')
.option('verbose', {
alias: 'v',
type: 'boolean',
description: 'Run with verbose logging'
})
.option('port', {
alias: 'p',
type: 'number',
description: 'Port to run the server on',
default: 3001
})
.option('host', {
type: 'string',
description: 'Host to run the server on',
default: 'localhost'
})
.help()
.alias('help', 'h')
.version()
.argv;
const app = express();
app.use(express.json());
app.use(express.static('public'));
// Load Nasal REPL library functions
const nasalLib = ffi.Library(path.join(__dirname, '../module/libnasal-web'), {
'nasal_repl_init': ['pointer', []],
'nasal_repl_cleanup': ['void', ['pointer']],
'nasal_repl_eval': ['string', ['pointer', 'string']],
'nasal_repl_is_complete': ['int', ['pointer', 'string']],
'nasal_repl_get_version': ['string', []],
});
// Store active REPL sessions
const replSessions = new Map();
// Clean up inactive sessions periodically (30 minutes timeout)
const SESSION_TIMEOUT = 30 * 60 * 1000;
setInterval(() => {
const now = Date.now();
for (const [sessionId, session] of replSessions.entries()) {
if (now - session.lastAccess > SESSION_TIMEOUT) {
if (argv.verbose) {
console.log(`Cleaning up inactive session: ${sessionId}`);
}
nasalLib.nasal_repl_cleanup(session.context);
replSessions.delete(sessionId);
}
}
}, 60000); // Check every minute
app.post('/repl/init', (req, res) => {
try {
const ctx = nasalLib.nasal_repl_init();
const sessionId = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const version = nasalLib.nasal_repl_get_version();
replSessions.set(sessionId, {
context: ctx,
lastAccess: Date.now()
});
if (argv.verbose) {
console.log(`New REPL session initialized: ${sessionId}`);
}
res.json({
sessionId,
version
});
} catch (err) {
if (argv.verbose) {
console.error('Failed to initialize REPL session:', err);
}
res.status(500).json({ error: 'Failed to initialize REPL session' });
}
});
app.post('/repl/eval', (req, res) => {
const { sessionId, line, check, buffer } = req.body;
if (!sessionId || !replSessions.has(sessionId)) {
return res.status(400).json({ error: 'Invalid or expired session' });
}
if (!line) {
return res.status(400).json({ error: 'No code provided' });
}
try {
const session = replSessions.get(sessionId);
session.lastAccess = Date.now();
if (check) {
const codeToCheck = buffer ? [...buffer, line].join('\n') : line;
const isComplete = nasalLib.nasal_repl_is_complete(session.context, codeToCheck);
if (isComplete === 1) {
return res.json({ needsMore: true });
} else if (isComplete === -1) {
return res.json({ error: 'Invalid input' });
}
}
const result = nasalLib.nasal_repl_eval(session.context, line);
if (argv.verbose) {
console.log(`REPL evaluation for session ${sessionId}:`, { line, result });
}
res.json({ result });
} catch (err) {
if (argv.verbose) {
console.error(`REPL evaluation error for session ${sessionId}:`, err);
}
res.status(500).json({ error: err.message });
}
});
app.post('/repl/cleanup', (req, res) => {
const { sessionId } = req.body;
if (!sessionId || !replSessions.has(sessionId)) {
return res.status(400).json({ error: 'Invalid session' });
}
try {
const session = replSessions.get(sessionId);
nasalLib.nasal_repl_cleanup(session.context);
replSessions.delete(sessionId);
if (argv.verbose) {
console.log(`REPL session cleaned up: ${sessionId}`);
}
res.json({ message: 'Session cleaned up successfully' });
} catch (err) {
if (argv.verbose) {
console.error(`Failed to cleanup session ${sessionId}:`, err);
}
res.status(500).json({ error: err.message });
}
});
// Handle cleanup on server shutdown
process.on('SIGINT', () => {
console.log('\nCleaning up REPL sessions before exit...');
for (const [sessionId, session] of replSessions.entries()) {
try {
nasalLib.nasal_repl_cleanup(session.context);
if (argv.verbose) {
console.log(`Cleaned up session: ${sessionId}`);
}
} catch (err) {
console.error(`Error cleaning up session ${sessionId}:`, err);
}
}
process.exit(0);
});
const PORT = argv.port || 3001;
app.listen(PORT, () => {
console.log(`REPL Server running on http://localhost:${PORT}`);
if (argv.verbose) console.log('Verbose logging enabled');
});