@@ -293,6 +293,74 @@ pub fn last_dotted_segment(dotted_name: &str) -> &str {
293293 dotted_name. rsplit ( '.' ) . next ( ) . unwrap_or ( dotted_name)
294294}
295295
296+ /// Проверяет, похож ли строковый литерал на точечную ссылку Python.
297+ ///
298+ /// Django и его экосистема повсеместно ссылаются на код строками:
299+ /// `handler404 = 'app.views.handler'`, `'EXCEPTION_HANDLER':
300+ /// 'api.exception_handler.custom_handler'`, динамические импорты вида
301+ /// `'dms.schema_filters.reviews_endpoints_filter'`. Такие строки невозможно
302+ /// отличить от обычного текста без эвристики, поэтому ссылкой считается
303+ /// строка не короче двух сегментов, каждый из которых — корректный
304+ /// идентификатор Python. Это отсекает версии (`1.2.3`), пути файлов
305+ /// и человекочитаемый текст, оставляя пути импорта и обращения к атрибутам.
306+ ///
307+ /// :param literal_value: Содержимое строкового литерала.
308+ /// :return: Признак строки, похожей на точечную ссылку на код.
309+ pub fn looks_like_dotted_reference ( literal_value : & str ) -> bool {
310+ let mut segment_count = 0usize ;
311+ for segment in literal_value. split ( '.' ) {
312+ if !is_python_identifier ( segment) {
313+ return false ;
314+ }
315+ segment_count += 1 ;
316+ }
317+ segment_count >= 2
318+ }
319+
320+ /// Проверяет, является ли сегмент корректным идентификатором Python.
321+ ///
322+ /// :param segment: Проверяемый сегмент точечного имени.
323+ /// :return: Признак корректного идентификатора Python.
324+ fn is_python_identifier ( segment : & str ) -> bool {
325+ let mut characters = segment. chars ( ) ;
326+ match characters. next ( ) {
327+ // Python 3 допускает не-ASCII идентификаторы (`обработчик`),
328+ // поэтому используются Unicode-категории, а не ASCII-проверки.
329+ Some ( first_character) if first_character == '_' || first_character. is_alphabetic ( ) => { }
330+ _ => return false ,
331+ }
332+ characters. all ( |character| character == '_' || character. is_alphanumeric ( ) )
333+ }
334+
335+ /// Проверяет принадлежность файла к тестам по соглашениям Pytest и Django.
336+ ///
337+ /// Тестами считаются файлы внутри пакета `tests`, а также файлы
338+ /// `test_*.py`, `*_test.py`, `tests.py` и `conftest.py`. Директория с
339+ /// именем `test` в единственном числе не считается тестовой: это слишком
340+ /// частое имя продуктового пакета, и пометка скрыла бы реальный мертвый
341+ /// код. Тестовые файлы анализируются только как источник ссылок: код,
342+ /// используемый исключительно в тестах, не считается мертвым, а сами
343+ /// тесты не попадают в отчет.
344+ ///
345+ /// :param file_path: Путь к анализируемому файлу.
346+ /// :return: Признак тестового файла.
347+ pub fn is_test_file_path ( file_path : & Path ) -> bool {
348+ let inside_tests_package = file_path
349+ . components ( )
350+ . filter_map ( |component| component. as_os_str ( ) . to_str ( ) )
351+ . any ( |component| component == "tests" ) ;
352+ if inside_tests_package {
353+ return true ;
354+ }
355+ let Some ( file_name) = file_path. file_name ( ) . and_then ( |name| name. to_str ( ) ) else {
356+ return false ;
357+ } ;
358+ file_name == "tests.py"
359+ || file_name == "conftest.py"
360+ || ( file_name. starts_with ( "test_" ) && file_name. ends_with ( ".py" ) )
361+ || file_name. ends_with ( "_test.py" )
362+ }
363+
296364#[ cfg( test) ]
297365mod tests {
298366 use super :: * ;
@@ -386,4 +454,38 @@ mod tests {
386454 ) ) ) ;
387455 assert ! ( !is_management_command_path( Path :: new( "shop/views.py" ) ) ) ;
388456 }
457+
458+ #[ test]
459+ fn dotted_references_are_recognized_in_strings ( ) {
460+ assert ! ( looks_like_dotted_reference(
461+ "mis_service.api.exception_handler.custom_exception_handler"
462+ ) ) ;
463+ assert ! ( looks_like_dotted_reference( "app.views.handler" ) ) ;
464+ assert ! ( looks_like_dotted_reference( "_private.module._name" ) ) ;
465+ // Не-ASCII идентификаторы допустимы в Python 3.
466+ assert ! ( looks_like_dotted_reference( "проект.views.обработчик" ) ) ;
467+ // Одного сегмента недостаточно: слишком много ложных совпадений.
468+ assert ! ( !looks_like_dotted_reference( "custom_exception_handler" ) ) ;
469+ // Версии и пути файлов не являются ссылками на код.
470+ assert ! ( !looks_like_dotted_reference( "1.2.3" ) ) ;
471+ assert ! ( !looks_like_dotted_reference( "app.views.1handler" ) ) ;
472+ assert ! ( !looks_like_dotted_reference( "product-list" ) ) ;
473+ assert ! ( !looks_like_dotted_reference( "module..name" ) ) ;
474+ assert ! ( !looks_like_dotted_reference( "some text.with spaces" ) ) ;
475+ assert ! ( !looks_like_dotted_reference( "" ) ) ;
476+ }
477+
478+ #[ test]
479+ fn test_files_are_recognized ( ) {
480+ assert ! ( is_test_file_path( Path :: new( "shop/tests/test_views.py" ) ) ) ;
481+ assert ! ( is_test_file_path( Path :: new( "shop/tests.py" ) ) ) ;
482+ assert ! ( is_test_file_path( Path :: new( "conftest.py" ) ) ) ;
483+ assert ! ( is_test_file_path( Path :: new( "shop/test_models.py" ) ) ) ;
484+ assert ! ( is_test_file_path( Path :: new( "shop/models_test.py" ) ) ) ;
485+ assert ! ( !is_test_file_path( Path :: new( "shop/models.py" ) ) ) ;
486+ assert ! ( !is_test_file_path( Path :: new( "shop/latest.py" ) ) ) ;
487+ // Каталог `test` в единственном числе — частое имя продуктового
488+ // пакета, поэтому тестовым не считается.
489+ assert ! ( !is_test_file_path( Path :: new( "app/test/factories.py" ) ) ) ;
490+ }
389491}
0 commit comments