diff --git a/src/components.json b/src/components.json index 7dfe6d098..b635e9a4a 100644 --- a/src/components.json +++ b/src/components.json @@ -772,6 +772,14 @@ "title": "METAFONT", "owner": "LaeriExNihilo" }, + "mikrotik": { + "title": "MikroTik", + "aliasTitles": { + "routeros": "RouterOS Script", + "ros": "RouterOS Script" + }, + "owner": "DmitrySharabin" + }, "mizar": { "title": "Mizar", "owner": "Golmote" diff --git a/src/languages/mikrotik.js b/src/languages/mikrotik.js new file mode 100644 index 000000000..6d1a31da6 --- /dev/null +++ b/src/languages/mikrotik.js @@ -0,0 +1,653 @@ +/** @type {import('../types.d.ts').LanguageProto<'mikrotik'>} */ +export default { + id: 'mikrotik', + optional: 'regex', + alias: ['routeros', 'ros'], + grammar () { + // MikroTik RouterOS Scripting Language Definition + // + // This definition highlights RouterOS scripts using structural + // syntax patterns rather than exhaustive keyword lists. RouterOS constructs + // are syntactically self-describing: + // - Commands are colon-prefixed → :put, :global, :if + // - Paths start with slash → /ip/firewall/filter + // - Parameters use word= syntax → src-address=10.0.0.1 + // - Variables are dollar-prefixed → $varName + // + // Only small, stable sets (global commands, menu commands, print parameters) + // are explicitly listed; everything else is matched by pattern. + // References: + // https://help.mikrotik.com/docs/spaces/ROS/pages/47579229/Scripting + // https://help.mikrotik.com/docs/spaces/ROS/pages/328134/Command+Line+Interface + // + // Known limitations (require semantic analysis beyond regex): + // 1. Multi-line arrays: `{ 1;\n2;\n3 }` is not highlighted as an array. + // The pattern intentionally excludes newlines to avoid misidentifying + // multi-line code blocks (e.g., `do={ ... }`) as arrays. + // 2. Sub-menu names in space form: `/tool e-mail send` — only the first + // path component (`tool`) is highlighted; the rest look like bare words. + // Fixing this would require a full vocabulary list of all sub-menu names. + // 3. Menu commands in value position: `action=add` — `add` is highlighted + // as a command even though it is used as a parameter value here. + // There is no regex-based way to distinguish the two contexts. + // 4. `!` in topic specs: `topics=!ppp` — `!` is highlighted as a logical + // operator. The negation-in-topic-spec usage is syntactically identical. + // 5. Bare property names without `=`: `get $id target-address` — `target-address` + // is not highlighted as a parameter because the generic parameter pattern + // requires `(?==)`. A bare hyphenated-word pattern would conflict with + // hyphenated menu commands (e.g., `monitor-traffic`). + + // https://help.mikrotik.com/docs/spaces/ROS/pages/47579229/Scripting#Scripting-Globalcommands + const GLOBAL_COMMANDS = [ + 'beep', + 'delay', + 'deserialize', + 'environment', + 'error', + 'execute', + 'find', + 'global', + 'jobname', + 'len', + 'local', + 'log', + 'nothing', + 'onerror', + 'parse', + 'pick', + 'put', + 'range', + 'resolve', + 'retry', + 'rndnum', + 'rndstr', + 'serialize', + 'set', + 'time', + 'timestamp', + 'toarray', + 'tobool', + 'tocrlf', + 'toid', + 'toip', + 'toip6', + 'tolf', + 'tonsec', + 'tonum', + 'tostr', + 'totime', + 'typeof', + ]; + + // https://help.mikrotik.com/docs/spaces/ROS/pages/47579229/Scripting#Scripting-Menuspecificcommands + const MENU_SPECIFIC_COMMANDS = [ + 'add', + 'disable', + 'edit', + 'enable', + 'export', + 'find', + 'flush', + 'get', + 'import', + 'monitor-traffic', + 'print', + 'quit', + 'redo', + 'remove', + 'send', + 'set', + 'undo', + ]; + + // Universal boolean-like values that appear across all RouterOS menus + const PROPERTY_VALUES = ['auto', 'disabled', 'enabled', 'no', 'none', 'yes']; + + // https://help.mikrotik.com/docs/spaces/ROS/pages/47579229/Scripting#Scripting-printparameters + const PRINT_PARAMETERS = [ + 'about', + 'append', + 'as-string', + 'as-value', + 'brief', + 'count-only', + 'detail', + 'file', + 'follow', + 'follow-only', + 'from', + 'interval', + 'terse', + 'value-list', + 'where', + 'without-paging', + ]; + + // https://help.mikrotik.com/docs/spaces/ROS/pages/47579229/Scripting#Scripting-Datatypes + const DATA_TYPES = { + 'internal-id': { + pattern: /(?<=\s)\*(?:[1-9a-f][\da-f]*|0)\b/i, + alias: 'constant', + }, + 'ip-address': [ + { + pattern: /\b\d{1,3}(?:\.\d{1,3}){3}\/(?:[0-2]?\d|3[0-2])\b/, + alias: ['ip-prefix', 'constant'], + }, + { + pattern: /\b\d{1,3}(?:\.\d{1,3}){3}\b/, + alias: 'constant', + }, + ], + 'ip6-address': [ + { + pattern: + /(? { + if (value instanceof RegExp) { + return { + pattern: value, + alias: key, + }; + } + + if (Array.isArray(value)) { + return value.map(v => ({ + ...v, + alias: addAlias(key, v.alias), + })); + } + + return { + ...value, + alias: addAlias(key, value.alias), + }; + }) + .flat(); + } + + /** + * Builds a regex pattern that matches nested structures with a limited depth. + * We use it to match balanced delimiters, such as parentheses, brackets, or custom tokens (e.g., `$[...]` and `$(...)`). + * + * @param {string} openToken e.g. `$(` or `(` or `[` + * @param {string} closeToken e.g. `)` or `]` + * @param {object} options + * @param {number} [options.depth=3] maximum nesting depth + * @param {string} [options.flags=''] regex flags + * @param {boolean} [options.captureInner=false] whether to capture the open/close tokens or use lookarounds to get inner content only + * @returns {RegExp} + */ + function buildNestedRegex ( + openToken, + closeToken, + { depth = 3, flags = '', captureInner = false } = {} + ) { + // Escape characters for regex patterns + const escape = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + + // last char of openToken is the nesting char; prefix is anything before it (e.g. '$' from '$(') + let [prefix, openerChar] = openToken; + if (!openerChar) { + // openToken is a single character string; prefix is empty + prefix = ''; + } + else { + // openToken is a string with a prefix and a single character; remove the prefix from openToken + openToken = openerChar; + } + + prefix = escape(prefix); + openToken = escape(openToken); + closeToken = escape(closeToken); + + // Single non-special char (or escaped char, or line continuation) + // Line continuation: backslash followed by optional \r and required \n + const atom = `[^${openToken}${closeToken}\\\\]|${/\\./.source}|\\\\\\r?\\n`; + + // Build depth-limited pattern recursively: + // content_0 = (?:atom)+ + // content_k = (?: atom | opener content_{k-1} closer )+ + // Each outer iteration matches exactly one atom or one nested pair, + // avoiding nested quantifiers that cause exponential backtracking. + let prev = `(?:${atom})+`; // content_0 + for (let level = 1; level <= depth; level++) { + prev = `(?:(?:${atom})|(?:${openToken}${prev}${closeToken}))+`; + } + + return new RegExp( + captureInner + ? `(?<=${prefix}${openToken})${prev}(?=${closeToken})` + : `${prefix}${openToken}${prev}${closeToken}`, + flags + ); + } + + // Shared inside grammar for string tokens (top-level strings and array string items) + const STRING_INSIDE = { + // Expressions inside strings: `$[...]` and `$(...)` + // https://help.mikrotik.com/docs/spaces/ROS/pages/47579229/Scripting#Scripting-ConcatenationOperators + 'expression': { + pattern: new RegExp( + buildNestedRegex('$(', ')').source + '|' + buildNestedRegex('$[', ']').source + ), + inside: { + 'begin-of-expression': { + pattern: /^\$[[(]/, + alias: 'punctuation', + }, + 'end-of-expression': { + pattern: /[\])]$/, + alias: 'punctuation', + }, + $rest: 'mikrotik', + }, + }, + 'variable': VARIABLE_PATTERNS, + 'substitution-operator': { + pattern: /\$$/, + alias: 'operator', + }, + // https://help.mikrotik.com/docs/spaces/ROS/pages/47579229/Scripting#Scripting-ConstantEscapeSequences + 'escape-sequence': { + pattern: /\\(?:["\\nrt$_abfv]|[0-9A-F]{2})/, + alias: 'char', + }, + }; + + return { + 'comment': { + pattern: /(?<=^|\s)#.*$/m, + greedy: true, + }, + + // Note: Only arrays defined in one line are supported; they should always end with `;`! + // Arrays appear in assignments or function calls, not as standalone scopes + 'array': { + // Match `{ ... }` that is NOT after `do=`, `else=`, and appears in array contexts + // Arrays are typically: :local arr {1;2;3}, :put {1;2;3}, or {1;2;3},5 + // Scopes are: `do={...}`, `else={...}`, or standalone blocks + // Exclude `do={` and `else={` by checking that we're not immediately after `do=` or `else=` + pattern: /(? ({ + ...v, + pattern: new RegExp(`${v.pattern.source}(?=;|$)`, v.pattern.flags), + })), + // String array items: "hello", "escaped \n", "interpolated $var" + { + pattern: /"(?:\\.|[^"\\])*"(?=;|$)/, + alias: 'string', + inside: STRING_INSIDE, + }, + + // Fallback for values without keys and unknown data type + /(?<=[\s{};]|^)[^;=]+(?=;|$)/, + ], + 'array-item-delimiter': { + pattern: /;/, + alias: 'punctuation', + }, + }, + }, + + 'regex': { + // Optionally captures the parameter name before `~`: `name~"pattern"` + pattern: /(?:(?<=\s)[a-z][\w-]+)?~"(?:\\.|[^"\\])*"/i, + greedy: true, + inside: { + 'parameter': { + // Parameter name before the `~` operator: `name` in `name~"pattern"` + pattern: /^[a-z][\w-]+/i, + alias: 'property', + }, + 'operator': { + pattern: /^~/, + alias: 'regex-operator', + }, + 'punctuation': /^"|"$/, + // Variables are interpolated inside regex strings, just like in regular strings. + // Unlike VARIABLE_PATTERNS (which use lookbehind and leave `$` as a text node), + // these patterns include `$` in the match so it isn't claimed by the regex + // language's `anchor` token via `$rest: 'regex'`. + // Only simple `$varName` references are supported here. Quoted variable references + // (`$"var-name"`) would appear as `$\"var-name\"` inside a regex string, but + // this is too niche to support now — add if real-world demand arises. + 'variable': [ + { + pattern: /\$[a-z\d]+/i, + greedy: true, + inside: { + 'substitution-operator': { + pattern: /^\$/, + alias: 'operator', + }, + }, + }, + ], + $rest: 'regex', + }, + }, + + // https://help.mikrotik.com/docs/spaces/ROS/pages/47579229/Scripting#Scripting-Variables + 'variable': [ + // Definition + { + // Quoted variable after `:(global|local|set)`: `"var-name"` + // Use (?:[^"\\]|\\.)* instead of [^"\\]*(?:\\.[^"\\]*)* to avoid exponential backtracking + pattern: /(?<=:(?:global|local|set)\s+)"(?:[^"\\]|\\.)*"/, + greedy: true, + }, + { + // Unquoted variable in declaration or loop: `varName` + // Covers `:global varName`, `:local varName`, `:set varName`, `:for varName`, `:foreach varName` + pattern: /(?<=:(?:for|foreach|global|local|set)\s+)[A-Za-z\d]+/, + greedy: true, + }, + + // Reference + ...VARIABLE_PATTERNS, + ], + + 'subexpression': { + // Depth 3 nested parentheses + pattern: buildNestedRegex('(', ')', { + captureInner: true, + }), + greedy: true, + inside: 'mikrotik', + }, + + 'command-substitution': { + // Depth 3 nested square brackets + pattern: buildNestedRegex('[', ']', { + captureInner: true, + }), + greedy: true, + alias: 'command-concatenation', + inside: 'mikrotik', + }, + + 'string': { + pattern: + /(?/, + alias: 'access-array-element-operator', + }, + { + // https://help.mikrotik.com/docs/spaces/ROS/pages/47579229/Scripting#Scripting-ArithmeticOperators + // `-` is excluded when flanked by letters on both sides to avoid matching hyphens + // in kebab-case identifiers (e.g., `target-address`, `src-address`). + // It still matches unary (`-42`), binary with spaces (`$a - $b`), and + // binary between non-letter tokens (`a-3` where `a` is a hex digit). + pattern: /[%*/+]|(?>|[~|^&]/, + alias: 'bitwise-operator', + }, + { + // https://help.mikrotik.com/docs/spaces/ROS/pages/47579229/Scripting#Scripting-RelationalOperators + pattern: /[<>]=?|!=/, + alias: 'relational-operator', + }, + { + // https://help.mikrotik.com/docs/spaces/ROS/pages/47579229/Scripting#Scripting-ConcatenationOperators + // Matches dots and commas used for concatenation, avoiding false positives in filenames (e.g., `file.txt`) + // Matches when NOT between two word characters (excludes `file.txt`) OR when adjacent to quotes/brackets/operators + pattern: /\B[.,]\B|(?<=[")\]}$])[.,]|[.,](?=["[({$])/, + alias: 'concatenation-operator', + }, + { + // https://help.mikrotik.com/docs/spaces/ROS/pages/47579229/Scripting#Scripting-OtherOperators + // Matches parentheses used for grouping (e.g., `(4+5)`) + // Note: `subexpression` uses `captureInner: true`, so parentheses are still available here + pattern: /[()]/, + alias: 'grouping-operator', + }, + { + // Substitution operator: matches $ followed by a variable + // Examples: $"my-var", $myVar, $varName + // https://help.mikrotik.com/docs/spaces/ROS/pages/47579229/Scripting#Scripting-OtherOperators + pattern: /\$$/, + alias: 'substitution-operator', + }, + /=/, + ], + + 'line-joining': { + // https://help.mikrotik.com/docs/spaces/ROS/pages/47579229/Scripting#Scripting-Linejoining + pattern: /\\(?=\r?\n|$)/m, + alias: 'punctuation', + }, + + 'begin-of-scope': { + pattern: /\{/, + alias: 'punctuation', + }, + + 'end-of-scope': { + pattern: /\}/, + alias: 'punctuation', + }, + + 'punctuation': /[[\]"]/, + }; + }, +}; diff --git a/tests/languages/mikrotik!+regex/regex_inclusion.test b/tests/languages/mikrotik!+regex/regex_inclusion.test new file mode 100644 index 000000000..c2488427e --- /dev/null +++ b/tests/languages/mikrotik!+regex/regex_inclusion.test @@ -0,0 +1,98 @@ +find name~"^[a-z]+\d?$" +find name~"prefix$varName\.\d+" +find src-address~"(10|192)\." +[find name~"\\.$currentQueue$"] +[find ~"test"] + +---------------------------------------------------- + +[ + ["command", "find"], + ["regex", [ + ["parameter", "name"], + ["operator", "~"], + ["punctuation", "\""], + ["anchor", "^"], + ["char-class", [ + ["char-class-punctuation", "["], + ["range", [ + "a", + ["range-punctuation", "-"], + "z" + ]], + ["char-class-punctuation", "]"] + ]], + ["quantifier", "+"], + ["char-set", "\\d"], + ["quantifier", "?"], + ["anchor", "$"], + ["punctuation", "\""] + ]], + + ["command", "find"], + ["regex", [ + ["parameter", "name"], + ["operator", "~"], + ["punctuation", "\""], + "prefix", + ["variable", [ + ["substitution-operator", "$"], + "varName" + ]], + ["special-escape", "\\."], + ["char-set", "\\d"], + ["quantifier", "+"], + ["punctuation", "\""] + ]], + + ["command", "find"], + ["regex", [ + ["parameter", "src-address"], + ["operator", "~"], + ["punctuation", "\""], + ["group", ["("]], + "10", + ["alternation", "|"], + "192", + ["group", ")"], + ["special-escape", "\\."], + ["punctuation", "\""] + ]], + + ["punctuation", "["], + ["command-substitution", [ + ["command", "find"], + ["regex", [ + ["parameter", "name"], + ["operator", "~"], + ["punctuation", "\""], + ["special-escape", "\\\\"], + ["char-set", "."], + ["variable", [ + ["substitution-operator", "$"], + "currentQueue" + ]], + ["anchor", "$"], + ["punctuation", "\""] + ]] + ]], + ["punctuation", "]"], + + ["punctuation", "["], + ["command-substitution", [ + ["command", "find"], + ["regex", [ + ["operator", "~"], + ["punctuation", "\""], + "test", + ["punctuation", "\""] + ]] + ]], + ["punctuation", "]"] +] + +---------------------------------------------------- + +Tests that the regex language is properly embedded inside MikroTik regex strings +via `$rest: 'regex'`. Also tests that `$` before variable names in regex strings +is tokenized as `substitution-operator` (not as the regex `anchor` token). diff --git a/tests/languages/mikrotik/array_feature.test b/tests/languages/mikrotik/array_feature.test new file mode 100644 index 000000000..ea90ef2b0 --- /dev/null +++ b/tests/languages/mikrotik/array_feature.test @@ -0,0 +1,239 @@ +:local arr {1;2;3}; +:local arr2 {"key1"=val1;"key2"=val2}; +:local arr3 {true;false;true}; +:local arr4 {192.168.1.1;10.0.0.1}; +:local arr5 {"hello";"world"}; +:local arr6 {"escaped \n \t";"$myVar"}; +:local arr7 {"key1"=val1;"key2"=val2;"key3"=val3}; +:local arr8 { *1A; *0}; +:local arr9 {10.0.0.0/24;192.168.0.0/16}; +:local arr10 {2001:db8::/32;::1}; +:local arr11 {may/13/1982 16:30:15;jan/01/2024}; +:local arr12 {16:30:15;1d2h}; +:local arr13 {ether1;wlan1}; + +---------------------------------------------------- + +[ + ["prefix", ":"], + ["command", "local"], + ["variable", "arr"], + ["array", [ + ["punctuation", "{"], + ["array-item", "1"], + ["array-item-delimiter", ";"], + ["array-item", "2"], + ["array-item-delimiter", ";"], + ["array-item", "3"], + ["punctuation", "}"] + ]], + ["end-of-command", ";"], + + ["prefix", ":"], + ["command", "local"], + ["variable", "arr2"], + ["array", [ + ["punctuation", "{"], + ["array-item", [ + ["key", [ + ["punctuation", "\""], + "key1", + ["punctuation", "\""] + ]], + ["operator", "="], + ["value", "val1"] + ]], + ["array-item-delimiter", ";"], + ["array-item", [ + ["key", [ + ["punctuation", "\""], + "key2", + ["punctuation", "\""] + ]], + ["operator", "="], + ["value", "val2"] + ]], + ["punctuation", "}"] + ]], + ["end-of-command", ";"], + + ["prefix", ":"], + ["command", "local"], + ["variable", "arr3"], + ["array", [ + ["punctuation", "{"], + ["array-item", "true"], + ["array-item-delimiter", ";"], + ["array-item", "false"], + ["array-item-delimiter", ";"], + ["array-item", "true"], + ["punctuation", "}"] + ]], + ["end-of-command", ";"], + + ["prefix", ":"], + ["command", "local"], + ["variable", "arr4"], + ["array", [ + ["punctuation", "{"], + ["array-item", "192.168.1.1"], + ["array-item-delimiter", ";"], + ["array-item", "10.0.0.1"], + ["punctuation", "}"] + ]], + ["end-of-command", ";"], + + ["prefix", ":"], + ["command", "local"], + ["variable", "arr5"], + ["array", [ + ["punctuation", "{"], + ["array-item", ["\"hello\""]], + ["array-item-delimiter", ";"], + ["array-item", ["\"world\""]], + ["punctuation", "}"] + ]], + ["end-of-command", ";"], + + ["prefix", ":"], + ["command", "local"], + ["variable", "arr6"], + ["array", [ + ["punctuation", "{"], + ["array-item", [ + "\"escaped ", + ["escape-sequence", "\\n"], + ["escape-sequence", "\\t"], + "\"" + ]], + ["array-item-delimiter", ";"], + ["array-item", [ + "\"", + ["substitution-operator", "$"], + ["variable", "myVar"], + "\"" + ]], + ["punctuation", "}"] + ]], + ["end-of-command", ";"], + + ["prefix", ":"], + ["command", "local"], + ["variable", "arr7"], + ["array", [ + ["punctuation", "{"], + ["array-item", [ + ["key", [ + ["punctuation", "\""], + "key1", + ["punctuation", "\""] + ]], + ["operator", "="], + ["value", "val1"] + ]], + ["array-item-delimiter", ";"], + ["array-item", [ + ["key", [ + ["punctuation", "\""], + "key2", + ["punctuation", "\""] + ]], + ["operator", "="], + ["value", "val2"] + ]], + ["array-item-delimiter", ";"], + ["array-item", [ + ["key", [ + ["punctuation", "\""], + "key3", + ["punctuation", "\""] + ]], + ["operator", "="], + ["value", "val3"] + ]], + ["punctuation", "}"] + ]], + ["end-of-command", ";"], + + ["prefix", ":"], + ["command", "local"], + ["variable", "arr8"], + ["array", [ + ["punctuation", "{"], + ["array-item", " "], + ["array-item", "*1A"], + ["array-item-delimiter", ";"], + ["array-item", " "], + ["array-item", "*0"], + ["punctuation", "}"] + ]], + ["end-of-command", ";"], + + ["prefix", ":"], + ["command", "local"], + ["variable", "arr9"], + ["array", [ + ["punctuation", "{"], + ["array-item", "10.0.0.0/24"], + ["array-item-delimiter", ";"], + ["array-item", "192.168.0.0/16"], + ["punctuation", "}"] + ]], + ["end-of-command", ";"], + + ["prefix", ":"], + ["command", "local"], + ["variable", "arr10"], + ["array", [ + ["punctuation", "{"], + ["array-item", "2001:db8::/32"], + ["array-item-delimiter", ";"], + ["array-item", "::1"], + ["punctuation", "}"] + ]], + ["end-of-command", ";"], + + ["prefix", ":"], + ["command", "local"], + ["variable", "arr11"], + ["array", [ + ["punctuation", "{"], + ["array-item", "may/13/1982 16:30:15"], + ["array-item-delimiter", ";"], + ["array-item", "jan/01/2024"], + ["punctuation", "}"] + ]], + ["end-of-command", ";"], + + ["prefix", ":"], + ["command", "local"], + ["variable", "arr12"], + ["array", [ + ["punctuation", "{"], + ["array-item", "16:30:15"], + ["array-item-delimiter", ";"], + ["array-item", "1d2h"], + ["punctuation", "}"] + ]], + ["end-of-command", ";"], + + ["prefix", ":"], + ["command", "local"], + ["variable", "arr13"], + ["array", [ + ["punctuation", "{"], + ["array-item", "ether1"], + ["array-item-delimiter", ";"], + ["array-item", "wlan1"], + ["punctuation", "}"] + ]], + ["end-of-command", ";"] +] + +---------------------------------------------------- + +Tests the `array` token: numeric arrays, key-value arrays, boolean arrays, IP address +arrays, string arrays, arrays with escape sequences and variable interpolation inside +string items, multi-pair key-value arrays. Also tests array items with all data types: +internal IDs, IP prefixes, IPv6 addresses and prefixes, date-times, dates, times, +durations, and bare fallback items. diff --git a/tests/languages/mikrotik/boolean_feature.test b/tests/languages/mikrotik/boolean_feature.test new file mode 100644 index 000000000..d342140d7 --- /dev/null +++ b/tests/languages/mikrotik/boolean_feature.test @@ -0,0 +1,9 @@ +true +false + +---------------------------------------------------- + +[ + ["boolean", "true"], + ["boolean", "false"] +] diff --git a/tests/languages/mikrotik/command-substitution_feature.test b/tests/languages/mikrotik/command-substitution_feature.test new file mode 100644 index 000000000..c758f037c --- /dev/null +++ b/tests/languages/mikrotik/command-substitution_feature.test @@ -0,0 +1,61 @@ +:local x [/ip address get [find interface="ether1"] address] +:local n [:len $myStr] +:local id [/ip firewall address-list find list="restricted"] + +---------------------------------------------------- + +[ + ["prefix", ":"], + ["command", "local"], + ["variable", "x"], + ["punctuation", "["], + ["command-substitution", [ + ["prefix", "/"], + ["command", "ip"], + " address ", + ["command", "get"], + ["punctuation", "["], + ["command-substitution", [ + ["command", "find"], + ["parameter", "interface"], + ["operator", "="], + ["string", ["\"ether1\""]] + ]], + ["punctuation", "]"], + " address" + ]], + ["punctuation", "]"], + + ["prefix", ":"], + ["command", "local"], + ["variable", "n"], + ["punctuation", "["], + ["command-substitution", [ + ["prefix", ":"], + ["command", "len"], + ["operator", "$"], + ["variable", "myStr"] + ]], + ["punctuation", "]"], + + ["prefix", ":"], + ["command", "local"], + ["variable", "id"], + ["punctuation", "["], + ["command-substitution", [ + ["prefix", "/"], + ["command", "ip"], + " firewall address-list ", + ["command", "find"], + ["parameter", "list"], + ["operator", "="], + ["string", ["\"restricted\""]] + ]], + ["punctuation", "]"] +] + +---------------------------------------------------- + +Tests the `command-substitution` token: nested `[...]` substitutions (depth 2), +global command substitution (`:len $var`), and menu path substitution +(`/ip firewall address-list find`). diff --git a/tests/languages/mikrotik/comment_feature.test b/tests/languages/mikrotik/comment_feature.test new file mode 100644 index 000000000..16a03fb41 --- /dev/null +++ b/tests/languages/mikrotik/comment_feature.test @@ -0,0 +1,21 @@ +# This is a comment +:put "hello" # inline comment + # indented comment + +---------------------------------------------------- + +[ + ["comment", "# This is a comment"], + + ["prefix", ":"], + ["command", "put"], + ["string", ["\"hello\""]], + ["comment", "# inline comment"], + + ["comment", "# indented comment"] +] + +---------------------------------------------------- + +Tests the `comment` token: standalone line comments, inline comments after code, +and indented comments. diff --git a/tests/languages/mikrotik/date-time_feature.test b/tests/languages/mikrotik/date-time_feature.test new file mode 100644 index 000000000..a6e54078c --- /dev/null +++ b/tests/languages/mikrotik/date-time_feature.test @@ -0,0 +1,15 @@ +may/13/1982 16:30:15 +jan/01/2024 0:0:0 +mar/05/2026 12:0:0.5 +MAY/13/1982 16:30:15 +Jan/01/2024 0:0:0 + +---------------------------------------------------- + +[ + ["date-time", "may/13/1982 16:30:15"], + ["date-time", "jan/01/2024 0:0:0"], + ["date-time", "mar/05/2026 12:0:0.5"], + ["date-time", "MAY/13/1982 16:30:15"], + ["date-time", "Jan/01/2024 0:0:0"] +] diff --git a/tests/languages/mikrotik/date_feature.test b/tests/languages/mikrotik/date_feature.test new file mode 100644 index 000000000..1086b6083 --- /dev/null +++ b/tests/languages/mikrotik/date_feature.test @@ -0,0 +1,15 @@ +jan/01/2024 +may/13/1982 +dec/31/2000 +JAN/01/2024 +May/13/1982 + +---------------------------------------------------- + +[ + ["date", "jan/01/2024"], + ["date", "may/13/1982"], + ["date", "dec/31/2000"], + ["date", "JAN/01/2024"], + ["date", "May/13/1982"] +] diff --git a/tests/languages/mikrotik/end-of-command_feature.test b/tests/languages/mikrotik/end-of-command_feature.test new file mode 100644 index 000000000..11016881c --- /dev/null +++ b/tests/languages/mikrotik/end-of-command_feature.test @@ -0,0 +1,16 @@ +:put "hello"; +:local x; + +---------------------------------------------------- + +[ + ["prefix", ":"], + ["command", "put"], + ["string", ["\"hello\""]], + ["end-of-command", ";"], + + ["prefix", ":"], + ["command", "local"], + ["variable", "x"], + ["end-of-command", ";"] +] diff --git a/tests/languages/mikrotik/global-command_feature.test b/tests/languages/mikrotik/global-command_feature.test new file mode 100644 index 000000000..bf4d29a8a --- /dev/null +++ b/tests/languages/mikrotik/global-command_feature.test @@ -0,0 +1,83 @@ +:beep +:delay +:deserialize +:environment +:error +:execute +:find +:global +:jobname +:len +:local +:log +:nothing +:onerror +:parse +:pick +:put +:range +:resolve +:retry +:rndnum +:rndstr +:serialize +:set +:time +:timestamp +:toarray +:tobool +:tocrlf +:toid +:toip +:toip6 +:tolf +:tonsec +:tonum +:tostr +:totime +:typeof +:return + +---------------------------------------------------- + +[ + ["prefix", ":"], ["command", "beep"], + ["prefix", ":"], ["command", "delay"], + ["prefix", ":"], ["command", "deserialize"], + ["prefix", ":"], ["command", "environment"], + ["prefix", ":"], ["command", "error"], + ["prefix", ":"], ["command", "execute"], + ["prefix", ":"], ["command", "find"], + ["prefix", ":"], ["command", "global"], + ["prefix", ":"], ["command", "jobname"], + ["prefix", ":"], ["command", "len"], + ["prefix", ":"], ["command", "local"], + ["prefix", ":"], ["command", "log"], + ["prefix", ":"], ["command", "nothing"], + ["prefix", ":"], ["command", "onerror"], + ["prefix", ":"], ["command", "parse"], + ["prefix", ":"], ["command", "pick"], + ["prefix", ":"], ["command", "put"], + ["prefix", ":"], ["command", "range"], + ["prefix", ":"], ["command", "resolve"], + ["prefix", ":"], ["command", "retry"], + ["prefix", ":"], ["command", "rndnum"], + ["prefix", ":"], ["command", "rndstr"], + ["prefix", ":"], ["command", "serialize"], + ["prefix", ":"], ["command", "set"], + ["prefix", ":"], ["command", "time"], + ["prefix", ":"], ["command", "timestamp"], + ["prefix", ":"], ["command", "toarray"], + ["prefix", ":"], ["command", "tobool"], + ["prefix", ":"], ["command", "tocrlf"], + ["prefix", ":"], ["command", "toid"], + ["prefix", ":"], ["command", "toip"], + ["prefix", ":"], ["command", "toip6"], + ["prefix", ":"], ["command", "tolf"], + ["prefix", ":"], ["command", "tonsec"], + ["prefix", ":"], ["command", "tonum"], + ["prefix", ":"], ["command", "tostr"], + ["prefix", ":"], ["command", "totime"], + ["prefix", ":"], ["command", "typeof"], + ["prefix", ":"], ["command", "return"] +] diff --git a/tests/languages/mikrotik/internal-id_feature.test b/tests/languages/mikrotik/internal-id_feature.test new file mode 100644 index 000000000..218b72e58 --- /dev/null +++ b/tests/languages/mikrotik/internal-id_feature.test @@ -0,0 +1,15 @@ +get *0 +get *1A +get *ff +get *FF +get *1a + +---------------------------------------------------- + +[ + ["command", "get"], ["internal-id", "*0"], + ["command", "get"], ["internal-id", "*1A"], + ["command", "get"], ["internal-id", "*ff"], + ["command", "get"], ["internal-id", "*FF"], + ["command", "get"], ["internal-id", "*1a"] +] diff --git a/tests/languages/mikrotik/ip-address_feature.test b/tests/languages/mikrotik/ip-address_feature.test new file mode 100644 index 000000000..f4349a7e1 --- /dev/null +++ b/tests/languages/mikrotik/ip-address_feature.test @@ -0,0 +1,13 @@ +192.168.1.1 +10.0.0.0/24 +0.0.0.0 +255.255.255.255/32 + +---------------------------------------------------- + +[ + ["ip-address", "192.168.1.1"], + ["ip-address", "10.0.0.0/24"], + ["ip-address", "0.0.0.0"], + ["ip-address", "255.255.255.255/32"] +] diff --git a/tests/languages/mikrotik/ip6-address_feature.test b/tests/languages/mikrotik/ip6-address_feature.test new file mode 100644 index 000000000..dafb4207e --- /dev/null +++ b/tests/languages/mikrotik/ip6-address_feature.test @@ -0,0 +1,21 @@ +::1 +fe80::1 +2001:db8::1 +2001:db8:0:0:0:0:0:1 +fe80::1/64 +::/0 +FE80::1 +2001:DB8::1 + +---------------------------------------------------- + +[ + ["ip6-address", "::1"], + ["ip6-address", "fe80::1"], + ["ip6-address", "2001:db8::1"], + ["ip6-address", "2001:db8:0:0:0:0:0:1"], + ["ip6-address", "fe80::1/64"], + ["ip6-address", "::/0"], + ["ip6-address", "FE80::1"], + ["ip6-address", "2001:DB8::1"] +] diff --git a/tests/languages/mikrotik/keyword_feature.test b/tests/languages/mikrotik/keyword_feature.test new file mode 100644 index 000000000..749371eae --- /dev/null +++ b/tests/languages/mikrotik/keyword_feature.test @@ -0,0 +1,29 @@ +do= +else= +for= +foreach= +from= +if= +in= +on-error= +once +step= +to= +while= + +---------------------------------------------------- + +[ + ["keyword", "do"], ["operator", "="], + ["keyword", "else"], ["operator", "="], + ["keyword", "for"], ["operator", "="], + ["keyword", "foreach"], ["operator", "="], + ["keyword", "from"], ["operator", "="], + ["keyword", "if"], ["operator", "="], + ["keyword", "in"], ["operator", "="], + ["keyword", "on-error"], ["operator", "="], + ["keyword", "once"], + ["keyword", "step"], ["operator", "="], + ["keyword", "to"], ["operator", "="], + ["keyword", "while"], ["operator", "="] +] diff --git a/tests/languages/mikrotik/line-joining_feature.test b/tests/languages/mikrotik/line-joining_feature.test new file mode 100644 index 000000000..d7b6688b4 --- /dev/null +++ b/tests/languages/mikrotik/line-joining_feature.test @@ -0,0 +1,9 @@ +:put \ +"hello" + +---------------------------------------------------- + +[ + ["prefix", ":"], ["command", "put"], ["line-joining", "\\"], + ["string", ["\"hello\""]] +] diff --git a/tests/languages/mikrotik/menu-command_feature.test b/tests/languages/mikrotik/menu-command_feature.test new file mode 100644 index 000000000..3230f48df --- /dev/null +++ b/tests/languages/mikrotik/menu-command_feature.test @@ -0,0 +1,39 @@ +add +disable +edit +enable +export +find +flush +get +import +monitor-traffic +print +quit +redo +remove +send +set +undo + +---------------------------------------------------- + +[ + ["command", "add"], + ["command", "disable"], + ["command", "edit"], + ["command", "enable"], + ["command", "export"], + ["command", "find"], + ["command", "flush"], + ["command", "get"], + ["command", "import"], + ["command", "monitor-traffic"], + ["command", "print"], + ["command", "quit"], + ["command", "redo"], + ["command", "remove"], + ["command", "send"], + ["command", "set"], + ["command", "undo"] +] diff --git a/tests/languages/mikrotik/menu-path_feature.test b/tests/languages/mikrotik/menu-path_feature.test new file mode 100644 index 000000000..fa864f6e8 --- /dev/null +++ b/tests/languages/mikrotik/menu-path_feature.test @@ -0,0 +1,42 @@ +/ip +/ip/firewall/filter +/interface/bridge +/routing/ospf/area +/system/scheduler +/ip/dhcp-server + +---------------------------------------------------- + +[ + ["prefix", "/"], + ["command", "ip"], + + ["prefix", "/"], + ["command", "ip"], + ["prefix", "/"], + ["command", "firewall"], + ["prefix", "/"], + ["command", "filter"], + + ["prefix", "/"], + ["command", "interface"], + ["prefix", "/"], + ["command", "bridge"], + + ["prefix", "/"], + ["command", "routing"], + ["prefix", "/"], + ["command", "ospf"], + ["prefix", "/"], + ["command", "area"], + + ["prefix", "/"], + ["command", "system"], + ["prefix", "/"], + ["command", "scheduler"], + + ["prefix", "/"], + ["command", "ip"], + ["prefix", "/"], + ["command", "dhcp-server"] +] diff --git a/tests/languages/mikrotik/number_edge-case_feature.test b/tests/languages/mikrotik/number_edge-case_feature.test new file mode 100644 index 000000000..20427b813 --- /dev/null +++ b/tests/languages/mikrotik/number_edge-case_feature.test @@ -0,0 +1,16 @@ +e-mail +b-tree +a-record + +---------------------------------------------------- + +[ + "e-mail\r\nb-tree\r\na-record" +] + +---------------------------------------------------- + +Kebab-case identifiers whose first component is a single hex letter (a-f) must not +produce a number token for that letter or an operator token for the hyphen. +Without the (?!-[a-z]) guard, `e` would be consumed as a number, leaving `-mail` +as a new segment where `(? ++ +* +/ +% +- +! +&& +|| +and +or +in +<< +>> +~ +& +^ +| +< +> +<= +>= +!= +. +, +() += +$ + +---------------------------------------------------- + +[ + ["operator", "->"], + ["operator", "+"], + ["operator", "*"], + ["operator", "/"], + ["operator", "%"], + ["operator", "-"], + ["operator", "!"], + ["operator", "&&"], + ["operator", "||"], + ["operator", "and"], + ["operator", "or"], + ["operator", "in"], + ["operator", "<<"], + ["operator", ">>"], + ["operator", "~"], + ["operator", "&"], + ["operator", "^"], + ["operator", "|"], + ["operator", "<"], + ["operator", ">"], + ["operator", "<="], + ["operator", ">="], + ["operator", "!="], + ["operator", "."], + ["operator", ","], + ["operator", "("], ["operator", ")"], + ["operator", "="], + ["operator", "$"] +] diff --git a/tests/languages/mikrotik/parameter_feature.test b/tests/languages/mikrotik/parameter_feature.test new file mode 100644 index 000000000..4466abfbe --- /dev/null +++ b/tests/languages/mikrotik/parameter_feature.test @@ -0,0 +1,43 @@ +add src-address= +add dst-address= +add connection-state= +print about +print append +print as-string +print as-value +print brief +print count-only +print detail +print file= +print follow +print follow-only +print from= +print interval= +print terse +print value-list +print where +print without-paging + +---------------------------------------------------- + +[ + ["command", "add"], ["parameter", "src-address"], ["operator", "="], + ["command", "add"], ["parameter", "dst-address"], ["operator", "="], + ["command", "add"], ["parameter", "connection-state"], ["operator", "="], + ["command", "print"], ["parameter", "about"], + ["command", "print"], ["parameter", "append"], + ["command", "print"], ["parameter", "as-string"], + ["command", "print"], ["parameter", "as-value"], + ["command", "print"], ["parameter", "brief"], + ["command", "print"], ["parameter", "count-only"], + ["command", "print"], ["parameter", "detail"], + ["command", "print"], ["parameter", "file"], ["operator", "="], + ["command", "print"], ["parameter", "follow"], + ["command", "print"], ["parameter", "follow-only"], + ["command", "print"], ["keyword", "from"], ["operator", "="], + ["command", "print"], ["parameter", "interval"], ["operator", "="], + ["command", "print"], ["parameter", "terse"], + ["command", "print"], ["parameter", "value-list"], + ["command", "print"], ["parameter", "where"], + ["command", "print"], ["parameter", "without-paging"] +] diff --git a/tests/languages/mikrotik/property-value_feature.test b/tests/languages/mikrotik/property-value_feature.test new file mode 100644 index 000000000..fa05aff2e --- /dev/null +++ b/tests/languages/mikrotik/property-value_feature.test @@ -0,0 +1,21 @@ +=auto +=disabled +=enabled +=no +=none +=yes +,yes +,no + +---------------------------------------------------- + +[ + ["operator", "="], ["property-value", "auto"], + ["operator", "="], ["property-value", "disabled"], + ["operator", "="], ["property-value", "enabled"], + ["operator", "="], ["property-value", "no"], + ["operator", "="], ["property-value", "none"], + ["operator", "="], ["property-value", "yes"], + ["operator", ","], ["property-value", "yes"], + ["operator", ","], ["property-value", "no"] +] diff --git a/tests/languages/mikrotik/scope_feature.test b/tests/languages/mikrotik/scope_feature.test new file mode 100644 index 000000000..629b2d36f --- /dev/null +++ b/tests/languages/mikrotik/scope_feature.test @@ -0,0 +1,52 @@ +:if (true) do={ :put 1 } +:if (true) do={ :if (false) do={ :put 2 } } + +---------------------------------------------------- + +[ + ["prefix", ":"], + ["keyword", "if"], + ["operator", "("], + ["subexpression", [ + ["boolean", "true"] + ]], + ["operator", ")"], + ["keyword", "do"], + ["operator", "="], + ["begin-of-scope", "{"], + ["prefix", ":"], + ["command", "put"], + ["number", "1"], + ["end-of-scope", "}"], + + ["prefix", ":"], + ["keyword", "if"], + ["operator", "("], + ["subexpression", [ + ["boolean", "true"] + ]], + ["operator", ")"], + ["keyword", "do"], + ["operator", "="], + ["begin-of-scope", "{"], + ["prefix", ":"], + ["keyword", "if"], + ["operator", "("], + ["subexpression", [ + ["boolean", "false"] + ]], + ["operator", ")"], + ["keyword", "do"], + ["operator", "="], + ["begin-of-scope", "{"], + ["prefix", ":"], + ["command", "put"], + ["number", "2"], + ["end-of-scope", "}"], + ["end-of-scope", "}"] +] + +---------------------------------------------------- + +Tests the `begin-of-scope` and `end-of-scope` tokens: `{` and `}` in code block +contexts (e.g., `do={...}`, `else={...}`). Also tests nested scopes. diff --git a/tests/languages/mikrotik/string_feature.test b/tests/languages/mikrotik/string_feature.test new file mode 100644 index 000000000..6f88cd84d --- /dev/null +++ b/tests/languages/mikrotik/string_feature.test @@ -0,0 +1,73 @@ +"hello world" +"escape sequences: \n \r \t \\ \" \$ \_ \61" +"variable: $myVar" +"quoted var: $\"my-var\"" +"expression: $[/system identity get name]" +"subexpr: $(1 + 2)" +"trailing dollar: $" + +---------------------------------------------------- + +[ + ["string", ["\"hello world\""]], + ["string", [ + "\"escape sequences: ", + ["escape-sequence", "\\n"], + ["escape-sequence", "\\r"], + ["escape-sequence", "\\t"], + ["escape-sequence", "\\\\"], + ["escape-sequence", "\\\""], + ["escape-sequence", "\\$"], + ["escape-sequence", "\\_"], + ["escape-sequence", "\\61"], + "\"" + ]], + ["string", [ + "\"variable: ", + ["substitution-operator", "$"], + ["variable", "myVar"], + "\"" + ]], + ["string", [ + "\"quoted var: $", + ["escape-sequence", "\\\""], + "my-var", + ["escape-sequence", "\\\""], + "\"" + ]], + ["string", [ + "\"expression: ", + ["expression", [ + ["begin-of-expression", "$["], + ["command-substitution", [ + ["prefix", "/"], + ["command", "system"], + " identity ", + ["command", "get"], + " name" + ]], + ["end-of-expression", "]"] + ]], + "\"" + ]], + ["string", [ + "\"subexpr: ", + ["expression", [ + ["begin-of-expression", "$("], + ["subexpression", [ + ["number", "1"], + ["operator", "+"], + ["number", "2"] + ]], + ["end-of-expression", ")"] + ]], + "\"" + ]], + ["string", ["\"trailing dollar: $\""]] +] + +---------------------------------------------------- + +Tests the `string` token: plain strings, escape sequences (`\n`, `\r`, `\t`, `\\`, +`\"`, `\$`, `\_`, octal), variable interpolation (`$var`), quoted variable names +(`$"var-name"`), command substitution inside strings, and a trailing literal `$`. diff --git a/tests/languages/mikrotik/time_feature.test b/tests/languages/mikrotik/time_feature.test new file mode 100644 index 000000000..225323484 --- /dev/null +++ b/tests/languages/mikrotik/time_feature.test @@ -0,0 +1,27 @@ +1:30:00 +0:45 +0:3:2.05 +1d +2h30m +45s +500ms +1w2d3h +1D +2H30M +1W2D3H + +---------------------------------------------------- + +[ + ["time", "1:30:00"], + ["time", "0:45"], + ["time", "0:3:2.05"], + ["time", "1d"], + ["time", "2h30m"], + ["time", "45s"], + ["time", "500ms"], + ["time", "1w2d3h"], + ["time", "1D"], + ["time", "2H30M"], + ["time", "1W2D3H"] +] diff --git a/tests/languages/mikrotik/variable_feature.test b/tests/languages/mikrotik/variable_feature.test new file mode 100644 index 000000000..4b6889539 --- /dev/null +++ b/tests/languages/mikrotik/variable_feature.test @@ -0,0 +1,27 @@ +:global myGlobal +:local myLocal +:set myVar +:local x1 +:global "my-global" +:local "my-local" +:for i +:foreach item +$myVar +$var1 +$"my-quoted-var" + +---------------------------------------------------- + +[ + ["prefix", ":"], ["command", "global"], ["variable", "myGlobal"], + ["prefix", ":"], ["command", "local"], ["variable", "myLocal"], + ["prefix", ":"], ["command", "set"], ["variable", "myVar"], + ["prefix", ":"], ["command", "local"], ["variable", "x1"], + ["prefix", ":"], ["command", "global"], ["variable", "\"my-global\""], + ["prefix", ":"], ["command", "local"], ["variable", "\"my-local\""], + ["prefix", ":"], ["keyword", "for"], ["variable", "i"], + ["prefix", ":"], ["keyword", "foreach"], ["variable", "item"], + ["operator", "$"], ["variable", "myVar"], + ["operator", "$"], ["variable", "var1"], + ["operator", "$"], ["variable", "\"my-quoted-var\""] +]