Class: SasLinter

Inherits:
Object
  • Object
show all
Defined in:
lib/sas_linter.rb,
lib/sas_linter/version.rb,
lib/sas_linter/formatter.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/unterminated_comment.rb,
lib/sas_linter/rules/malformed_if_condition.rb,
lib/sas_linter/rules/invalid_numeric_literal.rb,
lib/sas_linter/rules/malformed_label_statement.rb,
lib/sas_linter/rules/identical_if_else_branches.rb,
lib/sas_linter/rules/inconsistent_variable_case.rb,
lib/sas_linter/rules/format_for_unknown_variable.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, Formatter, Rule

Constant Summary collapse

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

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

#format_file(path, formatter:) ⇒ Object

Apply formatting to a file in-place. Runs the formatter's own transformations first, then the autofix pipeline for rules that have autofix: true in config — identical to lint_with_fixes except that the formatter pass runs first. Rules that haven't been opted in to autofix (e.g. missing_assignment_semicolon without explicit autofix: true) are left alone so that --format stays cosmetic by default.

Returns true if the file was rewritten, false if nothing changed.



252
253
254
255
256
257
258
259
260
261
262
263
264
# File 'lib/sas_linter.rb', line 252

def format_file(path, formatter:)
  original = read_source(path)
  modified = formatter.format(original)
  @rules.each do |rule|
    next unless rule.autofix? && rule.class.supports_autofix?

    modified = rule.autofix(modified)
  end
  return false if modified.b == original.b

  File.write(path, modified)
  true
end

#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