echolang 0.8.9__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.
Files changed (52) hide show
  1. echo/__init__.py +3 -0
  2. echo/__main__.py +4 -0
  3. echo/cli/__init__.py +3 -0
  4. echo/cli/main.py +470 -0
  5. echo/cli/test_runner.py +287 -0
  6. echo/core/__init__.py +1 -0
  7. echo/core/hashes.py +45 -0
  8. echo/core/jsonutil.py +74 -0
  9. echo/core/lists.py +103 -0
  10. echo/core/strings.py +152 -0
  11. echo/errors.py +113 -0
  12. echo/formatter.py +563 -0
  13. echo/frontend/__init__.py +5 -0
  14. echo/frontend/ast/__init__.py +1 -0
  15. echo/frontend/ast/nodes.py +456 -0
  16. echo/frontend/lexer.py +332 -0
  17. echo/frontend/parser.py +1376 -0
  18. echo/frontend/tokens.py +163 -0
  19. echo/linter.py +543 -0
  20. echo/modules/__init__.py +6 -0
  21. echo/modules/graph.py +88 -0
  22. echo/modules/loader.py +194 -0
  23. echo/modules/records.py +27 -0
  24. echo/modules/resolver.py +31 -0
  25. echo/runtime/__init__.py +3 -0
  26. echo/runtime/builtin_types.py +107 -0
  27. echo/runtime/builtins.py +1130 -0
  28. echo/runtime/class_registry.py +47 -0
  29. echo/runtime/context.py +182 -0
  30. echo/runtime/errors.py +17 -0
  31. echo/runtime/freeze.py +37 -0
  32. echo/runtime/functions.py +198 -0
  33. echo/runtime/host.py +85 -0
  34. echo/runtime/instances.py +9 -0
  35. echo/runtime/interpreter.py +1744 -0
  36. echo/runtime/operators.py +150 -0
  37. echo/runtime/testing.py +59 -0
  38. echo/runtime/values.py +589 -0
  39. echo/semantics/__init__.py +4 -0
  40. echo/semantics/analyzer.py +1814 -0
  41. echo/semantics/errors.py +3 -0
  42. echo/semantics/modules.py +15 -0
  43. echo/semantics/scope.py +67 -0
  44. echo/semantics/symbols.py +30 -0
  45. echo_cli.py +5 -0
  46. echolang-0.8.9.dist-info/METADATA +106 -0
  47. echolang-0.8.9.dist-info/RECORD +52 -0
  48. echolang-0.8.9.dist-info/WHEEL +5 -0
  49. echolang-0.8.9.dist-info/entry_points.txt +4 -0
  50. echolang-0.8.9.dist-info/licenses/LICENSE +21 -0
  51. echolang-0.8.9.dist-info/top_level.txt +3 -0
  52. main.py +9 -0
