diff --git a/examples/Sample.cs b/examples/Sample.cs new file mode 100644 index 0000000..3d9efee --- /dev/null +++ b/examples/Sample.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; + +#nullable enable +namespace Demo +{ + public class Sample + { + private readonly Dictionary> _index = new(); + private const string Path = @"C:\dir\file with spaces.txt"; + + public static void Main(string[] args) + { + int i = 10; + var name = "world"; + string msg = $"hello {name}, i = {i}"; + string? maybe = null; + var len = maybe?.Length ?? 0; + int @class = 3; + var range = new[] { 1, 2, 3, 4 }[1..^1]; + Func inc = x => x + 1; + int neg = - -i; + if (i is not 0 && len >= 0) + { + Console.WriteLine(msg + @"verbatim ""with quotes"""); + } + var raw = """ + raw text + """; + } + } +} diff --git a/examples/Sample.java b/examples/Sample.java new file mode 100644 index 0000000..8b7b03a --- /dev/null +++ b/examples/Sample.java @@ -0,0 +1,36 @@ +package demo; + +import java.util.*; +import java.util.function.Function; + + +@SuppressWarnings("unchecked") +public class Sample> { + private final Map> index = new HashMap<>(); + private static final String BLOCK = """ + line 1 + line 2 with spaces + """; + + public static void main(String[] args) { + int i = 10; + while (i-- > 0) { + if (i % 2 == 0 && i != 4) continue; + System.out.println("i = " + i + " 'aspas' \"escaped\""); + } + Function inc = x -> x + 1; + int neg = - -i; + long big = 1_000_000L; + double d = 1e-3 + .5; + char c = '\''; + List> nested = new ArrayList<>(); + Runnable r = Sample::helper; + r.run(); + } + + static void helper() { + int a = 1, b = 2; + a = a + +b; + b = a - --b; + } +} diff --git a/examples/sample.c b/examples/sample.c new file mode 100644 index 0000000..f088377 --- /dev/null +++ b/examples/sample.c @@ -0,0 +1,7 @@ +#include + +int main(void) { + int value = 2 + 3; + printf("%d\n", value); + return 0; +} diff --git a/examples/sample.cpp b/examples/sample.cpp new file mode 100644 index 0000000..bb3e372 --- /dev/null +++ b/examples/sample.cpp @@ -0,0 +1,42 @@ +#include +#include +#include + +#define SQUARE(x) \ + ((x) * (x)) + +using namespace std; + +template +struct Box { + T value; + Box(T v) : value(v) {} + T get() const { return value; } +}; + +static const char* RAW = R"txt( + raw text with "quotes" and \ backslashes +)txt"; + +int compute(int a, int b) { + int r = a - -b; + r = r + +a; + r -= b; + vector > grid(3, vector(3, 0)); + vector> grid2(2, vector(2, 1)); + for (int i = 0; i < 3; ++i) + for (int j = 0; j < 3; ++j) + grid[i][j] = i * j; + int big = 1'000'000; + double d = 1.5e+3; + unsigned x = 0xE + 1; + return r + grid[1][2] + big + (int)d + (int)x + SQUARE(3); +} + +int main() { + Box b(compute(3, 4)); + printf("value = %d (%s)\n", b.get(), "text with spaces"); + int k = 10; + while (k --> 0) { if (k % 3) continue; } + return 0; +} diff --git a/llm_compact/__init__.py b/llm_compact/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/llm_compact/__main__.py b/llm_compact/__main__.py new file mode 100644 index 0000000..eb53e2f --- /dev/null +++ b/llm_compact/__main__.py @@ -0,0 +1,3 @@ +from .cli import main + +raise SystemExit(main()) diff --git a/llm_compact/cli.py b/llm_compact/cli.py new file mode 100644 index 0000000..aa985b9 --- /dev/null +++ b/llm_compact/cli.py @@ -0,0 +1,68 @@ +import argparse +import sys +from pathlib import Path + +from .core import SUPPORTED_LANGUAGES, CompactOptions, compact +from .tokens import DEFAULT_ENCODINGS, compare_tokens + +EXTENSIONS = { + ".py": "python", + ".java": "java", + ".c": "c", + ".h": "c", + ".cpp": "cpp", + ".cc": "cpp", + ".cxx": "cpp", + ".hpp": "cpp", + ".cs": "csharp", +} + + +def build_parser() -> argparse.ArgumentParser: + ap = argparse.ArgumentParser( + prog="llm-compact", + description="Compact source code before sending it to an LLM and report the token saving.", + ) + ap.add_argument("file", help="source file, or - to read standard input") + ap.add_argument("-l", "--language", choices=SUPPORTED_LANGUAGES, help="default: from extension") + ap.add_argument("--level", choices=("full", "light"), default="full") + ap.add_argument("--strip-comments", action="store_true") + ap.add_argument("-o", "--output", help="write the result here instead of standard output") + ap.add_argument("--no-stats", action="store_true", help="do not print the token report") + return ap + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + if args.file == "-": + source = sys.stdin.read() + lang = args.language + else: + path = Path(args.file) + source = path.read_text(encoding="utf-8") + lang = args.language or EXTENSIONS.get(path.suffix.lower()) + if lang is None: + print("error: could not infer the language, use --language", file=sys.stderr) + return 2 + + result = compact(source, lang, CompactOptions(args.level, args.strip_comments)) + if args.output: + Path(args.output).write_text(result.code, encoding="utf-8") + else: + sys.stdout.write(result.code) + + if not args.no_stats: + print(f"validation: {result.validation}", file=sys.stderr) + for w in result.warnings: + print(f"warning: {w}", file=sys.stderr) + if result.fallback: + print("original kept (no change was applied)", file=sys.stderr) + for r in compare_tokens(source, result.code, DEFAULT_ENCODINGS): + print( + f"{r.encoding}: {r.before} -> {r.after} ({r.percent:.1f}% saved)", file=sys.stderr + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/llm_compact/core/__init__.py b/llm_compact/core/__init__.py new file mode 100644 index 0000000..bd9102d --- /dev/null +++ b/llm_compact/core/__init__.py @@ -0,0 +1,26 @@ +from .cfamily import compact_cfamily +from .python import compact_python +from .types import MAX_SOURCE_BYTES, SUPPORTED_LANGUAGES, CompactOptions, CoreResult + +__all__ = ["SUPPORTED_LANGUAGES", "CompactOptions", "CoreResult", "compact"] + + +def compact(source: str, language: str, options: CompactOptions | None = None) -> CoreResult: + if not isinstance(source, str): + raise TypeError("The code must be a string.") + + if language not in SUPPORTED_LANGUAGES: + raise ValueError(f"Unsupported language: {language}") + + if len(source.encode("utf-8")) > MAX_SOURCE_BYTES: + raise ValueError(f"The code exceeds {MAX_SOURCE_BYTES:,} bytes.") + + if options is not None and not isinstance(options, CompactOptions): + raise TypeError("options must be a CompactOptions instance.") + + opt = options or CompactOptions() + + if language == "python": + return compact_python(source, opt) + + return compact_cfamily(source, language, opt) diff --git a/llm_compact/core/cfamily.py b/llm_compact/core/cfamily.py new file mode 100644 index 0000000..fbb8ade --- /dev/null +++ b/llm_compact/core/cfamily.py @@ -0,0 +1,273 @@ +import re + +from .common import needs_space, signature +from .types import CompactOptions, CoreResult, Token, UnsafeSourceError + +SYMS = set( + """>>>= <<= >>= >>> ... ->* <=> ??= ++ -- && || == != <= >= += -= *= /= +%= &= |= ^= << >> -> :: .* ?? ?. => .. ## <: :> <% %> %: %:%:""".split() +) +ENC = {"L", "u", "U", "u8"} +RAW = {"R", "LR", "uR", "UR", "u8R"} + +_EOL = re.compile(r"[\r\n]") +_CSHARP_EOL = re.compile("[\r\n\x85\u2028\u2029]") +_INTERP = re.compile(r'\${2,}"') +_SPLICE_OR_TRIGRAPH = re.compile(r"\\\r?\n|\?\?[/=()'!<>-]") + + +def _eol(lang: str) -> re.Pattern[str]: + return _CSHARP_EOL if lang == "csharp" else _EOL + + +def _ident_start(ch: str) -> bool: + return bool(ch) and (ch.isalpha() or ch in "_$" or ord(ch) > 127) + + +def _ident_part(ch: str) -> bool: + return bool(ch) and (_ident_start(ch) or ch.isdigit()) + + +def _quoted(s: str, at: int, vb: bool = False) -> int: + q = s[at] + k = at + 1 + while k < len(s): + if s[k] == q: + if vb and s[k : k + 2] == q * 2: + k += 2 + continue + return k + 1 + if s[k] == "\\" and not vb: + k += 2 + continue + if s[k] in "\r\n" and not vb: + raise UnsafeSourceError("Incomplete literal or unsupported line continuation.") + k += 1 + raise UnsafeSourceError("Unterminated literal.") + + +def _raw(s: str, at: int, lang: str) -> int: + if lang == "cpp": + lp = s.find("(", at + 1) + tag = s[at + 1 : lp] + if lp < 0 or len(tag) > 16 or re.search(r"[\s)\\]", tag): + raise UnsafeSourceError("Invalid C++ raw string.") + stop = ")" + tag + '"' + e = s.find(stop, lp + 1) + if e < 0: + raise UnsafeSourceError("Incomplete C++ raw string.") + return e + len(stop) + w = 0 + while at + w < len(s) and s[at + w] == '"': + w += 1 + tag = '"' * (3 if lang == "java" else w) + k = at + len(tag) + while k < len(s): + if lang == "java" and s[k] == "\\": + k += 2 + elif s.startswith(tag, k): + return k + len(tag) + else: + k += 1 + raise UnsafeSourceError("Incomplete multiline literal.") + + +def _number(s: str, at: int, lang: str) -> int: + k = at + hx = s[at : at + 2].lower() == "0x" + while k < len(s): + ch = s[k] + if ch.isascii() and (ch.isalnum() or ch in "_."): + ex = ch in ("eEpP" if lang in ("c", "cpp") else ("pP" if hx else "eE")) + k += 1 + if ex and k < len(s) and s[k] in "+-": + k += 1 + elif ch == "'" and lang in ("c", "cpp") and k + 1 < len(s) and s[k + 1].isalnum(): + k += 2 + else: + break + return k + + +def lex_cfamily(s: str, lang: str) -> list[Token]: + toks: list[Token] = [] + eol = _eol(lang) + p = 0 + bol = True + while p < len(s): + ch = s[p] + if eol.match(ch): + bol = True + p += 1 + continue + if ch.isspace(): + p += 1 + continue + b = p + kd = "punct" + if s.startswith("//", p): + m = eol.search(s, p) + p = len(s) if m is None else m.start() + kd = "comment" + elif s.startswith("/*", p): + e = s.find("*/", p + 2) + if e < 0: + raise UnsafeSourceError("Incomplete block comment.") + p = e + 2 + kd = "comment" + elif ch == "#" and bol and lang != "java": + while True: + m = eol.search(s, p) + if m is None: + p = len(s) + break + e = m.start() + if lang != "csharp" and s[p:e].endswith("\\"): + p = e + (2 if s.startswith("\r\n", e) else 1) + else: + p = e + break + kd = "directive" + if lang in ("c", "cpp") and "/*" in s[b:p]: + raise UnsafeSourceError( + "C/C++ directives containing '/*' need a preprocessor; original kept." + ) + elif lang == "csharp" and ( + s.startswith('$"', p) + or s.startswith('$@"', p) + or s.startswith('@$"', p) + or _INTERP.match(s, p) + ): + raise UnsafeSourceError( + "C# string interpolation needs a dedicated parser; original kept." + ) + elif lang == "csharp" and s.startswith('@"', p): + p = _quoted(s, p + 1, vb=True) + kd = "string" + elif lang == "csharp" and ch == "\\" and s[p + 1 : p + 2] in ("u", "U"): + raise UnsafeSourceError( + "C# Unicode escapes outside literals and comments need a dedicated lexer; " + "original kept." + ) + elif ch in "\"'": + if ch == '"' and lang in ("java", "csharp") and s.startswith('"""', p): + p = _raw(s, p, lang) + else: + p = _quoted(s, p) + kd = "string" + elif _ident_start(ch) or (lang == "csharp" and ch == "@"): + p += 1 + while p < len(s) and _ident_part(s[p]): + p += 1 + nm = s[b:p] + kd = "word" + if lang in ("c", "cpp") and p < len(s): + if nm in ENC and s[p] in "\"'": + p = _quoted(s, p) + kd = "string" + elif lang == "cpp" and nm in RAW and s[p] == '"': + p = _raw(s, p, "cpp") + kd = "string" + elif ch.isdigit() or (ch == "." and p + 1 < len(s) and s[p + 1].isdigit()): + p = _number(s, p, lang) + kd = "word" + else: + w = next((n for n in (4, 3, 2) if s[p : p + n] in SYMS), 1) + p += w + if kd == "string" and lang == "cpp" and p < len(s) and _ident_start(s[p]): + p += 1 + while p < len(s) and _ident_part(s[p]): + p += 1 + toks.append(Token(kd, s[b:p], b, p)) + if kd == "comment": + bol = bol or "\n" in s[b:p] + else: + bol = False + return toks + + +def _newlines(s: str) -> str: + return "".join(re.findall(r"\r\n|\n|\r", s)) + + +def _has_splice_or_trigraph(text: str, toks: list[Token]) -> bool: + parts: list[str] = [] + pos = 0 + for tk in toks: + if tk.kind == "directive": + parts.append(text[pos : tk.start]) + pos = tk.end + parts.append(text[pos:]) + return _SPLICE_OR_TRIGRAPH.search("".join(parts)) is not None + + +def compact_cfamily(src: str, lang: str, opt: CompactOptions) -> CoreResult: + try: + if lang == "java" and re.search(r"\\u+[0-9a-fA-F]{4}", src): + raise UnsafeSourceError("Java Unicode escapes are processed before the lexer.") + if lang in ("c", "cpp") and ("__LINE__" in src or "source_location" in src): + raise UnsafeSourceError("The code depends on its position in the file.") + toks = lex_cfamily(src, lang) + if lang in ("c", "cpp"): + for tk in toks: + if tk.kind == "punct" and tk.text in ("%:", "%:%:"): + raise UnsafeSourceError("Digraph directives need a preprocessor.") + if tk.kind != "directive" and re.search(r"\\\r?\n|\\$", tk.text): + raise UnsafeSourceError("Line continuation outside a directive.") + if ( + tk.kind == "directive" + and re.match(r"#\s*define\b", tk.text) + and "#" in tk.text[1:] + ): + raise UnsafeSourceError( + "Macro with stringification or concatenation needs a preprocessor." + ) + if _has_splice_or_trigraph(src, toks): + raise UnsafeSourceError("Line splicing or trigraphs are not supported.") + + live = [tk for tk in toks if not (opt.strip_comments and tk.kind == "comment")] + buf: list[str] = [] + prev: Token | None = None + pos = 0 + + def lx(s: str) -> list[Token]: + return lex_cfamily(s, lang) + + for tk in live: + sp = src[pos : tk.start] + join = "" + if opt.level == "light" and ("\n" in sp or "\r" in sp): + join = _newlines(sp) + elif prev is not None: + if ( + prev.kind == "directive" + or tk.kind == "directive" + or (prev.kind == "comment" and prev.text.startswith("//")) + ): + join = "\n" + elif sp and needs_space(lx, prev, tk): + join = " " + buf.append(join + tk.text) + prev = tk + pos = tk.end + rest = src[pos:] + if opt.level == "light": + buf.append(_newlines(rest)) + elif prev is not None and prev.kind in ("directive", "comment") and "\n" in rest: + buf.append("\n") + new = "".join(buf) + new_toks = lx(new) + if signature(new_toks, opt.strip_comments) != signature(toks, opt.strip_comments): + raise UnsafeSourceError("The lexical sequence changed after the transformation.") + if lang in ("c", "cpp") and _has_splice_or_trigraph(new, new_toks): + raise UnsafeSourceError("Compaction would create a line splice or trigraph.") + if opt.level == "light" and new.count("\n") != src.count("\n"): + raise UnsafeSourceError("The line count changed in light mode.") + msgs = [ + "Lexical check: does not prove semantic equivalence and does not replace the compiler." + ] + if any(tk.kind == "directive" for tk in toks): + msgs.append("Directives kept as they are. External macros are not expanded.") + return CoreResult(new, "lexical", tuple(msgs)) + except UnsafeSourceError as err: + return CoreResult(src, "not-validated", (str(err),), fallback=True) diff --git a/llm_compact/core/common.py b/llm_compact/core/common.py new file mode 100644 index 0000000..ad64c00 --- /dev/null +++ b/llm_compact/core/common.py @@ -0,0 +1,24 @@ +from collections.abc import Callable + +from .types import Token + + +def signature(toks: list[Token], drop: bool = False) -> list[tuple[str, str]]: + return [(k.kind, k.text) for k in toks if not (drop and k.kind == "comment")] + + +def needs_space(lx: Callable[[str], list[Token]], left: Token, right: Token) -> bool: + solo = "(){}[];," + if left.kind == "punct" and left.text in solo: + return False + if right.kind == "punct" and right.text in solo: + return False + if left.kind == right.kind == "word": + return True + if {left.kind, right.kind} == {"word", "string"}: + return True + try: + merged = lx(left.text + right.text) + return signature(merged) != [(left.kind, left.text), (right.kind, right.text)] + except ValueError: + return True diff --git a/llm_compact/tokens.py b/llm_compact/tokens.py new file mode 100644 index 0000000..07dd64c --- /dev/null +++ b/llm_compact/tokens.py @@ -0,0 +1,39 @@ +from dataclasses import dataclass +from functools import lru_cache + +DEFAULT_ENCODINGS = ("o200k_base", "cl100k_base") + + +@dataclass(frozen=True) +class TokenReport: + encoding: str + before: int + after: int + + @property + def saved(self) -> int: + return self.before - self.after + + @property + def percent(self) -> float: + return 100.0 * self.saved / self.before if self.before else 0.0 + + +@lru_cache(maxsize=None) +def _encoding(name: str): + import tiktoken + + return tiktoken.get_encoding(name) + + +def count_tokens(text: str, encoding: str = DEFAULT_ENCODINGS[0]) -> int: + return len(_encoding(encoding).encode(text, disallowed_special=())) + + +def compare_tokens( + before: str, after: str, encodings: tuple[str, ...] = DEFAULT_ENCODINGS +) -> list[TokenReport]: + return [ + TokenReport(name, count_tokens(before, name), count_tokens(after, name)) + for name in encodings + ] diff --git a/pyproject.toml b/pyproject.toml index 761dcfc..6fdce35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,9 @@ license = "MIT" license-files = ["LICENSE"] dependencies = ["tiktoken>=0.14,<0.15"] +[project.scripts] +llm-compact = "llm_compact.cli:main" + [project.optional-dependencies] ui = ["streamlit>=1.64,<1.65"] dev = ["pytest>=9.1,<10", "ruff>=0.16,<0.17", "build>=1.6,<2"] diff --git a/tests/test_cfamily.py b/tests/test_cfamily.py new file mode 100644 index 0000000..a66472f --- /dev/null +++ b/tests/test_cfamily.py @@ -0,0 +1,213 @@ +from pathlib import Path + +import pytest + +from llm_compact.core.cfamily import compact_cfamily, lex_cfamily +from llm_compact.core.common import signature +from llm_compact.core.types import CompactOptions + + +def run(src, lang, lv="full", drop=False): + return compact_cfamily(src, lang, CompactOptions(lv, drop)) + + +@pytest.mark.parametrize( + "lang,src,want", + [ + ("java", "int x = 1 ;", "int x=1;"), + ("java", "a = b - -c;", "a=b- -c;"), + ("java", "a = b + +c;", "a=b+ +c;"), + ("cpp", "x = a - --b;", "x=a- --b;"), + ("cpp", "vector > v;", "vector >v;"), + ("java", "a = b / /* c */ d;", "a=b/ /* c */d;"), + ("cpp", "while (k --> 0) {}", "while(k-->0){}"), + ("java", 'return "x" ;', 'return "x";'), + ("csharp", "x = 1 .ToString();", "x=1 .ToString();"), + ("c", 'puts( "a b" );', 'puts("a b");'), + ("cpp", 'auto s = R"x( a b\n c )x" ;', 'auto s=R"x( a b\n c )x";'), + ("csharp", 'var s = @"a ""b"" c" ;', 'var s=@"a ""b"" c";'), + ("cpp", 'auto s = "x"s ;', 'auto s="x"s;'), + ], +) +def test_ports_original_rules(lang, src, want): + r = run(src, lang) + assert not r.fallback, r.warnings + assert r.code == want + + +@pytest.mark.parametrize("lv", ["full", "light"]) +def test_directives_and_macro_continuation_are_intact(lv): + src = "#include \n#define ADD(x) \\\n ((x) + 1)\nint a; // note\nint b;\n" + r = run(src, "cpp", lv) + assert "#define ADD(x) \\\n ((x) + 1)\n" in r.code + assert "// note\n" in r.code + assert signature(lex_cfamily(src, "cpp")) == signature(lex_cfamily(r.code, "cpp")) + + +def test_light_keeps_all_line_numbers_including_removed_comments(): + src = "\nint f() {\n /* first\n second */ int a = 1;\n\n\n return a;\n}\n" + r = run(src, "java", "light", True) + assert r.code.count("\n") == src.count("\n") + for nm in ["int f", "int a", "return a"]: + assert src[: src.index(nm)].count("\n") == r.code[: r.code.index(nm)].count("\n") + + +def test_strip_comment_prevents_token_merge(): + assert run("a/* x */b", "java", drop=True).code == "a b" + + +@pytest.mark.parametrize( + "lang,src", + [ + ("java", r"int x; \u002f\u002f comment"), + ("cpp", "#define S(x) #x\nauto s = S(a + b);"), + ("c", "int x = __LINE__;"), + ("c", "int x = 1 + \\\n 2;"), + ("cpp", "// note \\\nint hidden;"), + ("cpp", "/* no end"), + ("java", 'String x = "no end'), + ("csharp", 'var s = $"{d["key"]}";'), + ("csharp", 'var s = $$$""" {{{d}}} """;'), + ], +) +def test_unsupported_constructs_preserve_original(lang, src): + r = run(src, lang) + assert r.code == src + assert r.fallback + + +@pytest.mark.parametrize( + "fn,lang", [("Sample.java", "java"), ("sample.cpp", "cpp"), ("sample.c", "c")] +) +@pytest.mark.parametrize("lv", ["full", "light"]) +def test_fixtures_lexical_equality(fn, lang, lv): + src = (Path(__file__).parents[1] / "examples" / fn).read_text() + r = run(src, lang, lv) + assert not r.fallback, r.warnings + assert signature(lex_cfamily(src, lang)) == signature(lex_cfamily(r.code, lang)) + + +def test_java_text_block_preserved(): + lit = '"""\n a b\n """' + assert lit in run(f"String s = {lit} ;", "java").code + + +def test_pairwise_rule_does_not_silently_form_ellipsis(): + src = "a . . . b;" + r = run(src, "java") + assert r.fallback or signature(lex_cfamily(src, "java")) == signature( + lex_cfamily(r.code, "java") + ) + + +def test_hex_literal_does_not_become_invalid_pp_number(): + assert run("unsigned x = 0xE + 1;", "cpp").code == "unsigned x=0xE +1;" + + +def test_digraph_directive_falls_back(): + src = "%:define X 1\nint value = X;" + assert run(src, "cpp").fallback + + +@pytest.mark.parametrize("lang", ["c", "cpp"]) +@pytest.mark.parametrize("lv", ["full", "light"]) +@pytest.mark.parametrize("drop", [False, True]) +@pytest.mark.parametrize("newline", ["\n", "\r\n"]) +def test_multiline_comment_in_directive_preserves_original(lang, lv, drop, newline): + src = newline.join( + [ + "#define VALUE /* explanation", + "continued */ 1", + "int main(void) { return VALUE - 1; }", + "", + ] + ) + result = run(src, lang, lv, drop) + assert result.fallback + assert result.validation == "not-validated" + assert result.code == src + assert "preprocessor" in result.warnings[0] + + +@pytest.mark.parametrize( + "src", + [ + "#define VALUE /* inline */ 1\nint value = VALUE;", + '#define MARKER "/*"\nconst char *value = MARKER;', + ], +) +def test_directive_guard_is_deliberately_conservative(src): + result = run(src, "c") + assert result.fallback + assert result.code == src + + +@pytest.mark.parametrize("lv", ["full", "light"]) +@pytest.mark.parametrize("drop", [False, True]) +@pytest.mark.parametrize("name", [r"\u0061", r"\U00000061", r"@\u0061", r"a\u0062"]) +def test_csharp_escaped_identifiers_preserve_original(lv, drop, name): + src = f"class A {{ static int {name} = 1; }}\n" + result = run(src, "csharp", lv, drop) + assert result.fallback + assert result.validation == "not-validated" + assert result.code == src + assert "Unicode" in result.warnings[0] + + +@pytest.mark.parametrize( + "src", + [ + r'class A { string value = "\u0061"; }', + r'class A { string value = @"\u0061"; }', + r"class A { char value = '\u0061'; }", + r"class A { /* \u0061 */ int value = 1; }", + ], +) +def test_csharp_unicode_text_inside_literals_and_comments_is_preserved(src): + result = run(src, "csharp") + assert not result.fallback + assert r"\u0061" in result.code + + +def test_lexical_validation_does_not_claim_syntax_validation(): + result = run("int main( {", "c") + assert result.validation == "lexical" + assert any("does not prove semantic equivalence" in warning for warning in result.warnings) + + +@pytest.mark.parametrize("lang", ["c", "cpp", "java", "csharp"]) +@pytest.mark.parametrize("eol", ["\r", "\n", "\r\n"]) +@pytest.mark.parametrize("lv", ["full", "light"]) +def test_line_comment_ends_at_any_line_terminator(lang, eol, lv): + src = f"int a; // note{eol}int b;{eol}" + for drop in (False, True): + out = run(src, lang, lv, drop) + assert not out.fallback, out.warnings + assert "int b;" in out.code + + +@pytest.mark.parametrize("eol", ["\x85", "
", "
"]) +def test_csharp_unicode_line_terminators_end_line_comments(eol): + out = run(f"int a; // note{eol}int b;\n", "csharp", "full", True) + assert not out.fallback, out.warnings + assert "int b;" in out.code + + +@pytest.mark.parametrize("lang", ["c", "cpp"]) +def test_directive_ends_at_lone_cr(lang): + out = run("#define X 1\rint b;\r", lang) + assert not out.fallback, out.warnings + assert "int b;" in out.code + + +@pytest.mark.parametrize("lang", ["c", "cpp"]) +def test_trigraph_created_by_compaction_falls_back(lang): + src = "int x = a ?? /* c */ b;\n" + out = run(src, lang) + assert out.fallback and out.code == src + + +def test_csharp_scales_linearly_enough(): + src = "class A { void F() { int x = 1; } }\n" * 3000 + out = run(src, "csharp") + assert not out.fallback and out.code.startswith("class A{void F(){int x=1;}}") diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..a922289 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,33 @@ +import pytest + +from llm_compact.cli import main + + +def test_cli_writes_compacted_file_and_reports_tokens(tmp_path, capsys): + src = tmp_path / "a.c" + out = tmp_path / "b.c" + src.write_text("int main( void )\n{\n return 0;\n}\n") + assert main([str(src), "-o", str(out)]) == 0 + assert out.read_text() == "int main(void){return 0;}" + err = capsys.readouterr().err + assert "validation: lexical" in err and "o200k_base:" in err and "saved" in err + + +def test_cli_unknown_extension_needs_language(tmp_path, capsys): + f = tmp_path / "a.txt" + f.write_text("x") + assert main([str(f)]) == 2 + assert "--language" in capsys.readouterr().err + + +def test_cli_reads_stdin(monkeypatch, capsys): + import io + + monkeypatch.setattr("sys.stdin", io.StringIO("x = 1\n")) + assert main(["-", "-l", "python", "--no-stats"]) == 0 + assert capsys.readouterr().out == "x=1\n" + + +def test_cli_rejects_unknown_language(): + with pytest.raises(SystemExit): + main(["a.c", "-l", "rust"]) diff --git a/tests/test_compilers.py b/tests/test_compilers.py new file mode 100644 index 0000000..38f5b96 --- /dev/null +++ b/tests/test_compilers.py @@ -0,0 +1,58 @@ +import shutil +import subprocess +from pathlib import Path + +import pytest + +from llm_compact.core.cfamily import compact_cfamily +from llm_compact.core.types import CompactOptions + + +@pytest.mark.parametrize( + "lang,cc,fn", + [ + ("c", "gcc", "sample.c"), + ("cpp", "g++", "sample.cpp"), + ], +) +@pytest.mark.parametrize("lv", ["full", "light"]) +@pytest.mark.parametrize("drop", [False, True]) +def test_fixture_compiles_and_has_identical_stdout(tmp_path, lang, cc, fn, lv, drop): + if not shutil.which(cc): + pytest.skip(f"{cc} not installed") + src = (Path(__file__).parents[1] / "examples" / fn).read_text() + new = compact_cfamily(src, lang, CompactOptions(lv, drop)) + assert not new.fallback, new.warnings + outs = [] + for n, s in enumerate((src, new.code)): + f = tmp_path / f"{n}.{lang}" + exe = tmp_path / f"program{n}" + f.write_text(s) + subprocess.run([cc, str(f), "-o", str(exe)], check=True, capture_output=True, timeout=30) + run = subprocess.run([str(exe)], check=True, capture_output=True, timeout=5) + outs.append(run.stdout) + assert outs[0] == outs[1] + + +@pytest.mark.parametrize("lang,cc", [("c", "gcc"), ("cpp", "g++")]) +@pytest.mark.parametrize("lv", ["full", "light"]) +@pytest.mark.parametrize("drop", [False, True]) +def test_multiline_macro_comment_keeps_a_compilable_program(tmp_path, lang, cc, lv, drop): + if not shutil.which(cc): + pytest.skip(f"{cc} not installed") + src = ( + "#define VALUE /* explanation\n" + "continued */ 1\n" + "int main(void) { return VALUE - 1; }\n" + ) + result = compact_cfamily(src, lang, CompactOptions(lv, drop)) + assert result.fallback and result.code == src + for label, code in (("original", src), ("output", result.code)): + path = tmp_path / f"{label}.{lang}" + executable = tmp_path / label + path.write_text(code, encoding="utf-8") + subprocess.run( + [cc, str(path), "-o", str(executable)], + check=True, capture_output=True, timeout=30, + ) + subprocess.run([str(executable)], check=True, capture_output=True, timeout=5) diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..4026aeb --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,32 @@ +import pytest + +from llm_compact.core import CompactOptions, compact + + +@pytest.mark.parametrize("lang", ["javascript", "", "typescript"]) +def test_unsupported_language(lang): + with pytest.raises(ValueError): + compact("code", lang) + + +def test_bad_options_and_size(): + with pytest.raises(ValueError): + CompactOptions("invalid") + with pytest.raises(ValueError): + compact("x" * 1_000_001, "python") + with pytest.raises(TypeError): + compact(None, "python") + + +@pytest.mark.parametrize( + "lang,src,want", + [("python", "x = 1\n", "x=1\n"), ("java", "int x = 1 ;", "int x=1;")], +) +def test_language_dispatch(lang, src, want): + assert compact(src, lang).code == want + + +@pytest.mark.parametrize("bad", [{}, {"level": "full"}, "full", 1]) +def test_options_must_be_compact_options(bad): + with pytest.raises(TypeError): + compact("x = 1", "python", bad) diff --git a/tests/test_tokens.py b/tests/test_tokens.py new file mode 100644 index 0000000..6d753ed --- /dev/null +++ b/tests/test_tokens.py @@ -0,0 +1,26 @@ +import pytest + +from llm_compact.core import compact +from llm_compact.tokens import compare_tokens, count_tokens + +try: + count_tokens("x") +except Exception: + pytest.skip("tiktoken tables unavailable", allow_module_level=True) + + +def test_special_looking_text_is_counted_as_text(): + assert count_tokens("<|endoftext|>") > 1 + + +def test_empty_input_reports_zero_percent(): + for r in compare_tokens("", ""): + assert (r.before, r.after, r.percent) == (0, 0, 0.0) + + +@pytest.mark.parametrize("lang", ["java", "c", "cpp", "csharp"]) +def test_compaction_does_not_increase_tokens_on_formatted_code(lang): + src = "class A {\n int f(int x) {\n return x + 1;\n }\n}\n" + out = compact(src, lang) + for r in compare_tokens(src, out.code): + assert r.after <= r.before and r.saved == r.before - r.after