1package AnyEvent::Filesys::Notify;
2
3# ABSTRACT: An AnyEvent compatible module to monitor files/directories for changes
4
5use Moo;
6use Moo::Role ();
7use MooX::late;
8use namespace::autoclean;
9use AnyEvent;
10use Path::Iterator::Rule;
11use Cwd qw/abs_path/;
12use AnyEvent::Filesys::Notify::Event;
13use Carp;
14use Try::Tiny;
15
16our $VERSION = '1.23';
17my $AEFN = 'AnyEvent::Filesys::Notify';
18
19has dirs         => ( is => 'ro', isa => 'ArrayRef[Str]', required => 1 );
20has cb           => ( is => 'rw', isa => 'CodeRef',       required => 1 );
21has interval     => ( is => 'ro', isa => 'Num',           default  => 2 );
22has no_external  => ( is => 'ro', isa => 'Bool',          default  => 0 );
23has backend      => ( is => 'ro', isa => 'Str',           default  => '' );
24has filter       => ( is => 'rw', isa => 'RegexpRef|CodeRef' );
25has parse_events => ( is => 'rw', isa => 'Bool',          default  => 0 );
26has skip_subdirs => ( is => 'ro', isa => 'Bool',          default  => 0 );
27has _fs_monitor  => ( is => 'rw', );
28has _old_fs => ( is => 'rw', isa => 'HashRef' );
29has _watcher => ( is => 'rw', );
30
31sub BUILD {
32    my $self = shift;
33
34    $self->_old_fs( $self->_scan_fs( $self->dirs ) );
35
36    $self->_load_backend;
37    return $self->_init;    # initialize the backend
38}
39
40sub _process_events {
41    my ( $self, @raw_events ) = @_;
42
43    # Some implementations provided enough information to parse the raw events,
44    # other require rescanning the file system (ie, Mac::FSEvents).
45    # The original behavior was to rescan in all implementations, so we
46    # have added a flag to avoid breaking old code.
47
48    my @events;
49
50    if ( $self->parse_events and $self->can('_parse_events') ) {
51        @events =
52          $self->_parse_events( sub { $self->_apply_filter(@_) }, @raw_events );
53    } else {
54        my $new_fs = $self->_scan_fs( $self->dirs );
55        @events =
56          $self->_apply_filter( $self->_diff_fs( $self->_old_fs, $new_fs ) );
57        $self->_old_fs($new_fs);
58
59        # Some backends (when not using parse_events) need to add files
60        # (KQueue) or directories (Inotify2) to the watch list after they are
61        # created. Give them a chance to do that here.
62        $self->_post_process_events(@events)
63          if $self->can('_post_process_events');
64    }
65
66    $self->cb->(@events) if @events;
67
68    return \@events;
69}
70
71sub _apply_filter {
72    my ( $self, @events ) = @_;
73
74    if ( ref $self->filter eq 'CODE' ) {
75        my $cb = $self->filter;
76        @events = grep { $cb->( $_->path ) } @events;
77    } elsif ( ref $self->filter eq 'Regexp' ) {
78        my $re = $self->filter;
79        @events = grep { $_->path =~ $re } @events;
80    }
81
82    return @events;
83}
84
85# Return a hash ref representing all the files and stats in @path.
86# Keys are absolute path and values are path/mtime/size/is_dir
87# Takes either array or arrayref
88sub _scan_fs {
89    my ( $self, @args ) = @_;
90
91    # Accept either an array of dirs or a array ref of dirs
92    my @paths = ref $args[0] eq 'ARRAY' ? @{ $args[0] } : @args;
93
94    my $fs_stats = {};
95
96    my $rule = Path::Iterator::Rule->new;
97    $rule->skip_subdirs(qr/./)
98        if (ref $self) =~ /^AnyEvent::Filesys::Notify/
99        && $self->skip_subdirs;
100    my $next = $rule->iter(@paths);
101    while ( my $file = $next->() ) {
102        my $stat = $self->_stat($file)
103          or next; # Skip files that we can't stat (ie, broken symlinks on ext4)
104        $fs_stats->{ abs_path($file) } = $stat;
105    }
106
107    return $fs_stats;
108}
109
110sub _diff_fs {
111    my ( $self, $old_fs, $new_fs ) = @_;
112    my @events = ();
113
114    for my $path ( keys %$old_fs ) {
115        if ( not exists $new_fs->{$path} ) {
116            push @events,
117              AnyEvent::Filesys::Notify::Event->new(
118                path   => $path,
119                type   => 'deleted',
120                is_dir => $old_fs->{$path}->{is_dir},
121              );
122        } elsif (
123            $self->_is_path_modified( $old_fs->{$path}, $new_fs->{$path} ) )
124        {
125            push @events,
126              AnyEvent::Filesys::Notify::Event->new(
127                path   => $path,
128                type   => 'modified',
129                is_dir => $old_fs->{$path}->{is_dir},
130              );
131        }
132    }
133
134    for my $path ( keys %$new_fs ) {
135        if ( not exists $old_fs->{$path} ) {
136            push @events,
137              AnyEvent::Filesys::Notify::Event->new(
138                path   => $path,
139                type   => 'created',
140                is_dir => $new_fs->{$path}->{is_dir},
141              );
142        }
143    }
144
145    return @events;
146}
147
148sub _is_path_modified {
149    my ( $self, $old_path, $new_path ) = @_;
150
151    return 1 if $new_path->{mode} != $old_path->{mode};
152    return   if $new_path->{is_dir};
153    return 1 if $new_path->{mtime} != $old_path->{mtime};
154    return 1 if $new_path->{size} != $old_path->{size};
155    return;
156}
157
158# Originally taken from Filesys::Notify::Simple --Thanks Miyagawa
159sub _stat {
160    my ( $self, $path ) = @_;
161
162    my @stat = stat $path;
163
164    # Return undefined if no stats can be retrieved, as it happens with broken
165    # symlinks (at least under ext4).
166    return unless @stat;
167
168    return {
169        path   => $path,
170        mtime  => $stat[9],
171        size   => $stat[7],
172        mode   => $stat[2],
173        is_dir => -d _,
174    };
175
176}
177
178# Figure out which backend to use:
179# I would prefer this to be done at compile time not object build, but I also
180# want the user to be able to force the Fallback role. Something like an
181# import flag would be great, but Moose creates an import sub for us and
182# I'm not sure how to cleanly do it. Maybe need to use traits, but the
183# documentation suggests traits are for application of roles by object.
184# This will work for now.
185sub _load_backend {
186    my $self = shift;
187
188    if ( $self->backend ) {
189
190        # Use the AEFN::Role prefix unless the backend starts with a +
191        my $prefix  = "${AEFN}::Role::";
192        my $backend = $self->backend;
193        $backend = $prefix . $backend unless $backend =~ s{^\+}{};
194
195        try { Moo::Role->apply_roles_to_object( $self, $backend ); }
196        catch {
197            croak "Unable to load the specified backend ($backend). You may "
198              . "need to install Linux::INotify2, Mac::FSEvents or IO::KQueue:"
199              . "\n$_";
200        }
201    } elsif ( $self->no_external ) {
202        Moo::Role->apply_roles_to_object( $self, "${AEFN}::Role::Fallback" );
203    } elsif ( $^O eq 'linux' ) {
204        try {
205            Moo::Role->apply_roles_to_object( $self,
206                "${AEFN}::Role::Inotify2" );
207        }
208        catch {
209            croak "Unable to load the Linux plugin. You may want to install "
210              . "Linux::INotify2 or specify 'no_external' (but that is very "
211              . "inefficient):\n$_";
212        }
213    } elsif ( $^O eq 'darwin' ) {
214        try {
215            Moo::Role->apply_roles_to_object( $self,
216                "${AEFN}::Role::FSEvents" );
217        }
218        catch {
219            croak "Unable to load the Mac plugin. You may want to install "
220              . "Mac::FSEvents or specify 'no_external' (but that is very "
221              . "inefficient):\n$_";
222        }
223    } elsif ( $^O =~ /bsd/ ) {
224        try {
225            Moo::Role->apply_roles_to_object( $self, "${AEFN}::Role::KQueue" );
226        }
227        catch {
228            croak "Unable to load the BSD plugin. You may want to install "
229              . "IO::KQueue or specify 'no_external' (but that is very "
230              . "inefficient):\n$_";
231        }
232    } else {
233        Moo::Role->apply_roles_to_object( $self, "${AEFN}::Role::Fallback" );
234    }
235
236    return 1;
237}
238
2391;
240
241__END__
242
243=pod
244
245=head1 NAME
246
247AnyEvent::Filesys::Notify - An AnyEvent compatible module to monitor files/directories for changes
248
249=head1 VERSION
250
251version 1.23
252
253=head1 STATUS
254
255=for html <img src="https://travis-ci.org/mvgrimes/AnyEvent-Filesys-Notify.svg?branch=master" alt="Build Status">
256<a href="https://metacpan.org/pod/AnyEvent::Filesys::Notify"><img alt="CPAN version" src="https://badge.fury.io/pl/AnyEvent-Filesys-Notify.svg" /></a>
257
258=head1 SYNOPSIS
259
260    use AnyEvent::Filesys::Notify;
261
262    my $notifier = AnyEvent::Filesys::Notify->new(
263        dirs     => [ qw( this_dir that_dir ) ],
264        interval => 2.0,             # Optional depending on underlying watcher
265        filter   => sub { shift !~ /\.(swp|tmp)$/ },
266        cb       => sub {
267            my (@events) = @_;
268            # ... process @events ...
269        },
270        parse_events => 1,  # Improves efficiency on certain platforms
271    );
272
273    # enter an event loop, see AnyEvent documentation
274    Event::loop();
275
276=head1 DESCRIPTION
277
278This module provides a cross platform interface to monitor files and
279directories within an L<AnyEvent> event loop. The heavy lifting is done by
280L<Linux::INotify2> or L<Mac::FSEvents> on their respective O/S. A fallback
281which scans the directories at regular intervals is include for other systems.
282See L</WATCHER IMPLEMENTATIONS> for more on the backends.
283
284Events are passed to the callback (specified as a CodeRef to C<cb> in the
285constructor) in the form of L<AnyEvent::Filesys::Notify::Event>s.
286
287=head1 METHODS
288
289=head2 new()
290
291A constructor for a new AnyEvent watcher that will monitor the files in the
292given directories and execute a callback when a modification is detected.
293No action is take until a event loop is entered.
294
295Arguments for new are:
296
297=over 4
298
299=item dirs
300
301    dirs => [ '/var/log', '/etc' ],
302
303An ArrayRef of directories to watch. Required.
304
305=item interval
306
307    interval => 1.5,   # seconds
308
309Specifies the time in fractional seconds between file system checks for
310the L<AnyEvent::Filesys::Notify::Role::Fallback> implementation.
311
312Specifies the latency for L<Mac::FSEvents> for the
313C<AnyEvent::Filesys::Notify::Role::FSEvents> implementation.
314
315Ignored for the C<AnyEvent::Filesys::Notify::Role::Inotify2> implementation.
316
317=item filter
318
319    filter => qr/\.(ya?ml|co?nf|jso?n)$/,
320    filter => sub { shift !~ /\.(swp|tmp)$/,
321
322A CodeRef or Regexp which is used to filter wanted/unwanted events. If this
323is a Regexp, we attempt to match the absolute path name and filter out any
324that do not match. If a CodeRef, the absolute path name is passed as the
325only argument and the event is fired only if there sub returns a true value.
326
327=item cb
328
329    cb  => sub { my @events = @_; ... },
330
331A CodeRef that is called when a modification to the monitored directory(ies) is
332detected. The callback is passed a list of
333L<AnyEvent::Filesys::Notify::Event>s. Required.
334
335=item backend
336
337    backend => 'Fallback',
338    backend => 'KQueue',
339    backend => '+My::Filesys::Notify::Role::Backend',
340
341Force the use of the specified backend. The backend is assumed to have the
342C<AnyEvent::Filesys::Notify::Role> prefix, but you can force a fully qualified
343name by prefixing it with a plus. Optional.
344
345=item no_external
346
347    no_external => 1,
348
349This is retained for backward compatibility. Using C<backend => 'Fallback'>
350is preferred. Force the use of the L</Fallback> watcher implementation. This is
351not encouraged as the L</Fallback> implement is very inefficient, but it does
352not require either L<Linux::INotify2> nor L<Mac::FSEvents>. Optional.
353
354=item parse_events
355
356    parse_events => 1,
357
358In backends that support it (currently INotify2), parse the events instead of
359rescanning file system for changed C<stat()> information. Note, that this might
360cause slight changes in behavior. In particular, the Inotify2 backend will
361generate an additional 'modified' event when a file changes (once when opened
362for write, and once when modified).
363
364=item skip_subdirs
365
366    skip_subdirs => 1,
367
368Skips subdirectories and anything in them while building a list of files/dirs
369to watch. Optional.
370
371=back
372
373=head1 WATCHER IMPLEMENTATIONS
374
375=head2 INotify2 (Linux)
376
377Uses L<Linux::INotify2> to monitor directories. Sets up an C<AnyEvent-E<gt>io>
378watcher to monitor the C<$inotify-E<gt>fileno> filehandle.
379
380=head2 FSEvents (Mac)
381
382Uses L<Mac::FSEvents> to monitor directories. Sets up an C<AnyEvent-E<gt>io>
383watcher to monitor the C<$fsevent-E<gt>watch> filehandle.
384
385=head2 KQueue (BSD/Mac)
386
387Uses L<IO::KQueue> to monitor directories. Sets up an C<AnyEvent-E<gt>io>
388watcher to monitor the C<IO::KQueue> object.
389
390B<WARNING> - L<IO::KQueue> and the C<kqueue()> system call require an open
391filehandle for every directory and file that is being watched. This makes
392it impossible to watch large directory structures (and inefficient to watch
393moderately sized directories). The use of the KQueue backend is discouraged.
394
395=head2 Fallback
396
397A simple scan of the watched directories at regular intervals. Sets up an
398C<AnyEvent-E<gt>timer> watcher which is executed every C<interval> seconds
399(or fractions thereof). C<interval> can be specified in the constructor to
400L<AnyEvent::Filesys::Notify> and defaults to 2.0 seconds.
401
402This is a very inefficient implementation. Use one of the others if possible.
403
404=head1 Why Another Module For File System Notifications
405
406At the time of writing there were several very nice modules that accomplish
407the task of watching files or directories and providing notifications about
408changes. Two of which offer a unified interface that work on any system:
409L<Filesys::Notify::Simple> and L<File::ChangeNotify>.
410
411L<AnyEvent::Filesys::Notify> exists because I need a way to simply tie the
412functionality those modules provide into an event framework. Neither of the
413existing modules seem to work with well with an event loop.
414L<Filesys::Notify::Simple> does not supply a non-blocking interface and
415L<File::ChangeNotify> requires you to poll an method for new events. You could
416fork off a process to run L<Filesys::Notify::Simple> and use an event handler
417to watch for notices from that child, or setup a timer to check
418L<File::ChangeNotify> at regular intervals, but both of those approaches seem
419inefficient or overly complex. Particularly, since the underlying watcher
420implementations (L<Mac::FSEvents> and L<Linux::INotify2>) provide a filehandle
421that you can use and IO event to watch.
422
423This is not slight against the authors of those modules. Both are well
424respected, are certainly finer coders than I am, and built modules which
425are perfect for many situations. If one of their modules will work for you
426by all means use it, but if you are already using an event loop, this
427module may fit the bill.
428
429=head1 SEE ALSO
430
431Modules used to implement this module L<AnyEvent>, L<Mac::FSEvents>,
432L<Linux::INotify2>, L<Moose>.
433
434Alternatives to this module L<Filesys::Notify::Simple>, L<File::ChangeNotify>.
435
436=head1 AUTHOR
437
438Mark Grimes, E<lt>mgrimes@cpan.orgE<gt>
439
440=head1 CONTRIBUTORS
441
442=over 4
443
444=item *
445
446Gasol Wu E<lt>gasol.wu@gmail.comE<gt> who contributed the BSD support for IO::KQueue
447
448=item *
449
450Dave Hayes E<lt>dave@jetcafe.orgE<gt>
451
452=item *
453
454Carsten Wolff E<lt>carsten@wolffcarsten.deE<gt>
455
456=item *
457
458Ettore Di Giacinto (@mudler)
459
460=item *
461
462Martin Barth (@ufobat)
463
464=back
465
466=head1 SOURCE
467
468Source repository is at L<https://github.com/mvgrimes/AnyEvent-Filesys-Notify>.
469
470=head1 BUGS
471
472Please report any bugs or feature requests on the bugtracker website L<http://github.com/mvgrimes/AnyEvent-Filesys-Notify/issues>
473
474When submitting a bug or request, please include a test-file or a
475patch to an existing test-file that illustrates the bug or desired
476feature.
477
478=head1 COPYRIGHT AND LICENSE
479
480This software is copyright (c) 2017 by Mark Grimes, E<lt>mgrimes@cpan.orgE<gt>.
481
482This is free software; you can redistribute it and/or modify it under
483the same terms as the Perl 5 programming language system itself.
484
485=cut
486