echo/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Echo programming language."""
2
+
3
+ __version__ = "0.8.9"
echo/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from echo.cli.main import main
2
+ import sys
3
+
4
+ raise SystemExit(main())
echo/cli/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from echo.cli.main import main
2
+
3
+ __all__ = ["main"]
echo/cli/main.py ADDED
@@ -0,0 +1,470 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from echo import __version__
8
+ from echo.errors import (
9
+ ArgumentError,
10
+ EchoError,
11
+ EchoExit,
12
+ EchoIndexError,
13
+ EchoNameError,
14
+ EchoTypeError,
15
+ LexError,
16
+ ModuleLoadError,
17
+ MutationError,
18
+ ParseError,
19
+ SemanticError,
20
+ SourceLocation,
21
+ format_diagnostic,
22
+ )
23
+ from echo.formatter import format_source
24
+ from echo.linter import LintFinding, lint_source
25
+ from echo.frontend.ast.nodes import ImportDeclaration, Program
26
+ from echo.frontend.lexer import Lexer
27
+ from echo.frontend.parser import Parser
28
+ from echo.frontend.tokens import TokenType
29
+ from echo.modules.loader import ModuleLoader
30
+ from echo.modules.records import Module
31
+ from echo.runtime.context import Environment
32
+ from echo.runtime.functions import EchoFunction
33
+ from echo.runtime.host import Host
34
+ from echo.runtime.interpreter import Interpreter
35
+ from echo.semantics.analyzer import SemanticAnalyzer
36
+ from echo.semantics.modules import ModuleSymbols
37
+ from echo.semantics.scope import Scope
38
+
39
+ try:
40
+ from rich.console import Console
41
+ from rich.panel import Panel
42
+ except ImportError:
43
+ Console = None
44
+ Panel = None
45
+
46
+
47
+ def run_source(source: str, filename: str = "<input>", *, plain: bool = True, host: Host | None = None) -> int:
48
+ try:
49
+ tokens = Lexer().tokenize(source, filename=filename)
50
+ program = Parser(tokens).parse()
51
+ SemanticAnalyzer().analyze(program)
52
+ Interpreter(host=host).execute(program)
53
+ return 0
54
+ except EchoExit as exc:
55
+ return exc.code
56
+ except EchoError as exc:
57
+ _print_error(exc, source, plain)
58
+ return 1
59
+
60
+
61
+ def check_file(source_path: str, plain: bool = False) -> int:
62
+ file_path = Path(source_path).expanduser().resolve()
63
+ if not file_path.exists() or not file_path.is_file():
64
+ _print_plain_error("Error", f"source file not found: {file_path}", plain)
65
+ return 1
66
+ source = file_path.read_text(encoding="utf-8")
67
+ try:
68
+ tokens = Lexer().tokenize(source, filename=str(file_path))
69
+ program = Parser(tokens).parse()
70
+ if _has_imports(program):
71
+ ModuleLoader().check(file_path)
72
+ else:
73
+ SemanticAnalyzer().analyze(program)
74
+ return 0
75
+ except EchoError as exc:
76
+ _print_error(exc, _error_source(exc, source), plain)
77
+ return 1
78
+
79
+
80
+ def run_file(source_path: str, plain: bool = False, host: Host | None = None) -> int:
81
+ file_path = Path(source_path).expanduser().resolve()
82
+ if not file_path.exists() or not file_path.is_file():
83
+ _print_plain_error("Error", f"source file not found: {file_path}", plain)
84
+ return 1
85
+ source = file_path.read_text(encoding="utf-8")
86
+ try:
87
+ tokens = Lexer().tokenize(source, filename=str(file_path))
88
+ program = Parser(tokens).parse()
89
+ if _has_imports(program):
90
+ ModuleLoader().load(file_path, host=host)
91
+ else:
92
+ SemanticAnalyzer().analyze(program)
93
+ Interpreter(host=host).execute(program)
94
+ return 0
95
+ except EchoExit as exc:
96
+ return exc.code
97
+ except EchoError as exc:
98
+ _print_error(exc, _error_source(exc, source), plain)
99
+ return 1
100
+
101
+
102
+ def _has_imports(program: Program) -> bool:
103
+ return any(isinstance(statement, ImportDeclaration) for statement in program.statements)
104
+
105
+
106
+ def _error_source(error: EchoError, fallback: str) -> str:
107
+ if error.location is None or error.location.filename is None:
108
+ return fallback
109
+ origin = Path(error.location.filename)
110
+ if not origin.is_file():
111
+ return fallback
112
+ return origin.read_text(encoding="utf-8")
113
+
114
+
115
+ def _category(error: EchoError) -> str:
116
+ if isinstance(error, LexError):
117
+ return "Syntax Error"
118
+ if isinstance(error, ParseError):
119
+ return "Syntax Error"
120
+ if isinstance(error, SemanticError):
121
+ return "Semantic Error"
122
+ if isinstance(error, EchoNameError):
123
+ return "Name Error"
124
+ if isinstance(error, EchoTypeError):
125
+ return "Type Error"
126
+ if isinstance(error, ArgumentError):
127
+ return "Argument Error"
128
+ if isinstance(error, EchoIndexError):
129
+ return "Index Error"
130
+ if isinstance(error, MutationError):
131
+ return "Mutation Error"
132
+ return "Execution Error"
133
+
134
+
135
+ def _print_error(error: EchoError, source: str, plain: bool) -> None:
136
+ help_text = error.help_text
137
+ error.help_text = None
138
+ message = format_diagnostic(error, source)
139
+ error.help_text = help_text
140
+ title = _category(error)
141
+ _print_plain_error(title, message, plain)
142
+ if help_text:
143
+ _print_plain_error("Hint", help_text, plain)
144
+
145
+
146
+ def _print_plain_error(title: str, message: str, plain: bool) -> None:
147
+ if not plain and Console is not None and Panel is not None:
148
+ Console().print(Panel(message, title=title, border_style="red", expand=False))
149
+ return
150
+ print(f"{title}: {message}")
151
+
152
+
153
+ def main(argv: list[str] | None = None) -> int:
154
+ raw = list(sys.argv[1:] if argv is None else argv)
155
+ if raw[:1] == ["check"]:
156
+ return _main_check(raw[1:])
157
+ if raw[:1] == ["test"]:
158
+ return _main_test(raw[1:])
159
+ if raw[:1] == ["fmt"]:
160
+ return _main_fmt(raw[1:])
161
+ if raw[:1] == ["lint"]:
162
+ return _main_lint(raw[1:])
163
+ parser = argparse.ArgumentParser(description="Run an Echo source file")
164
+ parser.add_argument("source", nargs="?", help="Path to .echo source file")
165
+ parser.add_argument("--plain", action="store_true", help="Disable Rich styling and use plain text output")
166
+ parser.add_argument("--version", action="store_true", help="Print the Echo version and exit")
167
+ if "--" in raw:
168
+ split_at = raw.index("--")
169
+ interpreter_argv, program_args = raw[:split_at], raw[split_at + 1 :]
170
+ args, unknown = parser.parse_known_args(interpreter_argv)
171
+ if unknown:
172
+ parser.error(f"unrecognized arguments: {' '.join(unknown)}")
173
+ else:
174
+ args, program_args = parser.parse_known_args(raw)
175
+
176
+ if args.version:
177
+ print(f"Echo {__version__}")
178
+ return 0
179
+ host = Host(args=list(program_args))
180
+ if not args.source:
181
+ return run_repl(plain=args.plain, host=host)
182
+ return run_file(args.source, plain=args.plain, host=host)
183
+
184
+
185
+ def run_repl(*, plain: bool = True, host: Host | None = None) -> int:
186
+ host = host or Host()
187
+ interpreter = Interpreter(host=host)
188
+ env = Environment()
189
+ session_scope = SemanticAnalyzer.module_scope(SourceLocation(1, 1, "<repl>"))
190
+ loader = ModuleLoader()
191
+ print(f"Echo {__version__}")
192
+ buffer: list[str] = []
193
+ while True:
194
+ prompt = "echo> " if not buffer else "... "
195
+ try:
196
+ line = input(prompt)
197
+ except EOFError:
198
+ print()
199
+ return 0
200
+ except KeyboardInterrupt:
201
+ print()
202
+ buffer.clear()
203
+ continue
204
+ if not buffer and not line.strip():
205
+ continue
206
+ buffer.append(line)
207
+ source = "\n".join(buffer)
208
+ if _repl_source_incomplete(source):
209
+ continue
210
+ buffer.clear()
211
+ try:
212
+ session_scope = _run_repl_snippet(source, interpreter, env, session_scope, loader)
213
+ except EchoExit as exc:
214
+ return exc.code
215
+ except EchoError as exc:
216
+ _print_error(exc, source, plain)
217
+ except KeyboardInterrupt:
218
+ print()
219
+ except Exception:
220
+ _print_plain_error("Execution Error", "unexpected error", plain)
221
+
222
+
223
+ def _run_repl_snippet(
224
+ source: str,
225
+ interpreter: Interpreter,
226
+ env: Environment,
227
+ session_scope: Scope,
228
+ loader: ModuleLoader,
229
+ ) -> Scope:
230
+ tokens = Lexer().tokenize(source, filename="<repl>")
231
+ program = Parser(tokens).parse()
232
+ snippet_scope = session_scope.copy()
233
+ dependencies = _repl_import_dependencies(program, loader)
234
+ SemanticAnalyzer().analyze(program, dependencies=dependencies, scope=snippet_scope)
235
+ _repl_bind_imports(program, loader, env, interpreter.host)
236
+ interpreter.execute(program, env)
237
+ return snippet_scope
238
+
239
+
240
+ def _repl_import_dependencies(program: Program, loader: ModuleLoader) -> dict[str, ModuleSymbols]:
241
+ if not _has_imports(program):
242
+ return {}
243
+ importer = Path.cwd() / "<repl>"
244
+ dependencies: dict[str, ModuleSymbols] = {}
245
+ for statement in program.statements:
246
+ if not isinstance(statement, ImportDeclaration) or statement.module in dependencies:
247
+ continue
248
+ path = loader.resolver.resolve(importer, statement.module)
249
+ module = loader.check(path)
250
+ dependencies[statement.module] = SemanticAnalyzer().collect_symbols(module.ast)
251
+ return dependencies
252
+
253
+
254
+ def _repl_bind_imports(program: Program, loader: ModuleLoader, env: Environment, host: Host) -> None:
255
+ if not _has_imports(program):
256
+ return
257
+ importer = Path.cwd() / "<repl>"
258
+ loaded: dict[str, Module] = {}
259
+ for statement in program.statements:
260
+ if not isinstance(statement, ImportDeclaration):
261
+ continue
262
+ if statement.module not in loaded:
263
+ path = loader.resolver.resolve(importer, statement.module)
264
+ loaded[statement.module] = loader.load(path, host=host)
265
+ dependency = loaded[statement.module]
266
+ if statement.name in dependency.class_exports:
267
+ continue
268
+ value = _repl_export_value(dependency, statement.name)
269
+ if isinstance(value, EchoFunction):
270
+ env.define_function(statement.name, value)
271
+ module_env = dependency.env
272
+ is_const = bool(module_env.const.get(statement.name, False)) if module_env is not None else False
273
+ env.define(statement.name, value, mutable=False, const=is_const)
274
+
275
+
276
+ def _repl_export_value(module: Module, name: str) -> object:
277
+ module_env = module.env
278
+ if module_env is None:
279
+ raise ModuleLoadError(
280
+ f"module '{module.path.name}' is not fully initialized",
281
+ code="E3005",
282
+ )
283
+ if name in module_env.values:
284
+ return module_env.values[name]
285
+ function = module_env.functions.get(name)
286
+ if function is not None:
287
+ return function
288
+ raise ModuleLoadError(
289
+ f"module '{module.path.name}' has no export '{name}'",
290
+ code="E3005",
291
+ )
292
+
293
+
294
+ def _repl_source_incomplete(source: str) -> bool:
295
+ try:
296
+ tokens = Lexer().tokenize(source, filename="<repl>")
297
+ except LexError as exc:
298
+ return "closing \"\"\"" in exc.message or "closing '''" in exc.message
299
+ except EchoError:
300
+ return False
301
+ braces = 0
302
+ parens = 0
303
+ brackets = 0
304
+ for token in tokens:
305
+ if token.type is TokenType.COMMENT:
306
+ continue
307
+ if token.type is TokenType.LEFT_BRACE:
308
+ braces += 1
309
+ elif token.type is TokenType.RIGHT_BRACE:
310
+ braces -= 1
311
+ if braces < 0:
312
+ return False
313
+ elif token.type is TokenType.LEFT_PAREN:
314
+ parens += 1
315
+ elif token.type is TokenType.RIGHT_PAREN:
316
+ parens -= 1
317
+ if parens < 0:
318
+ return False
319
+ elif token.type is TokenType.LEFT_BRACKET:
320
+ brackets += 1
321
+ elif token.type is TokenType.RIGHT_BRACKET:
322
+ brackets -= 1
323
+ if brackets < 0:
324
+ return False
325
+ return braces > 0 or parens > 0 or brackets > 0
326
+
327
+
328
+ def _main_check(argv: list[str]) -> int:
329
+ parser = argparse.ArgumentParser(prog="echo check", description="Analyze Echo source files without running them")
330
+ parser.add_argument("paths", nargs="*", help="Files or directories of .echo sources")
331
+ parser.add_argument("--plain", action="store_true", help="Disable Rich styling and use plain text output")
332
+ args = parser.parse_args(argv)
333
+ if not args.paths:
334
+ parser.print_help()
335
+ return 2
336
+ status = 0
337
+ for raw_path in args.paths:
338
+ collected = _collect_echo_files(raw_path, args.plain)
339
+ if isinstance(collected, int):
340
+ return collected
341
+ for file_path in collected:
342
+ code = check_file(str(file_path), plain=args.plain)
343
+ if code != 0:
344
+ status = code
345
+ return status
346
+
347
+
348
+ def format_file(source_path: str, *, check: bool = False, plain: bool = False) -> int:
349
+ file_path = Path(source_path).expanduser().resolve()
350
+ if not file_path.exists() or not file_path.is_file():
351
+ _print_plain_error("Error", f"source file not found: {file_path}", plain)
352
+ return 1
353
+ source = file_path.read_text(encoding="utf-8")
354
+ try:
355
+ formatted = format_source(source, filename=str(file_path))
356
+ except EchoError as exc:
357
+ _print_error(exc, _error_source(exc, source), plain)
358
+ return 1
359
+ if formatted == source:
360
+ return 0
361
+ if check:
362
+ print(file_path)
363
+ return 1
364
+ file_path.write_text(formatted, encoding="utf-8")
365
+ return 0
366
+
367
+
368
+ def _collect_echo_files(source_path: str, plain: bool) -> list[Path] | int:
369
+ path = Path(source_path).expanduser().resolve()
370
+ if path.is_file():
371
+ return [path]
372
+ if path.is_dir():
373
+ return sorted(item for item in path.rglob("*.echo") if item.is_file())
374
+ _print_plain_error("Error", f"source file not found: {path}", plain)
375
+ return 1
376
+
377
+
378
+ def _main_fmt(argv: list[str]) -> int:
379
+ parser = argparse.ArgumentParser(prog="echo fmt", description="Format Echo source files")
380
+ parser.add_argument("paths", nargs="*", help="Files or directories of .echo sources")
381
+ parser.add_argument("--check", action="store_true", help="Exit 1 if any file would change")
382
+ parser.add_argument("--plain", action="store_true", help="Disable Rich styling and use plain text output")
383
+ args = parser.parse_args(argv)
384
+ if not args.paths:
385
+ parser.print_help()
386
+ return 2
387
+ status = 0
388
+ for raw_path in args.paths:
389
+ collected = _collect_echo_files(raw_path, args.plain)
390
+ if isinstance(collected, int):
391
+ return collected
392
+ for file_path in collected:
393
+ code = format_file(str(file_path), check=args.check, plain=args.plain)
394
+ if code != 0:
395
+ status = code
396
+ return status
397
+
398
+
399
+ def lint_file(source_path: str, *, plain: bool = False) -> tuple[int, list[LintFinding]]:
400
+ file_path = Path(source_path).expanduser().resolve()
401
+ if not file_path.exists() or not file_path.is_file():
402
+ _print_plain_error("Error", f"source file not found: {file_path}", plain)
403
+ return 1, []
404
+ source = file_path.read_text(encoding="utf-8")
405
+ try:
406
+ findings = lint_source(source, filename=str(file_path))
407
+ except EchoError as exc:
408
+ _print_error(exc, _error_source(exc, source), plain)
409
+ return 1, []
410
+ return (1 if findings else 0), findings
411
+
412
+
413
+ def _main_lint(argv: list[str]) -> int:
414
+ parser = argparse.ArgumentParser(prog="echo lint", description="Lint Echo source files for style and convention")
415
+ parser.add_argument("paths", nargs="*", help="Files or directories of .echo sources")
416
+ parser.add_argument("--plain", action="store_true", help="Disable Rich styling and use plain text output")
417
+ args = parser.parse_args(argv)
418
+ if not args.paths:
419
+ parser.print_help()
420
+ return 2
421
+ status = 0
422
+ for raw_path in args.paths:
423
+ collected = _collect_echo_files(raw_path, args.plain)
424
+ if isinstance(collected, int):
425
+ return collected
426
+ for file_path in collected:
427
+ code, findings = lint_file(str(file_path), plain=args.plain)
428
+ for finding in findings:
429
+ print(finding.format())
430
+ if code != 0:
431
+ status = code
432
+ return status
433
+
434
+
435
+ def _main_test(argv: list[str]) -> int:
436
+ parser = argparse.ArgumentParser(prog="echo test", description="Run Echo tests")
437
+ parser.add_argument("paths", nargs="*", help="Test files or directories of *_test.echo files")
438
+ parser.add_argument(
439
+ "-run",
440
+ "--run",
441
+ metavar="PATTERN",
442
+ dest="run",
443
+ help="Run only testXxx units whose names match glob PATTERN (e.g. testAdd or *Add*)",
444
+ )
445
+ parser.add_argument(
446
+ "--json",
447
+ action="store_true",
448
+ help="Write a machine-readable JSON report to stdout instead of the human summary",
449
+ )
450
+ parser.add_argument("--plain", action="store_true", help="Disable Rich styling and use plain text output")
451
+ args = parser.parse_args(argv)
452
+ if not args.paths:
453
+ parser.print_help()
454
+ return 2
455
+ from echo.cli.test_runner import print_json_report, print_summary, run_tests
456
+
457
+ code, results = run_tests(args.paths, plain=args.plain, run=args.run, json_output=args.json)
458
+ if args.json:
459
+ if not results and code != 0:
460
+ return code
461
+ print_json_report(results)
462
+ return code
463
+ if not results and code != 0:
464
+ return code
465
+ print_summary(results)
466
+ return code
467
+
468
+
469
+ if __name__ == "__main__":
470
+ raise SystemExit(main())