You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

MessageParser.kt 5.6KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  1. package com.dmdirc.ktirc.io
  2. import com.dmdirc.ktirc.model.IrcMessage
  3. import com.dmdirc.ktirc.model.messageTags
  4. import com.dmdirc.ktirc.util.logger
  5. /**
  6. * Parses a message received from an IRC server.
  7. *
  8. * IRC messages consist of:
  9. *
  10. * - Optionally, IRCv3 message tags. Identified with an '@' character
  11. * - Optionally, a prefix, identified with an ':' character
  12. * - A command in the form of a consecutive sequence of letters or exactly three numbers
  13. * - Some number of space-separated parameters
  14. * - Optionally, a final 'trailing' parameter prefixed with a ':' character
  15. *
  16. * For example:
  17. *
  18. * ```
  19. * @aaa=bbb;ccc;example.com/ddd=eee :nick!ident@host.com PRIVMSG #someChannel :This is a test message
  20. * ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^ ^^^^^^^ ^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^
  21. * IRCv3 tags Prefix Cmd Param #1 Trailing parameter
  22. * ```
  23. *
  24. * or:
  25. *
  26. * ```
  27. * PING 12345678
  28. * ^^^^ ^^^^^^^^
  29. * Cmd Param #1
  30. * ```
  31. */
  32. internal class MessageParser {
  33. companion object {
  34. private const val AT = '@'.toByte()
  35. private const val COLON = ':'.toByte()
  36. }
  37. private val log by logger()
  38. fun parse(message: ByteArray) = CursorByteArray(message).run {
  39. IrcMessage(takeTags(), takePrefix(), String(takeWord()), takeParams())
  40. }
  41. /**
  42. * Attempts to read IRCv3 tags from the message.
  43. */
  44. private fun CursorByteArray.takeTags() = takeOptionalPrefixedSection(AT).toTagMap()
  45. /**
  46. * Attempts to read a prefix from the message.
  47. */
  48. private fun CursorByteArray.takePrefix() = takeOptionalPrefixedSection(COLON)
  49. /**
  50. * Read a single parameter from the message. If the parameter is a trailing one, the entire message will be
  51. * consumed.
  52. */
  53. private fun CursorByteArray.takeParam() = when (peek()) {
  54. COLON -> takeRemaining(skip = 1)
  55. else -> takeWord()
  56. }
  57. /**
  58. * Reads all remaining parameters from the message.
  59. */
  60. private fun CursorByteArray.takeParams() = sequence {
  61. while (!exhausted()) {
  62. yield(takeParam())
  63. }
  64. }.toList()
  65. /**
  66. * If the next word starts with the given prefix, takes and returns it, otherwise returns null.
  67. */
  68. private fun CursorByteArray.takeOptionalPrefixedSection(prefix: Byte) = when {
  69. exhausted() -> null
  70. peek() == prefix -> takeWord(skip = 1)
  71. else -> null
  72. }
  73. /**
  74. * Parses the bytes as a list of message tags. Unknown tags are discarded.
  75. */
  76. private fun ByteArray?.toTagMap() = sequence {
  77. forEachPart(';') { tag ->
  78. val index = tag.indexOf('=')
  79. val name = if (index == -1) tag else tag.substring(0 until index)
  80. messageTags[name]?.let {
  81. yield(it to if (index == -1) "" else tag.substring(index + 1).unescapeTagValue())
  82. } ?: log.severe { "Unknown message tag: $name"}
  83. }
  84. }.toMap()
  85. /**
  86. * Resolves any backslash escaped characters in a tag value.
  87. */
  88. private fun String.unescapeTagValue() = String(sequence {
  89. var escaped = false
  90. forEach { char ->
  91. when {
  92. escaped -> {
  93. char.unescaped()?.let { yield(it) }
  94. escaped = false
  95. }
  96. char == '\\' -> escaped = true
  97. else -> yield(char)
  98. }
  99. }
  100. }.toList().toCharArray())
  101. /**
  102. * Maps an escaped character in a tag value back to its real form. Returns null if the sequence is invalid.
  103. */
  104. private fun Char.unescaped() = when (this) {
  105. ':' -> ';'
  106. 'n' -> '\n'
  107. 'r' -> '\r'
  108. 's' -> ' '
  109. '\\' -> '\\'
  110. else -> null
  111. }
  112. private inline fun ByteArray?.forEachPart(delimiter: Char, action: (String) -> Unit) = this?.let {
  113. String(it).split(delimiter).forEach(action)
  114. }
  115. }
  116. /**
  117. * A ByteArray with a 'cursor' that tracks the current read position.
  118. */
  119. internal class CursorByteArray(private val data: ByteArray, var cursor: Int = 0) {
  120. companion object {
  121. private const val SPACE = ' '.toByte()
  122. }
  123. /**
  124. * Returns whether or not the cursor has reached the end of the array.
  125. */
  126. fun exhausted() = cursor >= data.size
  127. /**
  128. * Returns the next byte in the array without advancing the cursor.
  129. *
  130. * @throws ArrayIndexOutOfBoundsException If the array is [exhausted].
  131. */
  132. @Throws(ArrayIndexOutOfBoundsException::class)
  133. fun peek() = data[cursor]
  134. /**
  135. * Returns the next "word" in the byte array - that is, all non-space characters up until the next space.
  136. *
  137. * After calling this method, the cursor will be advanced to the start of the next word (i.e., it will skip over
  138. * any number of space characters).
  139. *
  140. * @param skip Number of bytes to omit from the start of the word
  141. */
  142. fun takeWord(skip: Int = 0) = data.sliceArray(cursor + skip until seekTo { it == SPACE }).apply { seekTo { it != SPACE } }
  143. /**
  144. * Takes all remaining bytes from the cursor until the end of the array.
  145. *
  146. * @param skip Number of bytes to omit from the start of the remainder
  147. */
  148. fun takeRemaining(skip: Int = 0) = data.sliceArray(cursor + skip until data.size).apply { cursor = data.size }
  149. private fun seekTo(matcher: (Byte) -> Boolean): Int {
  150. while (!exhausted() && !matcher(peek())) {
  151. cursor++
  152. }
  153. return cursor
  154. }
  155. }