# Copyright 2025 The Emscripten Authors.  All rights reserved.
# Emscripten is available under two separate licenses, the MIT license and the
# University of Illinois/NCSA Open Source License.  Both these licenses can be
# found in the LICENSE file.

import json
import logging
import os
import re
import shlex
import sys
from enum import Enum, auto, unique
from subprocess import PIPE

from tools import (
  cache,
  colored_logger,
  config,
  diagnostics,
  feature_matrix,
  ports,
  shared,
  utils,
)
from tools.settings import MEM_SIZE_SETTINGS, settings, user_settings
from tools.toolchain_profiler import ToolchainProfiler
from tools.utils import exit_with_error, read_file

SIMD_INTEL_FEATURE_TOWER = ['-msse', '-msse2', '-msse3', '-mssse3', '-msse4.1', '-msse4.2', '-msse4', '-mavx', '-mavx2']
SIMD_NEON_FLAGS = ['-mfpu=neon']
CLANG_FLAGS_WITH_ARGS = {
    '-MT', '-MF', '-MJ', '-MQ', '-D', '-U', '-o', '-x',
    '-Xpreprocessor', '-include', '-imacros', '-idirafter',
    '-iprefix', '-iwithprefix', '-iwithprefixbefore',
    '-isysroot', '-imultilib', '-A', '-isystem', '-iquote',
    '-install_name', '-compatibility_version', '-mllvm',
    '-current_version', '-I', '-L', '-include-pch', '-u',
    '-undefined', '-target', '-Xlinker', '-Xclang', '-z',
}
# These symbol names are allowed in INCOMING_MODULE_JS_API but are not part of the
# default set.
EXTRA_INCOMING_JS_API = [
  'fetchSettings',
  'logReadFiles',
  'loadSplitModule',
  'onMalloc',
  'onRealloc',
  'onFree',
  'onSbrkGrow',
  'onCOSCacheHit',
  'onCOSCacheMiss',
  'onCOSStore',
]

logger = logging.getLogger('args')


@unique
class OFormat(Enum):
  # Output a relocatable object file. i.e. `-r` linker flag
  OBJECT = auto()
  WASM = auto()
  JS = auto()
  MJS = auto()
  HTML = auto()
  BARE = auto()


class EmccOptions:
  cpu_profiler = False
  dash_E = False
  dash_M = False
  dash_S = False
  dash_c = False
  dylibs: list[str] = []
  embed_files: list[str] = []
  emit_symbol_map = False
  emit_tsd = ''
  emrun = False
  exclude_files: list[str] = []
  executable = False
  extern_post_js: list[str] = [] # after all js, external to optimized code
  extern_pre_js: list[str] = [] # before all js, external to optimized code
  fast_math = False
  ignore_dynamic_linking = False
  input_files: list[str] = []
  input_language = None
  js_transform = None
  lib_dirs: list[str] = []
  memory_profiler = False
  no_entry = False
  no_minify = False
  nodefaultlibs = False
  nolibc = False
  nostartfiles = False
  nostdlib = False
  nostdlibxx = False
  oformat = None
  # Specifies the line ending format to use for all generated text files.
  # Defaults to using the native EOL on each platform (\r\n on Windows, \n on
  # Linux & MacOS)
  output_eol = os.linesep
  output_file = None
  post_js: list[str] = [] # after all js
  post_link = False
  pre_js: list[str] = [] # before all js
  preload_files: list[str] = []
  relocatable = False
  reproduce = None
  requested_debug = None
  sanitize: set[str] = set()
  sanitize_minimal_runtime = False
  s_args: list[str] = []
  save_temps = False
  shared = False
  shell_html = None
  source_map_base = ''
  syntax_only = False
  target = ''
  use_closure_compiler = None
  use_preload_cache = False
  use_preload_plugins = False
  valid_abspaths: list[str] = []


# Global/singleton EmccOptions
options = EmccOptions()


def is_unsigned_int(s):
  try:
    return int(s) >= 0
  except ValueError:
    return False


