ace-lint 0.25.0 → 0.27.4
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 +4 -4
- data/.ace-defaults/lint/config.yml +3 -2
- data/CHANGELOG.md +84 -0
- data/README.md +4 -2
- data/lib/ace/lint/cli/commands/lint.rb +71 -8
- data/lib/ace/lint/molecules/kramdown_formatter.rb +76 -9
- data/lib/ace/lint/molecules/markdown_linter.rb +171 -50
- data/lib/ace/lint/molecules/markdown_surgical_fixer.rb +247 -0
- data/lib/ace/lint/organisms/auto_fix_orchestrator.rb +382 -0
- data/lib/ace/lint/organisms/lint_orchestrator.rb +35 -3
- data/lib/ace/lint/version.rb +1 -1
- data/lib/ace/lint.rb +1 -0
- metadata +11 -9
|
@@ -22,10 +22,10 @@ module Ace
|
|
|
22
22
|
# Fenced code block pattern (``` or ~~~, with optional up to 3 leading spaces per CommonMark)
|
|
23
23
|
# Captures the fence character and length for proper matching
|
|
24
24
|
FENCE_PATTERN = /^(\s{0,3})(`{3,}|~{3,})/
|
|
25
|
-
# Markdown link pattern [text](url) - captures link text for typography checking
|
|
26
|
-
LINK_PATTERN = /\[([^\]]*)\]\([^)]*\)/
|
|
27
25
|
# Inline code pattern - handles single and double backtick spans
|
|
28
26
|
INLINE_CODE_PATTERN = /``[^`]+``|`[^`]+`/
|
|
27
|
+
HEADING_PATTERN = /^\#{1,6}\s+\S/
|
|
28
|
+
UNORDERED_LIST_PATTERN = /^[*-]\s+\S/
|
|
29
29
|
# Validate markdown file
|
|
30
30
|
# @param file_path [String] Path to markdown file
|
|
31
31
|
# @param options [Hash] Kramdown options
|
|
@@ -93,53 +93,77 @@ module Ace
|
|
|
93
93
|
def self.check_markdown_style(content)
|
|
94
94
|
warnings = []
|
|
95
95
|
lines = content.lines
|
|
96
|
-
|
|
97
|
-
|
|
96
|
+
each_fence_aware_line(lines) do |line:, index:, in_code_block:, transition:|
|
|
97
|
+
idx = index
|
|
98
98
|
line_num = idx + 1
|
|
99
|
+
prev_line = idx.positive? ? lines[idx - 1] : nil
|
|
99
100
|
next_line = lines[idx + 1]
|
|
100
101
|
|
|
101
|
-
|
|
102
|
-
|
|
102
|
+
case transition
|
|
103
|
+
when :open
|
|
104
|
+
if prev_line && !prev_line.strip.empty?
|
|
105
|
+
warnings << Models::ValidationError.new(
|
|
106
|
+
line: line_num,
|
|
107
|
+
message: "Missing blank line before code block",
|
|
108
|
+
severity: :warning
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
next
|
|
112
|
+
when :close
|
|
113
|
+
if next_line && !next_line.strip.empty?
|
|
114
|
+
warnings << Models::ValidationError.new(
|
|
115
|
+
line: line_num + 1,
|
|
116
|
+
message: "Missing blank line after code block",
|
|
117
|
+
severity: :warning
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
next
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Skip style checks inside fenced code blocks.
|
|
124
|
+
next if in_code_block
|
|
125
|
+
|
|
126
|
+
# Check: trailing whitespace
|
|
127
|
+
if line_has_trailing_whitespace?(line)
|
|
103
128
|
warnings << Models::ValidationError.new(
|
|
104
129
|
line: line_num,
|
|
105
|
-
message: "
|
|
130
|
+
message: "Trailing whitespace found",
|
|
106
131
|
severity: :warning
|
|
107
132
|
)
|
|
108
133
|
end
|
|
109
134
|
|
|
110
|
-
# Check: blank line before
|
|
111
|
-
|
|
112
|
-
if line.match?(/^[*-]\s+\S/) && prev_line && !prev_line.strip.empty? && !prev_line.match?(/^[*-]\s+\S/)
|
|
135
|
+
# Check: blank line before headers
|
|
136
|
+
if line.match?(HEADING_PATTERN) && prev_line && !prev_line.strip.empty?
|
|
113
137
|
warnings << Models::ValidationError.new(
|
|
114
138
|
line: line_num,
|
|
115
|
-
message: "Missing blank line before
|
|
139
|
+
message: "Missing blank line before heading",
|
|
116
140
|
severity: :warning
|
|
117
141
|
)
|
|
118
142
|
end
|
|
119
143
|
|
|
120
|
-
# Check: blank line after
|
|
121
|
-
if
|
|
144
|
+
# Check: blank line after headers
|
|
145
|
+
if line.match?(HEADING_PATTERN) && next_line && !next_line.strip.empty?
|
|
122
146
|
warnings << Models::ValidationError.new(
|
|
123
147
|
line: line_num,
|
|
124
|
-
message: "Missing blank line after
|
|
148
|
+
message: "Missing blank line after heading",
|
|
125
149
|
severity: :warning
|
|
126
150
|
)
|
|
127
151
|
end
|
|
128
152
|
|
|
129
|
-
# Check: blank line before
|
|
130
|
-
if line.match?(
|
|
153
|
+
# Check: blank line before lists (unless first line or after another list item)
|
|
154
|
+
if line.match?(UNORDERED_LIST_PATTERN) && prev_line && !prev_line.strip.empty? && !prev_line.match?(UNORDERED_LIST_PATTERN)
|
|
131
155
|
warnings << Models::ValidationError.new(
|
|
132
156
|
line: line_num,
|
|
133
|
-
message: "Missing blank line before
|
|
157
|
+
message: "Missing blank line before list",
|
|
134
158
|
severity: :warning
|
|
135
159
|
)
|
|
136
160
|
end
|
|
137
161
|
|
|
138
|
-
# Check: blank line after
|
|
139
|
-
if prev_line&.match?(
|
|
162
|
+
# Check: blank line after lists
|
|
163
|
+
if prev_line&.match?(UNORDERED_LIST_PATTERN) && !line.match?(UNORDERED_LIST_PATTERN) && !line.strip.empty?
|
|
140
164
|
warnings << Models::ValidationError.new(
|
|
141
165
|
line: line_num,
|
|
142
|
-
message: "Missing blank line after
|
|
166
|
+
message: "Missing blank line after list",
|
|
143
167
|
severity: :warning
|
|
144
168
|
)
|
|
145
169
|
end
|
|
@@ -156,6 +180,11 @@ module Ace
|
|
|
156
180
|
warnings
|
|
157
181
|
end
|
|
158
182
|
|
|
183
|
+
def self.line_has_trailing_whitespace?(line)
|
|
184
|
+
line_without_newline = line.sub(/\r?\n\z/, "")
|
|
185
|
+
line_without_newline.match?(/[ \t]+\z/)
|
|
186
|
+
end
|
|
187
|
+
|
|
159
188
|
def self.strip_frontmatter(content)
|
|
160
189
|
extraction = Atoms::FrontmatterExtractor.extract(content)
|
|
161
190
|
return content unless extraction[:has_frontmatter]
|
|
@@ -164,6 +193,44 @@ module Ace
|
|
|
164
193
|
("\n" * frontmatter_lines) + extraction[:body].to_s
|
|
165
194
|
end
|
|
166
195
|
|
|
196
|
+
def self.each_markdown_link(text)
|
|
197
|
+
return enum_for(:each_markdown_link, text) unless block_given?
|
|
198
|
+
|
|
199
|
+
cursor = 0
|
|
200
|
+
while (open_bracket = text.index("[", cursor))
|
|
201
|
+
close_bracket = find_matching_closer(text, open_bracket, "[", "]")
|
|
202
|
+
if close_bracket && text[close_bracket + 1] == "("
|
|
203
|
+
close_paren = find_matching_closer(text, close_bracket + 1, "(", ")")
|
|
204
|
+
if close_paren
|
|
205
|
+
yield(
|
|
206
|
+
start: open_bracket,
|
|
207
|
+
end_exclusive: close_paren + 1,
|
|
208
|
+
link_text: text[(open_bracket + 1)...close_bracket],
|
|
209
|
+
destination: text[(close_bracket + 2)...close_paren]
|
|
210
|
+
)
|
|
211
|
+
cursor = close_paren + 1
|
|
212
|
+
next
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
cursor = open_bracket + 1
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def self.strip_link_markup(text)
|
|
221
|
+
output = +""
|
|
222
|
+
cursor = 0
|
|
223
|
+
|
|
224
|
+
each_markdown_link(text) do |link|
|
|
225
|
+
output << text[cursor...link[:start]]
|
|
226
|
+
output << link[:link_text]
|
|
227
|
+
cursor = link[:end_exclusive]
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
output << (text[cursor..] || "")
|
|
231
|
+
output
|
|
232
|
+
end
|
|
233
|
+
|
|
167
234
|
# Check typography issues (em-dashes, smart quotes)
|
|
168
235
|
# Skips content inside fenced code blocks and inline code
|
|
169
236
|
# @param content [String] Markdown content
|
|
@@ -179,41 +246,15 @@ module Ace
|
|
|
179
246
|
return issues if em_dash_severity == "off" && smart_quotes_severity == "off"
|
|
180
247
|
|
|
181
248
|
lines = content.lines
|
|
182
|
-
in_code_block = false
|
|
183
|
-
fence_char = nil
|
|
184
|
-
fence_length = 0
|
|
185
249
|
|
|
186
|
-
lines
|
|
187
|
-
line_num =
|
|
250
|
+
each_fence_aware_line(lines) do |line:, index:, in_code_block:, transition:|
|
|
251
|
+
line_num = index + 1
|
|
188
252
|
|
|
189
|
-
|
|
190
|
-
if (match = line.match(FENCE_PATTERN))
|
|
191
|
-
current_fence_char = match[2][0] # First char (` or ~)
|
|
192
|
-
current_fence_length = match[2].length
|
|
193
|
-
|
|
194
|
-
if in_code_block
|
|
195
|
-
# Only close if same char and at least same length
|
|
196
|
-
if current_fence_char == fence_char && current_fence_length >= fence_length
|
|
197
|
-
in_code_block = false
|
|
198
|
-
fence_char = nil
|
|
199
|
-
fence_length = 0
|
|
200
|
-
end
|
|
201
|
-
else
|
|
202
|
-
# Opening fence
|
|
203
|
-
in_code_block = true
|
|
204
|
-
fence_char = current_fence_char
|
|
205
|
-
fence_length = current_fence_length
|
|
206
|
-
end
|
|
207
|
-
next
|
|
208
|
-
end
|
|
209
|
-
|
|
210
|
-
# Skip lines inside code blocks
|
|
211
|
-
next if in_code_block
|
|
253
|
+
next if transition || in_code_block
|
|
212
254
|
|
|
213
255
|
# Remove inline code spans (handles both single and double backticks)
|
|
214
256
|
# Then remove link markup but keep link text for checking
|
|
215
|
-
line_without_code = line.gsub(INLINE_CODE_PATTERN, "")
|
|
216
|
-
.gsub(LINK_PATTERN, '\1')
|
|
257
|
+
line_without_code = strip_link_markup(line.gsub(INLINE_CODE_PATTERN, ""))
|
|
217
258
|
|
|
218
259
|
# Check for em-dashes
|
|
219
260
|
if em_dash_severity != "off" && line_without_code.include?(EM_DASH)
|
|
@@ -243,6 +284,86 @@ module Ace
|
|
|
243
284
|
|
|
244
285
|
issues
|
|
245
286
|
end
|
|
287
|
+
|
|
288
|
+
def self.each_fence_aware_line(lines)
|
|
289
|
+
return enum_for(:each_fence_aware_line, lines) unless block_given?
|
|
290
|
+
|
|
291
|
+
in_code_block = false
|
|
292
|
+
fence_char = nil
|
|
293
|
+
fence_length = 0
|
|
294
|
+
|
|
295
|
+
lines.each_with_index do |line, idx|
|
|
296
|
+
transition = nil
|
|
297
|
+
opened_fence_char = nil
|
|
298
|
+
opened_fence_length = nil
|
|
299
|
+
|
|
300
|
+
if (match = line.match(FENCE_PATTERN))
|
|
301
|
+
current_fence_char = match[2][0]
|
|
302
|
+
current_fence_length = match[2].length
|
|
303
|
+
|
|
304
|
+
if in_code_block
|
|
305
|
+
transition = :close if current_fence_char == fence_char && current_fence_length >= fence_length
|
|
306
|
+
else
|
|
307
|
+
transition = :open
|
|
308
|
+
opened_fence_char = current_fence_char
|
|
309
|
+
opened_fence_length = current_fence_length
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
yield(
|
|
314
|
+
line: line,
|
|
315
|
+
index: idx,
|
|
316
|
+
in_code_block: in_code_block,
|
|
317
|
+
transition: transition
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
case transition
|
|
321
|
+
when :open
|
|
322
|
+
in_code_block = true
|
|
323
|
+
fence_char = opened_fence_char
|
|
324
|
+
fence_length = opened_fence_length
|
|
325
|
+
when :close
|
|
326
|
+
in_code_block = false
|
|
327
|
+
fence_char = nil
|
|
328
|
+
fence_length = 0
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def self.find_matching_closer(text, opener_index, opener_char, closer_char)
|
|
334
|
+
depth = 0
|
|
335
|
+
idx = opener_index
|
|
336
|
+
|
|
337
|
+
while idx < text.length
|
|
338
|
+
char = text[idx]
|
|
339
|
+
if escaped_character?(text, idx)
|
|
340
|
+
idx += 1
|
|
341
|
+
next
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
if char == opener_char
|
|
345
|
+
depth += 1
|
|
346
|
+
elsif char == closer_char
|
|
347
|
+
depth -= 1
|
|
348
|
+
return idx if depth.zero?
|
|
349
|
+
end
|
|
350
|
+
idx += 1
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
nil
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def self.escaped_character?(text, index)
|
|
357
|
+
backslashes = 0
|
|
358
|
+
idx = index - 1
|
|
359
|
+
|
|
360
|
+
while idx >= 0 && text[idx] == "\\"
|
|
361
|
+
backslashes += 1
|
|
362
|
+
idx -= 1
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
backslashes.odd?
|
|
366
|
+
end
|
|
246
367
|
end
|
|
247
368
|
end
|
|
248
369
|
end
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../atoms/frontmatter_extractor"
|
|
4
|
+
require_relative "markdown_linter"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Lint
|
|
8
|
+
module Molecules
|
|
9
|
+
# Applies low-risk, line-level markdown fixes without re-serializing the document.
|
|
10
|
+
class MarkdownSurgicalFixer
|
|
11
|
+
SMART_QUOTE_REPLACEMENTS = {
|
|
12
|
+
"\u201C" => '"',
|
|
13
|
+
"\u201D" => '"',
|
|
14
|
+
"\u2018" => "'",
|
|
15
|
+
"\u2019" => "'"
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
def fix_file(file_path)
|
|
20
|
+
content = File.binread(file_path).dup.force_encoding(Encoding::UTF_8)
|
|
21
|
+
unless content.valid_encoding?
|
|
22
|
+
return {
|
|
23
|
+
success: true,
|
|
24
|
+
formatted: false,
|
|
25
|
+
errors: [],
|
|
26
|
+
warnings: ["Skipped non-UTF8 file: #{file_path}"]
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
result = fix_content(content)
|
|
31
|
+
return {success: false, formatted: false, errors: result[:errors], warnings: result[:warnings]} unless result[:success]
|
|
32
|
+
|
|
33
|
+
File.write(file_path, result[:formatted_content]) if result[:formatted]
|
|
34
|
+
|
|
35
|
+
{
|
|
36
|
+
success: true,
|
|
37
|
+
formatted: result[:formatted],
|
|
38
|
+
errors: [],
|
|
39
|
+
warnings: result[:warnings]
|
|
40
|
+
}
|
|
41
|
+
rescue Errno::ENOENT
|
|
42
|
+
{success: false, formatted: false, errors: ["File not found: #{file_path}"], warnings: []}
|
|
43
|
+
rescue => e
|
|
44
|
+
{success: false, formatted: false, errors: ["Error fixing file: #{e.message}"], warnings: []}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def fix_content(content)
|
|
48
|
+
extraction = Atoms::FrontmatterExtractor.extract(content)
|
|
49
|
+
if extraction[:has_frontmatter]
|
|
50
|
+
body = extraction[:body].to_s
|
|
51
|
+
# FrontmatterExtractor returns body as the exact suffix after frontmatter delimiters.
|
|
52
|
+
frontmatter_prefix = content[0...(content.length - body.length)]
|
|
53
|
+
else
|
|
54
|
+
body = content
|
|
55
|
+
frontmatter_prefix = ""
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
fixed_body = apply_body_fixes(body)
|
|
59
|
+
formatted_content = frontmatter_prefix + fixed_body
|
|
60
|
+
|
|
61
|
+
{
|
|
62
|
+
success: true,
|
|
63
|
+
formatted: formatted_content != content,
|
|
64
|
+
formatted_content: formatted_content,
|
|
65
|
+
errors: [],
|
|
66
|
+
warnings: []
|
|
67
|
+
}
|
|
68
|
+
rescue => e
|
|
69
|
+
{
|
|
70
|
+
success: false,
|
|
71
|
+
formatted: false,
|
|
72
|
+
formatted_content: content,
|
|
73
|
+
errors: ["Error fixing markdown content: #{e.message}"],
|
|
74
|
+
warnings: []
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def apply_body_fixes(body)
|
|
81
|
+
return body if body.empty?
|
|
82
|
+
|
|
83
|
+
lines = body.lines
|
|
84
|
+
transformed = transform_lines(lines)
|
|
85
|
+
with_spacing_fixes = apply_spacing_rules(transformed)
|
|
86
|
+
fixed_body = with_spacing_fixes.join
|
|
87
|
+
ensure_trailing_newline(fixed_body)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def transform_lines(lines)
|
|
91
|
+
transformed = []
|
|
92
|
+
MarkdownLinter.each_fence_aware_line(lines) do |line:, in_code_block:, transition:, **|
|
|
93
|
+
case transition
|
|
94
|
+
when :open
|
|
95
|
+
transformed << trim_trailing_whitespace(line)
|
|
96
|
+
next
|
|
97
|
+
when :close
|
|
98
|
+
transformed << line
|
|
99
|
+
next
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
if in_code_block
|
|
103
|
+
transformed << line
|
|
104
|
+
next
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
normalized_line = trim_trailing_whitespace(line)
|
|
108
|
+
transformed << replace_typography_outside_inline_code(normalized_line)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
transformed
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def apply_spacing_rules(lines)
|
|
115
|
+
output = []
|
|
116
|
+
MarkdownLinter.each_fence_aware_line(lines) do |line:, index:, in_code_block:, transition:|
|
|
117
|
+
idx = index
|
|
118
|
+
next_line = lines[idx + 1]
|
|
119
|
+
previous_line = output.last
|
|
120
|
+
|
|
121
|
+
case transition
|
|
122
|
+
when :open
|
|
123
|
+
ensure_blank_line_before(output, previous_line || line)
|
|
124
|
+
output << line
|
|
125
|
+
next
|
|
126
|
+
when :close
|
|
127
|
+
output << line
|
|
128
|
+
ensure_blank_line_after(output, line) if next_line && !blank_line?(next_line)
|
|
129
|
+
next
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
if in_code_block
|
|
133
|
+
output << line
|
|
134
|
+
next
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
if line.match?(MarkdownLinter::HEADING_PATTERN)
|
|
138
|
+
ensure_blank_line_before(output, previous_line || line)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
if line.match?(MarkdownLinter::UNORDERED_LIST_PATTERN) &&
|
|
142
|
+
previous_line &&
|
|
143
|
+
!blank_line?(previous_line) &&
|
|
144
|
+
!previous_line.match?(MarkdownLinter::UNORDERED_LIST_PATTERN)
|
|
145
|
+
ensure_blank_line_before(output, previous_line)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
if previous_line&.match?(MarkdownLinter::UNORDERED_LIST_PATTERN) &&
|
|
149
|
+
!line.match?(MarkdownLinter::UNORDERED_LIST_PATTERN) &&
|
|
150
|
+
!blank_line?(line)
|
|
151
|
+
ensure_blank_line_before(output, previous_line)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
output << line
|
|
155
|
+
|
|
156
|
+
if line.match?(MarkdownLinter::HEADING_PATTERN) && next_line && !blank_line?(next_line)
|
|
157
|
+
ensure_blank_line_after(output, line)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
output
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def replace_typography_outside_inline_code(line)
|
|
165
|
+
body, line_ending = split_line_ending(line)
|
|
166
|
+
result = +""
|
|
167
|
+
cursor = 0
|
|
168
|
+
|
|
169
|
+
body.to_enum(:scan, MarkdownLinter::INLINE_CODE_PATTERN).each do
|
|
170
|
+
match = Regexp.last_match
|
|
171
|
+
result << replace_typography_outside_links(body[cursor...match.begin(0)])
|
|
172
|
+
result << match[0]
|
|
173
|
+
cursor = match.end(0)
|
|
174
|
+
end
|
|
175
|
+
result << replace_typography_outside_links(body[cursor..] || "")
|
|
176
|
+
|
|
177
|
+
result + line_ending
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def replace_typography_outside_links(text)
|
|
181
|
+
result = +""
|
|
182
|
+
cursor = 0
|
|
183
|
+
|
|
184
|
+
MarkdownLinter.each_markdown_link(text) do |link|
|
|
185
|
+
result << normalize_typography(text[cursor...link[:start]])
|
|
186
|
+
link_text = normalize_typography(link[:link_text])
|
|
187
|
+
result << "[#{link_text}](#{link[:destination]})"
|
|
188
|
+
cursor = link[:end_exclusive]
|
|
189
|
+
end
|
|
190
|
+
result << normalize_typography(text[cursor..] || "")
|
|
191
|
+
|
|
192
|
+
result
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def normalize_typography(text)
|
|
196
|
+
normalized = text.gsub(MarkdownLinter::EM_DASH, "--")
|
|
197
|
+
SMART_QUOTE_REPLACEMENTS.each do |source, target|
|
|
198
|
+
normalized = normalized.gsub(source, target)
|
|
199
|
+
end
|
|
200
|
+
normalized
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def trim_trailing_whitespace(line)
|
|
204
|
+
body, line_ending = split_line_ending(line)
|
|
205
|
+
body.sub(/[ \t]+\z/, "") + line_ending
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def split_line_ending(line)
|
|
209
|
+
if line.end_with?("\r\n")
|
|
210
|
+
[line[0...-2], "\r\n"]
|
|
211
|
+
elsif line.end_with?("\n")
|
|
212
|
+
[line[0...-1], "\n"]
|
|
213
|
+
else
|
|
214
|
+
[line, ""]
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def blank_line?(line)
|
|
219
|
+
line.nil? || line.strip.empty?
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def ensure_blank_line_before(lines, reference_line)
|
|
223
|
+
return if lines.empty? || blank_line?(lines.last)
|
|
224
|
+
|
|
225
|
+
lines << blank_line_for(reference_line)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def ensure_blank_line_after(lines, reference_line)
|
|
229
|
+
return if lines.empty? || blank_line?(lines.last)
|
|
230
|
+
|
|
231
|
+
lines << blank_line_for(reference_line)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def blank_line_for(reference_line)
|
|
235
|
+
reference_line&.end_with?("\r\n") ? "\r\n" : "\n"
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def ensure_trailing_newline(content)
|
|
239
|
+
return content if content.empty? || content.end_with?("\n")
|
|
240
|
+
|
|
241
|
+
"#{content}\n"
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|