From 73cb27e82df6b9bf892bcf66f735048dfa3f5cdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C5=8Dan?= Date: Fri, 13 Feb 2026 23:52:49 -0700 Subject: [PATCH] feat: skip require for already-loaded Template::Plugin subclasses When a plugin class is already loaded in memory (e.g., defined inline or bundled in the same file), skip the require call. This allows plugins to be preloaded/embedded without needing a separate .pm file on disk. Uses isa($PLUGIN_BASE) check before require in both PLUGIN_NAME and PLUGIN_BASE code paths. Classes that don't inherit from Template::Plugin still go through the normal require path. Resolves: https://github.com/abw/Template2/issues/112 Co-Authored-By: Claude Opus 4.6 --- lib/Template/Plugins.pm | 4 +- t/plugins_preloaded.t | 216 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 218 insertions(+), 2 deletions(-) create mode 100644 t/plugins_preloaded.t diff --git a/lib/Template/Plugins.pm b/lib/Template/Plugins.pm index eceb5ac6..72e93122 100644 --- a/lib/Template/Plugins.pm +++ b/lib/Template/Plugins.pm @@ -186,7 +186,7 @@ sub _load { $file =~ s|::|/|g; $self->debug("loading $module.pm (PLUGIN_NAME)") if $self->{ DEBUG }; - $ok = eval { require "$file.pm" }; + $ok = eval { $module->isa($PLUGIN_BASE) or require "$file.pm" }; $error = $@; } else { @@ -200,7 +200,7 @@ sub _load { $self->debug("loading $file.pm (PLUGIN_BASE)") if $self->{ DEBUG }; - $ok = eval { require "$file.pm" }; + $ok = eval { $pkg->isa($PLUGIN_BASE) or require "$file.pm" }; last unless $@; $error .= "$@\n" diff --git a/t/plugins_preloaded.t b/t/plugins_preloaded.t new file mode 100644 index 00000000..9e184763 --- /dev/null +++ b/t/plugins_preloaded.t @@ -0,0 +1,216 @@ +#============================================================= -*-perl-*- +# +# t/plugins_preloaded.t +# +# Test that Template::Plugins correctly handles plugins that are already +# loaded in memory (e.g., bundled/embedded in the same file as the +# calling code), without requiring a separate .pm file on disk. +# +# See: https://github.com/abw/Template2/issues/112 +# https://github.com/abw/Template2/pull/196 +# +#======================================================================== + +use strict; +use warnings; +use lib qw( ./lib ../lib ../blib/arch ); +use Test::More; + +use Template; +use Template::Plugin; + +#------------------------------------------------------------------------ +# Define an inline plugin that has no .pm file on disk. +# This simulates the use case from GH #112: plugins defined inline +# in the application code or bundled in the same file. +#------------------------------------------------------------------------ + +{ + package My::Inline::Plugin; + use base 'Template::Plugin'; + + sub new { + my ($class, $context, $value) = @_; + bless { VALUE => $value || 'default' }, $class; + } + + sub output { + my $self = shift; + return "Inline plugin, value is $self->{VALUE}"; + } +} + +{ + package My::Inline::AnotherPlugin; + use base 'Template::Plugin'; + + sub new { + my ($class, $context) = @_; + bless {}, $class; + } + + sub greet { + return "Hello from inline plugin"; + } +} + +#------------------------------------------------------------------------ +# Test 1: Plugin registered via PLUGINS hash — should not require a file +#------------------------------------------------------------------------ +subtest 'inline plugin via PLUGINS hash' => sub { + my $tt = Template->new({ + PLUGINS => { + inline => 'My::Inline::Plugin', + }, + }) || die Template->error(); + + my $input = '[% USE p = inline("test_value") %][% p.output %]'; + my $output = ''; + ok($tt->process(\$input, {}, \$output), 'process inline plugin') + || diag $tt->error(); + is($output, 'Inline plugin, value is test_value', + 'inline plugin produces correct output'); +}; + +#------------------------------------------------------------------------ +# Test 2: Multiple inline plugins via PLUGINS hash +#------------------------------------------------------------------------ +subtest 'multiple inline plugins via PLUGINS' => sub { + my $tt = Template->new({ + PLUGINS => { + inline => 'My::Inline::Plugin', + another => 'My::Inline::AnotherPlugin', + }, + }) || die Template->error(); + + my $input = '[% USE p = inline("42") %][% p.output %] / [% USE a = another %][% a.greet %]'; + my $output = ''; + ok($tt->process(\$input, {}, \$output), 'process multiple inline plugins') + || diag $tt->error(); + is($output, 'Inline plugin, value is 42 / Hello from inline plugin', + 'both inline plugins work correctly'); +}; + +#------------------------------------------------------------------------ +# Test 3: Inline plugin via PLUGIN_BASE — namespace-based lookup +#------------------------------------------------------------------------ + +# Define a plugin under a custom base namespace +{ + package MyBase::Embedded; + use base 'Template::Plugin'; + + sub new { + my ($class, $context) = @_; + bless {}, $class; + } + + sub output { + return "embedded via plugin base"; + } +} + +subtest 'inline plugin via PLUGIN_BASE' => sub { + my $tt = Template->new({ + PLUGIN_BASE => 'MyBase', + }) || die Template->error(); + + my $input = '[% USE e = Embedded %][% e.output %]'; + my $output = ''; + ok($tt->process(\$input, {}, \$output), 'process plugin via PLUGIN_BASE') + || diag $tt->error(); + is($output, 'embedded via plugin base', + 'inline plugin found via PLUGIN_BASE'); +}; + +#------------------------------------------------------------------------ +# Test 4: Standard disk-based plugins still work (regression check) +#------------------------------------------------------------------------ +subtest 'standard plugins still work' => sub { + my $tt = Template->new() || die Template->error(); + + my $input = '[% USE Table([1, 2, 3, 4], rows=2) %][% Table.row(0).join(",") %]'; + my $output = ''; + ok($tt->process(\$input, {}, \$output), 'process standard Table plugin') + || diag $tt->error(); + is($output, '1,3', 'standard plugin works as before'); +}; + +#------------------------------------------------------------------------ +# Test 5: Plugin that is NOT loaded and has no .pm file should fail +#------------------------------------------------------------------------ +subtest 'unknown plugin still fails' => sub { + my $tt = Template->new({ + PLUGINS => { + nonexistent => 'My::Nonexistent::Plugin', + }, + }) || die Template->error(); + + my $input = '[% USE nonexistent %]'; + my $output = ''; + ok(!$tt->process(\$input, {}, \$output), 'unloaded plugin fails as expected'); + like($tt->error(), qr/nonexistent|Can't locate/i, 'error message mentions the plugin'); +}; + +#------------------------------------------------------------------------ +# Test 6: Verify that the plugin is reused on second fetch (caching) +#------------------------------------------------------------------------ +subtest 'plugin caching after first load' => sub { + my $tt = Template->new({ + PLUGINS => { + inline => 'My::Inline::Plugin', + }, + }) || die Template->error(); + + my $input = '[% USE p1 = inline("first") %][% p1.output %] / [% USE p2 = inline("second") %][% p2.output %]'; + my $output = ''; + ok($tt->process(\$input, {}, \$output), 'process plugin used twice') + || diag $tt->error(); + is($output, 'Inline plugin, value is first / Inline plugin, value is second', + 'plugin works on repeated use'); +}; + +#------------------------------------------------------------------------ +# Test 7: Non-Template::Plugin class should NOT be skipped +# A class that exists but doesn't inherit from Template::Plugin +# should still go through the require path (and fail if no .pm file) +#------------------------------------------------------------------------ +{ + package My::NotAPlugin; + sub new { bless {}, shift } +} + +subtest 'non-Template::Plugin class not treated as preloaded' => sub { + my $tt = Template->new({ + PLUGINS => { + notplugin => 'My::NotAPlugin', + }, + }) || die Template->error(); + + my $input = '[% USE notplugin %]'; + my $output = ''; + # This should fail because My::NotAPlugin doesn't inherit Template::Plugin + # so isa() returns false, and there's no .pm file to require + ok(!$tt->process(\$input, {}, \$output), + 'non-Template::Plugin class is not skipped'); +}; + +#------------------------------------------------------------------------ +# Test 8: Inline plugin with default value +#------------------------------------------------------------------------ +subtest 'inline plugin with default value' => sub { + my $tt = Template->new({ + PLUGINS => { + inline => 'My::Inline::Plugin', + }, + }) || die Template->error(); + + my $input = '[% USE p = inline %][% p.output %]'; + my $output = ''; + ok($tt->process(\$input, {}, \$output), 'process inline plugin with default') + || diag $tt->error(); + is($output, 'Inline plugin, value is default', + 'inline plugin with default value works'); +}; + +done_testing();