def version_string():
  # if the emscripten folder is not a git repo, don't run git show - that can
  # look up and find the revision in a parent directory that is a git repo
  revision_suffix = ''
  if os.path.exists(utils.path_from_root('.git')):
    git_rev = utils.run_process(
      ['git', 'rev-parse', 'HEAD'],
      stdout=PIPE, stderr=PIPE, cwd=utils.path_from_root()).stdout.strip()
    revision_suffix = ' (%s)' % git_rev
  elif os.path.exists(utils.path_from_root('emscripten-revision.txt')):
    rev = read_file(utils.path_from_root('emscripten-revision.txt')).strip()
    revision_suffix = ' (%s)' % rev
  return f'emcc (Emscripten gcc/clang-like replacement + linker emulating GNU ld) {utils.EMSCRIPTEN_VERSION}{revision_suffix}'


def is_valid_abspath(path_name):
  # Any path that is underneath the emscripten repository root must be ok.
  if utils.normalize_path(path_name).startswith(utils.normalize_path(utils.path_from_root())):
    return True

  def in_directory(root, child):
    # make both path absolute
    root = os.path.realpath(root)
    child = os.path.realpath(child)

    # return true, if the common prefix of both is equal to directory
    # e.g. /a/b/c/d.rst and directory is /a/b, the common prefix is /a/b
    return os.path.commonpath([root, child]) == root

  for valid_abspath in options.valid_abspaths:
    if in_directory(valid_abspath, path_name):
      return True
  return False


def is_dash_s_for_emcc(args, i):
  # -s OPT=VALUE or -s OPT or -sOPT are all interpreted as emscripten flags.
  # -s by itself is a linker option (alias for --strip-all)
  if args[i] == '-s':
    if len(args) <= i + 1:
      return False
    arg = args[i + 1]
  else:
    arg = args[i].removeprefix('-s')
  arg = arg.split('=')[0]
  return arg.isidentifier() and arg.isupper()


def parse_s_args():
  for arg in options.s_args:
    assert arg.startswith('-s')
    arg = arg.removeprefix('-s')
    # If not = is specified default to 1
    if '=' in arg:
      key, value = arg.split('=', 1)
    else:
      key = arg
      value = '1'

    # Special handling of browser version targets. A version -1 means that the specific version
    # is not supported at all. Replace those with INT32_MAX to make it possible to compare e.g.
    # #if MIN_FIREFOX_VERSION < 68
    if re.match(r'MIN_.*_VERSION', key):
      try:
        if int(value) < 0:
          value = '0x7FFFFFFF'
      except Exception:
        pass

    key, value = normalize_boolean_setting(key, value)
    user_settings[key] = value


