Skip to content

Commit 562f025

Browse files
authored
Basic expr-eval language service (#2)
* Basic language server * Minor improvements * Minor adjustments * Formatted and improvements * Converted to vscode types * Added test coverage and documentation * Remover parser export as it is not longer required and internal * Update language-service.ts * Created sample * Added working sample with eval
1 parent 0a450c6 commit 562f025

12 files changed

Lines changed: 1520 additions & 5 deletions

File tree

index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import { Expression } from './src/core/expression.js';
1313
import { Parser } from './src/parsing/parser.js';
14+
import { createLanguageService } from "./src/language-service";
1415

1516
// Re-export types for public API
1617
export type {
@@ -39,5 +40,6 @@ export {
3940

4041
export {
4142
Expression,
42-
Parser
43+
Parser,
44+
createLanguageService
4345
};

package-lock.json

Lines changed: 18 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,8 @@
6767
"build": "npm run build:js",
6868
"watch": "cross-env BUILD_TARGET=esm vite build --watch",
6969
"clean": "rimraf dist",
70-
"prepublish": "npm run build"
70+
"prepublish": "npm run build",
71+
"monaco-sample:serve": "npm run build:umd && node samples/language-service-sample/serve-sample.cjs"
7172
},
7273
"devDependencies": {
7374
"@eslint/js": "^9.15.0",
@@ -90,7 +91,8 @@
9091
"typescript-eslint": "^8.15.0",
9192
"vite": "^6.0.1",
9293
"vite-plugin-dts": "^4.3.0",
93-
"vitest": "^3.2.4"
94+
"vitest": "^3.2.4",
95+
"vscode-languageserver-types": "^3.17.5"
9496
},
9597
"bundlesize": [
9698
{
@@ -101,5 +103,9 @@
101103
"path": "./dist/index.mjs",
102104
"maxSize": "80kb"
103105
}
104-
]
106+
],
107+
"dependencies": {
108+
"vscode-languageserver-textdocument": "^1.0.12"
109+
},
110+
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
105111
}
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8"/>
5+
<meta name="viewport" content="width=device-width, initial-scale=1"/>
6+
<title>expr-eval + Monaco (minimal)</title>
7+
<style>
8+
html, body {
9+
height: 100%;
10+
margin: 0;
11+
padding: 0;
12+
}
13+
14+
#editor {
15+
width: 100%;
16+
height: 95vh;
17+
}
18+
19+
#status {
20+
height: 5vh;
21+
}
22+
</style>
23+
<script src="/dist/bundle.js"></script>
24+
<!-- Monaco loader from CDN -->
25+
<script src="https://cdn.jsdelivr.net/npm/monaco-editor@0.49.0/min/vs/loader.js"></script>
26+
</head>
27+
<body>
28+
<div id="editor"></div>
29+
<div id="status">No evaluations yet!</div>
30+
31+
<script>
32+
// Configure Monaco AMD base path
33+
require.config({paths: {'vs': 'https://cdn.jsdelivr.net/npm/monaco-editor@0.49.0/min/vs'}});
34+
35+
// Start Monaco
36+
require(['vs/editor/editor.main'], function () {
37+
const languageId = 'expr-eval';
38+
monaco.languages.register({id: languageId});
39+
40+
// Create editor with a sample model
41+
const initial = 'sum([1,2,3]) + max(2, 5)';
42+
const model = monaco.editor.createModel(initial, languageId);
43+
const editor = monaco.editor.create(document.getElementById('editor'), {
44+
model,
45+
theme: 'vs-dark',
46+
automaticLayout: true,
47+
fontSize: 14,
48+
minimap: {enabled: false}
49+
});
50+
51+
// Access expr-eval UMD - because it is a umd bundle, it will be available as window.exprEval
52+
const {createLanguageService, Parser} = window.exprEval || {};
53+
if (!createLanguageService) {
54+
console.error('expr-eval not found. Make sure /dist/bundle.js is built.');
55+
return;
56+
}
57+
58+
const ls = createLanguageService();
59+
60+
// Minimal lsp text document backed by Monaco model
61+
function makeTextDocument(m) {
62+
return {
63+
uri: m.uri.toString(),
64+
getText: () => m.getValue(),
65+
positionAt: (offset) => {
66+
const p = m.getPositionAt(offset);
67+
return {line: p.lineNumber - 1, character: p.column - 1};
68+
},
69+
offsetAt: (pos) => m.getOffsetAt(new monaco.Position(pos.line + 1, pos.character + 1))
70+
};
71+
}
72+
73+
function toLspPosition(mp) {
74+
return {line: mp.lineNumber - 1, character: mp.column - 1};
75+
}
76+
77+
function fromLspPosition(lp) {
78+
return new monaco.Position(lp.line + 1, lp.character + 1);
79+
}
80+
81+
// Simple variables demo (appear in completions and hover)
82+
const demoVars = {x: 42, user: {name: 'Ada'}, flag: true};
83+
84+
// Completions provider
85+
monaco.languages.registerCompletionItemProvider(languageId, {
86+
provideCompletionItems: function (model, position) {
87+
const doc = makeTextDocument(model);
88+
const items = ls.getCompletions({
89+
textDocument: doc,
90+
position: toLspPosition(position),
91+
variables: demoVars
92+
}) || [];
93+
94+
const word = model.getWordUntilPosition(position);
95+
const range = new monaco.Range(position.lineNumber, word.startColumn, position.lineNumber, word.endColumn);
96+
97+
function mapKind(k) {
98+
// Map LSP CompletionItemKind (numbers) to Monaco kinds
99+
const map = {
100+
3: monaco.languages.CompletionItemKind.Function, // Function
101+
6: monaco.languages.CompletionItemKind.Variable, // Variable
102+
21: monaco.languages.CompletionItemKind.Constant, // Constant
103+
14: monaco.languages.CompletionItemKind.Keyword // Keyword
104+
};
105+
return map[k] || monaco.languages.CompletionItemKind.Text;
106+
}
107+
108+
const suggestions = items.map(it => ({
109+
label: it.label,
110+
kind: mapKind(it.kind),
111+
detail: it.detail,
112+
documentation: it.documentation,
113+
insertText: it.insertText || it.label,
114+
range
115+
}));
116+
117+
return {suggestions};
118+
}
119+
});
120+
121+
// Hover provider
122+
monaco.languages.registerHoverProvider(languageId, {
123+
provideHover: function (model, position) {
124+
const doc = makeTextDocument(model);
125+
const hover = ls.getHover({textDocument: doc, position: toLspPosition(position), variables: demoVars});
126+
if (!hover || !hover.contents) return {contents: []};
127+
128+
let contents = [];
129+
if (typeof hover.contents === 'string') {
130+
contents = [{value: hover.contents}];
131+
} else if (hover.contents && typeof hover.contents === 'object') {
132+
const kind = hover.contents.kind || 'plaintext';
133+
const val = hover.contents.value || '';
134+
contents = [{value: val}];
135+
}
136+
137+
let range = undefined;
138+
if (hover.range) {
139+
const start = fromLspPosition(hover.range.start);
140+
const end = fromLspPosition(hover.range.end);
141+
range = new monaco.Range(start.lineNumber, start.column, end.lineNumber, end.column);
142+
}
143+
return {contents, range};
144+
}
145+
});
146+
147+
// Very basic syntax highlighting using decorations from service tokens
148+
function applyHighlighting() {
149+
const doc = makeTextDocument(model);
150+
const tokens = ls.getHighlighting(doc);
151+
const rangesByClass = new Map();
152+
for (const t of tokens) {
153+
const start = model.getPositionAt(t.start);
154+
const end = model.getPositionAt(t.end);
155+
const range = new monaco.Range(start.lineNumber, start.column, end.lineNumber, end.column);
156+
const cls = 'tok-' + t.type;
157+
if (!rangesByClass.has(cls)) rangesByClass.set(cls, []);
158+
rangesByClass.get(cls).push({range, options: {inlineClassName: cls}});
159+
}
160+
// Clear and set decorations per class (store IDs to update later if needed)
161+
window.__exprEvalDecos = window.__exprEvalDecos || {};
162+
for (const [cls, decos] of rangesByClass.entries()) {
163+
const prev = window.__exprEvalDecos[cls] || [];
164+
window.__exprEvalDecos[cls] = editor.deltaDecorations(prev, decos);
165+
}
166+
}
167+
168+
function evaluate() {
169+
const expression = model.getValue();
170+
const status = document.getElementById('status');
171+
try{
172+
const parser = new Parser();
173+
const evaluationResult = parser.evaluate(expression, demoVars);
174+
status.textContent = `Result: ${evaluationResult}`;
175+
} catch(error) {
176+
status.textContent = `Evaluation error: ${error.message}`
177+
}
178+
}
179+
180+
// Some minimal styles for tokens
181+
const style = document.createElement('style');
182+
style.textContent = `
183+
.tok-number { color: #b5cea8; }
184+
.tok-string { color: #ce9178; }
185+
.tok-keyword { color: #c586c0; }
186+
.tok-operator { color: #d4d4d4; }
187+
.tok-function { color: #4fc1ff; }
188+
.tok-punctuation { color: #d4d4d4; }
189+
.tok-name { color: #9cdcfe; }
190+
`;
191+
document.head.appendChild(style);
192+
193+
applyHighlighting();
194+
evaluate();
195+
model.onDidChangeContent(() => { applyHighlighting(); evaluate(); });
196+
});
197+
</script>
198+
</body>
199+
</html>
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// Minimal static file server for the Monaco sample (no extra deps)
2+
3+
const http = require('http');
4+
const fs = require('fs');
5+
const path = require('path');
6+
7+
const root = path.resolve(__dirname, '../../');
8+
const port = process.env.PORT ? Number(process.env.PORT) : 8080;
9+
10+
const mime = {
11+
'.html': 'text/html; charset=utf-8',
12+
'.js': 'application/javascript; charset=utf-8',
13+
'.mjs': 'application/javascript; charset=utf-8',
14+
'.css': 'text/css; charset=utf-8',
15+
'.map': 'application/json; charset=utf-8',
16+
'.json': 'application/json; charset=utf-8'
17+
};
18+
19+
function send(res, status, body, headers = {}) {
20+
res.writeHead(status, {'Content-Length': Buffer.byteLength(body), ...headers});
21+
res.end(body);
22+
}
23+
24+
const server = http.createServer((req, res) => {
25+
let urlPath = decodeURIComponent(req.url || '/');
26+
27+
if (urlPath === '/' || urlPath === '/index.html') {
28+
urlPath = 'samples/language-service-sample/index.html';
29+
}
30+
31+
const filePath = path.join(root, urlPath);
32+
33+
// OBVIOUSLY THIS IS NOT SECURE! DO NOT USE IN UNSAFE ENVIRONMENTS!
34+
fs.stat(filePath, (err, stat) => {
35+
if (err) {
36+
return send(res, 404, 'Not found');
37+
}
38+
if (stat.isDirectory()) {
39+
return send(res, 403, 'Forbidden');
40+
}
41+
const ext = path.extname(filePath).toLowerCase();
42+
const type = mime[ext] || 'application/octet-stream';
43+
fs.readFile(filePath, (err2, data) => {
44+
if (err2) return send(res, 500, 'Server error');
45+
res.writeHead(200, {'Content-Type': type, 'Content-Length': data.length});
46+
res.end(data);
47+
});
48+
});
49+
});
50+
51+
server.listen(port, () => {
52+
console.log(`[expr-eval] Sample server running at http://localhost:${port}`);
53+
});

