From 365b0834eb736c94de8d7db677e735cd1fd1e347 Mon Sep 17 00:00:00 2001 From: "Cristian D. Moreno (Kyonax)" Date: Fri, 2 Oct 2026 09:35:40 -0500 Subject: [PATCH] Load legacy renderer gems with mutable string literals org-ruby 0.9.12 (with rubypants), creole 0.5.0 and wikicloth 0.8.3 modify string literals in place, so every Org, Creole and MediaWiki render raises FrozenError when string literals are frozen by default (--enable=frozen-string-literal, or a future Ruby). GemImplementation now accepts mutable_string_literals: true. When literals are frozen, it loads the gem with frozen_string_literal: false and restores the compile option afterwards. In the default "chilled" mode it does nothing, because Ruby can't restore that state once it has been changed. Also add a Ruby 4.0 CI job that runs the tests with frozen string literals. --- .github/workflows/ci.yml | 8 +++- lib/github/markup.rb | 3 +- lib/github/markup/gem_implementation.rb | 30 +++++++++++++- lib/github/markups.rb | 6 +-- test/coverage_test.rb | 54 +++++++++++++++++++++++++ test/fixtures/compile_option_probe.rb | 2 + 6 files changed, 96 insertions(+), 7 deletions(-) create mode 100644 test/fixtures/compile_option_probe.rb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a3e5674..725f3829 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ permissions: jobs: build: - name: "Test / Ruby ${{ matrix.ruby }}" + name: "Test / Ruby ${{ matrix.ruby }}${{ matrix.frozen-string-literal && ' (frozen string literals)' || '' }}" runs-on: ubuntu-latest strategy: matrix: @@ -17,6 +17,10 @@ jobs: - "3.3" - "3.4" - "4.0" + frozen-string-literal: [false] + include: + - ruby: "4.0" + frozen-string-literal: true fail-fast: false steps: @@ -57,6 +61,8 @@ jobs: python -m pip install --require-hashes --no-deps -r /tmp/requirements.txt - name: Run rake + env: + RUBYOPT: ${{ matrix.frozen-string-literal && '--enable=frozen-string-literal' || '' }} run: | export PATH=$PATH:/.perl6/bin:/opt/rakudo-pkg/bin bundle exec rake diff --git a/lib/github/markup.rb b/lib/github/markup.rb index a7ba9863..ac8b356b 100644 --- a/lib/github/markup.rb +++ b/lib/github/markup.rb @@ -58,7 +58,8 @@ def render_s(symbol, content, options: {}) end def markup(symbol, gem_name, regexp, languages, opts = {}, &block) - impl = GemImplementation.new(regexp, languages, gem_name, &block) + impl = GemImplementation.new(regexp, languages, gem_name, + mutable_string_literals: opts.fetch(:mutable_string_literals, false), &block) markup_impl(symbol, impl) end diff --git a/lib/github/markup/gem_implementation.rb b/lib/github/markup/gem_implementation.rb index 843717e6..dc5665e2 100644 --- a/lib/github/markup/gem_implementation.rb +++ b/lib/github/markup/gem_implementation.rb @@ -5,15 +5,20 @@ module Markup class GemImplementation < Implementation attr_reader :gem_name, :renderer - def initialize(regexp, languages, gem_name, &renderer) + def initialize(regexp, languages, gem_name, mutable_string_literals: false, &renderer) super(regexp, languages) @gem_name = gem_name.to_s + @mutable_string_literals = mutable_string_literals @renderer = renderer end def load return if defined?(@loaded) && @loaded - require gem_name + if @mutable_string_literals + with_mutable_string_literals { require gem_name } + else + require gem_name + end @loaded = true end @@ -25,6 +30,27 @@ def render(filename, content, options: {}) def name gem_name end + + private + + # Some renderer gems modify string literals in place. When string literals + # are frozen by default (--enable=frozen-string-literal, or a future Ruby), + # compile those gems with mutable literals so they keep working. Otherwise + # leave the compile options alone: Ruby can't restore its default + # "chilled" state once it has been changed. + def with_mutable_string_literals + # :nocov: + return yield unless defined?(RubyVM::InstructionSequence) + # :nocov: + options = RubyVM::InstructionSequence.compile_option + return yield unless options[:frozen_string_literal] + begin + RubyVM::InstructionSequence.compile_option = options.merge(frozen_string_literal: false) + yield + ensure + RubyVM::InstructionSequence.compile_option = options + end + end end end end diff --git a/lib/github/markups.rb b/lib/github/markups.rb index 4b2f8e0e..10171691 100644 --- a/lib/github/markups.rb +++ b/lib/github/markups.rb @@ -10,18 +10,18 @@ GitHub::Markup.markup_impl(::GitHub::Markups::MARKUP_RDOC, GitHub::Markup::RDoc.new) -GitHub::Markup.markup(::GitHub::Markups::MARKUP_ORG, 'org-ruby', /org/, ["Org"]) do |filename, content, options: {}| +GitHub::Markup.markup(::GitHub::Markups::MARKUP_ORG, 'org-ruby', /org/, ["Org"], mutable_string_literals: true) do |filename, content, options: {}| Orgmode::Parser.new(content, { :allow_include_files => false, :skip_syntax_highlight => true }).to_html end -GitHub::Markup.markup(::GitHub::Markups::MARKUP_CREOLE, :creole, /creole/, ["Creole"]) do |filename, content, options: {}| +GitHub::Markup.markup(::GitHub::Markups::MARKUP_CREOLE, :creole, /creole/, ["Creole"], mutable_string_literals: true) do |filename, content, options: {}| Creole.creolize(content) end -GitHub::Markup.markup(::GitHub::Markups::MARKUP_MEDIAWIKI, :wikicloth, /mediawiki|wiki/, ["MediaWiki"]) do |filename, content, options: {}| +GitHub::Markup.markup(::GitHub::Markups::MARKUP_MEDIAWIKI, :wikicloth, /mediawiki|wiki/, ["MediaWiki"], mutable_string_literals: true) do |filename, content, options: {}| wikicloth = WikiCloth::WikiCloth.new(:data => content) WikiCloth::WikiBuffer::HTMLElement::ESCAPED_TAGS << 'tt' unless WikiCloth::WikiBuffer::HTMLElement::ESCAPED_TAGS.include?('tt') wikicloth.to_html(:noedit => true) diff --git a/test/coverage_test.rb b/test/coverage_test.rb index f5043b5d..cdd214af 100644 --- a/test/coverage_test.rb +++ b/test/coverage_test.rb @@ -192,6 +192,34 @@ def test_mediawiki_render_is_idempotent_for_escaped_tags assert_equal 1, count_after_second, "second render must not append a duplicate 'tt'" end + # --- gem_implementation.rb mutable_string_literals --------------------- + + def test_mutable_string_literals_unfreezes_literals_while_the_gem_loads + probe = File.expand_path("fixtures/compile_option_probe", __dir__) + impl = GitHub::Markup::GemImplementation.new(/covmutable/, [], probe, mutable_string_literals: true) + with_fake_compile_option(frozen_string_literal: true) do |writes| + impl.load + assert_equal false, COMPILE_OPTION_PROBE, "the gem should load with mutable string literals" + assert_equal [false, true], writes.map { |options| options[:frozen_string_literal] } + end + end + + def test_mutable_string_literals_restores_the_option_when_the_gem_fails_to_load + impl = GitHub::Markup::GemImplementation.new(/covmissing/, [], "covmissing-not-a-gem", mutable_string_literals: true) + with_fake_compile_option(frozen_string_literal: true) do |writes| + assert_raises(LoadError) { impl.load } + assert_equal [false, true], writes.map { |options| options[:frozen_string_literal] } + end + end + + def test_mutable_string_literals_leaves_the_default_chilled_mode_alone + impl = GitHub::Markup::GemImplementation.new(/covchilled/, [], "shellwords", mutable_string_literals: true) + with_fake_compile_option(frozen_string_literal: nil) do |writes| + impl.load + assert_empty writes + end + end + # --- command_implementation.rb block arity branches -------------------- def test_command_block_with_arity_two_receives_rendered_and_content @@ -361,6 +389,32 @@ def with_stub_const(path, value) parent.const_set(name, original) if had_const end + # Replaces RubyVM::InstructionSequence.compile_option and compile_option= with + # a fake, so a test can simulate frozen string literals without touching the + # real compile options (Ruby can't restore its default "chilled" state). + # Yields the list of values written to compile_option=. + def with_fake_compile_option(options) + iseq = RubyVM::InstructionSequence + getter = iseq.method(:compile_option) + setter = iseq.method(:compile_option=) + current = getter.call.merge(options) + writes = [] + redefine_quietly(iseq, :compile_option) { current } + redefine_quietly(iseq, :compile_option=) { |value| writes << value; current = value } + yield writes + ensure + redefine_quietly(iseq, :compile_option, getter) + redefine_quietly(iseq, :compile_option=, setter) + end + + # define_singleton_method without the "method redefined" warning. + def redefine_quietly(object, name, body = nil, &block) + verbose, $VERBOSE = $VERBOSE, nil + object.define_singleton_method(name, body || block) + ensure + $VERBOSE = verbose + end + def without_linguist had_const = Object.const_defined?(:Linguist, false) original = Object.const_get(:Linguist) if had_const diff --git a/test/fixtures/compile_option_probe.rb b/test/fixtures/compile_option_probe.rb new file mode 100644 index 00000000..007c037d --- /dev/null +++ b/test/fixtures/compile_option_probe.rb @@ -0,0 +1,2 @@ +# Records the frozen_string_literal compile option in effect while this file is required. +COMPILE_OPTION_PROBE = RubyVM::InstructionSequence.compile_option[:frozen_string_literal]