def parse_args(newargs):  # noqa: C901, PLR0912, PLR0915
  """Future modifications should consider refactoring to reduce complexity.

  * The McCabe cyclomatiic complexity is currently 117 vs 10 recommended.
  * There are currently 115 branches vs 12 recommended.
  * There are currently 302 statements vs 50 recommended.

  To revalidate these numbers, run `ruff check --select=C901,PLR091`.
  """
  should_exit = False
  skip = False
  builtin_settings = set(settings.keys())
  LEGACY_ARGS = {'--js-opts', '--llvm-opts', '--llvm-lto', '--memory-init-file'}
  LEGACY_FLAGS = {'--separate-asm', '--jcache', '--proxy-to-worker', '--default-obj-ext',
                  '--embind-emit-tsd', '--remove-duplicates', '--no-heap-copy'}

  for i in range(len(newargs)):
    if skip:
      skip = False
      continue

    # Support legacy '--bind' flag, by mapping to `-lembind` which now
    # has the same effect
    if newargs[i] == '--bind':
      newargs[i] = '-lembind'

    arg = newargs[i]
    arg_value = None

    if arg in CLANG_FLAGS_WITH_ARGS:
      # Ignore the next argument rather than trying to parse it.  This is needed
      # because that next arg could, for example, start with `-o` and we don't want
      # to confuse that with a normal `-o` flag.
      skip = True

    def check_flag(value):
      # Check for and consume a flag
      if arg == value:
        newargs[i] = ''
        return True
      return False

    def check_arg(name):
      nonlocal arg, arg_value
      if arg.startswith(name) and '=' in arg:
        arg, arg_value = arg.split('=', 1)
        newargs[i] = ''
        return True
      if arg == name:
        if len(newargs) <= i + 1:
          exit_with_error(f"option '{arg}' requires an argument")
        arg_value = newargs[i + 1]
        newargs[i] = ''
        newargs[i + 1] = ''
        return True
      return False

    def consume_arg():
      nonlocal arg_value
      assert arg_value is not None
      rtn = arg_value
      arg_value = None
      return rtn

    def consume_arg_file():
      name = consume_arg()
      if not os.path.isfile(name):
        exit_with_error("'%s': file not found: '%s'" % (arg, name))
      return name

    if arg in LEGACY_FLAGS:
      diagnostics.warning('deprecated', f'{arg} is no longer supported')
      continue

    for l in LEGACY_ARGS:
      if check_arg(l):
        consume_arg()
        diagnostics.warning('deprecated', f'{arg} is no longer supported')
        continue

    if arg.startswith('-s') and is_dash_s_for_emcc(newargs, i):
      s_arg = arg
      if arg == '-s':
        s_arg = '-s' + newargs[i + 1]
        newargs[i + 1] = ''
      newargs[i] = ''
      options.s_args.append(s_arg)
    elif arg.startswith('-O'):
      # Let -O default to -O2, which is what gcc does.
      opt_level = arg.removeprefix('-O') or '2'
      if opt_level == 's':
        opt_level = 2
        settings.SHRINK_LEVEL = 1
      elif opt_level == 'z':
        opt_level = 2
        settings.SHRINK_LEVEL = 2
      elif opt_level == 'g':
        opt_level = 1
        settings.SHRINK_LEVEL = 0
        settings.DEBUG_LEVEL = max(settings.DEBUG_LEVEL, 1)
      elif opt_level == 'fast':
        # -Ofast typically includes -ffast-math semantics
        options.fast_math = True
        opt_level = 3
        settings.SHRINK_LEVEL = 0
      else:
        settings.SHRINK_LEVEL = 0
      try:
        level = int(opt_level)
      except ValueError:
        exit_with_error(f"invalid integral value '{opt_level}' in '{arg}'")
      if level > 3 or level < 0:
        diagnostics.warn(f"optimization level '{arg}' is not supported; using '-O3' instead")
        newargs[i] = '-O3'
        level = 3
      settings.OPT_LEVEL = level
    elif arg.startswith('-flto'):
      if '=' in arg:
        settings.LTO = arg.split('=')[1]
      else:
        settings.LTO = 'full'
    elif arg == "-fno-lto":
      settings.LTO = 0
    elif arg == "--save-temps":
      options.save_temps = True
    elif check_arg('--closure-args'):
      args = consume_arg()
      settings.CLOSURE_ARGS += shlex.split(args)
    elif check_arg('--closure'):
      options.use_closure_compiler = int(consume_arg())
    elif check_arg('--js-transform'):
      options.js_transform = consume_arg()
    elif check_arg('--reproduce'):
      options.reproduce = consume_arg()
    elif check_arg('--pre-js'):
      options.pre_js.append(consume_arg_file())
    elif check_arg('--post-js'):
      options.post_js.append(consume_arg_file())
    elif check_arg('--extern-pre-js'):
      options.extern_pre_js.append(consume_arg_file())
    elif check_arg('--extern-post-js'):
      options.extern_post_js.append(consume_arg_file())
    elif check_arg('--compiler-wrapper'):
      config.COMPILER_WRAPPER = consume_arg()
    elif check_flag('--post-link'):
      options.post_link = True
    elif check_arg('--oformat'):
      formats = [f.lower() for f in OFormat.__members__]
      fmt = consume_arg()
      if fmt not in formats:
        exit_with_error('invalid output format: `%s` (must be one of %s)' % (fmt, formats))
      options.oformat = getattr(OFormat, fmt.upper())
    elif check_arg('--minify'):
      arg = consume_arg()
      if arg != '0':
        exit_with_error('0 is the only supported option for --minify; 1 has been deprecated')
      options.no_minify = True
    elif arg.startswith('-g'):
      options.requested_debug = arg
      debug_level = arg.removeprefix('-g') or '3'
      if is_unsigned_int(debug_level):
        # the -gX value is the debug level (-g1, -g2, etc.)
        debug_level = int(debug_level)
        settings.DEBUG_LEVEL = debug_level
        if debug_level == 0:
          # Set these explicitly so -g0 overrides previous -g on the cmdline
          settings.GENERATE_DWARF = 0
          settings.GENERATE_SOURCE_MAP = 0
          settings.EMIT_NAME_SECTION = 0
        elif debug_level > 1:
          settings.EMIT_NAME_SECTION = 1
        # if we don't need to preserve LLVM debug info, do not keep this flag
        # for clang
        if debug_level < 3 and not (settings.GENERATE_SOURCE_MAP or settings.SEPARATE_DWARF):
          newargs[i] = '-g0'
        else:
          if debug_level == 3:
            settings.GENERATE_DWARF = 1
          elif debug_level == 4:
            # In the past we supported, -g4.  But clang never did.
            # Lower this to -g3, and report a warning.
            newargs[i] = '-g3'
            diagnostics.warning('deprecated', 'please replace -g4 with -gsource-map')
            settings.GENERATE_SOURCE_MAP = 1
          elif debug_level > 4:
            exit_with_error("unknown argument: '%s'", arg)
      else:
        if debug_level.startswith('force_dwarf'):
          exit_with_error('gforce_dwarf was a temporary option and is no longer necessary (use -g)')
        elif debug_level.startswith('separate-dwarf'):
          # emit full DWARF but also emit it in a file on the side
          newargs[i] = '-g'
          # if a file is provided, use that; otherwise use the default location
          # (note that we do not know the default location until all args have
          # been parsed, so just note True for now).
          if debug_level != 'separate-dwarf':
            if not debug_level.startswith('separate-dwarf=') or debug_level.count('=') != 1:
              exit_with_error('invalid -gseparate-dwarf=FILENAME notation')
            settings.SEPARATE_DWARF = debug_level.split('=')[1]
          else:
            settings.SEPARATE_DWARF = True
          settings.GENERATE_DWARF = 1
          settings.DEBUG_LEVEL = 3
        elif debug_level in {'source-map', 'source-map=inline'}:
          settings.GENERATE_SOURCE_MAP = 1 if debug_level == 'source-map' else 2
          newargs[i] = '-g'
        elif debug_level == 'z':
          # Ignore `-gz`.  We don't support debug info compression.
          pass
        else:
          # Other non-integer levels (e.g. -gline-tables-only or -gdwarf-5) are
          # usually clang flags that emit DWARF. So we pass them through to
          # clang and make the emscripten code treat it like any other DWARF.
          settings.GENERATE_DWARF = 1
          settings.EMIT_NAME_SECTION = 1
          settings.DEBUG_LEVEL = 3
    elif check_flag('-profiling') or check_flag('--profiling'):
      settings.DEBUG_LEVEL = max(settings.DEBUG_LEVEL, 2)
      settings.EMIT_NAME_SECTION = 1
    elif check_flag('-profiling-funcs') or check_flag('--profiling-funcs'):
      settings.EMIT_NAME_SECTION = 1
    elif newargs[i] == '--tracing' or newargs[i] == '--memoryprofiler':
      if newargs[i] == '--memoryprofiler':
        options.memory_profiler = True
      newargs[i] = ''
      settings.EMSCRIPTEN_TRACING = 1
    elif check_flag('--emit-symbol-map'):
      options.emit_symbol_map = True
      settings.EMIT_SYMBOL_MAP = 1
    elif check_arg('--emit-minification-map'):
      settings.MINIFICATION_MAP = consume_arg()
    elif check_arg('--embed-file'):
      options.embed_files.append(consume_arg())
    elif check_arg('--preload-file'):
      options.preload_files.append(consume_arg())
    elif check_arg('--exclude-file'):
      options.exclude_files.append(consume_arg())
    elif check_flag('--use-preload-cache'):
      options.use_preload_cache = True
    elif check_flag('--use-preload-plugins'):
      options.use_preload_plugins = True
    elif check_flag('--ignore-dynamic-linking'):
      options.ignore_dynamic_linking = True
    elif arg == '-v':
      shared.PRINT_SUBPROCS = True
    elif arg == '-###':
      shared.SKIP_SUBPROCS = True
    elif check_arg('--shell-file'):
      options.shell_html = consume_arg_file()
    elif check_arg('--source-map-base'):
      options.source_map_base = consume_arg()
    elif check_arg('--emit-tsd'):
      options.emit_tsd = consume_arg()
    elif check_flag('--no-entry'):
      options.no_entry = True
    elif check_arg('--cache'):
      config.CACHE = os.path.abspath(consume_arg())
      cache.setup()
      # Ensure child processes share the same cache (e.g. when using emcc to compiler system
      # libraries)
      os.environ['EM_CACHE'] = config.CACHE
    elif check_flag('--clear-cache'):
      logger.info('clearing cache as requested by --clear-cache: `%s`', cache.cachedir)
      cache.erase()
      shared.perform_sanity_checks() # this is a good time for a sanity check
      should_exit = True
    elif check_flag('--clear-ports'):
      logger.info('clearing ports and cache as requested by --clear-ports')
      ports.clear()
      cache.erase()
      shared.perform_sanity_checks() # this is a good time for a sanity check
      should_exit = True
    elif check_flag('--check'):
      print(version_string(), file=sys.stderr)
      shared.check_sanity(force=True)
      should_exit = True
    elif check_flag('--show-ports'):
      ports.show_ports()
      should_exit = True
    elif check_arg('--valid-abspath'):
      options.valid_abspaths.append(consume_arg())
    elif arg.startswith(('-I', '-L')):
      path_name = arg[2:]
      # Look for '/' explicitly so that we can also diagnose identically if -I/foo/bar is passed on Windows.
      # Python since 3.13 does not treat '/foo/bar' as an absolute path on Windows.
      if (path_name.startswith('/') or os.path.isabs(path_name)) and not is_valid_abspath(path_name):
        # Of course an absolute path to a non-system-specific library or header
        # is fine, and you can ignore this warning. The danger are system headers
        # that are e.g. x86 specific and non-portable. The emscripten bundled
        # headers are modified to be portable, local system ones are generally not.
        diagnostics.warning(
            'absolute-paths', f'-I or -L of an absolute path "{arg}" '
            'encountered. If this is to a local system header/library, it may '
            'cause problems (local system files make sense for compiling natively '
            'on your system, but not necessarily to JavaScript).')
      if arg.startswith('-L'):
        options.lib_dirs.append(path_name)
    elif check_flag('--emrun'):
      options.emrun = True
    elif check_flag('--cpuprofiler'):
      options.cpu_profiler = True
    elif check_flag('--threadprofiler'):
      settings.PTHREADS_PROFILING = 1
    elif arg in {'-fcolor-diagnostics', '-fdiagnostics-color', '-fdiagnostics-color=always'}:
      colored_logger.enable(force=True)
    elif arg in {'-fno-color-diagnostics', '-fno-diagnostics-color', '-fdiagnostics-color=never'}:
      colored_logger.disable()
    elif arg == '-fno-exceptions':
      settings.DISABLE_EXCEPTION_CATCHING = 1
      settings.DISABLE_EXCEPTION_THROWING = 1
    elif arg == '-mbulk-memory':
      feature_matrix.enable_feature(feature_matrix.Feature.BULK_MEMORY,
                                    '-mbulk-memory',
                                    override=True)
    elif arg == '-mno-bulk-memory':
      feature_matrix.disable_feature(feature_matrix.Feature.BULK_MEMORY)
    elif arg == '-mnontrapping-fptoint':
      feature_matrix.enable_feature(feature_matrix.Feature.NON_TRAPPING_FPTOINT,
                                    '-mnontrapping-fptoint',
                                    override=True)
    elif arg == '-mno-nontrapping-fptoint':
      feature_matrix.disable_feature(feature_matrix.Feature.NON_TRAPPING_FPTOINT)
    elif arg == '-fexceptions':
      # TODO Currently -fexceptions only means Emscripten EH. Switch to wasm
      # exception handling by default when -fexceptions is given when wasm
      # exception handling becomes stable.
      settings.DISABLE_EXCEPTION_THROWING = 0
      settings.DISABLE_EXCEPTION_CATCHING = 0
    elif arg == '-fwasm-exceptions':
      settings.WASM_EXCEPTIONS = 1
    elif arg == '-fignore-exceptions':
      settings.DISABLE_EXCEPTION_CATCHING = 1
    elif arg == '-ffast-math':
      options.fast_math = True
    elif arg.startswith('-fsanitize=cfi'):
      exit_with_error('emscripten does not currently support -fsanitize=cfi')
    elif check_arg('--output_eol') or check_arg('--output-eol'):
      style = consume_arg()
      if style.lower() == 'windows':
        options.output_eol = '\r\n'
      elif style.lower() == 'linux':
        options.output_eol = '\n'
      else:
        exit_with_error(f'invalid value for --output-eol: `{style}`')
    # Record PTHREADS setting because it controls whether --shared-memory is passed to lld
    elif arg == '-pthread':
      settings.PTHREADS = 1
      # Also set the legacy setting name, in case use JS code depends on it.
      settings.USE_PTHREADS = 1
    elif arg == '-no-pthread':
      settings.PTHREADS = 0
      # Also set the legacy setting name, in case use JS code depends on it.
      settings.USE_PTHREADS = 0
    elif arg == '-pthreads':
      exit_with_error('unrecognized command-line option `-pthreads`; did you mean `-pthread`?')
    elif arg == '-fno-rtti':
      settings.USE_RTTI = 0
    elif arg == '-frtti':
      settings.USE_RTTI = 1
    elif arg.startswith('-jsD'):
      key = arg.removeprefix('-jsD')
      if '=' in key:
        key, value = key.split('=', 1)
      else:
        value = '1'
      if key in builtin_settings:
        exit_with_error(f'{arg}: cannot change built-in settings values with a -jsD directive. Pass -s{key}={value} instead!')
      # Allow overrides/duplicates for user-defined -jsD flags
      settings[key] = value
      newargs[i] = ''
    elif check_flag('-shared'):
      options.shared = True
    elif check_flag('-r'):
      options.relocatable = True
    elif arg.startswith('-o'):
      options.output_file = arg.removeprefix('-o')
    elif check_flag('-m64'):
      settings.MEMORY64 = 1
    elif check_flag('-m32'):
      settings.MEMORY64 = 0
    elif check_arg('-target') or check_arg('--target'):
      options.target = consume_arg()
      if options.target not in {'wasm32', 'wasm64', 'wasm64-unknown-emscripten', 'wasm32-unknown-emscripten'}:
        exit_with_error(f'unsupported target: {options.target} (emcc only supports wasm64-unknown-emscripten and wasm32-unknown-emscripten)')
    elif check_arg('--use-port'):
      ports.handle_use_port_arg(settings, consume_arg())
    elif arg in {'-c', '--precompile'}:
      options.dash_c = True
    elif arg == '-S':
      options.dash_S = True
    elif arg == '-E':
      options.dash_E = True
    elif arg in {'-M', '-MM'}:
      options.dash_M = True
    elif arg.startswith('-x'):
      # TODO(sbc): Handle multiple -x flags on the same command line
      options.input_language = arg
    elif arg == '-fsyntax-only':
      options.syntax_only = True
    elif arg in SIMD_INTEL_FEATURE_TOWER or arg in SIMD_NEON_FLAGS:
      # SSEx is implemented on top of SIMD128 instruction set, but do not pass SSE flags to LLVM
      # so it won't think about generating native x86 SSE code.
      newargs[i] = ''
    elif arg == '-nostdlib':
      options.nostdlib = True
    elif arg == '-nostdlibxx':
      options.nostdlibxx = True
    elif arg == '-nodefaultlibs':
      options.nodefaultlibs = True
    elif arg == '-nolibc':
      options.nolibc = True
    elif arg == '-nostartfiles':
      options.nostartfiles = True
    elif arg == '-fsanitize-minimal-runtime':
      options.sanitize_minimal_runtime = True
    elif arg.startswith('-fsanitize='):
      options.sanitize.update(arg.split('=', 1)[1].split(','))
    elif arg.startswith('-fno-sanitize='):
      options.sanitize.difference_update(arg.split('=', 1)[1].split(','))
    elif arg and (arg == '-' or not arg.startswith('-')):
      options.input_files.append(arg)

  if should_exit:
    sys.exit(0)

  return [a for a in newargs if a]


