Commit ec4a018f authored by Roman Alifanov's avatar Roman Alifanov

Add compile-time method validation

parent 2aab914d
...@@ -907,7 +907,35 @@ Error: Missing closing brace ...@@ -907,7 +907,35 @@ Error: Missing closing brace
--> main.ct:15:1 --> main.ct:15:1
``` ```
Показывает все ошибки сразу. ### Проверка методов
Компилятор проверяет существование методов при компиляции:
```
Error: Unknown method 'nonexistent' for type 'array'. Available: filter, get, join, len, map, pop, push, set, shift, slice
--> main.ct:5:1
Error: Unknown method 'badMethod' for type 'fs'. Available: append, exists, list, mkdir, open, read, remove, write
--> main.ct:10:8
```
Проверяются методы для:
- **Массивов**`filter`, `get`, `join`, `len`, `map`, `pop`, `push`, `set`, `shift`, `slice`
- **Словарей**`del`, `get`, `has`, `keys`, `set`
- **Строк**`charAt`, `contains`, `ends`, `index`, `len`, `lower`, `replace`, `split`, `starts`, `substr`, `trim`, `upper`
- **Файловых дескрипторов**`close`, `read`, `readline`, `write`, `writeln`
- **Stdlib namespaces**:
- `fs``append`, `exists`, `list`, `mkdir`, `open`, `read`, `remove`, `write`
- `http``delete`, `get`, `post`, `put`
- `json``parse`, `stringify`
- `logger``debug`, `error`, `info`, `warn`
- `regex``extract`, `match`
- `args``count`, `get`
- `shell``capture`, `exec`, `source`
- `time``ms`, `now`
- `math``abs`, `add`, `div`, `max`, `min`, `mod`, `mul`, `sub`
Показывает все ошибки сразу (без дубликатов).
--- ---
......
...@@ -17,6 +17,7 @@ ...@@ -17,6 +17,7 @@
- **Error handling**`try/except/finally/throw/defer` - **Error handling**`try/except/finally/throw/defer`
- **String interpolation**`"Hello, {name}!"` - **String interpolation**`"Hello, {name}!"`
- **@awk functions** — compile to AWK for ~300x speedup on string/numeric operations - **@awk functions** — compile to AWK for ~300x speedup on string/numeric operations
- **Compile-time method validation** — catches unknown methods before runtime
- **Optimized output** - no unnecessary subshells, inlined methods - **Optimized output** - no unnecessary subshells, inlined methods
## Installation ## Installation
......
...@@ -17,6 +17,7 @@ ...@@ -17,6 +17,7 @@
- **Обработка ошибок**`try/except/finally/throw/defer` - **Обработка ошибок**`try/except/finally/throw/defer`
- **Строковая интерполяция**`"Привет, {name}!"` - **Строковая интерполяция**`"Привет, {name}!"`
- **@awk функции** — компиляция в AWK для ускорения ~300x на строковых/числовых операциях - **@awk функции** — компиляция в AWK для ускорения ~300x на строковых/числовых операциях
- **Проверка методов при компиляции** — выявление несуществующих методов до запуска
- **Оптимизированный вывод** — без лишних subshell, инлайнинг методов - **Оптимизированный вывод** — без лишних subshell, инлайнинг методов
## Установка ## Установка
......
...@@ -30,10 +30,68 @@ FILE_HANDLE_METHODS = { ...@@ -30,10 +30,68 @@ FILE_HANDLE_METHODS = {
BUILTIN_NAMESPACES = {"fs", "http", "json", "logger", "regex", "args", "shell", "time", "math"} BUILTIN_NAMESPACES = {"fs", "http", "json", "logger", "regex", "args", "shell", "time", "math"}
BUILTIN_FUNCS = {"print", "exit", "len", "range", "ngrep", "is_number", "is_empty", "chr", "ord", "assert", "assert_eq"} BUILTIN_FUNCS = {"print", "exit", "len", "range", "ngrep", "is_number", "is_empty", "chr", "ord", "assert", "assert_eq"}
FS_METHODS = {"read", "write", "append", "exists", "remove", "mkdir", "list", "open"}
HTTP_METHODS = {"get", "post", "put", "delete"}
JSON_METHODS = {"parse", "stringify"}
LOGGER_METHODS = {"info", "warn", "error", "debug"}
REGEX_METHODS = {"match", "extract"}
ARGS_METHODS = {"count", "get"}
SHELL_METHODS = {"exec", "capture", "source"}
TIME_METHODS = {"now", "ms"}
MATH_METHODS = {"add", "sub", "mul", "div", "mod", "min", "max", "abs"}
NAMESPACE_METHODS = {
"fs": FS_METHODS,
"http": HTTP_METHODS,
"json": JSON_METHODS,
"logger": LOGGER_METHODS,
"regex": REGEX_METHODS,
"args": ARGS_METHODS,
"shell": SHELL_METHODS,
"time": TIME_METHODS,
"math": MATH_METHODS,
}
class DispatchMixin: class DispatchMixin:
"""Mixin for method dispatch and assignment.""" """Mixin for method dispatch and assignment."""
def _report_unknown_method(self, obj_type: str, method: str, available: set, location=None):
"""Report unknown method error."""
from .errors import CompileError
available_str = ", ".join(sorted(available))
msg = f"Unknown method '{method}' for type '{obj_type}'. Available: {available_str}"
filename = location.filename if location else "<unknown>"
line = location.line if location else 0
col = location.column if location else 0
self.errors.add(CompileError(msg, filename, line, col))
def _validate_namespace_method(self, namespace: str, method: str, location=None) -> bool:
"""Validate that method exists for namespace. Returns True if valid."""
if namespace not in NAMESPACE_METHODS:
return True
methods = NAMESPACE_METHODS[namespace]
if method not in methods:
self._report_unknown_method(namespace, method, methods, location)
return False
return True
def _validate_type_method(self, var_type: str, method: str, location=None) -> bool:
"""Validate that method exists for variable type. Returns True if valid."""
type_methods = {
"array": set(ARR_METHODS.keys()),
"dict": set(DICT_METHODS.keys()),
"string": set(STR_METHODS.keys()),
"file_handle": set(FILE_HANDLE_METHODS.keys()),
}
if var_type not in type_methods:
return True
available = type_methods[var_type]
if method not in available:
self._report_unknown_method(var_type, method, available, location)
return False
return True
def _emit_array_assign(self, target: str): def _emit_array_assign(self, target: str):
"""Emit array assignment from __CT_RET.""" """Emit array assignment from __CT_RET."""
if self.in_function and target not in self.local_vars and target not in self.global_vars: if self.in_function and target not in self.local_vars and target not in self.global_vars:
...@@ -176,6 +234,7 @@ class DispatchMixin: ...@@ -176,6 +234,7 @@ class DispatchMixin:
"""Generate method call assignment. Returns True if handled.""" """Generate method call assignment. Returns True if handled."""
callee = stmt.value.callee callee = stmt.value.callee
method = callee.member if isinstance(callee, MemberAccess) else None method = callee.member if isinstance(callee, MemberAccess) else None
location = getattr(stmt.value, 'location', None) or getattr(callee, 'location', None)
if method in ("map", "filter"): if method in ("map", "filter"):
args = [self.generate_expr(arg) for arg in stmt.value.arguments if not isinstance(arg, Lambda)] args = [self.generate_expr(arg) for arg in stmt.value.arguments if not isinstance(arg, Lambda)]
...@@ -189,32 +248,34 @@ class DispatchMixin: ...@@ -189,32 +248,34 @@ class DispatchMixin:
self.emit_var_assign(target, '$__CT_RET') self.emit_var_assign(target, '$__CT_RET')
return True return True
# Handle this.field.method() - field holds an object reference
if isinstance(callee.object, MemberAccess) and isinstance(callee.object.object, ThisExpr): if isinstance(callee.object, MemberAccess) and isinstance(callee.object.object, ThisExpr):
field_name = callee.object.member field_name = callee.object.member
method = callee.member method = callee.member
args_str = " ".join([f'"{a}"' for a in args]) args_str = " ".join([f'"{a}"' for a in args])
# Check field type
field_type = self.class_field_types.get((self.current_class, field_name)) if self.current_class else None field_type = self.class_field_types.get((self.current_class, field_name)) if self.current_class else None
if field_type == "array" and method in ARR_METHODS: if field_type == "array":
if method in ARR_METHODS:
arr_name = f'"${{this}}_{field_name}"' arr_name = f'"${{this}}_{field_name}"'
self.emit(f'{ARR_METHODS[method]} {arr_name} {args_str} >/dev/null'.strip()) self.emit(f'{ARR_METHODS[method]} {arr_name} {args_str} >/dev/null'.strip())
self.emit_var_assign(target, '$__CT_RET') self.emit_var_assign(target, '$__CT_RET')
return True return True
if field_type == "dict" and method in DICT_METHODS: else:
self._validate_type_method("array", method, location)
if field_type == "dict":
if method in DICT_METHODS:
dict_ref = f'"${{__CT_OBJ[\\"$this.{field_name}\\"]}}"' dict_ref = f'"${{__CT_OBJ[\\"$this.{field_name}\\"]}}"'
self.emit(f'{DICT_METHODS[method]} {dict_ref} {args_str} >/dev/null'.strip()) self.emit(f'{DICT_METHODS[method]} {dict_ref} {args_str} >/dev/null'.strip())
self.emit_var_assign(target, '$__CT_RET') self.emit_var_assign(target, '$__CT_RET')
return True return True
else:
self._validate_type_method("dict", method, location)
if field_type == "object": if field_type == "object":
# Object field - use dynamic dispatch
obj_ref = f'${{__CT_OBJ["$this.{field_name}"]}}' obj_ref = f'${{__CT_OBJ["$this.{field_name}"]}}'
self.emit(f'__ct_call_method "{obj_ref}" "{method}" {args_str} >/dev/null') self.emit(f'__ct_call_method "{obj_ref}" "{method}" {args_str} >/dev/null')
self.emit_var_assign(target, '$__CT_RET') self.emit_var_assign(target, '$__CT_RET')
return True return True
# Scalar field - fall through to default handling
return False return False
if isinstance(callee.object, Identifier): if isinstance(callee.object, Identifier):
...@@ -222,11 +283,13 @@ class DispatchMixin: ...@@ -222,11 +283,13 @@ class DispatchMixin:
method = callee.member method = callee.member
if obj_name in BUILTIN_NAMESPACES: if obj_name in BUILTIN_NAMESPACES:
self._validate_namespace_method(obj_name, method, location)
return False return False
args_str = " ".join([f'"{a}"' for a in args]) args_str = " ".join([f'"{a}"' for a in args])
if obj_name in self.array_vars and method in ARR_METHODS: if obj_name in self.array_vars:
if method in ARR_METHODS:
func_name = ARR_METHODS[method] func_name = ARR_METHODS[method]
if method == "push" and len(args) == 1: if method == "push" and len(args) == 1:
self.emit(f'{obj_name}+=("{args[0]}")') self.emit(f'{obj_name}+=("{args[0]}")')
...@@ -244,18 +307,26 @@ class DispatchMixin: ...@@ -244,18 +307,26 @@ class DispatchMixin:
self.emit(f'{func_name} "{obj_name}" {args_str} >/dev/null'.replace(' ', ' ')) self.emit(f'{func_name} "{obj_name}" {args_str} >/dev/null'.replace(' ', ' '))
self.emit_var_assign(target, '$__CT_RET') self.emit_var_assign(target, '$__CT_RET')
return True return True
else:
self._validate_type_method("array", method, location)
if obj_name in self.dict_vars and method in DICT_METHODS: if obj_name in self.dict_vars:
if method in DICT_METHODS:
func_name = DICT_METHODS[method] func_name = DICT_METHODS[method]
self.emit(f'{func_name} "{obj_name}" {args_str} >/dev/null'.replace(' ', ' ')) self.emit(f'{func_name} "{obj_name}" {args_str} >/dev/null'.replace(' ', ' '))
self.emit_var_assign(target, '$__CT_RET') self.emit_var_assign(target, '$__CT_RET')
return True return True
else:
self._validate_type_method("dict", method, location)
if obj_name in self.file_handle_vars and method in FILE_HANDLE_METHODS: if obj_name in self.file_handle_vars:
if method in FILE_HANDLE_METHODS:
func_name = FILE_HANDLE_METHODS[method] func_name = FILE_HANDLE_METHODS[method]
self.emit(f'{func_name} "${obj_name}" {args_str} >/dev/null'.replace(' ', ' ')) self.emit(f'{func_name} "${obj_name}" {args_str} >/dev/null'.replace(' ', ' '))
self.emit_var_assign(target, '$__CT_RET') self.emit_var_assign(target, '$__CT_RET')
return True return True
else:
self._validate_type_method("file_handle", method, location)
if method in STR_METHODS: if method in STR_METHODS:
obj = self.generate_expr(callee.object) obj = self.generate_expr(callee.object)
...@@ -507,6 +578,7 @@ class DispatchMixin: ...@@ -507,6 +578,7 @@ class DispatchMixin:
var_name = expr.callee.object.name var_name = expr.callee.object.name
method = expr.callee.member method = expr.callee.member
args = [self.generate_expr(arg) for arg in expr.arguments] args = [self.generate_expr(arg) for arg in expr.arguments]
location = getattr(expr, 'location', None) or getattr(expr.callee, 'location', None)
if var_name in self.array_vars: if var_name in self.array_vars:
if method == "push": if method == "push":
...@@ -519,10 +591,13 @@ class DispatchMixin: ...@@ -519,10 +591,13 @@ class DispatchMixin:
elif method == "set" and len(args) >= 2: elif method == "set" and len(args) >= 2:
self.emit(f'{var_name}[{args[0]}]="{args[1]}"') self.emit(f'{var_name}[{args[0]}]="{args[1]}"')
return True return True
elif method in ("len", "shift", "join", "get", "slice"): elif method in ("len", "shift", "join", "get", "slice", "map", "filter"):
args_str = " ".join([f'"{a}"' for a in args]) args_str = " ".join([f'"{a}"' for a in args])
self.emit(f'__ct_arr_{method} "{var_name}" {args_str} >/dev/null'.strip()) self.emit(f'__ct_arr_{method} "{var_name}" {args_str} >/dev/null'.strip())
return True return True
else:
self._validate_type_method("array", method, location)
return False
if var_name in self.dict_vars: if var_name in self.dict_vars:
if method == "set" and len(args) >= 2: if method == "set" and len(args) >= 2:
...@@ -535,11 +610,18 @@ class DispatchMixin: ...@@ -535,11 +610,18 @@ class DispatchMixin:
args_str = " ".join([f'"{a}"' for a in args]) args_str = " ".join([f'"{a}"' for a in args])
self.emit(f'__ct_dict_{method} "{var_name}" {args_str} >/dev/null'.strip()) self.emit(f'__ct_dict_{method} "{var_name}" {args_str} >/dev/null'.strip())
return True return True
else:
self._validate_type_method("dict", method, location)
return False
if var_name in self.file_handle_vars and method in FILE_HANDLE_METHODS: if var_name in self.file_handle_vars:
if method in FILE_HANDLE_METHODS:
args_str = " ".join([f'"{a}"' for a in args]) args_str = " ".join([f'"{a}"' for a in args])
self.emit(f'{FILE_HANDLE_METHODS[method]} "${var_name}" {args_str}'.strip()) self.emit(f'{FILE_HANDLE_METHODS[method]} "${var_name}" {args_str}'.strip())
return True return True
else:
self._validate_type_method("file_handle", method, location)
return False
return False return False
...@@ -547,12 +629,13 @@ class DispatchMixin: ...@@ -547,12 +629,13 @@ class DispatchMixin:
callee = expr.callee callee = expr.callee
args = [self.generate_expr(arg) for arg in expr.arguments] args = [self.generate_expr(arg) for arg in expr.arguments]
args_str = " ".join([f'"{a}"' for a in args]) args_str = " ".join([f'"{a}"' for a in args])
location = getattr(expr, 'location', None) or getattr(callee, 'location', None)
if isinstance(callee, Identifier): if isinstance(callee, Identifier):
return self._generate_builtin_call(callee.name, args, args_str) return self._generate_builtin_call(callee.name, args, args_str)
if isinstance(callee, MemberAccess): if isinstance(callee, MemberAccess):
return self._generate_member_call(callee, args, args_str) return self._generate_member_call(callee, args, args_str, location)
return "" return ""
...@@ -585,7 +668,7 @@ class DispatchMixin: ...@@ -585,7 +668,7 @@ class DispatchMixin:
else: else:
return f'{name} {args_str}' return f'{name} {args_str}'
def _generate_member_call(self, callee: MemberAccess, args: list, args_str: str) -> str: def _generate_member_call(self, callee: MemberAccess, args: list, args_str: str, location=None) -> str:
"""Generate member access call.""" """Generate member access call."""
obj = self.generate_expr(callee.object) obj = self.generate_expr(callee.object)
method = callee.member method = callee.member
...@@ -597,37 +680,52 @@ class DispatchMixin: ...@@ -597,37 +680,52 @@ class DispatchMixin:
return f'__ct_class_{self.current_class}_{method} "$this" {args_str}' return f'__ct_class_{self.current_class}_{method} "$this" {args_str}'
if isinstance(callee.object, Identifier): if isinstance(callee.object, Identifier):
result = self._generate_stdlib_call(callee.object.name, method, args, args_str) result = self._generate_stdlib_call(callee.object.name, method, args, args_str, location)
if result: if result:
return result return result
if isinstance(callee.object, MemberAccess) and isinstance(callee.object.object, ThisExpr): if isinstance(callee.object, MemberAccess) and isinstance(callee.object.object, ThisExpr):
result = self._generate_this_field_call(callee, args_str) result = self._generate_this_field_call(callee, args_str, location)
if result: if result:
return result return result
if isinstance(callee.object, MemberAccess) and isinstance(callee.object.object, Identifier): if isinstance(callee.object, MemberAccess) and isinstance(callee.object.object, Identifier):
result = self._generate_var_field_call(callee, args_str) result = self._generate_var_field_call(callee, args_str, location)
if result: if result:
return result return result
var_name = callee.object.name if isinstance(callee.object, Identifier) else None var_name = callee.object.name if isinstance(callee.object, Identifier) else None
if var_name and var_name in self.array_vars and method in ARR_METHODS: if var_name and var_name in self.array_vars:
if method in ARR_METHODS:
if method == "push" and len(args) == 1: if method == "push" and len(args) == 1:
return f'{var_name}+=("{args[0]}")' return f'{var_name}+=("{args[0]}")'
return f'{ARR_METHODS[method]} "{var_name}" {args_str}'.strip() return f'{ARR_METHODS[method]} "{var_name}" {args_str}'.strip()
else:
self._validate_type_method("array", method, location)
if var_name and var_name in self.dict_vars and method in DICT_METHODS: if var_name and var_name in self.dict_vars:
if method in DICT_METHODS:
return f'{DICT_METHODS[method]} "{var_name}" {args_str}'.strip() return f'{DICT_METHODS[method]} "{var_name}" {args_str}'.strip()
else:
self._validate_type_method("dict", method, location)
if var_name and var_name in self.file_handle_vars:
if method in FILE_HANDLE_METHODS:
return f'{FILE_HANDLE_METHODS[method]} "${var_name}" {args_str}'.strip()
else:
self._validate_type_method("file_handle", method, location)
if method in STR_METHODS: if method in STR_METHODS:
return f'{STR_METHODS[method]} "{obj}" {args_str}'.strip() return f'{STR_METHODS[method]} "{obj}" {args_str}'.strip()
return f'__ct_call_method "{obj}" "{method}" {args_str}' return f'__ct_call_method "{obj}" "{method}" {args_str}'
def _generate_stdlib_call(self, obj_name: str, method: str, args: list, args_str: str) -> str: def _generate_stdlib_call(self, obj_name: str, method: str, args: list, args_str: str, location=None) -> str:
"""Generate stdlib call. Returns None if not stdlib.""" """Generate stdlib call. Returns None if not stdlib."""
if obj_name in BUILTIN_NAMESPACES:
self._validate_namespace_method(obj_name, method, location)
if obj_name == "http": if obj_name == "http":
return f'__ct_http_{method} {args_str}' return f'__ct_http_{method} {args_str}'
elif obj_name == "fs": elif obj_name == "fs":
...@@ -659,31 +757,34 @@ class DispatchMixin: ...@@ -659,31 +757,34 @@ class DispatchMixin:
return f'__ct_math_{method} {args_str}' return f'__ct_math_{method} {args_str}'
return None return None
def _generate_this_field_call(self, callee: MemberAccess, args_str: str) -> str: def _generate_this_field_call(self, callee: MemberAccess, args_str: str, location=None) -> str:
"""Generate this.field.method() call.""" """Generate this.field.method() call."""
field_name = callee.object.member field_name = callee.object.member
method = callee.member method = callee.member
# Check field type if known
field_type = self.class_field_types.get((self.current_class, field_name)) if self.current_class else None field_type = self.class_field_types.get((self.current_class, field_name)) if self.current_class else None
if field_type == "array" and method in ARR_METHODS: if field_type == "array":
if method in ARR_METHODS:
arr_name = f'"${{this}}_{field_name}"' arr_name = f'"${{this}}_{field_name}"'
return f'{ARR_METHODS[method]} {arr_name} {args_str}'.strip() return f'{ARR_METHODS[method]} {arr_name} {args_str}'.strip()
else:
self._validate_type_method("array", method, location)
if field_type == "dict" and method in DICT_METHODS: if field_type == "dict":
if method in DICT_METHODS:
dict_ref = f'"${{__CT_OBJ[\\"$this.{field_name}\\"]}}"' dict_ref = f'"${{__CT_OBJ[\\"$this.{field_name}\\"]}}"'
return f'{DICT_METHODS[method]} {dict_ref} {args_str}'.strip() return f'{DICT_METHODS[method]} {dict_ref} {args_str}'.strip()
else:
self._validate_type_method("dict", method, location)
if field_type == "object": if field_type == "object":
# Object field - use dynamic dispatch
obj_ref = f'${{__CT_OBJ["$this.{field_name}"]}}' obj_ref = f'${{__CT_OBJ["$this.{field_name}"]}}'
return f'__ct_call_method "{obj_ref}" "{method}" {args_str}' return f'__ct_call_method "{obj_ref}" "{method}" {args_str}'
# Scalar/unknown field - return None to fall through to default handling
return None return None
def _generate_var_field_call(self, callee: MemberAccess, args_str: str) -> str: def _generate_var_field_call(self, callee: MemberAccess, args_str: str, location=None) -> str:
"""Generate var.field.method() call.""" """Generate var.field.method() call."""
var_name = callee.object.object.name var_name = callee.object.object.name
field_name = callee.object.member field_name = callee.object.member
...@@ -702,16 +803,20 @@ class DispatchMixin: ...@@ -702,16 +803,20 @@ class DispatchMixin:
for cls in self.classes for cls in self.classes
) )
if is_array_field and method in ARR_METHODS: if is_array_field:
if method in ARR_METHODS:
return f'{ARR_METHODS[method]} "${{{var_name}}}_{field_name}" {args_str}'.strip() return f'{ARR_METHODS[method]} "${{{var_name}}}_{field_name}" {args_str}'.strip()
else:
self._validate_type_method("array", method, location)
if is_dict_field and method in DICT_METHODS: if is_dict_field:
if method in DICT_METHODS:
return f'{DICT_METHODS[method]} "${{{var_name}}}_{field_name}" {args_str}'.strip() return f'{DICT_METHODS[method]} "${{{var_name}}}_{field_name}" {args_str}'.strip()
else:
self._validate_type_method("dict", method, location)
if is_object_field: if is_object_field:
# Object field - use dynamic dispatch
obj_ref = f'${{__CT_OBJ["${{{var_name}}}.{field_name}"]}}' obj_ref = f'${{__CT_OBJ["${{{var_name}}}.{field_name}"]}}'
return f'__ct_call_method "{obj_ref}" "{method}" {args_str}' return f'__ct_call_method "{obj_ref}" "{method}" {args_str}'
# Scalar/unknown field - return None to fall through to default handling
return None return None
...@@ -20,11 +20,21 @@ class CompileError: ...@@ -20,11 +20,21 @@ class CompileError:
class ErrorCollector: class ErrorCollector:
def __init__ (self): def __init__ (self):
self.errors: List[CompileError] = [] self.errors: List[CompileError] = []
self._seen: set = set()
def _error_key(self, msg: str, filename: str, line: int, column: int) -> tuple:
return (msg, filename, line, column)
def add (self, error: CompileError): def add (self, error: CompileError):
self.errors.append (error) key = self._error_key(error.message, error.filename, error.line, error.column)
if key not in self._seen:
self._seen.add(key)
self.errors.append(error)
def add_error (self, message: str, filename: str, line: int, column: int, hint: str = None): def add_error (self, message: str, filename: str, line: int, column: int, hint: str = None):
key = self._error_key(message, filename, line, column)
if key not in self._seen:
self._seen.add(key)
self.errors.append (CompileError ( self.errors.append (CompileError (
message=message, message=message,
filename=filename, filename=filename,
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment