@@ -236,6 +236,49 @@ def _module_css_definitions(content: str, rel_path: str):
236236 yield from definitions
237237
238238
239+ def _global_css_rule_spans (content : str ):
240+ """Map existing global findings to complete rules without adding selectors."""
241+ masked = _CSS_NONCODE_PATTERN .sub (
242+ lambda match : match .group () if match .group ().startswith ("\\ " ) else re .sub (r"[^\n]" , " " , match .group ()), content
243+ )
244+ lines = [0 , * (match .end () for match in re .finditer ("\n " , content ))]
245+ spans : list [tuple [int , int , int | None ]] = []
246+ blocks : list [int | None ] = []
247+ start = 0
248+ parentheses = brackets = 0
249+ for match in _CSS_BLOCK_TOKEN_PATTERN .finditer (masked ):
250+ token = match .group ()
251+ if token .startswith ("\\ " ):
252+ continue
253+ if token == "(" :
254+ parentheses += 1
255+ elif token == ")" :
256+ parentheses = max (0 , parentheses - 1 )
257+ elif token == "[" :
258+ brackets += 1
259+ elif token == "]" :
260+ brackets = max (0 , brackets - 1 )
261+ elif parentheses or brackets :
262+ continue
263+ elif token == "{" :
264+ selector = masked [start : match .start ()]
265+ selector_start = start + len (selector ) - len (selector .lstrip ())
266+ if selector .strip () and not selector .lstrip ().startswith ("@" ):
267+ blocks .append (len (spans ))
268+ spans .append ((selector_start , match .start (), None ))
269+ else :
270+ blocks .append (None )
271+ start = match .end ()
272+ elif token == "}" :
273+ if blocks and (index := blocks .pop ()) is not None :
274+ header_start , opening , _ = spans [index ]
275+ spans [index ] = (header_start , opening , bisect_right (lines , match .start ()))
276+ start = match .end ()
277+ else :
278+ start = match .end ()
279+ return spans , lines
280+
281+
239282# import statements
240283# Handles: import {Foo} from ..., import Foo from ..., import type {Foo} from ...,
241284# import Default, {Named} from ..., import {type Foo} from ...
@@ -442,8 +485,11 @@ def _jsx_class_attributes(content: str, attribute_positions: set[int] | None = N
442485)
443486
444487# Computed keys are full quoted JavaScript literals, not identifier prefixes.
488+ # Keep non-ASCII continuation characters intact, including marks and ZWJ/ZWNJ.
489+ # Runtime token starts and the actual imported name still establish ownership.
490+ _JS_MODULE_BINDING = r"(?:[^\W\d]|[$])(?:[\w$]|[^\x00-\x7f\s])*"
445491_CSS_MODULE_USAGE_PATTERN = re .compile (
446- r """(?<![\w$])([\w$]+ )(?:\s*\??\.\s*([\w$]+)|\s*(?:\?\.)?\s*\[\s*"""
492+ rf """(?<![\w$])({ _JS_MODULE_BINDING } )(?:\s*\??\.\s*([\w$]+)|\s*(?:\?\.)?\s*\[\s*"""
447493 r"""('(?:\\[\s\S]|[^'\\\r\n])*'|"(?:\\[\s\S]|[^"\\\r\n])*"|`(?:\\[\s\S]|[^`\\])*`)\s*\])""" ,
448494)
449495
@@ -541,8 +587,46 @@ def _class_expression_values(expression: str, module_bindings: set[str]) -> tupl
541587# Detect a CSS-module import so we know which source-file class accessors
542588# correspond to module classes (``import styles from './x.module.css'``).
543589_CSS_MODULE_IMPORT_PATTERN = re .compile (
544- r"""import\s+(?:\*\s+as\s+|\{\s*default\s+as\s+)?([\w$]+)(?:\s*\}|\s*,\s*\{[^}]*\})?\s+from\s+['"]([^'"]*\.module\.css)['"]""" ,
590+ rf"""import\s+(?:\*\s+as\s+|\{{\s*default\s+as\s+)?({ _JS_MODULE_BINDING } )(?:\s*\}}|\s*,\s*\{{[^}}]*\}})?\s+from\s+['"]([^'"]*\.module\.css)['"]""" ,
591+ )
592+ _JS_IMPORT_GAP = rf"(?:\s|{ _JS_COMMENT_PATTERN .pattern } )*"
593+ _CSS_NAMED_IMPORT_PATTERN = re .compile (rf"import{ _JS_IMPORT_GAP } \{{" )
594+ _CSS_IMPORT_SOURCE_PATTERN = re .compile (rf"""{ _JS_IMPORT_GAP } from{ _JS_IMPORT_GAP } (['"])([^'"]*\.module\.css)\1""" )
595+ _JS_IMPORT_NONCODE_PATTERN = re .compile (_JS_LITERAL_PATTERN .pattern + "|" + _JS_COMMENT_PATTERN .pattern )
596+ _CSS_IMPORT_PART_PATTERN = re .compile (_JS_LITERAL_PATTERN .pattern + "|," )
597+ _CSS_DEFAULT_SPECIFIER_PATTERN = re .compile (
598+ rf"""(default|'(?:\\[\s\S]|[^'\\\r\n])*'|"(?:\\[\s\S]|[^"\\\r\n])*")\s+as\s+({ _JS_MODULE_BINDING } )"""
545599)
600+ _CSS_IMPORT_KEYWORD_PATTERN = re .compile (r"\bimport\b" )
601+
602+
603+ def _css_module_imports (content : str , code_starts : set [int ]):
604+ """Read runtime default bindings from complete import-specifier lists."""
605+ for keyword in _CSS_IMPORT_KEYWORD_PATTERN .finditer (content ):
606+ if keyword .start () not in code_starts :
607+ continue
608+ match = _CSS_MODULE_IMPORT_PATTERN .match (content , keyword .start ())
609+ if match :
610+ yield match .group (1 ), match .group (2 )
611+ match = _CSS_NAMED_IMPORT_PATTERN .match (content , keyword .start ())
612+ if match is None :
613+ continue
614+ end = _js_delimited_end (content , match .end () - 1 )
615+ source = _CSS_IMPORT_SOURCE_PATTERN .match (content , end ) if end is not None else None
616+ if source is None :
617+ continue
618+ specifiers = _JS_IMPORT_NONCODE_PATTERN .sub (
619+ lambda token : " " if token .group ().startswith (("//" , "/*" )) else token .group (),
620+ content [match .end () : end - 1 ],
621+ )
622+ start = 0
623+ # A comma inside a quoted import name is not a specifier separator.
624+ ends = [token .start () for token in _CSS_IMPORT_PART_PATTERN .finditer (specifiers ) if token .group () == "," ]
625+ for stop in [* ends , len (specifiers )]:
626+ specifier = _CSS_DEFAULT_SPECIFIER_PATTERN .fullmatch (specifiers [start :stop ].strip ())
627+ if specifier and (specifier .group (1 ) == "default" or _decode_js_string (specifier .group (1 )) == "default" ):
628+ yield specifier .group (2 ), source .group (2 )
629+ start = stop + 1
546630
547631
548632# ── Scanner ───────────────────────────────────────────────────────────
@@ -633,13 +717,20 @@ def scan(self) -> ScanResult:
633717 # Plain JS/TS can also export class props or classNames() values.
634718 if not self ._is_css_file (rel_path ):
635719 class_source = class_usage_source (content , rel_path )
636- self ._parse_classname_usage (class_source , used_css_classes , jsx = not rel_path .endswith (".ts" ))
720+ self ._parse_classname_usage (
721+ class_source , used_css_classes , jsx = not rel_path .endswith (".ts" ), import_source = content
722+ )
637723
638724 # CSS modules can also be consumed by plain TypeScript/JavaScript
639725 # files, for example when exporting class-name maps to components.
640726 if not self ._is_css_file (rel_path ):
641727 self ._parse_css_module_usage (
642- class_source , rel_path , file_set , used_css_module_classes , unresolved_css_module_classes
728+ class_source ,
729+ rel_path ,
730+ file_set ,
731+ used_css_module_classes ,
732+ unresolved_css_module_classes ,
733+ import_source = content ,
643734 )
644735
645736 # Parse components
@@ -884,21 +975,46 @@ def _parse_css_classes(self, content: str, rel_path: str, css_classes: dict[str,
884975 for cls , definition in _module_css_definitions (content , rel_path ):
885976 css_classes .setdefault (cls , []).append (definition )
886977 return
887- for i , line in enumerate (content .splitlines (), 1 ):
978+ spans , lines = _global_css_rule_spans (content )
979+ starts = [span [0 ] for span in spans ]
980+ offset = 0
981+ for i , line in enumerate (content .splitlines (keepends = True ), 1 ):
888982 for m in _CSS_CLASS_PATTERN .finditer (line ):
889983 identifier = m .group (1 )
890984 if "\\ " in identifier :
891985 # An unescaped colon following an escaped name begins a
892986 # pseudo-selector, not part of that class's decoded key.
893987 identifier = _CSS_IDENTIFIER_PATTERN .match (identifier ).group ()
894988 cls = _decode_css_identifier (identifier )
895- css_classes .setdefault (cls , []).append (_CssDefinition (rel_path , i , True ))
989+ index = bisect_right (starts , offset + m .start ()) - 1
990+ definition = _CssDefinition (rel_path , i , True , single_line_rule = False )
991+ if index >= 0 :
992+ header_start , opening , closing_line = spans [index ]
993+ if offset + m .start () < opening :
994+ definition = _CssDefinition (
995+ rel_path ,
996+ i ,
997+ True ,
998+ opening ,
999+ bisect_right (lines , header_start ) != bisect_right (lines , opening ),
1000+ single_line_rule = closing_line == i ,
1001+ )
1002+ css_classes .setdefault (cls , []).append (definition )
1003+ offset += len (line )
8961004
897- def _parse_classname_usage (self , content : str , used_css_classes : set [str | None ], * , jsx : bool = True ) -> None :
1005+ def _parse_classname_usage (
1006+ self ,
1007+ content : str ,
1008+ used_css_classes : set [str | None ],
1009+ * ,
1010+ jsx : bool = True ,
1011+ import_source : str | None = None ,
1012+ ) -> None :
8981013 """Read raw JSX attributes and JavaScript literals using their own grammar."""
8991014 properties , calls , code_starts = class_value_positions (content , jsx = jsx )
9001015 module_bindings = {
901- match .group (1 ) for match in _CSS_MODULE_IMPORT_PATTERN .finditer (content ) if match .start () in code_starts
1016+ binding
1017+ for binding , _ in _css_module_imports (content if import_source is None else import_source , code_starts )
9021018 }
9031019
9041020 def consume (raw_jsx : bool , value : str | None ) -> None :
@@ -942,6 +1058,8 @@ def _parse_css_module_usage(
9421058 file_set : set [str ],
9431059 used_css_module_classes : dict [Path , set [str | None ]],
9441060 unresolved_css_module_classes : set [str | None ],
1061+ * ,
1062+ import_source : str | None = None ,
9451063 ) -> None :
9461064 """Extract CSS-module class names consumed via object accessors.
9471065
@@ -963,14 +1081,14 @@ def _parse_css_module_usage(
9631081 # only module accessors are treated as class names.
9641082 module_bindings : dict [str , Path | None ] = {}
9651083 _ , _ , code_starts = class_value_positions (content , jsx = not rel_path .endswith (".ts" ))
966- for match in _CSS_MODULE_IMPORT_PATTERN . finditer ( content ):
967- if match . start () not in code_starts :
968- continue
969- stylesheet = self ._resolve_relative_module (rel_path , match . group ( 2 ) , file_set )
1084+ # Type erasure preserves offsets and decides whether an import is live.
1085+ # Its original clause retains quoted export names and their aliases.
1086+ for binding , specifier in _css_module_imports ( content if import_source is None else import_source , code_starts ):
1087+ stylesheet = self ._resolve_relative_module (rel_path , specifier , file_set )
9701088 if stylesheet is not None and stylesheet .endswith (".module.css" ):
971- module_bindings [match . group ( 1 ) ] = (self .project_dir / stylesheet ).resolve ()
1089+ module_bindings [binding ] = (self .project_dir / stylesheet ).resolve ()
9721090 else :
973- module_bindings [match . group ( 1 ) ] = None
1091+ module_bindings [binding ] = None
9741092 if not module_bindings :
9751093 return
9761094 recognized = set ()
0 commit comments