initial commit

This commit is contained in:
Svante Bengtson 2021-07-12 15:34:50 +00:00
commit 6fdc96c8bf
26 changed files with 5385 additions and 0 deletions

11
.eslintrc.js Normal file
View File

@ -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'
}
}

55
.github/workflows/publish.yml vendored Normal file
View File

@ -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 }}

48
.github/workflows/qa.yml vendored Normal file
View File

@ -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 }}

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules
.vscode
dist
coverage
.nyc_output

3
.mocharc.js Normal file
View File

@ -0,0 +1,3 @@
module.exports = {
exit: true
}

22
LICENSE Normal file
View File

@ -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.

99
README.md Normal file
View File

@ -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

35
index.js Normal file
View File

@ -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] }))
}
})

3647
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

55
package.json Normal file
View File

@ -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"
}
}

3
src/const.ts Normal file
View File

@ -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']

53
src/formatting.ts Normal file
View File

@ -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(' ')
}

36
src/hostmask.ts Normal file
View File

@ -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 })
}

48
src/index.ts Normal file
View File

@ -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'

144
src/line.ts Normal file
View File

@ -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)
}

84
src/stateful.ts Normal file
View File

@ -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())
}
}

221
tests/data/msg-join.yaml Normal file
View File

@ -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"

343
tests/data/msg-split.yaml Normal file
View File

@ -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"

99
tests/format.test.ts Normal file
View File

@ -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'
})
})
})
})

48
tests/hostmask.test.ts Normal file
View File

@ -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'
})
})
})

45
tests/parser.test.ts Normal file
View File

@ -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 ')}`)
})
}
})
})

View File

@ -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, 'á')
})
})

View File

@ -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())
})
})

112
tests/tokenise.test.ts Normal file
View File

@ -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, [])
})
})
})

17
tsconfig.json Normal file
View File

@ -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"
]
}

8
tsconfig.production.json Normal file
View File

@ -0,0 +1,8 @@
{
"extends": "./tsconfig",
"exclude": [
"dist",
"tests",
"node_modules"
]
}