diff --git a/.gitmodules b/.gitmodules index 748e50f5b1..2387ef3ad5 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1361,6 +1361,9 @@ [submodule "vendor/grammars/typst-grammar"] path = vendor/grammars/typst-grammar url = https://github.com/michidk/typst-grammar.git +[submodule "vendor/grammars/ucode-lsp"] + path = vendor/grammars/ucode-lsp + url = https://github.com/NoahBPeterson/ucode-lsp.git [submodule "vendor/grammars/verilog.tmbundle"] path = vendor/grammars/verilog.tmbundle url = https://github.com/textmate/verilog.tmbundle diff --git a/grammars.yml b/grammars.yml index a15a5790af..12a233eebb 100644 --- a/grammars.yml +++ b/grammars.yml @@ -1223,6 +1223,8 @@ vendor/grammars/typespec: - source.tsp vendor/grammars/typst-grammar: - source.typst +vendor/grammars/ucode-lsp: +- source.ucode vendor/grammars/verilog.tmbundle: - source.verilog vendor/grammars/vsc-ember-syntax: diff --git a/lib/linguist/heuristics.yml b/lib/linguist/heuristics.yml index c621d3ab4c..6085e82f0e 100644 --- a/lib/linguist/heuristics.yml +++ b/lib/linguist/heuristics.yml @@ -1019,6 +1019,11 @@ disambiguations: - language: Typst pattern: '^#(import|show|let|set)' - language: XML +- extensions: ['.uc'] + rules: + - language: ucode + named_pattern: ucode + - language: UnrealScript - extensions: ['.url'] rules: - language: INI @@ -1137,6 +1142,13 @@ named_patterns: - '(?i)^[ ]*\$(CONSOLE|CHECKING):' - '(?i)^[ ]*\$(FULLSCREEN|RESIZE|STATIC|DYNAMIC|NOPREFIX|SCREENSHOW|SCREENHIDE|EXEICON)\b' raku: '^\s*(?:use\s+v6\b|\bmodule\b|\b(?:my\s+)?class\b)' + ucode: + - '^#!.*\bucode\b' + - '^\s*[''"]use strict[''"];' + - '^[ \t]*(?:export|import)\b' + - '\b(?:require|include)\s*\(\s*[''"]' + - '^\s*let\s+\w+' + - '\b(?:printf|sprintf)\s*\(' vb-class: '^[ ]*VERSION [0-9]\.[0-9] CLASS' vb-form: '^[ ]*VERSION [0-9]\.[0-9]{2}' vb-module: '^[ ]*Attribute VB_Name = ' diff --git a/lib/linguist/languages.yml b/lib/linguist/languages.yml index 92b013df81..ec6157456b 100644 --- a/lib/linguist/languages.yml +++ b/lib/linguist/languages.yml @@ -9413,6 +9413,18 @@ templ: ace_mode: text tm_scope: source.templ language_id: 795579337 +ucode: + type: programming + color: "#00b8d4" + extensions: + - ".uc" + interpreters: + - ucode + ace_mode: javascript + codemirror_mode: javascript + codemirror_mime_type: text/javascript + tm_scope: source.ucode + language_id: 365454253 vCard: type: data color: "#ee2647" diff --git a/samples/ucode/commands.uc b/samples/ucode/commands.uc new file mode 100644 index 0000000000..9126d59eb0 --- /dev/null +++ b/samples/ucode/commands.uc @@ -0,0 +1,256 @@ +// Copyright 2012-2022 Jo-Philipp Wich +// Licensed to the public under the Apache License 2.0. + +'use strict'; + +import { basename, mkstemp, popen } from 'fs'; +import { urldecode } from 'luci.http'; + +// Decode a given string into arguments following shell quoting rules +// [[abc\ def "foo\"bar" abc'def']] -> [[abc def]] [[foo"bar]] [[abcdef]] +function parse_args(str) { + let args = []; + + function isspace(c) { + if (c == 9 || c == 10 || c == 11 || c == 12 || c == 13 || c == 32) + return c; + } + + function isquote(c) { + if (c == 34 || c == 39 || c == 96) + return c; + } + + function isescape(c) { + if (c == 92) + return c; + } + + function ismeta(c) { + if (c == 36 || c == 92 || c == 96) + return c; + } + + // Scan substring defined by the indexes [s, e] of the string "str", + // perform unquoting and de-escaping on the fly and store the result + function unquote(start, end) { + let esc, quote, res = []; + + for (let off = start; off < end; off++) { + const byte = ord(str, off); + const q = isquote(byte); + const e = isescape(byte); + const m = ismeta(byte); + + if (esc) { + if (!m) + push(res, 92); + + push(res, byte); + esc = false; + } + else if (e && quote != 39) { + esc = true; + } + else if (q && quote && q == quote) { + quote = null; + } + else if (q && !quote) { + quote = q; + } + else { + push(res, byte); + } + } + + push(args, chr(...res)); + } + + // Find substring boundaries in "str". Ignore escaped or quoted + // whitespace, pass found start- and end-index for each substring + // to unquote() + let esc, start, quote; + + for (let off = 0; off <= length(str); off++) { + const byte = ord(str, off); + const q = isquote(byte); + const s = isspace(byte) ?? (byte === null); + const e = isescape(byte); + + if (esc) { + esc = false; + } + else if (e && quote != 39) { + esc = true; + start ??= off; + } + else if (q && quote && q == quote) { + quote = null; + } + else if (q && !quote) { + start ??= off; + quote = q; + } + else if (s && !quote) { + if (start !== null) { + unquote(start, off); + start = null; + } + } + else { + start ??= off; + } + } + + // If the "quote" is still set we encountered an unfinished string + if (quote) + unquote(start, length(str)); + + return args; +} + +function test_binary(str) { + for (let off = 0, byte = ord(str); off < length(str); byte = ord(str, ++off)) + if (byte <= 8 || (byte >= 14 && byte <= 31)) + return true; + + return false; +} + +function parse_cmdline(cmdid, args) { + if (uci.get('luci', cmdid) == 'command') { + let cmd = uci.get_all('luci', cmdid); + let argv = parse_args(cmd?.command); + + if (cmd?.param == '1') { + if (length(args)) + push(argv, ...(parse_args(urldecode(args)) ?? [])); + else if (length(args = http.formvalue('args'))) + push(argv, ...(parse_args(args) ?? [])); + } + + return map(argv, v => match(v, /[^\w.\/|-]/) ? `'${replace(v, "'", "'\\''")}'` : v); + } +} + +function execute_command(callback, ...args) { + let argv = parse_cmdline(...args); + + if (argv) { + let outfd = mkstemp(); + let errfd = mkstemp(); + + const exitcode = system(`${join(' ', argv)} >&${outfd.fileno()} 2>&${errfd.fileno()}`); + + outfd.seek(0); + errfd.seek(0); + + const stdout = outfd.read(1024 * 512) ?? ''; + const stderr = errfd.read(1024 * 512) ?? ''; + + outfd.close(); + errfd.close(); + + const binary = test_binary(stdout); + + callback({ + ok: true, + command: join(' ', argv), + stdout: binary ? null : stdout, + stderr, + exitcode, + binary + }); + } + else { + callback({ + ok: false, + code: 404, + reason: "No such command" + }); + } +} + +function return_json(result) { + if (result.ok) { + http.prepare_content('application/json'); + http.write_json(result); + } + else { + http.status(result.code, result.reason); + } +} + + +function return_html(result) { + if (result.ok) { + include('commands_public', result); + } + else { + http.status(result.code, result.reason); + } +} + +return { + action_run: function(...args) { + execute_command(return_json, ...args); + }, + + action_download: function(...args) { + const argv = parse_cmdline(...args); + + if (argv) { + const fd = popen(`${join(' ', argv)} 2>/dev/null`); + + if (fd) { + let filename = replace(basename(argv[0]), /\W+/g, '.'); + let chunk = fd.read(4096) ?? ''; + let name; + + if (test_binary(chunk)) { + http.header("Content-Disposition", `attachment; filename=${filename}.bin`); + http.prepare_content("application/octet-stream"); + } + else { + http.header("Content-Disposition", `attachment; filename=${filename}.txt`); + http.prepare_content("text/plain"); + } + + while (length(chunk)) { + http.write(chunk); + chunk = fd.read(4096); + } + + fd.close(); + } + else { + http.status(500, "Failed to execute command"); + } + } + else { + http.status(404, "No such command"); + } + }, + + action_public: function(cmdid, ...args) { + let disp = false; + + if (substr(cmdid, -1) == "s") { + disp = true; + cmdid = substr(cmdid, 0, -1); + } + + if (cmdid && + uci.get('luci', cmdid) == 'command' && + uci.get('luci', cmdid, 'public') == '1') + { + if (disp) + execute_command(return_html, cmdid, ...args); + else + this.action_download(cmdid, args); + } + else { + http.status(403, "Access to command denied"); + } + } +}; diff --git a/samples/ucode/ddns.uc b/samples/ucode/ddns.uc new file mode 100644 index 0000000000..d7b0af2f12 --- /dev/null +++ b/samples/ucode/ddns.uc @@ -0,0 +1,319 @@ +#!/usr/bin/env ucode + +'use strict'; + +import { readfile, popen, stat, glob } from 'fs'; +import { init_enabled } from 'luci.sys'; +import { isnan } from 'math'; +import { cursor } from 'uci'; + +const uci = cursor(); +const ddns_log_path = '/var/log/ddns'; +const ddns_package_path = '/usr/share/ddns'; +const ddns_run_path = '/var/run/ddns'; +const luci_helper = '/usr/lib/ddns/dynamic_dns_lucihelper.sh'; +const ddns_version_file = '/usr/share/ddns/version'; + + +function shellquote(value) { + if (value == null) + value = ''; + + return "'" + replace(value, "'", "'\\''") + "'"; +} + +function get_dateformat() { + return uci.get('ddns', 'global', 'ddns_dateformat') || '%F %R'; +} + +function uptime() { + return split(readfile('/proc/uptime'), ' ')?.[0]; +} + +function killcmd(procid, signal) { + if (!signal) { + signal = 0; + } + // by default, we simply re-nice a process to check it is running + return system(`kill -${signal} ${procid}`); +} + +function trimnonewline(input) { + return replace(trim(input), /\n/g, ''); +} + +function get_date(seconds, format) { + return trimnonewline( popen(`date -d @${seconds} "+${format}" 2>/dev/null`, 'r')?.read?.('line') ); +} + +// convert epoch date to given format +function epoch2date(epoch, format) { + if (!format || length(format) < 2) { + format = get_dateformat(); + } + format = replace(format, /%n/g, '
'); // Replace '%n' with '
' + format = replace(format, /%t/g, ' '); // Replace '%t' with four spaces + + return get_date(epoch, format); +} + +// function to calculate seconds from given interval and unit +function calc_seconds(interval, unit) { + let parsedInterval = int(interval); + if (isnan(parsedInterval)) { + return null; + } + + switch (unit) { + case 'days': + return parsedInterval * 86400; // 60 sec * 60 min * 24 h + case 'hours': + return parsedInterval * 3600; // 60 sec * 60 min + case 'minutes': + return parsedInterval * 60; // 60 sec + case 'seconds': + return parsedInterval; + default: + return null; + } +} + +const methods = { + get_services_log: { + args: { service_name: 'service_name' }, + call: function(request) { + let result = 'File not found or empty'; + + // Get the log directory. Fall back to '/var/log/ddns' if not found + let logdir = uci.get('ddns', 'global', 'ddns_logdir') || ddns_log_path; + + // Fall back to default logdir with insecure path + if (match(logdir, /\.\.\//)) { + logdir = ddns_log_path; + } + + // Check if service_name is provided and log file exists + if (request.args && request.args.service_name && stat(`${logdir}/${request.args.service_name}.log`)?.type == 'file' ) { + result = readfile(`${logdir}/${request.args.service_name}.log`); + } + + uci.unload(); + return { result: result }; + } + }, + + get_services_status: { + call: function() { + const rundir = uci.get('ddns', 'global', 'ddns_rundir') || ddns_run_path; + let res = {}; + + uci.foreach('ddns', 'service', function(s) { + /* uci.foreach danger zone: if you inadvertently call uci.unload('ddns') + anywhere in this foreach loop, you will produce some spectacular undefined behaviour */ + let ip, lastUpdate, nextUpdate, nextCheck; + const section = s['.name']; + if (section == '.anonymous') + return; + + if (stat(`${rundir}/${section}.ip`)?.type == 'file') { + ip = readfile(`${rundir}/${section}.ip`); + } else { + const dnsServer = s['dns_server'] || ''; + const forceIpVersion = int(s['force_ipversion'] || 0); + const forceDnsTcp = int(s['force_dnstcp'] || 0); + // const isGlue = int(s['is_glue'] || 0); + const useIpv6 = int(s['use_ipv6'] || 0); + const lookupHost = s['lookup_host'] || '_nolookup_'; + let command = [luci_helper]; + + if (useIpv6 == 1) push(command, '-6'); + if (forceIpVersion == 1) push(command, '-f'); + if (forceDnsTcp == 1) push(command, '-t'); + // if (isGlue == 1) push(command, '-g'); + + push(command, '-l', shellquote(lookupHost)); + push(command, '-S', shellquote(section)); + if (length(dnsServer) > 0) push(command, '-d', shellquote(dnsServer)); + push(command, '-- get_registered_ip'); + + const result = system(`${join(' ', command)}`); + } + + lastUpdate = int(readfile(`${rundir}/${section}.update`) || 0); + nextCheck = int(readfile(`${rundir}/${section}.nextcheck`) || 0); + + let pid = int(readfile(`${rundir}/${section}.pid`) || 0); + + // if killcmd succeeds (0) to re-nice the process, we do not assume the pid is dead + if (pid > 0 && killcmd(pid)) { + pid = 0; + } + + let _uptime = int(uptime()); + + const forcedUpdateInterval = calc_seconds( + int(s['force_interval']) || 72, + s['force_unit'] || 'hours' + ); + + const checkInterval = calc_seconds( + int(s['check_interval']) || 10, + s['check_unit'] || 'minutes' + ); + + let convertedLastUpdate; + if (lastUpdate > 0) { + const epoch = time() - _uptime + lastUpdate; + convertedLastUpdate = epoch2date(epoch); + nextUpdate = epoch2date(epoch + forcedUpdateInterval); + } + + let convertedNextCheck; + if (nextCheck > 0) { + const epoch = time() - _uptime + nextCheck; + convertedNextCheck = epoch2date(epoch); + } + + if (pid > 0 && (lastUpdate + forcedUpdateInterval - _uptime) <= 0) { + nextUpdate = 'Verify'; + } else if (forcedUpdateInterval === 0) { + nextUpdate = 'Run once'; + } else if (pid == 0 && s['enabled'] == '0') { + nextUpdate = 'Disabled'; + } else if (pid == 0 && s['enabled'] != '0') { + nextUpdate = 'Stopped'; + } + + res[section] = { + ip: ip ? replace(trim(ip), '\n', '
') : null, + last_update: lastUpdate !== 0 ? convertedLastUpdate : null, + next_update: nextUpdate || null, + next_check : nextCheck !== 0 ? convertedNextCheck : null, + pid: pid || null, + }; + }); + + uci.unload('ddns'); + return res; + } + }, + + get_ddns_state: { + call: function() { + + const services_mtime = stat(ddns_package_path + '/list')?.mtime; + let res = {}; + let ver, control; + + if (stat(ddns_version_file)?.type == 'file') { + ver = readfile(ddns_version_file); + } + + res['_version'] = ver; + res['_enabled'] = init_enabled('ddns'); + res['_curr_dateformat'] = epoch2date(time()); + res['_services_list'] = (services_mtime && epoch2date(services_mtime)) || 'NO_LIST'; + + uci.unload('ddns'); + return res; + } + }, + + get_env: { + call: function () { + let res = {}; + let cache = {}; + + const hasCommand = (command) => { return (system(`command -v ${command} 1>/dev/null`) == 0) ? true : false }; + + const hasWget = () => { + return cache.has_wget ??= hasCommand('wget'); + }; + + const hasWgetSsl = () => { + return cache.has_wgetssl ??= hasWget() && system(`wget 2>&1 | grep -iqF 'https'`) == 0 ? true : false; + }; + + const hasGNUWgetSsl = () => { + return cache.has_gnuwgetssl ??= hasWget() && system(`wget -V 2>&1 | grep -iqF '+https'`) == 0 ? true : false; + }; + + const hasCurl = () => { + return cache.has_curl ??= hasCommand('curl'); + }; + + const hasCurlSsl = () => { + return cache.has_curl_ssl ??= system(`curl -V 2>&1 | grep -qF 'https'`) == 0 ? true : false; + }; + + const hasFetch = () => { + return cache.has_fetch ??= hasCommand('uclient-fetch'); + }; + + const hasFetchSsl = () => { + return cache.has_fetch_ssl ??= stat('/lib/libustream-ssl.so') ? true : false; + }; + + const hasCurlPxy = () => { + return cache.has_curl_proxy ??= system(`grep -i 'all_proxy' /usr/lib/libcurl.so*`) == 0 ? true : false; + }; + + const hasBbwget = () => { + return cache.has_bbwget ??= system(`wget -V 2>&1 | grep -iqF 'busybox'`) == 0 ? true : false; + }; + + + res['has_wget'] = hasWget(); + res['has_curl'] = hasCurl(); + + res['has_ssl'] = hasGNUWgetSsl() || hasWgetSsl() || hasCurlSsl() || (hasFetch() && hasFetchSsl()); + res['has_proxy'] = hasGNUWgetSsl() || hasWgetSsl() || hasCurlPxy() || hasFetch() || hasBbwget(); + res['has_forceip'] = hasGNUWgetSsl() || hasWgetSsl() || hasCurl() || hasFetch(); + res['has_bindnet'] = hasCurl() || hasGNUWgetSsl(); + + const hasBindHost = () => { + if (cache['has_bindhost']) return cache['has_bindhost']; + const commands = ['host', 'khost', 'drill']; + for (let command in commands) { + if (hasCommand(command)) { + cache['has_bindhost'] = true; + return true; + } + } + + cache['has_bindhost'] = false; + return false; + }; + + res['has_bindhost'] = cache['has_bindhost'] || hasBindHost(); + + const hasHostIp = () => { + return hasCommand('hostip'); + }; + + const hasNslookup = () => { + return hasCommand('nslookup'); + }; + + res['has_dnsserver'] = cache['has_bindhost'] || hasNslookup() || hasHostIp() || hasBindHost(); + + const checkCerts = () => { + let present = false; + for (let cert in glob('/etc/ssl/certs/*.crt', '/etc/ssl/certs/*.pem')) { + if (cert != null) + present = true; + } + return present; + }; + + res['has_cacerts'] = checkCerts(); + + res['has_ipv6'] = (stat('/proc/net/ipv6_route')?.type == 'file' && + (stat('/usr/sbin/ip6tables')?.type == 'file' || stat('/usr/sbin/nft')?.type == 'file')); + + return res; + } + } +}; + +return { 'luci.ddns': methods }; diff --git a/samples/ucode/sys.uc b/samples/ucode/sys.uc new file mode 100644 index 0000000000..35b5fcdebf --- /dev/null +++ b/samples/ucode/sys.uc @@ -0,0 +1,160 @@ +// Copyright 2022 Jo-Philipp Wich +// Licensed to the public under the Apache License 2.0. + +import { basename, readlink, readfile, open, popen, stat, glob } from 'fs'; + +export function process_list() { + const top = popen('/bin/busybox top -bn1'); + let line, list = []; + + for (let line = top.read('line'); length(line); line = top.read('line')) { + let m = match(trim(line), /^([0-9]+) +([0-9]+) +(.+) +([RSDZTWI][ { + const s = stat(path); + + return s?.type == 'file' && s?.perm?.user_exec; + }), basename); +}; + +export function init_index(name) { + const src = readfile(`/etc/init.d/${basename(name)}`, 2048); + const idx = []; + + for (let m in match(src, /^[[:space:]]*(START|STOP)=('[0-9][0-9]'|"[0-9][0-9]"|[0-9][0-9])[[:space:]]*$/gs)) { + switch (m[1]) { + case 'START': idx[0] = +trim(m[2], '"\''); break; + case 'STOP': idx[1] = +trim(m[2], '"\''); break; + } + } + + return length(idx) ? idx : null; +}; + +export function init_enabled(name) { + for (let path in glob(`/etc/rc.d/[SK][0-9][0-9]${basename(name)}`)) { + const ln = readlink(path); + const s1 = stat(index(ln, '/') == 0 ? ln : `/etc/rc.d/${ln}`); + const s2 = stat(`/etc/init.d/${basename(name)}`); + + if (s1?.inode == s2?.inode && s1?.type == 'file' && s1?.perm?.user_exec) + return true; + } + + return false; +}; + +export function init_action(name, action) { + const s = stat(`/etc/init.d/${basename(name)}`); + + if (s?.type != 'file' || s?.user_exec == false) + return false; + + return system(`env -i /etc/init.d/${basename(name)} ${action} >/dev/null`); +}; diff --git a/test/test_heuristics.rb b/test/test_heuristics.rb index f708d0fac5..1de5fc48e2 100755 --- a/test/test_heuristics.rb +++ b/test/test_heuristics.rb @@ -1199,6 +1199,13 @@ def test_typ_by_heuristics }) end + def test_uc_by_heuristics + assert_heuristics({ + "ucode" => all_fixtures("ucode", "*.uc"), + "UnrealScript" => all_fixtures("UnrealScript", "*.uc") + }) + end + def test_url_by_heuristics assert_heuristics({ "INI" => Dir.glob("#{fixtures_path}/Generic/url/INI/*"), diff --git a/vendor/README.md b/vendor/README.md index 44de308f78..6a1411bd9e 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -754,6 +754,7 @@ This is a list of grammars that Linguist selects to provide syntax highlighting - **reStructuredText:** [Lukasa/language-restructuredtext](https://github.com/Lukasa/language-restructuredtext) - **sed:** [Alhadis/language-sed](https://github.com/Alhadis/language-sed) - **templ:** [templ-go/templ-vscode](https://github.com/templ-go/templ-vscode) +- **ucode:** [NoahBPeterson/ucode-lsp](https://github.com/NoahBPeterson/ucode-lsp) - **vCard:** [cstrachan88/vscode-vcard](https://github.com/cstrachan88/vscode-vcard) - **wisp:** [atom/language-clojure](https://github.com/atom/language-clojure) - **xBase:** [hernad/atom-language-harbour](https://github.com/hernad/atom-language-harbour) diff --git a/vendor/grammars/ucode-lsp b/vendor/grammars/ucode-lsp new file mode 160000 index 0000000000..45ea5f31ad --- /dev/null +++ b/vendor/grammars/ucode-lsp @@ -0,0 +1 @@ +Subproject commit 45ea5f31ad19f3befab8013a44dcb8500ca89399 diff --git a/vendor/licenses/git_submodule/ucode-lsp.dep.yml b/vendor/licenses/git_submodule/ucode-lsp.dep.yml new file mode 100644 index 0000000000..8055666f96 --- /dev/null +++ b/vendor/licenses/git_submodule/ucode-lsp.dep.yml @@ -0,0 +1,33 @@ +--- +name: ucode-lsp +version: 45ea5f31ad19f3befab8013a44dcb8500ca89399 +type: git_submodule +homepage: https://github.com/NoahBPeterson/ucode-lsp.git +license: mit +licenses: +- sources: LICENSE + text: |- + MIT License + + Copyright (c) 2025 Noah Peterson + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. +- sources: README.md + text: MIT License — see [LICENSE](LICENSE) file for details. +notices: []