Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
test.py
/work/


### Python
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/terser", "src/alpha93"]
packages = ["src/terser", "src/alpha93", "src/terser_hints"]

[tool.ruff]
src = ["src"]
Expand Down
11 changes: 5 additions & 6 deletions src/terser/_minify.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,15 @@ def minify(
/,
config: TransformConfig,
*,
link_imports: bool = True,
strict: bool = False,
defines: dict[str, bool] | None = None,
rename: bool = True,
preserved_names: list[str] | None = None,
hoist_literals: bool = True,
) -> tuple[ast.Module, str | None]:
getattr(task, "_test", lambda _: None)(str(spec))

with task("Preprocessing sources"):
source, shebang = preprocessor.preprocess(source, defines, strict)

Expand All @@ -73,15 +76,11 @@ def minify(

with task("Resolving names"):
resolver.resolve(module)
resolver.bind(module)
resolver.bind(module, link_imports=link_imports)

cache = transforms.TransformCache(config)
for _ in task("Applying transforms", range(config.passes)):
for transform in transforms.__transforms__:
if not transform.is_enabled(config) or transform.FLAGS > 1:
continue

module: ast.Module = transform(cache)(module)
module = transforms.apply_pass(cache, module, transforms.__transforms__, 1)

if not any(cache.passes.values()):
break
Expand Down
31 changes: 22 additions & 9 deletions src/terser/_pipeline/mangler/_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,15 +64,28 @@ def additional_byte_cost(self):

def rename(self, new_name):

for node in self.references:
replace(node, ast.Name(id=new_name, ctx=ast.Load()))

self._local_namespace.body = list(
insert(
self._local_namespace.body,
ast.Assign(targets=[ast.Name(id=new_name, ctx=ast.Store())], value=self._value_node),
)
)
# snapshot first - add_reference (below) appends to this same list, and we
# only want to replace the references that existed going into this rename
old_references = list(self.references)

for node in old_references:
new_node = ast.Name(id=new_name, ctx=ast.Load())
replace(node, new_node)
self.add_reference(new_node)

# self._value_node was one of the just-replaced references (the first occurrence
# found) - it's an existing, already-registered node, just moving to a new parent
target = ast.Name(id=new_name, ctx=ast.Store())
new_stmt = ast.Assign(targets=[target], value=self._value_node)

NodeRef.new(new_stmt, self._local_namespace)
ref(new_stmt).namespace = self._local_namespace
NodeRef.new(target, new_stmt)
ref(target).namespace = self._local_namespace
ref(self._value_node).parent = new_stmt
self.add_reference(target)

self._local_namespace.body = list(insert(self._local_namespace.body, new_stmt))

self._name = new_name

Expand Down
14 changes: 7 additions & 7 deletions src/terser/_pipeline/mangler/_globals.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,10 @@ def _disallow(project: dict[str, ModuleRef], rename_globals: bool, preserved: di

def _from_import_links(project: dict[str, ModuleRef]):
"""
(alias node, origin binding) pairs for `from x import y [as z]`, where `y` is a name bound
in `x` (rather than a submodule of `x`) - once `y` is renamed, the alias's imported name
must follow, independently of whatever local name `z`/`y` mangles to in the importer.
(alias node, local binding, origin binding) triples for `from x import y [as z]`, where `y`
is a name bound in `x` (rather than a submodule of `x`) - once `y` is renamed, the alias's
imported name must follow, independently of whatever local name `z`/`y` mangles to in the
importer.
"""

links = []
Expand All @@ -39,7 +40,7 @@ def _from_import_links(project: dict[str, ModuleRef]):

origin = next((b for b in binding.target.bindings if b.name == binding.target_name), None)
if origin is not None:
links.append((binding.node, origin))
links.append((binding.node, binding, origin))

return links

Expand Down Expand Up @@ -144,10 +145,9 @@ def mangle_globals(project: dict[str, ModuleRef], rename_globals: bool = False,
for namespace, binding in pairs:
assigner.assign(namespace, binding)

for alias_node, origin in from_import_links:
for alias_node, local, origin in from_import_links:
alias_node.name = origin.name
if alias_node.asname == alias_node.name:
alias_node.asname = None
alias_node.asname = local.name if local.name != alias_node.name else None

for attribute_node, origin in attribute_links:
attribute_node.attr = origin.name
39 changes: 33 additions & 6 deletions src/terser/_pipeline/mangler/_locals.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,14 @@ def reserve_name(name, reservation_scope):
"""

for namespace in reservation_scope:
ref(namespace).assigned_names.add(name)
namespace_ref = ref(namespace)
if not hasattr(namespace_ref, 'assigned_names'):
# namespace is no longer reachable from the module root (a transform deleted
# the subtree it belonged to) - nothing will ever rename anything in it, so
# there's nothing to reserve
continue

namespace_ref.assigned_names.add(name)


def should_rename(binding, name, scope, is_available):
Expand Down Expand Up @@ -199,7 +206,16 @@ def is_available(self, name, reservation_scope):

"""

return all(name not in ref(namespace).assigned_names for namespace in reservation_scope)
def unreserved(namespace):
namespace_ref = ref(namespace)
if not hasattr(namespace_ref, 'assigned_names'):
# namespace is no longer reachable from the module root (a transform
# deleted the subtree it belonged to) - nothing reserves anything there
return True

return name not in namespace_ref.assigned_names

return all(unreserved(namespace) for namespace in reservation_scope)

def assign(self, namespace, binding, *, prefix=''):
"""
Expand All @@ -218,6 +234,17 @@ def assign(self, namespace, binding, *, prefix=''):
scope = reservation_scope(namespace, binding)

if binding.allow_rename:
# A binding may have already reserved its own current name in an earlier,
# separate pass (e.g. mangle_locals reserving every global's name so local
# mangling doesn't shadow it, before mangle_globals gets a turn at the same
# binding) - undo that self-reservation before checking availability, or
# `should_rename` sees its own name as "already taken" and force-renames it
# even when keeping it would be shorter.
for ns in scope:
ns_ref = ref(ns)
if hasattr(ns_ref, 'assigned_names'):
ns_ref.assigned_names.discard(binding.name)

name = self.available_name(scope, prefix=prefix)

if should_rename(binding, name, scope, self.is_available):
Expand Down Expand Up @@ -248,7 +275,7 @@ def __call__(self, module, prefix_globals=False, reserved_globals=None):
return module


def mangle_locals(module, rename_locals=True, preserve_locals=None):
def mangle_locals(module: ast.Module, rename_locals: bool = True, preserved_names: list[str] | None = None):
"""
Mangle locals/nonlocals - names bound in function and class namespaces

Expand All @@ -259,11 +286,11 @@ def mangle_locals(module, rename_locals=True, preserve_locals=None):
:param module: The module to mangle locals in
:type module: :class:`ast.Module`
:param bool rename_locals: If local names may be renamed
:param preserve_locals: Local names to leave unchanged
:type preserve_locals: list[str] | None
:param preserved_names: Local names to leave unchanged
:type preserved_names: list[str] | None
"""

allow_rename_locals(module, rename_locals, preserve_locals)
allow_rename_locals(module, rename_locals, preserved_names)

add_assigned(module)

Expand Down
2 changes: 1 addition & 1 deletion src/terser/_pipeline/parser/_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ def __function_arguments(self, node: ast.arguments, fn: Invokable, /):

for arg in (typed[list[ast.arg]].getattr(node, "posonlyargs", []) + node.args):
self.__resolve(arg, namespace=fn)
if hasattr(arg, "ref") and arg.annotation is not None:
if arg.annotation is not None:
self.__resolve(arg.annotation, namespace=namespace)

if hasattr(node, "kwonlyargs"):
Expand Down
28 changes: 28 additions & 0 deletions src/terser/_pipeline/printer/token_printer.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,30 @@
from re import compile as _re


def _raw_literal(value: str) -> str | None:
"""
Render `value` as a raw string literal (`r"..."`/`r'...'`), or `None` if it can't be
one - `repr()` always escapes backslashes, which is longer than the source for
backslash-heavy strings like regex patterns (`r"some\\.raw\\.strings"`).

A raw string can't represent: non-printable characters (no escape sequences at all
in raw mode), a trailing odd run of backslashes (would escape the closing quote), or
a value containing both quote characters (nothing left to delimit it with).
"""
if not value.isprintable():
return None

trailing_backslashes = len(value) - len(value.rstrip('\\'))
if trailing_backslashes % 2 == 1:
return None

for quote in ("'", '"'):
if quote not in value:
return f"r{quote}{value}{quote}"

return None


class TokenTypes(IntEnum):
NoToken = 0
Identifier = 1
Expand Down Expand Up @@ -143,6 +167,10 @@ def stringliteral(self, value):
"""Add a string literal to the output code."""
s = repr(value)

raw = _raw_literal(value)
if raw is not None and len(raw) < len(s):
s = raw

if len(s) > 0 and s[0].isalpha() and self.previous_token in [TokenTypes.Identifier, TokenTypes.Keyword, TokenTypes.SoftKeyword]:
self.delimiter(' ')

Expand Down
5 changes: 3 additions & 2 deletions src/terser/_pipeline/resolver/binder/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@
from ast import Module


def bind(module: Module):
def bind(module: Module, /, link_imports: bool = False):
__resolve__all__(module)
__mark_exports(module)
__resolve_imports(module)
if link_imports:
__resolve_imports(module)
__bind(module)


Expand Down
2 changes: 1 addition & 1 deletion src/terser/_pipeline/resolver/binder/_all.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ def resolve_all(module: ast.Module):
if not __is_assign(node):
continue

if not isinstance(node.value, ast.List):
if not isinstance(node.value, (ast.List, ast.Tuple)):
continue

for el in node.value.elts:
Expand Down
8 changes: 5 additions & 3 deletions src/terser/_pipeline/resolver/binder/_bind.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

from terser.ast import ModuleRef, ast, ref
from ..binding import Binding, BuiltinBinding, UnresolvedBinding
from ..util import scope_ref_global, scope_ref_nonlocal
from ..util import is_python_mangled_private, scope_ref_global, scope_ref_nonlocal
from ...parser._scope import ScopeResolver

if TYPE_CHECKING:
Expand Down Expand Up @@ -42,8 +42,10 @@ def __get_binding(name: str, namespace_ref: ScopedNode) -> Binding:
def __attr_get_binding(name: str, namespace: ScopedNode) -> Binding:
binding = __get_binding(name, namespace)

if isinstance(namespace.ast, ast.ClassDef):
# This name will become an attribute of a class, so it can't be renamed
if isinstance(namespace.ast, ast.ClassDef) and not is_python_mangled_private(name):
# This name will become an attribute of a class, so it can't be renamed -
# unless Python's own compiler already private-mangles it (`__foo`), in which
# case it's already unreachable from outside under its literal spelling.
binding.disallow_rename()

return binding
Expand Down
27 changes: 23 additions & 4 deletions src/terser/_pipeline/resolver/binder/_mark_exports.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,26 @@
from typing import TYPE_CHECKING

from terser.ast import ref
from terser.ast import ast, ref
from ..binding import ImportBinding

if TYPE_CHECKING:
from ast import Module


def _is_intentional_reexport(binding) -> bool:
"""
Without an explicit `__all__`, a locally-defined name is implicitly part of the module's
public interface - but a plain import isn't: `from typing import override` used only for
a decorator isn't "re-exporting `override`" by convention (matching ruff/pyflakes F401),
only the explicit `import x as x` / `from y import x as x` idiom is.
"""
if not isinstance(binding, ImportBinding):
return True

node = binding.node
return isinstance(node, ast.alias) and node.asname == node.name


def mark_exports(module: Module) -> None:
"""
Flag the module-level bindings that make up `module_ref`'s public interface - importable via
Expand All @@ -17,9 +32,13 @@ def mark_exports(module: Module) -> None:
"""

module_ref = ref(module)
exported_names: set[str] = module_ref.all or {
name for binding in module_ref.bindings if (name := binding.name) and not name.startswith('__')
}
if module_ref.all is not None:
exported_names: set[str] = module_ref.all
else:
exported_names = {
name for binding in module_ref.bindings
if (name := binding.name) and not name.startswith('__') and _is_intentional_reexport(binding)
}

for binding in module_ref.bindings:
if binding.name in exported_names:
Expand Down
Loading