moxml 0.1.0 → 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
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 +400 -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 +314 -0
  14. data/lib/moxml/adapter/oga.rb +309 -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 +61 -99
  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 +110 -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