diff --git a/docs/content/docs/code-compression.mdx b/docs/content/docs/code-compression.mdx index f407e96b1..2318c186d 100644 --- a/docs/content/docs/code-compression.mdx +++ b/docs/content/docs/code-compression.mdx @@ -18,7 +18,7 @@ Naive truncation breaks code. Cutting a function in half leaves invalid syntax t | Tier | Languages | Support Level | |---|---|---| | Tier 1 | Python, JavaScript, TypeScript | Full AST analysis | -| Tier 2 | Go, Rust, Java, C, C++ | Function body compression | +| Tier 2 | Go, Rust, Java, C, C++, C#, PHP | Function body compression | ## What Gets Preserved vs Compressed diff --git a/headroom/transforms/code_compressor.py b/headroom/transforms/code_compressor.py index e955bd74f..77ccc8ea8 100644 --- a/headroom/transforms/code_compressor.py +++ b/headroom/transforms/code_compressor.py @@ -167,7 +167,7 @@ def _get_parser(language: str) -> Any: except Exception as e: raise ValueError( f"Language '{language}' is not supported by tree-sitter. " - f"Supported: python, javascript, typescript, go, rust, java, c, cpp, csharp. " + f"Supported: python, javascript, typescript, go, rust, java, c, cpp, csharp, php. " f"Error: {e}" ) from e @@ -223,6 +223,7 @@ class CodeLanguage(Enum): CPP = "cpp" PERL = "perl" CSHARP = "csharp" + PHP = "php" UNKNOWN = "unknown" @@ -247,6 +248,10 @@ _LANGUAGE_ALIASES: dict[str, CodeLanguage] = { "cc": CodeLanguage.CPP, "hpp": CodeLanguage.CPP, "pl": CodeLanguage.PERL, + "phtml": CodeLanguage.PHP, + "php5": CodeLanguage.PHP, + "php7": CodeLanguage.PHP, + "php8": CodeLanguage.PHP, } @@ -461,6 +466,23 @@ _LANG_CONFIGS: dict[CodeLanguage, LangConfig] = { container_node_types=frozenset({"namespace_declaration"}), opaque_node_types=frozenset({"preproc_if"}), ), + CodeLanguage.PHP: LangConfig( + import_nodes=frozenset({"namespace_use_declaration"}), + function_nodes=frozenset({"function_definition", "method_declaration"}), + class_nodes=frozenset({"class_declaration", "interface_declaration", "trait_declaration"}), + type_nodes=frozenset({"enum_declaration"}), + body_node_types=frozenset({"compound_statement"}), + decorator_node=None, + comment_prefix="//", + uses_colon_after_signature=False, + # Statement-scoped `namespace App;` hoists to the top of the output + # (before the use declarations); the rarer block-scoped + # `namespace A { ... }` form takes the same path and is preserved + # verbatim — valid output, no compression inside the block. + package_node="namespace_definition", + detection_hints=("", "$this"), + class_body_node_types=frozenset({"declaration_list"}), + ), } @@ -666,6 +688,16 @@ _LANGUAGE_PREFILTER: dict[CodeLanguage, list[re.Pattern[str]]] = { ), re.compile(r"\bget;\s*set;", re.MULTILINE), ], + CodeLanguage.PHP: [ + re.compile(r"<\?php\b"), + re.compile(r"^\s*namespace\s+[\w\\]+\s*;", re.MULTILINE), + re.compile(r"^\s*use\s+[\w\\]+(\s+as\s+\w+)?\s*;", re.MULTILINE), + re.compile( + r"^\s*(public|private|protected|static|abstract|final)?\s*function\s+\w+\s*\(", + re.MULTILINE, + ), + re.compile(r"\$this->|->\w+\s*\(", re.MULTILINE), + ], } @@ -720,6 +752,13 @@ def detect_language(code: str) -> tuple[CodeLanguage, float]: if candidates[CodeLanguage.CPP] >= 2: candidates[CodeLanguage.C] = 0 + # Disambiguation: PHP's sigil variables ($x) overlap Perl's prefilter. + # An explicit ` 0: best_non_perl = max( diff --git a/headroom/transforms/content_detector.py b/headroom/transforms/content_detector.py index 48a969a8a..5e364fed6 100644 --- a/headroom/transforms/content_detector.py +++ b/headroom/transforms/content_detector.py @@ -111,6 +111,13 @@ _CODE_PATTERNS = { ), re.compile(r"^.*\b(get|set|init);"), # auto-property accessors ], + "php": [ + re.compile(r"<\?php\b"), + re.compile(r"^\s*namespace\s+[\w\\]+\s*;"), + re.compile(r"^\s*use\s+[\w\\]+(\s+as\s+\w+)?\s*;"), + re.compile(r"^\s*(public|private|protected|static|abstract|final)?\s*function\s+\w+\s*\("), + re.compile(r"\$this->"), + ], } # Structured-config (YAML/TOML/INI) patterns. TOML and INI share the diff --git a/tests/test_code_compressor_language_alias.py b/tests/test_code_compressor_language_alias.py index aaf327799..dc2031c6b 100644 --- a/tests/test_code_compressor_language_alias.py +++ b/tests/test_code_compressor_language_alias.py @@ -32,6 +32,7 @@ from headroom.transforms.code_compressor import CodeLanguage, coerce_language ("golang", CodeLanguage.GO), ("rs", CodeLanguage.RUST), ("c++", CodeLanguage.CPP), + ("phtml", CodeLanguage.PHP), ], ) def test_coerce_language_maps_common_aliases(alias, expected): @@ -40,7 +41,7 @@ def test_coerce_language_maps_common_aliases(alias, expected): @pytest.mark.parametrize( "canonical", - ["python", "javascript", "typescript", "go", "rust", "java", "c", "cpp", "perl"], + ["python", "javascript", "typescript", "go", "rust", "java", "c", "cpp", "perl", "php"], ) def test_coerce_language_accepts_canonical_values(canonical): assert coerce_language(canonical) == CodeLanguage(canonical) diff --git a/tests/test_proxy_savings_history.py b/tests/test_proxy_savings_history.py index aec947ac3..d40f0716d 100644 --- a/tests/test_proxy_savings_history.py +++ b/tests/test_proxy_savings_history.py @@ -1227,9 +1227,11 @@ def test_savings_tracker_batches_saves_and_matches_immediate(tmp_path): batched.record_request(**events[2]) # buffered again batched.flush() # tail persisted - assert json.loads(batched_path.read_text(encoding="utf-8")) == json.loads( - immediate_path.read_text(encoding="utf-8") - ) + batched_payload = json.loads(batched_path.read_text(encoding="utf-8")) + immediate_payload = json.loads(immediate_path.read_text(encoding="utf-8")) + for payload in (batched_payload, immediate_payload): + payload["lifetime_metrics"]["persistence"].pop("last_saved_at", None) + assert batched_payload == immediate_payload def test_failed_save_retries_on_next_record_not_after_full_window(tmp_path, monkeypatch): diff --git a/tests/test_transforms/test_code_compressor.py b/tests/test_transforms/test_code_compressor.py index 33c71fac3..c689f4976 100644 --- a/tests/test_transforms/test_code_compressor.py +++ b/tests/test_transforms/test_code_compressor.py @@ -21,6 +21,7 @@ from headroom.transforms.code_compressor import ( CodeCompressorConfig, CodeLanguage, DocstringMode, + coerce_language, detect_language, is_tree_sitter_available, is_tree_sitter_loaded, @@ -2096,3 +2097,116 @@ class TestCSharpSupport: lang, confidence = detect_language(code) assert lang == CodeLanguage.CSHARP assert confidence > 0.0 + + +@pytest.mark.skipif(not TREE_SITTER_INSTALLED, reason="tree-sitter grammar pack not installed") +class TestPhpSupport: + """PHP (``php`` grammar) parity with C#: signatures preserved verbatim, + function/method bodies compressed, ``getName()));\n" + " $tags = [];\n" + " foreach ($u->getTags() as $tag) {\n" + " $tags[] = $tag->normalize();\n" + " }\n" + " $this->logger->info($name);\n" + " return true;\n" + " }\n" + "}\n" + ) + result = self._compressor().compress(code, language="php") + + assert result.language == CodeLanguage.PHP + assert result.syntax_valid is True + assert result.compression_ratio < 1.0 + # signature + class header preserved verbatim + assert "final class UserService" in result.compressed + assert "public function process(User $u): bool" in result.compressed + # method body actually compressed + assert "lines omitted" in result.compressed + assert "$tag->normalize()" not in result.compressed + # the class is emitted exactly once + assert result.compressed.count("class UserService") == 1 + + def test_php_tag_and_namespace_precede_uses_and_types(self): + """`` 0.0 + + def test_phtml_alias_coerces_to_php(self): + assert coerce_language("phtml") == CodeLanguage.PHP + assert coerce_language("php8") == CodeLanguage.PHP + + def test_malformed_php_passes_through_unchanged(self): + code = "