codeecho 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
codeecho/normalizer.py ADDED
@@ -0,0 +1,519 @@
1
+ """
2
+ Token extraction and Type-2 normalisation for code fragments.
3
+
4
+ Tokens are extracted from the raw fragment source text using a language-aware
5
+ regex-based tokeniser. This avoids direct tree-sitter AST node traversal,
6
+ which is unstable across tree-sitter 0.26+ Python bindings.
7
+
8
+ Identifiers and literals are replaced with sequential placeholders
9
+ (``ID_0``, ``ID_1``, ``LIT_0``, …) so that two fragments with the same structure
10
+ but different variable names still produce the same normalised token sequence.
11
+
12
+ :author: Ron Webb
13
+ :since: 1.0.0
14
+ """
15
+
16
+ import re
17
+ import logging
18
+ from dataclasses import dataclass, field
19
+
20
+ from tree_sitter import Node
21
+
22
+ _logger = logging.getLogger("codeecho.normalizer")
23
+
24
+
25
+ @dataclass
26
+ class NormalisationProfile:
27
+ """Language-specific sets of identifier and literal node types."""
28
+
29
+ identifier_types: frozenset[str] = field(default_factory=frozenset)
30
+ literal_types: frozenset[str] = field(default_factory=frozenset)
31
+
32
+
33
+ _PROFILES: dict[str, NormalisationProfile] = {
34
+ "Python": NormalisationProfile(
35
+ identifier_types=frozenset({"identifier", "type_identifier"}),
36
+ literal_types=frozenset(
37
+ {"string", "integer", "float", "true", "false", "none"}
38
+ ),
39
+ ),
40
+ "JavaScript": NormalisationProfile(
41
+ identifier_types=frozenset(
42
+ {"identifier", "property_identifier", "shorthand_property_identifier"}
43
+ ),
44
+ literal_types=frozenset(
45
+ {"string", "number", "true", "false", "null", "template_string"}
46
+ ),
47
+ ),
48
+ "TypeScript": NormalisationProfile(
49
+ identifier_types=frozenset(
50
+ {
51
+ "identifier",
52
+ "property_identifier",
53
+ "type_identifier",
54
+ "shorthand_property_identifier",
55
+ }
56
+ ),
57
+ literal_types=frozenset(
58
+ {"string", "number", "true", "false", "null", "template_string"}
59
+ ),
60
+ ),
61
+ "Java": NormalisationProfile(
62
+ identifier_types=frozenset({"identifier", "type_identifier"}),
63
+ literal_types=frozenset(
64
+ {
65
+ "string_literal",
66
+ "decimal_integer_literal",
67
+ "decimal_floating_point_literal",
68
+ "true",
69
+ "false",
70
+ "null_literal",
71
+ }
72
+ ),
73
+ ),
74
+ "Gosu": NormalisationProfile(
75
+ identifier_types=frozenset({"identifier", "type_identifier"}),
76
+ literal_types=frozenset(
77
+ {
78
+ "string_literal",
79
+ "decimal_integer_literal",
80
+ "decimal_floating_point_literal",
81
+ "true",
82
+ "false",
83
+ "null_literal",
84
+ }
85
+ ),
86
+ ),
87
+ "Go": NormalisationProfile(
88
+ identifier_types=frozenset(
89
+ {"identifier", "type_identifier", "field_identifier"}
90
+ ),
91
+ literal_types=frozenset(
92
+ {
93
+ "interpreted_string_literal",
94
+ "raw_string_literal",
95
+ "int_literal",
96
+ "float_literal",
97
+ "true",
98
+ "false",
99
+ "nil",
100
+ }
101
+ ),
102
+ ),
103
+ }
104
+
105
+ _FALLBACK_PROFILE: NormalisationProfile = NormalisationProfile()
106
+
107
+
108
+ # ── Regex-based tokeniser ───────────────────────────────────────────────────
109
+ # Matches identifiers/keywords, number literals, quoted strings, and single
110
+ # non-whitespace characters (operators, punctuation).
111
+ _TOKEN_RE: re.Pattern[str] = re.compile(
112
+ r'"(?:[^"\\]|\\.)*"' # double-quoted string
113
+ r"|'(?:[^'\\]|\\.)*'" # single-quoted string
114
+ r"|`(?:[^`\\]|\\.)*`" # backtick string (JS/Go)
115
+ r"|//[^\n]*" # single-line comment
116
+ r"|/\*.*?\*/" # multi-line comment (non-greedy)
117
+ r"|[0-9]+(?:\.[0-9]+)?" # integer or float literal
118
+ r"|[a-zA-Z_$][a-zA-Z0-9_$]*" # identifier / keyword
119
+ r"|[^\s]", # any other single non-whitespace char
120
+ re.DOTALL,
121
+ )
122
+
123
+ # Per-language keyword sets used to distinguish identifiers from keywords
124
+ _KEYWORDS: dict[str, frozenset[str]] = {
125
+ "Python": frozenset(
126
+ {
127
+ "False",
128
+ "None",
129
+ "True",
130
+ "and",
131
+ "as",
132
+ "assert",
133
+ "async",
134
+ "await",
135
+ "break",
136
+ "class",
137
+ "continue",
138
+ "def",
139
+ "del",
140
+ "elif",
141
+ "else",
142
+ "except",
143
+ "finally",
144
+ "for",
145
+ "from",
146
+ "global",
147
+ "if",
148
+ "import",
149
+ "in",
150
+ "is",
151
+ "lambda",
152
+ "nonlocal",
153
+ "not",
154
+ "or",
155
+ "pass",
156
+ "raise",
157
+ "return",
158
+ "try",
159
+ "while",
160
+ "with",
161
+ "yield",
162
+ }
163
+ ),
164
+ "JavaScript": frozenset(
165
+ {
166
+ "break",
167
+ "case",
168
+ "catch",
169
+ "class",
170
+ "const",
171
+ "continue",
172
+ "debugger",
173
+ "default",
174
+ "delete",
175
+ "do",
176
+ "else",
177
+ "export",
178
+ "extends",
179
+ "false",
180
+ "finally",
181
+ "for",
182
+ "function",
183
+ "if",
184
+ "import",
185
+ "in",
186
+ "instanceof",
187
+ "let",
188
+ "new",
189
+ "null",
190
+ "return",
191
+ "static",
192
+ "super",
193
+ "switch",
194
+ "this",
195
+ "throw",
196
+ "true",
197
+ "try",
198
+ "typeof",
199
+ "undefined",
200
+ "var",
201
+ "void",
202
+ "while",
203
+ "with",
204
+ "yield",
205
+ }
206
+ ),
207
+ "TypeScript": frozenset(
208
+ {
209
+ "abstract",
210
+ "any",
211
+ "as",
212
+ "asserts",
213
+ "async",
214
+ "await",
215
+ "break",
216
+ "case",
217
+ "catch",
218
+ "class",
219
+ "const",
220
+ "continue",
221
+ "debugger",
222
+ "declare",
223
+ "default",
224
+ "delete",
225
+ "do",
226
+ "else",
227
+ "enum",
228
+ "export",
229
+ "extends",
230
+ "false",
231
+ "finally",
232
+ "for",
233
+ "from",
234
+ "function",
235
+ "if",
236
+ "implements",
237
+ "import",
238
+ "in",
239
+ "infer",
240
+ "instanceof",
241
+ "interface",
242
+ "is",
243
+ "keyof",
244
+ "let",
245
+ "module",
246
+ "namespace",
247
+ "never",
248
+ "new",
249
+ "null",
250
+ "of",
251
+ "override",
252
+ "private",
253
+ "protected",
254
+ "public",
255
+ "readonly",
256
+ "return",
257
+ "satisfies",
258
+ "static",
259
+ "super",
260
+ "switch",
261
+ "this",
262
+ "throw",
263
+ "true",
264
+ "try",
265
+ "type",
266
+ "typeof",
267
+ "undefined",
268
+ "unique",
269
+ "unknown",
270
+ "var",
271
+ "void",
272
+ "while",
273
+ "with",
274
+ "yield",
275
+ }
276
+ ),
277
+ "Java": frozenset(
278
+ {
279
+ "abstract",
280
+ "assert",
281
+ "boolean",
282
+ "break",
283
+ "byte",
284
+ "case",
285
+ "catch",
286
+ "char",
287
+ "class",
288
+ "const",
289
+ "continue",
290
+ "default",
291
+ "do",
292
+ "double",
293
+ "else",
294
+ "enum",
295
+ "extends",
296
+ "final",
297
+ "finally",
298
+ "float",
299
+ "for",
300
+ "goto",
301
+ "if",
302
+ "implements",
303
+ "import",
304
+ "instanceof",
305
+ "int",
306
+ "interface",
307
+ "long",
308
+ "native",
309
+ "new",
310
+ "null",
311
+ "package",
312
+ "private",
313
+ "protected",
314
+ "public",
315
+ "return",
316
+ "short",
317
+ "static",
318
+ "strictfp",
319
+ "super",
320
+ "switch",
321
+ "synchronized",
322
+ "this",
323
+ "throw",
324
+ "throws",
325
+ "transient",
326
+ "true",
327
+ "try",
328
+ "var",
329
+ "void",
330
+ "volatile",
331
+ "while",
332
+ }
333
+ ),
334
+ "Gosu": frozenset(
335
+ {
336
+ "abstract",
337
+ "as",
338
+ "assert",
339
+ "block",
340
+ "break",
341
+ "case",
342
+ "catch",
343
+ "class",
344
+ "classpath",
345
+ "continue",
346
+ "default",
347
+ "do",
348
+ "else",
349
+ "enum",
350
+ "erases",
351
+ "eval",
352
+ "exists",
353
+ "extends",
354
+ "false",
355
+ "final",
356
+ "finally",
357
+ "for",
358
+ "foreach",
359
+ "function",
360
+ "hiding",
361
+ "if",
362
+ "implements",
363
+ "import",
364
+ "in",
365
+ "index",
366
+ "interface",
367
+ "new",
368
+ "null",
369
+ "override",
370
+ "package",
371
+ "property",
372
+ "protected",
373
+ "public",
374
+ "readonly",
375
+ "return",
376
+ "static",
377
+ "super",
378
+ "switch",
379
+ "this",
380
+ "throw",
381
+ "throws",
382
+ "transient",
383
+ "true",
384
+ "try",
385
+ "unless",
386
+ "using",
387
+ "var",
388
+ "void",
389
+ "where",
390
+ "while",
391
+ }
392
+ ),
393
+ "Go": frozenset(
394
+ {
395
+ "break",
396
+ "case",
397
+ "chan",
398
+ "const",
399
+ "continue",
400
+ "default",
401
+ "defer",
402
+ "else",
403
+ "fallthrough",
404
+ "false",
405
+ "for",
406
+ "func",
407
+ "go",
408
+ "goto",
409
+ "if",
410
+ "import",
411
+ "interface",
412
+ "map",
413
+ "nil",
414
+ "package",
415
+ "range",
416
+ "return",
417
+ "select",
418
+ "struct",
419
+ "switch",
420
+ "true",
421
+ "type",
422
+ "var",
423
+ }
424
+ ),
425
+ }
426
+
427
+ # Patterns that indicate the token is a string/number literal
428
+ _STRING_RE: re.Pattern[str] = re.compile(r"""^["'`0-9]""")
429
+
430
+
431
+ def get_profile(language_name: str) -> NormalisationProfile:
432
+ """Return the :class:`NormalisationProfile` for *language_name*, falling back to empty sets."""
433
+ return _PROFILES.get(language_name, _FALLBACK_PROFILE)
434
+
435
+
436
+ def _tokenise_text(text: str) -> list[str]:
437
+ """Split *text* into a list of raw tokens using :data:`_TOKEN_RE`."""
438
+ return _TOKEN_RE.findall(text)
439
+
440
+
441
+ def _node_text(node: Node, source_bytes: bytes) -> str:
442
+ """Return the UTF-8 decoded source text for the byte range covered by *node*."""
443
+ return source_bytes[node.start_byte : node.end_byte].decode(
444
+ "utf-8", errors="replace"
445
+ )
446
+
447
+
448
+ def tokenise_and_normalise(
449
+ text: str, language_name: str
450
+ ) -> tuple[list[str], list[str]]:
451
+ """Return ``(raw_tokens, normalised_tokens)`` for a fragment source *text* string.
452
+
453
+ Args:
454
+ text: Raw fragment source text (UTF-8 decoded).
455
+ language_name: Language whose keyword set governs normalisation.
456
+
457
+ Returns:
458
+ Raw token list and normalised token list where identifiers become
459
+ ``ID_N`` and literals become ``LIT_N``.
460
+ """
461
+ raw_tokens = _tokenise_text(text)
462
+ keywords = _KEYWORDS.get(language_name, frozenset())
463
+ return raw_tokens, _normalise(raw_tokens, keywords)
464
+
465
+
466
+ def extract_tokens(node: Node, source_bytes: bytes) -> list[str]:
467
+ """Return a flat list of raw tokens for the source text covered by *node*.
468
+
469
+ Uses a regex-based tokeniser on the raw source slice — no tree traversal.
470
+ """
471
+ return _tokenise_text(_node_text(node, source_bytes))
472
+
473
+
474
+ def extract_and_normalise(
475
+ node: Node, source_bytes: bytes, language_name: str
476
+ ) -> tuple[list[str], list[str]]:
477
+ """Return ``(raw_tokens, normalised_tokens)`` for the source covered by *node*.
478
+
479
+ Args:
480
+ node: Tree-sitter node; only its byte range is used (no child traversal).
481
+ source_bytes: Full source bytes of the file.
482
+ language_name: Language whose keyword set governs normalisation.
483
+
484
+ Returns:
485
+ Raw token list and normalised token list where identifiers become
486
+ ``ID_N`` and literals become ``LIT_N``.
487
+ """
488
+ return tokenise_and_normalise(_node_text(node, source_bytes), language_name)
489
+
490
+
491
+ def _normalise(raw_tokens: list[str], keywords: frozenset[str]) -> list[str]:
492
+ """Build a normalised token list from *raw_tokens* using *keywords* for classification."""
493
+ norm_tokens: list[str] = []
494
+ id_map: dict[str, str] = {}
495
+ lit_count = 0
496
+
497
+ for token in raw_tokens:
498
+ if _is_comment(token):
499
+ continue # skip comments
500
+ if _is_identifier(token) and token not in keywords:
501
+ placeholder = id_map.setdefault(token, f"ID_{len(id_map)}")
502
+ norm_tokens.append(placeholder)
503
+ elif _STRING_RE.match(token):
504
+ norm_tokens.append(f"LIT_{lit_count}")
505
+ lit_count += 1
506
+ else:
507
+ norm_tokens.append(token)
508
+
509
+ return norm_tokens
510
+
511
+
512
+ def _is_identifier(token: str) -> bool:
513
+ """Return True if *token* looks like an identifier (letter/underscore start)."""
514
+ return bool(token) and (token[0].isalpha() or token[0] == "_")
515
+
516
+
517
+ def _is_comment(token: str) -> bool:
518
+ """Return True if *token* is a C-style single-line (``//``) or multi-line (``/* */``) comment."""
519
+ return token.startswith("//") or (token.startswith("/*") and token.endswith("*/"))
codeecho/parser.py ADDED
@@ -0,0 +1,94 @@
1
+ """
2
+ Tree-sitter parser wrapper providing one cached :class:`tree_sitter.Parser` per language.
3
+
4
+ Gosu (``.gs``) files are parsed using the Java grammar because Gosu is syntactically
5
+ Java-like (methods, classes, blocks).
6
+
7
+ :author: Ron Webb
8
+ :since: 1.0.0
9
+ """
10
+
11
+ import logging
12
+ from functools import lru_cache
13
+
14
+ from tree_sitter import Language, Parser, Tree
15
+
16
+ _logger = logging.getLogger("codeecho.parser")
17
+
18
+
19
+ def _build_language(
20
+ language_name: str,
21
+ ) -> Language | None: # pylint: disable=too-many-return-statements
22
+ """Instantiate and return the tree-sitter :class:`Language` for *language_name*."""
23
+ match language_name:
24
+ case "Python":
25
+ import tree_sitter_python as m # pylint: disable=import-outside-toplevel
26
+
27
+ return Language(m.language())
28
+ case "JavaScript":
29
+ import tree_sitter_javascript as m # pylint: disable=import-outside-toplevel
30
+
31
+ return Language(m.language())
32
+ case "TypeScript" | "TSX":
33
+ import tree_sitter_typescript as m # pylint: disable=import-outside-toplevel
34
+
35
+ return Language(m.language_typescript())
36
+ case "Java" | "Gosu":
37
+ import tree_sitter_java as m # pylint: disable=import-outside-toplevel
38
+
39
+ return Language(m.language())
40
+ case "Go":
41
+ import tree_sitter_go as m # pylint: disable=import-outside-toplevel
42
+
43
+ return Language(m.language())
44
+ case _:
45
+ return None
46
+
47
+
48
+ @lru_cache(maxsize=None)
49
+ def get_language(language_name: str) -> Language | None:
50
+ """Return a cached :class:`tree_sitter.Language` for *language_name*, or ``None`` on failure."""
51
+ try:
52
+ lang = _build_language(language_name)
53
+ except Exception as exc: # pylint: disable=broad-exception-caught
54
+ _logger.warning("Failed to load grammar for %s: %s", language_name, exc)
55
+ return None
56
+ if lang is None:
57
+ _logger.warning("No tree-sitter grammar for language: %s", language_name)
58
+ return lang
59
+
60
+
61
+ @lru_cache(maxsize=None)
62
+ def get_parser(language_name: str) -> Parser | None:
63
+ """Return a cached :class:`tree_sitter.Parser` for *language_name*, or ``None`` on failure.
64
+
65
+ .. note::
66
+ The returned parser is NOT used directly for parsing; call :func:`parse` instead,
67
+ which creates a fresh :class:`~tree_sitter.Parser` instance per call to avoid
68
+ internal state corruption across files in tree-sitter 0.26+.
69
+ """
70
+ lang = get_language(language_name)
71
+ if lang is None:
72
+ return None
73
+ return Parser(lang)
74
+
75
+
76
+ def parse(source_bytes: bytes, language_name: str) -> Tree | None:
77
+ """Parse *source_bytes* with the grammar for *language_name*.
78
+
79
+ A new :class:`~tree_sitter.Parser` is created for each call so that the
80
+ internal state of the C extension cannot carry over between files.
81
+
82
+ :param source_bytes: UTF-8-encoded source code.
83
+ :param language_name: Name as returned by :data:`codeecho.scanner.EXTENSION_TO_LANGUAGE`.
84
+ :returns: Parsed :class:`tree_sitter.Tree`, or ``None`` if the grammar is unavailable.
85
+ """
86
+ lang = get_language(language_name)
87
+ if lang is None:
88
+ _logger.warning("Skipping parse — no grammar for %s", language_name)
89
+ return None
90
+ fresh_parser = Parser(lang)
91
+ tree = fresh_parser.parse(source_bytes)
92
+ if tree.root_node.has_error:
93
+ _logger.debug("Parse errors detected in %s source.", language_name)
94
+ return tree
@@ -0,0 +1,6 @@
1
+ """
2
+ Reporter sub-package for codeecho: JSON and HTML output from session database results.
3
+
4
+ :author: Ron Webb
5
+ :since: 1.0.0
6
+ """