[web] Added Web REPL server and frontend demo
This commit is contained in:
parent
068da2fb41
commit
e6b88c9dec
|
@ -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>
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
}
|
|
@ -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');
|
||||
});
|
Loading…
Reference in New Issue