diff --git a/example/example.dart b/example/example.dart index 01f784b..fc0974a 100644 --- a/example/example.dart +++ b/example/example.dart @@ -15,20 +15,32 @@ void main(List args) async { timeout: Duration(seconds: 30), onConnect: () => print('Connected'), onDisconnect: () => print('Disconnected'), - onData: (data) { - print('DBG: ${data.toDebugString()}'); - print('toString(): ${data.toString()}'); - print('text: ${data.text}'); - print(''); - }, onError: (error) => print('Error: $error'), ); - await client.connect(); + final sub = await client.connect(); + if (sub == null) { + throw Exception('Failed to connect'); + } + + // listen to the stream of messages + sub.listen((data) { + print('Message received!'); + print('text: ${data.text}'); + print('Debug: ${data.toDebugString()}'); + print('Colored: ${data.coloredText.map((t) => t.formatted).join('')}'); + print(''); + }); + + // send a message client.send('Hello, world!'); + // send a command client.doo(Symbols.compression2); - // await client.disconnect(); + await Future.delayed(Duration(seconds: 5)); + + await client.disconnect(); } + diff --git a/lib/ctelnet.dart b/lib/ctelnet.dart index 57a4f43..0d1aaac 100644 --- a/lib/ctelnet.dart +++ b/lib/ctelnet.dart @@ -5,3 +5,4 @@ export 'src/client.dart'; export 'src/message.dart'; export 'src/symbols.dart'; export 'src/builder.dart'; +export 'src/color_parser/color_parser.dart'; diff --git a/lib/src/client.dart b/lib/src/client.dart index 67c6d68..ee05f0b 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -13,7 +13,7 @@ abstract class ITelnetClient { ConnectionStatus status = ConnectionStatus.disconnected; /// Connect to the server. - Future connect(); + Future?> connect(); /// Send data to the server. int send(String data); @@ -47,7 +47,6 @@ class CTelnetClient implements ITelnetClient { this.timeout = const Duration(seconds: 30), required this.onConnect, required this.onDisconnect, - required this.onData, required this.onError, }); @@ -56,7 +55,6 @@ class CTelnetClient implements ITelnetClient { final Duration timeout; final ConnectionCallback onConnect; final ConnectionCallback onDisconnect; - final DataCallback onData; final ErrorCallback onError; RawSocket? _socket; ConnectionTask? _task; @@ -67,10 +65,13 @@ class CTelnetClient implements ITelnetClient { bool get connected => _socket != null && status == ConnectionStatus.connected; StreamSubscription? _subscription; + StreamController? _controller; /// Connect to the host and port. + /// + /// Returns a [Stream] to listen for [Message]s if the connection is successful. @override - Future connect() async { + Future?> connect() async { try { status = ConnectionStatus.connecting; final task = await RawSocket.startConnect(host, port); @@ -83,12 +84,16 @@ class CTelnetClient implements ITelnetClient { onError: _onError, onDone: _onDisconnect, ); + _controller?.close(); + _controller = StreamController(); + return _controller!.stream; } catch (e, stack) { if (!_disposed) { _dispose(); _onError(e, stack); } } + return null; } _assertSocket([String? message]) { @@ -166,7 +171,7 @@ class CTelnetClient implements ITelnetClient { final data = _socket!.read(); if (data != null) { final msg = Message(data); - onData(msg); + _controller!.add(msg); } break; case RawSocketEvent.write: @@ -200,6 +205,8 @@ class CTelnetClient implements ITelnetClient { _task = null; _timeoutTask?.cancel(); _timeoutTask = null; + _controller?.close(); + _controller = null; _disposed = true; } } @@ -214,3 +221,4 @@ enum ConnectionStatus { /// The client is not connected to the server. disconnected, } + diff --git a/lib/src/color_parser/base.dart b/lib/src/color_parser/base.dart new file mode 100644 index 0000000..6e196da --- /dev/null +++ b/lib/src/color_parser/base.dart @@ -0,0 +1,78 @@ +import '../consts.dart' as consts; + +enum Token { + esc, + colorStart, + colorTerm, + colorSeparator, + literal, +} + +class TokenValue { + final Token token; + final String raw; + + const TokenValue(this.token, this.raw); + + static const esc = TokenValue(Token.esc, consts.esc); + static const colorStart = TokenValue(Token.colorStart, '['); + static const colorSeparator = TokenValue(Token.colorSeparator, ';'); + static const colorTerm = TokenValue(Token.colorTerm, 'm'); + static const empty = TokenValue(Token.literal, ''); + + TokenValue.literal(String raw) : this(Token.literal, raw); + + @override + int get hashCode => raw.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is TokenValue && + runtimeType == other.runtimeType && + token == other.token && + raw == other.raw; + + @override + String toString() => token != Token.esc ? '${token.name}($raw)' : token.name; +} + +class LexerValue { + String text; + int fgColor; + int bgColor; + + LexerValue(this.text, this.fgColor, this.bgColor); + + @override + int get hashCode => text.hashCode ^ fgColor.hashCode ^ bgColor.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is LexerValue && + runtimeType == other.runtimeType && + text == other.text && + fgColor == other.fgColor && + bgColor == other.bgColor; + + @override + String toString() => 'Lex("$text", $fgColor:$bgColor)'; +} + +abstract class IReader { + T? read(); + T? peek(); + int get length; + int get index; + bool get isDone; + void setPosition(int originalIndex); +} + +abstract class ITokenizer { + List tokenize(); +} + +abstract class ILexer { + List lex(); +} diff --git a/lib/src/color_parser/color_parser.dart b/lib/src/color_parser/color_parser.dart new file mode 100644 index 0000000..678cf54 --- /dev/null +++ b/lib/src/color_parser/color_parser.dart @@ -0,0 +1,3 @@ +export 'base.dart'; +export 'reader.dart'; +export 'parser.dart'; diff --git a/lib/src/color_parser/parser.dart b/lib/src/color_parser/parser.dart new file mode 100644 index 0000000..76bf58b --- /dev/null +++ b/lib/src/color_parser/parser.dart @@ -0,0 +1,265 @@ +import 'base.dart'; +import 'reader.dart'; +import '../consts.dart' as consts; + +/// Represents a string value with color information. +/// +/// Can be used to store the text and color information for a single token. +/// +/// Use [formatted] to get the ANSI formatted text, usable in a terminal. +class ColorToken { + /// The raw, uncoded text. + String text; + + /// The foreground color code. + int fgColor; + + /// The background color code. + int bgColor; + + /// Whether the text is bold. + bool bold; + + /// Whether the text is italic. + bool italic; + + /// Whether the text is underlined. + bool underline; + + /// Whether the text is an xterm256 color code. Otherwise, it is a standard color code. + bool xterm256; + + ColorToken({ + required this.text, + required this.fgColor, + required this.bgColor, + this.bold = false, + this.italic = false, + this.underline = false, + this.xterm256 = false, + }); + + /// Create an empty token. + factory ColorToken.empty() => ColorToken(text: '', fgColor: 0, bgColor: 0); + + /// Create a token with default color and the given text. + factory ColorToken.defaultColor(String text) => + ColorToken(text: text, fgColor: 0, bgColor: 0); + + /// Returns true if the text is empty. + bool get isEmpty => text.isEmpty; + + /// Returns true if the text is not empty. + bool get isNotEmpty => !isEmpty; + + /// Get the formatted text as ANSI formatted text. + /// + /// Outputting this value to a terminal will display the text with the correct colors. + /// + /// To format the text in other ways, use the properties to get the [fgColor] and [bgColor], + /// and construct it to whatever format you need. + String get formatted => bgColor == 0 + ? '\x1B[${fgColor}m$text\x1B[0m' + : '\x1B[$fgColor;${bgColor}m$text\x1B[0m'; + + @override + String toString() { + final b = bold ? 'b' : ''; + final i = italic ? 'i' : ''; + final u = underline ? 'u' : ''; + final x = xterm256 ? 'x' : ''; + final flags = '$b$i$u$x'; + return 'ColoredText("$text", $fgColor:$bgColor, $flags)'; + } + + @override + int get hashCode => text.hashCode ^ fgColor.hashCode ^ bgColor.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ColorToken && + runtimeType == other.runtimeType && + text == other.text && + fgColor == other.fgColor && + bgColor == other.bgColor; + + /// Set the style based on the given code. + void setStyle(int code) { + // debugPrint('setStyle: $code'); + if (code == consts.boldByte) { + bold = true; + } else if (code == consts.italicByte) { + italic = true; + } else if (code == consts.underlineByte) { + underline = true; + } + } +} + +/// A parser to parse a string with color codes. +class ColorParser implements IReader { + final IReader reader; + final _tokens = []; + + ColorParser._(this.reader); + + factory ColorParser(String text) => ColorParser._(StringReader(text)); + + /// Parse the text and return a list of [ColorToken]s. + /// + /// Each token represents a piece of text with color information. You can join all the text + /// together (without separators) to get the original text, uncolored. + /// + /// To get the colored text, use the [formatted] property of each token. + List parse() { + final lexed = []; + while (!reader.isDone) { + final token = reader.read(); + var cur = _getToken(token); + lexed.add(cur); + } + return lexed; + } + + ColorToken _getToken(String char) { + var token = ColorToken.empty(); + switch (char) { + case consts.esc: + String? next; + // keep reading until we hit the end of the escape sequence or the end of the string + while (!reader.isDone) { + next = reader.peek(); + if (next == consts.esc) { + break; + } + reader.read(); + if (next == '[') { + final color = _consumeUntil('m'); + reader.read(); + final colors = color.split(';'); + final first = int.tryParse(colors[0]) ?? 0; + final second = colors.length > 1 ? int.tryParse(colors[1]) ?? 0 : 0; + final third = colors.length > 2 ? int.tryParse(colors[2]) ?? 0 : 0; + int fg; + int bg; + if (first < 30) { + token.setStyle(first); + fg = second; + bg = third; + } else { + if (first == 38 && second == 5) { + token.xterm256 = true; + fg = third; + bg = 0; + } else { + fg = first; + bg = second; + } + } + token.fgColor = fg; + token.bgColor = bg; + // if (colors.length == 1) { + // final code = int.tryParse(colors[0]) ?? 0; + // if (code == consts.boldByte) { + // token.bold = true; + // } else if (code == consts.italicByte) { + // token.italic = true; + // } else if (code == consts.underlineByte) { + // token.underline = true; + // } else { + // token.fgColor = int.tryParse(colors[0]) ?? 0; + // } + // } else if (colors.length == 2) { + // final code = int.tryParse(colors[0]) ?? 0; + // if (code < 30) { + // token.fgColor = int.tryParse(colors[1]) ?? 0; + // } else { + // token.fgColor = int.tryParse(colors[1]) ?? 0; + // token.bgColor = int.tryParse(colors[0]) ?? 1; + // } + // } else if (colors.length == 3) { + // if (colors[0] == '38' && colors[1] == '5') { + // token.xterm256 = true; + // token.fgColor = int.tryParse(colors[2]) ?? 0; + // } else { + // token.bgColor = int.tryParse(colors[0]) ?? 1; + // token.fgColor = int.tryParse(colors[1]) ?? 0; + // } + // } + token.text = _consumeUntil(consts.esc); + return token; + } + if (next == null) { + break; + } + token.text += next; + } + return token; + default: + token.text += char; + return token; + } + } + + String _consumeUntil(String char) { + String? next = reader.peek(); + if (next == null) { + return ''; + } + var result = ''; + while (!reader.isDone) { + if (next == char) { + break; + } + next = reader.peek(); + if (next == null) { + break; + } + result += reader.read(); + next = reader.peek(); + } + return result; + } + + // String peekUntil(String char) { + // String? next = reader.peek(); + // if (next == null) { + // return ''; + // } + // var result = ''; + // var originalIndex = reader.index; + // while (!reader.isDone) { + // if (next == char) { + // break; + // } + // next = reader.peek(); + // if (next == null) { + // break; + // } + // result += reader.read(); + // next = reader.peek(); + // } + // reader.setPosition(originalIndex); + // return result; + // } + + @override + int index = 0; + + @override + bool get isDone => index >= reader.length; + + @override + peek() => _tokens[index]; + + @override + read() => _tokens[index++]; + + @override + int get length => _tokens.length; + + @override + setPosition(int position) => index = position; +} + diff --git a/lib/src/color_parser/reader.dart b/lib/src/color_parser/reader.dart new file mode 100644 index 0000000..4e78791 --- /dev/null +++ b/lib/src/color_parser/reader.dart @@ -0,0 +1,37 @@ +import 'base.dart'; + +class StringReader implements IReader { + final String input; + + @override + int index = 0; + + StringReader(this.input); + + @override + int get length => input.length; + + @override + bool get isDone => index >= length; + + @override + String? peek() { + if (isDone) { + return null; + } + return input[index]; + } + + @override + String? read() { + if (isDone) { + return null; + } + return input[index++]; + } + + @override + void setPosition(int position) { + index = position; + } +} diff --git a/lib/src/consts.dart b/lib/src/consts.dart index 550b080..83d293a 100644 --- a/lib/src/consts.dart +++ b/lib/src/consts.dart @@ -1 +1,10 @@ +const newline = '\n'; +const cr = '\r'; const lf = '\n'; +const esc = '\x1B'; +const colorPatternRaw = r'\[\d*m'; + +const boldByte = 1; +const italicByte = 3; +const underlineByte = 4; + diff --git a/lib/src/message.dart b/lib/src/message.dart index 15f816d..b2d9cae 100644 --- a/lib/src/message.dart +++ b/lib/src/message.dart @@ -1,3 +1,4 @@ +import 'color_parser/parser.dart'; import 'consts.dart'; import 'parser.dart'; import 'symbols.dart'; @@ -6,11 +7,13 @@ class Message { final List bytes; late final String text; late final List> commands; + late final List coloredText; Message(this.bytes) { - final parser = MessageParser(bytes).parse(); - commands = parser.commands; - text = parser.text; + final stringParser = MessageParser(bytes).parse(); + commands = stringParser.commands; + text = stringParser.text; + coloredText = ColorParser(text).parse(); } /// Returns true if the message contains the given WILL command @@ -74,7 +77,7 @@ class Message { } String toDebugString() { - return '${commands.map((e) => e.map((x) => symbolMap[x] ?? x)).join(' ')}${lf}n$text'; + return '${commands.map((e) => e.map((x) => symbolMap[x] ?? x)).join(' ')}$lf$text'; } int get firstLiteralByteIndex { diff --git a/script_runner.yaml b/script_runner.yaml index 0983499..762601c 100644 --- a/script_runner.yaml +++ b/script_runner.yaml @@ -1,6 +1,3 @@ scripts: - name: mud-test - cmd: HOST=aardmud.net PORT=6555 dart example/ctelnet_example.dart - env: - HOST: aardmud.net - PORT: 6555 + cmd: HOST=smud.ourmmo.com PORT=3000 dart example/example.dart diff --git a/test/color_parser_test.dart b/test/color_parser_test.dart new file mode 100644 index 0000000..97d578d --- /dev/null +++ b/test/color_parser_test.dart @@ -0,0 +1,29 @@ +import 'package:ctelnet/src/color_parser/parser.dart'; +import 'package:ctelnet/src/consts.dart'; +import 'package:test/test.dart'; + +const inputs = [ + '$esc[32mYou are standing in a small clearing.$esc[0m', + 'You are standing in a small clearing.', + '$esc[0m$esc[1m$esc[0m$esc[1m$esc[31mWelcome to SimpleMUD$esc[0m$esc[1m$esc[0m', + '$esc[0m$esc[37m$esc[0m$esc[37m$esc[1m[$esc[0m$esc[37m$esc[1m$esc[32m10$esc[0m$esc[37m$esc[1m/10]$esc[0m$esc[37m$esc[0m' +]; + +void main() { + group('ColorParser', () { + test('parse colors - simple', () { + final input = inputs[0]; + final output = ColorParser(input).parse(); + expect(output, [ + ColorToken(text: 'You are standing in a small clearing.', fgColor: 32, bgColor: 0), + ColorToken.empty(), + ]); + }); + + test('formatted', () { + final input = inputs[0]; + final output = ColorParser(input).parse(); + expect(output[0].formatted, inputs[0]); + }); + }); +} diff --git a/test/ctelnet_test.dart b/test/ctelnet_test.dart index dc5751e..7004d56 100644 --- a/test/ctelnet_test.dart +++ b/test/ctelnet_test.dart @@ -7,26 +7,32 @@ import 'package:test/test.dart'; final host = InternetAddress.anyIPv4; final port = 5555; ServerSocket? server; -Future> startServer() async { +Future> startServer({bool verbose = false}) async { + void d(Object msg) { + if (verbose) { + print(msg); + } + } + Future serverFuture = ServerSocket.bind(host, port); final timeout = Duration(seconds: 1); await Future.delayed(timeout); - print('Server init on $host:$port'); + d('Server init on $host:$port'); server = await serverFuture; - print('Server started on ${server!.address.address}:${server!.port}'); + d('Server started on ${server!.address.address}:${server!.port}'); return server!.listen( (Socket socket) { - print('Listening on ${socket.remoteAddress.address}:${socket.port}'); + d('Listening on ${socket.remoteAddress.address}:${socket.port}'); socket.listen((List data) { String result = String.fromCharCodes(data); socket.write(result); }); }, onError: (e) { - print('Server error: $e'); + d('Server error: $e'); }, onDone: () { - print('Server done'); + d('Server done'); }, cancelOnError: true, ); @@ -42,7 +48,6 @@ void main() { onError: (e) { expect(e, isA()); }, - onData: (d) {}, onConnect: () {}, onDisconnect: () {}, ); @@ -60,7 +65,6 @@ void main() { port: port, timeout: Duration(seconds: 1), onError: (e) {}, - onData: (d) {}, onConnect: () async { expect(client!.status, equals(ConnectionStatus.connected)); }, @@ -75,3 +79,4 @@ void main() { expect(client.status, equals(ConnectionStatus.disconnected)); }); } +