Repository: quora/pyanalyze
Pull request #372 (2021-12-28 11:10:09)

Overhaul treatment of function definitions


Patch:
diff --git a/docs/changelog.md b/docs/changelog.md
index 5ea5194..73889e7 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -2,6 +2,11 @@
 
 ## Unreleased
 
+- Overhaul treatment of function definitions (#372)
+  - Support positional-only arguments
+  - Infer more precise types for lambda functions
+  - Infer more precise types for nested functions
+  - Refactor related code
 - Add check for incompatible overrides in child classes
   (#371)
 - Add `pyanalyze.extensions.NoReturnGuard` (#370)
diff --git a/pyanalyze/__init__.py b/pyanalyze/__init__.py
index f08df53..802f6e2 100644
--- a/pyanalyze/__init__.py
+++ b/pyanalyze/__init__.py
@@ -19,6 +19,7 @@ from . import error_code
 from . import extensions
 from . import find_unused
 from .find_unused import used as used
+from . import functions
 from . import implementation
 from . import method_return_type
 from . import node_visitor
@@ -50,3 +51,4 @@ used(checker)
 used(suggested_type)
 used(options)
 used(shared_options)
+used(functions)
diff --git a/pyanalyze/asynq_checker.py b/pyanalyze/asynq_checker.py
index 3327e89..9f26451 100644
--- a/pyanalyze/asynq_checker.py
+++ b/pyanalyze/asynq_checker.py
@@ -8,41 +8,20 @@ import ast
 import asynq
 import contextlib
 from dataclasses import dataclass, field
-import enum
 import qcore
 import inspect
 import types
-from typing import Sequence, Any, Callable, List, Optional, Iterable
-
-from pyanalyze.options import Options, PyObjectSequenceOption, StringSequenceOption
+from typing import Iterator, Sequence, Any, Callable, Optional
 
 from .config import Config
 from .error_code import ErrorCode
+from .functions import AsyncFunctionKind
+from .options import Options, PyObjectSequenceOption, StringSequenceOption
 from .safe import safe_getattr, safe_hasattr
 from .stacked_scopes import Composite
 from .value import AnnotatedValue, Value, KnownValue, TypedValue, UnboundMethodValue
 
 
-class AsyncFunctionKind(enum.Enum):
-    non_async = 0
-    normal = 1
-    async_proxy = 2
-    pure = 3
-
-
-@dataclass(frozen=True)
-class FunctionInfo:
-    async_kind: AsyncFunctionKind
-    is_classmethod: bool  # has @classmethod
-    is_staticmethod: bool  # has @staticmethod
-    is_decorated_coroutine: bool  # has @asyncio.coroutine
-    is_overload: bool  # typing.overload or pyanalyze.extensions.overload
-    # a list of pairs of (decorator function, applied decorator function). These are different
-    # for decorators that take arguments, like @asynq(): the first element will be the asynq
-    # function and the second will be the result of calling asynq().
-    decorators: List[Any]
-
-
 class ClassesCheckedForAsynq(PyObjectSequenceOption[type]):
     """Normally, asynq calls to asynq functions are only enforced in functions that are already
     asynq. In subclasses of classes listed here, all asynq functions must be called asynq."""
@@ -98,7 +77,7 @@ class AsynqChecker:
         name: str,
         async_kind: AsyncFunctionKind = AsyncFunctionKind.non_async,
         is_classmethod: bool = False,
-    ) -> Iterable[None]:
+    ) -> Iterator[None]:
         """Sets the current function name for async data collection."""
         # Override current_func_name only if this is the outermost function, so that data access
         # within nested functions is attributed to the outer function. However, for async inner
diff --git a/pyanalyze/functions.py b/pyanalyze/functions.py
new file mode 100644
index 0000000..d8331ea
--- /dev/null
+++ b/pyanalyze/functions.py
@@ -0,0 +1,337 @@
+"""
+
+Code for understanding function definitions.
+
+"""
+from abc import abstractmethod
+import ast
+import asyncio
+import types
+import asynq
+import enum
+from dataclasses import dataclass, replace
+from itertools import zip_longest
+from typing import Iterable, List, Optional, Sequence, Tuple, TypeVar, Union
+from typing_extensions import Protocol
+
+from .config import Config
+from .error_code import ErrorCode
+from .extensions import overload, real_overload
+from .options import Options, PyObjectSequenceOption
+from .node_visitor import ErrorContext
+from .signature import SigParameter, ParameterKind, Signature
+from .stacked_scopes import Composite
+from .value import (
+    CallableValue,
+    CanAssignContext,
+    TypedValue,
+    Value,
+    AnySource,
+    AnyValue,
+    KnownValue,
+    GenericValue,
+    SubclassValue,
+    TypeVarValue,
+    unite_values,
+    CanAssignError,
+)
+
+FunctionNode = Union[ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda]
+IMPLICIT_CLASSMETHODS = ("__init_subclass__", "__new__")
+
+
+class AsyncFunctionKind(enum.Enum):
+    non_async = 0
+    normal = 1
+    async_proxy = 2
+    pure = 3
+
+
+@dataclass(frozen=True)
+class ParamInfo:
+    param: SigParameter
+    node: ast.AST
+    is_self: bool = False
+
+
+@dataclass(frozen=True)
+class FunctionInfo:
+    """Computed before visiting a function."""
+
+    async_kind: AsyncFunctionKind
+    is_classmethod: bool  # has @classmethod
+    is_staticmethod: bool  # has @staticmethod
+    is_decorated_coroutine: bool  # has @asyncio.coroutine
+    is_overload: bool  # typing.overload or pyanalyze.extensions.overload
+    is_abstractmethod: bool  # has @abstractmethod
+    # a list of pairs of (decorator function, applied decorator function). These are different
+    # for decorators that take arguments, like @asynq(): the first element will be the asynq
+    # function and the second will be the result of calling asynq().
+    decorators: List[Tuple[Value, Value]]
+    node: FunctionNode
+    params: Sequence[ParamInfo]
+    return_annotation: Optional[Value]
+    potential_function: Optional[object]
+
+
+@dataclass
+class FunctionResult:
+    """Computed after visiting a function."""
+
+    return_value: Value = AnyValue(AnySource.inference)
+    parameters: Sequence[SigParameter] = ()
+    has_return: bool = False
+    is_generator: bool = False
+    has_return_annotation: bool = False
+
+
+class Context(ErrorContext, CanAssignContext, Protocol):
+    options: Options
+
+    def visit_expression(self, __node: ast.AST) -> Value:
+        raise NotImplementedError
+
+    def value_of_annotation(self, __node: ast.expr) -> Value:
+        raise NotImplementedError
+
+    def check_call(
+        self,
+        node: ast.AST,
+        callee: Value,
+        args: Iterable[Composite],
+        *,
+        allow_call: bool = False,
+    ) -> Value:
+        raise NotImplementedError
+
+
+class AsynqDecorators(PyObjectSequenceOption[object]):
+    """Decorators that are equivalent to asynq.asynq."""
+
+    default_value = [asynq.asynq]
+    name = "asynq_decorators"
+
+    @classmethod
+    def get_value_from_fallback(cls, fallback: Config) -> Sequence[object]:
+        return list(fallback.ASYNQ_DECORATORS)
+
+
+class AsyncProxyDecorators(PyObjectSequenceOption[object]):
+    """Decorators that are equivalent to asynq.async_proxy."""
+
+    default_value = [asynq.async_proxy]
+    name = "async_proxy_decorators"
+
+    @classmethod
+    def get_value_from_fallback(cls, fallback: Config) -> Sequence[object]:
+        return list(fallback.ASYNC_PROXY_DECORATORS)
+
+
+class SafeDecoratorsForNestedFunctions(PyObjectSequenceOption[object]):
+    """These decorators can safely be applied to nested functions."""
+
+    name = "safe_decorators_for_nested_functions"
+    default_value = [asynq.asynq, classmethod, staticmethod, asyncio.coroutine]
+
+    @classmethod
+    def get_value_from_fallback(cls, fallback: Config) -> Sequence[object]:
+        return list(fallback.SAFE_DECORATORS_FOR_NESTED_FUNCTIONS)
+
+
+def compute_function_info(
+    node: FunctionNode,
+    ctx: Context,
+    *,
+    is_nested_in_class: bool = False,
+    enclosing_class: Optional[TypedValue] = None,
+    potential_function: Optional[object] = None,
+) -> FunctionInfo:
+    """Visits a function's decorator list."""
+    async_kind = AsyncFunctionKind.non_async
+    is_classmethod = False
+    is_decorated_coroutine = False
+    is_staticmethod = False
+    is_overload = False
+    is_abstractmethod = False
+    decorators = []
+    for decorator in [] if isinstance(node, ast.Lambda) else node.decorator_list:
+        # We have to descend into the Call node because the result of
+        # asynq.asynq() is a one-off function that we can't test against.
+        # This means that the decorator will be visited more than once, which seems OK.
+        if isinstance(decorator, ast.Call):
+            decorator_value = ctx.visit_expression(decorator)
+            callee = ctx.visit_expression(decorator.func)
+            if isinstance(callee, KnownValue):
+                if AsynqDecorators.contains(callee.val, ctx.options):
+                    if any(kw.arg == "pure" for kw in decorator.keywords):
+                        async_kind = AsyncFunctionKind.pure
+                    else:
+                        async_kind = AsyncFunctionKind.normal
+                elif AsyncProxyDecorators.contains(callee.val, ctx.options):
+                    # @async_proxy(pure=True) is a noop, so don't treat it specially
+                    if not any(kw.arg == "pure" for kw in decorator.keywords):
+                        async_kind = AsyncFunctionKind.async_proxy
+            decorators.append((callee, decorator_value))
+        else:
+            decorator_value = ctx.visit_expression(decorator)
+            if decorator_value == KnownValue(classmethod):
+                is_classmethod = True
+            elif decorator_value == KnownValue(staticmethod):
+                is_staticmethod = True
+            elif decorator_value == KnownValue(asyncio.coroutine):
+                is_decorated_coroutine = True
+            elif decorator_value == KnownValue(
+                real_overload
+            ) or decorator_value == KnownValue(overload):
+                is_overload = True
+            elif decorator_value == KnownValue(abstractmethod):
+                is_abstractmethod = True
+            decorators.append((decorator_value, decorator_value))
+    params = compute_parameters(
+        node,
+        enclosing_class,
+        ctx,
+        is_nested_in_class=is_nested_in_class,
+        is_classmethod=is_classmethod,
+        is_staticmethod=is_staticmethod,
+    )
+    if isinstance(node, ast.Lambda) or node.returns is None:
+        return_annotation = None
+    else:
+        return_annotation = ctx.value_of_annotation(node.returns)
+    return FunctionInfo(
+        async_kind=async_kind,
+        is_decorated_coroutine=is_decorated_coroutine,
+        is_classmethod=is_classmethod,
+        is_staticmethod=is_staticmethod,
+        is_abstractmethod=is_abstractmethod,
+        is_overload=is_overload,
+        decorators=decorators,
+        node=node,
+        params=params,
+        return_annotation=return_annotation,
+        potential_function=potential_function,
+    )
+
+
+def _visit_default(node: ast.AST, ctx: Context) -> Value:
+    val = ctx.visit_expression(node)
+    if val == KnownValue(...):
+        return AnyValue(AnySource.unannotated)
+    return val
+
+
+def compute_parameters(
+    node: FunctionNode,
+    enclosing_class: Optional[TypedValue],
+    ctx: Context,
+    *,
+    is_nested_in_class: bool = False,
+    is_staticmethod: bool = False,
+    is_classmethod: bool = False,
+) -> Sequence[ParamInfo]:
+    """Visits and checks the arguments to a function."""
+    defaults = [_visit_default(node, ctx) for node in node.args.defaults]
+    kw_defaults = [
+        None if kw_default is None else _visit_default(kw_default, ctx)
+        for kw_default in node.args.kw_defaults
+    ]
+
+    posonly_args = getattr(node.args, "posonlyargs", [])
+    num_without_defaults = len(node.args.args) + len(posonly_args) - len(defaults)
+    defaults = [*[None] * num_without_defaults, *defaults, *kw_defaults]
+    args: List[Tuple[ParameterKind, ast.arg]] = (
+        [(ParameterKind.POSITIONAL_ONLY, arg) for arg in posonly_args]
+        + [(ParameterKind.POSITIONAL_OR_KEYWORD, arg) for arg in node.args.args]
+        + [(ParameterKind.KEYWORD_ONLY, arg) for arg in node.args.kwonlyargs]
+    )
+    if node.args.vararg is not None:
+        args.append((ParameterKind.VAR_POSITIONAL, node.args.vararg))
+    if node.args.kwarg is not None:
+        args.append((ParameterKind.VAR_KEYWORD, node.args.kwarg))
+    params = []
+    tv_index = 1
+
+    for idx, ((kind, arg), default) in enumerate(zip_longest(args, defaults)):
+        is_self = (
+            idx == 0
+            and enclosing_class is not None
+            and not is_staticmethod
+            and not isinstance(node, ast.Lambda)
+        )
+        if arg.annotation is not None:
+            value = ctx.value_of_annotation(arg.annotation)
+            if default is not None:
+                tv_map = value.can_assign(default, ctx)
+                if isinstance(tv_map, CanAssignError):
+                    ctx.show_error(
+                        arg,
+                        f"Default value for argument {arg.arg} incompatible"
+                        f" with declared type {value}",
+                        error_code=ErrorCode.incompatible_default,
+                        detail=tv_map.display(),
+                    )
+        elif is_self:
+            assert enclosing_class is not None
+            if is_classmethod or getattr(node, "name", None) in IMPLICIT_CLASSMETHODS:
+                value = SubclassValue(enclosing_class)
+            else:
+                # normal method
+                value = enclosing_class
+        else:
+            # This is meant to exclude methods in nested classes. It's a bit too
+            # conservative for cases such as a function nested in a method nested in a
+            # class nested in a function.
+            if not isinstance(node, ast.Lambda) and not (
+                idx == 0 and not is_staticmethod and is_nested_in_class
+            ):
+                ctx.show_error(
+                    node,
+                    f"Missing type annotation for parameter {arg.arg}",
+                    error_code=ErrorCode.missing_parameter_annotation,
+                )
+            if isinstance(node, ast.Lambda):
+                value = TypeVarValue(TypeVar(f"T{tv_index}"))
+                tv_index += 1
+            else:
+                value = AnyValue(AnySource.unannotated)
+            if default is not None:
+                value = unite_values(value, default)
+
+        if kind is ParameterKind.VAR_POSITIONAL:
+            value = GenericValue(tuple, [value])
+        elif kind is ParameterKind.VAR_KEYWORD:
+            value = GenericValue(dict, [TypedValue(str), value])
+
+        param = SigParameter(arg.arg, kind, default, value)
+        info = ParamInfo(param, arg, is_self)
+        params.append(info)
+    return params
+
+
+def compute_value_of_function(
+    info: FunctionInfo, ctx: Context, *, result: Optional[Value] = None
+) -> Value:
+    if result is None:
+        result = info.return_annotation
+    if result is None:
+        result = AnyValue(AnySource.unannotated)
+    sig = Signature.make(
+        [param_info.param for param_info in info.params],
+        result,
+        has_return_annotation=info.return_annotation is not None,
+    )
+    val = CallableValue(sig, types.FunctionType)
+    for unapplied, decorator in reversed(info.decorators):
+        # Special case asynq.asynq until we can create the type automatically
+        if unapplied == KnownValue(asynq.asynq) and isinstance(val, CallableValue):
+            sig = replace(val.signature, is_asynq=True)
+            val = CallableValue(sig, val.typ)
+            continue
+        allow_call = isinstance(
+            unapplied, KnownValue
+        ) and SafeDecoratorsForNestedFunctions.contains(unapplied.val, ctx.options)
+        val = ctx.check_call(
+            info.node, decorator, [Composite(val)], allow_call=allow_call
+        )
+    return val
diff --git a/pyanalyze/name_check_visitor.py b/pyanalyze/name_check_visitor.py
index 3019fe1..7a5ebdb 100644
--- a/pyanalyze/name_check_visitor.py
+++ b/pyanalyze/name_check_visitor.py
@@ -7,7 +7,6 @@ type inference. It is the central object that invokes other parts of
 the system.
 
 """
-from abc import abstractmethod
 from argparse import ArgumentParser
 import ast
 import enum
@@ -18,7 +17,6 @@ import collections
 import collections.abc
 import contextlib
 from dataclasses import dataclass
-import inspect
 from itertools import chain
 import logging
 import operator
@@ -33,7 +31,6 @@ import sys
 import tempfile
 import traceback
 import types
-import typing
 from typing import (
     ClassVar,
     ContextManager,
@@ -56,7 +53,6 @@ from typing_extensions import Annotated
 
 import asynq
 import qcore
-from qcore.helpers import safe_str
 
 from . import attributes, format_strings, node_visitor, importer, method_return_type
 from .annotations import (
@@ -70,7 +66,7 @@ from .boolability import Boolability, get_boolability
 from .checker import Checker
 from .config import Config
 from .error_code import ErrorCode, DISABLED_BY_DEFAULT, ERROR_DESCRIPTION
-from .extensions import ParameterTypeGuard, overload
+from .extensions import ParameterTypeGuard
 from .find_unused import UnusedObjectFinder, used
 from .options import (
     ConfigOption,
@@ -79,11 +75,10 @@ from .options import (
     InvalidConfigOption,
     IntegerOption,
     Options,
-    PyObjectSequenceOption,
     StringSequenceOption,
 )
 from .shared_options import Paths, ImportPaths, EnforceNoUnused
-from .reexport import ErrorContext, ImplicitReexportTracker
+from .reexport import ImplicitReexportTracker
 from .safe import safe_getattr, is_hashable, all_of_type
 from .stacked_scopes import (
     AbstractConstraint,
@@ -117,6 +112,7 @@ from .signature import (
     ConcreteSignature,
     MaybeSignature,
     OverloadedSignature,
+    SigParameter,
     Signature,
     make_bound_method,
     ARGS,
@@ -128,7 +124,16 @@ from .suggested_type import (
     prepare_type,
     should_suggest_type,
 )
-from .asynq_checker import AsyncFunctionKind, AsynqChecker, FunctionInfo
+from .asynq_checker import AsynqChecker
+from .functions import (
+    AsyncFunctionKind,
+    FunctionInfo,
+    FunctionResult,
+    FunctionNode,
+    compute_function_info,
+    IMPLICIT_CLASSMETHODS,
+    compute_value_of_function,
+)
 from .yield_checker import YieldChecker
 from .type_object import TypeObject, get_mro
 from .value import (
@@ -173,9 +178,7 @@ AwaitableValue = GenericValue(collections.abc.Awaitable, [TypeVarValue(T)])
 KnownNone = KnownValue(None)
 ExceptionValue = TypedValue(BaseException) | SubclassValue(TypedValue(BaseException))
 ExceptionOrNone = ExceptionValue | KnownNone
-FunctionNode = Union[ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda]
 
-IMPLICIT_CLASSMETHODS = ("__init_subclass__", "__new__")
 
 BINARY_OPERATION_TO_DESCRIPTION_AND_METHOD = {
     ast.Add: ("addition", "__add__", "__iadd__", "__radd__"),
@@ -311,23 +314,6 @@ class _AttrContext(attributes.AttrContext):
         return self.visitor.get_generic_bases(typ, generic_args)
 
 
-# FunctionInfo for a vanilla function (e.g. a lambda)
-_DEFAULT_FUNCTION_INFO = FunctionInfo(
-    AsyncFunctionKind.normal, False, False, False, False, []
-)
-
-
-class SafeDecoratorsForNestedFunctions(PyObjectSequenceOption[object]):
-    """These decorators can safely be applied to nested functions."""
-
-    name = "safe_decorators_for_nested_functions"
-    default_value = [asynq.asynq, classmethod, staticmethod, asyncio.coroutine]
-
-    @classmethod
-    def get_value_from_fallback(cls, fallback: Config) -> Sequence[object]:
-        return list(fallback.SAFE_DECORATORS_FOR_NESTED_FUNCTIONS)
-
-
 class ComprehensionLengthInferenceLimit(IntegerOption):
     """If we iterate over something longer than this, we don't try to infer precise
     types for comprehensions. Increasing this can hurt performance."""
@@ -351,28 +337,6 @@ class UnionSimplificationLimit(IntegerOption):
         return fallback.UNION_SIMPLIFICATION_LIMIT
 
 
-class AsynqDecorators(PyObjectSequenceOption[object]):
-    """Decorators that are equivalent to asynq.asynq."""
-
-    default_value = [asynq.asynq]
-    name = "asynq_decorators"
-
-    @classmethod
-    def get_value_from_fallback(cls, fallback: Config) -> Sequence[object]:
-        return list(fallback.ASYNQ_DECORATORS)
-
-
-class AsyncProxyDecorators(PyObjectSequenceOption[object]):
-    """Decorators that are equivalent to asynq.async_proxy."""
-
-    default_value = [asynq.async_proxy]
-    name = "async_proxy_decorators"
-
-    @classmethod
-    def get_value_from_fallback(cls, fallback: Config) -> Sequence[object]:
-        return list(fallback.ASYNC_PROXY_DECORATORS)
-
-
 class DisallowCallsToDunders(StringSequenceOption):
     """Set of dunder methods (e.g., '{"__lshift__"}') that pyanalyze is not allowed to call on
     objects."""
@@ -894,9 +858,7 @@ class CallSiteCollector:
             pass
 
 
-class NameCheckVisitor(
-    node_visitor.ReplacingNodeVisitor, CanAssignContext, ErrorContext
-):
+class NameCheckVisitor(node_visitor.ReplacingNodeVisitor, CanAssignContext):
     """Visitor class that infers the type and value of Python objects and detects errors."""
 
     error_code_enum = ErrorCode
@@ -907,6 +869,7 @@ class NameCheckVisitor(
     """Path (relative to this class's file) to a pyproject.toml config file."""
 
     checker: Checker
+    options: Options
     arg_spec_cache: ArgSpecCache
     reexport_tracker: ImplicitReexportTracker
     being_assigned: Value
@@ -1551,180 +1514,155 @@ class NameCheckVisitor(
         return AnyValue(AnySource.inference)
 
     def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> Value:
-        return self.visit_FunctionDef(node, is_coroutine=True)
+        return self.visit_FunctionDef(node)
 
     def visit_FunctionDef(
-        self,
-        node: Union[ast.FunctionDef, ast.AsyncFunctionDef],
-        is_coroutine: bool = False,
+        self, node: Union[ast.FunctionDef, ast.AsyncFunctionDef]
     ) -> Value:
-        with qcore.override(self, "current_class", None):
-            info = self._visit_decorators_and_check_asynq(node.decorator_list)
-        defaults, kw_defaults = self._visit_defaults(node)
-
-        scope_type = self.scopes.scope_type()
-        if scope_type == ScopeType.module_scope and self.module is not None:
-            potential_function = safe_getattr(self.module, node.name, None)
-        elif scope_type == ScopeType.class_scope and self.current_class is not None:
-            potential_function = safe_getattr(self.current_class, node.name, None)
-        else:
-            potential_function = None
-
-        if (
-            potential_function is not None
-            and self.options.is_error_code_enabled_anywhere(
-                ErrorCode.suggested_parameter_type
-            )
-        ):
-            sig = self.signature_from_value(KnownValue(potential_function))
-            if isinstance(sig, Signature):
-                self.checker.callable_tracker.record_callable(
-                    node, potential_function, sig, self
-                )
+        potential_function = self._get_potential_function(node)
+        info = compute_function_info(
+            node,
+            self,
+            enclosing_class=TypedValue(self.current_class)
+            if self.current_class is not None
+            else None,
+            is_nested_in_class=self.node_context.includes(ast.ClassDef),
+            potential_function=potential_function,
+        )
 
         self.yield_checker.reset_yield_checks()
 
-        # This code handles nested functions
-        evaled_function = None
-        if potential_function is None:
-            if scope_type != ScopeType.function_scope:
-                self.log(
-                    logging.INFO, "Failed to find function", (node.name, scope_type)
-                )
-            evaled_function = self._get_evaled_function(node, info.decorators)
-            # evaled_function should be a KnownValue of a function unless a decorator messed it up.
-            if isinstance(evaled_function, KnownValue):
-                potential_function = evaled_function.val
-
-        if node.returns is not None:
-            return_annotation = self._visit_annotation(node.returns)
-            expected_return_value = self._value_of_annotation_type(
-                return_annotation, node.returns
-            )
-        else:
+        if node.returns is None:
             self._show_error_if_checking(
                 node, error_code=ErrorCode.missing_return_annotation
             )
-            expected_return_value = None
 
+        if potential_function is None:
+            val = compute_value_of_function(info, self)
+        else:
+            val = KnownValue(potential_function)
         if not info.is_overload:
-            if evaled_function is not None:
-                self._set_name_in_scope(node.name, node, evaled_function)
-            else:
-                self._set_name_in_scope(node.name, node, KnownValue(potential_function))
+            self._set_name_in_scope(node.name, node, val)
 
         with self.asynq_checker.set_func_name(
             node.name, async_kind=info.async_kind, is_classmethod=info.is_classmethod
         ), qcore.override(self, "yield_checker", YieldChecker(self)), qcore.override(
-            self, "is_async_def", is_coroutine
+            self, "is_async_def", isinstance(node, ast.AsyncFunctionDef)
         ), qcore.override(
             self, "current_function_name", node.name
         ), qcore.override(
             self, "current_function", potential_function
         ), qcore.override(
-            self, "expected_return_value", expected_return_value
+            self, "expected_return_value", info.return_annotation
         ):
-            return_value, has_return, is_generator = self._visit_function_body(
-                node,
-                function_info=info,
-                name=node.name,
-                defaults=defaults,
-                kw_defaults=kw_defaults,
-            )
+            result = self._visit_function_body(info)
 
         if (
-            not has_return
+            not result.has_return
             and not info.is_overload
-            and expected_return_value is not None
-            and expected_return_value != KnownNone
+            and node.returns is not None
+            and info.return_annotation != KnownNone
             and not (
-                isinstance(expected_return_value, AnnotatedValue)
-                and expected_return_value.value == KnownNone
-            )
-            and not any(
-                decorator == KnownValue(abstractmethod)
-                for _, decorator in info.decorators
+                isinstance(info.return_annotation, AnnotatedValue)
+                and info.return_annotation.value == KnownNone
             )
+            and not info.is_abstractmethod
         ):
-            if expected_return_value is NO_RETURN_VALUE:
+            if info.return_annotation is NO_RETURN_VALUE:
                 self._show_error_if_checking(
                     node, error_code=ErrorCode.no_return_may_return
                 )
             else:
                 self._show_error_if_checking(node, error_code=ErrorCode.missing_return)
 
-        if (
-            has_return
-            and expected_return_value is None
-            and not info.is_overload
-            and not any(
-                decorator == KnownValue(abstractmethod)
-                for _, decorator in info.decorators
-            )
-        ):
-            prepared = prepare_type(return_value)
-            if should_suggest_type(prepared):
-                detail, metadata = display_suggested_type(prepared)
+        if node.returns is None:
+            if (
+                result.has_return
+                and not info.is_overload
+                and not info.is_abstractmethod
+            ):
+                prepared = prepare_type(result.return_value)
+                if should_suggest_type(prepared):
+                    detail, metadata = display_suggested_type(prepared)
+                    self._show_error_if_checking(
+                        node,
+                        error_code=ErrorCode.suggested_return_type,
+                        detail=detail,
+                        extra_metadata=metadata,
+                    )
+
+            if info.async_kind == AsyncFunctionKind.normal and _is_asynq_future(
+                result.return_value
+            ):
                 self._show_error_if_checking(
-                    node,
-                    error_code=ErrorCode.suggested_return_type,
-                    detail=detail,
-                    extra_metadata=metadata,
+                    node, error_code=ErrorCode.task_needs_yield
                 )
 
-        if evaled_function:
-            return evaled_function
+        self._set_argspec_to_retval(val, info, result)
+        return val
 
-        if info.async_kind == AsyncFunctionKind.normal and _is_asynq_future(
-            return_value
+    def _set_argspec_to_retval(
+        self, val: Value, info: FunctionInfo, result: FunctionResult
+    ) -> None:
+        if isinstance(info.node, ast.Lambda) or info.node.returns is not None:
+            return
+        if info.async_kind == AsyncFunctionKind.async_proxy:
+            # Don't attempt to infer the return value of async_proxy functions, since it will be
+            # set within the Future returned. Without this, we'll incorrectly infer the return
+            # value to be the Future instead of the Future's value.
+            return
+        if info.node.decorator_list and not (
+            len(info.decorators) == 1
+            and info.decorators[0][0]
+            in (
+                KnownValue(asynq.asynq),
+                KnownValue(asyncio.coroutine),
+                KnownValue(property),
+            )
         ):
-            self._show_error_if_checking(node, error_code=ErrorCode.task_needs_yield)
+            return  # With decorators we don't know what it will return
+        return_value = result.return_value
 
-        # If there was an annotation, use it as the return value in the
-        # _argspec_to_retval cache, even if we inferred something else while visiting
-        # the function.
-        if not is_generator and expected_return_value is not None:
-            return_value = expected_return_value
-
-        if is_generator and return_value == KnownNone:
+        if result.is_generator and return_value == KnownNone:
             return_value = AnyValue(AnySource.inference)
 
         # pure async functions are otherwise incorrectly inferred as returning whatever the
         # underlying function returns
         if info.async_kind == AsyncFunctionKind.pure:
-            return_value = AsyncTaskIncompleteValue(
-                _get_task_cls(potential_function), return_value
-            )
-
-        # If a decorator turned the function async, believe it. This fixes one
-        # instance of the general problem where a decorator can change the return
-        # type, but we still look at the original function's annotation. A more
-        # principled fix for such issues will have to wait for better support for
-        # callable types.
-        if potential_function is not None and not is_coroutine:
-            is_coroutine = _is_coroutine_function(potential_function)
+            task_cls = _get_task_cls(info.potential_function)
+            return_value = AsyncTaskIncompleteValue(task_cls, return_value)
 
-        if is_coroutine or info.is_decorated_coroutine:
+        if isinstance(info.node, ast.AsyncFunctionDef) or info.is_decorated_coroutine:
             return_value = GenericValue(collections.abc.Awaitable, [return_value])
 
-        try:
-            argspec = self.arg_spec_cache.get_argspec(potential_function)
-        except TypeError:
-            return KnownValue(potential_function)
-        if argspec is not None:
-            if (
-                info.async_kind != AsyncFunctionKind.async_proxy
-                and node.returns is None
-            ):
-                # Don't attempt to infer the return value of async_proxy functions, since it will be
-                # set within the Future returned. Without this, we'll incorrectly infer the return
-                # value to be the Future instead of the Future's value.
-                # Similarly, we don't infer the return value if there is an annotation, because
-                # we should be able to get it from __annotations__ instead.
-                self._argspec_to_retval[id(argspec)] = return_value
+        sig = self.signature_from_value(val)
+        if sig is None:
+            return
+        self._argspec_to_retval[id(sig)] = return_value
+
+    def _get_potential_function(
+        self, node: Union[ast.FunctionDef, ast.AsyncFunctionDef]
+    ) -> Optional[object]:
+        scope_type = self.scopes.scope_type()
+        if scope_type == ScopeType.module_scope and self.module is not None:
+            potential_function = safe_getattr(self.module, node.name, None)
+        elif scope_type == ScopeType.class_scope and self.current_class is not None:
+            potential_function = safe_getattr(self.current_class, node.name, None)
         else:
-            self.log(logging.DEBUG, "No argspec", (potential_function, node))
-        return KnownValue(potential_function)
+            potential_function = None
+
+        if (
+            potential_function is not None
+            and self.options.is_error_code_enabled_anywhere(
+                ErrorCode.suggested_parameter_type
+            )
+        ):
+            sig = self.signature_from_value(KnownValue(potential_function))
+            if isinstance(sig, Signature):
+                self.checker.callable_tracker.record_callable(
+                    node, potential_function, sig, self
+                )
+        return potential_function
 
     def record_call(self, callable: object, arguments: CallArgs) -> None:
         if self.options.is_error_code_enabled_anywhere(
@@ -1732,141 +1670,30 @@ class NameCheckVisitor(
         ):
             self.checker.callable_tracker.record_call(callable, arguments)
 
-    def _visit_defaults(
-        self, node: FunctionNode
-    ) -> Tuple[List[Value], List[Optional[Value]]]:
-        with qcore.override(self, "current_class", None):
-            defaults = self._generic_visit_list(node.args.defaults)
-            kw_defaults = [
-                None if kw_default is None else self.visit(kw_default)
-                for kw_default in node.args.kw_defaults
-            ]
-            return defaults, kw_defaults
-
-    def _get_evaled_function(
-        self,
-        node: Union[ast.FunctionDef, ast.AsyncFunctionDef],
-        decorators: Sequence[Tuple[Value, Value]],
-    ) -> Value:
-        to_apply = []
-        for decorator, applied_decorator in decorators:
-            if (
-                not isinstance(decorator, KnownValue)
-                or not isinstance(applied_decorator, KnownValue)
-                or not SafeDecoratorsForNestedFunctions.contains(
-                    decorator.val, self.options
-                )
-            ):
-                self.log(
-                    logging.DEBUG,
-                    "Reject nested function because of decorator",
-                    (node, decorator),
-                )
-                return TypedValue(types.FunctionType)
-            else:
-                to_apply.append(applied_decorator.val)
-        scope = {}
-        new_args = ast.arguments(
-            args=[self._strip_annotation(arg) for arg in node.args.args],
-            vararg=self._strip_annotation(node.args.vararg),
-            kwarg=self._strip_annotation(node.args.kwarg),
-            defaults=[ast.Name(id="None") for _ in node.args.defaults],
-            kwonlyargs=[self._strip_annotation(arg) for arg in node.args.kwonlyargs],
-            kw_defaults=[ast.Name(id="None") for _ in node.args.kw_defaults],
-        )
-        new_node = ast.FunctionDef(
-            name=node.name, args=new_args, body=[ast.Pass()], decorator_list=[]
-        )
-        code = decompile(new_node)
-        exec(code, scope, scope)
-        fn = scope[node.name]
-        for decorator in reversed(to_apply):
-            fn = decorator(fn)
-        try:
-            fn._pyanalyze_is_nested_function = True
-            fn._pyanalyze_parent_function = self.current_function
-        except AttributeError:
-            # could happen if decorator wrapped it in an object that doesn't allow attribute
-            # assignment
-            pass
-        return KnownValue(fn)
-
-    def _strip_annotation(self, node: Optional[ast.arg]) -> Optional[ast.arg]:
-        if node is None:
-            return None
-        return ast.arg(arg=node.arg, annotation=None)
-
     def visit_Lambda(self, node: ast.Lambda) -> Value:
-        defaults, kw_defaults = self._visit_defaults(node)
-
         with self.asynq_checker.set_func_name("<lambda>"):
-            self._visit_function_body(node, defaults=defaults, kw_defaults=kw_defaults)
-            return AnyValue(AnySource.inference)
+            info = compute_function_info(node, self)
+            result = self._visit_function_body(info)
+            return compute_value_of_function(info, self, result=result.return_value)
 
-    def _visit_decorators_and_check_asynq(
-        self, decorator_list: List[ast.expr]
-    ) -> FunctionInfo:
-        """Visits a function's decorator list."""
-        async_kind = AsyncFunctionKind.non_async
-        is_classmethod = False
-        is_decorated_coroutine = False
-        is_staticmethod = False
-        is_overload = False
-        decorators = []
-        for decorator in decorator_list:
-            # We have to descend into the Call node because the result of
-            # asynq.asynq() is a one-off function that we can't test against.
-            # This means that the decorator will be visited more than once, which seems OK.
-            if isinstance(decorator, ast.Call):
-                decorator_value = self.visit(decorator)
-                callee = self.visit(decorator.func)
-                if isinstance(callee, KnownValue):
-                    if AsynqDecorators.contains(callee.val, self.options):
-                        if any(kw.arg == "pure" for kw in decorator.keywords):
-                            async_kind = AsyncFunctionKind.pure
-                        else:
-                            async_kind = AsyncFunctionKind.normal
-                    elif AsyncProxyDecorators.contains(callee.val, self.options):
-                        # @async_proxy(pure=True) is a noop, so don't treat it specially
-                        if not any(kw.arg == "pure" for kw in decorator.keywords):
-                            async_kind = AsyncFunctionKind.async_proxy
-                decorators.append((callee, decorator_value))
-            else:
-                decorator_value = self.visit(decorator)
-                if decorator_value == KnownValue(classmethod):
-                    is_classmethod = True
-                elif decorator_value == KnownValue(staticmethod):
-                    is_staticmethod = True
-                elif decorator_value == KnownValue(asyncio.coroutine):
-                    is_decorated_coroutine = True
-                elif decorator_value == KnownValue(
-                    typing.overload
-                ) or decorator_value == KnownValue(overload):
-                    is_overload = True
-                decorators.append((decorator_value, decorator_value))
-        return FunctionInfo(
-            async_kind=async_kind,
-            is_decorated_coroutine=is_decorated_coroutine,
-            is_classmethod=is_classmethod,
-            is_staticmethod=is_staticmethod,
-            is_overload=is_overload,
-            decorators=decorators,
+    def _visit_function_body(self, function_info: FunctionInfo) -> FunctionResult:
+        is_collecting = self._is_collecting()
+        node = function_info.node
+
+        class_ctx = (
+            qcore.empty_context
+            if not self.scopes.is_nested_function()
+            else qcore.override(self, "current_class", None)
         )
+        with class_ctx:
+            self._check_method_first_arg(node, function_info=function_info)
+        infos = function_info.params
+        params = [info.param for info in infos]
 
-    def _visit_function_body(
-        self,
-        node: FunctionNode,
-        *,
-        function_info: FunctionInfo = _DEFAULT_FUNCTION_INFO,
-        name: Optional[str] = None,
-        defaults: Sequence[Optional[Value]],
-        kw_defaults: Sequence[Optional[Value]],
-    ) -> Tuple[Value, bool, bool]:
-        is_collecting = self._is_collecting()
         if is_collecting and not self.scopes.contains_scope_of_type(
             ScopeType.function_scope
         ):
-            return AnyValue(AnySource.inference), False, False
+            return FunctionResult()
 
         # We pass in the node to add_scope() and visit the body once in collecting
         # mode if in a nested function, so that constraints on nonlocals in the outer
@@ -1883,27 +1710,32 @@ class NameCheckVisitor(
             scope = self.scopes.current_scope()
             assert isinstance(scope, FunctionScope)
 
-            if isinstance(node.body, list):
-                body = node.body
-            else:
-                # hack for lambdas
-                body = [node.body]
-
-            class_ctx = (
-                qcore.empty_context
-                if not self.scopes.is_nested_function()
-                else qcore.override(self, "current_class", None)
-            )
-            with class_ctx:
-                self._visit_function_args(node, function_info, defaults, kw_defaults)
+            for info in infos:
+                if info.is_self:
+                    # we need this for the implementation of super()
+                    self.scopes.set(
+                        "%first_arg",
+                        info.param.annotation,
+                        "%first_arg",
+                        VisitorState.check_names,
+                    )
+                self.scopes.set(
+                    info.param.name,
+                    info.param.annotation,
+                    info.node,
+                    VisitorState.check_names,
+                )
 
             with qcore.override(
                 self, "state", VisitorState.collect_names
             ), qcore.override(self, "return_values", []):
-                self._generic_visit_list(body)
+                if isinstance(node, ast.Lambda):
+                    self.visit(node.body)
+                else:
+                    self._generic_visit_list(node.body)
                 scope.get_local(LEAVES_SCOPE, node, self.state)
             if is_collecting:
-                return AnyValue(AnySource.inference), False, self.is_generator
+                return FunctionResult(is_generator=self.is_generator, parameters=params)
 
             # otherwise we may end up using results from the last yield (generated during the
             # collect state) to evaluate the first one visited during the check state
@@ -1912,23 +1744,26 @@ class NameCheckVisitor(
             with qcore.override(self, "current_class", None), qcore.override(
                 self, "state", VisitorState.check_names
             ), qcore.override(self, "return_values", []):
-                self._generic_visit_list(body)
-                return_values = self.return_values
+                if isinstance(node, ast.Lambda):
+                    return_values = [self.visit(node.body)]
+                else:
+                    self._generic_visit_list(node.body)
+                    return_values = self.return_values
                 return_set, _ = scope.get_local(LEAVES_SCOPE, node, self.state)
 
             self._check_function_unused_vars(scope)
             return self._compute_return_type(
-                node, name, return_values, return_set, function_info
+                node, return_values, return_set, function_info, params
             )
 
     def _compute_return_type(
         self,
-        node: ast.AST,
-        name: Optional[str],
+        node: FunctionNode,
         return_values: Sequence[Optional[Value]],
         return_set: Value,
         info: FunctionInfo,
-    ) -> Tuple[Value, bool, bool]:
+        params: Sequence[SigParameter],
+    ) -> FunctionResult:
         # Ignore generators for now.
         if isinstance(return_set, AnyValue) or (
             self.is_generator and info.async_kind is not AsyncFunctionKind.normal
@@ -1940,9 +1775,9 @@ class NameCheckVisitor(
             assert False, return_set
         # if the return value was never set, the function returns None
         if not return_values:
-            if name is not None:
-                method_return_type.check_no_return(node, self, name)
-            return KnownNone, has_return, self.is_generator
+            if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
+                method_return_type.check_no_return(node, self, node.name)
+            return FunctionResult(KnownNone, params, has_return, self.is_generator)
         # None is added to return_values if the function raises an error.
         return_values = [val for val in return_values if val is not None]
         # If it only ever raises an error, we don't know what it returns. Strictly
@@ -1950,9 +1785,20 @@ class NameCheckVisitor(
         # in practice this condition often occurs in abstract methods that just
         # raise NotImplementedError.
         if not return_values:
-            return AnyValue(AnySource.inference), has_return, self.is_generator
+            ret = AnyValue(AnySource.inference)
         else:
-            return unite_values(*return_values), has_return, self.is_generator
+            ret = unite_values(*return_values)
+        if isinstance(node, ast.Lambda):
+            has_return_annotation = False
+        else:
+            has_return_annotation = node.returns is not None
+        return FunctionResult(
+            ret,
+            params,
+            has_return=has_return,
+            is_generator=self.is_generator,
+            has_return_annotation=has_return_annotation,
+        )
 
     def _check_function_unused_vars(
         self, scope: FunctionScope, enclosing_statement: Optional[ast.stmt] = None
@@ -1984,7 +1830,7 @@ class NameCheckVisitor(
                 #   [None for i in range(3)]  # error
                 #   [a for a, b in pairs]  # no error
                 #   [None for a, b in pairs]  # error
-                statement = self._name_node_to_statement[unused]
+                statement = self._name_node_to_statement.get(unused)
                 if isinstance(statement, ast.Assign):
                     # it's an assignment
                     if not (
@@ -2035,101 +1881,10 @@ class NameCheckVisitor(
                 replacement=replacement,
             )
 
-    def _visit_function_args(
-        self,
-        node: FunctionNode,
-        function_info: FunctionInfo,
-        defaults: Sequence[Optional[Value]],
-        kw_defaults: Sequence[Optional[Value]],
-    ) -> None:
-        """Visits and checks the arguments to a function. Returns the list of argument names."""
-        self._check_method_first_arg(node, function_info=function_info)
-
-        num_without_defaults = len(node.args.args) - len(defaults)
-        defaults = [*[None] * num_without_defaults, *defaults, *kw_defaults]
-        args = node.args.args + node.args.kwonlyargs
-
-        with qcore.override(self, "state", VisitorState.check_names):
-            for idx, (arg, default) in enumerate(zip(args, defaults)):
-                is_self = (
-                    idx == 0
-                    and self.current_class is not None
-                    and not function_info.is_staticmethod
-                    and not isinstance(node, ast.Lambda)
-                )
-                if arg.annotation is not None:
-                    value = self._value_of_annotated_arg(arg)
-                    if default is not None:
-                        tv_map = value.can_assign(default, self)
-                        if isinstance(tv_map, CanAssignError):
-                            self._show_error_if_checking(
-                                arg,
-                                f"Default value for argument {arg.arg} incompatible"
-                                f" with declared type {value}",
-                                error_code=ErrorCode.incompatible_default,
-                                detail=tv_map.display(),
-                            )
-                elif is_self:
-                    assert self.current_class is not None
-                    if (
-                        function_info.is_classmethod
-                        or getattr(node, "name", None) in IMPLICIT_CLASSMETHODS
-                    ):
-                        value = SubclassValue(TypedValue(self.current_class))
-                    else:
-                        # normal method
-                        value = TypedValue(self.current_class)
-                else:
-                    # This is meant to exclude methods in nested classes. It's a bit too
-                    # conservative for cases such as a function nested in a method nested in a
-                    # class nested in a function.
-                    if not isinstance(node, ast.Lambda) and not (
-                        idx == 0
-                        and not function_info.is_staticmethod
-                        and self.node_context.includes(ast.ClassDef)
-                    ):
-                        self._show_error_if_checking(
-                            node,
-                            f"Missing type annotation for parameter {arg.arg}",
-                            error_code=ErrorCode.missing_parameter_annotation,
-                        )
-                    if default is not None:
-                        value = unite_values(AnyValue(AnySource.unannotated), default)
-                    else:
-                        value = AnyValue(AnySource.unannotated)
-
-                if is_self:
-                    # we need this for the implementation of super()
-                    self.scopes.set("%first_arg", value, "%first_arg", self.state)
-
-                with qcore.override(self, "being_assigned", value):
-                    self.visit(arg)
-
-            if node.args.vararg is not None:
-                # the vararg is wrapped in an arg object
-                vararg = node.args.vararg.arg
-                arg_value = self._value_of_annotated_arg(node.args.vararg)
-                if isinstance(arg_value, AnyValue):
-                    value = TypedValue(tuple)
-                else:
-                    value = GenericValue(tuple, [arg_value])
-                self.scopes.set(vararg, value, vararg, self.state)
-            if node.args.kwarg is not None:
-                kwarg = node.args.kwarg.arg
-                arg_value = self._value_of_annotated_arg(node.args.kwarg)
-                value = GenericValue(dict, [TypedValue(str), arg_value])
-                self.scopes.set(kwarg, value, kwarg, self.state)
-
-    def _value_of_annotated_arg(self, arg: ast.arg) -> Value:
-        if arg.annotation is None:
-            return AnyValue(AnySource.unannotated)
-        # Evaluate annotations in the surrounding scope,
-        # not the function's scope.
-        with self.scopes.ignore_topmost_scope(), qcore.override(
-            self, "state", VisitorState.collect_names
-        ):
-            annotated_type = self._visit_annotation(arg.annotation)
-        return self._value_of_annotation_type(annotated_type, arg.annotation)
+    def value_of_annotation(self, node: ast.expr) -> Value:
+        with qcore.override(self, "state", VisitorState.collect_names):
+            annotated_type = self._visit_annotation(node)
+        return self._value_of_annotation_type(annotated_type, node)
 
     def _visit_annotation(self, node: ast.AST) -> Value:
         with qcore.override(self, "in_annotation", True):
@@ -2142,7 +1897,7 @@ class NameCheckVisitor(
         return type_from_value(val, visitor=self, node=node, is_typeddict=is_typeddict)
 
     def _check_method_first_arg(
-        self, node: FunctionNode, function_info: FunctionInfo = _DEFAULT_FUNCTION_INFO
+        self, node: FunctionNode, function_info: FunctionInfo
     ) -> None:
         """Makes sure the first argument to a method is self or cls."""
         if self.current_class is None:
@@ -2151,7 +1906,7 @@ class NameCheckVisitor(
         if function_info.is_staticmethod:
             return
         # try to confirm that it's actually a method
-        if not hasattr(node, "name") or not hasattr(self.current_class, node.name):
+        if isinstance(node, ast.Lambda) or not hasattr(self.current_class, node.name):
             return
         if node.name in IMPLICIT_CLASSMETHODS:
             return
@@ -3701,7 +3456,7 @@ class NameCheckVisitor(
 
         if (
             self.current_enum_members is not None
-            and self.current_function is None
+            and self.current_function_name is None
             and isinstance(value, KnownValue)
             and is_hashable(value.val)
         ):
@@ -4234,7 +3989,6 @@ class NameCheckVisitor(
             if not self._has_only_known_attributes(root_value.val) and (
                 _static_hasattr(root_value.val, "__getattr__")
                 or self._should_ignore_val(node)
-                or safe_getattr(root_value.val, "_pyanalyze_is_nested_function", False)
             ):
                 return AnyValue(AnySource.inference)
         elif isinstance(root_value, TypedValue):
@@ -4392,30 +4146,6 @@ class NameCheckVisitor(
             for keyword, arg in keywords
         )
 
-    def _try_perform_call(
-        self,
-        callee_val: Any,
-        node: ast.AST,
-        args: Iterable[KnownValue],
-        keywords: Iterable[Tuple[str, KnownValue]],
-        fallback_return: Value,
-    ) -> Value:
-        """Tries to call callee_val with the given arguments.
-
-        Falls back to fallback_return and emits an error if the call fails.
-
-        """
-        unwrapped_args = [arg.val for arg in args]
-        unwrapped_kwargs = {key: value.val for key, value in keywords}
-        try:
-            value = callee_val(*unwrapped_args, **unwrapped_kwargs)
-        except Exception as e:
-            message = "Error in {}: {}".format(safe_str(callee_val), safe_str(e))
-            self._show_error_if_checking(node, message, ErrorCode.incompatible_call)
-            return fallback_return
-        else:
-            return KnownValue(value)
-
     def check_call(
         self,
         node: ast.AST,
@@ -4877,6 +4607,10 @@ class NameCheckVisitor(
             )
             attribute_checker.filename_to_visitor.update(checker.filename_to_visitor)
 
+    # Protocol compliance
+    def visit_expression(self, node: ast.AST) -> Value:
+        return self.visit(node)
+
 
 def build_stacked_scopes(
     module: Optional[types.ModuleType], simplification_limit: Optional[int] = None
@@ -4959,14 +4693,6 @@ def _static_hasattr(value: object, attr: str) -> bool:
         return True
 
 
-def _is_coroutine_function(obj: object) -> bool:
-    try:
-        return inspect.iscoroutinefunction(obj)
-    except AttributeError:
-        # This can happen to cached classmethods.
-        return False
-
-
 def _has_annotation_for_attr(typ: type, attr: str) -> bool:
     try:
         return attr in typ.__annotations__
diff --git a/pyanalyze/node_visitor.py b/pyanalyze/node_visitor.py
index 1801341..fcfb2eb 100644
--- a/pyanalyze/node_visitor.py
+++ b/pyanalyze/node_visitor.py
@@ -26,7 +26,7 @@ import tempfile
 import builtins
 from builtins import print as real_print
 from types import ModuleType
-from typing_extensions import NotRequired, TypedDict
+from typing_extensions import NotRequired, Protocol, TypedDict
 from typing import (
     Any,
     Dict,
@@ -140,6 +140,22 @@ class Failure(TypedDict):
     extra_metadata: NotRequired[Dict[str, Any]]
 
 
+class ErrorContext(Protocol):
+    all_failures: List[Failure]
+
+    def show_error(
+        self,
+        node: ast.AST,
+        e: str,
+        error_code: Enum,
+        *,
+        detail: Optional[str] = None,
+        save: bool = True,
+        extra_metadata: Optional[Dict[str, Any]] = None,
+    ) -> Optional[Failure]:
+        raise NotImplementedError
+
+
 class BaseNodeVisitor(ast.NodeVisitor):
     """Base Visitor class that can run on all files in a/ and show detailed error messages."""
 
diff --git a/pyanalyze/reexport.py b/pyanalyze/reexport.py
index 74aec9c..c2eb061 100644
--- a/pyanalyze/reexport.py
+++ b/pyanalyze/reexport.py
@@ -6,12 +6,10 @@ Functionality for dealing with implicit reexports.
 from ast import AST
 from collections import defaultdict
 from dataclasses import InitVar, dataclass, field
-from enum import Enum
-from typing import Any, Callable, Dict, List, Optional, Sequence, Set, Tuple
+from typing import Callable, Dict, List, Sequence, Set, Tuple
 
-from pyanalyze.options import Options, PyObjectSequenceOption
-
-from .node_visitor import Failure
+from .options import Options, PyObjectSequenceOption
+from .node_visitor import ErrorContext
 from .config import Config
 from .error_code import ErrorCode
 
@@ -32,22 +30,6 @@ class ReexportConfig(PyObjectSequenceOption[_ReexportConfigProvider]):
         return (fallback.configure_reexports,)
 
 
-class ErrorContext:
-    all_failures: List[Failure]
-
-    def show_error(
-        self,
-        node: AST,
-        message: str,
-        error_code: Enum,
-        *,
-        detail: Optional[str] = None,
-        save: bool = True,
-        extra_metadata: Optional[Dict[str, Any]] = None,
-    ) -> Optional[Failure]:
-        raise NotImplementedError
-
-
 @dataclass
 class ImplicitReexportTracker:
     options: InitVar[Options]
diff --git a/pyanalyze/suggested_type.py b/pyanalyze/suggested_type.py
index 4384b6d..64fd18e 100644
--- a/pyanalyze/suggested_type.py
+++ b/pyanalyze/suggested_type.py
@@ -9,10 +9,9 @@ from dataclasses import dataclass, field
 from types import FunctionType
 from typing import Any, Dict, Iterator, List, Mapping, Optional, Sequence, Tuple, Union
 
-from pyanalyze.safe import safe_getattr, safe_isinstance
-
+from .safe import safe_getattr, safe_isinstance
 from .error_code import ErrorCode
-from .node_visitor import Failure
+from .node_visitor import Failure, ErrorContext
 from .value import (
     NO_RETURN_VALUE,
     AnnotatedValue,
@@ -33,7 +32,6 @@ from .value import (
     stringify_object,
     unite_values,
 )
-from .reexport import ErrorContext
 from .signature import Signature
 
 CallArgs = Mapping[str, Value]
diff --git a/pyanalyze/value.py b/pyanalyze/value.py
index 21c4995..e648e2b 100644
--- a/pyanalyze/value.py
+++ b/pyanalyze/value.py
@@ -160,7 +160,7 @@ class Value:
         return unite_values(other, self)
 
 
-class CanAssignContext:
+class CanAssignContext(Protocol):
     """A context passed to the :meth:`Value.can_assign` method.
 
     Provides access to various functionality used for type checking.
@@ -1157,12 +1157,16 @@ class CallableValue(TypedValue):
 
     signature: "pyanalyze.signature.ConcreteSignature"
 
-    def __init__(self, signature: "pyanalyze.signature.ConcreteSignature") -> None:
-        super().__init__(collections.abc.Callable)
+    def __init__(
+        self,
+        signature: "pyanalyze.signature.ConcreteSignature",
+        fallback: Union[type, str] = collections.abc.Callable,
+    ) -> None:
+        super().__init__(fallback)
         self.signature = signature
 
     def substitute_typevars(self, typevars: TypeVarMap) -> Value:
-        return CallableValue(self.signature.substitute_typevars(typevars))
+        return CallableValue(self.signature.substitute_typevars(typevars), self.typ)
 
     def walk_values(self) -> Iterable[Value]:
         yield self
@@ -1171,7 +1175,7 @@ class CallableValue(TypedValue):
     def get_asynq_value(self) -> Value:
         """Return the CallableValue for the .asynq attribute of an AsynqCallable."""
         sig = self.signature.get_asynq_value()
-        return CallableValue(sig)
+        return CallableValue(sig, self.typ)
 
     def can_assign(self, other: Value, ctx: CanAssignContext) -> CanAssign:
         if not isinstance(other, (MultiValuedValue, AnyValue)):


Test patch:
diff --git a/pyanalyze/test_annotations.py b/pyanalyze/test_annotations.py
index 5c387d8..1716399 100644
--- a/pyanalyze/test_annotations.py
+++ b/pyanalyze/test_annotations.py
@@ -178,7 +178,8 @@ class TestAnnotations(TestNameCheckVisitorBase):
             def capybara(a: x, *b: x, c: x, d: x = 3, **kwargs: x):
                 pass
 
-            assert_is_value(capybara, KnownValue(capybara))
+            capybara(1, c=1)
+            capybara(1, c="x")  # E: incompatible_argument
 
     @assert_passes()
     def annotations_class(self):
diff --git a/pyanalyze/test_functions.py b/pyanalyze/test_functions.py
new file mode 100644
index 0000000..71f18b2
--- /dev/null
+++ b/pyanalyze/test_functions.py
@@ -0,0 +1,142 @@
+# static analysis: ignore
+from .value import KnownValue, AnyValue, AnySource
+from .error_code import ErrorCode
+from .implementation import assert_is_value
+from .test_node_visitor import assert_passes, skip_before
+from .test_name_check_visitor import TestNameCheckVisitorBase
+
+
+class TestNestedFunction(TestNameCheckVisitorBase):
+    @assert_passes()
+    def test_inference(self):
+        def capybara():
+            def nested():
+                pass
+
+            class NestedClass(object):
+                pass
+
+            assert_is_value(nested(), KnownValue(None))
+            nested(1)  # E: incompatible_call
+            # Should ideally be something more specific
+            assert_is_value(NestedClass, AnyValue(AnySource.inference))
+
+    @assert_passes()
+    def test_usage_in_nested_scope():
+        def capybara(cond, x):
+            if cond:
+
+                def nested(y):
+                    pass
+
+                ys = [nested(y) for y in x]
+
+                class Nested(object):
+                    xs = ys
+
+    @assert_passes()
+    def test_async(self):
+        from asynq import asynq
+        from typing_extensions import Literal
+
+        @asynq()
+        def capybara():
+            @asynq()
+            def nested() -> Literal[3]:
+                return 3
+
+            assert_is_value(nested(), KnownValue(3))
+            val = yield nested.asynq()
+            assert_is_value(val, KnownValue(3))
+
+    @assert_passes()
+    def test_bad_decorator(self):
+        def decorator(fn):
+            return fn
+
+        def capybara():
+            @decorator
+            def nested():
+                pass
+
+            assert_is_value(nested, AnyValue(AnySource.unannotated))
+
+    @assert_passes()
+    def test_attribute_set(self):
+        def capybara():
+            def inner():
+                pass
+
+            inner.punare = 3
+            assert_is_value(inner.punare, KnownValue(3))
+
+
+class TestFunctionDefinitions(TestNameCheckVisitorBase):
+    @assert_passes()
+    def test_keyword_only(self):
+        def capybara(a, *, b, c=3):
+            assert_is_value(a, AnyValue(AnySource.unannotated))
+            assert_is_value(b, AnyValue(AnySource.unannotated))
+            assert_is_value(c, AnyValue(AnySource.unannotated) | KnownValue(3))
+            capybara(1, b=2)
+
+            fn = lambda a, *, b: None
+            fn(a, b=b)
+
+        def failing_capybara(a, *, b):
+            capybara(1, 2)  # E: incompatible_call
+
+    @skip_before((3, 8))
+    def test_pos_only(self):
+        self.assert_passes(
+            """
+            from typing import Optional
+
+            def f(a: int, /) -> None:
+                assert_is_value(a, TypedValue(int))
+
+            def g(a: Optional[str] = None, /, b: int = 1) -> None:
+                assert_is_value(a, TypedValue(str) | KnownValue(None))
+                assert_is_value(b, TypedValue(int))
+
+            def h(a, b: int = 1, /, c: int = 2) -> None:  # E: missing_parameter_annotation
+                assert_is_value(a, AnyValue(AnySource.unannotated))
+                assert_is_value(b, TypedValue(int))
+
+            def capybara() -> None:
+                f(1)
+                f("x")  # E: incompatible_argument
+                f(a=1)  # E: incompatible_call
+                g(a=1)  # E: incompatible_call
+                g(b=1)
+                g(None, b=1)
+                h(1, 1, c=2)
+                h(1)
+                h(1, b=1)  # E: incompatible_call
+            """,
+            settings={ErrorCode.missing_parameter_annotation: True},
+        )
+
+    @assert_passes()
+    def test_lambda(self):
+        from typing import Callable
+
+        def capybara():
+            fun = lambda: 1
+            x: Callable[[], int] = fun
+            y: Callable[[], str] = fun  # E: incompatible_assignment
+            fun(1)  # E: incompatible_call
+            assert_is_value(fun(), KnownValue(1))
+
+            fun2 = lambda a: a
+            fun2()  # E: incompatible_call
+            assert_is_value(fun2(1), KnownValue(1))
+
+            fun3 = lambda c=3: c
+            assert_is_value(
+                fun3(), KnownValue(3) | AnyValue(AnySource.generic_argument)
+            )
+            assert_is_value(fun3(2), KnownValue(2) | KnownValue(3))
+
+            fun4 = lambda a, b, c: a if c else b
+            assert_is_value(fun4(1, 2, 3), KnownValue(1) | KnownValue(2))
diff --git a/pyanalyze/test_name_check_visitor.py b/pyanalyze/test_name_check_visitor.py
index ebcbae5..8f1a492 100644
--- a/pyanalyze/test_name_check_visitor.py
+++ b/pyanalyze/test_name_check_visitor.py
@@ -842,7 +842,7 @@ class TestReturn(TestNameCheckVisitorBase):
                 async_fn.asynq(), AsyncTaskIncompleteValue(AsyncTask, KnownValue(3))
             )
             assert_is_value(WithAProperty().this_is_one, TypedValue(str))
-            assert_is_value(pure_async_proxy(oid), TypedValue(ConstFuture))
+            assert_is_value(pure_async_proxy(oid), AnyValue(AnySource.unannotated))
             assert_is_value(impure_async_proxy(), AnyValue(AnySource.unannotated))
             assert_is_value(
                 impure_async_proxy.asynq(),
@@ -1460,78 +1460,6 @@ def capybara():
         )
 
 
-class TestNestedFunction(TestNameCheckVisitorBase):
-    @assert_passes()
-    def test_inference(self):
-        def capybara():
-            def nested():
-                pass
-
-            class NestedClass(object):
-                pass
-
-            assert_is_value(nested, KnownValue(nested))
-            # Should ideally be something more specific
-            assert_is_value(NestedClass, AnyValue(AnySource.inference))
-
-    @assert_passes()
-    def test_usage_in_nested_scope():
-        def capybara(cond, x):
-            if cond:
-
-                def nested(y):
-                    pass
-
-                ys = [nested(y) for y in x]
-
-                class Nested(object):
-                    xs = ys
-
-    @assert_fails(ErrorCode.incompatible_call)
-    def test_argument_mismatch(self):
-        def capybara():
-            def nested():
-                pass
-
-            nested(None)
-
-    @assert_passes()
-    def test_async(self):
-        from asynq import asynq, result
-
-        @asynq()
-        def capybara():
-            @asynq()
-            def nested():
-                return 3
-
-            assert_is_value(nested, KnownValue(nested))
-            result((yield nested.asynq()))
-
-    @assert_passes()
-    def test_bad_decorator(self):
-        import types
-
-        def decorator(fn):
-            return fn
-
-        def capybara():
-            @decorator
-            def nested():
-                pass
-
-            assert_is_value(nested, TypedValue(types.FunctionType))
-
-    @assert_passes()
-    def test_attribute_set(self):
-        def capybara():
-            def inner():
-                pass
-
-            inner.punare = 3
-            print(inner.punare)
-
-
 class TestYieldInComprehension(TestNameCheckVisitorBase):
     # this became a syntax error in 3.8
     @only_before((3, 8))
@@ -1910,24 +1838,6 @@ class TestAsyncAwait(TestNameCheckVisitorBase):
             return [x async for x in []]  # E: unsupported_operation
 
 
-class TestKeywordOnlyArguments(TestNameCheckVisitorBase):
-    @assert_passes()
-    def test_success(self):
-        def capybara(a, *, b, c=3):
-            assert_is_value(a, AnyValue(AnySource.unannotated))
-            assert_is_value(b, AnyValue(AnySource.unannotated))
-            assert_is_value(c, AnyValue(AnySource.unannotated) | KnownValue(3))
-            capybara(1, b=2)
-
-            fn = lambda a, *, b: assert_is_value(b, AnyValue(AnySource.unannotated))
-            fn(a, b=b)
-
-    @assert_fails(ErrorCode.incompatible_call)
-    def test_failure(self):
-        def capybara(a, *, b):
-            capybara(1, 2)
-
-
 class TestMissingAwait(TestNameCheckVisitorBase):
     @assert_fails(ErrorCode.missing_await)
     def test_asyncio_coroutine_internal(self):
@@ -2081,7 +1991,7 @@ class TestingCallSiteCollector(object):
     def record_call(self, caller, callee):
         try:
             self.map[callee.__qualname__].append(caller.__qualname__)
-        except TypeError:
+        except (AttributeError, TypeError):
             # Copied for consistency; see comment in name_check_visitor.py:CallSiteCollector
             pass
 
@@ -2097,37 +2007,23 @@ class TestCallSiteCollection(TestNameCheckVisitorBase):
     def test_member_function_call(self):
         call_map = self.run_and_get_call_map(
             """
-class TestClass(object):
-    def __init__(self):
-        self.first_function(5)
+            class TestClass(object):
+                def __init__(self):
+                    self.first_function(5)
 
-    def first_function(self, x):
-        print(x)
-        self.second_function(x, 4)
+                def first_function(self, x):
+                    print(x)
+                    self.second_function(x, 4)
 
-    def second_function(self, y, z):
-        print(y + z)
-"""
+                def second_function(self, y, z):
+                    print(y + z)
+            """
         )
 
         assert "TestClass.first_function" in call_map["TestClass.second_function"]
         assert "TestClass.__init__" in call_map["TestClass.first_function"]
         assert "TestClass.second_function" in call_map["print"]
 
-    def test_nested_function_call(self):
-        call_map = self.run_and_get_call_map(
-            """
-class TestClass(object):
-    def __init__(self):
-        def second_function(y):
-            print(y)
-        second_function(3)
-"""
-        )
-
-        assert "TestClass.__init__" in call_map["second_function"]
-        assert "second_function" in call_map["print"]
-
 
 def test_get_task_cls():
     assert FutureBase is _get_task_cls(proxied_fn)
diff --git a/pyanalyze/test_node_visitor.py b/pyanalyze/test_node_visitor.py
index 0c88423..da5d2a2 100644
--- a/pyanalyze/test_node_visitor.py
+++ b/pyanalyze/test_node_visitor.py
@@ -31,6 +31,7 @@ class BaseNodeVisitorTester(object):
         # Can be bytes in Python 2.
         if isinstance(code_str, bytes):
             code_str = code_str.decode("utf-8")
+        code_str = textwrap.dedent(code_str)
         tree = ast.parse(code_str, "<test input>")
         try:
             result = self._run_tree(
diff --git a/pyanalyze/test_stacked_scopes.py b/pyanalyze/test_stacked_scopes.py
index 2be69db..7e677a3 100644
--- a/pyanalyze/test_stacked_scopes.py
+++ b/pyanalyze/test_stacked_scopes.py
@@ -178,7 +178,9 @@ class TestScoping(TestNameCheckVisitorBase):
     @assert_passes()
     def test_args_kwargs(self):
         def capybara(*args, **kwargs):
-            assert_is_value(args, TypedValue(tuple))
+            assert_is_value(
+                args, GenericValue(tuple, [AnyValue(AnySource.unannotated)])
+            )
             assert_is_value(
                 kwargs,
                 GenericValue(dict, [TypedValue(str), AnyValue(AnySource.unannotated)]),
@@ -496,14 +498,19 @@ class TestLoops(TestNameCheckVisitorBase):
 
     @assert_passes()
     def test_recursive_func_in_loop(self):
-        def capybara(xs):
+        from typing import Iterable
+
+        def capybara(xs: Iterable[int]):
             for x in xs:
 
-                def do_something(y):
+                def do_something(y: int) -> int:
                     if x:
-                        do_something(y)
+                        assert_is_value(do_something(y), TypedValue(int))
+                        do_something("x")  # E: incompatible_argument
+                    return y
 
-                do_something(x)
+                assert_is_value(do_something(x), TypedValue(int))
+                do_something("x")  # E: incompatible_argument
 
 
 class TestUnusedVariable(TestNameCheckVisitorBase):