Created
March 31, 2026 03:25
-
-
Save lcanady/3d88c6cdc9b622e6eb4c09af3c46a958 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ============================================================================ | |
| // MUX Softcode Grammar — PEG.js / Peggy | |
| // | |
| // Parses TinyMUX 2.x / PennMUSH softcode stored in attribute values. | |
| // Produces a typed AST suitable for analysis, transformation, and linting. | |
| // | |
| // Allowed start rules: "Start" (attribute value), "LockExpr" (lock key) | |
| // | |
| // Install Peggy: npm install -g peggy | |
| // Compile: peggy --allowed-start-rules Start,LockExpr mux-softcode.pegjs | |
| // | |
| // Quick test: | |
| // const parser = require("./mux-softcode.js"); | |
| // parser.parse('$+finger *:@pemit %#=[u(me/FN_FINGER,%0)]'); | |
| // | |
| // AST node types: | |
| // AttributeValue DollarPattern PatternAlts Pattern Wildcard | |
| // CommandList AtCommand AttributeSet UserCommand | |
| // EvalBlock FunctionCall Arg | |
| // BracedString Text | |
| // Substitution SpecialVar Escape Literal | |
| // LockOr LockAnd LockNot LockMe LockDbref | |
| // LockFlagCheck LockTypeCheck LockAttrCheck LockPlayerName | |
| // ============================================================================ | |
| {{ | |
| // ── Module-level helpers (shared across all parses) ────────────────────── | |
| /** Construct a typed AST node */ | |
| function node(type, props) { | |
| return Object.assign({ type }, props); | |
| } | |
| /** | |
| * Merge adjacent Literal nodes to reduce AST noise. | |
| * e.g. [Literal("foo"), Literal("bar")] → [Literal("foobar")] | |
| */ | |
| function coalesce(parts) { | |
| if (!parts || parts.length === 0) return parts; | |
| const out = []; | |
| for (const p of parts) { | |
| if ( | |
| p.type === "Literal" && | |
| out.length > 0 && | |
| out[out.length - 1].type === "Literal" | |
| ) { | |
| out[out.length - 1].value += p.value; | |
| } else { | |
| out.push(p); | |
| } | |
| } | |
| return out; | |
| } | |
| }} | |
| // ============================================================================ | |
| // Entry Point | |
| // ============================================================================ | |
| // Default start rule — parse a full attribute value. | |
| // Leading/trailing whitespace is consumed so multi-line attribute values | |
| // (whitespace-normalized when stored by MUX) parse cleanly. | |
| Start | |
| = _ av:AttributeValue _ { return av; } | |
| // An attribute value is either a dollar-sign command definition or a command list. | |
| AttributeValue | |
| = DollarPattern | |
| / CommandList | |
| // ============================================================================ | |
| // Dollar-Sign Pattern — $<pattern> : <action> | |
| // | |
| // Defines a soft-coded user command. The attribute value begins with `$` | |
| // followed by a glob pattern, a `:`, and then a command action. | |
| // | |
| // Multiple pattern alternatives may be separated by `;` before the `:`. | |
| // | |
| // Examples: | |
| // $+finger *:@pemit %#=[u(me/FN_FINGER,%0)] | |
| // $hi;hello;hey *:@pemit %#=Greetings, %0! | |
| // $+stat/set *=*:@switch [setq(0,pmatch(%0))]=1,{...},{...} | |
| // ============================================================================ | |
| DollarPattern | |
| = "$" pattern:PatternSpec ":" action:CommandList { | |
| return node("DollarPattern", { pattern, action }); | |
| } | |
| // Multiple glob alternatives before the colon | |
| PatternSpec | |
| = head:SinglePattern tail:(";" SinglePattern)* { | |
| const patterns = [head, ...tail.map(t => t[1])]; | |
| return patterns.length === 1 | |
| ? patterns[0] | |
| : node("PatternAlts", { patterns }); | |
| } | |
| // One glob pattern — may contain * and ? wildcards and escape sequences | |
| SinglePattern | |
| = parts:PatternPiece+ { | |
| return node("Pattern", { parts: coalesce(parts) }); | |
| } | |
| PatternPiece | |
| = "*" { return node("Wildcard", { wildcard: "*" }); } | |
| / "?" { return node("Wildcard", { wildcard: "?" }); } | |
| / "\\" char:. { return node("Literal", { value: char }); } | |
| / chars:$([^;:*?\\]+) { return node("Literal", { value: chars }); } | |
| // ============================================================================ | |
| // Command List — cmd ; cmd ; cmd ... | |
| // | |
| // Commands at the top level are separated by unprotected semicolons. | |
| // A semicolon inside `{}` or `[]` does NOT end a command. | |
| // | |
| // When only one command is present, return it directly (no wrapping node). | |
| // ============================================================================ | |
| CommandList | |
| = head:Command tail:(";" Command)* { | |
| const commands = [head, ...tail.map(t => t[1])]; | |
| return commands.length === 1 | |
| ? commands[0] | |
| : node("CommandList", { commands }); | |
| } | |
| // ============================================================================ | |
| // Command Forms | |
| // | |
| // MUX softcode has three kinds of commands at the top level: | |
| // 1. @built-in commands: @pemit, @set, @dolist, @switch, @lock, … | |
| // 2. Attribute-set commands: &ATTRNAME object=value | |
| // 3. User/soft commands: +finger Bob, say Hello, go north, … | |
| // ============================================================================ | |
| Command | |
| = AtCommand | |
| / AttributeSet | |
| / UserCommand | |
| // ── @command ────────────────────────────────────────────────────────────── | |
| // | |
| // @name[/switch]* [object[=value]] | |
| // | |
| // The value after `=` is parsed as a full AttributeValue, so it can itself | |
| // contain dollar patterns (e.g., @trigger inside an attribute set) or nested | |
| // command lists. | |
| // | |
| // Note: @command-specific argument syntax (e.g., @switch's comma-delimited | |
| // cases, @wait's time:command form) is NOT parsed here — those cases appear | |
| // as generic text inside the value. Semantic analysis is a separate concern. | |
| // | |
| // Examples: | |
| // @pemit %#=Hello, [name(%#)]! | |
| // @set me=SAFE | |
| // @lock/enter me=flag^WIZARD | |
| // @dolist [lwho()]={@pemit ##=Restart in 5 min.} | |
| // @switch [gt(%0,10)]=1,{big},{small} | |
| AtCommand | |
| = "@" name:AtCmdName switches:AtSwitch* body:AtCmdBody? { | |
| return node("AtCommand", { | |
| name, | |
| switches, | |
| object: body ? body.object : null, | |
| value: body ? body.value : null, | |
| }); | |
| } | |
| AtCmdName = $([a-zA-Z][a-zA-Z0-9_-]*) | |
| AtSwitch | |
| = "/" n:$([a-zA-Z][a-zA-Z0-9_-]*) { return n; } | |
| // The body of an @command: optional object, optional =value. | |
| // Both alternatives begin with optional whitespace (_). | |
| AtCmdBody | |
| = _ obj:ObjText "=" val:AttributeValue { | |
| return { object: obj, value: val }; | |
| } | |
| / _ obj:ObjText { | |
| return { object: obj, value: null }; | |
| } | |
| // ── Attribute-Set Command ────────────────────────────────────────────────── | |
| // | |
| // &ATTR_NAME object=value | |
| // | |
| // The value is a full AttributeValue, so it may be a DollarPattern | |
| // (the common case when defining soft commands on objects). | |
| // | |
| // Examples: | |
| // &DATA_SCORE me=100 | |
| // &FN_ADD me=[add(%0,%1)] | |
| // &CMD_FINGER Global=$+finger *:@pemit %#=[u(me/FN_FINGER,%0)] | |
| AttributeSet | |
| = "&" attr:AttrIdent _ obj:ObjText "=" val:AttributeValue { | |
| return node("AttributeSet", { attribute: attr, object: obj, value: val }); | |
| } | |
| // Attribute name: letters, digits, underscores, hyphens (case-sensitive in storage) | |
| AttrIdent = $([a-zA-Z_][a-zA-Z0-9_-]*) | |
| // ── User / Soft Command (catch-all) ────────────────────────────────────── | |
| // | |
| // Anything that isn't an @command or &attr-set. | |
| // Includes built-in player commands (say, go, look, …) and soft-coded | |
| // user commands (+finger, +who, etc.) triggered from dollar patterns. | |
| // | |
| // Examples: | |
| // +finger Bob | |
| // say Hello, world! | |
| // go north | |
| UserCommand | |
| = parts:CmdToken* { | |
| return node("UserCommand", { parts: coalesce(parts) }); | |
| } | |
| // ============================================================================ | |
| // Object Text (before the `=` in a command) | |
| // | |
| // Used in both @command and &attr-set positions. | |
| // Terminates at `=` or `;` (next command). | |
| // | |
| // Object names may contain spaces (e.g., "Finger Object"), dbrefs (#123), | |
| // function results ([name(%#)]), and substitutions (%N). | |
| // ============================================================================ | |
| ObjText | |
| = parts:ObjToken+ { | |
| return node("Text", { parts: coalesce(parts) }); | |
| } | |
| ObjToken | |
| = EvalBlock | |
| / BracedString | |
| / Substitution | |
| / SpecialVar | |
| / Escape | |
| / ObjLiteralChars | |
| // Literal characters in object position: anything except = ; [ { % \ | |
| ObjLiteralChars | |
| = chars:$([^=;\[{%\\]+) { return node("Literal", { value: chars }); } | |
| // ============================================================================ | |
| // Command-Level Tokens | |
| // | |
| // Tokens that may appear inside a command's value/body. | |
| // Terminates at `;` (next command). | |
| // ============================================================================ | |
| CmdToken | |
| = EvalBlock | |
| / BracedString | |
| / Substitution | |
| / SpecialVar | |
| / Escape | |
| / CmdLiteralChars | |
| // Literal characters at command level: anything except ; [ { % \ | |
| // NOTE: = , ( ) # are all legal literal characters in command context. | |
| CmdLiteralChars | |
| = chars:$([^;\[{%\\]+) { return node("Literal", { value: chars }); } | |
| // ============================================================================ | |
| // Braced String — { ... } | |
| // | |
| // Protects the contents from the surrounding parser: | |
| // • Semicolons `;` inside braces do NOT separate commands. | |
| // • Commas `,` inside braces do NOT separate function arguments. | |
| // • Braces nest: { outer { inner } more } | |
| // | |
| // However, the following still apply inside braces: | |
| // • %x substitutions (e.g., %N, %0, %q0) | |
| // • [] evaluation (e.g., [add(1,2)]) | |
| // • \ escape sequences | |
| // | |
| // Examples: | |
| // {don't;split;this} | |
| // {@pemit %#=Hello, %0!} ← protects the semicolon | |
| // {[add(%0,1)]} ← evaluation still happens | |
| // ============================================================================ | |
| BracedString | |
| = "{" parts:BracedToken* "}" { | |
| return node("BracedString", { parts: coalesce(parts) }); | |
| } | |
| BracedToken | |
| = BracedString // nested braces — braces always nest | |
| / EvalBlock // [] evaluation still applies inside {} | |
| / Substitution // %x substitution still applies | |
| / SpecialVar // ## #@ #$ still work | |
| / Escape // \ still escapes | |
| / BracedLiteralChars // everything else — including ; , = ( ) | |
| // Literal characters inside braces: anything except { } [ % \ | |
| // Note: ; and , are allowed here — that is the whole point of braces. | |
| BracedLiteralChars | |
| = chars:$([^{}\[%\\]+) { return node("Literal", { value: chars }); } | |
| // ============================================================================ | |
| // Eval Block — [ ... ] | |
| // | |
| // The content is evaluated and the result string replaces the block. | |
| // Evaluation is innermost-first (deep-to-shallow nesting). | |
| // | |
| // The primary content is function calls, but substitutions, nested eval | |
| // blocks, and literal text are also valid inside []. | |
| // | |
| // Examples: | |
| // [add(1,2)] → "3" | |
| // [name(%#)] → enactor's name | |
| // [if(gt(%0,10),big,small)] | |
| // [setq(0,pmatch(%0))][r(0)] → two back-to-back eval blocks | |
| // [ansi(hg,SUCCESS)] → bold green "SUCCESS" | |
| // ============================================================================ | |
| EvalBlock | |
| = "[" parts:EvalToken* "]" { | |
| return node("EvalBlock", { parts: coalesce(parts) }); | |
| } | |
| // Inside an eval block, FunctionCall is tried first because it has a specific | |
| // signature (identifier immediately followed by `(`). If that fails, fall | |
| // through to the other token types. | |
| EvalToken | |
| = FunctionCall // name(arg, ...) — most common eval content | |
| / EvalBlock // nested [] | |
| / BracedString // {} inside [] still protects content | |
| / Substitution // %x | |
| / SpecialVar // ## #@ #$ | |
| / Escape // \x | |
| / EvalLiteralChars // anything except [ ] { % \ | |
| // In eval context, ( and ) can appear as literal characters | |
| // (they are only syntactically meaningful after an identifier, handled by FunctionCall). | |
| EvalLiteralChars | |
| = chars:$([^\[\]{}%\\]+) { return node("Literal", { value: chars }); } | |
| // ============================================================================ | |
| // Function Call — name(arg, arg, ...) | |
| // | |
| // All MUX built-in and user-defined functions (via u()) follow this pattern. | |
| // Function names are case-insensitive at runtime; this grammar preserves case. | |
| // | |
| // Zero-argument functions are supported: lwho(), rand(0), secs(). | |
| // | |
| // Examples: | |
| // add(1,2) | |
| // if(gt(%0,10),big,small) | |
| // u(me/FN_HELLO,%0,%1) | |
| // iter([lcon(%L)],##: [get(%q0/S_##)], ,%b ) | |
| // setq(0,pmatch(trim(%0))) | |
| // ============================================================================ | |
| // Zero-arg functions use the first alternative to avoid a zero-length match | |
| // ambiguity: lwho() secs() time() rand() → args: [] | |
| // Functions with arguments use the second alternative. | |
| FunctionCall | |
| = name:FuncIdent "()" { | |
| return node("FunctionCall", { name, args: [] }); | |
| } | |
| / name:FuncIdent "(" args:ArgList ")" { | |
| return node("FunctionCall", { name, args }); | |
| } | |
| // Function identifiers: letters, digits, underscores (must start with letter/underscore) | |
| FuncIdent = $([a-zA-Z_][a-zA-Z0-9_]*) | |
| // Argument list: one or more arguments separated by commas. | |
| // Empty positional args are valid: setq(0,) · iter(list,,delim) | |
| // FuncArg accepts zero tokens so empty positions parse correctly. | |
| ArgList | |
| = head:FuncArg tail:("," FuncArg)* { | |
| return [head, ...tail.map(t => t[1])]; | |
| } | |
| // A single function argument — zero or more arg tokens. | |
| // An empty arg (between two commas, or before/after the only comma) is valid. | |
| FuncArg | |
| = parts:ArgToken* { | |
| return node("Arg", { parts: coalesce(parts) }); | |
| } | |
| // Tokens inside a function argument: | |
| // • `,` and `)` terminate the argument — except when inside {} or [] | |
| // • {} braces protect commas: {a,b} passes literal "a,b" as one argument | |
| // • [] blocks are evaluated: [add(1,2)] → "3" as part of the argument value | |
| ArgToken | |
| = FunctionCall // nested call: iter([lcon(%L)],name(##)) | |
| / EvalBlock // [...] within an argument | |
| / BracedString // {...} — commas and ) inside are literal | |
| / Substitution // %x | |
| / SpecialVar // ## #@ #$ | |
| / Escape // \x | |
| / ArgLiteralChars // everything except , ( ) [ ] { } % \ | |
| // Literal characters inside function arguments. | |
| // NOTE: ( and ) are excluded because ) ends the argument list and | |
| // ( could begin a nested call. Use {} to pass literal parens: {(text)} | |
| ArgLiteralChars | |
| = chars:$([^,\[\](){}%\\]+) { return node("Literal", { value: chars }); } | |
| // ============================================================================ | |
| // Substitutions — % + code | |
| // | |
| // Expanded at evaluation time to their runtime values. | |
| // | |
| // Standard codes: | |
| // %# enactor's dbref %! executing object's dbref | |
| // %N enactor's name (mixed case) %L location's dbref | |
| // %n enactor's name (lowercase) | |
| // %s subjective pronoun (he/she/it) | |
| // %o objective pronoun (him/her/it) | |
| // %p possessive pronoun (his/her/its) | |
| // %a absolute possessive (his/hers/its) | |
| // %0–%9 positional arguments (passed via u() or @trigger) | |
| // %q0–%q9 local registers (set with setq()) | |
| // %qa–%qz extended registers (TinyMUX 2.10+) | |
| // %i0–%i9 nested iter() current item (equivalent to itext(n)) | |
| // %r carriage return / newline | |
| // %t tab character | |
| // %b space character | |
| // %% literal percent sign | |
| // %[ literal [ | |
| // %] literal ] | |
| // %, literal comma | |
| // %; literal semicolon | |
| // ============================================================================ | |
| Substitution | |
| = "%" code:SubCode { | |
| return node("Substitution", { code }); | |
| } | |
| SubCode | |
| = "q" n:[0-9a-z] { return "q" + n; } // %q0–%q9 and %qa–%qz (local regs) | |
| / "i" n:[0-9] { return "i" + n; } // %i0–%i9 (nested iter reference) | |
| / c:[#NnsopaL!0-9rtb%] { return c; } // standard single-char codes | |
| / "[" { return "["; } // %[ → literal [ | |
| / "]" { return "]"; } // %] → literal ] | |
| / "," { return ","; } // %, → literal comma | |
| / ";" { return ";"; } // %; → literal semicolon | |
| // ============================================================================ | |
| // Special Variables — ## · #@ · #$ | |
| // | |
| // Used inside iter() and @dolist to reference the current iteration state. | |
| // | |
| // ## current list item value (= itext(0) at the innermost level) | |
| // #@ current list item position (1-indexed; = inum(0)) | |
| // #$ last dbref returned by a name-lookup function | |
| // | |
| // These are tried as higher-priority alternatives before literal text in every | |
| // token context, so they are always recognised even when adjacent to other # | |
| // characters (e.g., #1 is still a literal dbref reference). | |
| // ============================================================================ | |
| SpecialVar | |
| = "##" { return node("SpecialVar", { code: "##" }); } | |
| / "#@" { return node("SpecialVar", { code: "#@" }); } | |
| / "#$" { return node("SpecialVar", { code: "#$" }); } | |
| // ============================================================================ | |
| // Escape Sequence — \ + char | |
| // | |
| // Prevents one level of evaluation for the next character. | |
| // In command context: `;` → literal semicolon, `[` → literal bracket, etc. | |
| // In function-arg context: `,` → literal comma, `)` → literal close-paren. | |
| // | |
| // The grammar records the escaped character as-is for later analysis. | |
| // ============================================================================ | |
| Escape | |
| = "\\" char:. { | |
| return node("Escape", { char }); | |
| } | |
| // ============================================================================ | |
| // Lock Expression Grammar | |
| // | |
| // Lock expressions are used as values in @lock commands. | |
| // This grammar can be used as an alternate start rule for parsing lock keys. | |
| // | |
| // Example lock expressions: | |
| // me owner only | |
| // #123 specific dbref | |
| // flag^WIZARD players with WIZARD flag | |
| // !me anyone except owner | |
| // me|#123 owner OR dbref #123 | |
| // meLj owner AND #456 | |
| // =PlayerName specific player by name | |
| // type^ROOM type check | |
| // flag^WIZARD|flag^ADMIN wizard or admin | |
| // | |
| // Operator precedence (lowest to highest): | |
| // | OR | |
| // & AND | |
| // ! NOT (prefix) | |
| // (primary terms) | |
| // ============================================================================ | |
| LockExpr = LockOr | |
| LockOr | |
| = head:LockAnd tail:("|" LockAnd)* { | |
| if (tail.length === 0) return head; | |
| return node("LockOr", { operands: [head, ...tail.map(t => t[1])] }); | |
| } | |
| LockAnd | |
| = head:LockNot tail:("&" LockNot)* { | |
| if (tail.length === 0) return head; | |
| return node("LockAnd", { operands: [head, ...tail.map(t => t[1])] }); | |
| } | |
| LockNot | |
| = "!" operand:LockNot { return node("LockNot", { operand }); } | |
| / LockPrimary | |
| LockPrimary | |
| = "(" _ expr:LockExpr _ ")" { return expr; } | |
| / "me" ![a-zA-Z0-9_] { return node("LockMe", {}); } | |
| / LockDbref | |
| / LockFlagCheck | |
| / LockTypeCheck | |
| / LockAttrCheck | |
| / LockPlayerName | |
| // #123 — specific object by dbref (#-1 is also valid in some contexts) | |
| LockDbref | |
| = "#" n:$("-"? [0-9]+) { | |
| return node("LockDbref", { dbref: "#" + n }); | |
| } | |
| // flag^FLAGNAME — object must have this flag | |
| LockFlagCheck | |
| = "flag^" name:$([a-zA-Z_]+) { | |
| return node("LockFlagCheck", { flag: name }); | |
| } | |
| // type^ROOM|THING|EXIT|PLAYER — object must be this type | |
| LockTypeCheck | |
| = "type^" name:$([a-zA-Z_]+) { | |
| return node("LockTypeCheck", { typeName: name }); | |
| } | |
| // attr^ATTRNAME — object must have this attribute set | |
| LockAttrCheck | |
| = "attr^" name:$([a-zA-Z_][a-zA-Z0-9_-]*) { | |
| return node("LockAttrCheck", { attribute: name }); | |
| } | |
| // =PlayerName — specific connected player by name | |
| LockPlayerName | |
| = "=" name:$([^|&!()[\]{}\r\n]+) { | |
| return node("LockPlayerName", { name: name.trim() }); | |
| } | |
| // ============================================================================ | |
| // Whitespace | |
| // ============================================================================ | |
| _ = [ \t\r\n]* | |
| __ = [ \t\r\n]+ |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment