tree_sitter 0.0.1
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 +7 -0
- data/LICENSE.txt +21 -0
- data/Makefile +116 -0
- data/README.md +482 -0
- data/ext/tree_sitter/Cargo.toml +18 -0
- data/ext/tree_sitter/extconf.rb +6 -0
- data/ext/tree_sitter/src/language.rs +152 -0
- data/ext/tree_sitter/src/lib.rs +140 -0
- data/ext/tree_sitter/src/node.rs +248 -0
- data/ext/tree_sitter/src/parser.rs +126 -0
- data/ext/tree_sitter/src/point.rs +45 -0
- data/ext/tree_sitter/src/query.rs +161 -0
- data/ext/tree_sitter/src/range.rs +50 -0
- data/ext/tree_sitter/src/tree.rs +38 -0
- data/lib/tree_sitter/formatting.rb +236 -0
- data/lib/tree_sitter/inserter.rb +306 -0
- data/lib/tree_sitter/query_rewriter.rb +314 -0
- data/lib/tree_sitter/refactor.rb +214 -0
- data/lib/tree_sitter/rewriter.rb +155 -0
- data/lib/tree_sitter/transformer.rb +324 -0
- data/lib/tree_sitter/version.rb +5 -0
- data/lib/tree_sitter.rb +25 -0
- metadata +113 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
use magnus::{RArray, Ruby};
|
|
2
|
+
|
|
3
|
+
#[magnus::wrap(class = "TreeSitter::Point")]
|
|
4
|
+
#[derive(Clone)]
|
|
5
|
+
pub struct Point {
|
|
6
|
+
row: usize,
|
|
7
|
+
column: usize,
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
impl Point {
|
|
11
|
+
pub fn new(row: usize, column: usize) -> Self {
|
|
12
|
+
Self { row, column }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
pub fn from_ts(point: tree_sitter::Point) -> Self {
|
|
16
|
+
Self {
|
|
17
|
+
row: point.row,
|
|
18
|
+
column: point.column,
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
pub fn row(&self) -> usize {
|
|
23
|
+
self.row
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
pub fn column(&self) -> usize {
|
|
27
|
+
self.column
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
pub fn to_a(&self) -> RArray {
|
|
31
|
+
let ruby = Ruby::get().unwrap();
|
|
32
|
+
let array = ruby.ary_new();
|
|
33
|
+
let _ = array.push(self.row);
|
|
34
|
+
let _ = array.push(self.column);
|
|
35
|
+
array
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
pub fn inspect(&self) -> String {
|
|
39
|
+
format!("#<TreeSitter::Point row={} column={}>", self.row, self.column)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
pub fn eq(&self, other: &Point) -> bool {
|
|
43
|
+
self.row == other.row && self.column == other.column
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
use crate::language::Language;
|
|
2
|
+
use crate::node::Node;
|
|
3
|
+
use magnus::{Error, RArray, Ruby};
|
|
4
|
+
use std::cell::RefCell;
|
|
5
|
+
use streaming_iterator::StreamingIterator;
|
|
6
|
+
|
|
7
|
+
#[magnus::wrap(class = "TreeSitter::Query")]
|
|
8
|
+
pub struct Query {
|
|
9
|
+
inner: tree_sitter::Query,
|
|
10
|
+
capture_names: Vec<String>,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
impl Query {
|
|
14
|
+
pub fn new(language: &Language, source: String) -> Result<Self, Error> {
|
|
15
|
+
let ruby = Ruby::get().unwrap();
|
|
16
|
+
|
|
17
|
+
let query = tree_sitter::Query::new(&language.inner, &source).map_err(|e| {
|
|
18
|
+
Error::new(
|
|
19
|
+
ruby.exception_syntax_error(),
|
|
20
|
+
format!("Query syntax error: {}", e),
|
|
21
|
+
)
|
|
22
|
+
})?;
|
|
23
|
+
|
|
24
|
+
let capture_names = query.capture_names().iter().map(|s| s.to_string()).collect();
|
|
25
|
+
|
|
26
|
+
Ok(Self {
|
|
27
|
+
inner: query,
|
|
28
|
+
capture_names,
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
pub fn capture_names(&self) -> RArray {
|
|
33
|
+
let ruby = Ruby::get().unwrap();
|
|
34
|
+
let array = ruby.ary_new();
|
|
35
|
+
for name in &self.capture_names {
|
|
36
|
+
let _ = array.push(name.clone());
|
|
37
|
+
}
|
|
38
|
+
array
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
pub fn pattern_count(&self) -> usize {
|
|
42
|
+
self.inner.pattern_count()
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
#[magnus::wrap(class = "TreeSitter::QueryCursor")]
|
|
47
|
+
pub struct QueryCursor {
|
|
48
|
+
inner: RefCell<tree_sitter::QueryCursor>,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
impl QueryCursor {
|
|
52
|
+
pub fn new() -> Self {
|
|
53
|
+
Self {
|
|
54
|
+
inner: RefCell::new(tree_sitter::QueryCursor::new()),
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
pub fn matches(
|
|
59
|
+
&self,
|
|
60
|
+
query: &Query,
|
|
61
|
+
node: &Node,
|
|
62
|
+
source: String,
|
|
63
|
+
) -> RArray {
|
|
64
|
+
let ruby = Ruby::get().unwrap();
|
|
65
|
+
let array = ruby.ary_new();
|
|
66
|
+
let Some(ts_node) = node.get_ts_node_pub() else {
|
|
67
|
+
return array;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
let mut cursor = self.inner.borrow_mut();
|
|
71
|
+
let mut matches = cursor.matches(&query.inner, ts_node, source.as_bytes());
|
|
72
|
+
|
|
73
|
+
while let Some(m) = matches.next() {
|
|
74
|
+
let captures: Vec<QueryCapture> = m
|
|
75
|
+
.captures
|
|
76
|
+
.iter()
|
|
77
|
+
.map(|c| {
|
|
78
|
+
let capture_name = query.capture_names[c.index as usize].clone();
|
|
79
|
+
QueryCapture {
|
|
80
|
+
name: capture_name,
|
|
81
|
+
node: Node::new(c.node, node.source.clone(), node.tree.clone()),
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
.collect();
|
|
85
|
+
|
|
86
|
+
let _ = array.push(QueryMatch {
|
|
87
|
+
pattern_index: m.pattern_index,
|
|
88
|
+
captures,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
array
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
pub fn captures(
|
|
96
|
+
&self,
|
|
97
|
+
query: &Query,
|
|
98
|
+
node: &Node,
|
|
99
|
+
source: String,
|
|
100
|
+
) -> RArray {
|
|
101
|
+
let ruby = Ruby::get().unwrap();
|
|
102
|
+
let array = ruby.ary_new();
|
|
103
|
+
let Some(ts_node) = node.get_ts_node_pub() else {
|
|
104
|
+
return array;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
let mut cursor = self.inner.borrow_mut();
|
|
108
|
+
let mut captures = cursor.captures(&query.inner, ts_node, source.as_bytes());
|
|
109
|
+
|
|
110
|
+
while let Some((m, capture_index)) = captures.next() {
|
|
111
|
+
if let Some(c) = m.captures.get(*capture_index) {
|
|
112
|
+
let capture_name = query.capture_names[c.index as usize].clone();
|
|
113
|
+
let _ = array.push(QueryCapture {
|
|
114
|
+
name: capture_name,
|
|
115
|
+
node: Node::new(c.node, node.source.clone(), node.tree.clone()),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
array
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
#[magnus::wrap(class = "TreeSitter::QueryMatch")]
|
|
125
|
+
#[derive(Clone)]
|
|
126
|
+
pub struct QueryMatch {
|
|
127
|
+
pattern_index: usize,
|
|
128
|
+
captures: Vec<QueryCapture>,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
impl QueryMatch {
|
|
132
|
+
pub fn pattern_index(&self) -> usize {
|
|
133
|
+
self.pattern_index
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
pub fn captures(&self) -> RArray {
|
|
137
|
+
let ruby = Ruby::get().unwrap();
|
|
138
|
+
let array = ruby.ary_new();
|
|
139
|
+
for capture in &self.captures {
|
|
140
|
+
let _ = array.push(capture.clone());
|
|
141
|
+
}
|
|
142
|
+
array
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
#[magnus::wrap(class = "TreeSitter::QueryCapture")]
|
|
147
|
+
#[derive(Clone)]
|
|
148
|
+
pub struct QueryCapture {
|
|
149
|
+
name: String,
|
|
150
|
+
node: Node,
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
impl QueryCapture {
|
|
154
|
+
pub fn name(&self) -> String {
|
|
155
|
+
self.name.clone()
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
pub fn node(&self) -> Node {
|
|
159
|
+
self.node.clone()
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
use crate::point::Point;
|
|
2
|
+
|
|
3
|
+
#[magnus::wrap(class = "TreeSitter::Range")]
|
|
4
|
+
#[derive(Clone)]
|
|
5
|
+
pub struct Range {
|
|
6
|
+
start_byte: usize,
|
|
7
|
+
end_byte: usize,
|
|
8
|
+
start_point: Point,
|
|
9
|
+
end_point: Point,
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
impl Range {
|
|
13
|
+
pub fn new(start_byte: usize, end_byte: usize, start_point: Point, end_point: Point) -> Self {
|
|
14
|
+
Self {
|
|
15
|
+
start_byte,
|
|
16
|
+
end_byte,
|
|
17
|
+
start_point,
|
|
18
|
+
end_point,
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
pub fn start_byte(&self) -> usize {
|
|
23
|
+
self.start_byte
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
pub fn end_byte(&self) -> usize {
|
|
27
|
+
self.end_byte
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
pub fn start_point(&self) -> Point {
|
|
31
|
+
self.start_point.clone()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
pub fn end_point(&self) -> Point {
|
|
35
|
+
self.end_point.clone()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
pub fn size(&self) -> usize {
|
|
39
|
+
self.end_byte - self.start_byte
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
pub fn inspect(&self) -> String {
|
|
43
|
+
format!(
|
|
44
|
+
"#<TreeSitter::Range start_byte={} end_byte={} size={}>",
|
|
45
|
+
self.start_byte,
|
|
46
|
+
self.end_byte,
|
|
47
|
+
self.size()
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
use crate::language::{get_language_internal, Language};
|
|
2
|
+
use crate::node::Node;
|
|
3
|
+
use magnus::Error;
|
|
4
|
+
use std::sync::Arc;
|
|
5
|
+
|
|
6
|
+
#[magnus::wrap(class = "TreeSitter::Tree")]
|
|
7
|
+
pub struct Tree {
|
|
8
|
+
pub inner: Arc<tree_sitter::Tree>,
|
|
9
|
+
pub source: Arc<String>,
|
|
10
|
+
pub language_name: String,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
impl Tree {
|
|
14
|
+
pub fn new(tree: tree_sitter::Tree, source: String, language_name: String) -> Self {
|
|
15
|
+
Self {
|
|
16
|
+
inner: Arc::new(tree),
|
|
17
|
+
source: Arc::new(source),
|
|
18
|
+
language_name,
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
pub fn root_node(&self) -> Node {
|
|
23
|
+
let ts_node = self.inner.root_node();
|
|
24
|
+
Node::new(ts_node, self.source.clone(), self.inner.clone())
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
pub fn source(&self) -> String {
|
|
28
|
+
(*self.source).clone()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
pub fn language(&self) -> Result<Language, Error> {
|
|
32
|
+
let ts_lang = get_language_internal(&self.language_name)?;
|
|
33
|
+
Ok(Language {
|
|
34
|
+
name: self.language_name.clone(),
|
|
35
|
+
inner: ts_lang,
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TreeSitter
|
|
4
|
+
# Formatting utilities for syntax-aware code manipulation
|
|
5
|
+
module Formatting
|
|
6
|
+
# Detects and works with indentation in source code
|
|
7
|
+
class IndentationDetector
|
|
8
|
+
# Characters considered whitespace for indentation
|
|
9
|
+
INDENT_CHARS = [" ", "\t"].freeze
|
|
10
|
+
|
|
11
|
+
attr_reader :source, :indent_string, :indent_size, :style
|
|
12
|
+
|
|
13
|
+
# Initialize detector with source code
|
|
14
|
+
#
|
|
15
|
+
# @param source [String] The source code to analyze
|
|
16
|
+
def initialize(source)
|
|
17
|
+
@source = source
|
|
18
|
+
@lines = source.lines
|
|
19
|
+
detect
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Detect the indentation style used in the source
|
|
23
|
+
#
|
|
24
|
+
# @return [Hash] { style: :spaces|:tabs|:unknown, size: Integer, string: String }
|
|
25
|
+
def detect
|
|
26
|
+
space_indents = []
|
|
27
|
+
tab_count = 0
|
|
28
|
+
space_count = 0
|
|
29
|
+
|
|
30
|
+
@lines.each do |line|
|
|
31
|
+
next if line.strip.empty?
|
|
32
|
+
|
|
33
|
+
leading = line[/\A[ \t]*/]
|
|
34
|
+
next if leading.empty?
|
|
35
|
+
|
|
36
|
+
if leading.include?("\t")
|
|
37
|
+
tab_count += 1
|
|
38
|
+
else
|
|
39
|
+
space_count += 1
|
|
40
|
+
# Track indent sizes for space-indented lines
|
|
41
|
+
space_indents << leading.length if leading.length.positive?
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
if tab_count > space_count
|
|
46
|
+
@style = :tabs
|
|
47
|
+
@indent_size = 1
|
|
48
|
+
@indent_string = "\t"
|
|
49
|
+
elsif space_count.positive?
|
|
50
|
+
@style = :spaces
|
|
51
|
+
@indent_size = detect_space_indent_size(space_indents)
|
|
52
|
+
@indent_string = " " * @indent_size
|
|
53
|
+
else
|
|
54
|
+
# Default to 4 spaces
|
|
55
|
+
@style = :spaces
|
|
56
|
+
@indent_size = 4
|
|
57
|
+
@indent_string = " "
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
{ style: @style, size: @indent_size, string: @indent_string }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Get indentation level (count) at a specific line
|
|
64
|
+
#
|
|
65
|
+
# @param line_number [Integer] Zero-based line number
|
|
66
|
+
# @return [Integer] Number of indentation units
|
|
67
|
+
def level_at_line(line_number)
|
|
68
|
+
return 0 if line_number < 0 || line_number >= @lines.length
|
|
69
|
+
|
|
70
|
+
line = @lines[line_number]
|
|
71
|
+
leading = line[/\A[ \t]*/] || ""
|
|
72
|
+
|
|
73
|
+
return leading.count("\t") if @style == :tabs
|
|
74
|
+
|
|
75
|
+
leading.length / [@indent_size, 1].max
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Get raw indentation string at a specific line
|
|
79
|
+
#
|
|
80
|
+
# @param line_number [Integer] Zero-based line number
|
|
81
|
+
# @return [String] The indentation whitespace
|
|
82
|
+
def raw_indentation_at_line(line_number)
|
|
83
|
+
return "" if line_number < 0 || line_number >= @lines.length
|
|
84
|
+
|
|
85
|
+
line = @lines[line_number]
|
|
86
|
+
line[/\A[ \t]*/] || ""
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Get indentation string at a specific byte position
|
|
90
|
+
#
|
|
91
|
+
# @param byte_pos [Integer] Byte position in source
|
|
92
|
+
# @return [String] The indentation whitespace for that line
|
|
93
|
+
def indentation_at_byte(byte_pos)
|
|
94
|
+
line_number = byte_to_line(byte_pos)
|
|
95
|
+
raw_indentation_at_line(line_number)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Get indentation level at a specific byte position
|
|
99
|
+
#
|
|
100
|
+
# @param byte_pos [Integer] Byte position in source
|
|
101
|
+
# @return [Integer] Indentation level
|
|
102
|
+
def level_at_byte(byte_pos)
|
|
103
|
+
line_number = byte_to_line(byte_pos)
|
|
104
|
+
level_at_line(line_number)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Create indentation string for a given level
|
|
108
|
+
#
|
|
109
|
+
# @param level [Integer] Indentation level
|
|
110
|
+
# @return [String] Indentation whitespace
|
|
111
|
+
def indent_string_for_level(level)
|
|
112
|
+
return "" if level <= 0
|
|
113
|
+
|
|
114
|
+
@indent_string * level
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Adjust indentation of content to a target level
|
|
118
|
+
#
|
|
119
|
+
# @param content [String] Content to adjust
|
|
120
|
+
# @param target_level [Integer] Target indentation level
|
|
121
|
+
# @param current_level [Integer, nil] Current base level (auto-detected if nil)
|
|
122
|
+
# @return [String] Re-indented content
|
|
123
|
+
def adjust_indentation(content, target_level, current_level: nil)
|
|
124
|
+
content_lines = content.lines
|
|
125
|
+
return content if content_lines.empty?
|
|
126
|
+
|
|
127
|
+
# Auto-detect current level from first non-empty line
|
|
128
|
+
if current_level.nil?
|
|
129
|
+
first_content_line = content_lines.find { |l| !l.strip.empty? }
|
|
130
|
+
if first_content_line
|
|
131
|
+
leading = first_content_line[/\A[ \t]*/] || ""
|
|
132
|
+
current_level = if @style == :tabs
|
|
133
|
+
leading.count("\t")
|
|
134
|
+
else
|
|
135
|
+
leading.length / [@indent_size, 1].max
|
|
136
|
+
end
|
|
137
|
+
else
|
|
138
|
+
current_level = 0
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
level_diff = target_level - current_level
|
|
143
|
+
|
|
144
|
+
content_lines.map do |line|
|
|
145
|
+
if line.strip.empty?
|
|
146
|
+
line
|
|
147
|
+
else
|
|
148
|
+
leading = line[/\A[ \t]*/] || ""
|
|
149
|
+
rest = line[leading.length..]
|
|
150
|
+
|
|
151
|
+
# Calculate this line's level relative to base
|
|
152
|
+
line_level = if @style == :tabs
|
|
153
|
+
leading.count("\t")
|
|
154
|
+
else
|
|
155
|
+
leading.length / [@indent_size, 1].max
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Apply the level difference
|
|
159
|
+
new_level = [line_level + level_diff, 0].max
|
|
160
|
+
indent_string_for_level(new_level) + rest
|
|
161
|
+
end
|
|
162
|
+
end.join
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Increase indentation of all lines by one level
|
|
166
|
+
#
|
|
167
|
+
# @param content [String] Content to indent
|
|
168
|
+
# @return [String] Indented content
|
|
169
|
+
def indent(content)
|
|
170
|
+
content.lines.map do |line|
|
|
171
|
+
if line.strip.empty?
|
|
172
|
+
line
|
|
173
|
+
else
|
|
174
|
+
@indent_string + line
|
|
175
|
+
end
|
|
176
|
+
end.join
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Decrease indentation of all lines by one level
|
|
180
|
+
#
|
|
181
|
+
# @param content [String] Content to dedent
|
|
182
|
+
# @return [String] Dedented content
|
|
183
|
+
def dedent(content)
|
|
184
|
+
content.lines.map do |line|
|
|
185
|
+
if line.strip.empty?
|
|
186
|
+
line
|
|
187
|
+
elsif @style == :tabs && line.start_with?("\t")
|
|
188
|
+
line[1..]
|
|
189
|
+
elsif @style == :spaces && line.start_with?(@indent_string)
|
|
190
|
+
line[@indent_size..]
|
|
191
|
+
else
|
|
192
|
+
line
|
|
193
|
+
end
|
|
194
|
+
end.join
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
private
|
|
198
|
+
|
|
199
|
+
# Detect the most common space indent size
|
|
200
|
+
def detect_space_indent_size(indents)
|
|
201
|
+
return 4 if indents.empty?
|
|
202
|
+
|
|
203
|
+
# Find GCD of all indent sizes to determine base unit
|
|
204
|
+
differences = []
|
|
205
|
+
sorted = indents.uniq.sort
|
|
206
|
+
|
|
207
|
+
sorted.each_cons(2) do |a, b|
|
|
208
|
+
differences << (b - a)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Also consider the smallest non-zero indent
|
|
212
|
+
differences << sorted.first if sorted.first&.positive?
|
|
213
|
+
|
|
214
|
+
return 4 if differences.empty?
|
|
215
|
+
|
|
216
|
+
# Find GCD
|
|
217
|
+
gcd = differences.reduce { |a, b| a.gcd(b) }
|
|
218
|
+
gcd = 4 if gcd.nil? || gcd <= 0 || gcd > 8
|
|
219
|
+
|
|
220
|
+
gcd
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Convert byte position to line number (zero-based)
|
|
224
|
+
def byte_to_line(byte_pos)
|
|
225
|
+
current_byte = 0
|
|
226
|
+
@lines.each_with_index do |line, idx|
|
|
227
|
+
line_end = current_byte + line.bytesize
|
|
228
|
+
return idx if byte_pos < line_end
|
|
229
|
+
|
|
230
|
+
current_byte = line_end
|
|
231
|
+
end
|
|
232
|
+
[@lines.length - 1, 0].max
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|