def expand_byte_size_suffixes(value):
  """Convert a string with KB/MB size suffix, such as "32MB", to number of bytes."""
  value = value.strip()
  match = re.match(r'^(\d+)\s*([kmgt]?b)?$', value, re.I)
  if not match:
    exit_with_error("invalid byte size `%s`.  Valid suffixes are: kb, mb, gb, tb" % value)
  value, suffix = match.groups()
  value = int(value)
  if suffix:
    size_suffixes = {suffix: 1024 ** i for i, suffix in enumerate(['b', 'kb', 'mb', 'gb', 'tb'])}
    value *= size_suffixes[suffix.lower()]
  return value


def parse_symbol_list_file(contents):
  """Parse contents of one-symbol-per-line response file.

  This format can by used with, for example, -sEXPORTED_FUNCTIONS=@filename and
  avoids the need for any kind of quoting or escaping.
  """
  values = contents.splitlines()
  return [v.strip() for v in values if not v.startswith('#')]


def parse_value(text, expected_type):
  # Note that using response files can introduce whitespace, if the file
  # has a newline at the end. For that reason, we rstrip() in relevant
  # places here.
  def parse_string_value(text):
    first = text[0]
    if first in {"'", '"'}:
      text = text.rstrip()
      if text[-1] != text[0] or len(text) < 2:
         raise ValueError(f'unclosed quoted string. expected final character to be "{text[0]}" and length to be greater than 1 in "{text[0]}"')
      return text[1:-1]
    return text

  def parse_string_list_members(text):
    sep = ','
    values = text.split(sep)
    result = []
    index = 0
    while True:
      current = values[index].lstrip() # Cannot safely rstrip for cases like: "HERE-> ,"
      if not len(current):
        raise ValueError('empty value in string list')
      first = current[0]
      if first not in {"'", '"'}:
        result.append(current.rstrip())
      else:
        start = index
        while True: # Continue until closing quote found
          if index >= len(values):
            raise ValueError(f"unclosed quoted string. expected final character to be '{first}' in '{values[start]}'")
          new = values[index].rstrip()
          if new and new[-1] == first:
            if start == index:
              result.append(current.rstrip()[1:-1])
            else:
              result.append((current + sep + new)[1:-1])
            break
          else:
            current += sep + values[index]
            index += 1

      index += 1
      if index >= len(values):
        break
    return result

  def parse_string_list(text):
    text = text.rstrip()
    if text and text[0] == '[':
      if text[-1] != ']':
        raise ValueError('unterminated string list. expected final character to be "]"')
      text = text[1:-1]
    if not text.strip():
      return []
    return parse_string_list_members(text)

  if expected_type == list or (text and text[0] == '['):
    # if json parsing fails, we fall back to our own parser, which can handle a few
    # simpler syntaxes
    try:
      parsed = json.loads(text)
    except ValueError:
      return parse_string_list(text)

    # if we succeeded in parsing as json, check some properties of it before returning
    if type(parsed) not in {str, list}:
      raise ValueError(f'settings must be strings or lists (not {type(parsed)})')
    if type(parsed) is list:
      for elem in parsed:
        if type(elem) is not str:
          raise ValueError(f'list members in settings must be strings (not {type(elem)})')

    return parsed

  if expected_type == float:
    try:
      return float(text)
    except ValueError:
      pass

  try:
    if text.startswith('0x'):
      base = 16
    else:
      base = 10
    return int(text, base)
  except ValueError:
    return parse_string_value(text)


