diff --git a/CHANGELOG.md b/CHANGELOG.md index 387e9146..d6e516d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,10 @@ Status of the `main` branch. Changes prior to the next official version change w (which does not correctly unpack structured output) #1042 - Fix: Project-specific filtering of files for source files ignored the language backend. The check is really only possible for LSP. - - Fix: File system permission errors during gitignore scanning were not caught #1624 + - Fix: File system permission errors during gitignore scanning were not caught #1624 + - During project creation, language composition percentages are now computed relative to the total number + of recognised source files instead of all files, i.e. unrecognised files are ignored in the percentage + computation. * CLI: - Fix `--project-from-cwd` hijacking git worktrees nested under a Serena project. `find_project_root` diff --git a/src/serena/util/inspection.py b/src/serena/util/inspection.py index a0e3e96a..440d7c5b 100644 --- a/src/serena/util/inspection.py +++ b/src/serena/util/inspection.py @@ -31,36 +31,40 @@ def determine_programming_language_composition(repo_path: str) -> dict[Language, """ Determine the programming language composition of a repository. - :param repo_path: Path to the repository to analyze + Percentages are computed relative to the number of files that match at least + one supported language, not the total file count. This prevents files that + belong to no supported language (images, plain text, licenses, lock files, etc.) + from diluting language percentages in repositories where such files dominate. - :return: Dictionary mapping languages to percentages of files matching each language + :param repo_path: Path to the repository to analyze + :return: Dictionary mapping languages to percentages of recognised source files + matching each language (denominator = files matched by at least one language) """ all_files = find_all_non_ignored_files(repo_path) if not all_files: return {} - # Count files for each language + # collect all language matchers once + all_languages = list(Language.iter_all(include_experimental=False)) + matchers = {lang: lang.get_source_fn_matcher() for lang in all_languages} + + # count files per language in a single pass over the files language_counts: dict[Language, int] = {} - total_files = len(all_files) - - for language in Language.iter_all(include_experimental=False): - matcher = language.get_source_fn_matcher() - count = 0 - - for file_path in all_files: - # Use just the filename for matching, not the full path - filename = os.path.basename(file_path) + recognised_files = 0 + for file_path in all_files: + # Use just the filename for matching, not the full path + filename = os.path.basename(file_path) + matched_any = False + for lang, matcher in matchers.items(): if matcher.is_relevant_filename(filename): - count += 1 + language_counts[lang] = language_counts.get(lang, 0) + 1 + matched_any = True + if matched_any: + recognised_files += 1 - if count > 0: - language_counts[language] = count + if recognised_files == 0: + return {} - # Convert counts to percentages - language_percentages: dict[Language, float] = {} - for language, count in language_counts.items(): - percentage = (count / total_files) * 100 - language_percentages[language] = round(percentage, 2) - - return language_percentages + # convert to percentages relative to recognised source files only + return {lang: round(count / recognised_files * 100, 2) for lang, count in language_counts.items()} diff --git a/test/serena/util/test_inspection.py b/test/serena/util/test_inspection.py new file mode 100644 index 00000000..90378aac --- /dev/null +++ b/test/serena/util/test_inspection.py @@ -0,0 +1,45 @@ +"""Unit tests for :func:`serena.util.inspection.determine_programming_language_composition`.""" + +from pathlib import Path + +from serena.util.inspection import determine_programming_language_composition +from solidlsp.ls_config import Language + + +def _touch(directory: Path, *names: str) -> None: + for name in names: + (directory / name).write_text("content", encoding="utf-8") + + +class TestDetermineProgrammingLanguageComposition: + def test_single_language_repo(self, tmp_path: Path) -> None: + _touch(tmp_path, "a.py", "b.py", "c.py") + composition = determine_programming_language_composition(str(tmp_path)) + assert composition[Language.PYTHON] == 100.0 + + def test_unrecognised_files_do_not_dilute_percentages(self, tmp_path: Path) -> None: + # 2 source files vs many files that belong to no supported language + _touch(tmp_path, "main.py", "util.py") + _touch(tmp_path, *[f"note_{i}.txt" for i in range(20)]) + _touch(tmp_path, "logo.png", "LICENSE", "data.csv") + + composition = determine_programming_language_composition(str(tmp_path)) + + # previously: 2 / 25 files = 8% — now the denominator is recognised source files only + assert composition[Language.PYTHON] == 100.0 + + def test_mixed_language_percentages_relative_to_recognised_files(self, tmp_path: Path) -> None: + _touch(tmp_path, "a.py", "b.py", "c.py", "d.go") + _touch(tmp_path, *[f"asset_{i}.dat" for i in range(50)]) + + composition = determine_programming_language_composition(str(tmp_path)) + + assert composition[Language.PYTHON] == 75.0 + assert composition[Language.GO] == 25.0 + + def test_repo_without_recognised_files(self, tmp_path: Path) -> None: + _touch(tmp_path, "readme.txt", "logo.png") + assert determine_programming_language_composition(str(tmp_path)) == {} + + def test_empty_repo(self, tmp_path: Path) -> None: + assert determine_programming_language_composition(str(tmp_path)) == {}