moxml 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/rake.yml +15 -0
  3. data/.github/workflows/release.yml +23 -0
  4. data/.gitignore +3 -0
  5. data/.rubocop.yml +2 -0
  6. data/.rubocop_todo.yml +65 -0
  7. data/.ruby-version +1 -0
  8. data/Gemfile +10 -3
  9. data/README.adoc +401 -594
  10. data/lib/moxml/adapter/base.rb +102 -0
  11. data/lib/moxml/adapter/customized_oga/xml_declaration.rb +18 -0
  12. data/lib/moxml/adapter/customized_oga/xml_generator.rb +104 -0
  13. data/lib/moxml/adapter/nokogiri.rb +319 -0
  14. data/lib/moxml/adapter/oga.rb +318 -0
  15. data/lib/moxml/adapter/ox.rb +325 -0
  16. data/lib/moxml/adapter.rb +26 -170
  17. data/lib/moxml/attribute.rb +47 -14
  18. data/lib/moxml/builder.rb +64 -0
  19. data/lib/moxml/cdata.rb +4 -26
  20. data/lib/moxml/comment.rb +6 -22
  21. data/lib/moxml/config.rb +39 -15
  22. data/lib/moxml/context.rb +29 -0
  23. data/lib/moxml/declaration.rb +16 -26
  24. data/lib/moxml/doctype.rb +9 -0
  25. data/lib/moxml/document.rb +51 -63
  26. data/lib/moxml/document_builder.rb +87 -0
  27. data/lib/moxml/element.rb +63 -97
  28. data/lib/moxml/error.rb +20 -0
  29. data/lib/moxml/namespace.rb +12 -37
  30. data/lib/moxml/node.rb +78 -58
  31. data/lib/moxml/node_set.rb +19 -222
  32. data/lib/moxml/processing_instruction.rb +6 -25
  33. data/lib/moxml/text.rb +4 -26
  34. data/lib/moxml/version.rb +1 -1
  35. data/lib/moxml/xml_utils/encoder.rb +55 -0
  36. data/lib/moxml/xml_utils.rb +80 -0
  37. data/lib/moxml.rb +33 -33
  38. data/moxml.gemspec +1 -1
  39. data/spec/moxml/adapter/nokogiri_spec.rb +14 -0
  40. data/spec/moxml/adapter/oga_spec.rb +14 -0
  41. data/spec/moxml/adapter/ox_spec.rb +49 -0
  42. data/spec/moxml/all_with_adapters_spec.rb +46 -0
  43. data/spec/moxml/config_spec.rb +55 -0
  44. data/spec/moxml/error_spec.rb +71 -0
  45. data/spec/moxml/examples/adapter_spec.rb +27 -0
  46. data/spec/moxml_spec.rb +50 -0
  47. data/spec/spec_helper.rb +32 -0
  48. data/spec/support/shared_examples/attribute.rb +165 -0
  49. data/spec/support/shared_examples/builder.rb +25 -0
  50. data/spec/support/shared_examples/cdata.rb +70 -0
  51. data/spec/support/shared_examples/comment.rb +65 -0
  52. data/spec/support/shared_examples/context.rb +35 -0
  53. data/spec/support/shared_examples/declaration.rb +93 -0
  54. data/spec/support/shared_examples/doctype.rb +25 -0
  55. data/spec/support/shared_examples/document.rb +110 -0
  56. data/spec/support/shared_examples/document_builder.rb +43 -0
  57. data/spec/support/shared_examples/edge_cases.rb +185 -0
  58. data/spec/support/shared_examples/element.rb +130 -0
  59. data/spec/support/shared_examples/examples/attribute.rb +42 -0
  60. data/spec/support/shared_examples/examples/basic_usage.rb +67 -0
  61. data/spec/support/shared_examples/examples/memory.rb +54 -0
  62. data/spec/support/shared_examples/examples/namespace.rb +65 -0
  63. data/spec/support/shared_examples/examples/readme_examples.rb +100 -0
  64. data/spec/support/shared_examples/examples/thread_safety.rb +43 -0
  65. data/spec/support/shared_examples/examples/xpath.rb +39 -0
  66. data/spec/support/shared_examples/integration.rb +135 -0
  67. data/spec/support/shared_examples/namespace.rb +96 -0
  68. data/spec/support/shared_examples/node.rb +110 -0
  69. data/spec/support/shared_examples/node_set.rb +90 -0
  70. data/spec/support/shared_examples/processing_instruction.rb +88 -0
  71. data/spec/support/shared_examples/text.rb +66 -0
  72. data/spec/support/shared_examples/xml_adapter.rb +191 -0
  73. data/spec/support/xml_matchers.rb +27 -0
  74. metadata +55 -6
  75. data/.github/workflows/main.yml +0 -27
  76. data/lib/moxml/error_handler.rb +0 -77
  77. data/lib/moxml/errors.rb +0 -169
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "xml_utils/encoder"
4
+
5
+ # Ruby 3.3+ requires the URI module to be explicitly required
6
+ require "uri" unless defined?(::URI)
7
+
8
+ module Moxml
9
+ module XmlUtils
10
+ def encode_entities(text, mode = nil)
11
+ Encoder.new(text, mode).call
12
+ end
13
+
14
+ def validate_declaration_version(version)
15
+ return if ::Moxml::Declaration::ALLOWED_VERSIONS.include?(version)
16
+
17
+ raise ValidationError, "Invalid XML version: #{version}"
18
+ end
19
+
20
+ def validate_declaration_encoding(encoding)
21
+ return if encoding.nil?
22
+
23
+ begin
24
+ Encoding.find(encoding)
25
+ rescue ArgumentError
26
+ raise ValidationError, "Invalid encoding: #{encoding}"
27
+ end
28
+ end
29
+
30
+ def validate_declaration_standalone(standalone)
31
+ return if standalone.nil?
32
+ return if ::Moxml::Declaration::ALLOWED_STANDALONE.include?(standalone)
33
+
34
+ raise ValidationError, "Invalid standalone value: #{standalone}"
35
+ end
36
+
37
+ def validate_comment_content(text)
38
+ if text.start_with?("-") || text.end_with?("-")
39
+ raise ValidationError, "XML comment cannot start or end with a hyphen"
40
+ end
41
+
42
+ return unless text.include?("--")
43
+
44
+ raise ValidationError, "XML comment cannot contain double hyphens (--)"
45
+ end
46
+
47
+ def validate_element_name(name)
48
+ return if name.is_a?(String) && name.match?(/^[a-zA-Z_][\w\-.:]*$/)
49
+
50
+ raise ValidationError, "Invalid XML name: #{name}"
51
+ end
52
+
53
+ def validate_pi_target(target)
54
+ return if target.is_a?(String) && target.match?(/^[a-zA-Z_][\w\-.]*$/)
55
+
56
+ raise ValidationError, "Invalid XML target: #{target}"
57
+ end
58
+
59
+ def validate_uri(uri)
60
+ return if uri.empty? || uri.match?(/\A#{::URI::DEFAULT_PARSER.make_regexp}\z/)
61
+
62
+ raise ValidationError, "Invalid URI: #{uri}"
63
+ end
64
+
65
+ def validate_prefix(prefix)
66
+ return if prefix.match?(/\A[a-zA-Z_][\w-]*\z/)
67
+
68
+ raise ValidationError, "Invalid namespace prefix: #{prefix}"
69
+ end
70
+
71
+ def normalize_xml_value(value)
72
+ case value
73
+ when nil then ""
74
+ when true then "true"
75
+ when false then "false"
76
+ else value.to_s
77
+ end
78
+ end
79
+ end
80
+ end
data/lib/moxml.rb CHANGED
@@ -1,44 +1,44 @@
1
- # lib/moxml.rb
2
- require_relative "moxml/version"
3
- require_relative "moxml/config"
4
- require_relative "moxml/document"
5
- require_relative "moxml/node"
6
- require_relative "moxml/element"
7
- require_relative "moxml/text"
8
- require_relative "moxml/cdata_section"
9
- require_relative "moxml/comment"
10
- require_relative "moxml/processing_instruction"
11
- require_relative "moxml/visitor"
12
- require_relative "moxml/errors"
13
- require_relative "moxml/backends/base"
1
+ # frozen_string_literal: true
14
2
 
15
3
  module Moxml
16
4
  class << self
17
- def config
18
- @config ||= Config.new
5
+ def new(adapter = nil, &block)
6
+ context = Context.new(adapter)
7
+ context.config.instance_eval(&block) if block_given?
8
+ context
19
9
  end
20
10
 
21
11
  def configure
22
- yield(config)
12
+ yield Config.default if block_given?
23
13
  end
24
14
 
25
- def backend
26
- @backend ||= begin
27
- backend_class = case config.backend
28
- when :nokogiri
29
- require_relative "moxml/backends/nokogiri"
30
- Backends::Nokogiri
31
- when :ox
32
- require_relative "moxml/backends/ox"
33
- Backends::Ox
34
- when :oga
35
- require_relative "moxml/backends/oga"
36
- Backends::Oga
37
- else
38
- raise ArgumentError, "Unknown backend: #{config.backend}"
39
- end
40
- backend_class.new
41
- end
15
+ def with_config(adapter_name = nil, strict_parsing = nil, default_encoding = nil)
16
+ original_config = Config.default.dup
17
+
18
+ configure do |config|
19
+ config.adapter = adapter_name unless adapter_name.nil?
20
+ config.strict_parsing = strict_parsing unless strict_parsing.nil?
21
+ config.default_encoding = default_encoding unless default_encoding.nil?
22
+ end
23
+
24
+ yield if block_given?
25
+
26
+ # restore the original config
27
+ configure do |config|
28
+ config.adapter = original_config.adapter_name
29
+ config.strict_parsing = original_config.strict_parsing
30
+ config.default_encoding = original_config.default_encoding
31
+ end
32
+ original_config = nil
42
33
  end
43
34
  end
44
35
  end
36
+
37
+ require_relative "moxml/version"
38
+ require_relative "moxml/document"
39
+ require_relative "moxml/document_builder"
40
+ require_relative "moxml/error"
41
+ require_relative "moxml/builder"
42
+ require_relative "moxml/config"
43
+ require_relative "moxml/context"
44
+ require_relative "moxml/adapter"
data/moxml.gemspec CHANGED
@@ -25,7 +25,7 @@ Gem::Specification.new do |spec|
25
25
  # RubyGem that have been added into git.
26
26
  spec.files = Dir.chdir(File.expand_path(__dir__)) do
27
27
  `git ls-files -z`.split("\x0").reject do |f|
28
- f.match(%r{^(test|spec|features)/})
28
+ f.match(%r{^(test|features)/})
29
29
  end
30
30
  end
31
31
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+ require "moxml/adapter/nokogiri"
5
+
6
+ RSpec.describe Moxml::Adapter::Nokogiri do
7
+ around do |example|
8
+ Moxml.with_config(:nokogiri, true, "UTF-8") do
9
+ example.run
10
+ end
11
+ end
12
+
13
+ it_behaves_like "xml adapter"
14
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "oga"
4
+ require "moxml/adapter/oga"
5
+
6
+ RSpec.describe Moxml::Adapter::Oga do
7
+ around do |example|
8
+ Moxml.with_config(:oga, true, "UTF-8") do
9
+ example.run
10
+ end
11
+ end
12
+
13
+ it_behaves_like "xml adapter"
14
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ox"
4
+ require "moxml/adapter/ox"
5
+
6
+ RSpec.describe Moxml::Adapter::Ox, skip: "Ox will be added later" do
7
+ before(:all) do
8
+ Moxml.configure do |config|
9
+ config.adapter = :ox
10
+ config.strict_parsing = true
11
+ config.default_encoding = "UTF-8"
12
+ end
13
+ end
14
+
15
+ it_behaves_like "xml adapter"
16
+
17
+ describe "text handling" do
18
+ let(:doc) { described_class.create_document }
19
+ let(:element) { described_class.create_native_element("test") }
20
+
21
+ it "creates text nodes as strings" do
22
+ text = described_class.create_native_text("content")
23
+ expect(text).to be_a(String)
24
+ expect(text).to eq("content")
25
+ end
26
+
27
+ it "adds text nodes to elements" do
28
+ text = described_class.create_native_text("content")
29
+ described_class.add_child(element, text)
30
+ expect(element.nodes.first).to eq("content")
31
+ end
32
+ end
33
+
34
+ describe "xpath support" do
35
+ let(:doc) { described_class.parse("<root><child id='1'>text</child></root>") }
36
+
37
+ it "supports basic element matching" do
38
+ nodes = described_class.xpath(doc, "//child")
39
+ expect(nodes.size).to eq(1)
40
+ expect(nodes.first.name).to eq("child")
41
+ end
42
+
43
+ it "supports attribute matching" do
44
+ nodes = described_class.xpath(doc, "//child[@id='1']")
45
+ expect(nodes.size).to eq(1)
46
+ expect(nodes.first.attributes["id"]).to eq("1")
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe "Test all shared examples" do
4
+ all_shared_examples = [
5
+ "Moxml::Node",
6
+ "Moxml::Namespace",
7
+ "Moxml::Attribute",
8
+ "Moxml::NodeSet",
9
+ "Moxml::Element",
10
+ "Moxml::Cdata",
11
+ "Moxml::Comment",
12
+ "Moxml::Text",
13
+ "Moxml::ProcessingInstruction",
14
+ "Moxml::Declaration",
15
+ "Moxml::Doctype",
16
+ "Moxml::Document",
17
+ "Moxml::Context",
18
+ "Moxml::Builder",
19
+ "Moxml::DocumentBuilder",
20
+ "Moxml Integration",
21
+ "Moxml Edge Cases",
22
+ "Attribute Examples",
23
+ "Basic Usage Examples",
24
+ "Namespace Examples",
25
+ "README Examples",
26
+ "XPath Examples",
27
+ "Memory Usage Examples",
28
+ "Thread Safety Examples"
29
+ ]
30
+
31
+ Moxml::Adapter::AVALIABLE_ADAPTERS.each do |adapter_name|
32
+ # [:nokogiri].each do |adapter_name|
33
+ # [:oga].each do |adapter_name|
34
+ context "with #{adapter_name}" do
35
+ around do |example|
36
+ Moxml.with_config(adapter_name) do
37
+ example.run
38
+ end
39
+ end
40
+
41
+ all_shared_examples.each do |shared_example_name|
42
+ it_behaves_like shared_example_name
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ # spec/moxml/config_spec.rb
4
+ RSpec.describe Moxml::Config do
5
+ subject(:config) { described_class.new }
6
+
7
+ describe "#initialize" do
8
+ it "sets default values" do
9
+ expect(config.adapter_name).to eq(:nokogiri)
10
+ expect(config.strict_parsing).to be true
11
+ expect(config.default_encoding).to eq("UTF-8")
12
+ expect(config.default_indent).to eq(2)
13
+ expect(config.entity_encoding).to eq(:basic)
14
+ end
15
+ end
16
+
17
+ describe "#adapter=" do
18
+ it "sets valid adapter" do
19
+ config.adapter = :ox
20
+ expect(config.adapter_name).to eq(:ox)
21
+ end
22
+
23
+ it "raises error for invalid adapter" do
24
+ expect { config.adapter = :invalid }.to raise_error(ArgumentError)
25
+ end
26
+
27
+ it "requires adapter gem" do
28
+ expect { config.adapter = :oga }.not_to raise_error
29
+
30
+ expect(defined?(::Oga)).to be_truthy
31
+ end
32
+
33
+ it "handles missing gems" do
34
+ allow(Moxml::Adapter).to receive(:require).and_raise(LoadError)
35
+ expect { config.adapter = :nokogiri }.to raise_error(LoadError)
36
+ end
37
+ end
38
+
39
+ describe "#adapter" do
40
+ it "returns nokogiri adapter by default" do
41
+ expect(config.adapter).to eq(Moxml::Adapter::Nokogiri)
42
+ end
43
+
44
+ it "caches adapter instance" do
45
+ adapter = config.adapter
46
+ expect(config.adapter.object_id).to eq(adapter.object_id)
47
+ end
48
+
49
+ it "resets cached adapter when changing adapter type" do
50
+ original = config.adapter
51
+ config.adapter = :ox
52
+ expect(config.adapter).not_to eq(original)
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ # spec/moxml/errors_spec.rb
4
+ RSpec.describe "Moxml errors" do
5
+ describe Moxml::Error do
6
+ it "is a StandardError" do
7
+ expect(Moxml::Error.new).to be_a(StandardError)
8
+ end
9
+ end
10
+
11
+ describe Moxml::ParseError do
12
+ it "includes line and column information" do
13
+ error = Moxml::ParseError.new("Invalid XML", line: 5, column: 10)
14
+ expect(error.line).to eq(5)
15
+ expect(error.column).to eq(10)
16
+ expect(error.message).to eq("Invalid XML")
17
+ end
18
+
19
+ it "works without line and column" do
20
+ error = Moxml::ParseError.new("Invalid XML")
21
+ expect(error.line).to be_nil
22
+ expect(error.column).to be_nil
23
+ end
24
+ end
25
+
26
+ describe Moxml::ValidationError do
27
+ it "handles validation errors" do
28
+ error = Moxml::ValidationError.new("Invalid document structure")
29
+ expect(error.message).to eq("Invalid document structure")
30
+ end
31
+ end
32
+
33
+ describe Moxml::XPathError do
34
+ it "handles XPath errors" do
35
+ error = Moxml::XPathError.new("Invalid XPath expression")
36
+ expect(error.message).to eq("Invalid XPath expression")
37
+ end
38
+ end
39
+
40
+ describe Moxml::NamespaceError do
41
+ it "handles namespace errors" do
42
+ error = Moxml::NamespaceError.new("Invalid namespace URI")
43
+ expect(error.message).to eq("Invalid namespace URI")
44
+ end
45
+ end
46
+
47
+ describe "error handling in context" do
48
+ let(:context) { Moxml.new }
49
+
50
+ it "raises ParseError for invalid XML" do
51
+ expect do
52
+ context.parse("<invalid>")
53
+ end.to raise_error(Moxml::ParseError)
54
+ end
55
+
56
+ it "raises XPathError for invalid XPath" do
57
+ doc = context.parse("<root/>")
58
+ expect do
59
+ doc.xpath("///")
60
+ end.to raise_error(Moxml::XPathError)
61
+ end
62
+
63
+ it "raises NamespaceError for invalid namespace" do
64
+ doc = context.parse("<root/>")
65
+
66
+ expect do
67
+ doc.root.add_namespace("xml", "http//invalid.com")
68
+ end.to raise_error(Moxml::NamespaceError, "Invalid URI: http//invalid.com")
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe "Adapter Examples" do
4
+ let(:xml) { "<root><child>text</child></root>" }
5
+
6
+ describe "Serialization consistency" do
7
+ it "produces equivalent XML across adapters",
8
+ skip: "No easy way to exclude the declaration from Nokogiri documents" do
9
+ docs = Moxml::Adapter::AVALIABLE_ADAPTERS.map do |adapter|
10
+ Moxml.new(adapter).parse(xml, fragment: true)
11
+ end
12
+
13
+ xmls = docs.map { |doc| normalize_xml(doc.to_xml) }
14
+ expect(xmls.uniq.size).to eq(1)
15
+ end
16
+
17
+ private
18
+
19
+ def normalize_xml(xml)
20
+ xml.gsub(/>\s+</, "><")
21
+ .gsub(/\s+/, " ")
22
+ .gsub(/ >/, ">")
23
+ .gsub(/\?></, "?>\n<")
24
+ .strip
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ # spec/moxml_spec.rb
4
+ RSpec.describe Moxml do
5
+ it "has a version number" do
6
+ expect(Moxml::VERSION).not_to be_nil
7
+ end
8
+
9
+ describe ".new" do
10
+ it "creates a new context" do
11
+ expect(Moxml.new).to be_a(Moxml::Context)
12
+ end
13
+
14
+ it "accepts adapter specification" do
15
+ context = Moxml.new(:nokogiri)
16
+ expect(context.config.adapter_name).to eq(:nokogiri)
17
+ end
18
+
19
+ it "raises error for invalid adapter" do
20
+ expect { Moxml.new(:invalid) }.to raise_error(ArgumentError)
21
+ end
22
+ end
23
+
24
+ describe ".configure" do
25
+ around do |example|
26
+ # preserve the original config because it may be changed in examples
27
+ Moxml.with_config { example.run }
28
+ end
29
+
30
+ it "sets default values without a block" do
31
+ Moxml.configure
32
+
33
+ context = Moxml.new
34
+ expect(context.config.adapter_name).to eq(:nokogiri)
35
+ end
36
+
37
+ it "uses configured options from the block" do
38
+ Moxml.configure do |config|
39
+ config.adapter = :oga
40
+ config.strict_parsing = false
41
+ config.default_encoding = "US-ASCII"
42
+ end
43
+
44
+ context = Moxml.new
45
+ expect(context.config.adapter_name).to eq(:oga)
46
+ expect(context.config.strict_parsing).to eq(false)
47
+ expect(context.config.default_encoding).to eq("US-ASCII")
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "moxml"
4
+ require "nokogiri"
5
+ require "byebug"
6
+
7
+ Dir[File.expand_path("support/**/*.rb", __dir__)].each { |f| require f }
8
+
9
+ RSpec.configure do |config|
10
+ config.expect_with :rspec do |expectations|
11
+ expectations.include_chain_clauses_in_custom_matcher_descriptions = true
12
+ end
13
+
14
+ config.mock_with :rspec do |mocks|
15
+ mocks.verify_partial_doubles = true
16
+ end
17
+
18
+ config.shared_context_metadata_behavior = :apply_to_host_groups
19
+ config.filter_run_when_matching :focus
20
+ config.example_status_persistence_file_path = "spec/examples.txt"
21
+ config.disable_monkey_patching!
22
+ config.warnings = true
23
+
24
+ config.order = :random
25
+ Kernel.srand config.seed
26
+ end
27
+
28
+ Moxml.configure do |config|
29
+ config.adapter = :nokogiri
30
+ config.strict_parsing = true
31
+ config.default_encoding = "UTF-8"
32
+ end