Class: SasLinter

Inherits:
Object
  • Object
show all
Defined in:
lib/sas_linter.rb,
lib/sas_linter/version.rb,
lib/sas_linter/rules/line_endings.rb,
lib/sas_linter/rules/tab_expansion.rb,
lib/sas_linter/rules/source_headers.rb,
lib/sas_linter/rules/encoding_issues.rb,
lib/sas_linter/rules/choose_one_template.rb,
lib/sas_linter/rules/commented_out_guard.rb,
lib/sas_linter/rules/trailing_whitespace.rb,
lib/sas_linter/rules/malformed_if_condition.rb,
lib/sas_linter/rules/identical_if_else_branches.rb,
lib/sas_linter/rules/missing_assignment_semicolon.rb,
lib/sas_linter/rules/unreachable_inner_branch_value.rb,
lib/sas_linter/rules/variable_value_out_of_known_range.rb

Overview

Configurable lint engine for SAS source files. Walks the token stream produced by ‘SasLexer::Lexer` and applies a set of pluggable rules.

Each rule is a subclass of ‘SasLinter::Rule` and is auto-registered when its file is required. Use `SasLinter.new(rules: […])` to constrain the rule set, or `SasLinter.from_config(config_hash)` to honor a YAML config.

Defined Under Namespace

Modules: Rules Classes: Finding, Rule

Constant Summary collapse

DEFAULT_CONFIG_PATH =
"config/lint.yaml"
VERSION =
"0.1.0"

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(rules: nil) ⇒ SasLinter

Returns a new instance of SasLinter.

Parameters:

  • rules (Array<Symbol|Class|Rule>, nil) (defaults to: nil)

    When nil, every registered rule runs with default options. Symbols and classes are instantiated via ‘Rule#new`; rule instances are used as-is.



140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
# File 'lib/sas_linter.rb', line 140

def initialize(rules: nil)
  classes_or_instances =
    if rules.nil?
      Rule.all
    else
      rules
    end

  @rules = classes_or_instances.map do |r|
    case r
    when Rule then r
    when Class then r.new
    else Rule.fetch(r).new
    end
  end
end

Class Method Details

.from_config(config) ⇒ Object

Build a linter from a parsed config hash. Schema:

rules:
  <rule_id>:
    enabled: true|false        # default: true
    <option>: <value>           # passed to Rule.from_config

Rules omitted from the config default to enabled with no options, so adding a new rule to the gem won’t silently disable it for users with an existing config file. To suppress a rule, list it with ‘enabled: false`.



167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
# File 'lib/sas_linter.rb', line 167

def self.from_config(config)
  config = (config || {}).transform_keys(&:to_s)
  rules_config = (config["rules"] || {}).transform_keys(&:to_s)
  instances = []

  rules_config.each do |id, opts|
    opts = (opts || {}).transform_keys(&:to_s)
    next if opts["enabled"] == false

    klass = Rule.fetch(id.to_sym)
    instances << klass.from_config(opts.reject { |k, _| k == "enabled" })
  end

  Rule.all.each do |klass|
    next if rules_config.key?(klass.rule_id.to_s)

    instances << klass.new
  end

  new(rules: instances)
end

.load_config_file(path) ⇒ Object

Load a YAML config file and return a parsed hash. Returns an empty hash when the file is missing — the default ‘config/lint.yaml` is optional.



191
192
193
194
195
# File 'lib/sas_linter.rb', line 191

def self.load_config_file(path)
  return {} unless File.file?(path)

  YAML.safe_load_file(path) || {}
end

Instance Method Details

#lint(source, path: "(string)") ⇒ Object

Lint a SAS source string. ‘path` is used for finding location output. Returns just the findings array. Use `lint_with_fixes` when the caller wants the (possibly-modified) source back too.



200
201
202
# File 'lib/sas_linter.rb', line 200

def lint(source, path: "(string)")
  lint_with_fixes(source, path: path).first
end

#lint_file(path) ⇒ Object

Lint a file by path. Sources are commonly Windows-1252 or ISO-8859-1 rather than UTF-8 — read as binary and best-effort transcode so the lexer (which requires valid UTF-8) doesn’t reject them.

If any autofix-enabled rule rewrote the source, the file is updated in place. Returns the findings array regardless of write outcome.

The ‘modified.b != original.b` guard compares raw bytes so a difference in encoding tags alone (e.g. UTF-8 vs ASCII-8BIT) doesn’t trigger a write. That can happen when EncodingIssues autofix returns a binary string but no rule actually changed any bytes — without ‘.b` the file would be rewritten with byte- identical contents and a different encoding label, surfacing as a no-op diff in git that overwrites the user’s chosen encoding.



237
238
239
240
241
242
# File 'lib/sas_linter.rb', line 237

def lint_file(path)
  original = read_source(path)
  findings, modified = lint_with_fixes(original, path: path)
  File.write(path, modified) if modified.b != original.b
  findings
end

#lint_with_fixes(source, path: "(string)") ⇒ Object

Lint a SAS source string and apply any autofixes from rules whose ‘autofix?` instance flag is true. Returns `[findings, modified_source]`. When no rule has autofix enabled the modified source equals the input.



207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
# File 'lib/sas_linter.rb', line 207

def lint_with_fixes(source, path: "(string)")
  default_tokens, all_tokens = tokenize(source)
  findings = @rules.flat_map do |rule|
    rule.check(default_tokens, path: path, all_tokens: all_tokens, source: source)
  end

  modified = source
  @rules.each do |rule|
    next unless rule.autofix? && rule.class.supports_autofix?

    modified = rule.autofix(modified)
  end

  [findings, modified]
end