# SPDX-License-Identifier: Apache-2.0
# Copyright © 2022-2024 Intel Corporation

"""Interpreter for converting Cargo Toml definitions to Meson AST

There are some notable limits here. We don't even try to convert something with
a build.rs: there's so few limits on what Cargo allows a build.rs (basically
none), and no good way for us to convert them. In that case, an actual meson
port will be required.
"""

from __future__ import annotations
import dataclasses
import functools
import itertools
import os
import pathlib
import collections
import urllib.parse
import typing as T
from pathlib import PurePath

from . import builder, version
from .cfg import eval_cfg
from .toml import load_toml
from .manifest import (
    Manifest, CargoLock, CargoLockPackage, Workspace, fixup_meson_varname,
    validate_patch,
)
from ..mesonlib import (
    is_parent_path, lazy_property, MesonException, MachineChoice,
    PerMachine, unique_list, SubProject,
)
from .. import coredata, mlog
from ..wrap.wrap import PackageDefinition

if T.TYPE_CHECKING:
    from . import raw
    from .. import mparser
    from typing_extensions import Literal

    from .manifest import Dependency
    from ..environment import Environment
    from ..compilers.rust import RustCompiler

    RUST_ABI = Literal['rust', 'c', 'proc-macro']

def _dependency_name(package_name: str, api: str, suffix: str = '-rs') -> str:
    basename = package_name[:-len(suffix)] if suffix and package_name.endswith(suffix) else package_name
    return f'{basename}-{api}{suffix}'


def _extra_args_varname() -> str:
    return 'extra_args'


def _extra_deps_varname() -> str:
    return 'extra_deps'


@dataclasses.dataclass
class PackageConfiguration:
    """Configuration for a package during dependency resolution."""
    for_machine: MachineChoice
    features: T.Set[str] = dataclasses.field(default_factory=set)
    required_deps: T.Set[str] = dataclasses.field(default_factory=set)
    optional_deps_features: T.Dict[str, T.Set[str]] = dataclasses.field(default_factory=lambda: collections.defaultdict(set))
    # Cache of resolved dependency packages
    dep_packages: T.Dict[PackageKey, PackageState] = dataclasses.field(default_factory=dict)

    def get_features_args(self) -> T.List[str]:
        """Get feature configuration arguments."""
        args: T.List[str] = []
        for feature in sorted(self.features):
            args.extend(['--cfg', f'feature="{feature}"'])
        return args

    def get_dependency_map(self, manifest: Manifest) -> T.Dict[str, str]:
        """Get the rust dependency mapping for this package configuration."""
        dependency_map: T.Dict[str, str] = {}
        for name in sorted(self.required_deps):
            dep = manifest.dependencies[name]
            dep_key = PackageKey(dep.package, dep.api)
            dep_pkg = self.dep_packages[dep_key]
            dep_lib_name = dep_pkg.library_name(self.for_machine)
            dep_crate_name = name if name != dep.package else dep_pkg.manifest.lib.name
            dependency_map[dep_lib_name] = dep_crate_name
        return dependency_map


