beid 0.1.0

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 203b09a7d1880b4113345b87d2571e0c84aefb40371e6a4b9f97fdf32b417c7e
4
+ data.tar.gz: 72145389c81cb8e86045bdef7bc3373f5a371ea57717676b3990a580cb9a7e7a
5
+ SHA512:
6
+ metadata.gz: 68d48286462af7a869d4007540357c2a42957df7c2dee6a98e9d827309de9d3c426ba26d29de9262210b868f0a08ac52b39cfb3a98eb01568e0494a96c2d5f8d
7
+ data.tar.gz: d45e9fd574d845ddc0d69567f044addd222882d9bc2644ca18849c8ee93ff45ec14d7f0455acfbe49c8994eea1816edd227f0c516b4bfe7b378b2da2668284e1
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 - 2026-09-23
4
+
5
+ - Initial release.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Yudai Takada
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,52 @@
1
+ # Beid
2
+
3
+ Beid (ο¹ Eridani; Arabic *bayḍ*, “egg”) parses Markdown into a source-positioned tree and edits the original bytes in place. It is intended for applications that need to update part of a Markdown document without normalizing untouched formatting.
4
+
5
+ Beid uses Ruby's standard library at runtime and supports Ruby 3.1 and later.
6
+
7
+ ## Installation
8
+
9
+ ```ruby
10
+ gem "beid"
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```ruby
16
+ require "beid"
17
+
18
+ source = "# Title\n\nKeep this *formatting*.\n"
19
+ document = Beid::Document.parse(source)
20
+ paragraph = document.root.children.find { |node| node.type == :paragraph }
21
+
22
+ updated = Beid::Editing.replace_text(document, paragraph, "A new sentence.")
23
+ puts updated
24
+ # # Title
25
+ #
26
+ # A new sentence.
27
+ ```
28
+
29
+ `Document#source` and `#to_s` return the exact source string. Node ranges and all offsets are half-open byte ranges in that original UTF-8 string. `position_at` returns zero-based line and Unicode-codepoint column; `utf16_position_at` returns a zero-based LSP position. `nodes_at` returns the document-to-deepest-node path at a byte offset.
30
+
31
+ `Beid::Editing` provides `replace`, `replace_text`, `insert_before`, `insert_after`, `remove`, `set_attribute`, `set_directive`, and `move`. Each operation returns a new parsed `Document`; the original remains unchanged. Edits splice only the requested byte range. Replacements are reparsed, and nodes from another document are rejected.
32
+
33
+ ```ruby
34
+ heading = document.root.children.first
35
+ updated = Beid::Editing.set_attribute(document, heading, :level, 2)
36
+ ```
37
+
38
+ `Document.parse(text, gfm: true, front_matter: true)` retains YAML front matter as raw text, recognizes HTML-comment directives (`<!-- layout: two-column -->`), fenced code blocks, headings, paragraphs, nested lists, block quotes, tables, task-list items, strikethrough, and footnote references/definitions. `::: name` fenced divs are retained as directive nodes. Inline nodes include emphasis, strong emphasis, inline and reference links, autolinks, images, code spans, and text. Reference definitions are retained as `:link_definition` nodes and resolved link nodes carry their destination and source ranges.
39
+
40
+ Beid does not evaluate YAML or render HTML. It is not a complete CommonMark/GFM implementation: unsupported or ambiguous syntax may be represented as plain text or raw HTML. The official CommonMark 0.31.2 fixture is bundled under `spec/fixtures/commonmark`. CI verifies exact source round-tripping and node byte ranges, and compares a test-only HTML rendering of Beid's tree with the fixture's expected output after HTML5 DOM normalization. The current semantic HTML DOM agreement is 620/652 (95.1%), meeting the design's 95% CommonMark semantic-conformance gate; the remaining mismatches are listed by section in CI output. Nokogiri is a development-only dependency for this oracle and is not a runtime gem dependency. Regardless of parser coverage, `Document#to_s` is an exact round trip because serialization returns the original source rather than regenerating Markdown from the tree. Treat the tree as a best-effort editing view and keep application-level edits within recognized node ranges.
41
+
42
+ ## Development
43
+
44
+ ```sh
45
+ bundle install
46
+ bundle exec rake
47
+ bundle exec rbs -I sig validate
48
+ ```
49
+
50
+ ## License
51
+
52
+ MIT. See [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,16 @@
1
+ # ADR 001: Byte-range edits instead of AST reserialization
2
+
3
+ - Status: Accepted
4
+ - Date: 2026-09-23
5
+
6
+ ## Context
7
+
8
+ Beid is meant to support editing Markdown documents without changing untouched formatting. Rebuilding source from a parsed tree would normalize markers, spacing, line endings, and syntax Beid does not understand.
9
+
10
+ ## Decision
11
+
12
+ Each node keeps a half-open byte range into an immutable source snapshot. Editing operations splice only that range and reparse the result; `Document#to_s` returns the stored source verbatim.
13
+
14
+ ## Consequences
15
+
16
+ Unedited bytes remain unchanged, including syntax outside Beid's supported subset. Consumers must use a node from the same document snapshot and reparse after edits; ranges from an earlier document are not reusable.
@@ -0,0 +1,16 @@
1
+ # ADR 002: HTML comments as directives
2
+
3
+ - Status: Accepted
4
+ - Date: 2026-09-23
5
+
6
+ ## Context
7
+
8
+ Applications built on Markdown need lightweight metadata without introducing a custom block syntax that ordinary Markdown tools cannot preserve.
9
+
10
+ ## Decision
11
+
12
+ Beid recognizes a standalone HTML comment containing one `key: value` pair, such as `<!-- layout: two-column -->`, as a directive. It also retains `::: name` fenced div blocks as source-positioned directive nodes, without interpreting their contents.
13
+
14
+ ## Consequences
15
+
16
+ Directive metadata remains valid Markdown and round-trips unchanged. YAML and directive values are not evaluated; applications define their meaning. Beid's directive helper deliberately handles one key-value pair per HTML comment.
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Beid
4
+ module Directive
5
+ PATTERN = /\A<!--[ \t]*([A-Za-z][A-Za-z0-9_-]*)[ \t]*:[ \t]*(.*?)[ \t]*-->\z/m
6
+
7
+ def self.parse(html_comment)
8
+ match = PATTERN.match(html_comment)
9
+ return nil unless match
10
+
11
+ { match[1] => match[2] }
12
+ end
13
+
14
+ def self.render(values)
15
+ raise TypeError, "directive must be a Hash" unless values.is_a?(Hash)
16
+ raise ArgumentError, "one key-value pair per comment is supported" unless values.length == 1
17
+
18
+ fields = values.map do |key, value|
19
+ key = key.to_s
20
+ value = value.to_s
21
+ raise ArgumentError, "invalid directive key" unless key.match?(/\A[A-Za-z][A-Za-z0-9_-]*\z/)
22
+ raise ArgumentError, "directive value cannot contain a comment terminator or newline" if value.include?("-->") || value.match?(/[\r\n]/)
23
+
24
+ "#{key}: #{value}"
25
+ end
26
+ "<!-- #{fields.join("; ")} -->"
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Beid
4
+ class Document
5
+ attr_reader :source, :root, :front_matter, :options, :diagnostics
6
+
7
+ def self.parse(text, gfm: true, front_matter: true)
8
+ raise TypeError, "source must be a String" unless text.is_a?(String)
9
+
10
+ source = text.dup.freeze
11
+ options = { gfm: gfm, front_matter: front_matter }.freeze
12
+ root, raw_front_matter, diagnostics = Parser.new(source, **options).parse
13
+ new(source, root, raw_front_matter, options, diagnostics)
14
+ end
15
+
16
+ def initialize(source, root, front_matter, options, diagnostics)
17
+ @source, @root, @front_matter = source, root, front_matter&.freeze
18
+ @options, @diagnostics = options, diagnostics.freeze
19
+ @nodes = {}
20
+ walk(@root) { |node| @nodes[node.object_id] = node }
21
+ end
22
+
23
+ def nodes_at(offset)
24
+ check_offset(offset)
25
+ return [@root] if @source.empty? && offset.zero?
26
+ return [] unless @root.range.cover?(offset)
27
+
28
+ result = [@root]
29
+ current = @root
30
+ loop do
31
+ child = current.children.find { |entry| entry.range.cover?(offset) }
32
+ break unless child
33
+
34
+ result << child
35
+ current = child
36
+ end
37
+ result
38
+ end
39
+
40
+ def node_at(offset)
41
+ nodes_at(offset).last
42
+ rescue RangeError
43
+ nil
44
+ end
45
+
46
+ def range_of(node)
47
+ raise TypeError, "node must be a Beid::Node" unless node.is_a?(Node)
48
+ raise ArgumentError, "node does not belong to this document" unless @nodes[node.object_id].equal?(node)
49
+
50
+ node.range
51
+ end
52
+
53
+ # Returns zero-based [line, Unicode-codepoint column] for a UTF-8 byte offset.
54
+ def position_at(offset)
55
+ check_offset(offset)
56
+ raise EncodingError, "positions require valid UTF-8" unless @source.valid_encoding?
57
+
58
+ line_index = line_index_at(offset)
59
+ start = line_starts[line_index]
60
+ prefix = @source.byteslice(start...offset).to_s.sub(/(?:\r\n|\r|\n)\z/, "")
61
+ [line_index, prefix.length]
62
+ end
63
+
64
+ # Returns zero-based LSP [line, UTF-16 code-unit column].
65
+ def utf16_position_at(offset)
66
+ line, = position_at(offset)
67
+ start = line_starts[line]
68
+ prefix = @source.byteslice(start...offset).to_s.sub(/(?:\r\n|\r|\n)\z/, "")
69
+ [line, prefix.encode("UTF-16LE").bytesize / 2]
70
+ end
71
+
72
+ def valid?
73
+ @source.valid_encoding? && @diagnostics.empty?
74
+ end
75
+
76
+ def to_s
77
+ @source
78
+ end
79
+
80
+ def include_node?(node)
81
+ node.is_a?(Node) && @nodes[node.object_id].equal?(node)
82
+ end
83
+
84
+ private
85
+
86
+ def walk(node, &block)
87
+ yield node
88
+ node.children.each { |child| walk(child, &block) }
89
+ end
90
+
91
+ def check_offset(offset)
92
+ unless offset.is_a?(Integer) && (0..@source.bytesize).cover?(offset)
93
+ raise RangeError, "Byte offset is outside the document"
94
+ end
95
+ if @source.valid_encoding? && offset < @source.bytesize && (@source.getbyte(offset) & 0xc0) == 0x80
96
+ raise RangeError, "Byte offset splits a UTF-8 character"
97
+ end
98
+ end
99
+
100
+ def line_index_at(offset)
101
+ (line_starts.bsearch_index { |start| start > offset } || line_starts.length) - 1
102
+ end
103
+
104
+ def line_starts
105
+ @line_starts ||= begin
106
+ starts = [0]
107
+ @source.b.to_enum(:scan, /\r\n|\r|\n/n).each { starts << Regexp.last_match.end(0) }
108
+ starts
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Beid
4
+ module Editing
5
+ def self.replace(document, node, markdown)
6
+ document.range_of(node)
7
+ raise TypeError, "markdown must be a String" unless markdown.is_a?(String)
8
+ terminator = document.source.byteslice(node.range.end - 2...node.range.end).to_s
9
+ terminator = document.source.byteslice(node.range.end - 1...node.range.end).to_s unless ["\r\n", "\n", "\r"].include?(terminator)
10
+ markdown += terminator if !markdown.empty? && ["\r\n", "\n", "\r"].include?(terminator) && !markdown.end_with?("\r", "\n")
11
+ edit_range(document, node, node.range, markdown)
12
+ end
13
+
14
+ def self.replace_text(document, node, text)
15
+ raise TypeError, "text must be a String" unless text.is_a?(String)
16
+ if node.type == :code_span
17
+ marker_size = [node.marker.length, text.scan(/`+/).map(&:length).max.to_i + 1].max
18
+ marker = "`" * marker_size
19
+ return edit(document, node, "#{marker}#{text}#{marker}")
20
+ end
21
+ range = case node.type
22
+ when :text
23
+ document.range_of(node)
24
+ when :heading, :paragraph, :footnote_definition
25
+ node.attributes.fetch(:content_range)
26
+ when :link, :image
27
+ node.attributes.fetch(:label_range)
28
+ when :emphasis, :strong, :strikethrough
29
+ node.range.begin + node.marker.bytesize...(node.range.end - node.marker.bytesize)
30
+ else
31
+ raise ArgumentError, "#{node.type} does not have replaceable text"
32
+ end
33
+ edit_range(document, node, range, escape_text(text))
34
+ end
35
+
36
+ def self.insert_after(document, node, markdown)
37
+ insert(document, node, markdown, after: true)
38
+ end
39
+
40
+ def self.insert_before(document, node, markdown)
41
+ insert(document, node, markdown, after: false)
42
+ end
43
+
44
+ def self.remove(document, node)
45
+ edit(document, node, "")
46
+ end
47
+
48
+ def self.set_attribute(document, node, key, value)
49
+ document.range_of(node)
50
+ case [node.type, key.to_sym]
51
+ when [:heading, :level]
52
+ level = Integer(value)
53
+ raise ArgumentError, "heading level must be between 1 and 6" unless (1..6).cover?(level)
54
+
55
+ if node.attributes[:style] == :atx
56
+ range = node.attributes.fetch(:marker_range)
57
+ edit_range(document, node, range, "#" * level)
58
+ else
59
+ edit_range(document, node, node.attributes.fetch(:marker_range), (level == 1 ? "=" : "-") * [node.marker.length, 3].max)
60
+ end
61
+ when [:link, :destination], [:image, :destination]
62
+ edit_range(document, node, node.attributes.fetch(:destination_range), escape_destination(value.to_s))
63
+ else
64
+ raise ArgumentError, "unsupported attribute #{key.inspect} for #{node.type}"
65
+ end
66
+ end
67
+
68
+ def self.set_directive(document, node, key, value)
69
+ document.range_of(node)
70
+ raise ArgumentError, "node is not an HTML directive" unless node.type == :directive && node.attributes[:kind] == :html_comment
71
+
72
+ values = Directive.parse(document.source.byteslice(node.range)) || {}
73
+ values[key.to_s] = value.to_s
74
+ edit_range(document, node, node.attributes.fetch(:comment_range), Directive.render(values))
75
+ end
76
+
77
+ def self.move(document, node, before: nil, after: nil)
78
+ document.range_of(node)
79
+ raise ArgumentError, "specify exactly one of before or after" unless (!!before ^ !!after)
80
+ target = before || after
81
+ target_range = document.range_of(target)
82
+ source_range = node.range
83
+ raise ArgumentError, "cannot move a node relative to itself" if node.equal?(target)
84
+ raise ArgumentError, "move ranges overlap" if source_range.begin < target_range.end && target_range.begin < source_range.end
85
+
86
+ source = document.source.b
87
+ moved = source.byteslice(source_range)
88
+ remainder = source.byteslice(0...source_range.begin) + source.byteslice(source_range.end..-1).to_s
89
+ destination = before ? target_range.begin : target_range.end
90
+ destination -= source_range.size if source_range.end <= destination
91
+ result = remainder.byteslice(0...destination) + moved + remainder.byteslice(destination..-1).to_s
92
+ parse_result(document, result)
93
+ end
94
+
95
+ def self.insert(document, node, markdown, after:)
96
+ document.range_of(node)
97
+ raise TypeError, "markdown must be a String" unless markdown.is_a?(String)
98
+ markdown = adapt_list_marker(markdown, node.marker) if node.type == :list_item
99
+ source = document.source.b
100
+ range = node.range
101
+ newline = newline_for(document.source)
102
+ if after
103
+ offset = range.end
104
+ if node.type == :list_item
105
+ item = source.byteslice(range)
106
+ prefix = item.end_with?("\r", "\n") ? "" : newline
107
+ insertion = prefix + markdown + (offset < source.bytesize ? newline : "")
108
+ else
109
+ insertion = newline + markdown
110
+ insertion += newline if offset < source.bytesize
111
+ end
112
+ else
113
+ offset = range.begin
114
+ insertion = if node.type == :list_item
115
+ markdown + newline
116
+ else
117
+ markdown + newline * 2
118
+ end
119
+ end
120
+ result = source.byteslice(0...offset) + insertion.b + source.byteslice(offset..-1).to_s
121
+ parse_result(document, result)
122
+ end
123
+
124
+ def self.edit(document, node, markdown)
125
+ document.range_of(node)
126
+ raise TypeError, "markdown must be a String" unless markdown.is_a?(String)
127
+ edit_range(document, node, node.range, markdown)
128
+ end
129
+
130
+ def self.edit_range(document, node, range, replacement)
131
+ document.range_of(node)
132
+ unless range.is_a?(Range) && range.begin >= node.range.begin && range.end <= node.range.end
133
+ raise ArgumentError, "edit range must be within the node"
134
+ end
135
+
136
+ source = document.source.b
137
+ result = source.byteslice(0...range.begin) + replacement.b + source.byteslice(range.end..-1).to_s
138
+ parse_result(document, result)
139
+ end
140
+
141
+ def self.parse_result(document, source)
142
+ result = Document.parse(source.force_encoding(document.source.encoding), **document.options)
143
+ raise Error, "edit produced invalid Markdown" unless result.valid?
144
+
145
+ result
146
+ end
147
+ private_class_method :parse_result
148
+
149
+ def self.escape_text(text)
150
+ text.gsub(/([\\`*_{}\[\]<>!])/) { |character| "\\#{character}" }
151
+ end
152
+ private_class_method :escape_text
153
+
154
+ def self.escape_destination(value)
155
+ raise ArgumentError, "destination cannot contain newlines or angle brackets" if value.match?(/[\r\n<>]/)
156
+
157
+ value.gsub(/([\\() ])/) { |character| "\\#{character}" }
158
+ end
159
+ private_class_method :escape_destination
160
+
161
+ def self.adapt_list_marker(markdown, marker)
162
+ return markdown unless marker && (match = /\A( {0,3})(?:[-+*]|\d{1,9}[.)])(?=[ \t])/.match(markdown))
163
+
164
+ markdown.sub(/\A( {0,3})(?:[-+*]|\d{1,9}[.)])(?=[ \t])/, "\\1#{marker}")
165
+ end
166
+ private_class_method :adapt_list_marker
167
+
168
+ def self.newline_for(source)
169
+ source[/\r\n|\n|\r/] || "\n"
170
+ end
171
+ private_class_method :newline_for
172
+ end
173
+ end