Class: MilkTea::Linter
- Inherits:
-
Object
- Object
- MilkTea::Linter
- Includes:
- LinterDocTags, LinterFlowRules, LinterImportsPlatform, LinterReleaseRules, LinterReservedNames, LinterRules, LinterSourceHelpers, LinterTrailingComma, LinterVisitors
- Defined in:
- lib/milk_tea/tooling/linter.rb,
lib/milk_tea/tooling/linter/rules.rb,
lib/milk_tea/tooling/linter/doc_tags.rb,
lib/milk_tea/tooling/linter/visitors.rb,
lib/milk_tea/tooling/linter/fix_engine.rb,
lib/milk_tea/tooling/linter/flow_rules.rb,
lib/milk_tea/tooling/linter/release_rules.rb,
lib/milk_tea/tooling/linter/reserved_names.rb,
lib/milk_tea/tooling/linter/source_helpers.rb,
lib/milk_tea/tooling/linter/trailing_comma.rb,
lib/milk_tea/tooling/linter/imports_platform.rb
Defined Under Namespace
Modules: FixEngine, LinterDocTags, LinterFlowRules, LinterImportsPlatform, LinterReleaseRules, LinterReservedNames, LinterRules, LinterSourceHelpers, LinterTrailingComma, LinterVisitors Classes: Binding, DeadAssignmentAnalysis, FixEdit, Profile, ReservedPrimitiveNameFix, ReservedPrimitiveNameSite, StatementFlowAnalysis, Warning
Constant Summary collapse
- UNSET =
Object.new.freeze
- DEFAULT_CONFIG_FILE_NAME =
".mt-lint.yml".freeze
- KNOWN_RULE_CODES =
%w[ borrow-and-mutate constant-condition dead-assignment duplicate-if-condition directional-ffi-arg doc-tag event-capacity line-too-long loop-single-iteration missing-return noop-compound-assignment platform-api-drift owning-release-leak owning-release-double prefer-conditional-expression prefer-inline-if prefer-is-variant prefer-let prefer-let-else prefer-or-pattern prefer-own-ptr prefer-struct-with prefer-try prefer-var-else redundant-bool-compare redundant-cast redundant-else redundant-ignored-match-binding redundant-null-check redundant-return redundant-type-annotation reserved-primitive-name self-assignment self-comparison shadow trailing-list-comma unreachable-code unused-import unused-local unused-param useless-expression ].freeze
- RESERVED_VALUE_TYPE_NAMES =
Types::RESERVED_VALUE_TYPE_NAMES.to_set.freeze
- RESERVED_IMPORT_ALIAS_NAMES =
Types::RESERVED_IMPORT_ALIAS_NAMES.to_set.freeze
- RESERVED_TYPE_BINDING_NAMES =
Types::RESERVED_TYPE_BINDING_NAMES.to_set.freeze
- AUTO_FIXABLE_RULE_CODES =
%w[ prefer-let redundant-ignored-match-binding prefer-let-else prefer-var-else redundant-bool-compare redundant-cast redundant-else redundant-return redundant-type-annotation reserved-primitive-name trailing-list-comma ].freeze
- LINT_TIERS =
NOTE:
unused-importis intentionally NOT auto-fixable. Removing an import has non-local effects the linter cannot see under per-file validation: it may drop extension methods / canonical hooks (hash[T],equal[T]), a bare-name type provided via the prelude bootstrap (e.g.std.option), or break downstream consumers of a library module. It remains a reported hint so authors can remove genuinely-unused imports deliberately. %i[fast full].freeze
- STATIC_QUICK_FIX_TITLES =
{ "line-too-long" => "Wrap long line", "prefer-let" => "Replace 'var' with 'let'", "redundant-ignored-match-binding" => "Remove redundant as _", "redundant-bool-compare" => "Simplify boolean comparison", "redundant-cast" => "Remove redundant cast", "redundant-else" => "Remove redundant else", "redundant-return" => "Remove redundant return", "redundant-type-annotation" => "Remove redundant type annotation", "prefer-let-else" => "Rewrite as let-else", "prefer-var-else" => "Rewrite as var-else", "trailing-list-comma" => "Remove trailing list comma", }.freeze
- EVENT_STACK_SNAPSHOT_WARNING_THRESHOLD =
128- FIX_ALL_TITLE =
"Apply all auto-fixes".freeze
Class Method Summary collapse
- .apply_reserved_primitive_name_fixes(source, fixes) ⇒ Object
- .apply_suppressions(warnings, suppressions) ⇒ Object
- .best_effort_lint_context(source, path: nil, profile: nil, label: "best_effort_lint_context") ⇒ Object
- .best_effort_sema_facts(source, path: nil) ⇒ Object
- .bool_literal_value(text) ⇒ Object
- .build_prefer_let_else_fix(lines, if_idx, symbol_name: nil) ⇒ Object
- .collect_reserved_primitive_name_fixes(source, path: nil, sema_facts: UNSET, unresolved_import_paths: UNSET) ⇒ Object
- .default_config_source ⇒ Object
- .effective_max_line_length(path = nil) ⇒ Object
- .error_signature_counts(errors) ⇒ Object
- .filter_by_rules(warnings, select:, ignore:) ⇒ Object
-
.fix_source(source, path: nil, sema_facts: nil, select: nil, ignore: nil, max_passes: 5, profile: nil) ⇒ Object
Apply auto-fixable rules to source text.
- .fix_source_single_pass(source, path: nil, sema_facts: nil, select: nil, ignore: nil) ⇒ Object
- .fix_source_single_pass_isolating_rules(source, path: nil, sema_facts: nil, select: nil, ignore: nil, profile: nil) ⇒ Object
- .imported_modules_from_facts(ast, sema_facts) ⇒ Object
- .introduces_new_errors?(modified_counts, baseline_counts) ⇒ Boolean
- .line_start_offsets(source) ⇒ Object
- .lint_source(source, path: nil, select: nil, ignore: nil, sema_facts: UNSET, unresolved_import_paths: UNSET, profile: nil, lint_tier: :full) ⇒ Object
-
.load_config(path) ⇒ Object
Load the nearest .mt-lint.yml walking up from the source file's directory.
- .load_lint_package_graph(path) ⇒ Object
- .negate_expression_text(text) ⇒ Object
- .normalize_lint_tier(tier) ⇒ Object
- .parse_config_file(path) ⇒ Object
-
.parse_suppressions(trivia) ⇒ Object
Parse
# lint: ignore/# lint: ignore(rule1, rule2)comments. - .profile_phase(profile, name) ⇒ Object
- .quick_fix_title(code) ⇒ Object
- .redundant_bool_compare_replacement(expression_text) ⇒ Object
- .redundant_ignored_match_binding_span(line, column:) ⇒ Object
- .resolve_lint_path(path) ⇒ Object
- .validated_fixed_source(original_source, fixed_source, path:, baseline_errors:) ⇒ Object
Instance Method Summary collapse
- #full_tier? ⇒ Boolean
-
#initialize(path: nil, sema_facts: nil, source: nil, unresolved_import_paths: nil, imported_modules: nil, source_ast: nil, profile: nil, lint_tier: :full, max_line_length: nil) ⇒ Linter
constructor
A new instance of Linter.
- #lint(ast) ⇒ Object
- #reserved_primitive_name_fixes ⇒ Object
Constructor Details
#initialize(path: nil, sema_facts: nil, source: nil, unresolved_import_paths: nil, imported_modules: nil, source_ast: nil, profile: nil, lint_tier: :full, max_line_length: nil) ⇒ Linter
Returns a new instance of Linter.
727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 |
# File 'lib/milk_tea/tooling/linter.rb', line 727 def initialize(path: nil, sema_facts: nil, source: nil, unresolved_import_paths: nil, imported_modules: nil, source_ast: nil, profile: nil, lint_tier: :full, max_line_length: nil) @path = path @sema_facts = sema_facts @unresolved_import_paths = (unresolved_import_paths || Set.new).to_set @imported_modules = (imported_modules || {}).dup @source = source.to_s @source_lines = source ? source.lines.map { |line| line.delete_suffix("\n") } : [] @tokens = begin Lexer.lex(@source, path: @path) rescue StandardError [] end @token_index_by_location = @tokens.each_with_index.each_with_object({}) do |(token, index), locations| locations[[token.line, token.column]] ||= index end @tokens_by_line = @tokens.each_with_object(Hash.new { |hash, line| hash[line] = [] }) do |token, by_line| next if %i[newline indent dedent eof].include?(token.type) by_line[token.line] << token end @source_ast = source_ast @profile = profile @warnings = [] @scopes = [] @module_bindings = {} @declared_callable_names = Set.new @declared_directional_functions = {} @generic_function_depth = 0 @current_function_stack = [] @unsafe_depth = 0 @binding_ptr_unsafe_uses = Hash.new { |h, k| h[k] = { safe: 0, unsafe: 0 } } @ptr_candidates = Set.new @reserved_primitive_name_fixes = [] @lint_tier = self.class.normalize_lint_tier(lint_tier) @max_line_length = max_line_length&.to_i&.positive? ? max_line_length.to_i : Formatter::DEFAULT_MAX_LINE_LENGTH @cfg_binding_resolution = nil @cfg_binding_resolution_computed = false @statement_flow_analysis_cache = {} @dead_assignment_analysis_cache = {} end |
Class Method Details
.apply_reserved_primitive_name_fixes(source, fixes) ⇒ Object
517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 |
# File 'lib/milk_tea/tooling/linter.rb', line 517 def self.apply_reserved_primitive_name_fixes(source, fixes) return source if fixes.empty? line_offsets = line_start_offsets(source) edits = fixes.flat_map do |fix| fix.sites.uniq { |site| [site.line, site.column] }.filter_map do |site| next unless site.line && site.column next unless site.line >= 1 && site.line <= line_offsets.length { start_offset: line_offsets[site.line - 1] + site.column - 1, length: site.length, replacement: fix.replacement_name, } end end return source if edits.empty? updated_source = source.dup edits.sort_by { |edit| [edit[:start_offset], edit[:length]] }.reverse_each do |edit| updated_source[edit[:start_offset], edit[:length]] = edit[:replacement] end updated_source end |
.apply_suppressions(warnings, suppressions) ⇒ Object
708 709 710 711 712 713 714 715 716 717 718 719 |
# File 'lib/milk_tea/tooling/linter.rb', line 708 def self.apply_suppressions(warnings, suppressions) return warnings if suppressions.empty? warnings.reject do |w| next false unless w.line # Suppression on same line (trailing) or preceding line (leading comment) [w.line, w.line - 1].any? do |check_line| entry = suppressions[check_line] entry == :all || (entry.is_a?(Set) && entry.include?(w.code)) end end end |
.best_effort_lint_context(source, path: nil, profile: nil, label: "best_effort_lint_context") ⇒ Object
576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 |
# File 'lib/milk_tea/tooling/linter.rb', line 576 def self.best_effort_lint_context(source, path: nil, profile: nil, label: "best_effort_lint_context") ast = profile_phase(profile, "#{label}.parse") { Parser.parse(source, path:) } imported_modules = {} unresolved_import_paths = Set.new resolved_path = resolve_lint_path(path) if resolved_path && File.file?(resolved_path) effective_platform = ModuleLoader.effective_platform_for_path(resolved_path) loader = ModuleLoader.new( module_roots: MilkTea::ModuleRoots.roots_for_path(resolved_path), package_graph: load_lint_package_graph(resolved_path), source_overrides: { resolved_path => source }, platform: effective_platform, ) ast = profile_phase(profile, "#{label}.load_root") { loader.load_file(resolved_path) } resolution = profile_phase(profile, "#{label}.imports") do loader.imported_modules_for_ast_collecting_errors(ast, importer_path: resolved_path) end imported_modules = resolution.modules unresolved_import_paths.merge(resolution.errors.filter_map { |entry| entry.import&.path&.to_s }) end sema_snapshot = profile_phase(profile, "#{label}.sema") do SemanticAnalyzer.tooling_snapshot(ast, imported_modules: imported_modules, path: resolved_path || path) end { ast: ast, facts: sema_snapshot.facts, sema_snapshot: sema_snapshot, errors: sema_snapshot.diagnostics, imported_modules: imported_modules, unresolved_import_paths: unresolved_import_paths, } rescue StandardError unresolved = ast ? ast.imports.map { |i| i.path.to_s }.to_set : Set.new { ast: nil, facts: nil, sema_snapshot: nil, errors: nil, imported_modules: {}, unresolved_import_paths: unresolved } end |
.best_effort_sema_facts(source, path: nil) ⇒ Object
615 616 617 |
# File 'lib/milk_tea/tooling/linter.rb', line 615 def self.best_effort_sema_facts(source, path: nil) best_effort_lint_context(source, path:).fetch(:facts) end |
.bool_literal_value(text) ⇒ Object
671 672 673 674 675 676 677 |
# File 'lib/milk_tea/tooling/linter.rb', line 671 def self.bool_literal_value(text) case text when "true" then true when "false" then false else nil end end |
.build_prefer_let_else_fix(lines, if_idx, symbol_name: nil) ⇒ Object
552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 |
# File 'lib/milk_tea/tooling/linter.rb', line 552 def self.build_prefer_let_else_fix(lines, if_idx, symbol_name: nil) return nil if if_idx.nil? || if_idx <= 0 || if_idx >= lines.length declaration = lines[if_idx - 1].delete_suffix("\n") guard = lines[if_idx].delete_suffix("\n") name = symbol_name || guard[/\A\s*if\s+([A-Za-z_][A-Za-z0-9_]*)\s*==\s*null\s*:/, 1] || guard[/\A\s*if\s+null\s*==\s*([A-Za-z_][A-Za-z0-9_]*)\s*:/, 1] return nil unless name declaration_comment = declaration[/\s+#.*\z/] || "" declaration_base = declaration.sub(/\s+#.*\z/, "").rstrip declaration_match = declaration_base.match(/\A(\s*)(let|var)\s+#{Regexp.escape(name)}\s*=\s*.+\z/) return nil unless declaration_match return nil if declaration_base.end_with?(" else:") return nil unless guard.match?(/\A\s*if\s+(?:#{Regexp.escape(name)}\s*==\s*null|null\s*==\s*#{Regexp.escape(name)})\s*:\s*(?:#.*)?\z/) new_text = +"#{declaration_base} else:" new_text << declaration_comment unless declaration_comment.empty? new_text << "\n" { start_line_idx: if_idx - 1, end_line_idx: if_idx, new_text: } end |
.collect_reserved_primitive_name_fixes(source, path: nil, sema_facts: UNSET, unresolved_import_paths: UNSET) ⇒ Object
275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 |
# File 'lib/milk_tea/tooling/linter.rb', line 275 def self.collect_reserved_primitive_name_fixes(source, path: nil, sema_facts: UNSET, unresolved_import_paths: UNSET) sema_facts_provided = !sema_facts.equal?(UNSET) unresolved_import_paths_provided = !unresolved_import_paths.equal?(UNSET) context = nil if !sema_facts_provided || !unresolved_import_paths_provided context = best_effort_lint_context(source, path:) sema_facts = context[:facts] unless sema_facts_provided unresolved_import_paths = context[:unresolved_import_paths] unless unresolved_import_paths_provided end ast = sema_facts&.ast || context&.fetch(:ast, nil) || Parser.parse(source, path:) imported_modules = context&.fetch(:imported_modules, nil) imported_modules ||= imported_modules_from_facts(ast, sema_facts) linter = new(path:, sema_facts:, source:, unresolved_import_paths:, imported_modules:, source_ast: ast) linter.lint(ast) linter.reserved_primitive_name_fixes end |
.default_config_source ⇒ Object
261 262 263 264 265 266 267 268 269 270 271 272 273 |
# File 'lib/milk_tea/tooling/linter.rb', line 261 def self.default_config_source lines = [ "# Default Milk Tea lint configuration.", "# Remove entries from `select` to disable rules globally.", "max_line_length: #{Formatter::DEFAULT_MAX_LINE_LENGTH}", "select:", ] KNOWN_RULE_CODES.each do |code| lines << " - #{code}" end lines << "ignore: []" "#{lines.join("\n")}\n" end |
.effective_max_line_length(path = nil) ⇒ Object
257 258 259 |
# File 'lib/milk_tea/tooling/linter.rb', line 257 def self.effective_max_line_length(path = nil) load_config(path)&.fetch(:max_line_length, nil) || Formatter::DEFAULT_MAX_LINE_LENGTH end |
.error_signature_counts(errors) ⇒ Object
505 506 507 508 509 |
# File 'lib/milk_tea/tooling/linter.rb', line 505 def self.error_signature_counts(errors) Array(errors).each_with_object(Hash.new(0)) do |error, counts| counts[[error.line, error.column, error.]] += 1 end end |
.filter_by_rules(warnings, select:, ignore:) ⇒ Object
721 722 723 724 725 |
# File 'lib/milk_tea/tooling/linter.rb', line 721 def self.filter_by_rules(warnings, select:, ignore:) warnings = warnings.select { |w| select.include?(w.code) } if select warnings = warnings.reject { |w| ignore.include?(w.code) } if ignore warnings end |
.fix_source(source, path: nil, sema_facts: nil, select: nil, ignore: nil, max_passes: 5, profile: nil) ⇒ Object
Apply auto-fixable rules to source text. Handles: prefer-let, redundant-ignored-match-binding, prefer-let-else, prefer-var-else, redundant-bool-compare, redundant-else, redundant-return, reserved-primitive-name, trailing-list-comma. Returns the fixed source (may be identical if nothing was fixable).
358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 |
# File 'lib/milk_tea/tooling/linter.rb', line 358 def self.fix_source(source, path: nil, sema_facts: nil, select: nil, ignore: nil, max_passes: 5, profile: nil) pass_limit = [max_passes.to_i, 1].max current_source = source current_sema_facts = sema_facts pass_limit.times do updated_source = fix_source_single_pass_isolating_rules( current_source, path:, sema_facts: current_sema_facts, select:, ignore:, profile:, ) return current_source if updated_source == current_source current_source = updated_source # Facts passed by callers are only valid for the first source snapshot. current_sema_facts = nil end current_source end |
.fix_source_single_pass(source, path: nil, sema_facts: nil, select: nil, ignore: nil) ⇒ Object
431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 |
# File 'lib/milk_tea/tooling/linter.rb', line 431 def self.fix_source_single_pass(source, path: nil, sema_facts: nil, select: nil, ignore: nil) cfg = load_config(path) effective_select = select || cfg&.fetch(:select, nil) effective_ignore = ignore || cfg&.fetch(:ignore, nil) rule_enabled = lambda do |code| next false if effective_select && !effective_select.include?(code) next false if effective_ignore && effective_ignore.include?(code) true end working_context = sema_facts ? nil : best_effort_lint_context(source, path:) working_sema_facts = sema_facts || working_context[:facts] unresolved_import_paths = sema_facts ? Set.new : working_context[:unresolved_import_paths] warnings = lint_source( source, path:, sema_facts: working_sema_facts, unresolved_import_paths:, select:, ignore:, ) lines = source.lines warnings.group_by(&:code).each do |code, code_warnings| next unless rule_enabled.call(code) code_warnings.sort_by { |w| [-(w.line || 0), -(w.column || 0)] }.each do |warning| edits = FixEngine.edits_for_rule(code, lines, warning) FixEngine.apply_fix_edits(lines, edits) end end fixed_source = lines.join if rule_enabled.call("reserved-primitive-name") reserved_warnings = lint_source(fixed_source, path:, select: Set["reserved-primitive-name"]).select do |w| w.code == "reserved-primitive-name" && w.line && w.column && w.symbol_name end unless reserved_warnings.empty? warning_sites = reserved_warnings.each_with_object(Set.new) do |warning, sites| sites << [warning.line, warning.column, warning.symbol_name] end reserved_fixes = collect_reserved_primitive_name_fixes(fixed_source, path:).select do |fix| declaration_site = fix.sites.first warning_sites.include?([declaration_site.line, declaration_site.column, fix.original_name]) end fixed_source = apply_reserved_primitive_name_fixes(fixed_source, reserved_fixes) end end # Validation compares a best-effort re-analysis of the fixed source against # a baseline. When the caller supplied rich (full-package) facts there is # no working_context, but the fixed-source check is still best-effort — so # the baseline must also come from a best-effort analysis of the ORIGINAL # source, otherwise pre-existing best-effort-only errors (e.g. unresolved # cross-module imports in std files) would be counted as "new" and reject # every otherwise-valid fix. baseline_context = working_context || best_effort_lint_context(source, path:) validated_fixed_source(source, fixed_source, path:, baseline_errors: baseline_context[:errors]) end |
.fix_source_single_pass_isolating_rules(source, path: nil, sema_facts: nil, select: nil, ignore: nil, profile: nil) ⇒ Object
382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 |
# File 'lib/milk_tea/tooling/linter.rb', line 382 def self.fix_source_single_pass_isolating_rules(source, path: nil, sema_facts: nil, select: nil, ignore: nil, profile: nil) cfg = load_config(path) effective_select = select || cfg&.fetch(:select, nil) effective_ignore = ignore || cfg&.fetch(:ignore, nil) enabled_rules = AUTO_FIXABLE_RULE_CODES.select do |rule_code| next false if effective_select && !effective_select.include?(rule_code) next false if effective_ignore && effective_ignore.include?(rule_code) true end return source if enabled_rules.empty? current_source = source current_sema_facts = sema_facts # When no semantic facts are supplied, omit the argument entirely so # lint_source rebuilds a best-effort context. Passing an explicit `nil` # would suppress sema-dependent rules (e.g. widening redundant-cast). preflight_args = { path:, select: Set.new(enabled_rules), ignore: effective_ignore, profile:, } preflight_args[:sema_facts] = current_sema_facts if current_sema_facts preflight_warnings = lint_source(current_source, **preflight_args) return current_source if preflight_warnings.empty? preflight_codes = preflight_warnings.map(&:code).to_set active_rules = enabled_rules.select { |rule_code| preflight_codes.include?(rule_code) } return current_source if active_rules.empty? active_rules.each do |rule_code| updated_source = fix_source_single_pass( current_source, path:, sema_facts: current_sema_facts, select: Set[rule_code], ignore: effective_ignore, ) next if updated_source == current_source current_source = updated_source current_sema_facts = nil end current_source end |
.imported_modules_from_facts(ast, sema_facts) ⇒ Object
341 342 343 344 345 346 347 348 349 |
# File 'lib/milk_tea/tooling/linter.rb', line 341 def self.imported_modules_from_facts(ast, sema_facts) return {} unless ast && sema_facts ast.imports.each_with_object({}) do |import, imported_modules| alias_name = import.alias_name || import.path.parts.last module_binding = sema_facts.imports[alias_name] imported_modules[import.path.to_s] = module_binding if module_binding end end |
.introduces_new_errors?(modified_counts, baseline_counts) ⇒ Boolean
511 512 513 514 515 |
# File 'lib/milk_tea/tooling/linter.rb', line 511 def self.introduces_new_errors?(modified_counts, baseline_counts) modified_counts.any? do |signature, count| count > baseline_counts.fetch(signature, 0) end end |
.line_start_offsets(source) ⇒ Object
542 543 544 545 546 547 548 549 550 |
# File 'lib/milk_tea/tooling/linter.rb', line 542 def self.line_start_offsets(source) offsets = [] start_offset = 0 source.lines.each do |line| offsets << start_offset start_offset += line.length end offsets end |
.lint_source(source, path: nil, select: nil, ignore: nil, sema_facts: UNSET, unresolved_import_paths: UNSET, profile: nil, lint_tier: :full) ⇒ Object
203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 |
# File 'lib/milk_tea/tooling/linter.rb', line 203 def self.lint_source(source, path: nil, select: nil, ignore: nil, sema_facts: UNSET, unresolved_import_paths: UNSET, profile: nil, lint_tier: :full) sema_facts_provided = !sema_facts.equal?(UNSET) unresolved_import_paths_provided = !unresolved_import_paths.equal?(UNSET) if sema_facts_provided && !unresolved_import_paths_provided # Callers that already computed semantic facts should not pay for a # second context bootstrap only to derive unresolved imports. unresolved_import_paths = Set.new unresolved_import_paths_provided = true end cfg = load_config(path) context = nil if !sema_facts_provided || !unresolved_import_paths_provided context = best_effort_lint_context(source, path:, profile:, label: "context_bootstrap") sema_facts = context[:facts] unless sema_facts_provided unresolved_import_paths = context[:unresolved_import_paths] unless unresolved_import_paths_provided end ast = profile_phase(profile, "resolve_ast") do sema_facts&.ast || context&.fetch(:ast, nil) || Parser.parse(source, path:) end imported_modules = context&.fetch(:imported_modules, nil) imported_modules ||= imported_modules_from_facts(ast, sema_facts) suppressions = {} if source.match?(/#\s*lint:\s*ignore(?:\(|\b)/) trivia = profile_phase(profile, "lex_trivia") { Lexer.lex_with_trivia(source, path:).trivia } suppressions = profile_phase(profile, "parse_suppressions") { parse_suppressions(trivia) } end warnings = new( path:, sema_facts:, source:, unresolved_import_paths:, imported_modules:, source_ast: ast, profile:, lint_tier: normalize_lint_tier(lint_tier), max_line_length: cfg&.fetch(:max_line_length, nil), ).lint(ast) warnings = profile_phase(profile, "apply_suppressions") { apply_suppressions(warnings, suppressions) } # Layer in config-file defaults before per-call overrides if cfg select ||= cfg[:select] ignore ||= cfg[:ignore] end warnings = profile_phase(profile, "filter_rules") { filter_by_rules(warnings, select:, ignore:) } warnings end |
.load_config(path) ⇒ Object
Load the nearest .mt-lint.yml walking up from the source file's directory. Returns Hash { select: Set|nil, ignore: Set|nil } or nil if no config found.
295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 |
# File 'lib/milk_tea/tooling/linter.rb', line 295 def self.load_config(path) resolved_path = resolve_lint_path(path) return nil unless resolved_path dir = File.directory?(resolved_path) ? resolved_path : File.dirname(resolved_path) # Walk up until we either find a config or leave the project 100.times do candidate = File.join(dir, DEFAULT_CONFIG_FILE_NAME) if File.exist?(candidate) return parse_config_file(candidate) end parent = File.dirname(dir) break if parent == dir # filesystem root dir = parent end nil rescue StandardError nil end |
.load_lint_package_graph(path) ⇒ Object
686 687 688 689 690 |
# File 'lib/milk_tea/tooling/linter.rb', line 686 def self.load_lint_package_graph(path) PackageGraph.load(path) rescue PackageManifestError, PackageLockError nil end |
.negate_expression_text(text) ⇒ Object
679 680 681 682 683 684 |
# File 'lib/milk_tea/tooling/linter.rb', line 679 def self.negate_expression_text(text) stripped = text.strip return "not #{stripped}" if stripped.match?(/\A[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*\z/) "not (#{stripped})" end |
.normalize_lint_tier(tier) ⇒ Object
198 199 200 201 |
# File 'lib/milk_tea/tooling/linter.rb', line 198 def self.normalize_lint_tier(tier) normalized = tier.to_s.strip.downcase.to_sym LINT_TIERS.include?(normalized) ? normalized : :full end |
.parse_config_file(path) ⇒ Object
316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 |
# File 'lib/milk_tea/tooling/linter.rb', line 316 def self.parse_config_file(path) require "yaml" raw = YAML.safe_load_file(path, symbolize_names: true) || {} result = {} if (s = raw[:select]) result[:select] = Array(s).map(&:to_s).to_set end if (i = raw[:ignore]) result[:ignore] = Array(i).map(&:to_s).to_set end if raw.key?(:max_line_length) max_line_length = raw[:max_line_length].to_i result[:max_line_length] = max_line_length if max_line_length.positive? end result rescue StandardError {} end |
.parse_suppressions(trivia) ⇒ Object
Parse # lint: ignore / # lint: ignore(rule1, rule2) comments.
Returns Hash { line_number => Set | :all }
694 695 696 697 698 699 700 701 702 703 704 705 706 |
# File 'lib/milk_tea/tooling/linter.rb', line 694 def self.parse_suppressions(trivia) result = {} trivia.select { |t| t.kind == :comment }.each do |t| text = t.text.strip if (m = text.match(/\A#\s*lint:\s*ignore\(([^)]+)\)\z/)) codes = m[1].split(",").map(&:strip).to_set result[t.line] = codes elsif text.match(/\A#\s*lint:\s*ignore\z/) result[t.line] = :all end end result end |
.profile_phase(profile, name) ⇒ Object
335 336 337 338 339 |
# File 'lib/milk_tea/tooling/linter.rb', line 335 def self.profile_phase(profile, name) return yield unless profile profile.measure(name) { yield } end |
.quick_fix_title(code) ⇒ Object
253 254 255 |
# File 'lib/milk_tea/tooling/linter.rb', line 253 def self.quick_fix_title(code) STATIC_QUICK_FIX_TITLES[code] end |
.redundant_bool_compare_replacement(expression_text) ⇒ Object
649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 |
# File 'lib/milk_tea/tooling/linter.rb', line 649 def self.redundant_bool_compare_replacement(expression_text) match = expression_text.match(/\A\s*(.+?)\s*(==|!=)\s*(.+?)\s*\z/m) return nil unless match left_text = match[1].strip operator = match[2] right_text = match[3].strip left_bool = bool_literal_value(left_text) right_bool = bool_literal_value(right_text) return nil if left_bool.nil? == right_bool.nil? literal_value = left_bool.nil? ? right_bool : left_bool compared_text = left_bool.nil? ? left_text : right_text negate = if operator == "==" literal_value == false else literal_value == true end negate ? negate_expression_text(compared_text) : compared_text end |
.redundant_ignored_match_binding_span(line, column:) ⇒ Object
631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 |
# File 'lib/milk_tea/tooling/linter.rb', line 631 def self.redundant_ignored_match_binding_span(line, column:) anchor_idx = column - 1 return nil unless anchor_idx >= 0 && anchor_idx < line.length scan_start = [anchor_idx - 5, 0].max match = line[scan_start..]&.match(/\s+as\s+_/) return nil unless match start_char = scan_start + match.begin(0) end_char = scan_start + match.end(0) return nil unless anchor_idx >= start_char && anchor_idx < end_char { start_char:, end_char:, } end |
.resolve_lint_path(path) ⇒ Object
619 620 621 622 623 624 625 626 627 628 629 |
# File 'lib/milk_tea/tooling/linter.rb', line 619 def self.resolve_lint_path(path) return nil unless path.is_a?(String) && !path.empty? if path.start_with?("file://") CGI.unescape(URI.parse(path).path) else File.(path) end rescue StandardError nil end |
.validated_fixed_source(original_source, fixed_source, path:, baseline_errors:) ⇒ Object
493 494 495 496 497 498 499 500 501 502 503 |
# File 'lib/milk_tea/tooling/linter.rb', line 493 def self.validated_fixed_source(original_source, fixed_source, path:, baseline_errors:) return fixed_source if fixed_source == original_source fixed_context = best_effort_lint_context(fixed_source, path:) return fixed_source if fixed_context[:ast] && !introduces_new_errors?( error_signature_counts(fixed_context[:errors]), error_signature_counts(baseline_errors), ) original_source end |
Instance Method Details
#full_tier? ⇒ Boolean
778 779 780 |
# File 'lib/milk_tea/tooling/linter.rb', line 778 def full_tier? @lint_tier == :full end |
#lint(ast) ⇒ Object
768 769 770 771 772 773 774 775 776 |
# File 'lib/milk_tea/tooling/linter.rb', line 768 def lint(ast) @source_ast ||= ast visit_source_file(ast) profile_phase("rule.doc_tag") { emit_doc_tag_warnings(ast) } if full_tier? profile_phase("rule.event_capacity") { emit_event_capacity_warnings(ast) } profile_phase("rule.trailing_list_comma") { emit_trailing_list_comma_warnings(ast) } profile_phase("rule.line_too_long") { emit_line_too_long_warnings } @warnings end |
#reserved_primitive_name_fixes ⇒ Object
782 783 784 |
# File 'lib/milk_tea/tooling/linter.rb', line 782 def reserved_primitive_name_fixes @reserved_primitive_name_fixes end |