def apply_user_settings():
  """Take a map of users settings {NAME: VALUE} and apply them to the global settings object."""
  # Stash a copy of all available incoming APIs before the user can potentially override it
  settings.ALL_INCOMING_MODULE_JS_API = settings.INCOMING_MODULE_JS_API + EXTRA_INCOMING_JS_API

  for key, value in user_settings.items():
    if key in settings.internal_settings:
      exit_with_error('%s is an internal setting and cannot be set from command line', key)

    # map legacy settings which have aliases to the new names
    # but keep the original key so errors are correctly reported via the `setattr` below
    user_key = key
    if key in settings.legacy_settings and key in settings.alt_names:
      key = settings.alt_names[key]

    # In those settings fields that represent amount of memory, translate suffixes to multiples of 1024.
    if key in MEM_SIZE_SETTINGS:
      value = str(expand_byte_size_suffixes(value))

    filename = None
    if value and value[0] == '@':
      filename = value.removeprefix('@')
      if not os.path.isfile(filename):
        exit_with_error('%s: file not found parsing argument: %s=%s' % (filename, key, value))
      value = read_file(filename).strip()
    else:
      value = value.replace('\\', '\\\\')

    expected_type = settings.types.get(key)

    if filename and expected_type == list and value.strip()[0] != '[':
      # Prefer simpler one-line-per value parser
      value = parse_symbol_list_file(value)
    else:
      try:
        value = parse_value(value, expected_type)
      except Exception as e:
        exit_with_error(f'error parsing "-s" setting "{key}={value}": {e}')

    setattr(settings, user_key, value)

    if key == 'EXPORTED_FUNCTIONS':
      # used for warnings in emscripten.py
      settings.USER_EXPORTS = settings.EXPORTED_FUNCTIONS.copy()

    if key == 'JSPI':
      settings.ASYNCIFY = 2
    if key == 'JSPI_IMPORTS':
      settings.ASYNCIFY_IMPORTS = value
    if key == 'JSPI_EXPORTS':
      settings.ASYNCIFY_EXPORTS = value


