diff --git a/lib/typeprof/core/ast.rb b/lib/typeprof/core/ast.rb index 6426a6d5..a1d657dc 100644 --- a/lib/typeprof/core/ast.rb +++ b/lib/typeprof/core/ast.rb @@ -15,7 +15,8 @@ def self.parse_rb(path, src) cref = CRef::Toplevel lenv = LocalEnv.new(path, cref, {}, []) - ProgramNode.new(raw_scope, lenv) + disable_ranges = TypeProf::Diagnostic::DisableDirective::Scanner.collect(result, src) + ProgramNode.new(raw_scope, lenv, disable_ranges: disable_ranges) end #: (untyped, TypeProf::Core::LocalEnv, ?bool) -> TypeProf::Core::AST::Node diff --git a/lib/typeprof/core/ast/base.rb b/lib/typeprof/core/ast/base.rb index d132ba7e..a512c567 100644 --- a/lib/typeprof/core/ast/base.rb +++ b/lib/typeprof/core/ast/base.rb @@ -192,20 +192,33 @@ def pretty_print_instance_variables end class ProgramNode < Node - def initialize(raw_node, lenv) + def initialize(raw_node, lenv, disable_ranges: []) super(raw_node, lenv) @tbl = raw_node.locals + @disable_ranges = disable_ranges raw_body = raw_node.statements @body = AST.create_node(raw_body, lenv, false) end - attr_reader :tbl, :body + attr_reader :tbl, :disable_ranges, :body def subnodes = { body: } def attrs = { tbl: } + def diagnostics(genv, &blk) + if disable_ranges.empty? + super(genv, &blk) + else + filter = TypeProf::Diagnostic::DisableDirective::Filter.new(disable_ranges) + super(genv) do |diag| + next if filter.skip?(diag.code_range.first.lineno) + blk&.call(diag) + end + end + end + def install0(genv) @tbl.each {|var| @lenv.locals[var] = Source.new(genv.nil_type) } @lenv.locals[:"*self"] = lenv.cref.get_self(genv) diff --git a/lib/typeprof/diagnostic.rb b/lib/typeprof/diagnostic.rb index cad52126..f3c38440 100644 --- a/lib/typeprof/diagnostic.rb +++ b/lib/typeprof/diagnostic.rb @@ -1,3 +1,6 @@ +require_relative "./diagnostic/disable_directive/scanner" +require_relative "./diagnostic/disable_directive/filter" + module TypeProf class Diagnostic def initialize(node, meth, msg, tags: nil) diff --git a/lib/typeprof/diagnostic/disable_directive/filter.rb b/lib/typeprof/diagnostic/disable_directive/filter.rb new file mode 100644 index 00000000..c647b0d0 --- /dev/null +++ b/lib/typeprof/diagnostic/disable_directive/filter.rb @@ -0,0 +1,16 @@ +module TypeProf + class Diagnostic + module DisableDirective + # Determine which diagnostic ranges should not be reported. + class Filter + def initialize(ranges) + @ranges = ranges + end + + def skip?(line) + @ranges.any? { |r| r.cover?(line) } + end + end + end + end +end diff --git a/lib/typeprof/diagnostic/disable_directive/scanner.rb b/lib/typeprof/diagnostic/disable_directive/scanner.rb new file mode 100644 index 00000000..4f7bb344 --- /dev/null +++ b/lib/typeprof/diagnostic/disable_directive/scanner.rb @@ -0,0 +1,66 @@ +module TypeProf + class Diagnostic + module DisableDirective + # Determine which diagnostic ranges should not be reported. + # + # This scanner processes comments in the source code to identify which lines should be excluded from diagnostics. + # It supports both block-level and inline disable/enable comments. + # + # Block-level comments start with `# typeprof:disable` and end with `# typeprof:enable`. + # Inline comments with `# typeprof:disable` exclude diagnostics only for the line containing the comment. + class Scanner + DISABLE_RE = /\s*#\stypeprof:disable$/ + ENABLE_RE = /\s*#\stypeprof:enable$/ + + def self.collect(prism_result, src) + lines = src.lines + comments_by_line = Hash.new { |h, k| h[k] = [] } + + prism_result.comments.each do |c| + comments_by_line[c.location.start_line] << c.location.slice + end + + ranges = [] + current_start = nil + + 1.upto(lines.size) do |ln| + comment_text = comments_by_line[ln].join(' ') + line_text = lines[ln - 1] + + disable = (comment_text =~ DISABLE_RE) || (line_text =~ DISABLE_RE) + enable = (comment_text =~ ENABLE_RE) || (line_text =~ ENABLE_RE) + + if current_start # Inside a disable comment block. + if enable # Enable comment found. + ranges << (current_start..ln - 1) + if line_text.strip.start_with?('#') # Block-level enable comment found. + current_start = nil # Close the disable comment block. + else + # Inline enable comment found. + # Exclude lines from the start of the disable comment block up to the current line. + current_start = ln + 1 # Start a new disable comment block on the next line. + end + end + else + # Outside a disable comment block. + next unless disable + + if line_text.strip.start_with?('#') # Block-level disable comment found. + current_start = ln + 1 # Disable comment block starts on the next line. + else + # Inline disable comment found. + ranges << (ln..ln) # Exclude only the current line with inline disable. + end + end + end + + # If a disable comment block was started but no matching enable comment was found, + # exclude all lines from the start of the disable comment block to the end of the file. + ranges << (current_start..Float::INFINITY) if current_start && current_start <= lines.size + + ranges + end + end + end + end +end diff --git a/test/cli_test.rb b/test/cli_test.rb index 79ecde02..b531de9e 100644 --- a/test/cli_test.rb +++ b/test/cli_test.rb @@ -47,6 +47,17 @@ def check: -> :ok END end + def test_e2e_disable_directive + assert_equal(<<~END, test_run("disable_directive", ["--show-error", "."])) + # TypeProf #{ TypeProf::VERSION } + + # ./disable_directive.rb + class Object + def check: -> :ok + end + END + end + def test_e2e_syntax_error assert_equal(<<~END, test_run("syntax_error", ["."])) # TypeProf #{ TypeProf::VERSION } diff --git a/test/diagnostic/disable_directive/filter_test.rb b/test/diagnostic/disable_directive/filter_test.rb new file mode 100644 index 00000000..83aabbb2 --- /dev/null +++ b/test/diagnostic/disable_directive/filter_test.rb @@ -0,0 +1,41 @@ +require_relative '../../helper' + +module TypeProf + class Diagnostic + module DisableDirective + class FilterTest < Test::Unit::TestCase + def test_skip_when_line_is_in_range + ranges = [1..3] + filter = Filter.new(ranges) + + assert_equal(true, filter.skip?(1)) + assert_equal(true, filter.skip?(2)) + assert_equal(true, filter.skip?(3)) + end + + def test_not_ignore_when_line_is_not_in_range + ranges = [2..3] + filter = Filter.new(ranges) + + assert_equal(false, filter.skip?(1)) + assert_equal(false, filter.skip?(4)) + end + + def test_with_empty_ranges + filter = Filter.new([]) + + assert_equal(false, filter.skip?(1)) + end + + def test_with_infinite_range + ranges = [2..Float::INFINITY] + filter = Filter.new(ranges) + + assert_equal(false, filter.skip?(1)) + assert_equal(true, filter.skip?(2)) + assert_equal(true, filter.skip?(100)) + end + end + end + end +end diff --git a/test/diagnostic/disable_directive/scanner_test.rb b/test/diagnostic/disable_directive/scanner_test.rb new file mode 100644 index 00000000..9f0c2ba6 --- /dev/null +++ b/test/diagnostic/disable_directive/scanner_test.rb @@ -0,0 +1,132 @@ +require_relative '../../helper' + +module TypeProf + class Diagnostic + module DisableDirective + class ScannerTest < Test::Unit::TestCase + def test_when_no_directives + src = <<~RUBY + def foo + x = 1 + y = 2 + end + RUBY + prism_result = Prism.parse(src) + ranges = Scanner.collect(prism_result, src) + + assert_empty ranges + end + + def test_when_only_inline_enable_comment + src = <<~RUBY + def foo + x = 1 # typeprof:enable + y = 2 + end + RUBY + prism_result = Prism.parse(src) + ranges = Scanner.collect(prism_result, src) + + assert_equal 0, ranges.size + end + + def test_when_only_inline_disable_comment + src = <<~RUBY + def foo + x = 1 # typeprof:disable + y = 2 + end + RUBY + prism_result = Prism.parse(src) + ranges = Scanner.collect(prism_result, src) + + assert_equal 1, ranges.size + assert_equal (2..2), ranges[0] + end + + def test_when_only_block_disable_comment + src = <<~RUBY + def foo + # typeprof:disable + x = 1 + y = 2 + end + RUBY + prism_result = Prism.parse(src) + ranges = Scanner.collect(prism_result, src) + + assert_equal 1, ranges.size + assert_equal (3..Float::INFINITY), ranges[0] + end + + def test_when_only_block_disable_and_enable_comment + src = <<~RUBY + def foo + # typeprof:disable + x = 1 + y = 2 + # typeprof:enable + z = 3 + end + RUBY + prism_result = Prism.parse(src) + ranges = Scanner.collect(prism_result, src) + + assert_equal 1, ranges.size + assert_equal (3..4), ranges.first + end + + def test_when_inline_disable_comment + src = <<~RUBY + def foo + x = 1 # typeprof:disable + y = 2 + end + RUBY + prism_result = Prism.parse(src) + ranges = Scanner.collect(prism_result, src) + + assert_equal 1, ranges.size + assert_equal (2..2), ranges[0] + end + + def test_when_only_block_disable_and_inline_enable_comment + src = <<~RUBY + def foo + # typeprof:disable + x = 1 + y = 2 + z = 3 # typeprof:enable + w = 4 + end + RUBY + prism_result = Prism.parse(src) + ranges = Scanner.collect(prism_result, src) + + assert_equal 2, ranges.size + assert_equal (3..4), ranges[0] + assert_equal (6..Float::INFINITY), ranges[1] + end + + def test_when_multiple_comments + src = <<~RUBY + def foo + # typeprof:disable + x = 1 + # typeprof:enable + y = 2 + z = 3 # typeprof:disable + w = 4 + end + RUBY + prism_result = Prism.parse(src) + ranges = Scanner.collect(prism_result, src) + + assert_equal 2, ranges.size + assert_equal (3..3), ranges[0] + assert_equal (6..6), ranges[1] + end + end + end + end +end diff --git a/test/fixtures/disable_directive/disable_directive.rb b/test/fixtures/disable_directive/disable_directive.rb new file mode 100644 index 00000000..47b7cf3f --- /dev/null +++ b/test/fixtures/disable_directive/disable_directive.rb @@ -0,0 +1,3 @@ +def check + Foo.new.accept_int("str") # typeprof:disable +end diff --git a/test/fixtures/disable_directive/disable_directive.rbs b/test/fixtures/disable_directive/disable_directive.rbs new file mode 100644 index 00000000..e21a5690 --- /dev/null +++ b/test/fixtures/disable_directive/disable_directive.rbs @@ -0,0 +1,3 @@ +class Foo + def accept_int: (Integer) -> :ok +end diff --git a/test/lsp/lsp_test.rb b/test/lsp/lsp_test.rb index 25acf90a..52d6c460 100644 --- a/test/lsp/lsp_test.rb +++ b/test/lsp/lsp_test.rb @@ -190,6 +190,34 @@ def foo(nnn) end end + def test_disable_directive + init("basic") + + notify( + "textDocument/didOpen", + textDocument: { uri: @folder + "basic.rb", version: 0, text: <<-END }, +def foo(nnn) + nnn +end + +foo(1, 2) # typeprof:disable +foo(1, 2) + END + ) + + expect_request("workspace/codeLens/refresh") {|json| } + expect_notification("textDocument/publishDiagnostics") do |json| + assert_equal([ + { + message: "wrong number of arguments (2 for 1)", + range: { start: { line: 5, character: 0 }, end: { line: 5, character: 3 }}, + severity: 1, + source: "TypeProf", + } + ], json[:diagnostics]) + end + end + def test_completion init("basic")