Files
jcoffey-dev 136fc2540b
ci / fork-checks (pull_request) Skipped
ci / build (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 7m45s
Sieve playground: the server's interpreter in the browser
tools/sieve-playground builds the vendored sieve-rs 0.7.3 (vnd.inbuxa.*
names included) to WebAssembly, with a static page around it, for inbuxa
Admin's Debug button on a Sieve script: until now that button sent the
script to an outside site whose interpreter knows only upstream's
extension names, so any script that requires vnd.inbuxa.* failed to
compile there.

Ported from the playground in the upstream sieve-rs repository's web/
directory (commit 549a2d57f7), which targets sieve-rs 1.x:

- run.rs and handler.rs drive 0.7's event loop the way the server's
  delivery does, carrying on after a runtime error; llm_prompt compiles
  and answers false, as on a server without a model.
- The expression functions are the server's own modules, included by
  path; tests fail if the list, ApplyString or the few functions copied
  from misc.rs/unicode.rs stop matching crates/common.
- Built for wasm32-wasip1 rather than with wasm-bindgen: sieve-rs and
  mail-builder read the clock, which panics on wasm32-unknown-unknown.
  The page's worker answers the six WASI calls, giving the module the
  playground's current-time setting. shim/gethostname stands in for a
  crate with no WASI build. Nothing in vendor/ or crates/ changes.
- The page carries no upstream names or links; storage keys and the
  samples are renamed to match.

build.sh --admin <dir> stages the result into an inbuxa-admin checkout.
2026-10-09 01:13:53 -07:00

385 lines
15 KiB
JavaScript

/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
import { ACTIONS, CONTROL, DATE_PARTS, ENVIRONMENT_ITEMS, FUNCTIONS, TAGS, TESTS, lookup } from "./sieve-docs.js";
export const LANGUAGE_ID = "sieve";
const CAPABILITY_ERROR = /Undeclared capability '([^']+)'/;
function monarch() {
return {
ignoreCase: true,
defaultToken: "",
keywords: [...Object.keys(CONTROL), ...Object.keys(ACTIONS), "foreach"],
tests: Object.keys(TESTS),
tokenizer: {
root: [
[/#.*$/, "comment"],
[/\/\*/, "comment", "@comment"],
[/text:/, { token: "string.heredoc", next: "@heredoc" }],
[/"/, { token: "string.quote", next: "@string" }],
[/:[A-Za-z_][A-Za-z0-9_-]*/, "attribute.name"],
[/\d+[KMGkmg]?\b/, "number"],
[/[A-Za-z_][A-Za-z0-9_.-]*/, { cases: { "@keywords": "keyword", "@tests": "type", "@default": "identifier" } }],
[/[{}()[\]]/, "@brackets"],
[/[;,]/, "delimiter"],
[/\s+/, "white"],
],
comment: [
[/[^/*]+/, "comment"],
[/\*\//, "comment", "@pop"],
[/[/*]/, "comment"],
],
string: [
[/\$\{[^}"]*\}/, "variable"],
[/[^"\\$]+/, "string"],
[/\\./, "string.escape"],
[/\$/, "string"],
[/"/, { token: "string.quote", next: "@pop" }],
],
heredoc: [
[/\.\s*$/, { token: "string.heredoc", next: "@pop" }],
[/.*$/, "string"],
],
},
};
}
const configuration = {
comments: { lineComment: "#", blockComment: ["/*", "*/"] },
brackets: [["{", "}"], ["[", "]"], ["(", ")"]],
autoClosingPairs: [
{ open: "{", close: "}" },
{ open: "[", close: "]" },
{ open: "(", close: ")" },
{ open: '"', close: '"', notIn: ["string", "comment"] },
{ open: "/*", close: " */", notIn: ["string"] },
],
surroundingPairs: [
{ open: "{", close: "}" },
{ open: "[", close: "]" },
{ open: "(", close: ")" },
{ open: '"', close: '"' },
],
indentationRules: {
increaseIndentPattern: /\{[^}"]*$/,
decreaseIndentPattern: /^\s*\}/,
},
folding: { markers: { start: /\{\s*$/, end: /^\s*\}/ } },
wordPattern: /(-?\d*\.\d\w*)|([^\s`~!@#%^&*()=+[{\]}\\|;:'",<>/?]+)/g,
};
function docMarkdown(word, entry) {
const parts = [`\`\`\`sieve\n${entry.sig}\n\`\`\``, entry.doc];
const links = [];
if (entry.capability) links.push(`Requires \`"${entry.capability}"\``);
if (entry.rfc) links.push(`[Specification](${entry.rfc})`);
if (links.length) parts.push(links.join(" · "));
return parts.join("\n\n");
}
const isComment = (mode) => mode === "lineComment" || mode === "blockComment";
export class SieveScanner {
constructor() {
this.mode = "code";
this.statement = "";
this.variableOpen = false;
}
feedLine(line, hasNewline = true) {
const startMode = this.mode;
let opens = 0;
let closes = 0;
if (this.mode === "heredoc" && /^\.\s*$/.test(line)) {
this.mode = "code";
this.variableOpen = false;
this.statement += line;
} else {
for (let i = 0; i < line.length; i++) {
const ch = line[i];
const start = i;
const mode = this.mode;
switch (this.mode) {
case "code":
if (ch === '"') {
this.mode = "string";
this.variableOpen = false;
} else if (ch === "#") {
this.mode = "lineComment";
} else if (ch === "/" && line[i + 1] === "*") {
this.mode = "blockComment";
i++;
} else if (ch === ";" || ch === "{" || ch === "}") {
this.statement = "";
if (ch === "{") opens++;
if (ch === "}") closes++;
continue;
} else if ((ch === "t" || ch === "T") && line.slice(i, i + 5).toLowerCase() === "text:" && !/[A-Za-z0-9_]/.test(line[i - 1] || "")) {
this.mode = "heredocHeader";
i += 4;
}
break;
case "string":
case "heredoc":
if (ch === "\\" && this.mode === "string") {
i++;
} else if (ch === '"' && this.mode === "string") {
this.mode = "code";
this.variableOpen = false;
} else if (ch === "$" && line[i + 1] === "{") {
this.variableOpen = true;
i++;
} else if (ch === "}") {
this.variableOpen = false;
}
break;
case "blockComment":
if (ch === "*" && line[i + 1] === "/") {
this.mode = "code";
i++;
}
break;
default:
break;
}
if (!isComment(mode) && !isComment(this.mode)) this.statement += line.slice(start, i + 1);
}
if (hasNewline) {
if (!isComment(this.mode)) this.statement += "\n";
if (this.mode === "lineComment") this.mode = "code";
if (this.mode === "heredocHeader") this.mode = "heredoc";
}
}
return { startMode, opens, closes };
}
}
function context(model, position) {
const before = model.getValueInRange({ startLineNumber: 1, startColumn: 1, endLineNumber: position.lineNumber, endColumn: position.column });
const lines = before.split("\n");
const scanner = new SieveScanner();
lines.forEach((line, index) => scanner.feedLine(line, index < lines.length - 1));
const mode = isComment(scanner.mode) ? "comment" : scanner.mode;
const statement = scanner.statement;
const inString = mode === "string" || mode === "heredoc";
const command = (statement.match(/^\s*([A-Za-z_]+)/) || [])[1]?.toLowerCase() || "";
const line = model.getLineContent(position.lineNumber).slice(0, position.column - 1);
return { statement, inString, inComment: mode === "comment", inVariable: inString && scanner.variableOpen, command, line };
}
function variableNames(model) {
const names = new Set();
const pattern = /\b(?:set|let|global|extracttext)\s+(?::[A-Za-z]+\s+(?:\d+\s+)?)*(\[[^\]]*\]|"[^"]*")/gi;
for (const match of model.getValue().matchAll(pattern)) {
for (const name of match[1].matchAll(/"([^"]+)"/g)) names.add(name[1]);
}
return [...names];
}
function replaceRange(model, position) {
const word = model.getWordUntilPosition(position);
return {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: word.startColumn,
endColumn: word.endColumn,
};
}
export function registerSieve(monaco, getSettings) {
monaco.languages.register({ id: LANGUAGE_ID, extensions: [".sieve", ".siv"], aliases: ["Sieve"] });
monaco.languages.setMonarchTokensProvider(LANGUAGE_ID, monarch());
monaco.languages.setLanguageConfiguration(LANGUAGE_ID, configuration);
const { CompletionItemKind: Kind, CompletionItemInsertTextRule: Rule } = monaco.languages;
monaco.languages.registerCompletionItemProvider(LANGUAGE_ID, {
triggerCharacters: [":", '"', "$", "{"],
provideCompletionItems(model, position, completionContext) {
const ctx = context(model, position);
const range = replaceRange(model, position);
const settings = getSettings();
const items = [];
if (ctx.inComment) return { suggestions: [] };
if (ctx.inVariable) {
for (const name of variableNames(model)) items.push({ label: name, kind: Kind.Variable, insertText: name, range });
return { suggestions: items };
}
const trigger = completionContext?.triggerCharacter;
if ((trigger === "$" || trigger === "{") || (trigger === '"' && !ctx.inString)) return { suggestions: [] };
if (ctx.inString) {
if (ctx.command === "require" || /\bihave\b/.test(ctx.statement)) {
for (const capability of settings.capabilities || []) {
items.push({ label: capability, kind: Kind.Module, insertText: capability, range });
}
} else if (/^\s*(fileinto|mailboxexists)\b/.test(ctx.statement) && !/:(flags|specialuse|mailboxid)\s+"[^"]*$/.test(ctx.statement)) {
for (const mailbox of settings.mailboxes || []) {
items.push({ label: mailbox.name, kind: Kind.Folder, insertText: mailbox.name, detail: mailbox.specialUse?.join(" "), range });
}
} else if (/\benvironment\b/.test(ctx.statement)) {
const names = new Set([...ENVIRONMENT_ITEMS, ...(settings.environment || []).map((item) => item.name)]);
for (const name of names) items.push({ label: name, kind: Kind.Constant, insertText: name, range });
} else if (/\b(currentdate|date)\b/.test(ctx.statement)) {
for (const part of DATE_PARTS) items.push({ label: part, kind: Kind.EnumMember, insertText: part, range });
} else if (/\b(let|eval|while)\b/.test(ctx.statement)) {
for (const [name, arity] of Object.entries(FUNCTIONS)) {
const args = Array.from({ length: arity }, (_, i) => `\${${i + 1}}`).join(", ");
items.push({ label: name, kind: Kind.Function, detail: `${arity} argument${arity === 1 ? "" : "s"}`, insertText: `${name}(${args})`, insertTextRules: Rule.InsertAsSnippet, range });
}
for (const name of ["header.subject", "header.from.addr", "header.from.name", "header.to[*].addr[*]", "envelope.from", "envelope.to", "env.remote_ip"]) {
items.push({ label: name, kind: Kind.Variable, insertText: name, range });
}
} else if (/\b(addflag|setflag|removeflag|hasflag)\b|:flags\s+/.test(ctx.statement)) {
for (const flag of ["\\\\Seen", "\\\\Flagged", "\\\\Answered", "\\\\Deleted", "\\\\Draft", "$Junk", "$NotJunk"]) {
items.push({ label: flag, kind: Kind.EnumMember, insertText: flag, range });
}
}
return { suggestions: items };
}
if (trigger === ":" && !/:[A-Za-z_-]*$/.test(ctx.line)) return { suggestions: [] };
if (/:[A-Za-z_-]*$/.test(ctx.line)) {
for (const [tag, doc] of Object.entries(TAGS)) {
items.push({ label: `:${tag}`, kind: Kind.Property, insertText: tag, documentation: { value: doc }, range });
}
return { suggestions: items };
}
const snippets = {
if: "if ${1:header :contains \"Subject\" \"${2:text}\"} {\n\t$0\n}",
require: "require [\"${1:fileinto}\"];",
fileinto: "fileinto \"${1:INBOX}\";",
redirect: "redirect \"${1:[email protected]}\";",
vacation: "vacation :days ${1:7} :subject \"${2:Out of office}\" text:\n${3:I am away.}\n.\n;",
foreverypart: "foreverypart {\n\t$0\n}",
while: "while \"${1:i < 10}\" {\n\t$0\n}",
set: "set \"${1:name}\" \"${2:value}\";",
let: "let \"${1:name}\" \"${2:expression}\";",
};
for (const [group, kind] of [[CONTROL, Kind.Keyword], [ACTIONS, Kind.Function], [TESTS, Kind.Interface]]) {
for (const [name, entry] of Object.entries(group)) {
items.push({
label: name,
kind,
detail: entry.sig,
documentation: { value: docMarkdown(name, entry) },
insertText: snippets[name] || name,
insertTextRules: snippets[name] ? Rule.InsertAsSnippet : undefined,
range,
});
}
}
return { suggestions: items };
},
});
monaco.languages.registerHoverProvider(LANGUAGE_ID, {
provideHover(model, position) {
const word = model.getWordAtPosition(position);
if (!word) return null;
const line = model.getLineContent(position.lineNumber);
const range = new monaco.Range(position.lineNumber, word.startColumn, position.lineNumber, word.endColumn);
if (line[word.startColumn - 2] === ":") {
const doc = TAGS[word.word.toLowerCase()];
if (!doc) return null;
return { range, contents: [{ value: `**:${word.word}**` }, { value: doc }] };
}
const token = monaco.editor.tokenize(line, LANGUAGE_ID)[0]?.filter((t) => t.offset <= word.startColumn - 1).pop();
if (token && /string|comment/.test(token.type)) return null;
const entry = lookup(word.word);
if (!entry) return null;
return { range, contents: [{ value: docMarkdown(word.word, entry) }] };
},
});
monaco.languages.registerCodeActionProvider(LANGUAGE_ID, {
provideCodeActions(model, _range, ctx) {
const actions = [];
for (const marker of ctx.markers) {
const match = CAPABILITY_ERROR.exec(marker.message);
if (!match) continue;
const edit = requireEdit(monaco, model, match[1]);
if (!edit) continue;
actions.push({
title: `Add "${match[1]}" to require`,
kind: "quickfix",
diagnostics: [marker],
isPreferred: true,
edit: { edits: [{ resource: model.uri, textEdit: edit, versionId: model.getVersionId() }] },
});
}
return { actions, dispose() {} };
},
});
monaco.languages.registerDocumentFormattingEditProvider(LANGUAGE_ID, {
provideDocumentFormattingEdits(model, options) {
const unit = options.insertSpaces ? " ".repeat(options.tabSize) : "\t";
const scanner = new SieveScanner();
let depth = 0;
const lines = model.getLinesContent().map((raw) => {
const trimmed = raw.trim();
const leadingCloses = scanner.mode === "code" ? (trimmed.match(/^\}+/) || [""])[0].length : 0;
const { startMode, opens, closes } = scanner.feedLine(raw);
if (startMode !== "code") return raw;
const indent = Math.max(0, depth - leadingCloses);
depth = Math.max(0, depth + opens - closes);
return trimmed === "" ? "" : unit.repeat(indent) + trimmed;
});
return [{ range: model.getFullModelRange(), text: lines.join(model.getEOL()) }];
},
});
}
export function requireEdit(monaco, model, capability) {
const text = model.getValue();
const quoted = `"${capability}"`;
const listMatch = /require\s*\[([^\]]*)\]\s*;/i.exec(text);
if (listMatch) {
const closeOffset = listMatch.index + listMatch[0].lastIndexOf("]");
const inner = listMatch[1].trim();
const pos = model.getPositionAt(closeOffset);
return { range: new monaco.Range(pos.lineNumber, pos.column, pos.lineNumber, pos.column), text: inner ? `, ${quoted}` : quoted };
}
const singleMatch = /require\s*("[^"]*")\s*;/i.exec(text);
if (singleMatch) {
const start = model.getPositionAt(singleMatch.index);
const end = model.getPositionAt(singleMatch.index + singleMatch[0].length);
return { range: new monaco.Range(start.lineNumber, start.column, end.lineNumber, end.column), text: `require [${singleMatch[1]}, ${quoted}];` };
}
let line = 1;
const lines = model.getLinesContent();
while (line <= lines.length && /^\s*(#.*)?$/.test(lines[line - 1])) line++;
return { range: new monaco.Range(line, 1, line, 1), text: `require [${quoted}];\n\n` };
}
export function markersFor(monaco, model, diagnostics) {
return diagnostics.map((diagnostic) => {
const known = diagnostic.line > 0 && diagnostic.line <= model.getLineCount();
const line = known ? diagnostic.line : 1;
const maxColumn = model.getLineMaxColumn(line);
const startColumn = known ? Math.min(Math.max(diagnostic.column, 1), maxColumn) : 1;
return {
severity: diagnostic.severity === "warning" ? monaco.MarkerSeverity.Warning : monaco.MarkerSeverity.Error,
message: known ? diagnostic.message : `${diagnostic.message} (position unknown)`,
startLineNumber: line,
endLineNumber: line,
startColumn: startColumn === maxColumn ? Math.max(1, startColumn - 1) : startColumn,
endColumn: maxColumn,
source: "sieve",
};
});
}