diff --git a/lib/rexml/xpath_parser.rb b/lib/rexml/xpath_parser.rb
index 761b5281..20bdd14d 100644
--- a/lib/rexml/xpath_parser.rb
+++ b/lib/rexml/xpath_parser.rb
@@ -1,6 +1,5 @@
# frozen_string_literal: false
-require "pp"
require "set"
require_relative 'namespace'
@@ -801,6 +800,11 @@ def evaluate_predicate(expression, nodesets)
end
def trace(*args)
+ # Loaded here rather than at the top of the file because this method is
+ # only reached when REXML_XPATH_PARSER_DEBUG is set. Requiring pp
+ # eagerly made every REXML user pay for a debugging aid.
+ require "pp"
+
indent = " " * @nest
PP.pp(args, "").each_line do |line|
puts("#{indent}#{line}")
diff --git a/test/test_require.rb b/test/test_require.rb
new file mode 100644
index 00000000..71e11461
--- /dev/null
+++ b/test/test_require.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: false
+
+require "test/unit"
+
+module REXMLTests
+ # Guards the deferred `require "pp"` in REXML::XPathParser#trace. The checks
+ # run in a subprocess because this one has pp loaded already.
+ class TestRequire < Test::Unit::TestCase
+ PP_LOADED = '$LOADED_FEATURES.any? { |f| File.basename(f) == "pp.rb" }'
+
+ def subprocess(script)
+ lib = File.join(File.dirname(File.expand_path(__dir__)), "lib")
+ IO.popen([RbConfig.ruby, "-I", lib, "-e", script], &:read)
+ end
+
+ def test_requiring_document_does_not_load_pp
+ assert_equal("false", subprocess("require 'rexml/document'; print #{PP_LOADED}"))
+ end
+
+ def test_xpath_works_without_pp
+ script = "require 'rexml/document'; " \
+ "d = REXML::Document.new('xy'); " \
+ "print REXML::XPath.match(d, '//a').map(&:text).join(',')"
+ assert_equal("x,y", subprocess(script))
+ end
+
+ def test_xpath_does_not_load_pp
+ script = "require 'rexml/document'; " \
+ "d = REXML::Document.new('x'); " \
+ "REXML::XPath.match(d, '//a'); " \
+ "print #{PP_LOADED}"
+ assert_equal("false", subprocess(script))
+ end
+ end
+end