initial commit
This commit is contained in:
commit
6fdc96c8bf
|
@ -0,0 +1,11 @@
|
||||||
|
module.exports = {
|
||||||
|
extends: 'standard-with-typescript',
|
||||||
|
parserOptions: {
|
||||||
|
project: 'tsconfig.json'
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||||
|
'@typescript-eslint/strict-boolean-expressions': 'off',
|
||||||
|
'@typescript-eslint/restrict-template-expressions': 'off'
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,55 @@
|
||||||
|
name: Publish to NPM and GPR
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [created]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- uses: actions/cache@v2
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.npm
|
||||||
|
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-node-
|
||||||
|
- name: Use Node.js 14
|
||||||
|
uses: actions/setup-node@v1
|
||||||
|
with:
|
||||||
|
node-version: 14
|
||||||
|
- run: npm ci --prefer-offline
|
||||||
|
- run: npm t
|
||||||
|
|
||||||
|
publish:
|
||||||
|
needs: test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- registry: npm
|
||||||
|
url: https://registry.npmjs.org/
|
||||||
|
token: ${{ secrets.NPM_TOKEN }}
|
||||||
|
- registry: gpr
|
||||||
|
url: https://npm.pkg.github.com/
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- uses: actions/cache@v2
|
||||||
|
with:
|
||||||
|
path: ~/.npm
|
||||||
|
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-node-
|
||||||
|
- uses: actions/setup-node@v2
|
||||||
|
with:
|
||||||
|
node-version: 14
|
||||||
|
registry-url: ${{ matrix.url }}
|
||||||
|
- run: npm ci --prefer-offline
|
||||||
|
- run: sed -i 's,"irctokens","@swantzter/irctokens",' package*.json
|
||||||
|
if: matrix.registry == 'gpr'
|
||||||
|
- run: npm publish
|
||||||
|
env:
|
||||||
|
NODE_AUTH_TOKEN: ${{ matrix.token }}
|
|
@ -0,0 +1,48 @@
|
||||||
|
name: QA
|
||||||
|
|
||||||
|
on: [push, pull_request]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- uses: actions/cache@v2
|
||||||
|
with:
|
||||||
|
path: ~/.npm
|
||||||
|
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-node-
|
||||||
|
- name: Use Node.js 14
|
||||||
|
uses: actions/setup-node@v1
|
||||||
|
with:
|
||||||
|
node-version: 14
|
||||||
|
- run: npm ci --prefer-offline
|
||||||
|
- run: npm run lint
|
||||||
|
- run: npm run typecheck
|
||||||
|
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
node: [12, 14, 16]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- uses: actions/cache@v2
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.npm
|
||||||
|
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-node-
|
||||||
|
- name: Use Node.js 14
|
||||||
|
uses: actions/setup-node@v1
|
||||||
|
with:
|
||||||
|
node-version: ${{ matrix.node }}
|
||||||
|
- run: npm ci --prefer-offline
|
||||||
|
- run: npm run coverage
|
||||||
|
- name: Codecov
|
||||||
|
if: matrix.node == 14
|
||||||
|
uses: codecov/codecov-action@v1.0.6
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.CODECOV_TOKEN }}
|
|
@ -0,0 +1,5 @@
|
||||||
|
node_modules
|
||||||
|
.vscode
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
.nyc_output
|
|
@ -0,0 +1,3 @@
|
||||||
|
module.exports = {
|
||||||
|
exit: true
|
||||||
|
}
|
|
@ -0,0 +1,22 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2021 Svante Bengtson <svante@swantzter.se> (https://swantzter.se)
|
||||||
|
Copyright (c) 2020 jesopo
|
||||||
|
|
||||||
|
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.
|
|
@ -0,0 +1,99 @@
|
||||||
|
# irctokens
|
||||||
|
|
||||||
|
[![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com)
|
||||||
|
[![QA](https://github.com/swantzter/irctokens-js/actions/workflows/qa.yml/badge.svg)](https://github.com/swantzter/irctokens-js/actions/workflows/qa.yml)
|
||||||
|
[![Publish to NPM and GCR](https://github.com/swantzter/irctokens-js/actions/workflows/publish.yml/badge.svg)](https://github.com/swantzter/irctokens-js/actions/workflows/publish.yml)
|
||||||
|
[![codecov](https://codecov.io/gh/swantzter/irctokens-js/branch/main/graph/badge.svg)](https://codecov.io/gh/swantzter/irctokens-js)
|
||||||
|
|
||||||
|
TypeScrip port of the python library [irctokens](https://github.com/jesopo/irctokens)
|
||||||
|
|
||||||
|
## rationale
|
||||||
|
|
||||||
|
there's far too many IRC client implementations out in the world that do not
|
||||||
|
tokenise data correctly and thus fall victim to things like colons either being
|
||||||
|
where you don't expect them or not being where you expect them.
|
||||||
|
|
||||||
|
## usage
|
||||||
|
|
||||||
|
### installation
|
||||||
|
|
||||||
|
`$ npm install irctokens`
|
||||||
|
|
||||||
|
### tokenisation
|
||||||
|
```typescript
|
||||||
|
import { tokenise } from 'irctokens'
|
||||||
|
const line = tokenise('@id=123 :Swant!~swant@hostname PRIVMSG #chat :hello there!')
|
||||||
|
|
||||||
|
console.log(line.tags)
|
||||||
|
// { id: '123' }
|
||||||
|
|
||||||
|
console.log(line.source)
|
||||||
|
// 'Swant!~swant@hostname'
|
||||||
|
|
||||||
|
console.log(line.hostmask)
|
||||||
|
// Hostmask { nickname: 'Swant', username: '~swant', hostname: 'hostname' }
|
||||||
|
|
||||||
|
console.log(line.command)
|
||||||
|
// 'PRIVMSG'
|
||||||
|
|
||||||
|
console.log(line.params)
|
||||||
|
// ['#chat', 'hello there!']
|
||||||
|
```
|
||||||
|
|
||||||
|
### formatting
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Line } from 'irctokens'
|
||||||
|
const line = new Line({ command: 'USER', params: ['user', '0', '*', 'real name'] })
|
||||||
|
|
||||||
|
console.log(line.format())
|
||||||
|
// 'USER user 0 * :real name'
|
||||||
|
```
|
||||||
|
|
||||||
|
### stateful
|
||||||
|
|
||||||
|
below is an example of a fully socket-wise safe IRC client connection that will
|
||||||
|
connect and join a channel. both protocol sending and receiving are handled by
|
||||||
|
irctokens.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Socket } from 'net'
|
||||||
|
import { Line, StatefulEncoder, StatefulDecoder } from 'irctokens'
|
||||||
|
|
||||||
|
const NICK = 'nickname'
|
||||||
|
const CHAN = '#channel'
|
||||||
|
|
||||||
|
const d = new StatefulDecoder()
|
||||||
|
const e = new StatefulEncoder()
|
||||||
|
const s = new Socket()
|
||||||
|
s.connect(6667, '127.0.0.1')
|
||||||
|
|
||||||
|
function send (line: Line) {
|
||||||
|
console.log(`> ${line.format()}`)
|
||||||
|
e.push(line)
|
||||||
|
const pending = e.pending()
|
||||||
|
s.write(pending)
|
||||||
|
e.pop(pending.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.once('connect', () => {
|
||||||
|
send(new Line({ command: 'USER', params: ['username', '0', '*', 'real name'] }))
|
||||||
|
send(new Line({ command: 'NICK', params: [NICK] }))
|
||||||
|
})
|
||||||
|
|
||||||
|
s.on('data', data => {
|
||||||
|
const lines = d.push(Uint8Array.from(data))
|
||||||
|
|
||||||
|
if (!lines) return
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
console.log(`< ${line.format()}`)
|
||||||
|
if (line.command === 'PING') send(new Line({ command: 'PONG', params: [line.params[0]] }))
|
||||||
|
else if (line.command === '001') send(new Line({ command: 'JOIN', params: [CHAN] }))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## contact
|
||||||
|
|
||||||
|
Come say hi at `#irctokens` on irc.libera.chat
|
|
@ -0,0 +1,35 @@
|
||||||
|
const { Socket } = require('net')
|
||||||
|
const irctokens = require('.')
|
||||||
|
|
||||||
|
const NICK = 'nickname'
|
||||||
|
const CHAN = '#channel'
|
||||||
|
|
||||||
|
const d = new irctokens.StatefulDecoder()
|
||||||
|
const e = new irctokens.StatefulEncoder()
|
||||||
|
const s = new Socket()
|
||||||
|
s.connect(6667, '127.0.0.1')
|
||||||
|
|
||||||
|
function send (line) { // :Line
|
||||||
|
console.log(`> ${line.format()}`)
|
||||||
|
e.push(line)
|
||||||
|
const pend = e.pending()
|
||||||
|
s.write(pend)
|
||||||
|
e.pop(pend.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.once('connect', () => {
|
||||||
|
send(new irctokens.Line({ command: 'USER', params: ['username', '0', '*', 'real name'] }))
|
||||||
|
send(new irctokens.Line({ command: 'NICK', params: [NICK] }))
|
||||||
|
})
|
||||||
|
|
||||||
|
s.on('data', data => {
|
||||||
|
const lines = d.push(Uint8Array.from(data))
|
||||||
|
|
||||||
|
if (!lines) return
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
console.log(`< ${line.format()}`)
|
||||||
|
if (line.command === 'PING') send(new irctokens.Line({ command: 'PONG', params: [line.params[0]] }))
|
||||||
|
else if (line.command === '001') send(new irctokens.Line({ command: 'JOIN', params: [CHAN] }))
|
||||||
|
}
|
||||||
|
})
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,55 @@
|
||||||
|
{
|
||||||
|
"name": "irctokens",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"description": "RFC1459 and IRCv3 protocol tokeniser",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.production.json",
|
||||||
|
"watch": "npm run build -- --watch",
|
||||||
|
"test": "ts-mocha tests/**/*.test.ts",
|
||||||
|
"coverage": "nyc -r lcov -r text npm test",
|
||||||
|
"lint": "eslint src/**/*.ts tests/**/*.ts",
|
||||||
|
"lint:fix": "npm run lint -- --fix",
|
||||||
|
"typecheck": "npm run build -- --noEmit",
|
||||||
|
"prepack": "npm run build"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/swantzter/irctokens-js.git"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"irc",
|
||||||
|
"ircv3",
|
||||||
|
"rfc1459",
|
||||||
|
"tokeniser"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">11.0.0"
|
||||||
|
},
|
||||||
|
"author": "Svante Bengtson <svante@swantzter.se> (https://swantzter.se)",
|
||||||
|
"license": "MIT",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/swantzter/irctokens-js/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/swantzter/irctokens-js#readme",
|
||||||
|
"files": [
|
||||||
|
"/dist"
|
||||||
|
],
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/mocha": "^8.2.3",
|
||||||
|
"@types/node": "^14.17.5",
|
||||||
|
"@types/yaml": "^1.9.7",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^4.28.2",
|
||||||
|
"eslint": "^7.30.0",
|
||||||
|
"eslint-config-standard-with-typescript": "^20.0.0",
|
||||||
|
"eslint-plugin-import": "^2.23.4",
|
||||||
|
"eslint-plugin-node": "^11.1.0",
|
||||||
|
"eslint-plugin-promise": "^4.3.1",
|
||||||
|
"mocha": "^9.0.2",
|
||||||
|
"nyc": "^15.1.0",
|
||||||
|
"ts-mocha": "^8.0.0",
|
||||||
|
"typescript": "^4.2.4",
|
||||||
|
"yaml": "^1.10.2"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
export const TAG_UNESCAPED = ['\\', ' ', ';', '\r', '\n']
|
||||||
|
export const TAG_UNESCAPED_REGEX = [/\\\\/g, / /g, /;/g, /\r/g, /\n/g]
|
||||||
|
export const TAG_ESCAPED = ['\\\\', '\\s', '\\:', '\\r', '\\n']
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { TAG_ESCAPED, TAG_UNESCAPED_REGEX } from './const'
|
||||||
|
|
||||||
|
function escapeTag (value: string) {
|
||||||
|
let processed = value
|
||||||
|
for (let idx = 0; idx < TAG_ESCAPED.length; idx++) {
|
||||||
|
processed = processed.replace(TAG_UNESCAPED_REGEX[idx], TAG_ESCAPED[idx])
|
||||||
|
}
|
||||||
|
return processed
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormatArgs {
|
||||||
|
tags?: Record<string, string>
|
||||||
|
source?: string
|
||||||
|
command: string
|
||||||
|
params: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function format ({ tags, source, command, params }: FormatArgs) {
|
||||||
|
const outs: string[] = []
|
||||||
|
|
||||||
|
if (tags) {
|
||||||
|
const tagsStr = []
|
||||||
|
for (const [key, val] of Object.entries(tags).sort(([a], [b]) => a.localeCompare(b))) {
|
||||||
|
if (val) tagsStr.push(`${key}=${escapeTag(val)}`)
|
||||||
|
else tagsStr.push(key)
|
||||||
|
}
|
||||||
|
outs.push(`@${tagsStr.join(';')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source) {
|
||||||
|
outs.push(`:${source}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
outs.push(command)
|
||||||
|
|
||||||
|
if (params) {
|
||||||
|
const paramCopy = [...params]
|
||||||
|
let last = paramCopy.pop()
|
||||||
|
|
||||||
|
for (const param of paramCopy) {
|
||||||
|
if (param.includes(' ')) throw new TypeError('non last params cannot have spaces')
|
||||||
|
else if (param.startsWith(':')) throw new TypeError('non last params cannot start with colon')
|
||||||
|
}
|
||||||
|
outs.push(...paramCopy)
|
||||||
|
|
||||||
|
if (!last || last.includes(' ') || last.startsWith(':')) {
|
||||||
|
last = `:${last ?? ''}`
|
||||||
|
}
|
||||||
|
outs.push(last)
|
||||||
|
}
|
||||||
|
|
||||||
|
return outs.join(' ')
|
||||||
|
}
|
|
@ -0,0 +1,36 @@
|
||||||
|
export interface HostmaskArgs {
|
||||||
|
source: string
|
||||||
|
nickname: string
|
||||||
|
username?: string
|
||||||
|
hostname?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Hostmask {
|
||||||
|
#source: string
|
||||||
|
nickname: string
|
||||||
|
username?: string
|
||||||
|
hostname?: string
|
||||||
|
|
||||||
|
constructor ({ source, nickname, username, hostname }: HostmaskArgs) {
|
||||||
|
this.#source = source
|
||||||
|
this.nickname = nickname
|
||||||
|
this.username = username
|
||||||
|
this.hostname = hostname
|
||||||
|
}
|
||||||
|
|
||||||
|
toString () {
|
||||||
|
return this.#source
|
||||||
|
}
|
||||||
|
|
||||||
|
eq (other: any) {
|
||||||
|
if (other instanceof Hostmask) return this.toString() === other.toString()
|
||||||
|
else return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hostmask (source: string) {
|
||||||
|
let username, nickname, hostname
|
||||||
|
; [username, hostname] = source.split(/@(.*)/)
|
||||||
|
; [nickname, username] = username.split(/!(.*)/)
|
||||||
|
return new Hostmask({ source, nickname, username, hostname })
|
||||||
|
}
|
|
@ -0,0 +1,48 @@
|
||||||
|
export { Line, tokenise } from './line'
|
||||||
|
export { Hostmask, hostmask } from './hostmask'
|
||||||
|
export { StatefulDecoder, StatefulEncoder } from './stateful'
|
||||||
|
|
||||||
|
// From https://developer.mozilla.org/en-US/docs/Web/API/Encoding_API/Encodings
|
||||||
|
export type TextEncodingLabel =
|
||||||
|
'unicode-1-1-utf-8'| 'utf-8'| 'utf8'|
|
||||||
|
'866'| 'cp866'| 'csibm866'| 'ibm866'|
|
||||||
|
'csisolatin2'| 'iso-8859-2'| 'iso-ir-101'| 'iso8859-2'| 'iso88592'| 'iso_8859-2'| 'iso_8859-2:1987'| 'l2'| 'latin2'|
|
||||||
|
'csisolatin3'| 'iso-8859-3'| 'iso-ir-109'| 'iso8859-3'| 'iso88593'| 'iso_8859-3'| 'iso_8859-3:1988'| 'l3'| 'latin3'|
|
||||||
|
'csisolatin4'| 'iso-8859-4'| 'iso-ir-110'| 'iso8859-4'| 'iso88594'| 'iso_8859-4'| 'iso_8859-4:1988'| 'l4'| 'latin4'|
|
||||||
|
'csisolatincyrillic'| 'cyrillic'| 'iso-8859-5'| 'iso-ir-144'| 'iso88595'| 'iso_8859-5'| 'iso_8859-5:1988'|
|
||||||
|
'arabic'| 'asmo-708'| 'csiso88596e'| 'csiso88596i'| 'csisolatinarabic'| 'ecma-114'| 'iso-8859-6'| 'iso-8859-6-e'| 'iso-8859-6-i'| 'iso-ir-127'| 'iso8859-6'| 'iso88596'| 'iso_8859-6'| 'iso_8859-6:1987'|
|
||||||
|
'csisolatingreek'| 'ecma-118'| 'elot_928'| 'greek'| 'greek8'| 'iso-8859-7'| 'iso-ir-126'| 'iso8859-7'| 'iso88597'| 'iso_8859-7'| 'iso_8859-7:1987'| 'sun_eu_greek'|
|
||||||
|
'csiso88598e'| 'csisolatinhebrew'| 'hebrew'| 'iso-8859-8'| 'iso-8859-8-e'| 'iso-ir-138'| 'iso8859-8'| 'iso88598'| 'iso_8859-8'| 'iso_8859-8:1988'| 'visual'|
|
||||||
|
'csiso88598i'| 'iso-8859-8-i'| 'logical'|
|
||||||
|
'csisolatin6'| 'iso-8859-10'| 'iso-ir-157'| 'iso8859-10'| 'iso885910'| 'l6'| 'latin6'|
|
||||||
|
'iso-8859-13'| 'iso8859-13'| 'iso885913'|
|
||||||
|
'iso-8859-14'| 'iso8859-14'| 'iso885914'|
|
||||||
|
'csisolatin9'| 'iso-8859-15'| 'iso8859-15'| 'iso885915'| 'l9'| 'latin9'|
|
||||||
|
'iso-8859-16'|
|
||||||
|
'cskoi8r'| 'koi'| 'koi8'| 'koi8-r'| 'koi8_r'|
|
||||||
|
'koi8-u'|
|
||||||
|
'csmacintosh'| 'mac'| 'macintosh'| 'x-mac-roman'|
|
||||||
|
'dos-874'| 'iso-8859-11'| 'iso8859-11'| 'iso885911'| 'tis-620'| 'windows-874'|
|
||||||
|
'cp1250'| 'windows-1250'| 'x-cp1250'|
|
||||||
|
'cp1251'| 'windows-1251'| 'x-cp1251'|
|
||||||
|
'ansi_x3.4-1968'| 'ascii'| 'cp1252'| 'cp819'| 'csisolatin1'| 'ibm819'| 'iso-8859-1'| 'iso-ir-100'| 'iso8859-1'| 'iso88591'| 'iso_8859-1'| 'iso_8859-1:1987'| 'l1'| 'latin1'| 'us-ascii'| 'windows-1252'| 'x-cp1252'|
|
||||||
|
'cp1253'| 'windows-1253'| 'x-cp1253'|
|
||||||
|
'cp1254'| 'csisolatin5'| 'iso-8859-9'| 'iso-ir-148'| 'iso8859-9'| 'iso88599'| 'iso_8859-9'| 'iso_8859-9:1989'| 'l5'| 'latin5'| 'windows-1254'| 'x-cp1254'|
|
||||||
|
'cp1255'| 'windows-1255'| 'x-cp1255'|
|
||||||
|
'cp1256'| 'windows-1256'| 'x-cp1256'|
|
||||||
|
'cp1257'| 'windows-1257'| 'x-cp1257'|
|
||||||
|
'cp1258'| 'windows-1258'| 'x-cp1258'|
|
||||||
|
'x-mac-cyrillic'| 'x-mac-ukrainian'|
|
||||||
|
'chinese'| 'csgb2312'| 'csiso58gb231280'| 'gb2312'| 'gb_2312'| 'gb_2312-80'| 'gbk'| 'iso-ir-58'| 'x-gbk'|
|
||||||
|
'gb18030'|
|
||||||
|
'hz-gb-2312'|
|
||||||
|
'big5'| 'big5-hkscs'| 'cn-big5'| 'csbig5'| 'x-x-big5'|
|
||||||
|
'cseucpkdfmtjapanese'| 'euc-jp'| 'x-euc-jp'|
|
||||||
|
'csiso2022jp'| 'iso-2022-jp'|
|
||||||
|
'csshiftjis'| 'ms_kanji'| 'shift-jis'| 'shift_jis'| 'sjis'| 'windows-31j'| 'x-sjis'|
|
||||||
|
'cseuckr'| 'csksc56011987'| 'euc-kr'| 'iso-ir-149'| 'korean'| 'ks_c_5601-1987'| 'ks_c_5601-1989'| 'ksc5601'| 'ksc_5601'| 'windows-949'|
|
||||||
|
'csiso2022kr'| 'iso-2022-kr'|
|
||||||
|
'utf-16be'|
|
||||||
|
'utf-16'| 'utf-16le'|
|
||||||
|
'x-user-defined'|
|
||||||
|
'iso-2022-cn'| 'iso-2022-cn-ext'
|
|
@ -0,0 +1,144 @@
|
||||||
|
import { TAG_ESCAPED, TAG_UNESCAPED } from './const'
|
||||||
|
import { format } from './formatting'
|
||||||
|
import { hostmask, Hostmask } from './hostmask'
|
||||||
|
import type { TextEncodingLabel } from '.'
|
||||||
|
|
||||||
|
export interface LineArgs {
|
||||||
|
tags?: Record<string, string>
|
||||||
|
source?: string
|
||||||
|
command: string
|
||||||
|
params: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Line {
|
||||||
|
tags?: Record<string, string>
|
||||||
|
source?: string
|
||||||
|
command: string
|
||||||
|
params: string[]
|
||||||
|
|
||||||
|
constructor ({ tags, source, command, params }: LineArgs) {
|
||||||
|
this.tags = tags
|
||||||
|
this.source = source
|
||||||
|
this.command = command
|
||||||
|
this.params = params
|
||||||
|
}
|
||||||
|
|
||||||
|
#hostmask?: Hostmask
|
||||||
|
get hostmask () {
|
||||||
|
if (this.source) {
|
||||||
|
if (!this.#hostmask) this.#hostmask = hostmask(this.source)
|
||||||
|
return this.#hostmask
|
||||||
|
} else {
|
||||||
|
throw new TypeError('cannot parse hostmask from null source')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
format () {
|
||||||
|
return format({
|
||||||
|
tags: this.tags,
|
||||||
|
source: this.source,
|
||||||
|
command: this.command,
|
||||||
|
params: this.params
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
withSource (source: string) {
|
||||||
|
return new Line({
|
||||||
|
tags: this.tags,
|
||||||
|
source,
|
||||||
|
command: this.command,
|
||||||
|
params: this.params
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
copy () {
|
||||||
|
return new Line(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function unescapeTag (value: string) {
|
||||||
|
let unescaped = ''
|
||||||
|
const escaped = value.split('')
|
||||||
|
while (escaped.length) {
|
||||||
|
const current = escaped.shift() as string
|
||||||
|
if (current === '\\') {
|
||||||
|
if (escaped.length) {
|
||||||
|
const next = escaped.shift() as string
|
||||||
|
const duo = `${current}${next}`
|
||||||
|
const index = TAG_ESCAPED.indexOf(duo)
|
||||||
|
|
||||||
|
if (index > -1) unescaped += TAG_UNESCAPED[index]
|
||||||
|
else unescaped += next
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
unescaped += current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return unescaped
|
||||||
|
}
|
||||||
|
|
||||||
|
function _tokenise (line: string) {
|
||||||
|
let value = line
|
||||||
|
let tags: Record<string, string> | undefined
|
||||||
|
if (value[0] === '@') {
|
||||||
|
let tagsS
|
||||||
|
; [tagsS, value] = value.substring(1).split(/ (.*)/)
|
||||||
|
tags = {}
|
||||||
|
for (const part of tagsS.split(';')) {
|
||||||
|
const [key, val] = part.split(/=(.*)/)
|
||||||
|
tags[key] = unescapeTag(val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let trailing
|
||||||
|
; [value, trailing] = value.split(/ :(.*)/)
|
||||||
|
const params = value.split(' ').filter(part => !!part)
|
||||||
|
|
||||||
|
let source: string | undefined
|
||||||
|
if (params[0][0] === ':') {
|
||||||
|
source = (params.shift() as string).substring(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!params.length) throw TypeError('Cannot tokenise command-less line')
|
||||||
|
const command = (params.shift() as string).toLocaleUpperCase()
|
||||||
|
|
||||||
|
if (trailing) params.push(trailing)
|
||||||
|
|
||||||
|
return new Line({
|
||||||
|
tags,
|
||||||
|
source,
|
||||||
|
command,
|
||||||
|
params
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tokenise (
|
||||||
|
line: string | Uint8Array,
|
||||||
|
encoding: TextEncodingLabel = 'utf8',
|
||||||
|
fallback: TextEncodingLabel = 'latin1'
|
||||||
|
) {
|
||||||
|
let dline = ''
|
||||||
|
const decoder = new TextDecoder(encoding, { fatal: true })
|
||||||
|
const fallbackDecoder = new TextDecoder(fallback, { fatal: true })
|
||||||
|
if (line instanceof Uint8Array) {
|
||||||
|
if (line[0] === '@'.charCodeAt(0)) {
|
||||||
|
const idx = line.indexOf(' '.charCodeAt(0))
|
||||||
|
const tagsB = line.slice(0, idx + 1)
|
||||||
|
line = line.slice(idx + 1)
|
||||||
|
dline += decoder.decode(tagsB)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
dline += decoder.decode(line)
|
||||||
|
} catch {
|
||||||
|
dline += fallbackDecoder.decode(line)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dline = line
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dline.includes('\x00')) {
|
||||||
|
[dline] = dline.split('\x00')
|
||||||
|
}
|
||||||
|
|
||||||
|
return _tokenise(dline)
|
||||||
|
}
|
|
@ -0,0 +1,84 @@
|
||||||
|
import type { TextEncodingLabel } from '.'
|
||||||
|
import { Line, tokenise } from './line'
|
||||||
|
|
||||||
|
export class StatefulDecoder {
|
||||||
|
#encoding: TextEncodingLabel
|
||||||
|
#fallback: TextEncodingLabel
|
||||||
|
#buffer: Uint8Array = new Uint8Array()
|
||||||
|
|
||||||
|
constructor (encoding: TextEncodingLabel = 'utf8', fallback: TextEncodingLabel = 'latin1') {
|
||||||
|
this.#encoding = encoding
|
||||||
|
this.#fallback = fallback
|
||||||
|
this.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
clear () {
|
||||||
|
this.#buffer = new Uint8Array()
|
||||||
|
}
|
||||||
|
|
||||||
|
pending () {
|
||||||
|
return this.#buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
push (data: Uint8Array): Line[] | undefined {
|
||||||
|
if (!data.length) return
|
||||||
|
|
||||||
|
const newBuffer = new Uint8Array(this.#buffer.length + data.length)
|
||||||
|
newBuffer.set(this.#buffer)
|
||||||
|
newBuffer.set(data, this.#buffer.length)
|
||||||
|
this.#buffer = newBuffer
|
||||||
|
|
||||||
|
const lineBuffers: Uint8Array[] = []
|
||||||
|
while (this.#buffer.includes('\n'.charCodeAt(0))) {
|
||||||
|
const lfIdx = this.#buffer.indexOf('\n'.charCodeAt(0))
|
||||||
|
const crIdx = this.#buffer.indexOf('\r'.charCodeAt(0))
|
||||||
|
lineBuffers.push(this.#buffer.slice(0, crIdx < lfIdx ? crIdx : lfIdx))
|
||||||
|
this.#buffer = this.#buffer.slice(lfIdx + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = []
|
||||||
|
for (const line of lineBuffers) {
|
||||||
|
lines.push(tokenise(line, this.#encoding, this.#fallback))
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StatefulEncoder {
|
||||||
|
// #encoding: TextEncodingLabel // TODO only supports utf8 at the moment because of TextEncoder
|
||||||
|
#encoder = new TextEncoder()
|
||||||
|
#buffer = new Uint8Array()
|
||||||
|
#bufferedLines: Line[] = []
|
||||||
|
|
||||||
|
constructor () {
|
||||||
|
this.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
clear () {
|
||||||
|
this.#buffer = new Uint8Array()
|
||||||
|
this.#bufferedLines = []
|
||||||
|
}
|
||||||
|
|
||||||
|
pending () {
|
||||||
|
return this.#buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
push (line: Line) {
|
||||||
|
const lineBuffer = this.#encoder.encode(`${line.format()}\r\n`)
|
||||||
|
const newBuffer = new Uint8Array(this.#buffer.length + lineBuffer.length)
|
||||||
|
newBuffer.set(this.#buffer)
|
||||||
|
newBuffer.set(lineBuffer, this.#buffer.length)
|
||||||
|
|
||||||
|
this.#buffer = newBuffer
|
||||||
|
this.#bufferedLines.push(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
pop (byteCount: number) {
|
||||||
|
const sent = this.#buffer.slice(0, byteCount).reduce((acc, curr) => {
|
||||||
|
return acc + (curr === '\n'.charCodeAt(0) ? 1 : 0)
|
||||||
|
}, 0)
|
||||||
|
this.#buffer = this.#buffer.slice(byteCount)
|
||||||
|
return new Array(sent).fill(null).map(_ => this.#bufferedLines.shift())
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,221 @@
|
||||||
|
# IRC parser tests
|
||||||
|
# joining atoms into sendable messages
|
||||||
|
|
||||||
|
# Written in 2015 by Daniel Oaks <daniel@danieloaks.net>
|
||||||
|
#
|
||||||
|
# To the extent possible under law, the author(s) have dedicated all copyright
|
||||||
|
# and related and neighboring rights to this software to the public domain
|
||||||
|
# worldwide. This software is distributed without any warranty.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the CC0 Public Domain Dedication along
|
||||||
|
# with this software. If not, see
|
||||||
|
# <http://creativecommons.org/publicdomain/zero/1.0/>.
|
||||||
|
|
||||||
|
# some of the tests here originate from grawity's test vectors, which is WTFPL v2 licensed
|
||||||
|
# https://github.com/grawity/code/tree/master/lib/tests
|
||||||
|
# some of the tests here originate from Mozilla's test vectors, which is public domain
|
||||||
|
# https://dxr.mozilla.org/comm-central/source/chat/protocols/irc/test/test_ircMessage.js
|
||||||
|
# some of the tests here originate from SaberUK's test vectors, which he's indicated I am free to include here
|
||||||
|
# https://github.com/SaberUK/ircparser/tree/master/test
|
||||||
|
|
||||||
|
tests:
|
||||||
|
# the desc string holds a description of the test, if it exists
|
||||||
|
|
||||||
|
# the atoms dict has the keys:
|
||||||
|
# * tags: tags dict
|
||||||
|
# tags with no value are an empty string
|
||||||
|
# * source: source string, without single leading colon
|
||||||
|
# * verb: verb string
|
||||||
|
# * params: params split up as a list
|
||||||
|
# if the params key does not exist, assume it is empty
|
||||||
|
# if any other keys do no exist, assume they are null
|
||||||
|
# a key that is null does not exist or is not specified with the
|
||||||
|
# given input string
|
||||||
|
|
||||||
|
# matches is a list of messages that match
|
||||||
|
|
||||||
|
# simple tests
|
||||||
|
- desc: Simple test with verb and params.
|
||||||
|
atoms:
|
||||||
|
verb: "foo"
|
||||||
|
params:
|
||||||
|
- "bar"
|
||||||
|
- "baz"
|
||||||
|
- "asdf"
|
||||||
|
matches:
|
||||||
|
- "foo bar baz asdf"
|
||||||
|
- "foo bar baz :asdf"
|
||||||
|
|
||||||
|
# with no regular params
|
||||||
|
- desc: Simple test with source and no params.
|
||||||
|
atoms:
|
||||||
|
source: "src"
|
||||||
|
verb: "AWAY"
|
||||||
|
matches:
|
||||||
|
- ":src AWAY"
|
||||||
|
|
||||||
|
- desc: Simple test with source and empty trailing param.
|
||||||
|
atoms:
|
||||||
|
source: "src"
|
||||||
|
verb: "AWAY"
|
||||||
|
params:
|
||||||
|
- ""
|
||||||
|
matches:
|
||||||
|
- ":src AWAY :"
|
||||||
|
|
||||||
|
# with source
|
||||||
|
- desc: Simple test with source.
|
||||||
|
atoms:
|
||||||
|
source: "coolguy"
|
||||||
|
verb: "foo"
|
||||||
|
params:
|
||||||
|
- "bar"
|
||||||
|
- "baz"
|
||||||
|
- "asdf"
|
||||||
|
matches:
|
||||||
|
- ":coolguy foo bar baz asdf"
|
||||||
|
- ":coolguy foo bar baz :asdf"
|
||||||
|
|
||||||
|
# with trailing param
|
||||||
|
- desc: Simple test with trailing param.
|
||||||
|
atoms:
|
||||||
|
verb: "foo"
|
||||||
|
params:
|
||||||
|
- "bar"
|
||||||
|
- "baz"
|
||||||
|
- "asdf quux"
|
||||||
|
matches:
|
||||||
|
- "foo bar baz :asdf quux"
|
||||||
|
|
||||||
|
- desc: Simple test with empty trailing param.
|
||||||
|
atoms:
|
||||||
|
verb: "foo"
|
||||||
|
params:
|
||||||
|
- "bar"
|
||||||
|
- "baz"
|
||||||
|
- ""
|
||||||
|
matches:
|
||||||
|
- "foo bar baz :"
|
||||||
|
|
||||||
|
- desc: Simple test with trailing param containing colon.
|
||||||
|
atoms:
|
||||||
|
verb: "foo"
|
||||||
|
params:
|
||||||
|
- "bar"
|
||||||
|
- "baz"
|
||||||
|
- ":asdf"
|
||||||
|
matches:
|
||||||
|
- "foo bar baz ::asdf"
|
||||||
|
|
||||||
|
# with source and trailing param
|
||||||
|
- desc: Test with source and trailing param.
|
||||||
|
atoms:
|
||||||
|
source: "coolguy"
|
||||||
|
verb: "foo"
|
||||||
|
params:
|
||||||
|
- "bar"
|
||||||
|
- "baz"
|
||||||
|
- "asdf quux"
|
||||||
|
matches:
|
||||||
|
- ":coolguy foo bar baz :asdf quux"
|
||||||
|
|
||||||
|
- desc: Test with trailing containing beginning+end whitespace.
|
||||||
|
atoms:
|
||||||
|
source: "coolguy"
|
||||||
|
verb: "foo"
|
||||||
|
params:
|
||||||
|
- "bar"
|
||||||
|
- "baz"
|
||||||
|
- " asdf quux "
|
||||||
|
matches:
|
||||||
|
- ":coolguy foo bar baz : asdf quux "
|
||||||
|
|
||||||
|
- desc: Test with trailing containing what looks like another trailing param.
|
||||||
|
atoms:
|
||||||
|
source: "coolguy"
|
||||||
|
verb: "PRIVMSG"
|
||||||
|
params:
|
||||||
|
- "bar"
|
||||||
|
- "lol :) "
|
||||||
|
matches:
|
||||||
|
- ":coolguy PRIVMSG bar :lol :) "
|
||||||
|
|
||||||
|
- desc: Simple test with source and empty trailing.
|
||||||
|
atoms:
|
||||||
|
source: "coolguy"
|
||||||
|
verb: "foo"
|
||||||
|
params:
|
||||||
|
- "bar"
|
||||||
|
- "baz"
|
||||||
|
- ""
|
||||||
|
matches:
|
||||||
|
- ":coolguy foo bar baz :"
|
||||||
|
|
||||||
|
- desc: Trailing contains only spaces.
|
||||||
|
atoms:
|
||||||
|
source: "coolguy"
|
||||||
|
verb: "foo"
|
||||||
|
params:
|
||||||
|
- "bar"
|
||||||
|
- "baz"
|
||||||
|
- " "
|
||||||
|
matches:
|
||||||
|
- ":coolguy foo bar baz : "
|
||||||
|
|
||||||
|
- desc: Param containing tab (tab is not considered SPACE for message splitting).
|
||||||
|
atoms:
|
||||||
|
source: "coolguy"
|
||||||
|
verb: "foo"
|
||||||
|
params:
|
||||||
|
- "b\tar"
|
||||||
|
- "baz"
|
||||||
|
matches:
|
||||||
|
- ":coolguy foo b\tar baz"
|
||||||
|
- ":coolguy foo b\tar :baz"
|
||||||
|
|
||||||
|
# with tags
|
||||||
|
- desc: Tag with no value and space-filled trailing.
|
||||||
|
atoms:
|
||||||
|
tags:
|
||||||
|
"asd": ""
|
||||||
|
source: "coolguy"
|
||||||
|
verb: "foo"
|
||||||
|
params:
|
||||||
|
- "bar"
|
||||||
|
- "baz"
|
||||||
|
- " "
|
||||||
|
matches:
|
||||||
|
- "@asd :coolguy foo bar baz : "
|
||||||
|
|
||||||
|
- desc: Tags with escaped values.
|
||||||
|
atoms:
|
||||||
|
verb: "foo"
|
||||||
|
tags:
|
||||||
|
"a": "b\\and\nk"
|
||||||
|
"d": "gh;764"
|
||||||
|
matches:
|
||||||
|
- "@a=b\\\\and\\nk;d=gh\\:764 foo"
|
||||||
|
- "@d=gh\\:764;a=b\\\\and\\nk foo"
|
||||||
|
|
||||||
|
- desc: Tags with escaped values and params.
|
||||||
|
atoms:
|
||||||
|
verb: "foo"
|
||||||
|
tags:
|
||||||
|
"a": "b\\and\nk"
|
||||||
|
"d": "gh;764"
|
||||||
|
params:
|
||||||
|
- "par1"
|
||||||
|
- "par2"
|
||||||
|
matches:
|
||||||
|
- "@a=b\\\\and\\nk;d=gh\\:764 foo par1 par2"
|
||||||
|
- "@a=b\\\\and\\nk;d=gh\\:764 foo par1 :par2"
|
||||||
|
- "@d=gh\\:764;a=b\\\\and\\nk foo par1 par2"
|
||||||
|
- "@d=gh\\:764;a=b\\\\and\\nk foo par1 :par2"
|
||||||
|
|
||||||
|
- desc: Tag with long, strange values (including LF and newline).
|
||||||
|
atoms:
|
||||||
|
tags:
|
||||||
|
foo: "\\\\;\\s \r\n"
|
||||||
|
verb: "COMMAND"
|
||||||
|
matches:
|
||||||
|
- "@foo=\\\\\\\\\\:\\\\s\\s\\r\\n COMMAND"
|
|
@ -0,0 +1,343 @@
|
||||||
|
# IRC parser tests
|
||||||
|
# splitting messages into usable atoms
|
||||||
|
|
||||||
|
# Written in 2015 by Daniel Oaks <daniel@danieloaks.net>
|
||||||
|
#
|
||||||
|
# To the extent possible under law, the author(s) have dedicated all copyright
|
||||||
|
# and related and neighboring rights to this software to the public domain
|
||||||
|
# worldwide. This software is distributed without any warranty.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the CC0 Public Domain Dedication along
|
||||||
|
# with this software. If not, see
|
||||||
|
# <http://creativecommons.org/publicdomain/zero/1.0/>.
|
||||||
|
|
||||||
|
# some of the tests here originate from grawity's test vectors, which is WTFPL v2 licensed
|
||||||
|
# https://github.com/grawity/code/tree/master/lib/tests
|
||||||
|
# some of the tests here originate from Mozilla's test vectors, which is public domain
|
||||||
|
# https://dxr.mozilla.org/comm-central/source/chat/protocols/irc/test/test_ircMessage.js
|
||||||
|
# some of the tests here originate from SaberUK's test vectors, which he's indicated I am free to include here
|
||||||
|
# https://github.com/SaberUK/ircparser/tree/master/test
|
||||||
|
|
||||||
|
# we follow RFC1459 with regards to multiple ascii spaces splitting atoms:
|
||||||
|
# The prefix, command, and all parameters are
|
||||||
|
# separated by one (or more) ASCII space character(s) (0x20).
|
||||||
|
# because doing it as RFC2812 says (strictly as a single ascii space) isn't sane
|
||||||
|
|
||||||
|
tests:
|
||||||
|
# input is the string coming directly from the server to parse
|
||||||
|
|
||||||
|
# the atoms dict has the keys:
|
||||||
|
# * tags: tags dict
|
||||||
|
# tags with no value are an empty string
|
||||||
|
# * source: source string, without single leading colon
|
||||||
|
# * verb: verb string
|
||||||
|
# * params: params split up as a list
|
||||||
|
# if the params key does not exist, assume it is empty
|
||||||
|
# if any other keys do no exist, assume they are null
|
||||||
|
# a key that is null does not exist or is not specified with the
|
||||||
|
# given input string
|
||||||
|
|
||||||
|
# simple
|
||||||
|
- input: "foo bar baz asdf"
|
||||||
|
atoms:
|
||||||
|
verb: "foo"
|
||||||
|
params:
|
||||||
|
- "bar"
|
||||||
|
- "baz"
|
||||||
|
- "asdf"
|
||||||
|
|
||||||
|
# with source
|
||||||
|
- input: ":coolguy foo bar baz asdf"
|
||||||
|
atoms:
|
||||||
|
source: "coolguy"
|
||||||
|
verb: "foo"
|
||||||
|
params:
|
||||||
|
- "bar"
|
||||||
|
- "baz"
|
||||||
|
- "asdf"
|
||||||
|
|
||||||
|
# with trailing param
|
||||||
|
- input: "foo bar baz :asdf quux"
|
||||||
|
atoms:
|
||||||
|
verb: "foo"
|
||||||
|
params:
|
||||||
|
- "bar"
|
||||||
|
- "baz"
|
||||||
|
- "asdf quux"
|
||||||
|
|
||||||
|
- input: "foo bar baz :"
|
||||||
|
atoms:
|
||||||
|
verb: "foo"
|
||||||
|
params:
|
||||||
|
- "bar"
|
||||||
|
- "baz"
|
||||||
|
- ""
|
||||||
|
|
||||||
|
- input: "foo bar baz ::asdf"
|
||||||
|
atoms:
|
||||||
|
verb: "foo"
|
||||||
|
params:
|
||||||
|
- "bar"
|
||||||
|
- "baz"
|
||||||
|
- ":asdf"
|
||||||
|
|
||||||
|
# with source and trailing param
|
||||||
|
- input: ":coolguy foo bar baz :asdf quux"
|
||||||
|
atoms:
|
||||||
|
source: "coolguy"
|
||||||
|
verb: "foo"
|
||||||
|
params:
|
||||||
|
- "bar"
|
||||||
|
- "baz"
|
||||||
|
- "asdf quux"
|
||||||
|
|
||||||
|
- input: ":coolguy foo bar baz : asdf quux "
|
||||||
|
atoms:
|
||||||
|
source: "coolguy"
|
||||||
|
verb: "foo"
|
||||||
|
params:
|
||||||
|
- "bar"
|
||||||
|
- "baz"
|
||||||
|
- " asdf quux "
|
||||||
|
|
||||||
|
- input: ":coolguy PRIVMSG bar :lol :) "
|
||||||
|
atoms:
|
||||||
|
source: "coolguy"
|
||||||
|
verb: "PRIVMSG"
|
||||||
|
params:
|
||||||
|
- "bar"
|
||||||
|
- "lol :) "
|
||||||
|
|
||||||
|
- input: ":coolguy foo bar baz :"
|
||||||
|
atoms:
|
||||||
|
source: "coolguy"
|
||||||
|
verb: "foo"
|
||||||
|
params:
|
||||||
|
- "bar"
|
||||||
|
- "baz"
|
||||||
|
- ""
|
||||||
|
|
||||||
|
- input: ":coolguy foo bar baz : "
|
||||||
|
atoms:
|
||||||
|
source: "coolguy"
|
||||||
|
verb: "foo"
|
||||||
|
params:
|
||||||
|
- "bar"
|
||||||
|
- "baz"
|
||||||
|
- " "
|
||||||
|
|
||||||
|
# with tags
|
||||||
|
- input: "@a=b;c=32;k;rt=ql7 foo"
|
||||||
|
atoms:
|
||||||
|
verb: "foo"
|
||||||
|
tags:
|
||||||
|
"a": "b"
|
||||||
|
"c": "32"
|
||||||
|
"k": ""
|
||||||
|
"rt": "ql7"
|
||||||
|
|
||||||
|
# with escaped tags
|
||||||
|
- input: "@a=b\\\\and\\nk;c=72\\s45;d=gh\\:764 foo"
|
||||||
|
atoms:
|
||||||
|
verb: "foo"
|
||||||
|
tags:
|
||||||
|
"a": "b\\and\nk"
|
||||||
|
"c": "72 45"
|
||||||
|
"d": "gh;764"
|
||||||
|
|
||||||
|
# with tags and source
|
||||||
|
- input: "@c;h=;a=b :quux ab cd"
|
||||||
|
atoms:
|
||||||
|
tags:
|
||||||
|
"c": ""
|
||||||
|
"h": ""
|
||||||
|
"a": "b"
|
||||||
|
source: "quux"
|
||||||
|
verb: "ab"
|
||||||
|
params:
|
||||||
|
- "cd"
|
||||||
|
|
||||||
|
# different forms of last param
|
||||||
|
- input: ":src JOIN #chan"
|
||||||
|
atoms:
|
||||||
|
source: "src"
|
||||||
|
verb: "JOIN"
|
||||||
|
params:
|
||||||
|
- "#chan"
|
||||||
|
|
||||||
|
- input: ":src JOIN :#chan"
|
||||||
|
atoms:
|
||||||
|
source: "src"
|
||||||
|
verb: "JOIN"
|
||||||
|
params:
|
||||||
|
- "#chan"
|
||||||
|
|
||||||
|
# with and without last param
|
||||||
|
- input: ":src AWAY"
|
||||||
|
atoms:
|
||||||
|
source: "src"
|
||||||
|
verb: "AWAY"
|
||||||
|
|
||||||
|
- input: ":src AWAY "
|
||||||
|
atoms:
|
||||||
|
source: "src"
|
||||||
|
verb: "AWAY"
|
||||||
|
|
||||||
|
# tab is not considered <SPACE>
|
||||||
|
- input: ":cool\tguy foo bar baz"
|
||||||
|
atoms:
|
||||||
|
source: "cool\tguy"
|
||||||
|
verb: "foo"
|
||||||
|
params:
|
||||||
|
- "bar"
|
||||||
|
- "baz"
|
||||||
|
|
||||||
|
# with weird control codes in the source
|
||||||
|
- input: ":coolguy!ag@net\x035w\x03ork.admin PRIVMSG foo :bar baz"
|
||||||
|
atoms:
|
||||||
|
source: "coolguy!ag@net\x035w\x03ork.admin"
|
||||||
|
verb: "PRIVMSG"
|
||||||
|
params:
|
||||||
|
- "foo"
|
||||||
|
- "bar baz"
|
||||||
|
|
||||||
|
- input: ":coolguy!~ag@n\x02et\x0305w\x0fork.admin PRIVMSG foo :bar baz"
|
||||||
|
atoms:
|
||||||
|
source: "coolguy!~ag@n\x02et\x0305w\x0fork.admin"
|
||||||
|
verb: "PRIVMSG"
|
||||||
|
params:
|
||||||
|
- "foo"
|
||||||
|
- "bar baz"
|
||||||
|
|
||||||
|
- input: "@tag1=value1;tag2;vendor1/tag3=value2;vendor2/tag4= :irc.example.com COMMAND param1 param2 :param3 param3"
|
||||||
|
atoms:
|
||||||
|
tags:
|
||||||
|
tag1: "value1"
|
||||||
|
tag2: ""
|
||||||
|
vendor1/tag3: "value2"
|
||||||
|
vendor2/tag4: ""
|
||||||
|
source: "irc.example.com"
|
||||||
|
verb: "COMMAND"
|
||||||
|
params:
|
||||||
|
- "param1"
|
||||||
|
- "param2"
|
||||||
|
- "param3 param3"
|
||||||
|
|
||||||
|
- input: ":irc.example.com COMMAND param1 param2 :param3 param3"
|
||||||
|
atoms:
|
||||||
|
source: "irc.example.com"
|
||||||
|
verb: "COMMAND"
|
||||||
|
params:
|
||||||
|
- "param1"
|
||||||
|
- "param2"
|
||||||
|
- "param3 param3"
|
||||||
|
|
||||||
|
- input: "@tag1=value1;tag2;vendor1/tag3=value2;vendor2/tag4 COMMAND param1 param2 :param3 param3"
|
||||||
|
atoms:
|
||||||
|
tags:
|
||||||
|
tag1: "value1"
|
||||||
|
tag2: ""
|
||||||
|
vendor1/tag3: "value2"
|
||||||
|
vendor2/tag4: ""
|
||||||
|
verb: "COMMAND"
|
||||||
|
params:
|
||||||
|
- "param1"
|
||||||
|
- "param2"
|
||||||
|
- "param3 param3"
|
||||||
|
|
||||||
|
- input: "COMMAND"
|
||||||
|
atoms:
|
||||||
|
verb: "COMMAND"
|
||||||
|
|
||||||
|
# yaml encoding + slashes is fun
|
||||||
|
- input: "@foo=\\\\\\\\\\:\\\\s\\s\\r\\n COMMAND"
|
||||||
|
atoms:
|
||||||
|
tags:
|
||||||
|
foo: "\\\\;\\s \r\n"
|
||||||
|
verb: "COMMAND"
|
||||||
|
|
||||||
|
# broken messages from unreal
|
||||||
|
- input: ":gravel.mozilla.org 432 #momo :Erroneous Nickname: Illegal characters"
|
||||||
|
atoms:
|
||||||
|
source: "gravel.mozilla.org"
|
||||||
|
verb: "432"
|
||||||
|
params:
|
||||||
|
- "#momo"
|
||||||
|
- "Erroneous Nickname: Illegal characters"
|
||||||
|
|
||||||
|
- input: ":gravel.mozilla.org MODE #tckk +n "
|
||||||
|
atoms:
|
||||||
|
source: "gravel.mozilla.org"
|
||||||
|
verb: "MODE"
|
||||||
|
params:
|
||||||
|
- "#tckk"
|
||||||
|
- "+n"
|
||||||
|
|
||||||
|
- input: ":services.esper.net MODE #foo-bar +o foobar "
|
||||||
|
atoms:
|
||||||
|
source: "services.esper.net"
|
||||||
|
verb: "MODE"
|
||||||
|
params:
|
||||||
|
- "#foo-bar"
|
||||||
|
- "+o"
|
||||||
|
- "foobar"
|
||||||
|
|
||||||
|
# tag values should be parsed char-at-a-time to prevent wayward replacements.
|
||||||
|
- input: "@tag1=value\\\\ntest COMMAND"
|
||||||
|
atoms:
|
||||||
|
tags:
|
||||||
|
tag1: "value\\ntest"
|
||||||
|
verb: "COMMAND"
|
||||||
|
|
||||||
|
# If a tag value has a slash followed by a character which doesn't need
|
||||||
|
# to be escaped, the slash should be dropped.
|
||||||
|
- input: "@tag1=value\\1 COMMAND"
|
||||||
|
atoms:
|
||||||
|
tags:
|
||||||
|
tag1: "value1"
|
||||||
|
verb: "COMMAND"
|
||||||
|
|
||||||
|
# A slash at the end of a tag value should be dropped
|
||||||
|
- input: "@tag1=value1\\ COMMAND"
|
||||||
|
atoms:
|
||||||
|
tags:
|
||||||
|
tag1: "value1"
|
||||||
|
verb: "COMMAND"
|
||||||
|
|
||||||
|
# Duplicate tags: Parsers SHOULD disregard all but the final occurence
|
||||||
|
- input: "@tag1=1;tag2=3;tag3=4;tag1=5 COMMAND"
|
||||||
|
atoms:
|
||||||
|
tags:
|
||||||
|
tag1: "5"
|
||||||
|
tag2: "3"
|
||||||
|
tag3: "4"
|
||||||
|
verb: "COMMAND"
|
||||||
|
|
||||||
|
# vendored tags can have the same name as a non-vendored tag
|
||||||
|
- input: "@tag1=1;tag2=3;tag3=4;tag1=5;vendor/tag2=8 COMMAND"
|
||||||
|
atoms:
|
||||||
|
tags:
|
||||||
|
tag1: "5"
|
||||||
|
tag2: "3"
|
||||||
|
tag3: "4"
|
||||||
|
vendor/tag2: "8"
|
||||||
|
verb: "COMMAND"
|
||||||
|
|
||||||
|
# Some parsers handle /MODE in a special way, make sure they do it right
|
||||||
|
- input: ":SomeOp MODE #channel :+i"
|
||||||
|
atoms:
|
||||||
|
source: "SomeOp"
|
||||||
|
verb: "MODE"
|
||||||
|
params:
|
||||||
|
- "#channel"
|
||||||
|
- "+i"
|
||||||
|
|
||||||
|
- input: ":SomeOp MODE #channel +oo SomeUser :AnotherUser"
|
||||||
|
atoms:
|
||||||
|
source: "SomeOp"
|
||||||
|
verb: "MODE"
|
||||||
|
params:
|
||||||
|
- "#channel"
|
||||||
|
- "+oo"
|
||||||
|
- "SomeUser"
|
||||||
|
- "AnotherUser"
|
|
@ -0,0 +1,99 @@
|
||||||
|
/* eslint-env mocha */
|
||||||
|
import assert from 'assert'
|
||||||
|
import { Line } from '../src'
|
||||||
|
|
||||||
|
describe('format', () => {
|
||||||
|
describe('tags', () => {
|
||||||
|
it('Should format a line with tags', () => {
|
||||||
|
const line = new Line({
|
||||||
|
command: 'PRIVMSG',
|
||||||
|
params: ['#channel', 'hello'],
|
||||||
|
tags: { id: '\\ ;\r\n' }
|
||||||
|
})
|
||||||
|
assert.strictEqual(line.format(), '@id=\\\\\\s\\:\\r\\n PRIVMSG #channel hello')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should format without tags', () => {
|
||||||
|
const line = new Line({ command: 'PRIVMSG', params: ['#channel', 'hello'] })
|
||||||
|
assert.strictEqual(line.format(), 'PRIVMSG #channel hello')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should handle tags with undefined value', () => {
|
||||||
|
const line = new Line({
|
||||||
|
command: 'PRIVMSG',
|
||||||
|
params: ['#channel', 'hello'],
|
||||||
|
tags: { a: undefined as unknown as string }
|
||||||
|
})
|
||||||
|
assert.strictEqual(line.format(), '@a PRIVMSG #channel hello')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should handle tags with empty string value', () => {
|
||||||
|
const line = new Line({
|
||||||
|
command: 'PRIVMSG',
|
||||||
|
params: ['#channel', 'hello'],
|
||||||
|
tags: { a: '' }
|
||||||
|
})
|
||||||
|
assert.strictEqual(line.format(), '@a PRIVMSG #channel hello')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('source', () => {
|
||||||
|
it('Should format a line with source', () => {
|
||||||
|
const line = new Line({
|
||||||
|
command: 'PRIVMSG',
|
||||||
|
params: ['#channel', 'hello'],
|
||||||
|
source: 'nick!user@host'
|
||||||
|
})
|
||||||
|
assert.strictEqual(line.format(), ':nick!user@host PRIVMSG #channel hello')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('command', () => {
|
||||||
|
it('Should format a line with a lowercase command', () => {
|
||||||
|
const line = new Line({ command: 'privmsg', params: null as unknown as string[] })
|
||||||
|
assert.strictEqual(line.format(), 'privmsg')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should format a line with an uppercase command', () => {
|
||||||
|
const line = new Line({ command: 'PRIVMSG', params: null as unknown as string[] })
|
||||||
|
assert.strictEqual(line.format(), 'PRIVMSG')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('trailing param', () => {
|
||||||
|
it('Should format a line with a space in the trailing param', () => {
|
||||||
|
const line = new Line({ command: 'PRIVMSG', params: ['#channel', 'hello world'] })
|
||||||
|
assert.strictEqual(line.format(), 'PRIVMSG #channel :hello world')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should format a line without a space in the trailing param', () => {
|
||||||
|
const line = new Line({ command: 'PRIVMSG', params: ['#channel', 'helloworld'] })
|
||||||
|
assert.strictEqual(line.format(), 'PRIVMSG #channel helloworld')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should format a line with a colon in the trailing param', () => {
|
||||||
|
const line = new Line({ command: 'PRIVMSG', params: ['#channel', ':helloworld'] })
|
||||||
|
assert.strictEqual(line.format(), 'PRIVMSG #channel ::helloworld')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('invalid param', () => {
|
||||||
|
it('Should throw if non-last param includes a space', () => {
|
||||||
|
const line = new Line({ command: 'PRIVMSG', params: ['user', '0 *', 'real name'] })
|
||||||
|
assert.throws(() => {
|
||||||
|
line.format()
|
||||||
|
}, {
|
||||||
|
name: 'TypeError'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should throw if non-lst param includes a colon', () => {
|
||||||
|
const line = new Line({ command: 'PRIVMSG', params: [':#channel', 'hello'] })
|
||||||
|
assert.throws(() => {
|
||||||
|
line.format()
|
||||||
|
}, {
|
||||||
|
name: 'TypeError'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -0,0 +1,48 @@
|
||||||
|
/* eslint-env mocha */
|
||||||
|
import assert from 'assert'
|
||||||
|
import { hostmask, tokenise } from '../src'
|
||||||
|
|
||||||
|
describe('Hostmask', () => {
|
||||||
|
it('Should parse n!u@h', () => {
|
||||||
|
const hm = hostmask('nick!user@host')
|
||||||
|
assert.strictEqual(hm.nickname, 'nick')
|
||||||
|
assert.strictEqual(hm.username, 'user')
|
||||||
|
assert.strictEqual(hm.hostname, 'host')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should parse n!u', () => {
|
||||||
|
const hm = hostmask('nick!user')
|
||||||
|
assert.strictEqual(hm.nickname, 'nick')
|
||||||
|
assert.strictEqual(hm.username, 'user')
|
||||||
|
assert.strictEqual(hm.hostname, undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should parse n@h', () => {
|
||||||
|
const hm = hostmask('nick@host')
|
||||||
|
assert.strictEqual(hm.nickname, 'nick')
|
||||||
|
assert.strictEqual(hm.username, undefined)
|
||||||
|
assert.strictEqual(hm.hostname, 'host')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should parse n', () => {
|
||||||
|
const hm = hostmask('nick')
|
||||||
|
assert.strictEqual(hm.nickname, 'nick')
|
||||||
|
assert.strictEqual(hm.username, undefined)
|
||||||
|
assert.strictEqual(hm.hostname, undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should be parsed as part of a line', () => {
|
||||||
|
const line = tokenise(':nick!user@host PRIVMSG #channel hello')
|
||||||
|
const hm = hostmask('nick!user@host')
|
||||||
|
assert.deepStrictEqual(line.hostmask, hm)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Getting line.hostname should throw if message lacks source', () => {
|
||||||
|
const line = tokenise('PRIVMSG #channel hello')
|
||||||
|
assert.throws(() => {
|
||||||
|
line.hostmask
|
||||||
|
}, {
|
||||||
|
name: 'TypeError'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -0,0 +1,45 @@
|
||||||
|
/* eslint-env mocha */
|
||||||
|
import assert from 'assert'
|
||||||
|
import YAML from 'yaml'
|
||||||
|
import path from 'path'
|
||||||
|
import { readFileSync } from 'fs'
|
||||||
|
import { Line, tokenise } from '../src'
|
||||||
|
|
||||||
|
// run test cases sourced from:
|
||||||
|
// https://github.com/ircdocs/parser-tests
|
||||||
|
|
||||||
|
const dataDir = path.join(__dirname, 'data')
|
||||||
|
|
||||||
|
describe('parser tests', () => {
|
||||||
|
describe('split', () => {
|
||||||
|
const { tests } = YAML.parse(readFileSync(path.join(dataDir, 'msg-split.yaml'), 'utf-8'))
|
||||||
|
|
||||||
|
for (const test of tests) {
|
||||||
|
it(`parses ${test.input}`, () => {
|
||||||
|
const tokens = tokenise(test.input)
|
||||||
|
|
||||||
|
assert.deepStrictEqual(tokens.tags, test.atoms.tags)
|
||||||
|
assert.deepStrictEqual(tokens.source, test.atoms.source)
|
||||||
|
assert.deepStrictEqual(tokens.command, test.atoms.verb.toLocaleUpperCase())
|
||||||
|
assert.deepStrictEqual(tokens.params, test.atoms.params ?? [])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('join', () => {
|
||||||
|
const { tests } = YAML.parse(readFileSync(path.join(dataDir, 'msg-join.yaml'), 'utf-8'))
|
||||||
|
|
||||||
|
for (const { atoms, matches } of tests) {
|
||||||
|
it(`joins ${matches}`, () => {
|
||||||
|
const line = new Line({
|
||||||
|
command: atoms.verb,
|
||||||
|
params: atoms.params ?? [],
|
||||||
|
source: atoms.source,
|
||||||
|
tags: atoms.tags
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.ok(matches.includes(line.format()), `\n ${line.format()}\nwas not found in\n ${matches.join('\n ')}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
|
@ -0,0 +1,95 @@
|
||||||
|
/* eslint-env mocha */
|
||||||
|
import assert from 'assert'
|
||||||
|
import { StatefulDecoder, tokenise } from '../src'
|
||||||
|
|
||||||
|
export function str2buf (str: string) {
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
return encoder.encode(str)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('StatefulDecoder', () => {
|
||||||
|
it('Should decode partial', () => {
|
||||||
|
const decoder = new StatefulDecoder()
|
||||||
|
let lines = decoder.push(str2buf('PRIVMSG '))
|
||||||
|
|
||||||
|
assert.deepStrictEqual(lines, [])
|
||||||
|
|
||||||
|
lines = decoder.push(str2buf('#channel hello\r\n'))
|
||||||
|
assert.strictEqual(lines?.length, 1)
|
||||||
|
const line = tokenise('PRIVMSG #channel hello')
|
||||||
|
assert.deepStrictEqual(lines, [line])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should decode multiple', () => {
|
||||||
|
const decoder = new StatefulDecoder()
|
||||||
|
const lines = decoder.push(str2buf('PRIVMSG #channel1 hello\r\nPRIVMSG #channel2 hello\r\n'))
|
||||||
|
assert.strictEqual(lines?.length, 2)
|
||||||
|
|
||||||
|
const line1 = tokenise('PRIVMSG #channel1 hello')
|
||||||
|
const line2 = tokenise('PRIVMSG #channel2 hello')
|
||||||
|
assert.deepStrictEqual(lines[0], line1)
|
||||||
|
assert.deepStrictEqual(lines[1], line2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should handle non-utf8 encoding', () => {
|
||||||
|
const decoder = new StatefulDecoder('iso-8859-2')
|
||||||
|
const lines = decoder.push(Uint8Array.from([
|
||||||
|
0x50, 0x52, 0x49, 0x56, 0x4d, 0x53, 0x47, 0x20, 0x23, 0x63, 0x68, 0x61,
|
||||||
|
0x6e, 0x6e, 0x65, 0x6c, 0x20, 0x3a, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x20,
|
||||||
|
0xc8,
|
||||||
|
0x0d, 0x0a // \r\n
|
||||||
|
]))
|
||||||
|
const line = tokenise('PRIVMSG #channel :hello Č')
|
||||||
|
|
||||||
|
assert.strictEqual(lines?.length, 1)
|
||||||
|
assert.deepStrictEqual(lines[0], line)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fallback to non-utf encoding', () => {
|
||||||
|
const decoder = new StatefulDecoder('utf8', 'latin1')
|
||||||
|
const lines = decoder.push(Uint8Array.from([
|
||||||
|
0x50, 0x52, 0x49, 0x56, 0x4d, 0x53, 0x47, 0x20, 0x23, 0x63, 0x68, 0x61,
|
||||||
|
0x6e, 0x6e, 0x65, 0x6c, 0x20, 0x68, 0xe9, 0x6c, 0x6c, 0xf3,
|
||||||
|
0x0d, 0x0a // \r\n
|
||||||
|
]))
|
||||||
|
const line = tokenise('PRIVMSG #channel hélló')
|
||||||
|
|
||||||
|
assert.strictEqual(lines?.length, 1)
|
||||||
|
assert.deepStrictEqual(lines[0], line)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should return undefined if given no input', () => {
|
||||||
|
const decoder = new StatefulDecoder()
|
||||||
|
const lines = decoder.push(new Uint8Array())
|
||||||
|
assert.strictEqual(lines, undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should return undefined if given unterminated input', () => {
|
||||||
|
const decoder = new StatefulDecoder()
|
||||||
|
decoder.push(str2buf('PRIVMSG #channel hello'))
|
||||||
|
const lines = decoder.push(new Uint8Array())
|
||||||
|
assert.strictEqual(lines, undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should be clearable', () => {
|
||||||
|
const decoder = new StatefulDecoder()
|
||||||
|
decoder.push(str2buf('PRIVMSG '))
|
||||||
|
decoder.clear()
|
||||||
|
assert.deepStrictEqual(decoder.pending(), new Uint8Array())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should handle encoding mismatches', () => {
|
||||||
|
const decoder = new StatefulDecoder()
|
||||||
|
decoder.push(str2buf('@asd=á '))
|
||||||
|
const lines = decoder.push(Uint8Array.from([
|
||||||
|
// latin1: PRIVMSG #chan :á
|
||||||
|
0x50, 0x52, 0x49, 0x56, 0x4d, 0x53, 0x47, 0x20, 0x23, 0x63, 0x68, 0x61,
|
||||||
|
0x6e, 0x20, 0x3a, 0xe1,
|
||||||
|
0x0d, 0x0a // \r\n
|
||||||
|
]))
|
||||||
|
|
||||||
|
assert.strictEqual(lines?.length, 1)
|
||||||
|
assert.strictEqual(lines[0].params[1], 'á')
|
||||||
|
assert.strictEqual(lines[0].tags?.asd, 'á')
|
||||||
|
})
|
||||||
|
})
|
|
@ -0,0 +1,49 @@
|
||||||
|
/* eslint-env mocha */
|
||||||
|
import assert from 'assert'
|
||||||
|
import { StatefulEncoder, tokenise } from '../src'
|
||||||
|
import { str2buf } from './stateful-decode.test'
|
||||||
|
|
||||||
|
describe('StatefulEncoder', () => {
|
||||||
|
it('Should push a line', () => {
|
||||||
|
const encoder = new StatefulEncoder()
|
||||||
|
const line = tokenise('PRIVMSG #channel hello')
|
||||||
|
encoder.push(line)
|
||||||
|
assert.deepStrictEqual(encoder.pending(), str2buf('PRIVMSG #channel hello\r\n'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should pop of a partial line', () => {
|
||||||
|
const encoder = new StatefulEncoder()
|
||||||
|
const line = tokenise('PRIVMSG #channel hello')
|
||||||
|
encoder.push(line)
|
||||||
|
encoder.pop(str2buf('PRIVMSG #channel hello').length)
|
||||||
|
|
||||||
|
assert.deepStrictEqual(encoder.pending(), str2buf('\r\n'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should only pop off first full line when more requested', () => {
|
||||||
|
const encoder = new StatefulEncoder()
|
||||||
|
const line = tokenise('PRIVMSG #channel hello')
|
||||||
|
encoder.push(line)
|
||||||
|
encoder.push(line)
|
||||||
|
const lines = encoder.pop(str2buf('PRIVMSG #channel hello\r\nPRIVMSG').length)
|
||||||
|
|
||||||
|
assert.strictEqual(lines.length, 1)
|
||||||
|
assert.deepStrictEqual(lines[0], line)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should return no lines on pop if there are no complete lines in requested size', () => {
|
||||||
|
const encoder = new StatefulEncoder()
|
||||||
|
const line = tokenise('PRIVMSG #channel hello')
|
||||||
|
encoder.push(line)
|
||||||
|
const lines = encoder.pop(1)
|
||||||
|
assert.strictEqual(lines.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should be clearable', () => {
|
||||||
|
const encoder = new StatefulEncoder()
|
||||||
|
encoder.push(tokenise('PRIVMSG #channel hello'))
|
||||||
|
encoder.clear()
|
||||||
|
|
||||||
|
assert.deepStrictEqual(encoder.pending(), new Uint8Array())
|
||||||
|
})
|
||||||
|
})
|
|
@ -0,0 +1,112 @@
|
||||||
|
/* eslint-env mocha */
|
||||||
|
import assert from 'assert'
|
||||||
|
import { Line, tokenise } from '../src'
|
||||||
|
import { str2buf } from './stateful-decode.test'
|
||||||
|
|
||||||
|
describe('tokenise', () => {
|
||||||
|
it('Should tokenise a line', () => {
|
||||||
|
const line = tokenise('@id=123 :nick!user@host PRIVMSG #channel :hello world')
|
||||||
|
assert.deepStrictEqual(line, new Line({
|
||||||
|
tags: { id: '123' },
|
||||||
|
source: 'nick!user@host',
|
||||||
|
command: 'PRIVMSG',
|
||||||
|
params: ['#channel', 'hello world']
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should stop tokenising at nullbyte', () => {
|
||||||
|
const line = tokenise(':nick!user@host PRIVMSG #channel :hello\x00 world')
|
||||||
|
assert.deepStrictEqual(line.params, ['#channel', 'hello'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should parse byte representation of a line', () => {
|
||||||
|
const strLine = tokenise('@a=1 :n!u@h PRIVMSG #chan :hello word')
|
||||||
|
const bufLine = tokenise(str2buf('@a=1 :n!u@h PRIVMSG #chan :hello word'))
|
||||||
|
|
||||||
|
assert.deepStrictEqual(strLine, bufLine)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should throw if missing command', () => {
|
||||||
|
assert.throws(() => { tokenise(':n!u@h') }, { name: 'TypeError' })
|
||||||
|
assert.throws(() => { tokenise('@tag=1 :n!u@h') }, { name: 'TypeError' })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('tags', () => {
|
||||||
|
it('Should tokenise a line without tags', () => {
|
||||||
|
const line = tokenise('PRIVMSG #channel')
|
||||||
|
assert.strictEqual(line.tags, undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should tokenise an empty tag', () => {
|
||||||
|
const line = tokenise('@id= PRIVMSG #channel')
|
||||||
|
assert.deepStrictEqual(line.tags, { id: '' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should tokenise a tag with only key', () => {
|
||||||
|
const line = tokenise('@id PRIVMSG #channel')
|
||||||
|
assert.deepStrictEqual(line.tags, { id: '' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should unescape tags', () => {
|
||||||
|
const line = tokenise('@id=1\\\\\\:\\r\\n\\s2 PRIVMSG #channel')
|
||||||
|
assert.deepStrictEqual(line.tags, { id: '1\\;\r\n 2' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should unescape tags with overlapping escapes', () => {
|
||||||
|
const line = tokenise('@id=1\\\\\\s\\\\s PRIVMSG #channel')
|
||||||
|
assert.deepStrictEqual(line.tags, { id: '1\\ \\s' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should ignore trailing backslash', () => {
|
||||||
|
const line = tokenise('@id=1\\ PRIVMSG #channel')
|
||||||
|
assert.deepStrictEqual(line.tags, { id: '1' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('source', () => {
|
||||||
|
it('Should find source in message without tags', () => {
|
||||||
|
const line = tokenise(':nick!user@host PRIVMSG #channel')
|
||||||
|
assert.strictEqual(line.source, 'nick!user@host')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should find source in message with tags', () => {
|
||||||
|
const line = tokenise('@id=123 :nick!user@host PRIVMSG #channel')
|
||||||
|
assert.strictEqual(line.source, 'nick!user@host')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should be undefined if no source in message without tags', () => {
|
||||||
|
const line = tokenise('PRIVMSG #channel')
|
||||||
|
assert.strictEqual(line.source, undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should be undefined if no source in message with tags', () => {
|
||||||
|
const line = tokenise('@id=123 PRIVMSG #channel')
|
||||||
|
assert.strictEqual(line.source, undefined)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('command', () => {
|
||||||
|
it('Should transform the command to uppercase', () => {
|
||||||
|
const line = tokenise('privmsg #channel')
|
||||||
|
assert.strictEqual(line.command, 'PRIVMSG')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('params', () => {
|
||||||
|
it('Should tokenise trailing paramerer', () => {
|
||||||
|
const line = tokenise('PRIVMSG #channel :hello world')
|
||||||
|
assert.deepStrictEqual(line.params, ['#channel', 'hello world'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should tokenise message with only a trailing parameter', () => {
|
||||||
|
const line = tokenise('PRIVMSG :hello world')
|
||||||
|
assert.deepStrictEqual(line.params, ['hello world'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should have an empty array fro messages without params', () => {
|
||||||
|
const line = tokenise('PRIVMSG')
|
||||||
|
assert.strictEqual(line.command, 'PRIVMSG')
|
||||||
|
assert.deepStrictEqual(line.params, [])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2019",
|
||||||
|
"module": "commonjs",
|
||||||
|
"declaration": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"esModuleInterop": true
|
||||||
|
},
|
||||||
|
"exclude": [
|
||||||
|
"dist",
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig",
|
||||||
|
"exclude": [
|
||||||
|
"dist",
|
||||||
|
"tests",
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
Loading…
Reference in New Issue