varar-core 0.5.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.
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Varar
4
+ module Core
5
+ # Split a block of plain text into sentences on . ! ? and \n, skipping
6
+ # terminators inside backtick spans and double-quoted strings and treating
7
+ # common abbreviations as non-breaking. All offsets are UTF-16 code units
8
+ # into the block text. Port of sentences.ts.
9
+ Sentence = Data.define(:text, :start_offset, :end_offset)
10
+
11
+ module Sentences
12
+ ABBREVIATIONS = Set.new(['e.g.', 'i.e.', 'etc.', 'cf.', 'vs.']).freeze
13
+
14
+ module_function
15
+
16
+ def split_sentences(text)
17
+ cp_to_u16 = build_cp_to_u16(text)
18
+ n = text.length
19
+ out = []
20
+
21
+ # Mark no-split zones (backtick spans, double-quoted strings).
22
+ skip = Array.new(n, false)
23
+ j = 0
24
+ while j < n
25
+ c = text[j]
26
+ if ['`', '"'].include?(c)
27
+ close = text.index(c, j + 1)
28
+ break if close.nil?
29
+
30
+ (j..close).each { |k| skip[k] = true }
31
+ j = close + 1
32
+ next
33
+ end
34
+ j += 1
35
+ end
36
+
37
+ i = 0
38
+ segment_start = 0
39
+ while i < n
40
+ if skip[i]
41
+ i += 1
42
+ next
43
+ end
44
+ ch = text[i]
45
+ if ["\n", '.', '!', '?'].include?(ch)
46
+ if ch == '.' && inside_number_or_abbrev?(text, i)
47
+ i += 1
48
+ next
49
+ end
50
+ stop = i + 1
51
+ push_segment(out, text, segment_start, stop, cp_to_u16)
52
+ i = stop
53
+ i += 1 while i < n && [' ', "\n"].include?(text[i])
54
+ segment_start = i
55
+ next
56
+ end
57
+ i += 1
58
+ end
59
+
60
+ push_segment(out, text, segment_start, n, cp_to_u16)
61
+ out
62
+ end
63
+
64
+ # cp_to_u16[cp_i] is the UTF-16 offset of text[cp_i].
65
+ def build_cp_to_u16(text)
66
+ result = Array.new(text.length + 1, 0)
67
+ u16 = 0
68
+ text.each_char.with_index do |ch, i|
69
+ result[i] = u16
70
+ u16 += ch.ord > 0xFFFF ? 2 : 1
71
+ end
72
+ result[text.length] = u16
73
+ result
74
+ end
75
+
76
+ def inside_number_or_abbrev?(text, dot_pos)
77
+ prev = dot_pos.positive? ? text[dot_pos - 1] : ''
78
+ nxt = dot_pos + 1 < text.length ? text[dot_pos + 1] : ''
79
+ return true if digit?(prev) && digit?(nxt)
80
+
81
+ ABBREVIATIONS.each do |abbrev|
82
+ start = [0, dot_pos + 1 - abbrev.length].max
83
+ return true if text[start...(dot_pos + 1)] == abbrev
84
+ end
85
+ lower?(nxt)
86
+ end
87
+
88
+ def push_segment(out, text, start_cp, end_cp, cp_to_u16)
89
+ return if end_cp <= start_cp
90
+
91
+ raw = text[start_cp...end_cp]
92
+ stripped = raw.strip
93
+ return if stripped.empty?
94
+
95
+ lead = raw.length - raw.lstrip.length
96
+ trail = raw.length - raw.rstrip.length
97
+ out << Sentence.new(
98
+ text: stripped,
99
+ start_offset: cp_to_u16[start_cp + lead],
100
+ end_offset: cp_to_u16[end_cp - trail]
101
+ )
102
+ end
103
+
104
+ def digit?(ch)
105
+ !ch.empty? && ch.match?(/[0-9]/)
106
+ end
107
+
108
+ # Unicode-aware "is a lowercase letter": has case and is already lower.
109
+ def lower?(ch)
110
+ !ch.empty? && ch != ch.upcase && ch == ch.downcase
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Varar
4
+ module Core
5
+ # A source span. Offsets and columns are **UTF-16 code units** (an astral
6
+ # character like 😀 counts as 2), matching the goldens and LSP's default
7
+ # position encoding. Lines/cols are 1-based.
8
+ Span = Data.define(
9
+ :start_offset, :end_offset,
10
+ :start_line, :start_col,
11
+ :end_line, :end_col
12
+ )
13
+
14
+ # UTF-16 offset conversion. Ruby strings are code-point indexed, so the
15
+ # whole core converts to/from UTF-16 code units here (the single riskiest
16
+ # part of the port — mirrors Python's span.py). Reused by the matcher and
17
+ # by hash.rb.
18
+ module Offsets
19
+ module_function
20
+
21
+ # UTF-16 code-unit length of a string (astral chars count as 2).
22
+ def utf16_len(str)
23
+ n = 0
24
+ str.each_char { |ch| n += ch.ord > 0xFFFF ? 2 : 1 }
25
+ n
26
+ end
27
+
28
+ # UTF-16 offset of the code-point index `cp_index` in `source`.
29
+ def to_utf16_offset(source, cp_index)
30
+ utf16_len(source[0...cp_index])
31
+ end
32
+
33
+ # Inverse of to_utf16_offset: the code-point index at a UTF-16 offset.
34
+ def cp_index_for_utf16(source, u16)
35
+ count = 0
36
+ source.each_char.with_index do |ch, i|
37
+ return i if count >= u16
38
+
39
+ count += ch.ord > 0xFFFF ? 2 : 1
40
+ end
41
+ source.length
42
+ end
43
+
44
+ # Slice `source` by UTF-16 offsets, returning the covered substring.
45
+ def utf16_slice(source, start_u16, end_u16)
46
+ a = cp_index_for_utf16(source, start_u16)
47
+ b = cp_index_for_utf16(source, end_u16)
48
+ source[a...b]
49
+ end
50
+
51
+ # 1-based [line, col] at a UTF-16 offset; col counts UTF-16 units and
52
+ # resets to 1 after each newline (mirrors span.ts's lineCol).
53
+ def line_col(source, offset_u16)
54
+ line = 1
55
+ col = 1
56
+ count = 0
57
+ source.each_char do |ch|
58
+ break if count >= offset_u16
59
+
60
+ width = ch.ord > 0xFFFF ? 2 : 1
61
+ if ch == "\n"
62
+ line += 1
63
+ col = 1
64
+ else
65
+ col += width
66
+ end
67
+ count += width
68
+ end
69
+ [line, col]
70
+ end
71
+
72
+ # Build a Span from UTF-16 start/end offsets.
73
+ def span_from_offsets(source, start_u16, end_u16)
74
+ start_line, start_col = line_col(source, start_u16)
75
+ end_line, end_col = line_col(source, end_u16)
76
+ Span.new(
77
+ start_offset: start_u16, end_offset: end_u16,
78
+ start_line: start_line, start_col: start_col,
79
+ end_line: end_line, end_col: end_col
80
+ )
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Varar
4
+ module Core
5
+ # The role a step definition plays:
6
+ # "stimulus" — drives the software (arranges and acts on state)
7
+ # "sensor" — the read-only assertion (the only role that returns for
8
+ # comparison)
9
+ # Purely structural — never inspects sentence words (no Given/When/Then
10
+ # heuristics). Port of step-role.ts.
11
+ module StepRole
12
+ module_function
13
+
14
+ # Guess a step's role from its document-order neighbours. A step with
15
+ # nothing after it is most likely the observation; anything followed by
16
+ # other steps is most likely driving the software.
17
+ def infer_step_role(neighbours)
18
+ after = neighbours[:after] || neighbours['after'] || []
19
+ after.empty? ? 'sensor' : 'stimulus'
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'varar/core/span'
4
+ require 'varar/core/ast'
5
+
6
+ module Varar
7
+ module Core
8
+ # Group scanned blocks into Examples, tracking heading scope and orphan
9
+ # attachments. Port of structurer.ts.
10
+ module Structurer
11
+ module_function
12
+
13
+ def structure(path, source, blocks)
14
+ examples = []
15
+ orphan_attachments = []
16
+ scope_stack = [] # [[level, text], ...]
17
+ last_example_idx = -1
18
+ attachment_open = false
19
+
20
+ blocks.each do |block|
21
+ case block.kind
22
+ when 'heading'
23
+ # Pop deeper-or-equal-level entries before pushing the new heading.
24
+ scope_stack.pop while !scope_stack.empty? && scope_stack.last[0] >= block.level
25
+ scope_stack << [block.level, block.text]
26
+ attachment_open = false
27
+
28
+ when 'paragraph', 'list_item', 'blockquote'
29
+ # Merge a block into the previous example when that example's last
30
+ # block is an attachment (table/fence) with no blank line between.
31
+ if attachment_open && last_example_idx >= 0
32
+ prev = examples[last_example_idx]
33
+ prev_last = prev.body.last
34
+ last_is_attachment = !prev_last.nil? && %w[table fence].include?(prev_last.kind)
35
+ if last_is_attachment
36
+ between = Offsets.utf16_slice(source, prev.span.end_offset, block.span.start_offset)
37
+ unless between.match?(/\n\s*\n/)
38
+ new_span = Offsets.span_from_offsets(source, prev.span.start_offset, block.span.end_offset)
39
+ examples[last_example_idx] = Example.new(
40
+ scope_stack: prev.scope_stack,
41
+ span: new_span,
42
+ body: prev.body + [block]
43
+ )
44
+ next
45
+ end
46
+ end
47
+ end
48
+
49
+ examples << Example.new(
50
+ scope_stack: scope_stack.map { |(_, text)| text },
51
+ span: block.span,
52
+ body: [block]
53
+ )
54
+ last_example_idx = examples.length - 1
55
+ attachment_open = true
56
+
57
+ when 'table', 'fence'
58
+ if attachment_open && last_example_idx >= 0
59
+ prev = examples[last_example_idx]
60
+ new_span = Offsets.span_from_offsets(source, prev.span.start_offset, block.span.end_offset)
61
+ examples[last_example_idx] = Example.new(
62
+ scope_stack: prev.scope_stack,
63
+ span: new_span,
64
+ body: prev.body + [block]
65
+ )
66
+ else
67
+ orphan_attachments << block
68
+ end
69
+
70
+ when 'thematic_break'
71
+ attachment_open = false
72
+ end
73
+ end
74
+
75
+ VarDoc.new(
76
+ path: path,
77
+ source: source,
78
+ examples: examples,
79
+ orphan_attachments: orphan_attachments
80
+ )
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'varar/core/span'
4
+
5
+ module Varar
6
+ module Core
7
+ # Parse a Markdown/Gherkin table row into trimmed cells and per-cell source
8
+ # spans. Port of table-cells.ts. All offsets count UTF-16 code units.
9
+ module TableCells
10
+ module_function
11
+
12
+ # Split a `| a | b |` row into [cells, cell_spans]. +line_start_offset+
13
+ # is the UTF-16 offset of the row's first character within +source+.
14
+ def parse_row_cells(line_text, line_start_offset, source)
15
+ first_cp = line_text.index('|')
16
+ last_cp = line_text.rindex('|')
17
+ return [[], []] if first_cp.nil? || last_cp.nil? || last_cp <= first_cp
18
+
19
+ # Pipe positions: convert code-point index to UTF-16 (matters when
20
+ # astral chars precede the pipe). '|' is ASCII → 1 UTF-16 unit.
21
+ first_u16 = Offsets.to_utf16_offset(line_text, first_cp)
22
+ inner_start_u16 = first_u16 + 1
23
+
24
+ inner = line_text[(first_cp + 1)...last_cp]
25
+
26
+ cells = []
27
+ cell_spans = []
28
+ cursor = 0 # running UTF-16 position within inner
29
+
30
+ # split(-1) keeps trailing empty segments, matching JS/Python split.
31
+ inner.split('|', -1).each do |seg|
32
+ trimmed = seg.strip
33
+ leading = Offsets.utf16_len(seg) - Offsets.utf16_len(seg.lstrip)
34
+ abs_start = line_start_offset + inner_start_u16 + cursor + leading
35
+ cells << trimmed
36
+ cell_spans << Offsets.span_from_offsets(source, abs_start, abs_start + Offsets.utf16_len(trimmed))
37
+ cursor += Offsets.utf16_len(seg) + 1 # +1 for the '|' delimiter
38
+ end
39
+
40
+ [cells, cell_spans]
41
+ end
42
+ end
43
+ end
44
+ end
data/lib/varar/core.rb ADDED
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Varar
4
+ # The pure functional core: parse, match, plan, execute, diffs, drift, and
5
+ # the conformance projections. No filesystem, network, globals, or time.
6
+ module Core
7
+ VERSION = '0.5.0'
8
+ end
9
+ end
10
+
11
+ require 'varar/core/span'
12
+ require 'varar/core/ast'
13
+ require 'varar/core/table_cells'
14
+ require 'varar/core/scanner'
15
+ require 'varar/core/structurer'
16
+ require 'varar/core/parse'
17
+ require 'varar/core/step_role'
18
+ require 'varar/core/registry'
19
+ require 'varar/core/sentences'
20
+ require 'varar/core/diagnostics'
21
+ require 'varar/core/cell_diff'
22
+ require 'varar/core/matcher'
23
+ require 'varar/core/plan'
24
+ require 'varar/core/deep_freeze'
25
+ require 'varar/core/doc_string_diff'
26
+ require 'varar/core/param_diff'
27
+ require 'varar/core/failure_anchor'
28
+ require 'varar/core/execute'
29
+ require 'varar/core/hash'
30
+ require 'varar/core/drift'
31
+ require 'varar/core/canonical_json'
32
+ require 'varar/core/conformance'
metadata ADDED
@@ -0,0 +1,79 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: varar-core
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.5.0
5
+ platform: ruby
6
+ authors:
7
+ - Aslak Hellesøy
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: cucumber-cucumber-expressions
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - '='
17
+ - !ruby/object:Gem::Version
18
+ version: 20.0.0
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - '='
24
+ - !ruby/object:Gem::Version
25
+ version: 20.0.0
26
+ description: The pure functional pipeline (parse, match, plan, execute, drift) behind
27
+ Vár.
28
+ email:
29
+ - aslak@oselvar.com
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - lib/varar/core.rb
35
+ - lib/varar/core/ast.rb
36
+ - lib/varar/core/canonical_json.rb
37
+ - lib/varar/core/cell_diff.rb
38
+ - lib/varar/core/conformance.rb
39
+ - lib/varar/core/deep_freeze.rb
40
+ - lib/varar/core/diagnostics.rb
41
+ - lib/varar/core/doc_string_diff.rb
42
+ - lib/varar/core/drift.rb
43
+ - lib/varar/core/execute.rb
44
+ - lib/varar/core/failure_anchor.rb
45
+ - lib/varar/core/hash.rb
46
+ - lib/varar/core/matcher.rb
47
+ - lib/varar/core/param_diff.rb
48
+ - lib/varar/core/parse.rb
49
+ - lib/varar/core/plan.rb
50
+ - lib/varar/core/registry.rb
51
+ - lib/varar/core/scanner.rb
52
+ - lib/varar/core/sentences.rb
53
+ - lib/varar/core/span.rb
54
+ - lib/varar/core/step_role.rb
55
+ - lib/varar/core/structurer.rb
56
+ - lib/varar/core/table_cells.rb
57
+ homepage: https://varar.dev
58
+ licenses:
59
+ - MIT
60
+ metadata:
61
+ rubygems_mfa_required: 'true'
62
+ rdoc_options: []
63
+ require_paths:
64
+ - lib
65
+ required_ruby_version: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: '3.2'
70
+ required_rubygems_version: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ requirements: []
76
+ rubygems_version: 4.0.16
77
+ specification_version: 4
78
+ summary: Markdown-native BDD — pure functional core engine
79
+ test_files: []