The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.

package Tie::File::Hashify;

use strict;
use warnings;

use Carp;
use IO::File;

our $VERSION = '0.03';


sub TIEHASH {
	my ($class, $path, %options) = @_;

	my $self = bless {
		hash => {},
		format => $options{format},
		parse => $options{parse},
		path => $path,
		ro => $options{ro},
		dirty => 0,
	}, $class;

	if($path and -e $path and $options{parse}) {
		my $io = new IO::File($path) or croak "Can't read $path. $!.\n";

		while(my $line = $io->getline) {
			next unless defined $line and length($line);

			my ($key, $value);

			# Use callback for parsing.
			if(ref($options{parse}) eq 'CODE') {
				($key, $value) = &{$options{parse}}($line);
			}

			# Parse line using a regular expression.
			elsif(ref($options{parse}) eq '' or uc(ref($options{parse})) eq 'REGEXP') {
				my $re = ref($options{parse}) ? $options{parse} : qr/^$options{parse}$/;
				($key, $value) = ($line =~ $re);
			}

			# Croak.
			else {
				croak 'Can\'t use ', lc(ref($options{parse})), " for parsing.\n";
			}

			if(defined $key and length $key) {
				$self->{hash}->{$key} = $value if(length $key);
			}
		}

		$io->close;
	}

	return $self;
}


sub FETCH {
	my ($self, $key) = @_;
	return $self->{hash}->{$key};
}


sub STORE {
	my ($self, $key, $value) = @_;

	croak "Can't store in read-only mode" if($self->{ro});

	$self->{dirty} = !0;

	return $self->{hash}->{$key} = $value;
}


sub EXISTS {
	my ($self, $key) = @_;
	return exists($self->{hash}->{$key});
}


sub DELETE {
	my ($self, $key) = @_;

	croak "Can't delete in read-only mode" if($self->{ro});

	$self->{dirty} = !0;

	return delete($self->{hash}->{$key});
}


sub CLEAR {
	my ($self) = @_;

	croak "Can't clear in read-only mode" if($self->{ro});

	$self->{dirty} = !0;

	%{$self->{hash}} = ();
}


sub FIRSTKEY {
	my ($self) = @_;
	my ($key) = each %{$self->{hash}};
	return $key;
}


sub NEXTKEY {
	my ($self) = @_;

	my ($k, $v) = each %{$self->{hash}};

	return $k;
}


sub SCALAR {
	my ($self) = @_;

	my $format = $self->{format};

	if(defined $format) {
		my $text = '';

		values %{$self->{hash}};

		while(my ($key, $value) = each %{$self->{hash}}) {
			# Format using callback.
			if(ref($format) eq 'CODE') {
				$text .= &{$format}($key, $value) . "\n";
			}

			# Format using sprintf and a format string.
			elsif(ref($format) eq '') {
				$text .= sprintf($format, $key, $value) . "\n";
			}

			# Croak.
			else {
				croak 'Can\'t use ' . ref($format) . " as format.\n";
			}
		}

		return $text;
	}

	else {
		return %{$self->{hash}};
	}
}


sub _store {
	my ($self) = @_;

	my $path = $self->{path};

	if($path and $self->{dirty} and $self->{format} and !$self->{ro}) {
		my $io = new IO::File('>' . $path) or croak "Can't write $path. $!.\n";

		$io->print($self->SCALAR);
		$io->close;

		$self->{dirty} = 0;
	}
}


sub UNTIE {
	my ($self) = @_;

	$self->_store;
}


sub DESTROY {
	my ($self) = @_;

	$self->_store;
}


!0;

__END__

=head1 NAME

TIe::File::Hashify - Parse a file and tie the result to a hash.

=head1 SYNOPSIS

	use Tie::File::Hashify;

	my %rc;
	my $path = "$ENV{HOME}/.some.rc";

	# Parse lines like 'foo = bar':
	sub parse { $_[0] =~ /^\s*(\S+)\s*=\s*(.*?)\s*$/ };

	# Format pairs as 'key = value':
	sub format { "$_[0] = $_[1]" };

	tie(%rc, 'Tie::File::Hashify', $path, parse => \&parse, format => \&format);

	print "option 'foo' = $rc{foo}\n";

	# Add new option.
	$rc{bar} = 'moo';

	# Save file.
	untie %rc;

=head1 DESCRIPTION

This module helps parsing simple text files and mapping it's contents to a
plain hash. It reads a file line by line and uses a callback or expression you
provide to parse a key and a value from it. The key/value pairs are then
available through the generated hash. You can also provide another callback or
format string that formats a key/value pair to a line to be stored back to the
file.

=head1 METHODS

=over 4

=item B<tie>(%hash, 'Tie::File::Hashify', $path, %options)

The third argument (after the hash itself and the package name of course) is
the path to a file. The file does not really have to exist, but using a path to
a non-existent file does only make sense if you provide a format-callback to
write a new file.

After the second argument, a list of options may/should follow:

=over 4

=item B<parse>

Either a code reference, which will be called with a line as argument and
should return the key and the value for the hash element; or a string or
compiled regular expression (qr//). The expression will be applied to every
line and $1 and $2 will be used as key/value afterwards.

=item B<format>

This is used for formatting the hash into something that can be written back to
the file. It may be a code reference that takes two arguments (key and value)
as arguments and returns a string (without trailing line-break - it will be
added automatically), or a format string that is forwarded to B<sprintf>
together with the key and the value.

=item B<ro>

If this is true, changing the hash will make it croak, and the content will not
be written back to the file.

=back

All arguments are optional. If you don't give any arugments, you get a plain
normal hash.

=back

=head1 COPYRIGHT

Copyright (C) 2008 by Jonas Kramer <jkramer@cpan.org>. Published under the
terms of the Artistic License 2.0.

"read-only" functionality by Marco Emilio Poleggi.

=cut