lib/steep/services/type_check_service.rb



module Steep
  module Services
    class TypeCheckService
      attr_reader :project
      attr_reader :signature_validation_diagnostics
      attr_reader :source_files
      attr_reader :signature_services

      class SourceFile
        attr_reader :path
        attr_reader :target
        attr_reader :content
        attr_reader :node
        attr_reader :typing
        attr_reader :errors
        attr_reader :ignores

        def initialize(path:, node:, content:, typing:, ignores:, errors:)
          @path = path
          @node = node
          @content = content
          @typing = typing
          @ignores = ignores
          @errors = errors
        end

        def self.with_syntax_error(path:, content:, error:)
          new(path: path, node: false, content: content, errors: [error], typing: nil, ignores: nil)
        end

        def self.with_typing(path:, content:, typing:, node:, ignores:)
          new(path: path, node: node, content: content, errors: nil, typing: typing, ignores: ignores)
        end

        def self.no_data(path:, content:)
          new(path: path, content: content, node: false, errors: nil, typing: nil, ignores: nil)
        end

        def update_content(content)
          self.class.new(
            path: path,
            content: content,
            node: node,
            errors: errors,
            typing: typing,
            ignores: ignores
          )
        end

        def diagnostics
          case
          when errors
            errors
          when typing && ignores
            errors = [] #: Array[Diagnostic::Ruby::Base]

            errors.concat(
              typing.errors.delete_if do |diagnostic|
                case diagnostic.location
                when ::Parser::Source::Range
                  ignores.ignore?(diagnostic.location.first_line, diagnostic.location.last_line, diagnostic.diagnostic_code)
                when RBS::Location
                  ignores.ignore?(diagnostic.location.start_line, diagnostic.location.end_line, diagnostic.diagnostic_code)
                end
              end
            )

            ignores.error_ignores.each do |ignore|
              errors << Diagnostic::Ruby::InvalidIgnoreComment.new(comment: ignore.comment)
            end

            errors
          else
            []
          end
        end
      end

      class TargetRequest
        attr_reader :target
        attr_reader :source_paths

        def initialize(target:)
          @target = target
          @source_paths = Set[]
          @signature_updated = false
        end

        def signature_updated!(value = true)
          @signature_updated = value
          self
        end

        def signature_updated?
          @signature_updated
        end

        def empty?
          !signature_updated? && source_paths.empty?
        end

        def ==(other)
          other.is_a?(TargetRequest) &&
            other.target == target &&
            other.source_paths == source_paths &&
            other.signature_updated? == signature_updated?
        end

        alias eql? ==

        def hash
          self.class.hash ^ target.hash ^ source_paths.hash ^ @signature_updated.hash
        end
      end

      def initialize(project:)
        @project = project

        @source_files = {}
        @signature_services = project.targets.each.with_object({}) do |target, hash|
          loader = Project::Target.construct_env_loader(options: target.options, project: project)
          hash[target.name] = SignatureService.load_from(loader)
        end
        @signature_validation_diagnostics = project.targets.each.with_object({}) do |target, hash|
          hash[target.name] = {}
        end
      end

      def signature_diagnostics
        # @type var signature_diagnostics: Hash[Pathname, Array[Diagnostic::Signature::Base]]
        signature_diagnostics = {}

        project.targets.each do |target|
          service = signature_services[target.name]

          service.each_rbs_path do |path|
            signature_diagnostics[path] ||= []
          end

          case service.status
          when SignatureService::SyntaxErrorStatus, SignatureService::AncestorErrorStatus
            service.status.diagnostics.group_by {|diag| Pathname(diag.location.buffer.name) }.each do |path, diagnostics|
              signature_diagnostics[path].push(*diagnostics)
            end
          when SignatureService::LoadedStatus
            validation_diagnostics = signature_validation_diagnostics[target.name] || {}
            validation_diagnostics.each do |path, diagnostics|
              signature_diagnostics[path].push(*diagnostics)
            end
          end
        end

        signature_diagnostics
      end

      def has_diagnostics?
        each_diagnostics.count > 0
      end

      def diagnostics
        each_diagnostics.to_h
      end

      def each_diagnostics(&block)
        if block
          signature_diagnostics.each do |path, diagnostics|
            yield [path, diagnostics]
          end

          source_files.each_value do |file|
            yield [file.path, file.diagnostics]
          end
        else
          enum_for :each_diagnostics
        end
      end

      def update(changes:)
        requests = project.targets.each_with_object({}.compare_by_identity) do |target, hash|
          hash[target] = TargetRequest.new(target: target)
        end

        Steep.measure "#update_signature" do
          update_signature(changes: changes, requests: requests)
        end

        Steep.measure "#update_sources" do
          update_sources(changes: changes, requests: requests)
        end

        requests.transform_keys(&:name).reject {|_, request| request.empty? }
      end

      def update_and_check(changes:, assignment:, &block)
        requests = update(changes: changes)

        signatures = requests.each_value.with_object(Set[]) do |request, sigs|
          if request.signature_updated?
            service = signature_services[request.target.name]
            sigs.merge(service.each_rbs_path)
          end
        end

        signatures.each do |path|
          if assignment =~ path
            validate_signature(path: path, &block)
          end
        end

        requests.each_value do |request|
          request.source_paths.each do |path|
            if assignment =~ path
              typecheck_source(path: path, target: request.target, &block)
            end
          end
        end
      end

      def validate_signature(path:, &block)
        Steep.logger.tagged "#validate_signature(path=#{path})" do
          Steep.measure "validation" do
            # @type var accumulated_diagnostics: Array[Diagnostic::Signature::Base]
            accumulated_diagnostics = []

            project.targets.each do |target|
              service = signature_services[target.name]

              next unless target.possible_signature_file?(path) || service.env_rbs_paths.include?(path)

              case service.status
              when SignatureService::SyntaxErrorStatus
                diagnostics = service.status.diagnostics.select do |diag|
                  Pathname(diag.location.buffer.name) == path &&
                    (diag.is_a?(Diagnostic::Signature::SyntaxError) || diag.is_a?(Diagnostic::Signature::UnexpectedError))
                end
                accumulated_diagnostics.push(*diagnostics)
                unless diagnostics.empty?
                  yield [path, accumulated_diagnostics]
                end

              when SignatureService::AncestorErrorStatus
                diagnostics = service.status.diagnostics.select {|diag| Pathname(diag.location.buffer.name) == path }
                accumulated_diagnostics.push(*diagnostics)
                yield [path, accumulated_diagnostics]

              when SignatureService::LoadedStatus
                validator = Signature::Validator.new(checker: service.current_subtyping)
                type_names = service.type_names(paths: Set[path], env: service.latest_env).to_set

                unless type_names.empty?
                  Steep.measure2 "Validating #{type_names.size} types" do |sampler|
                    type_names.each do |type_name|
                      sampler.sample(type_name.to_s) do
                        case
                        when type_name.class?
                          validator.validate_one_class(type_name)
                        when type_name.interface?
                          validator.validate_one_interface(type_name)
                        when type_name.alias?
                          validator.validate_one_alias(type_name)
                        end
                      end
                    end
                  end
                end

                const_decls = service.const_decls(paths: Set[path], env: service.latest_env)
                unless const_decls.empty?
                  Steep.measure2 "Validating #{const_decls.size} constants" do |sampler|
                    const_decls.each do |name, entry|
                      sampler.sample(name.to_s) do
                        validator.validate_one_constant(name, entry)
                      end
                    end
                  end
                end

                global_decls = service.global_decls(paths: Set[path])
                unless global_decls.empty?
                  Steep.measure2 "Validating #{global_decls.size} globals" do |sampler|
                    global_decls.each do |name, entry|
                      sampler.sample(name.to_s) do
                        validator.validate_one_global(name, entry)
                      end
                    end
                  end
                end

                diagnostics = validator.each_error.select {|error| Pathname(error.location.buffer.name) == path }
                accumulated_diagnostics.push(*diagnostics)
                yield [path, accumulated_diagnostics]
              end

              signature_validation_diagnostics[target.name][path] = diagnostics
            end
          end
        end
      end

      def typecheck_source(path:, target: project.target_for_source_path(path), &block)
        return unless target

        Steep.logger.tagged "#typecheck_source(path=#{path})" do
          Steep.measure "typecheck" do
            signature_service = signature_services[target.name]
            subtyping = signature_service.current_subtyping

            if subtyping
              text = source_files[path].content
              file = type_check_file(target: target, subtyping: subtyping, path: path, text: text) { signature_service.latest_constant_resolver }
              yield [file.path, file.diagnostics]
              source_files[path] = file
            end
          end
        end
      end

      def update_signature(changes:, requests:)
        Steep.logger.tagged "#update_signature" do
          project.targets.each do |target|
            signature_service = signature_services[target.name]
            signature_changes = changes.filter {|path, _| target.possible_signature_file?(path) }

            unless signature_changes.empty?
              requests[target].signature_updated!
              signature_service.update(signature_changes)
            end
          end
        end
      end

      def update_sources(changes:, requests:)
        requests.each_value do |request|
          source_files
            .select {|path, file| request.target.possible_source_file?(path) }
            .each do |path, file|
            (changes[path] ||= []).prepend(ContentChange.string(file.content))
          end
        end

        changes.each do |path, changes|
          target = project.target_for_source_path(path)

          if target
            file = source_files[path] || SourceFile.no_data(path: path, content: "")
            content = changes.inject(file.content) {|text, change| change.apply_to(text) }
            source_files[path] = file.update_content(content)
            requests[target].source_paths << path
          end
        end
      end

      def type_check_file(target:, subtyping:, path:, text:)
        Steep.logger.tagged "#type_check_file(#{path}@#{target.name})" do
          source = Source.parse(text, path: path, factory: subtyping.factory)
          typing = TypeCheckService.type_check(source: source, subtyping: subtyping, constant_resolver: yield)
          ignores = Source::IgnoreRanges.new(ignores: source.ignores)
          SourceFile.with_typing(path: path, content: text, node: source.node, typing: typing, ignores: ignores)
        end
      rescue AnnotationParser::SyntaxError => exn
        error = Diagnostic::Ruby::SyntaxError.new(message: exn.message, location: exn.location)
        SourceFile.with_syntax_error(path: path, content: text, error: error)
      rescue ::Parser::SyntaxError => exn
        error = Diagnostic::Ruby::SyntaxError.new(message: exn.message, location: exn.diagnostic.location)
        SourceFile.with_syntax_error(path: path, content: text, error: error)
      rescue EncodingError => exn
        SourceFile.no_data(path: path, content: "")
      rescue RuntimeError => exn
        Steep.log_error(exn)
        SourceFile.no_data(path: path, content: text)
      end

      def self.type_check(source:, subtyping:, constant_resolver:)
        annotations = source.annotations(block: source.node, factory: subtyping.factory, context: nil)

        definition = subtyping.factory.definition_builder.build_instance(AST::Builtin::Object.module_name)

        const_env = TypeInference::ConstantEnv.new(
          factory: subtyping.factory,
          context: nil,
          resolver: constant_resolver
        )
        type_env = TypeInference::TypeEnv.new(const_env)
        type_env = TypeInference::TypeEnvBuilder.new(
          TypeInference::TypeEnvBuilder::Command::ImportConstantAnnotations.new(annotations),
          TypeInference::TypeEnvBuilder::Command::ImportGlobalDeclarations.new(subtyping.factory),
          TypeInference::TypeEnvBuilder::Command::ImportInstanceVariableDefinition.new(definition, subtyping.factory),
          TypeInference::TypeEnvBuilder::Command::ImportInstanceVariableAnnotations.new(annotations),
          TypeInference::TypeEnvBuilder::Command::ImportLocalVariableAnnotations.new(annotations)
        ).build(type_env)

        context = TypeInference::Context.new(
          block_context: nil,
          module_context: TypeInference::Context::ModuleContext.new(
            instance_type: AST::Builtin::Object.instance_type,
            module_type: AST::Builtin::Object.module_type,
            implement_name: nil,
            nesting: nil,
            class_name: AST::Builtin::Object.module_name,
            instance_definition: subtyping.factory.definition_builder.build_instance(AST::Builtin::Object.module_name),
            module_definition: subtyping.factory.definition_builder.build_singleton(AST::Builtin::Object.module_name)
          ),
          method_context: nil,
          break_context: nil,
          self_type: AST::Builtin::Object.instance_type,
          type_env: type_env,
          call_context: TypeInference::MethodCall::TopLevelContext.new,
          variable_context: TypeInference::Context::TypeVariableContext.empty
        )

        typing = Typing.new(source: source, root_context: context)

        construction = TypeConstruction.new(
          checker: subtyping,
          annotations: annotations,
          source: source,
          context: context,
          typing: typing
        )

        construction.synthesize(source.node) if source.node

        typing
      end

      def source_file?(path)
        if source_files.key?(path)
          project.target_for_source_path(path)
        end
      end

      def signature_file?(path)
        relative_path = project.relative_path(path)
        targets = signature_services.select {|_, sig| sig.files.key?(relative_path) || sig.env_rbs_paths.include?(path) }
        unless targets.empty?
          targets.keys
        end
      end

      def app_signature_file?(path)
        target_names = signature_services.select {|_, sig| sig.files.key?(path) }.keys
        unless target_names.empty?
          target_names
        end
      end

      def lib_signature_file?(path)
        signature_services.each_value.any? {|sig| sig.env_rbs_paths.include?(path) }
      end
    end
  end
end