def normalize_boolean_setting(name, value):
  # boolean NO_X settings are aliases for X
  # (note that *non*-boolean setting values have special meanings,
  # and we can't just flip them, so leave them as-is to be
  # handled in a special way later)
  if name.startswith('NO_') and value in {'0', '1'}:
    name = name.removeprefix('NO_')
    value = str(1 - int(value))
  return name, value


def normalize_args(args):
  """Normalize argument that can be specific as either one or two arguments.

  In some cases these arguments are simply joined together.  For example
  [`-o` `foo`] becomes `-ofoo` and [`-L` `bar`] becomes `-Lbar`.

  In other cases they are joined by an equals sign.  For example ['--js-library`, `foo.js`]
  becomes `--js-library=foo.js`.
  """
  equals_args = {'--js-library'}
  join_args = {'-l', '-L', '-I', '-z', '-o', '-x', '-u'} | equals_args
  for i in range(len(args)):
    if args[i] in join_args:
      if args[i] in equals_args:
        args[i] += '='
      if len(args) <= i + 1:
        exit_with_error(f"option '{args[i]}' requires an argument")
      args[i] += args[i + 1]
      args[i + 1] = ''

  return [a for a in args if a]


@ToolchainProfiler.profile()
def parse_arguments(args):
  newargs = list(args)

  # Scan and strip emscripten specific cmdline warning flags.
  # This needs to run before other cmdline flags have been parsed, so that
  # warnings are properly printed during arg parse.
  newargs = diagnostics.capture_warnings(newargs)

  if not diagnostics.is_enabled('deprecated'):
    settings.WARN_DEPRECATED = 0

  newargs = normalize_args(newargs)
  newargs = parse_args(newargs)

  if options.post_link or options.oformat == OFormat.BARE:
    diagnostics.warning('experimental', '--oformat=bare/--post-link are experimental and subject to change.')

  parse_s_args()

  # STRICT is used when applying settings so it needs to be applied first before
  # calling `apply_user_settings`.
  strict_cmdline = user_settings.get('STRICT')
  if strict_cmdline:
    settings.STRICT = int(strict_cmdline)

  # Apply -s args here (after optimization levels, so they can override them)
  apply_user_settings()

  return newargs
