gabriel / muse public
test_code_language_config.py python
418 lines 16.5 KB
Raw
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2 fix: remove commit_exists filter from have anchors — server… Sonnet 4.6 patch 21 days ago
1 """Tests for language-config machinery in muse/plugins/code/ast_parser.py.
2
3 Coverage
4 --------
5 LangSpec.name field
6 - All 14 built-in specs (13 tree-sitter + bash) have a non-empty ``name``.
7 - Names are unique across the full _TS_LANG_SPECS list.
8 - ``name`` is lowercase and contains only alphanumeric chars / hyphens.
9
10 _BASH_SPEC
11 - Extensions include .sh, .bash, .zsh, .plugin.zsh.
12 - module_name is "tree_sitter_bash".
13 - query_str captures function_definition and variable_assignment.
14 - Included in _TS_LANG_SPECS.
15
16 CodeConfig
17 - Default instance has active_languages == None (all enabled).
18 - active_languages is frozenset when set.
19
20 load_code_config
21 - Missing file → CodeConfig() defaults (active_languages is None).
22 - Valid [code] languages list → frozenset of lowercased names.
23 - Names are lowercased and stripped.
24 - Absent [code] section → defaults.
25 - Malformed TOML → defaults (no crash).
26 - languages is not a list → defaults with warning.
27 - languages contains non-string entries → defaults with warning.
28 - Empty languages list → empty frozenset (zero grammars active).
29 - [code] present but [framework_detection] absent → CodeConfig parses OK.
30 - Both sections present → CodeConfig.active_languages comes only from [code].
31
32 configure_language_filter / reset_adapter_cache
33 - Setting filter to None → _LANGUAGE_FILTER is None.
34 - Setting filter to a frozenset → _LANGUAGE_FILTER matches.
35 - reset_adapter_cache() clears _ADAPTERS_CACHE and _SEM_EXT_CACHE.
36 - After reset + filter to {"python"} → only PythonAdapter in ADAPTERS.
37 - After reset + filter to {"python", "bash"} → Python + bash adapters present
38 (bash as FallbackAdapter when tree-sitter-bash not installed, real adapter
39 when installed).
40 - After reset + filter None → all built-in adapters present.
41 - Excluded spec extensions → adapter_for_path returns FallbackAdapter.
42 - Included spec extensions → adapter_for_path returns real adapter.
43 - Built-in adapters (markdown, html, toml) filtered by name correctly.
44
45 adapter_for_path routing
46 - .sh → bash adapter (or FallbackAdapter when not installed).
47 - .zsh → bash adapter (same package).
48 - .plugin.zsh → bash adapter.
49 - .bash → bash adapter.
50 - Unknown extension → FallbackAdapter regardless of filter.
51 """
52
53 from __future__ import annotations
54
55 import pathlib
56
57 import pytest
58
59 from muse.core.paths import muse_dir
60 from muse.plugins.code.ast_parser import (
61 CodeConfig,
62 FallbackAdapter,
63 HtmlAdapter,
64 MarkdownAdapter,
65 PythonAdapter,
66 TomlAdapter,
67 TreeSitterAdapter,
68 _BASH_SPEC,
69 _BUILTIN_ADAPTER_NAMES,
70 _TS_LANG_SPECS,
71 adapter_for_path,
72 configure_language_filter,
73 load_code_config,
74 reset_adapter_cache,
75 )
76
77
78 # ---------------------------------------------------------------------------
79 # Helpers
80 # ---------------------------------------------------------------------------
81
82
83 def _write_toml(tmp: pathlib.Path, content: str) -> pathlib.Path:
84 """Write *content* to ``.muse/code_config.toml`` under *tmp* and return root."""
85 dot_muse = muse_dir(tmp)
86 dot_muse.mkdir()
87 (dot_muse / "code_config.toml").write_text(content, encoding="utf-8")
88 return tmp
89
90
91 # ---------------------------------------------------------------------------
92 # LangSpec.name field
93 # ---------------------------------------------------------------------------
94
95
96 class TestLangSpecName:
97 def test_all_specs_have_name(self) -> None:
98 """Every entry in _TS_LANG_SPECS has a non-empty ``name`` field."""
99 for spec in _TS_LANG_SPECS:
100 assert spec["name"], f"spec for {spec['module_name']} has empty name"
101
102 def test_names_are_unique(self) -> None:
103 """No two specs share the same ``name``."""
104 names = [s["name"] for s in _TS_LANG_SPECS]
105 assert len(names) == len(set(names)), f"duplicate names: {names}"
106
107 def test_names_are_lowercase_alphanumeric(self) -> None:
108 """Names contain only lowercase letters, digits, or hyphens."""
109 for spec in _TS_LANG_SPECS:
110 name = spec["name"]
111 assert name == name.lower(), f"{name} is not lowercase"
112 assert all(c.isalnum() or c == "-" for c in name), (
113 f"{name!r} contains invalid characters"
114 )
115
116 def test_known_names_present(self) -> None:
117 """Spot-check a representative set of names are registered."""
118 names = {s["name"] for s in _TS_LANG_SPECS}
119 for expected in ("javascript", "typescript", "tsx", "go", "rust",
120 "java", "c", "cpp", "csharp", "ruby", "kotlin",
121 "swift", "css", "bash"):
122 assert expected in names, f"expected language {expected!r} missing from _TS_LANG_SPECS"
123
124
125 # ---------------------------------------------------------------------------
126 # _BASH_SPEC
127 # ---------------------------------------------------------------------------
128
129
130 class TestBashSpec:
131 def test_extensions_include_sh(self) -> None:
132 assert ".sh" in _BASH_SPEC["extensions"]
133
134 def test_extensions_include_bash(self) -> None:
135 assert ".bash" in _BASH_SPEC["extensions"]
136
137 def test_extensions_include_zsh(self) -> None:
138 assert ".zsh" in _BASH_SPEC["extensions"]
139
140 def test_extensions_include_plugin_zsh(self) -> None:
141 assert ".plugin.zsh" in _BASH_SPEC["extensions"]
142
143 def test_module_name(self) -> None:
144 assert _BASH_SPEC["module_name"] == "tree_sitter_bash"
145
146 def test_query_captures_function_definition(self) -> None:
147 assert "function_definition" in _BASH_SPEC["query_str"]
148
149 def test_query_captures_variable_assignment(self) -> None:
150 assert "variable_assignment" in _BASH_SPEC["query_str"]
151
152 def test_name_is_bash(self) -> None:
153 assert _BASH_SPEC["name"] == "bash"
154
155 def test_in_ts_lang_specs(self) -> None:
156 assert _BASH_SPEC in _TS_LANG_SPECS
157
158 def test_no_class_node_types(self) -> None:
159 assert _BASH_SPEC["class_node_types"] == frozenset()
160
161 def test_no_receiver_capture(self) -> None:
162 assert _BASH_SPEC["receiver_capture"] == ""
163
164 def test_no_async_child(self) -> None:
165 assert _BASH_SPEC["async_node_child"] == ""
166
167
168 # ---------------------------------------------------------------------------
169 # CodeConfig
170 # ---------------------------------------------------------------------------
171
172
173 class TestCodeConfig:
174 def test_default_active_languages_is_none(self) -> None:
175 """Default CodeConfig means all installed grammars are enabled."""
176 cfg = CodeConfig()
177 assert cfg.active_languages is None
178
179 def test_active_languages_is_frozenset_when_set(self) -> None:
180 cfg = CodeConfig(active_languages=frozenset({"python", "bash"}))
181 assert isinstance(cfg.active_languages, frozenset)
182 assert "python" in cfg.active_languages
183
184 def test_empty_frozenset_is_valid(self) -> None:
185 """Empty frozenset means zero grammars — valid config (user intent)."""
186 cfg = CodeConfig(active_languages=frozenset())
187 assert cfg.active_languages == frozenset()
188
189
190 # ---------------------------------------------------------------------------
191 # load_code_config
192 # ---------------------------------------------------------------------------
193
194
195 class TestLoadCodeConfig:
196 def test_missing_file_returns_defaults(self, tmp_path: pathlib.Path) -> None:
197 cfg = load_code_config(tmp_path)
198 assert cfg.active_languages is None
199
200 def test_valid_languages_list(self, tmp_path: pathlib.Path) -> None:
201 root = _write_toml(tmp_path, '[code]\nlanguages = ["python", "typescript", "bash"]\n')
202 cfg = load_code_config(root)
203 assert cfg.active_languages == frozenset({"python", "typescript", "bash"})
204
205 def test_names_are_lowercased(self, tmp_path: pathlib.Path) -> None:
206 root = _write_toml(tmp_path, '[code]\nlanguages = ["Python", "TypeScript"]\n')
207 cfg = load_code_config(root)
208 assert cfg.active_languages == frozenset({"python", "typescript"})
209
210 def test_names_are_stripped(self, tmp_path: pathlib.Path) -> None:
211 root = _write_toml(tmp_path, '[code]\nlanguages = [" python ", " bash"]\n')
212 cfg = load_code_config(root)
213 assert cfg.active_languages == frozenset({"python", "bash"})
214
215 def test_absent_code_section_returns_defaults(self, tmp_path: pathlib.Path) -> None:
216 root = _write_toml(tmp_path, '[framework_detection]\nauto_detect = true\n')
217 cfg = load_code_config(root)
218 assert cfg.active_languages is None
219
220 def test_malformed_toml_returns_defaults(self, tmp_path: pathlib.Path) -> None:
221 dot_muse = muse_dir(tmp_path)
222 dot_muse.mkdir()
223 (dot_muse / "code_config.toml").write_bytes(b"not = valid [[[toml")
224 cfg = load_code_config(tmp_path)
225 assert cfg.active_languages is None
226
227 def test_languages_not_a_list_returns_defaults(self, tmp_path: pathlib.Path) -> None:
228 root = _write_toml(tmp_path, '[code]\nlanguages = "python"\n')
229 cfg = load_code_config(root)
230 assert cfg.active_languages is None
231
232 def test_languages_with_non_string_entry_returns_defaults(
233 self, tmp_path: pathlib.Path
234 ) -> None:
235 root = _write_toml(tmp_path, '[code]\nlanguages = ["python", 42]\n')
236 cfg = load_code_config(root)
237 assert cfg.active_languages is None
238
239 def test_empty_languages_list(self, tmp_path: pathlib.Path) -> None:
240 """Empty list is valid — user explicitly wants zero grammars."""
241 root = _write_toml(tmp_path, '[code]\nlanguages = []\n')
242 cfg = load_code_config(root)
243 assert cfg.active_languages == frozenset()
244
245 def test_both_sections_present(self, tmp_path: pathlib.Path) -> None:
246 toml = (
247 '[code]\n'
248 'languages = ["python", "bash"]\n'
249 '\n'
250 '[framework_detection]\n'
251 'auto_detect = false\n'
252 )
253 root = _write_toml(tmp_path, toml)
254 cfg = load_code_config(root)
255 assert cfg.active_languages == frozenset({"python", "bash"})
256
257 def test_code_section_without_languages_key(self, tmp_path: pathlib.Path) -> None:
258 """[code] present but languages key absent → all languages enabled."""
259 root = _write_toml(tmp_path, '[code]\n# no languages key\n')
260 cfg = load_code_config(root)
261 assert cfg.active_languages is None
262
263 def test_code_section_not_a_table(self, tmp_path: pathlib.Path) -> None:
264 """If [code] is not a table (e.g. scalar), return defaults."""
265 root = _write_toml(tmp_path, 'code = "invalid"\n')
266 cfg = load_code_config(root)
267 assert cfg.active_languages is None
268
269
270 # ---------------------------------------------------------------------------
271 # configure_language_filter / reset_adapter_cache
272 # ---------------------------------------------------------------------------
273
274
275 class TestLanguageFilter:
276 """These tests manipulate module-level state — always reset before and after."""
277
278 def setup_method(self) -> None:
279 configure_language_filter(None)
280 reset_adapter_cache()
281
282 def teardown_method(self) -> None:
283 configure_language_filter(None)
284 reset_adapter_cache()
285
286 def test_default_filter_is_none(self) -> None:
287 from muse.plugins.code import ast_parser
288 assert ast_parser._LANGUAGE_FILTER is None
289
290 def test_set_filter_stored(self) -> None:
291 from muse.plugins.code import ast_parser
292 configure_language_filter(frozenset({"python"}))
293 assert ast_parser._LANGUAGE_FILTER == frozenset({"python"})
294
295 def test_reset_to_none(self) -> None:
296 from muse.plugins.code import ast_parser
297 configure_language_filter(frozenset({"python"}))
298 configure_language_filter(None)
299 assert ast_parser._LANGUAGE_FILTER is None
300
301 def test_reset_cache_clears_adapters(self) -> None:
302 from muse.plugins.code import ast_parser
303 # Build cache
304 _ = adapter_for_path("test.py")
305 assert ast_parser._ADAPTERS_CACHE is not None
306 reset_adapter_cache()
307 assert ast_parser._ADAPTERS_CACHE is None
308 assert ast_parser._SEM_EXT_CACHE is None
309
310 def test_filter_python_only_includes_python_adapter(self) -> None:
311 configure_language_filter(frozenset({"python"}))
312 # adapter_for_path triggers cache build
313 adapter = adapter_for_path("script.py")
314 assert isinstance(adapter, PythonAdapter)
315
316 def test_filter_python_only_excludes_js(self) -> None:
317 configure_language_filter(frozenset({"python"}))
318 adapter = adapter_for_path("app.js")
319 assert isinstance(adapter, FallbackAdapter)
320
321 def test_filter_none_includes_python(self) -> None:
322 configure_language_filter(None)
323 adapter = adapter_for_path("script.py")
324 assert isinstance(adapter, PythonAdapter)
325
326 def test_filter_markdown_by_name(self) -> None:
327 configure_language_filter(frozenset({"markdown"}))
328 adapter = adapter_for_path("README.md")
329 assert isinstance(adapter, MarkdownAdapter)
330
331 def test_filter_html_by_name(self) -> None:
332 configure_language_filter(frozenset({"html"}))
333 adapter = adapter_for_path("index.html")
334 assert isinstance(adapter, HtmlAdapter)
335
336 def test_filter_toml_by_name(self) -> None:
337 configure_language_filter(frozenset({"toml"}))
338 adapter = adapter_for_path("pyproject.toml")
339 assert isinstance(adapter, TomlAdapter)
340
341 def test_filter_excludes_markdown_when_not_listed(self) -> None:
342 configure_language_filter(frozenset({"python"}))
343 adapter = adapter_for_path("README.md")
344 assert isinstance(adapter, FallbackAdapter)
345
346 def test_empty_filter_excludes_everything(self) -> None:
347 configure_language_filter(frozenset())
348 for path in ("script.py", "app.js", "README.md", "index.html"):
349 assert isinstance(adapter_for_path(path), FallbackAdapter), (
350 f"expected FallbackAdapter for {path} with empty filter"
351 )
352
353 def test_builtin_adapter_names_constant(self) -> None:
354 assert "python" in _BUILTIN_ADAPTER_NAMES
355 assert "markdown" in _BUILTIN_ADAPTER_NAMES
356 assert "html" in _BUILTIN_ADAPTER_NAMES
357 assert "toml" in _BUILTIN_ADAPTER_NAMES
358
359
360 # ---------------------------------------------------------------------------
361 # adapter_for_path routing — shell extensions
362 # ---------------------------------------------------------------------------
363
364
365 class TestShellAdapterRouting:
366 """Route shell extensions to the bash adapter (or FallbackAdapter if not installed)."""
367
368 def setup_method(self) -> None:
369 configure_language_filter(None)
370 reset_adapter_cache()
371
372 def teardown_method(self) -> None:
373 configure_language_filter(None)
374 reset_adapter_cache()
375
376 @pytest.mark.parametrize("path", [
377 "install.sh",
378 "bootstrap.bash",
379 "muse.plugin.zsh",
380 "config.zsh",
381 "src/helpers.sh",
382 ])
383 def test_shell_path_does_not_route_to_python(self, path: str) -> None:
384 """Shell files must never be parsed by PythonAdapter."""
385 adapter = adapter_for_path(path)
386 assert not isinstance(adapter, PythonAdapter), (
387 f"{path} routed to PythonAdapter"
388 )
389
390 @pytest.mark.parametrize("path", [
391 "install.sh",
392 "bootstrap.bash",
393 "muse.plugin.zsh",
394 "config.zsh",
395 ])
396 def test_shell_extension_routes_to_bash_or_fallback(self, path: str) -> None:
397 """Shell paths route to BashAdapter (tree-sitter-bash installed) or FallbackAdapter."""
398 adapter = adapter_for_path(path)
399 # Accept both — test is grammar-package-agnostic.
400 assert isinstance(adapter, (TreeSitterAdapter, FallbackAdapter)), (
401 f"{path} routed to unexpected adapter type {type(adapter).__name__}"
402 )
403
404 def test_unknown_extension_is_always_fallback(self) -> None:
405 adapter = adapter_for_path("binary.exe")
406 assert isinstance(adapter, FallbackAdapter)
407
408 def test_filter_bash_routes_sh_to_bash_adapter_if_installed(self) -> None:
409 """When filter includes 'bash', .sh files get the bash adapter (if grammar installed)."""
410 configure_language_filter(frozenset({"bash"}))
411 adapter = adapter_for_path("install.sh")
412 assert isinstance(adapter, (TreeSitterAdapter, FallbackAdapter))
413
414 def test_filter_without_bash_routes_sh_to_fallback(self) -> None:
415 """When filter excludes 'bash', .sh files fall through to FallbackAdapter."""
416 configure_language_filter(frozenset({"python", "typescript"}))
417 adapter = adapter_for_path("install.sh")
418 assert isinstance(adapter, FallbackAdapter)
File History 4 commits
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2 fix: remove commit_exists filter from have anchors — server… Sonnet 4.6 patch 21 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 22 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 28 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 29 days ago