Module: Sloplint::Engine

Defined in:
lib/sloplint/engine.rb

Constant Summary collapse

CONTEXT_CHARS =
40

Class Method Summary collapse

Class Method Details

.blank_markdown(text) ⇒ Object

Replace fenced code, inline code, and URLs with same-length whitespace so line/column stay correct. Newlines are preserved.



100
101
102
103
104
105
106
# File 'lib/sloplint/engine.rb', line 100

def blank_markdown(text)
  blank = lambda { |s| s.gsub(/[^\n]/, " ") }
  text
    .gsub(/```.*?```/m) { |s| blank.call(s) }   # fenced code
    .gsub(/`[^`\n]*`/) { |s| blank.call(s) }    # inline code
    .gsub(%r{https?://\S+}) { |s| blank.call(s) } # bare URLs
end

.context_for(source, match) ⇒ Object

The match plus ~CONTEXT_CHARS either side, bracketed, whitespace collapsed. A bare excerpt is useless for a rule whose match is one character -- see em-dash, where the note said only "—" and you had to open the file and count to the column to learn anything.

Truncated ends get an ellipsis and are trimmed back to a word boundary so the window doesn't open mid-word. A match already CONTEXT_CHARS long carries its own context; padding it just makes an em-dash-overuse span (which can run a whole paragraph) longer for no gain, so those return the match alone.

source is the PRE-blanking text. blank_markdown replaces each non-newline char with one space, so offsets are identical either way, but a window over blanked text shows code and URLs as a run of spaces. Offsets here are character offsets (MatchData#begin), matching the char-based line_starts_for.



60
61
62
63
64
65
66
67
68
69
# File 'lib/sloplint/engine.rb', line 60

def context_for(source, match)
  return "[#{match[0].gsub(/\s+/, " ").strip}]" if match[0].length >= CONTEXT_CHARS

  b, e = match.begin(0), match.end(0)
  pre  = source[[b - CONTEXT_CHARS, 0].max...b]
  post = source[e, CONTEXT_CHARS].to_s
  pre  = "#{pre.sub(/\A\S*\s+/, "")}" if b > CONTEXT_CHARS
  post = "#{post.sub(/\s+\S*\z/, "")}" if e + CONTEXT_CHARS < source.length
  "#{pre}[#{match[0]}]#{post}".gsub(/\s+/, " ").strip
end

.line_col(text, offset, line_starts: nil) ⇒ Object

1-indexed line and column for a char offset into text. Binary-searches a precomputed line_starts table (see line_starts_for) so a scan with many notes doesn't re-walk the prefix from offset 0 for every single one -- the previous version did text[0, offset] per note, which is O(n) per call and O(n * notes) overall, quadratic on a large file with many hits. line_starts is optional so this stays callable standalone.



77
78
79
80
81
# File 'lib/sloplint/engine.rb', line 77

def line_col(text, offset, line_starts: nil)
  line_starts ||= line_starts_for(text)
  idx = (line_starts.bsearch_index { |s| s > offset } || line_starts.length) - 1
  [idx + 1, offset - line_starts[idx] + 1]
end

.line_starts_for(text) ⇒ Object

Char offset where each line begins, index 0 = line 1. Computed once per scan and shared across every note instead of recomputed per note.

Built via each_line + line.length, not repeated String#index(pat, pos) calls -- on non-ASCII-only text (e.g. curly quotes, em dashes), index with a start position is not O(1)-amortized per call in CRuby, and a few thousand newlines turned this quadratic: 7.4s on a 1.2MB UTF-8 file that each_line does in 0.004s.



91
92
93
94
95
96
# File 'lib/sloplint/engine.rb', line 91

def line_starts_for(text)
  starts = [0]
  text.each_line { |line| starts << starts.last + line.length }
  starts.pop unless text.empty? || text.end_with?("\n")
  starts
end

.scan(text, rules: RULES, markdown: false, path: "-") ⇒ Object

text: the source. rules: which Rule objects to run. markdown: blank code/URLs first. path: label carried into each Note (e.g. filename or "-" for stdin).



19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# File 'lib/sloplint/engine.rb', line 19

def scan(text, rules: RULES, markdown: false, path: "-")
  source = text
  text = blank_markdown(text) if markdown
  line_starts = line_starts_for(text)
  notes = []
  rules.each do |rule|
    text.to_enum(:scan, rule.pattern).each do
      m = Regexp.last_match
      matched = m[0]
      next if rule.skip.any? { |re| matched.match?(re) }

      count = rule.count_group ? matched.scan(rule.count_group).size : nil
      line, column = line_col(text, m.begin(0), line_starts:)
      message = count ? rule.message % { count: count } : rule.message
      notes << Note.new(
        path: path, line: line, column: column,
        severity: rule.severity, rule: rule.id, category: rule.category,
        message: message, excerpt: matched.gsub(/\s+/, " ").strip,
        context: context_for(source, m),
        count: count, rationale: rule.rationale, suggestion: rule.suggestion
      )
    end
  end
  notes.sort_by { |n| [n.line, n.column] }
end