markly-merge 1.0.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.
data/REEK ADDED
File without changes
data/RUBOCOP.md ADDED
@@ -0,0 +1,71 @@
1
+ # RuboCop Usage Guide
2
+
3
+ ## Overview
4
+
5
+ A tale of two RuboCop plugin gems.
6
+
7
+ ### RuboCop Gradual
8
+
9
+ This project uses `rubocop_gradual` instead of vanilla RuboCop for code style checking. The `rubocop_gradual` tool allows for gradual adoption of RuboCop rules by tracking violations in a lock file.
10
+
11
+ ### RuboCop LTS
12
+
13
+ This project uses `rubocop-lts` to ensure, on a best-effort basis, compatibility with Ruby >= 1.9.2.
14
+ RuboCop rules are meticulously configured by the `rubocop-lts` family of gems to ensure that a project is compatible with a specific version of Ruby. See: https://rubocop-lts.gitlab.io for more.
15
+
16
+ ## Checking RuboCop Violations
17
+
18
+ To check for RuboCop violations in this project, always use:
19
+
20
+ ```bash
21
+ bundle exec rake rubocop_gradual:check
22
+ ```
23
+
24
+ **Do not use** the standard RuboCop commands like:
25
+ - `bundle exec rubocop`
26
+ - `rubocop`
27
+
28
+ ## Understanding the Lock File
29
+
30
+ The `.rubocop_gradual.lock` file tracks all current RuboCop violations in the project. This allows the team to:
31
+
32
+ 1. Prevent new violations while gradually fixing existing ones
33
+ 2. Track progress on code style improvements
34
+ 3. Ensure CI builds don't fail due to pre-existing violations
35
+
36
+ ## Common Commands
37
+
38
+ - **Check violations**
39
+ - `bundle exec rake rubocop_gradual`
40
+ - `bundle exec rake rubocop_gradual:check`
41
+ - **(Safe) Autocorrect violations, and update lockfile if no new violations**
42
+ - `bundle exec rake rubocop_gradual:autocorrect`
43
+ - **Force update the lock file (w/o autocorrect) to match violations present in code**
44
+ - `bundle exec rake rubocop_gradual:force_update`
45
+
46
+ ## Workflow
47
+
48
+ 1. Before submitting a PR, run `bundle exec rake rubocop_gradual:autocorrect`
49
+ a. or just the default `bundle exec rake`, as autocorrection is a pre-requisite of the default task.
50
+ 2. If there are new violations, either:
51
+ - Fix them in your code
52
+ - Run `bundle exec rake rubocop_gradual:force_update` to update the lock file (only for violations you can't fix immediately)
53
+ 3. Commit the updated `.rubocop_gradual.lock` file along with your changes
54
+
55
+ ## Never add inline RuboCop disables
56
+
57
+ Do not add inline `rubocop:disable` / `rubocop:enable` comments anywhere in the codebase (including specs, except when following the few existing `rubocop:disable` patterns for a rule already being disabled elsewhere in the code). We handle exceptions in two supported ways:
58
+
59
+ - Permanent/structural exceptions: prefer adjusting the RuboCop configuration (e.g., in `.rubocop.yml`) to exclude a rule for a path or file pattern when it makes sense project-wide.
60
+ - Temporary exceptions while improving code: record the current violations in `.rubocop_gradual.lock` via the gradual workflow:
61
+ - `bundle exec rake rubocop_gradual:autocorrect` (preferred; will autocorrect what it can and update the lock only if no new violations were introduced)
62
+ - If needed, `bundle exec rake rubocop_gradual:force_update` (as a last resort when you cannot fix the newly reported violations immediately)
63
+
64
+ In general, treat the rules as guidance to follow; fix violations rather than ignore them. For example, RSpec conventions in this project expect `described_class` to be used in specs that target a specific class under test.
65
+
66
+ ## Benefits of rubocop_gradual
67
+
68
+ - Allows incremental adoption of code style rules
69
+ - Prevents CI failures due to pre-existing violations
70
+ - Provides a clear record of code style debt
71
+ - Enables focused efforts on improving code quality over time
data/SECURITY.md ADDED
@@ -0,0 +1,21 @@
1
+ # Security Policy
2
+
3
+ ## Supported Versions
4
+
5
+ | Version | Supported |
6
+ |----------|-----------|
7
+ | 1.latest | ✅ |
8
+
9
+ ## Security contact information
10
+
11
+ To report a security vulnerability, please use the
12
+ [Tidelift security contact](https://tidelift.com/security).
13
+ Tidelift will coordinate the fix and disclosure.
14
+
15
+ ## Additional Support
16
+
17
+ If you are interested in support for versions older than the latest release,
18
+ please consider sponsoring the project / maintainer @ https://liberapay.com/pboling/donate,
19
+ or find other sponsorship links in the [README].
20
+
21
+ [README]: README.md
@@ -0,0 +1,422 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markly
4
+ module Merge
5
+ # Markly backend using the Markly gem (cmark-gfm C library)
6
+ #
7
+ # This backend wraps Markly, a Ruby gem that provides bindings to
8
+ # cmark-gfm, GitHub's fork of the CommonMark C library with extensions.
9
+ #
10
+ # @note This backend only parses Markdown source code
11
+ # @see https://github.com/ioquatix/markly Markly gem
12
+ #
13
+ # @example Basic usage
14
+ # parser = TreeHaver::Parser.new
15
+ # parser.language = Markly::Merge::Backend::Language.markdown(
16
+ # flags: Markly::DEFAULT,
17
+ # extensions: [:table, :strikethrough]
18
+ # )
19
+ # tree = parser.parse(markdown_source)
20
+ # root = tree.root_node
21
+ # puts root.type # => "document"
22
+ module Backend
23
+ @load_attempted = false # rubocop:disable ThreadSafety/ClassInstanceVariable
24
+ @loaded = false # rubocop:disable ThreadSafety/ClassInstanceVariable
25
+
26
+ # Check if the Markly backend is available
27
+ #
28
+ # @return [Boolean] true if markly gem is available
29
+ class << self
30
+ def available?
31
+ return @loaded if @load_attempted # rubocop:disable ThreadSafety/ClassInstanceVariable
32
+ @load_attempted = true # rubocop:disable ThreadSafety/ClassInstanceVariable
33
+ begin
34
+ require "markly"
35
+ @loaded = true # rubocop:disable ThreadSafety/ClassInstanceVariable
36
+ rescue LoadError
37
+ @loaded = false # rubocop:disable ThreadSafety/ClassInstanceVariable
38
+ rescue StandardError
39
+ @loaded = false # rubocop:disable ThreadSafety/ClassInstanceVariable
40
+ end
41
+ @loaded # rubocop:disable ThreadSafety/ClassInstanceVariable
42
+ end
43
+
44
+ # Reset the load state (primarily for testing)
45
+ #
46
+ # @return [void]
47
+ # @api private
48
+ def reset!
49
+ @load_attempted = false # rubocop:disable ThreadSafety/ClassInstanceVariable
50
+ @loaded = false # rubocop:disable ThreadSafety/ClassInstanceVariable
51
+ end
52
+
53
+ # Get capabilities supported by this backend
54
+ #
55
+ # @return [Hash{Symbol => Object}] capability map
56
+ def capabilities
57
+ return {} unless available?
58
+ {
59
+ backend: :markly,
60
+ query: false,
61
+ bytes_field: false, # Markly uses line/column
62
+ incremental: false,
63
+ pure_ruby: false, # Uses C via FFI
64
+ markdown_only: true,
65
+ error_tolerant: true, # Markdown is forgiving
66
+ gfm_extensions: true, # Supports GitHub Flavored Markdown
67
+ }
68
+ end
69
+ end
70
+
71
+ # Markly language wrapper
72
+ #
73
+ # Markly only parses Markdown. This class exists for API compatibility
74
+ # and to pass through Markly-specific options (flags, extensions).
75
+ #
76
+ # @example
77
+ # language = Markly::Merge::Backend::Language.markdown(
78
+ # flags: Markly::DEFAULT | Markly::FOOTNOTES,
79
+ # extensions: [:table, :strikethrough]
80
+ # )
81
+ # parser.language = language
82
+ class Language < ::TreeHaver::Base::Language
83
+ # Markly parse flags
84
+ # @return [Integer]
85
+ attr_reader :flags
86
+
87
+ # Markly extensions to enable
88
+ # @return [Array<Symbol>]
89
+ attr_reader :extensions
90
+
91
+ # Create a new Markly language instance
92
+ #
93
+ # @param name [Symbol] Language name (should be :markdown)
94
+ # @param flags [Integer] Markly parse flags (default: Markly::DEFAULT)
95
+ # @param extensions [Array<Symbol>] Extensions to enable (default: [:table])
96
+ # @param options [Hash] parsing options (reserved for future use)
97
+ def initialize(name = :markdown, flags: nil, extensions: [:table], options: {})
98
+ super(name, backend: :markly, options: options.merge({flags: flags, extensions: extensions}))
99
+ @flags = flags # Will use Markly::DEFAULT if nil at parse time
100
+ @extensions = extensions
101
+
102
+ unless @name == :markdown
103
+ raise TreeHaver::NotAvailable,
104
+ "Markly backend only supports Markdown parsing. " \
105
+ "Got language: #{name.inspect}"
106
+ end
107
+ end
108
+
109
+ class << self
110
+ # Create a Markdown language instance
111
+ #
112
+ # @param flags [Integer] Markly parse flags
113
+ # @param extensions [Array<Symbol>] Extensions to enable
114
+ # @param options [Hash] parsing options (reserved for future use)
115
+ # @return [Language] Markdown language
116
+ def markdown(flags: nil, extensions: [:table], options: {})
117
+ new(:markdown, flags: flags, extensions: extensions, options: options)
118
+ end
119
+
120
+ # Load language from library path (API compatibility)
121
+ #
122
+ # @param _path [String] Ignored - Markly doesn't load external grammars
123
+ # @param symbol [String, nil] Ignored
124
+ # @param name [String, nil] Language name hint (defaults to :markdown)
125
+ # @return [Language] Markdown language
126
+ # @raise [TreeHaver::NotAvailable] if requested language is not Markdown
127
+ def from_library(_path = nil, symbol: nil, name: nil)
128
+ lang_name = name || symbol&.to_s&.sub(/^tree_sitter_/, "")&.to_sym || :markdown
129
+
130
+ unless lang_name == :markdown
131
+ raise TreeHaver::NotAvailable,
132
+ "Markly backend only supports Markdown, not #{lang_name}. " \
133
+ "Use a tree-sitter backend for #{lang_name} support."
134
+ end
135
+
136
+ markdown
137
+ end
138
+ end
139
+ end
140
+
141
+ # Markly parser wrapper
142
+ class Parser < ::TreeHaver::Base::Parser
143
+ # Create a new RBS parser instance
144
+ #
145
+ # @raise [TreeHaver::NotAvailable] if rbs gem is not available
146
+ def initialize
147
+ super()
148
+ raise TreeHaver::NotAvailable, "markly gem not available" unless Backend.available?
149
+ end
150
+
151
+ # Set the language for this parser
152
+ #
153
+ # @param lang [Language, Symbol] RBS language (should be :rbs or Language instance)
154
+ # @return [void]
155
+ def language=(lang)
156
+ case lang
157
+ when Language
158
+ @language = lang
159
+ when Symbol, String
160
+ if lang.to_sym == :markdown
161
+ @language = Language.markdown
162
+ else
163
+ raise ArgumentError,
164
+ "Markly backend only supports Markdown parsing. Got: #{lang.inspect}"
165
+ end
166
+ else
167
+ raise ArgumentError,
168
+ "Expected Backend::Language or :markdown, got #{lang.class}"
169
+ end
170
+ end
171
+
172
+ # Parse Markdown source code
173
+ #
174
+ # @param source [String] Markdown source to parse
175
+ # @return [Tree] Parsed tree
176
+ def parse(source)
177
+ raise "Language not set" unless language
178
+ Backend.available? or raise "Markly not available"
179
+
180
+ flags = language.flags || ::Markly::DEFAULT
181
+ exts = language.extensions || [:table]
182
+ doc = ::Markly.parse(source, flags: flags, extensions: exts)
183
+ Tree.new(doc, source)
184
+ end
185
+ end
186
+
187
+ # Markly tree wrapper
188
+ #
189
+ # Wraps Markly parse results to provide tree-sitter-compatible API.
190
+ #
191
+ # @api private
192
+ class Tree < ::TreeHaver::Base::Tree
193
+ def initialize(document, source)
194
+ super(document, source: source)
195
+ end
196
+
197
+ def root_node
198
+ Node.new(inner_tree, source: source, lines: lines)
199
+ end
200
+ end
201
+
202
+ # Markly node wrapper
203
+ #
204
+ # Wraps Markly::Node to provide TreeHaver::Node-compatible interface.
205
+ class Node < ::TreeHaver::Base::Node
206
+ # Type normalization map (Markly → canonical)
207
+ TYPE_MAP = {
208
+ header: "heading",
209
+ hrule: "thematic_break",
210
+ html: "html_block",
211
+ }.freeze
212
+
213
+ # Default source position for nodes that don't have position info
214
+ DEFAULT_SOURCE_POSITION = {
215
+ start_line: 1,
216
+ start_column: 1,
217
+ end_line: 1,
218
+ end_column: 1,
219
+ }.freeze
220
+
221
+ # Get source position from the inner Markly node
222
+ #
223
+ # @return [Hash{Symbol => Integer}] Source position from Markly
224
+ # @api private
225
+ def inner_source_position
226
+ @inner_source_position ||= if inner_node.respond_to?(:source_position)
227
+ inner_node.source_position || DEFAULT_SOURCE_POSITION
228
+ else
229
+ DEFAULT_SOURCE_POSITION
230
+ end
231
+ end
232
+
233
+ # Get the node type as a string (normalized)
234
+ #
235
+ # @return [String] Node type
236
+ def type
237
+ raw = inner_node.type.to_s
238
+ TYPE_MAP[raw.to_sym]&.to_s || raw
239
+ end
240
+
241
+ # Get the raw (non-normalized) type
242
+ # @return [String]
243
+ def raw_type
244
+ inner_node.type.to_s
245
+ end
246
+
247
+ # Get the text content of this node
248
+ #
249
+ # @return [String] Node text
250
+ def text
251
+ if inner_node.respond_to?(:string_content)
252
+ content = inner_node.string_content.to_s
253
+ return content unless content.empty?
254
+ end
255
+
256
+ if inner_node.respond_to?(:to_plaintext)
257
+ begin
258
+ inner_node.to_plaintext
259
+ rescue
260
+ children.map(&:text).join
261
+ end
262
+ else
263
+ children.map(&:text).join
264
+ end
265
+ end
266
+
267
+ # Get child nodes (Markly uses first_child/next pattern)
268
+ #
269
+ # @return [Array<Node>] Child nodes
270
+ def children
271
+ result = []
272
+ child = begin
273
+ inner_node.first_child
274
+ rescue
275
+ nil
276
+ end
277
+ while child
278
+ result << Node.new(child, source: source, lines: lines)
279
+ child = begin
280
+ child.next
281
+ rescue
282
+ nil
283
+ end
284
+ end
285
+ result
286
+ end
287
+
288
+ # Position information
289
+
290
+ def start_byte
291
+ pos = inner_source_position
292
+ calculate_byte_offset(pos[:start_line] - 1, pos[:start_column] - 1)
293
+ end
294
+
295
+ def end_byte
296
+ pos = inner_source_position
297
+ calculate_byte_offset(pos[:end_line] - 1, pos[:end_column] - 1)
298
+ end
299
+
300
+ def start_point
301
+ pos = inner_source_position
302
+ {row: pos[:start_line] - 1, column: pos[:start_column] - 1}
303
+ end
304
+
305
+ def end_point
306
+ pos = inner_source_position
307
+ {row: pos[:end_line] - 1, column: pos[:end_column] - 1}
308
+ end
309
+
310
+ # Convert node to CommonMark/Markdown/HTML/plaintext
311
+ def to_commonmark
312
+ inner_node.to_commonmark
313
+ end
314
+
315
+ def to_markdown
316
+ inner_node.to_markdown
317
+ end
318
+
319
+ def to_plaintext
320
+ inner_node.to_plaintext
321
+ end
322
+
323
+ def to_html
324
+ inner_node.to_html
325
+ end
326
+
327
+ # Markly-specific methods
328
+
329
+ # Get heading level (1-6)
330
+ # @return [Integer, nil]
331
+ def header_level
332
+ return unless raw_type == "header"
333
+ begin
334
+ inner_node.header_level
335
+ rescue
336
+ nil
337
+ end
338
+ end
339
+
340
+ # Get fence info for code blocks
341
+ # @return [String, nil]
342
+ def fence_info
343
+ return unless type == "code_block"
344
+ begin
345
+ inner_node.fence_info
346
+ rescue
347
+ nil
348
+ end
349
+ end
350
+
351
+ # Get URL for links/images
352
+ # @return [String, nil]
353
+ def url
354
+ inner_node.url
355
+ rescue
356
+ nil
357
+ end
358
+
359
+ # Get title for links/images
360
+ # @return [String, nil]
361
+ def title
362
+ inner_node.title
363
+ rescue
364
+ nil
365
+ end
366
+
367
+ # Get the next sibling (Markly uses .next)
368
+ # @return [Node, nil]
369
+ def next_sibling
370
+ sibling = begin
371
+ inner_node.next
372
+ rescue
373
+ nil
374
+ end
375
+ sibling ? Node.new(sibling, source: source, lines: lines) : nil
376
+ end
377
+
378
+ # Get the previous sibling
379
+ # @return [Node, nil]
380
+ def prev_sibling
381
+ sibling = begin
382
+ inner_node.previous
383
+ rescue
384
+ nil
385
+ end
386
+ sibling ? Node.new(sibling, source: source, lines: lines) : nil
387
+ end
388
+
389
+ # Get the parent node
390
+ # @return [Node, nil]
391
+ def parent
392
+ p = begin
393
+ inner_node.parent
394
+ rescue
395
+ nil
396
+ end
397
+ p ? Node.new(p, source: source, lines: lines) : nil
398
+ end
399
+ end
400
+
401
+ # Alias Point to the base class for compatibility
402
+ Point = ::TreeHaver::Base::Point
403
+
404
+ # Register this backend with TreeHaver
405
+ ::TreeHaver.register_language(
406
+ :markdown,
407
+ backend_type: :markly,
408
+ backend_module: self,
409
+ gem_name: "markly",
410
+ )
411
+
412
+ # Register the full tag for RSpec dependency tags with require path
413
+ # This enables tree_haver to lazily load this gem when checking availability
414
+ ::TreeHaver::BackendRegistry.register_tag(
415
+ :markly_backend,
416
+ category: :backend,
417
+ backend_name: :markly,
418
+ require_path: "markly/merge",
419
+ ) { available? }
420
+ end
421
+ end
422
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markly
4
+ module Merge
5
+ # Debug logging utility for Markly::Merge operations.
6
+ #
7
+ # Extends Ast::Merge::DebugLogger to provide consistent logging
8
+ # across all merge gems. Logs are controlled via environment variables.
9
+ #
10
+ # @example Enable debug logging
11
+ # ENV["MARKLY_MERGE_DEBUG"] = "1"
12
+ # DebugLogger.debug("Parsing markdown", { file: "README.md" })
13
+ #
14
+ # @example Time an operation
15
+ # result = DebugLogger.time("parse") { Markly.parse(source) }
16
+ #
17
+ # @see Ast::Merge::DebugLogger Base module
18
+ module DebugLogger
19
+ extend Ast::Merge::DebugLogger
20
+
21
+ # Configure for markly-merge
22
+ self.env_var_name = "MARKLY_MERGE_DEBUG"
23
+ self.log_prefix = "[markly-merge]"
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markly
4
+ module Merge
5
+ # File analysis for Markdown files using Markly.
6
+ #
7
+ # This is a thin wrapper around Markdown::Merge::FileAnalysis that:
8
+ # - Forces the :markly backend
9
+ # - Sets the default freeze token to "markly-merge"
10
+ # - Exposes markly-specific options (flags, extensions)
11
+ #
12
+ # @example Basic usage
13
+ # analysis = FileAnalysis.new(markdown_source)
14
+ # analysis.statements.each do |node|
15
+ # puts "#{node.merge_type}: #{node.type}"
16
+ # end
17
+ #
18
+ # @example With custom freeze token
19
+ # analysis = FileAnalysis.new(source, freeze_token: "my-merge")
20
+ #
21
+ # @see Markdown::Merge::FileAnalysis Underlying implementation
22
+ class FileAnalysis < Markdown::Merge::FileAnalysis
23
+ # Default freeze token for markly-merge
24
+ # @return [String]
25
+ DEFAULT_FREEZE_TOKEN = "markly-merge"
26
+
27
+ # Initialize file analysis with Markly backend.
28
+ #
29
+ # @param source [String] Markdown source code to analyze
30
+ # @param freeze_token [String] Token for freeze block markers (default: "markly-merge")
31
+ # @param signature_generator [Proc, nil] Custom signature generator
32
+ # @param flags [Integer] Markly parse flags (e.g., Markly::FOOTNOTES | Markly::SMART)
33
+ # @param extensions [Array<Symbol>] Markly extensions to enable (e.g., [:table, :strikethrough])
34
+ def initialize(source, freeze_token: DEFAULT_FREEZE_TOKEN, signature_generator: nil, flags: ::Markly::DEFAULT, extensions: [:table])
35
+ super(
36
+ source,
37
+ backend: :markly,
38
+ freeze_token: freeze_token,
39
+ signature_generator: signature_generator,
40
+ flags: flags,
41
+ extensions: extensions,
42
+ )
43
+ end
44
+
45
+ # Returns the FreezeNode class to use.
46
+ #
47
+ # @return [Class] Markly::Merge::FreezeNode
48
+ def freeze_node_class
49
+ FreezeNode
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markly
4
+ module Merge
5
+ # Represents a frozen block of Markdown content that should be preserved during merges.
6
+ #
7
+ # Inherits from Markdown::Merge::FreezeNode which provides the generic
8
+ # freeze block handling.
9
+ #
10
+ # Freeze blocks are marked with HTML comments:
11
+ # <!-- markly-merge:freeze -->
12
+ # ... frozen content ...
13
+ # <!-- markly-merge:unfreeze -->
14
+ #
15
+ # @example Basic freeze block
16
+ # <!-- markly-merge:freeze -->
17
+ # ## Custom Section
18
+ # This content will not be modified by merge operations.
19
+ # <!-- markly-merge:unfreeze -->
20
+ #
21
+ # @example Freeze block with reason
22
+ # <!-- markly-merge:freeze Manual TOC -->
23
+ # ## Table of Contents
24
+ # - [Introduction](#introduction)
25
+ # - [Usage](#usage)
26
+ # <!-- markly-merge:unfreeze -->
27
+ #
28
+ # @see Markdown::Merge::FreezeNode
29
+ class FreezeNode < Markdown::Merge::FreezeNode
30
+ end
31
+ end
32
+ end