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.
@@ -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
- lines.each_with_index do |line, idx|
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
- # Check: blank line after headers
102
- if line.match?(/^\#{1,6}\s+\S/) && next_line && !next_line.strip.empty?
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: "Missing blank line after heading",
130
+ message: "Trailing whitespace found",
106
131
  severity: :warning
107
132
  )
108
133
  end
109
134
 
110
- # Check: blank line before lists (unless first line or after another list item)
111
- prev_line = idx.positive? ? lines[idx - 1] : nil
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 list",
139
+ message: "Missing blank line before heading",
116
140
  severity: :warning
117
141
  )
118
142
  end
119
143
 
120
- # Check: blank line after lists
121
- if prev_line&.match?(/^[*-]\s+\S/) && !line.match?(/^[*-]\s+\S/) && !line.strip.empty?
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 list",
148
+ message: "Missing blank line after heading",
125
149
  severity: :warning
126
150
  )
127
151
  end
128
152
 
129
- # Check: blank line before code blocks
130
- if line.match?(/^```/) && prev_line && !prev_line.strip.empty?
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 code block",
157
+ message: "Missing blank line before list",
134
158
  severity: :warning
135
159
  )
136
160
  end
137
161
 
138
- # Check: blank line after code blocks (closing ```)
139
- if prev_line&.match?(/^```$/) && !line.strip.empty?
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 code block",
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.each_with_index do |line, idx|
187
- line_num = idx + 1
250
+ each_fence_aware_line(lines) do |line:, index:, in_code_block:, transition:|
251
+ line_num = index + 1
188
252
 
189
- # Track fenced code block state with proper fence matching
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