diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..0edd406 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,6 @@ +root = true + +[*] +charset = utf-8 +insert_final_newline = true +trim_trailing_whitespace = true \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2f0f18a --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/.dart_tool/ +/.vscode/settings.json + +/pubspec.lock + +/build/ +/doc/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8768f7b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0] - 2024-07-10 + +### Added + +- Initial release. + +[1.0.0]: https://github.com/cathive/dart-file-tailer/releases/tag/v1.0.0 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2c8aca9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,27 @@ +Copyright 2014, Benjamin Patrick Jung. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b985b7e --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +# file_tailer + +A cross-platform file tailing library for Dart. + +The file_tailer package provides functionality to read the stream the contents of a file to which data might be appended. This is especially useful for log files. + +## Using + +The file_tailer library was designed to be used without a prefix. + +```dart +import 'package:file_tailer/file_tailer.dart'; +``` + +The most common way to use this library to stream log files is by handling the data that is emitted +by tailing a file: + +```dart +import 'dart:convert' show LineSplitter, utf8; +import 'dart:io' show File, stderr, stdout; + +import 'package:file_tailer/file_tailer.dart' show tailFile; + +void main(List arguments) { + if (arguments.length != 1) { + stderr.write('You need to provide exactly one file to be tailed.\n'); + return 1; + } + final (stream, _) = tailFile(File(arguments.first)); + stream + .transform(utf8.decoder) + .transform(const LineSplitter()) + .forEach((line) async => stdout.write('$line\n')); +} +``` diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..0b63e94 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,17 @@ +include: package:dart_flutter_team_lints/analysis_options.yaml + +linter: + rules: + - avoid_private_typedef_functions + - avoid_unused_constructor_parameters + - avoid_void_async + - cancel_subscriptions + - join_return_with_assignment + - missing_whitespace_between_adjacent_strings + - no_runtimeType_toString + - package_api_docs + - prefer_const_declarations + - prefer_expression_function_bodies + - prefer_final_locals + - unnecessary_breaks + - use_string_buffers diff --git a/example/example.dart b/example/example.dart new file mode 100644 index 0000000..a870664 --- /dev/null +++ b/example/example.dart @@ -0,0 +1,16 @@ +import 'dart:convert' show LineSplitter, utf8; +import 'dart:io' show File, stderr, stdout; + +import 'package:file_tailer/file_tailer.dart' show tailFile; + +void main(List arguments) { + if (arguments.length != 1) { + stderr.write('You need to provide exactly one file to be tailed.\n'); + return 1; + } + final (stream, _) = tailFile(File(arguments.first)); + stream + .transform(utf8.decoder) + .transform(const LineSplitter()) + .forEach((line) async => stdout.write('$line\n')); +} diff --git a/lib/file_tailer.dart b/lib/file_tailer.dart new file mode 100755 index 0000000..9660f5b --- /dev/null +++ b/lib/file_tailer.dart @@ -0,0 +1,126 @@ +import 'dart:async' show Completer; +import 'dart:io' + show + File, + FileMode, + FileSystemEvent, + FileSystemModifyEvent, + RandomAccessFile; +import 'dart:typed_data' show Uint8List; + +import 'package:async/async.dart' show StreamGroup; + +const _DEFAULT_BUFFER_SIZE = 8192; +const _DEFAULT_READ_TIMEOUT = Duration(milliseconds: 100); + +/// Facility to tail the contents of a file. +abstract class FileTailer { + File get file; + + /// Starts to read from the file pointed at by this tailer. + /// The caller of this function is responsible for cancelling the + /// read request by calling the appropriate method if no more data + /// shall be received. + Stream> stream(); + + /// Cancels the tailing once the given position at [pos] has been reached. + /// Pass `-1` as [pos] to cancel immediately. + Future cancel({int pos = -1}); + + // Creates a new tailer, that can be used to stream the contents of a file. + factory FileTailer(final File file, + {final int bufferSize = _DEFAULT_BUFFER_SIZE, + final Duration readTimeout = _DEFAULT_READ_TIMEOUT}) => + _FileTailer(file, bufferSize: bufferSize, readTimeout: readTimeout); +} + +// Starts tailing the contents of a file. +(Stream>, Future Function({int pos})) tailFile(final File file, + {final int bufferSize = _DEFAULT_BUFFER_SIZE, + final Duration readTimeout = _DEFAULT_READ_TIMEOUT}) { + final tailer = + FileTailer(file, bufferSize: bufferSize, readTimeout: readTimeout); + return (tailer.stream(), tailer.cancel); +} + +/// Default implementation of the file tailer interface. +class _FileTailer implements FileTailer { + final File _file; + final Uint8List _buf; + final Duration _readTimeout; + + final Completer _done = Completer(); + + int _pos; + bool _cancelled = false; + int _cancelledPos = -1; + + _FileTailer(final File file, + {required final int bufferSize, required final Duration readTimeout}) + : _file = file, + _buf = Uint8List(bufferSize), + _readTimeout = readTimeout, + _pos = 0; + + @override + File get file => _file; + + @override + Stream> stream() async* { + final events = StreamGroup.merge([ + // Initial event, because the file might already contain data which + // we want to consume before something gets appended. + Stream.value(FileSystemModifyEvent(_file.path, false, true)), + // Modification events + _file.watch(events: FileSystemEvent.all) + ]); + + final fileHandle = await _file.open(mode: FileMode.read); + _pos = await fileHandle.position(); + + // Wait for modify events and read more bytes from file + await for (final event in events) { + if (_cancelled) { + await fileHandle.close(); + return; + } + switch (event.type) { + case FileSystemEvent.modify: + yield* _read(fileHandle); + break; + case FileSystemEvent.delete: + await cancel(); + break; + default: + // All other events should be ignored for now. + break; + } + if (_cancelled) { + await fileHandle.close(); + return; + } + } + } + + @override + Future cancel({final int pos = -1}) async { + _cancelled = true; + _cancelledPos = pos; + + // Wait until reading has truly come to an end. + return _done.future; + } + + Stream _read(final RandomAccessFile fileHandle) async* { + while (!(_cancelled && _pos >= _cancelledPos)) { + final bytesRead = await fileHandle.readInto(_buf).timeout(_readTimeout); + if (bytesRead == 0) { + // Let's check if we have been cancelled. + continue; + } + _pos += bytesRead; + yield _buf.sublist(0, bytesRead); + } + _done.complete(); + } +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100755 index 0000000..a51971f --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,19 @@ +name: file_tailer +description: Watch for file changes + +version: "1.0.0" + +repository: https://github.com/cathive/dart-file-tailer + +environment: + sdk: ">=3.0.0 <4.0.0" + +dependencies: + async: ^2.11.0 + +dev_dependencies: + dart_flutter_team_lints: ^3.1.0 + file: ^7.0.0 + path: ^1.9.0 + test: ^1.25.8 + diff --git a/test/file_tailer_test.dart b/test/file_tailer_test.dart new file mode 100755 index 0000000..5947d07 --- /dev/null +++ b/test/file_tailer_test.dart @@ -0,0 +1,101 @@ +import 'dart:convert' show LineSplitter, utf8; +import 'dart:io' show Directory, File, FileMode, IOSink; + +import 'package:file/memory.dart' show FileSystemStyle, MemoryFileSystem; +import 'package:file_tailer/file_tailer.dart' show FileTailer; +import 'package:path/path.dart' as path; +import 'package:test/test.dart' show group, setUp, tearDown, test, expect; + +Directory? tmpDir; + +/// Test data +List movies = [ + 'Star Wars Episode IV - A New Hope', + 'Star Wars Episode V - The Empire Strikes Back', + 'Star Wars Episode VI - Return of the Jedi', + 'Star Wars Episode VII - The Force Awakens' +]; + +typedef AsyncCallback = Future Function(); + +class FileContentsTester { + final File _file; + final List _lines; + final AsyncCallback? _onClose; + IOSink? _ioSink; + int _linesWritten = 0; + FileContentsTester(final File file, final List lines, + {final AsyncCallback? onClose}) + : _file = file, + _lines = lines, + _onClose = onClose { + _ioSink = _file.openWrite(mode: FileMode.writeOnlyAppend); + } + + IOSink get ioSink => _ioSink!; + + bool get hasNext => _linesWritten < _lines.length; + + Future writeNext() async { + if (hasNext) { + ioSink.writeln(_lines[_linesWritten]); + await ioSink.flush(); + _linesWritten++; + } else { + throw StateError('Cannot write data past last line.'); + } + } + + Future writeAll() async { + while (hasNext) { + await writeNext(); + } + + await ioSink.flush(); + await ioSink.close(); + if (_onClose != null) { + await _onClose!(); + } + } +} + +void main() { + setUp(() async { + tmpDir = await Directory.systemTemp.createTemp('file_tailer_test_'); + }); + tearDown(() async { + if (await tmpDir!.exists()) { + await tmpDir!.delete(recursive: true); + } + }); + group('FileTailer', () { + final fs = MemoryFileSystem(style: FileSystemStyle.posix); + test('Default constructor / factory', () { + final file = fs.file('/tmp/does-not-exist.txt'); + final tailer = FileTailer(file); + expect(tailer.file, file); + }); + test('tail()', () async { + final file = await File(path.join(tmpDir!.path, 'movies.txt')).create(); + final tailer = FileTailer(file); + final tester = FileContentsTester(file, movies, + onClose: () async => await tailer.cancel(pos: await file.length())); + + var idx = 0; + + await Future.wait([ + tester.writeAll(), + tailer + .stream() + .transform(utf8.decoder) + .transform(LineSplitter()) + .forEach((line) async { + expect(line, movies[idx]); + idx++; + }) + ]); + + expect(movies.length, idx); + }); + }); +}