@dataclasses.dataclass
class PackageState:
    manifest: Manifest
    downloaded: bool = False
    # If this package is member of a workspace.
    ws_subdir: T.Optional[str] = None
    ws_member: T.Optional[str] = None
    # Per-machine configuration state
    cfg: PerMachine[T.Optional[PackageConfiguration]] = dataclasses.field(
        default_factory=lambda: PerMachine(None, None)
    )
    # Subproject name as known to the wrap resolver (may differ from the
    # meson dep name for git sources, where the wrap is named after the
    # git directory rather than the crate name + api version).
    subproject_name: T.Optional[str] = None

    @lazy_property
    def path(self) -> T.Optional[str]:
        if not self.ws_subdir:
            return None
        return os.path.normpath(os.path.join(self.ws_subdir, self.ws_member))

    def library_name(self, machine: MachineChoice = MachineChoice.HOST, lib_type: RUST_ABI = 'rust') -> str:
        # Add the API version to the library name to avoid conflicts when multiple
        # versions of the same crate are used. The Ninja backend removed everything
        # after the + to form the crate name.
        name = fixup_meson_varname(self.manifest.package.name)
        suffix = '+build' if machine == MachineChoice.BUILD else ''
        if lib_type == 'c':
            return name
        return f'{name}+{self.manifest.package.api.replace(".", "_")}{suffix}'

    def has_both_machines(self) -> bool:
        return bool(self.cfg.host and self.cfg.build)

    def get_env_dict(self, environment: Environment, subdir: str) -> T.Dict[str, str]:
        """Get environment variables for this package."""
        # Common variables for build.rs and crates
        # https://doc.rust-lang.org/cargo/reference/environment-variables.html
        # OUT_DIR is the directory where build.rs generate files. In our case,
        # it's the directory where meson/meson.build places generated files.
        out_dir = os.path.join(environment.build_dir, subdir, 'meson')
        os.makedirs(out_dir, exist_ok=True)
        version_arr = self.manifest.package.version.split('.')
        version_arr += [''] * (4 - len(version_arr))

        return {
            'OUT_DIR': out_dir,
            'CARGO_MANIFEST_DIR': os.path.join(environment.source_dir, subdir),
            'CARGO_MANIFEST_PATH': os.path.join(environment.source_dir, subdir, 'Cargo.toml'),
            'CARGO_PKG_VERSION': self.manifest.package.version,
            'CARGO_PKG_VERSION_MAJOR': version_arr[0],
            'CARGO_PKG_VERSION_MINOR': version_arr[1],
            'CARGO_PKG_VERSION_PATCH': version_arr[2],
            'CARGO_PKG_VERSION_PRE': version_arr[3],
            'CARGO_PKG_AUTHORS': ','.join(self.manifest.package.authors),
            'CARGO_PKG_NAME': self.manifest.package.name,
            # FIXME: description can contain newlines which breaks ninja.
            #'CARGO_PKG_DESCRIPTION': self.manifest.package.description or '',
            'CARGO_PKG_HOMEPAGE': self.manifest.package.homepage or '',
            'CARGO_PKG_REPOSITORY': self.manifest.package.repository or '',
            'CARGO_PKG_LICENSE': self.manifest.package.license or '',
            'CARGO_PKG_LICENSE_FILE': self.manifest.package.license_file or '',
            'CARGO_PKG_RUST_VERSION': self.manifest.package.rust_version or '',
            'CARGO_PKG_README': self.manifest.package.readme or '',
            'CARGO_CRATE_NAME': fixup_meson_varname(self.manifest.package.name),
        }

    def get_lint_args(self, rustc: RustCompiler) -> T.List[str]:
        """Get lint arguments for this package."""
        args: T.List[str] = []
        has_check_cfg = rustc.has_check_cfg

        for lint in self.manifest.lints:
            args.extend(lint.to_arguments(has_check_cfg))

        if has_check_cfg:
            args.extend(['--check-cfg', 'cfg(docsrs)',
                         '--check-cfg', 'cfg(test)'])
            for feature in self.manifest.features:
                if feature != 'default':
                    args.append('--check-cfg')
                    args.append(f'cfg(feature,values("{feature}"))')
            for name in self.manifest.system_dependencies:
                args.append('--check-cfg')
                args.append(f'cfg(system_deps_have_{fixup_meson_varname(name)})')

        return args

    def get_env_args(self, rustc: RustCompiler, environment: Environment, subdir: str) -> T.List[str]:
        """Get environment variable arguments for rustc."""
        enable_env_set_args = rustc.enable_env_set_args()
        if enable_env_set_args is None:
            return []

        env_dict = self.get_env_dict(environment, subdir)
        env_args = list(enable_env_set_args)
        for k, v in env_dict.items():
            env_args.extend(['--env-set', f'{k}={v}'])
        return env_args

    def get_rustc_args(self, environment: Environment, subdir: str, machine: MachineChoice) -> T.List[str]:
        """Get rustc arguments for this package."""
        if not environment.is_cross_build():
            machine = MachineChoice.HOST

        rustc = T.cast('RustCompiler', environment.coredata.compilers[machine]['rust'])
        cfg = self.cfg[machine]

        args: T.List[str] = []
        args.extend(self.get_lint_args(rustc))
        args.extend(cfg.get_features_args())
        args.extend(self.get_env_args(rustc, environment, subdir))
        return args

    def supported_abis(self) -> T.Set[RUST_ABI]:
        """Return which ABIs are exposed by the package's crate_types."""
        crate_types = self.manifest.lib.crate_type
        abis: T.Set[RUST_ABI] = set()
        if any(ct in {'lib', 'rlib', 'dylib'} for ct in crate_types):
            abis.add('rust')
        if any(ct in {'staticlib', 'cdylib'} for ct in crate_types):
            abis.add('c')
        if 'proc-macro' in crate_types:
            abis.add('proc-macro')
        return abis

    def get_subproject_name(self) -> SubProject:
        if self.subproject_name is not None:
            return SubProject(self.subproject_name)
        dep = _dependency_name(self.manifest.package.name, self.manifest.package.api)
        return SubProject(dep)

    def abi_resolve_default(self, rust_abi: T.Optional[RUST_ABI]) -> RUST_ABI:
        supported_abis = self.supported_abis()
        if rust_abi is None:
            if len(supported_abis) > 1:
                raise MesonException(f'Package {self.manifest.package.name} support more than one ABI')
            return next(iter(supported_abis))
        else:
            if rust_abi not in supported_abis:
                raise MesonException(f'Package {self.manifest.package.name} does not support ABI {rust_abi}')
            return rust_abi

    def abi_has_shared(self, rust_abi: RUST_ABI) -> bool:
        if rust_abi == 'proc-macro':
            return True
        return ('cdylib' if rust_abi == 'c' else 'dylib') in self.manifest.lib.crate_type

    def abi_has_static(self, rust_abi: RUST_ABI) -> bool:
        if rust_abi == 'proc-macro':
            return False
        crate_type = self.manifest.lib.crate_type
        if rust_abi == 'c':
            return 'staticlib' in crate_type
        return 'lib' in crate_type or 'rlib' in crate_type

    def get_dependency_name(self, rust_abi: T.Optional[RUST_ABI]) -> str:
        """Get the dependency name for a package with the given ABI."""
        rust_abi = self.abi_resolve_default(rust_abi)
        package_name = self.manifest.package.name
        api = self.manifest.package.api

        if rust_abi in {'rust', 'proc-macro'}:
            return _dependency_name(package_name, api)
        elif rust_abi == 'c':
            return _dependency_name(package_name, api, '')
        else:
            raise MesonException(f'Unknown rust_abi: {rust_abi}')

    def get_rust_dependency_name(self) -> str:
        """Get the dependency name for a package with the rust or proc-macro ABI."""
        supported_abis = self.supported_abis()
        package_name = self.manifest.package.name
        if 'rust' in supported_abis or 'proc-macro' in supported_abis:
            return _dependency_name(package_name, self.manifest.package.api)
        raise MesonException(f'Package {package_name} does not support rust or proc-macro ABI')

