"""Tests for language-config machinery in muse/plugins/code/ast_parser.py. Coverage -------- LangSpec.name field - All 14 built-in specs (13 tree-sitter + bash) have a non-empty ``name``. - Names are unique across the full _TS_LANG_SPECS list. - ``name`` is lowercase and contains only alphanumeric chars / hyphens. _BASH_SPEC - Extensions include .sh, .bash, .zsh, .plugin.zsh. - module_name is "tree_sitter_bash". - query_str captures function_definition and variable_assignment. - Included in _TS_LANG_SPECS. CodeConfig - Default instance has active_languages == None (all enabled). - active_languages is frozenset when set. load_code_config - Missing file → CodeConfig() defaults (active_languages is None). - Valid [code] languages list → frozenset of lowercased names. - Names are lowercased and stripped. - Absent [code] section → defaults. - Malformed TOML → defaults (no crash). - languages is not a list → defaults with warning. - languages contains non-string entries → defaults with warning. - Empty languages list → empty frozenset (zero grammars active). - [code] present but [framework_detection] absent → CodeConfig parses OK. - Both sections present → CodeConfig.active_languages comes only from [code]. configure_language_filter / reset_adapter_cache - Setting filter to None → _LANGUAGE_FILTER is None. - Setting filter to a frozenset → _LANGUAGE_FILTER matches. - reset_adapter_cache() clears _ADAPTERS_CACHE and _SEM_EXT_CACHE. - After reset + filter to {"python"} → only PythonAdapter in ADAPTERS. - After reset + filter to {"python", "bash"} → Python + bash adapters present (bash as FallbackAdapter when tree-sitter-bash not installed, real adapter when installed). - After reset + filter None → all built-in adapters present. - Excluded spec extensions → adapter_for_path returns FallbackAdapter. - Included spec extensions → adapter_for_path returns real adapter. - Built-in adapters (markdown, html, toml) filtered by name correctly. adapter_for_path routing - .sh → bash adapter (or FallbackAdapter when not installed). - .zsh → bash adapter (same package). - .plugin.zsh → bash adapter. - .bash → bash adapter. - Unknown extension → FallbackAdapter regardless of filter. """ from __future__ import annotations import pathlib import pytest from muse.core.paths import muse_dir from muse.plugins.code.ast_parser import ( CodeConfig, FallbackAdapter, HtmlAdapter, MarkdownAdapter, PythonAdapter, TomlAdapter, TreeSitterAdapter, _BASH_SPEC, _BUILTIN_ADAPTER_NAMES, _TS_LANG_SPECS, adapter_for_path, configure_language_filter, load_code_config, reset_adapter_cache, ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _write_toml(tmp: pathlib.Path, content: str) -> pathlib.Path: """Write *content* to ``.muse/code_config.toml`` under *tmp* and return root.""" dot_muse = muse_dir(tmp) dot_muse.mkdir() (dot_muse / "code_config.toml").write_text(content, encoding="utf-8") return tmp # --------------------------------------------------------------------------- # LangSpec.name field # --------------------------------------------------------------------------- class TestLangSpecName: def test_all_specs_have_name(self) -> None: """Every entry in _TS_LANG_SPECS has a non-empty ``name`` field.""" for spec in _TS_LANG_SPECS: assert spec["name"], f"spec for {spec['module_name']} has empty name" def test_names_are_unique(self) -> None: """No two specs share the same ``name``.""" names = [s["name"] for s in _TS_LANG_SPECS] assert len(names) == len(set(names)), f"duplicate names: {names}" def test_names_are_lowercase_alphanumeric(self) -> None: """Names contain only lowercase letters, digits, or hyphens.""" for spec in _TS_LANG_SPECS: name = spec["name"] assert name == name.lower(), f"{name} is not lowercase" assert all(c.isalnum() or c == "-" for c in name), ( f"{name!r} contains invalid characters" ) def test_known_names_present(self) -> None: """Spot-check a representative set of names are registered.""" names = {s["name"] for s in _TS_LANG_SPECS} for expected in ("javascript", "typescript", "tsx", "go", "rust", "java", "c", "cpp", "csharp", "ruby", "kotlin", "swift", "css", "bash"): assert expected in names, f"expected language {expected!r} missing from _TS_LANG_SPECS" # --------------------------------------------------------------------------- # _BASH_SPEC # --------------------------------------------------------------------------- class TestBashSpec: def test_extensions_include_sh(self) -> None: assert ".sh" in _BASH_SPEC["extensions"] def test_extensions_include_bash(self) -> None: assert ".bash" in _BASH_SPEC["extensions"] def test_extensions_include_zsh(self) -> None: assert ".zsh" in _BASH_SPEC["extensions"] def test_extensions_include_plugin_zsh(self) -> None: assert ".plugin.zsh" in _BASH_SPEC["extensions"] def test_module_name(self) -> None: assert _BASH_SPEC["module_name"] == "tree_sitter_bash" def test_query_captures_function_definition(self) -> None: assert "function_definition" in _BASH_SPEC["query_str"] def test_query_captures_variable_assignment(self) -> None: assert "variable_assignment" in _BASH_SPEC["query_str"] def test_name_is_bash(self) -> None: assert _BASH_SPEC["name"] == "bash" def test_in_ts_lang_specs(self) -> None: assert _BASH_SPEC in _TS_LANG_SPECS def test_no_class_node_types(self) -> None: assert _BASH_SPEC["class_node_types"] == frozenset() def test_no_receiver_capture(self) -> None: assert _BASH_SPEC["receiver_capture"] == "" def test_no_async_child(self) -> None: assert _BASH_SPEC["async_node_child"] == "" # --------------------------------------------------------------------------- # CodeConfig # --------------------------------------------------------------------------- class TestCodeConfig: def test_default_active_languages_is_none(self) -> None: """Default CodeConfig means all installed grammars are enabled.""" cfg = CodeConfig() assert cfg.active_languages is None def test_active_languages_is_frozenset_when_set(self) -> None: cfg = CodeConfig(active_languages=frozenset({"python", "bash"})) assert isinstance(cfg.active_languages, frozenset) assert "python" in cfg.active_languages def test_empty_frozenset_is_valid(self) -> None: """Empty frozenset means zero grammars — valid config (user intent).""" cfg = CodeConfig(active_languages=frozenset()) assert cfg.active_languages == frozenset() # --------------------------------------------------------------------------- # load_code_config # --------------------------------------------------------------------------- class TestLoadCodeConfig: def test_missing_file_returns_defaults(self, tmp_path: pathlib.Path) -> None: cfg = load_code_config(tmp_path) assert cfg.active_languages is None def test_valid_languages_list(self, tmp_path: pathlib.Path) -> None: root = _write_toml(tmp_path, '[code]\nlanguages = ["python", "typescript", "bash"]\n') cfg = load_code_config(root) assert cfg.active_languages == frozenset({"python", "typescript", "bash"}) def test_names_are_lowercased(self, tmp_path: pathlib.Path) -> None: root = _write_toml(tmp_path, '[code]\nlanguages = ["Python", "TypeScript"]\n') cfg = load_code_config(root) assert cfg.active_languages == frozenset({"python", "typescript"}) def test_names_are_stripped(self, tmp_path: pathlib.Path) -> None: root = _write_toml(tmp_path, '[code]\nlanguages = [" python ", " bash"]\n') cfg = load_code_config(root) assert cfg.active_languages == frozenset({"python", "bash"}) def test_absent_code_section_returns_defaults(self, tmp_path: pathlib.Path) -> None: root = _write_toml(tmp_path, '[framework_detection]\nauto_detect = true\n') cfg = load_code_config(root) assert cfg.active_languages is None def test_malformed_toml_returns_defaults(self, tmp_path: pathlib.Path) -> None: dot_muse = muse_dir(tmp_path) dot_muse.mkdir() (dot_muse / "code_config.toml").write_bytes(b"not = valid [[[toml") cfg = load_code_config(tmp_path) assert cfg.active_languages is None def test_languages_not_a_list_returns_defaults(self, tmp_path: pathlib.Path) -> None: root = _write_toml(tmp_path, '[code]\nlanguages = "python"\n') cfg = load_code_config(root) assert cfg.active_languages is None def test_languages_with_non_string_entry_returns_defaults( self, tmp_path: pathlib.Path ) -> None: root = _write_toml(tmp_path, '[code]\nlanguages = ["python", 42]\n') cfg = load_code_config(root) assert cfg.active_languages is None def test_empty_languages_list(self, tmp_path: pathlib.Path) -> None: """Empty list is valid — user explicitly wants zero grammars.""" root = _write_toml(tmp_path, '[code]\nlanguages = []\n') cfg = load_code_config(root) assert cfg.active_languages == frozenset() def test_both_sections_present(self, tmp_path: pathlib.Path) -> None: toml = ( '[code]\n' 'languages = ["python", "bash"]\n' '\n' '[framework_detection]\n' 'auto_detect = false\n' ) root = _write_toml(tmp_path, toml) cfg = load_code_config(root) assert cfg.active_languages == frozenset({"python", "bash"}) def test_code_section_without_languages_key(self, tmp_path: pathlib.Path) -> None: """[code] present but languages key absent → all languages enabled.""" root = _write_toml(tmp_path, '[code]\n# no languages key\n') cfg = load_code_config(root) assert cfg.active_languages is None def test_code_section_not_a_table(self, tmp_path: pathlib.Path) -> None: """If [code] is not a table (e.g. scalar), return defaults.""" root = _write_toml(tmp_path, 'code = "invalid"\n') cfg = load_code_config(root) assert cfg.active_languages is None # --------------------------------------------------------------------------- # configure_language_filter / reset_adapter_cache # --------------------------------------------------------------------------- class TestLanguageFilter: """These tests manipulate module-level state — always reset before and after.""" def setup_method(self) -> None: configure_language_filter(None) reset_adapter_cache() def teardown_method(self) -> None: configure_language_filter(None) reset_adapter_cache() def test_default_filter_is_none(self) -> None: from muse.plugins.code import ast_parser assert ast_parser._LANGUAGE_FILTER is None def test_set_filter_stored(self) -> None: from muse.plugins.code import ast_parser configure_language_filter(frozenset({"python"})) assert ast_parser._LANGUAGE_FILTER == frozenset({"python"}) def test_reset_to_none(self) -> None: from muse.plugins.code import ast_parser configure_language_filter(frozenset({"python"})) configure_language_filter(None) assert ast_parser._LANGUAGE_FILTER is None def test_reset_cache_clears_adapters(self) -> None: from muse.plugins.code import ast_parser # Build cache _ = adapter_for_path("test.py") assert ast_parser._ADAPTERS_CACHE is not None reset_adapter_cache() assert ast_parser._ADAPTERS_CACHE is None assert ast_parser._SEM_EXT_CACHE is None def test_filter_python_only_includes_python_adapter(self) -> None: configure_language_filter(frozenset({"python"})) # adapter_for_path triggers cache build adapter = adapter_for_path("script.py") assert isinstance(adapter, PythonAdapter) def test_filter_python_only_excludes_js(self) -> None: configure_language_filter(frozenset({"python"})) adapter = adapter_for_path("app.js") assert isinstance(adapter, FallbackAdapter) def test_filter_none_includes_python(self) -> None: configure_language_filter(None) adapter = adapter_for_path("script.py") assert isinstance(adapter, PythonAdapter) def test_filter_markdown_by_name(self) -> None: configure_language_filter(frozenset({"markdown"})) adapter = adapter_for_path("README.md") assert isinstance(adapter, MarkdownAdapter) def test_filter_html_by_name(self) -> None: configure_language_filter(frozenset({"html"})) adapter = adapter_for_path("index.html") assert isinstance(adapter, HtmlAdapter) def test_filter_toml_by_name(self) -> None: configure_language_filter(frozenset({"toml"})) adapter = adapter_for_path("pyproject.toml") assert isinstance(adapter, TomlAdapter) def test_filter_excludes_markdown_when_not_listed(self) -> None: configure_language_filter(frozenset({"python"})) adapter = adapter_for_path("README.md") assert isinstance(adapter, FallbackAdapter) def test_empty_filter_excludes_everything(self) -> None: configure_language_filter(frozenset()) for path in ("script.py", "app.js", "README.md", "index.html"): assert isinstance(adapter_for_path(path), FallbackAdapter), ( f"expected FallbackAdapter for {path} with empty filter" ) def test_builtin_adapter_names_constant(self) -> None: assert "python" in _BUILTIN_ADAPTER_NAMES assert "markdown" in _BUILTIN_ADAPTER_NAMES assert "html" in _BUILTIN_ADAPTER_NAMES assert "toml" in _BUILTIN_ADAPTER_NAMES # --------------------------------------------------------------------------- # adapter_for_path routing — shell extensions # --------------------------------------------------------------------------- class TestShellAdapterRouting: """Route shell extensions to the bash adapter (or FallbackAdapter if not installed).""" def setup_method(self) -> None: configure_language_filter(None) reset_adapter_cache() def teardown_method(self) -> None: configure_language_filter(None) reset_adapter_cache() @pytest.mark.parametrize("path", [ "install.sh", "bootstrap.bash", "muse.plugin.zsh", "config.zsh", "src/helpers.sh", ]) def test_shell_path_does_not_route_to_python(self, path: str) -> None: """Shell files must never be parsed by PythonAdapter.""" adapter = adapter_for_path(path) assert not isinstance(adapter, PythonAdapter), ( f"{path} routed to PythonAdapter" ) @pytest.mark.parametrize("path", [ "install.sh", "bootstrap.bash", "muse.plugin.zsh", "config.zsh", ]) def test_shell_extension_routes_to_bash_or_fallback(self, path: str) -> None: """Shell paths route to BashAdapter (tree-sitter-bash installed) or FallbackAdapter.""" adapter = adapter_for_path(path) # Accept both — test is grammar-package-agnostic. assert isinstance(adapter, (TreeSitterAdapter, FallbackAdapter)), ( f"{path} routed to unexpected adapter type {type(adapter).__name__}" ) def test_unknown_extension_is_always_fallback(self) -> None: adapter = adapter_for_path("binary.exe") assert isinstance(adapter, FallbackAdapter) def test_filter_bash_routes_sh_to_bash_adapter_if_installed(self) -> None: """When filter includes 'bash', .sh files get the bash adapter (if grammar installed).""" configure_language_filter(frozenset({"bash"})) adapter = adapter_for_path("install.sh") assert isinstance(adapter, (TreeSitterAdapter, FallbackAdapter)) def test_filter_without_bash_routes_sh_to_fallback(self) -> None: """When filter excludes 'bash', .sh files fall through to FallbackAdapter.""" configure_language_filter(frozenset({"python", "typescript"})) adapter = adapter_for_path("install.sh") assert isinstance(adapter, FallbackAdapter)