src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ export * from './functions/utility/index.js';
2222
// Core evaluation engine
2323
export * from './core/index.js';
2424

25+
// Language service for intellisense
26+
export * from './language-service/index.js';
27+
2528
// Parsing utilities
2629
export * from './parsing/index.js';
2730

src/language-service/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/**
2+
* Language service for expression evaluation
3+
* Provides intellisense details and type information for expression evaluation
4+
*/
5+
6+
export * from './language-service.js';
7+
export type * from './language-service.types.js';
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Built-in lightweight docs for known functions and keywords
2+
3+
export const BUILTIN_FUNCTION_DOCS: Record<string, string> = {
4+
random: 'random(n): Get a random number in the range [0, n). If n is zero or missing, defaults to 1.',
5+
fac: 'fac(n): Factorial of n. Deprecated; prefer the ! operator.',
6+
min: 'min(a, b, …): Smallest number in the list.',
7+
max: 'max(a, b, …): Largest number in the list.',
8+
hypot: 'hypot(a, b): Hypotenuse √(a² + b²).',
9+
pyt: 'pyt(a, b): Alias for hypot(a, b).',
10+
pow: 'pow(x, y): Equivalent to x^y.',
11+
atan2: 'atan2(y, x): Arc tangent of x/y.',
12+
roundTo: 'roundTo(x, n): Round x to n decimal places.',
13+
map: 'map(f, a): Array map; returns [f(x,i) for x of a].',
14+
fold: 'fold(f, y, a): Array reduce; y = f(y, x, i) for each x of a.',
15+
filter: 'filter(f, a): Array filter.',
16+
indexOf: 'indexOf(x, a): First index of x in a (array/string), -1 if not found.',
17+
join: 'join(sep, a): Join array a with separator sep.',
18+
if: 'if(c, a, b): c ? a : b (both branches evaluate).',
19+
json: 'json(x): Returns JSON string for x.',
20+
sum: 'sum(a): Sum of all elements in a.',
21+
};
22+
23+
export const BUILTIN_KEYWORD_DOCS: Record<string, string> = {
24+
undefined: 'Represents an undefined value.',
25+
case: 'Start of a case-when-then-else-end block.',
26+
when: 'Case branch condition.',
27+
then: 'Then branch result.',
28+
else: 'Else branch result.',
29+
end: 'End of case block.'
30+
};
31+
32+
export const DEFAULT_CONSTANT_DOCS: Record<string, string> = {
33+
E: 'Math.E',
34+
PI: 'Math.PI',
35+
true: 'Logical true',
36+
false: 'Logical false'
37+
};

0 commit comments

Comments
 (0)