@dataclasses.dataclass(frozen=True)
class PackageKey:
    package_name: str
    api: str


@dataclasses.dataclass
class WorkspaceState:
    workspace: Workspace
    subdir: str
    downloaded: bool = False
    # member path -> PackageState, for all members of this workspace
    packages: T.Dict[str, PackageState] = dataclasses.field(default_factory=dict)
    # package name to member path, for all members of this workspace
    packages_to_member: T.Dict[str, str] = dataclasses.field(default_factory=dict)
    # member paths that are required to be built
    required_members: T.List[str] = dataclasses.field(default_factory=list)


class Interpreter:
    _features: T.Optional[T.List[str]] = None

    def __init__(self, env: Environment, subdir: str, subprojects_dir: str) -> None:
        self.environment = env
        self.subprojects_dir = subprojects_dir
        # Map Cargo.toml's subdir to loaded manifest.
        self.manifests: T.Dict[str, T.Union[Manifest, Workspace]] = {}
        # Map of cargo package (name + api) to its state
        self.packages: T.Dict[PackageKey, PackageState] = {}
        # Map subdir to workspace
        self.workspaces: T.Dict[str, WorkspaceState] = {}
        # Files that should trigger a reconfigure if modified
        self.build_def_files: T.List[str] = []
        # Cargo packages
        filename = os.path.join(self.environment.get_source_dir(), subdir, 'Cargo.lock')
        subprojects_dir = os.path.join(self.environment.get_source_dir(), subdir, subprojects_dir)
        self.cargolock = load_cargo_lock(filename, subprojects_dir)
        if self.cargolock:
            self.environment.wrap_resolver.merge_wraps(self.cargolock.wraps)
            self.build_def_files.append(filename)

    @property
    def is_cross(self) -> bool:
        return self.environment.is_cross_build()

    @property
    def features(self) -> T.List[str]:
        """Get the features list. Once read, it cannot be modified."""
        if self._features is None:
            self._features = ['default']
        return self._features

    @features.setter
    def features(self, value: T.List[str]) -> None:
        """Set the features list. Can only be set before first read."""
        value_unique = sorted(unique_list(value))
        if self._features is not None and value_unique != self._features:
            raise MesonException("Cannot modify features after they have been selected or used")
        self._features = value_unique

    def get_build_def_files(self) -> T.List[str]:
        return self.build_def_files

    def load_workspace(self, subdir: str, extra_members: T.Optional[T.List[str]]) -> WorkspaceState:
        """Load the root Cargo.toml package and prepare it with features and dependencies."""
        subdir = os.path.normpath(subdir)
        manifest, cached = self._load_manifest(subdir)
        ws = self._get_workspace(manifest, subdir, extra_members, False)
        if not cached:
            # [patch] only takes effect in the top-level Cargo.toml
            for warning in validate_patch(ws.workspace.patch, ws.packages_to_member):
                mlog.warning(warning)
            if ws.workspace.profile:
                mlog.warning('[profile] entries are not implemented yet')

            self._prepare_entry_point(ws)
        return ws

    def _prepare_entry_point(self, ws: WorkspaceState) -> None:
        pkgs = [self._require_workspace_member(ws, m) for m in ws.workspace.default_members]
        for pkg in pkgs:
            for machine in pkg.manifest.machines_from(MachineChoice.HOST, bin=True, is_cross=self.is_cross):
                self._prepare_package(pkg, machine)
                for feature in self.features:
                    self._enable_feature(pkg, feature, machine)

    def load_package(self, ws: WorkspaceState, package_name: T.Optional[str]) -> PackageState:
        if package_name is None:
            if not ws.workspace.root_package:
                raise MesonException('no root package in workspace')
            path = '.'
        else:
            try:
                path = ws.packages_to_member[package_name]
            except KeyError:
                raise MesonException(f'workspace member "{package_name}" not found')

        if is_parent_path(self.subprojects_dir, path):
            raise MesonException('argument to package() cannot be a subproject')
        return ws.packages[path]

    def interpret(self, subdir: str, project_root: T.Optional[str] = None) -> mparser.CodeBlockNode:
        filename = os.path.join(self.environment.source_dir, subdir, 'Cargo.toml')
        build = builder.Builder(filename)
        if project_root:
            # this is a subdir()
            manifest, _ = self._load_manifest(subdir)
            assert isinstance(manifest, Manifest)
            return self.interpret_package(manifest, build, subdir, project_root)
        else:
            ws = self.load_workspace(subdir, None)
            return self.interpret_workspace(ws, build, subdir)

    def interpret_package(self, manifest: Manifest, build: builder.Builder, subdir: str, project_root: str) -> mparser.CodeBlockNode:
        # Build an AST for this package
        ws = self.workspaces[project_root]
        member = ws.packages_to_member[manifest.package.name]
        pkg = ws.packages[member]
        ast = self._create_package(pkg, build, subdir)
        return build.block(ast)

    def _create_package(self, pkg: PackageState, build: builder.Builder, subdir: str) -> T.List[mparser.BaseNode]:
        # proc_macro automatically adds native: true; passing "native: true"
        # to cargo_ws.package() is only needed to query the features and
        # pass them to meson/meson.build.
        native = pkg.manifest.lib.crate_type == ['proc-macro']
        ast: T.List[mparser.BaseNode] = [
            build.assign(build.method('package', build.identifier('cargo_ws'),
                                      [build.string(pkg.manifest.package.name)],
                                      {'native': build.bool(native)}),
                         'pkg_obj'),
            build.assign(build.method('features', build.identifier('pkg_obj')), 'features'),
            build.function('message', [
                build.string('Enabled features:'),
                build.identifier('features'),
            ]),
        ]
        ast += self._create_meson_subdir(build)

        if pkg.manifest.lib:
            crate_type = pkg.manifest.lib.crate_type
            if 'dylib' in crate_type and 'cdylib' in crate_type:
                raise MesonException('Cannot build both dylib and cdylib due to file name conflict')
            for abi in pkg.supported_abis():
                ast.extend(self._create_lib(pkg, build, subdir, abi))

        return ast

    def interpret_workspace(self, ws: WorkspaceState, build: builder.Builder, subdir: str) -> mparser.CodeBlockNode:
        name = os.path.basename(subdir)
        subprojects_dir = os.path.join(subdir, 'subprojects')
        self.environment.wrap_resolver.load_and_merge(subprojects_dir, SubProject(name))
        ast: T.List[mparser.BaseNode] = []

        # Call subdir() for each required member of the workspace. The order is
        # important, if a member depends on another member, that member must be
        # processed first.
        processed_members: T.Dict[str, PackageState] = {}

        def _process_member(member: str) -> None:
            if member in processed_members:
                return
            pkg = ws.packages[member]
            # Process dependencies for all configured machines
            found = False
            for machine in MachineChoice:
                cfg = pkg.cfg[machine]
                if not cfg:
                    continue
                for depname in cfg.required_deps:
                    dep = pkg.manifest.dependencies[depname]
                    if dep.path:
                        dep_member = os.path.normpath(os.path.join(pkg.ws_member, dep.path))
                        _process_member(dep_member)
                found = True
            if not found:
                raise MesonException(f'Package {pkg.manifest.package.name!r} is not enabled for this build '
                                     'configuration. Maybe you forgot to enable a Cargo feature, or to check '
                                     'a Meson option?')
            if member == '.':
                ast.extend(self._create_package(pkg, build, subdir))
            elif is_parent_path(self.subprojects_dir, member):
                depname = _dependency_name(pkg.manifest.package.name, pkg.manifest.package.api)
                ast.append(build.function('subproject', [build.string(depname)]))
            else:
                ast.append(build.function('subdir', [build.string(member)]))
            processed_members[member] = pkg

        for member in ws.required_members:
            _process_member(member)
        ast = self._create_project(name, processed_members.get('.'), build) + ast
        return build.block(ast)

    def _load_workspace_member(self, ws: WorkspaceState, m: str) -> None:
        m = os.path.normpath(m)
        if m in ws.packages:
            return
        # Load member's manifest
        m_subdir = os.path.join(ws.subdir, m)
        manifest_, _ = self._load_manifest(m_subdir, ws.workspace, m)
        assert isinstance(manifest_, Manifest)
        self._add_workspace_member(manifest_, ws, m)

    def _add_workspace_member(self, manifest_: Manifest, ws: WorkspaceState, m: str) -> None:
        key = PackageKey(manifest_.package.name, manifest_.package.api)
        ws.packages_to_member[manifest_.package.name] = m
        if key in self.packages:
            ws.packages[m] = self.packages[key]
            self._require_workspace_member(ws, m)
        else:
            ws.packages[m] = PackageState(manifest_, ws_subdir=ws.subdir, ws_member=m, downloaded=ws.downloaded)

    def _get_workspace(self, manifest: T.Union[Workspace, Manifest], subdir: str, extra_members: T.Optional[T.List[str]], downloaded: bool) -> WorkspaceState:
        ws = self.workspaces.get(subdir)
        if ws:
            return ws
        workspace = manifest if isinstance(manifest, Workspace) else \
            Workspace(root_package=manifest, members=['.'], default_members=['.'])
        ws = WorkspaceState(workspace, subdir, downloaded=downloaded)
        if workspace.root_package:
            self._add_workspace_member(workspace.root_package, ws, '.')

        if extra_members is not None:
            for m in extra_members:
                m = PurePath(m).as_posix()
                if m not in workspace.members:
                    l = ', '.join(sorted(list(workspace.members)))
                    raise MesonException(f'{m} is not a workspace member for {subdir}/Cargo.toml (valid members are {l})')
                if m not in workspace.default_members:
                    workspace.default_members.append(m)
        for m in workspace.members:
            self._load_workspace_member(ws, m)
        self.workspaces[subdir] = ws
        return ws

    def _record_package(self, pkg: PackageState) -> None:
        key = PackageKey(pkg.manifest.package.name, pkg.manifest.package.api)
        if key not in self.packages:
            self.packages[key] = pkg

    def _require_workspace_member(self, ws: WorkspaceState, member: str) -> PackageState:
        member = os.path.normpath(member)
        pkg = ws.packages[member]
        if member not in ws.required_members:
            self._record_package(pkg)
            ws.required_members.append(member)
        return pkg

    def _fetch_package(self, package_name: str, api: str) -> PackageState:
        key = PackageKey(package_name, api)
        pkg = self.packages.get(key)
        if pkg:
            return pkg
        return self._fetch_package_from_provider(package_name, api)

    def _resolve_package(self, package_name: str, accepts_version: T.Callable[[str], bool]) -> \
            T.Optional[CargoLockPackage]:
        """From all available versions from Cargo.lock, pick the most recent
           satisfying the constraints and return it."""
        if not self.cargolock:
            return None

        for cargo_pkg in self.cargolock.named(package_name):
            if accepts_version(cargo_pkg.version):
                return cargo_pkg
        return None

    def resolve_package(self, package_name: str, api: str) -> T.Optional[PackageState]:
        cargo_pkg = self._resolve_package(package_name, version.cargo_parse(api))
        if not cargo_pkg:
            return None
        api = version.api(cargo_pkg.version)
        return self._fetch_package(package_name, api)

    def _fetch_package_from_provider(self, package_name: str, api: str) -> PackageState:
        meson_depname = _dependency_name(package_name, api)
        subp_name, _ = self.environment.wrap_resolver.find_dep_provider(meson_depname)
        if subp_name is None:
            if self.cargolock is None:
                raise MesonException(f'Dependency {meson_depname!r} not found in any wrap files.')
            # If Cargo.lock has a different version, this could be a resolution
            # bug, but maybe also a version mismatch?  I am not sure yet...
            similar_deps = [pkg.subproject
                            for pkg in self.cargolock.named(package_name)]
            if similar_deps:
                similar_msg = f'Cargo.lock provides: {", ".join(similar_deps)}.'
            else:
                similar_msg = 'Cargo.lock does not contain this crate name.'
            raise MesonException(f'Dependency {meson_depname!r} not found in any wrap files or Cargo.lock; {similar_msg} This could be a Meson bug, please report it.')

        return self._fetch_package_from_subproject(package_name, subp_name)

    def _fetch_package_from_subproject(self, package_name: str, subp_name: str) -> PackageState:
        subdir, _ = self.environment.wrap_resolver.resolve(subp_name)
        subprojects_dir = os.path.join(subdir, 'subprojects')
        self.environment.wrap_resolver.load_and_merge(subprojects_dir, SubProject(subp_name))
        manifest, _ = self._load_manifest(subdir)
        downloaded = \
            subp_name in self.environment.wrap_resolver.wraps and \
            self.environment.wrap_resolver.wraps[subp_name].type is not None

        ws = self._get_workspace(manifest, subdir, None, downloaded=downloaded)
        member = ws.packages_to_member[package_name]
        pkg = self._require_workspace_member(ws, member)
        pkg.subproject_name = subp_name
        return pkg

    def _prepare_package(self, pkg: PackageState, machine: MachineChoice) -> None:
        key = PackageKey(pkg.manifest.package.name, pkg.manifest.package.api)
        assert key in self.packages
        if pkg.cfg[machine] is not None:
            return  # Already prepared for this machine

        pkg.cfg[machine] = PackageConfiguration(for_machine=machine)
        # Merge target-specific dependencies that are enabled for this machine
        target_cfgs = self._get_cfgs(machine)
        for condition, dependencies in pkg.manifest.target.items():
            if eval_cfg(condition, target_cfgs):
                pkg.manifest.dependencies.update(dependencies)

        # If you specify the optional dependency with the dep: prefix anywhere in the [features]
        # table, that disables the implicit feature.
        deps = set(feature[4:]
                   for feature in itertools.chain.from_iterable(pkg.manifest.features.values())
                   if feature.startswith('dep:'))
        for name, dep in itertools.chain(pkg.manifest.dependencies.items(),
                                         pkg.manifest.dev_dependencies.items(),
                                         pkg.manifest.build_dependencies.items()):
            if dep.optional and name not in deps:
                pkg.manifest.features.setdefault(name, [])
                pkg.manifest.features[name].append(f'dep:{name}')
                deps.add(name)

        # Fetch required dependencies recursively for this machine
        for depname, dep in pkg.manifest.dependencies.items():
            if not dep.optional:
                self._add_dependency(pkg, depname, machine)

    def _dep_package(self, pkg: PackageState, dep: Dependency, cfg: PackageConfiguration) -> PackageState:
        if dep.path:
            ws = self.workspaces[pkg.ws_subdir]
            dep_member = os.path.normpath(os.path.join(pkg.ws_member, dep.path))
            if is_parent_path(self.subprojects_dir, dep_member):
                if len(pathlib.PurePath(dep_member).parts) != 2:
                    raise MesonException('found "{self.subprojects_dir}" in path but it is not a valid subproject path')
            self._load_workspace_member(ws, dep_member)
            dep_pkg = self._require_workspace_member(ws, dep_member)
        elif dep.git:
            _, _, directory = _parse_git_url(dep.git, dep.branch)
            dep_pkg = self._fetch_package_from_subproject(dep.package, directory)
        else:
            cargo_pkg = self._resolve_package(dep.package, dep.accepts_version)
            if cargo_pkg:
                dep.update_version(f'={cargo_pkg.version}')
            dep_pkg = self._fetch_package(dep.package, dep.api)

        if not dep.version:
            dep.update_version(f'={dep_pkg.manifest.package.version}')

        dep_key = PackageKey(dep.package, dep.api)
        cfg.dep_packages.setdefault(dep_key, dep_pkg)
        assert cfg.dep_packages[dep_key] == dep_pkg
        return dep_pkg

    def _load_manifest(self, subdir: str, workspace: T.Optional[Workspace] = None, member_path: str = '') -> T.Tuple[T.Union[Manifest, Workspace], bool]:
        manifest_ = self.manifests.get(subdir)
        if manifest_:
            return manifest_, True
        path = os.path.join(self.environment.source_dir, subdir)
        filename = os.path.join(path, 'Cargo.toml')
        try:
            raw_manifest = T.cast('raw.Manifest', load_toml(filename))
        except OSError as e:
            raise MesonException(f'could not load {subdir}/Cargo.toml: {e}')

        self.build_def_files.append(filename)
        if 'workspace' in raw_manifest:
            manifest_ = Workspace.from_raw(raw_manifest, path)
        elif 'package' in raw_manifest:
            manifest_ = Manifest.from_raw(raw_manifest, path, workspace, member_path)
        else:
            raise MesonException(f'{subdir}/Cargo.toml does not have [package] or [workspace] section')
        self.manifests[subdir] = manifest_
        return manifest_, False

    def _add_dependency(self, pkg: PackageState, depname: str, machine: MachineChoice) -> None:
        cfg = pkg.cfg[machine]
        if depname in cfg.required_deps:
            return
        dep = pkg.manifest.dependencies.get(depname)
        if not dep:
            # It could be build/dev/target dependency. Just ignore it.
            return
        cfg.required_deps.add(depname)
        dep_pkg = self._dep_package(pkg, dep, cfg)
        # Use machines_from() to determine which machines the dependency needs
        for dep_machine in dep_pkg.manifest.machines_from(machine, self.is_cross):
            self._prepare_package(dep_pkg, dep_machine)
            if dep.default_features:
                self._enable_feature(dep_pkg, 'default', dep_machine)
            for f in dep.features:
                self._enable_feature(dep_pkg, f, dep_machine)
            for f in cfg.optional_deps_features[depname]:
                self._enable_feature(dep_pkg, f, dep_machine)

    def _enable_feature(self, pkg: PackageState, feature: str, machine: MachineChoice) -> None:
        cfg = pkg.cfg[machine]
        if feature in cfg.features:
            return
        cfg.features.add(feature)
        # Recurse on extra features and dependencies this feature pulls.
        # https://doc.rust-lang.org/cargo/reference/features.html#the-features-section
        for f in pkg.manifest.features.get(feature, []):
            if '/' in f:
                depname, dep_f = f.split('/', 1)
                if depname[-1] == '?':
                    depname = depname[:-1]
                else:
                    self._add_dependency(pkg, depname, machine)
                if depname in cfg.required_deps:
                    dep = pkg.manifest.dependencies[depname]
                    dep_pkg = self._dep_package(pkg, dep, cfg)
                    # Use machines_from() to determine which machines the dependency needs
                    for dep_machine in dep_pkg.manifest.machines_from(machine, self.is_cross):
                        self._enable_feature(dep_pkg, dep_f, dep_machine)
                else:
                    # This feature will be enabled only if that dependency
                    # is later added.
                    cfg.optional_deps_features[depname].add(dep_f)
            elif f.startswith('dep:'):
                self._add_dependency(pkg, f[4:], machine)
            else:
                self._enable_feature(pkg, f, machine)

    def has_check_cfg(self, machine: MachineChoice) -> bool:
        if not self.environment.is_cross_build():
            machine = MachineChoice.HOST
        rustc = T.cast('RustCompiler', self.environment.coredata.compilers[machine]['rust'])
        return rustc.has_check_cfg

    @functools.lru_cache(maxsize=None)
    def _get_cfgs(self, machine: MachineChoice) -> T.Dict[str, str]:
        if not self.environment.is_cross_build():
            machine = MachineChoice.HOST
        rustc = T.cast('RustCompiler', self.environment.coredata.compilers[machine]['rust'])
        cfgs = rustc.get_cfgs().copy()
        rustflags = self.environment.coredata.get_external_args(machine, 'rust')
        rustflags_i = iter(rustflags)
        for i in rustflags_i:
            if i == '--cfg':
                cfgs.append(next(rustflags_i))
        return dict(self._split_cfg(i) for i in cfgs)

    @staticmethod
    def _split_cfg(cfg: str) -> T.Tuple[str, str]:
        pair = cfg.split('=', maxsplit=1)
        value = pair[1] if len(pair) > 1 else ''
        if value and value[0] == '"':
            value = value[1:-1]
        return pair[0], value

    def _create_project(self, name: str, pkg: T.Optional[PackageState], build: builder.Builder) -> T.List[mparser.BaseNode]:
        """Create the project() function call

        :param pkg: The package to generate from
        :param build: The AST builder
        :return: a list nodes
        """
        args: T.List[mparser.BaseNode] = [
            build.string(name),
            build.string('rust'),
        ]
        kwargs: T.Dict[str, mparser.BaseNode] = {
            # Always assume that the generated meson is using the latest features
            # This will warn when when we generate deprecated code, which is helpful
            # for the upkeep of the module
            'meson_version': build.string(f'>= {coredata.stable_version}'),
        }
        if pkg:
            default_options: T.Dict[str, mparser.BaseNode] = {}
            if pkg.downloaded:
                default_options['warning_level'] = build.string('0')

            kwargs.update({
                'version': build.string(pkg.manifest.package.version),
                'default_options': build.dict({build.string(k): v for k, v in default_options.items()}),
            })
            if pkg.manifest.package.license:
                kwargs['license'] = build.string(pkg.manifest.package.license)
            elif pkg.manifest.package.license_file:
                kwargs['license_files'] = build.string(pkg.manifest.package.license_file)

        # project(...)
        # rust = import('rust')
        # cargo_ws = rust.workspace()
        return [
            build.function('project', args, kwargs),
            build.assign(build.function('import', [build.string('rust')]),
                         'rust'),
            build.assign(build.method('workspace', build.identifier('rust'), []),
                         'cargo_ws')
        ]

    def _create_meson_subdir(self, build: builder.Builder) -> T.List[mparser.BaseNode]:
        # Allow Cargo subprojects to add extra Rust args in meson/meson.build file.
        # This is used to replace build.rs logic.

        # extra_args = []
        # extra_deps = []
        # fs = import('fs')
        # if fs.is_dir('meson')
        #  subdir('meson')
        # endif
        return [
            build.assign(build.array([]), _extra_args_varname()),
            build.assign(build.array([]), _extra_deps_varname()),
            build.assign(build.function('import', [build.string('fs')]), 'fs'),
            build.if_(build.method('is_dir', build.identifier('fs'), [build.string('meson')]),
                      build.block([build.function('subdir', [build.string('meson')])]))
        ]

    def _create_lib(self, pkg: PackageState, build: builder.Builder, subdir: str,
                    lib_type: RUST_ABI) -> T.List[mparser.BaseNode]:
        machine = MachineChoice.BUILD if lib_type == 'proc-macro' else MachineChoice.HOST
        posargs: T.List[mparser.BaseNode] = [
            build.string(pkg.library_name(machine, lib_type)),
        ]

        kwargs: T.Dict[str, mparser.BaseNode] = {
            'dependencies': build.identifier(_extra_deps_varname()),
            'rust_args': build.identifier(_extra_args_varname()),
        }

        if lib_type == 'proc-macro':
            lib = build.method('proc_macro', build.identifier('pkg_obj'), posargs, kwargs)
        else:
            kwargs['rust_abi'] = build.string(lib_type)
            lib = build.method('library', build.identifier('pkg_obj'), posargs, kwargs)

        # lib = xxx_library()
        # dep = declare_dependency()
        # meson.override_dependency()
        return [
            build.assign(lib, 'lib'),
            build.assign(
                build.function(
                    'declare_dependency',
                    kw={
                        'link_with': build.identifier('lib'),
                        'variables': build.dict({
                            build.string('features'): build.method('join', build.string(','),
                                                                   [build.identifier('features')]),
                        }),
                        'version': build.method('version', build.identifier('pkg_obj')),
                    },
                ),
                'dep'
            ),
            build.method(
                'override_dependency',
                build.identifier('pkg_obj'),
                [build.identifier('dep')],
                {'rust_abi': build.string(lib_type)}
            ),
        ]


