Skip to content

Commit ea3c873

Browse files
committed
Refactor error handling in parser and semantic analyzer
- Introduced a new error handling system with specific error classes for compile-time errors. - Replaced generic error messages with more descriptive and structured error types. - Updated the parser to use the new error classes for expected token errors, invalid expressions, and semantic errors. - Enhanced the semantic analyzer to log errors with source location and context. - Improved error reporting in tests to reflect the new error structure.
1 parent c815c3d commit ea3c873

14 files changed

Lines changed: 608 additions & 273 deletions

File tree

docs/changelog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ firescript follows [Semantic Versioning](https://semver.org/). This makes it eas
1414

1515
### Compiler improvements
1616
- Standard library modules can now import sibling modules using short relative paths (e.g., `import tuple.Tuple;`).
17+
- Compiler diagnostics are now unified under structured compile-time error objects across parser, semantic analysis, code generation, and `lint_text(...)`; this improves consistency of reported locations and diagnostics integrations (for example LSP).
1718
- Bug fixes
1819
- Fixed `for-in` loops and `length()` calls on array function parameters.
1920
- Fixed error caret positions for indented code.

firescript/codegen/base.py

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from enums import NodeTypes
22
from parser import ASTNode, get_line_and_coumn_from_index, get_line
3+
from errors import CompileTimeError, CodegenError
34
from utils.type_utils import is_copyable, is_owned, register_class
45
from typing import Optional
56
import logging
@@ -35,6 +36,7 @@ def __init__(self, ast: ASTNode, source_file: Optional[str] = None):
3536
self.ast = ast
3637
self.source_file = source_file
3738
self.source_code: Optional[str] = None
39+
self.errors: list[CompileTimeError] = []
3840
self.symbol_table: SymbolTable = {}
3941
# Fixed-size array lengths by variable name
4042
self.array_lengths: dict[str, int] = {}
@@ -142,7 +144,12 @@ def __init__(self, ast: ASTNode, source_file: Optional[str] = None):
142144
def error(self, text: str, node: Optional[ASTNode] = None):
143145
"""Report a compilation error with source location"""
144146
if node is None:
145-
logging.error(text)
147+
err = CodegenError(
148+
message=text,
149+
source_file=self.source_file,
150+
)
151+
self.errors.append(err)
152+
logging.error(err.to_log_string())
146153
return
147154

148155
# Get source file and source code for this node
@@ -160,22 +167,34 @@ def error(self, text: str, node: Optional[ASTNode] = None):
160167
node_source_file = self.source_file
161168

162169
if node_source_file is None or node_source_code is None:
163-
logging.error(text)
170+
err = CodegenError(
171+
message=text,
172+
source_file=self.source_file,
173+
)
174+
self.errors.append(err)
175+
logging.error(err.to_log_string())
164176
return
165177

166178
try:
167179
line_num, column_num = get_line_and_coumn_from_index(node_source_code, node.index)
168180
line_text = get_line(node_source_code, line_num)
169-
logging.error(
170-
text
171-
+ f"\n> {line_text.rstrip()}\n"
172-
+ " " * (column_num + 1)
173-
+ "^"
174-
+ f"\n({node_source_file}:{line_num}:{column_num})"
181+
err = CodegenError(
182+
message=text,
183+
source_file=node_source_file,
184+
line=line_num,
185+
column=column_num,
186+
snippet=line_text,
175187
)
188+
self.errors.append(err)
189+
logging.error(err.to_log_string())
176190
except (IndexError, ValueError):
177191
# Node index is out of range - just show the error without source location
178-
logging.error(text)
192+
err = CodegenError(
193+
message=text,
194+
source_file=node_source_file,
195+
)
196+
self.errors.append(err)
197+
logging.error(err.to_log_string())
179198

180199
def _normalize_source_path(self, source_path: Optional[str]) -> Optional[str]:
181200
"""Normalize a source path for directive/source-map lookups."""

firescript/errors.py

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
from __future__ import annotations
2+
3+
from enum import Enum
4+
from typing import Optional
5+
6+
7+
class ErrorCategory(str, Enum):
8+
PARSER = "parser"
9+
SEMANTIC = "semantic"
10+
CODEGEN = "codegen"
11+
IMPORT = "import"
12+
13+
14+
class CompileTimeError:
15+
code: str = "FS-COMP-0000"
16+
category: ErrorCategory = ErrorCategory.PARSER
17+
message_template: str = "{message}"
18+
19+
def __init__(
20+
self,
21+
*,
22+
source_file: Optional[str] = None,
23+
line: int = 0,
24+
column: int = 0,
25+
snippet: Optional[str] = None,
26+
**context: object,
27+
):
28+
self.source_file = source_file
29+
self.line = line
30+
self.column = column
31+
self.snippet = snippet
32+
self.message = self._render(context)
33+
34+
def _render(self, context: dict[str, object]) -> str:
35+
try:
36+
return self.message_template.format(**context)
37+
except Exception:
38+
# Fallback to avoid formatter crashes hiding the actual compiler error.
39+
return self.message_template
40+
41+
def to_log_string(self) -> str:
42+
header = self.message
43+
if (
44+
self.source_file
45+
and self.line > 0
46+
and self.column >= 0
47+
and self.snippet is not None
48+
):
49+
return (
50+
header
51+
+ f"\n> {self.snippet.rstrip()}\n"
52+
+ " " * (self.column + 1)
53+
+ "^"
54+
+ f"\n({self.source_file}:{self.line}:{self.column})"
55+
)
56+
return header
57+
58+
59+
class ParserError(CompileTimeError):
60+
code = "FS-PARSE-0001"
61+
category = ErrorCategory.PARSER
62+
message_template = "{message}"
63+
64+
65+
class UnexpectedTokenError(CompileTimeError):
66+
code = "FS-PARSE-0002"
67+
category = ErrorCategory.PARSER
68+
message_template = "Expected {expected} but got {actual}"
69+
70+
71+
class UndefinedIdentifierError(CompileTimeError):
72+
code = "FS-PARSE-0003"
73+
category = ErrorCategory.PARSER
74+
message_template = "Variable '{identifier}' not defined"
75+
76+
77+
class SemanticError(CompileTimeError):
78+
code = "FS-SEM-0001"
79+
category = ErrorCategory.SEMANTIC
80+
message_template = "{message}"
81+
82+
83+
class TypeError(CompileTimeError):
84+
code = "FS-SEM-0002"
85+
category = ErrorCategory.SEMANTIC
86+
message_template = "{detail}"
87+
88+
89+
class CodegenError(CompileTimeError):
90+
code = "FS-CGEN-0001"
91+
category = ErrorCategory.CODEGEN
92+
message_template = "{message}"
93+
94+
95+
class ImportNotFoundError(CompileTimeError):
96+
code = "FS-IMP-0001"
97+
category = ErrorCategory.IMPORT
98+
message_template = "Module not found: {module}"
99+
100+
101+
class CyclicImportError(CompileTimeError):
102+
code = "FS-IMP-0002"
103+
category = ErrorCategory.IMPORT
104+
message_template = "Cyclic import detected: {cycle}"
105+
106+
107+
# Additional Parser Errors for specific syntax issues
108+
class ExpectedTokenError(CompileTimeError):
109+
code = "FS-PARSE-0010"
110+
category = ErrorCategory.PARSER
111+
message_template = "Expected {expected}"
112+
113+
114+
class MissingIdentifierError(CompileTimeError):
115+
code = "FS-PARSE-0011"
116+
category = ErrorCategory.PARSER
117+
message_template = "Expected identifier"
118+
119+
120+
class InvalidExpressionError(CompileTimeError):
121+
code = "FS-PARSE-0012"
122+
category = ErrorCategory.PARSER
123+
message_template = "{detail}"
124+
125+
126+
class InvalidArrayAccessError(CompileTimeError):
127+
code = "FS-PARSE-0013"
128+
category = ErrorCategory.PARSER
129+
message_template = "{detail}"
130+
131+
132+
class InvalidFieldAccessError(CompileTimeError):
133+
code = "FS-PARSE-0014"
134+
category = ErrorCategory.PARSER
135+
message_template = "{detail}"
136+
137+
138+
# Type System Errors
139+
class InvalidTypeError(CompileTimeError):
140+
code = "FS-SEM-0010"
141+
category = ErrorCategory.SEMANTIC
142+
message_template = "{detail}"
143+
144+
145+
class FieldNotFoundError(CompileTimeError):
146+
code = "FS-SEM-0011"
147+
category = ErrorCategory.SEMANTIC
148+
message_template = "Type '{type_name}' has no field '{field_name}'"
149+
150+
151+
class MethodNotFoundError(CompileTimeError):
152+
code = "FS-SEM-0012"
153+
category = ErrorCategory.SEMANTIC
154+
message_template = "Type '{type_name}' has no method '{method_name}'"
155+
156+
157+
class ConstructorNotFoundError(CompileTimeError):
158+
code = "FS-SEM-0013"
159+
category = ErrorCategory.SEMANTIC
160+
message_template = "No constructor defined for type '{type_name}'"
161+
162+
163+
class InvalidOperatorError(CompileTimeError):
164+
code = "FS-SEM-0014"
165+
category = ErrorCategory.SEMANTIC
166+
message_template = "Operator '{operator}' is not valid for type '{type_name}'"
167+
168+
169+
class ControlFlowError(CompileTimeError):
170+
code = "FS-SEM-0015"
171+
category = ErrorCategory.SEMANTIC
172+
message_template = "{statement} statement not within a loop"
173+
174+
175+
class InvalidSuperError(CompileTimeError):
176+
code = "FS-SEM-0016"
177+
category = ErrorCategory.SEMANTIC
178+
message_template = "{detail}"

firescript/frontend_pipeline.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from lexer import Lexer
1111
from parser import Parser, ASTNode
1212
from enums import NodeTypes
13+
from errors import UndefinedIdentifierError
1314
from imports import ModuleResolver, Module, build_merged_ast
1415

1516

@@ -69,6 +70,9 @@ def resolve_imports_and_deferred_identifiers(
6970
for c in (merged_ast.children or [])
7071
):
7172
continue
72-
parser_instance.error(f"Variable '{name}' not defined", tok)
73+
parser_instance.report_error(
74+
UndefinedIdentifierError(identifier=name, source_file=file_path),
75+
token=tok,
76+
)
7377

7478
return merged_ast

firescript/imports.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from lexer import Lexer
77
from parser import Parser, ASTNode
88
from enums import NodeTypes
9+
from errors import ImportNotFoundError, CyclicImportError
910
from compiler_types import SourceMap, MergedSymbolTable
1011

1112

@@ -99,8 +100,10 @@ def parse_file(self, file_path: str) -> tuple[ASTNode, str]:
99100
ast = parser.parse()
100101
if parser.errors:
101102
# Surface the first parser error to the caller
102-
msg, line, col = parser.errors[0]
103-
raise RuntimeError(f"Parse error in {file_path}: {msg} at {line}:{col}")
103+
err = parser.errors[0]
104+
raise RuntimeError(
105+
f"Parse error in {file_path}: {err.message} at {err.line}:{err.column}"
106+
)
104107
return ast, file_content
105108

106109
def collect_exports(self, mod: Module) -> Dict[str, ASTNode]:
@@ -134,11 +137,13 @@ def _load_module(self, dotted: str, load_stack: List[str]) -> Module:
134137
# Cycle detection
135138
if dotted in load_stack:
136139
cycle = load_stack + [dotted]
137-
raise RuntimeError("Cyclic import detected: " + " -> ".join(cycle))
140+
err = CyclicImportError(cycle=" -> ".join(cycle))
141+
raise RuntimeError(err.to_log_string())
138142

139143
path = self.dotted_to_path(dotted)
140144
if not os.path.isfile(path):
141-
raise FileNotFoundError(f"Module not found: {dotted} (looked in {path})")
145+
err = ImportNotFoundError(module=f"{dotted} (looked in {path})")
146+
raise FileNotFoundError(err.to_log_string())
142147

143148
ast, source_text = self.parse_file(path)
144149
mod = Module(dotted, path, ast, source_text)

firescript/lsp/lsp_server.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,10 @@ def _publish_diagnostics(ls: LanguageServer, uri: str, text: str, version: Optio
135135

136136
source_lines = text.split("\n")
137137
diagnostics: list[Diagnostic] = []
138-
for message, line, col in raw_errors:
138+
for err in raw_errors:
139+
message = err.message
140+
line = err.line
141+
col = err.column
139142
# lint_text returns 1-based line/col; LSP is 0-based.
140143
# When position is unavailable both are 0 — keep them at 0:0.
141144
lsp_line = max(0, line - 1) if line > 0 else 0

firescript/main.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from log_formatter import LogFormatter
1111
from compiler_pipeline import CompilerPipeline
12+
from errors import CompileTimeError
1213

1314
FIRESCRIPT_VERSION = "0.4.0"
1415
FIRESCRIPT_RELEASE_DATE = "February 2, 2026"
@@ -129,6 +130,9 @@ def compile_file(file_path, target, cc=None, output=None):
129130
generator = CCodeGenerator(ast, source_file=file_path)
130131
generator.source_code = file_content
131132
output = generator.generate()
133+
if generator.errors:
134+
logging.error(f"Code generation failed with {len(generator.errors)} errors")
135+
return False
132136
# Safety: wrap any raw free() calls emitted by codegen into firescript_free()
133137
# This prevents double-free and freeing static literals.
134138
output = output.replace(" free(", " firescript_free(")
@@ -248,16 +252,16 @@ def lint_text(source_text: str, file_path: str = "<stdin>"):
248252
249253
Runs the full compiler front-end (lex → parse → import-merge → preprocess →
250254
semantic-analysis) against the given in-memory text and returns all collected
251-
diagnostics as a list of ``(message, line, col)`` tuples (1-based line/col).
252-
Line and col are both 0 when no position is available.
255+
diagnostics as a list of CompileTimeError objects. Line and column are
256+
1-based when available, otherwise 0.
253257
254258
This function never writes to disk and never writes to logging — errors are
255259
returned purely as structured data so callers (e.g. the LSP server) can
256260
display them without polluting stdio.
257261
"""
258262
import logging as _logging
259263

260-
errors: list[tuple[str, int, int]] = []
264+
errors: list[CompileTimeError] = []
261265

262266
# Silence the root logger while linting so that error() calls inside the
263267
# parser / semantic-analyser don't write to stderr and corrupt JSON-RPC I/O.

0 commit comments

Comments
 (0)