def _parse_git_url(url: str, branch: T.Optional[str] = None) -> T.Tuple[str, str, str]:
    if url.startswith('git+'):
        url = url[4:]
    parts = urllib.parse.urlparse(url)
    query = urllib.parse.parse_qs(parts.query)
    query_branch = query['branch'][0] if 'branch' in query else ''
    branch = branch or query_branch
    revision = parts.fragment or branch
    directory = PurePath(parts.path).name
    if directory.endswith('.git'):
        directory = directory[:-4]
    if branch:
        branch_encoded = branch.replace('/', '-')
        directory += f'-{branch_encoded}'
    url = urllib.parse.urlunparse(parts._replace(params='', query='', fragment=''))
    return url, revision, directory


def load_cargo_lock(filename: str, subproject_dir: str) -> T.Optional[CargoLock]:
    """ Convert Cargo.lock into a list of wraps """

    # Map directory -> PackageDefinition, to avoid duplicates. Multiple packages
    # can have the same source URL, in that case we have a single wrap that
    # provides multiple dependency names.
    if os.path.exists(filename):
        toml = load_toml(filename)
        raw_cargolock = T.cast('raw.CargoLock', toml)
        cargolock = CargoLock.from_raw(raw_cargolock)
        packagefiles_dir = os.path.join(subproject_dir, 'packagefiles')
        wraps: T.Dict[str, PackageDefinition] = {}
        for package in cargolock.package:
            meson_depname = _dependency_name(package.name, version.api(package.version))
            if package.source is None:
                # This is project's package, or one of its workspace members.
                continue
            elif package.source == 'registry+https://github.com/rust-lang/crates.io-index':
                checksum = package.checksum
                if checksum is None:
                    checksum = cargolock.metadata[f'checksum {package.name} {package.version} ({package.source})']
                url = f'https://static.crates.io/crates/{package.name}/{package.version}/download'
                directory = f'{package.name}-{package.version}'
                name = SubProject(meson_depname)
                wrap_type = 'file'
                cfg = {
                    'directory': directory,
                    'source_url': url,
                    'source_filename': f'{directory}.tar.gz',
                    'source_hash': checksum,
                    'method': 'cargo',
                }
            elif package.source.startswith('git+'):
                url, revision, directory = _parse_git_url(package.source)
                name = SubProject(directory)
                wrap_type = 'git'
                cfg = {
                    'url': url,
                    'revision': revision,
                    'method': 'cargo',
                }
            else:
                mlog.warning(f'Unsupported source URL in {filename}: {package.source}')
                continue
            if os.path.isdir(os.path.join(packagefiles_dir, name)):
                cfg['patch_directory'] = name
            if directory not in wraps:
                wraps[directory] = PackageDefinition.from_values(name, subproject_dir, wrap_type, cfg)
            wraps[directory].add_provided_dep(meson_depname)
        cargolock.wraps = {w.name: w for w in wraps.values()}
        return cargolock
    return None
