Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions docs/language/limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ ChadScript supports a practical subset of TypeScript. All types must be known at
| Compound assignment (`+=`, `-=`, `*=`, `/=`, `\|=`, `&=`) | Supported |
| Regular expressions (`/pattern/flags`) | Supported |
| `for...in` | Supported (desugared to `for...of Object.keys()`) |
| Computed property access (`obj[key]`) | Supported (read and write, for objects with known fields) |
| Generator functions (`function*`, `yield`) | Not supported |
| Decorators | Not supported |
| Tagged template literals | Not supported |
Expand Down Expand Up @@ -51,7 +52,7 @@ ChadScript supports a practical subset of TypeScript. All types must be known at
| `Map<K, V>`, `Set<T>` | Supported |
| Enums (numeric and string) | Supported |
| Type aliases | Supported |
| Union types (`string \| null`) | Supported (when members share the same memory layout) |
| Union types (`string \| null`) | Supported (nullable unions only — unsafe unions like `string \| number` are rejected at compile time) |
| `any`, `unknown`, `never` | Not supported |
| User-defined generics (`<T>`) | Not supported (built-in generics like `Map<K,V>` work) |
| Intersection types (`A & B`) | Not supported |
Expand Down Expand Up @@ -119,7 +120,6 @@ These require runtime code evaluation and are not possible in a native compiler:
| `eval()` | No runtime code evaluation |
| `Function()` constructor | No runtime code evaluation |
| `Proxy` / `Reflect` | Require runtime interception |
| Computed property access (`obj[someVar]`) | Object fields are fixed at compile time (array index access works) |
| `globalThis` | Not available |

## Numbers
Expand All @@ -141,6 +141,13 @@ x = 2;
f(); // prints 1, not 2
```

Inline lambdas with captures work in array methods:

```typescript
const offset = 10;
const result = [1, 2, 3].map(x => x + offset); // [11, 12, 13]
```

## npm Compatibility

npm packages work as long as they only use supported TypeScript features.
Expand Down
29 changes: 29 additions & 0 deletions src/analysis/semantic-analyzer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
ReturnStatement,
MapNode,
SetNode,
InterfaceDeclaration,
} from "../ast/types.js";
import { checkUnsafeUnionType } from "../codegen/infrastructure/type-system.js";
import { DiagnosticEngine, DIAG_ERROR, DIAG_WARNING } from "../diagnostics/engine.js";
Expand Down Expand Up @@ -153,6 +154,11 @@ export class SemanticAnalyzer {
}
}

// NOTE: Interface field/method union checking is intentionally omitted here.
// The native compiler can't handle iface.fields[i].name or iface.methods[i].name
// (array-of-objects field access pattern) during self-hosting. The variable
// declaration and class field checks below still catch most unsafe unions.

for (let _si = 0; _si < this.ast.topLevelStatements.length; _si++) {
const stmt = this.ast.topLevelStatements[_si];
if (stmt.type === "variable_declaration") {
Expand Down Expand Up @@ -232,6 +238,17 @@ export class SemanticAnalyzer {
}

private analyzeVariableDeclaration(stmt: VariableDeclaration): void {
// Reject unsafe union type annotations on variables (e.g. `let x: string | number`)
if (stmt.declaredType) {
const warning = checkUnsafeUnionType(stmt.declaredType);
if (warning) {
this.errors.push({
message: `Variable '${stmt.name}': ${warning}`,
location: this.currentFunction || "top-level",
});
}
}

if (!stmt.value) {
const inferred = this.inferDeclaredType(stmt.declaredType);
this.symbols.set(stmt.name, {
Expand Down Expand Up @@ -327,6 +344,18 @@ export class SemanticAnalyzer {
const classFields = classNode.fields || [];
for (let _fli = 0; _fli < classFields.length; _fli++) {
const field = classFields[_fli];

// Check tsType for unsafe unions (fieldType is already resolved to a primitive)
if (field.tsType) {
const warning = checkUnsafeUnionType(field.tsType);
if (warning) {
this.errors.push({
message: `In class '${classNode.name}', field '${field.name}': ${warning}`,
location: classNode.name,
});
}
}

let llvmType = "i32";
let type: SymbolType = "number";

Expand Down
105 changes: 103 additions & 2 deletions src/codegen/expressions/access/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -650,9 +650,21 @@ export class IndexAccessGenerator {
return this.generateUint8ArrayAssignment(expr, value, params);
} else if (isNumericArray) {
return this.generateNumericArrayAssignment(expr, value, params);
} else {
throw new Error("Index access assignment only supported for arrays");
}

// Check if it's an object variable with dynamic property write (obj[key] = value)
const exprObjBase = expr.object as ExprBase;
if (exprObjBase.type === "variable") {
const varName = (expr.object as VariableNode).name;
if (this.ctx.symbolTable.isObject(varName)) {
const objMeta = this.ctx.symbolTable.getObjectMetadata(varName);
if (objMeta && objMeta.keys.length > 0) {
return this.generateDynamicObjectAssignment(expr, value, params, objMeta);
}
}
}

throw new Error("Index access assignment only supported for arrays and objects");
}

private generateStringArrayAssignment(
Expand Down Expand Up @@ -846,6 +858,95 @@ export class IndexAccessGenerator {
return result;
}

/**
* Dynamic object property write: obj[key] = value
* Mirrors generateDynamicObjectAccess but does stores instead of loads.
* Uses a strcmp chain over known keys to find the matching field, then GEP + store.
*/
/**
* Determine whether the generated value is a double or a string (i8*).
*/
private isValueDouble(value: string, expr: Expression): boolean {
const vType = this.ctx.getVariableType(value);
if (vType === "double" || vType === "i64") return true;
if (vType === "i8*") return false;
// Fallback: check the expression itself
const exprBase = expr as ExprBase;
if (exprBase.type === "number") return true;
if (exprBase.type === "string") return false;
return false;
}

private generateDynamicObjectAssignment(
expr: IndexAccessAssignmentNode,
value: string,
params: string[],
objMeta: ObjectMetaBasic,
): string {
const varName = (expr.object as VariableNode).name;

const keyValue = this.ctx.generateExpression(expr.index, params);
const keyType = this.ctx.getVariableType(keyValue);
if (keyType !== "i8*" && !this.ctx.isStringExpression(expr.index)) {
throw new Error(`Dynamic object property write requires a string key, got: ${keyType}`);
}

const objAlloca = this.ctx.getVariableAlloca(varName);
if (!objAlloca) {
throw new Error(`Cannot find alloca for object '${varName}'`);
}
const objPtr = this.ctx.nextTemp();
this.ctx.emit(`${objPtr} = load i8*, i8** ${objAlloca}`);

const structType = this.buildStructType(objMeta.types);
const endLabel = this.ctx.nextLabel("obj_write_end");
const valueIsDouble = this.isValueDouble(value, expr.value);

for (let i = 0; i < objMeta.keys.length; i++) {
const key = objMeta.keys[i]!;
const fieldType = objMeta.types[i]!;

// Skip fields where value type doesn't match field type.
// At runtime only the matching key executes, but LLVM requires
// valid IR in all branches.
const fieldIsDouble = fieldType === "double";
if (fieldIsDouble !== valueIsDouble) continue;

const keyStr = this.ctx.stringGen.doCreateStringConstant(key);
const cmpResult = this.ctx.nextTemp();
this.ctx.emit(`${cmpResult} = call i32 @strcmp(i8* ${keyValue}, i8* ${keyStr})`);
const isMatch = this.ctx.nextTemp();
this.ctx.emit(`${isMatch} = icmp eq i32 ${cmpResult}, 0`);

const matchLabel = this.ctx.nextLabel("obj_key_wmatch");
const nextLabel = this.ctx.nextLabel("obj_key_wnext");
this.ctx.emit(`br i1 ${isMatch}, label %${matchLabel}, label %${nextLabel}`);

this.ctx.emit(`${matchLabel}:`);
const typedPtr = this.ctx.nextTemp();
this.ctx.emit(`${typedPtr} = bitcast i8* ${objPtr} to ${structType}*`);
const fieldPtr = this.ctx.nextTemp();
this.ctx.emit(
`${fieldPtr} = getelementptr inbounds ${structType}, ${structType}* ${typedPtr}, i32 0, i32 ${i}`,
);

if (fieldIsDouble) {
const doubleVal = this.ctx.ensureDouble(value);
this.ctx.emit(`store double ${doubleVal}, double* ${fieldPtr}`);
} else {
this.ctx.emit(`store i8* ${value}, i8** ${fieldPtr}`);
}

this.ctx.emit(`br label %${endLabel}`);
this.ctx.emit(`${nextLabel}:`);
}

this.ctx.emit(`br label %${endLabel}`);
this.ctx.emit(`${endLabel}:`);

return value;
}

private buildStructType(types: string[]): string {
return "{ " + types.join(", ") + " }";
}
Expand Down
44 changes: 43 additions & 1 deletion src/codegen/expressions/arrow-functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ export class ArrowFunctionExpressionGenerator extends BaseGenerator {
private liftedFunctions: LiftedFunction[] = [];
private envStructDefs: EnvStructDef[] = [];
private closureAnalyzer: ClosureAnalyzer;
// Struct-of-arrays for last lambda's closure info (avoids array-of-objects
// access in getClosureInfoForLambda, which the native compiler can't handle).
private lastCaptureNames: string[] = [];
private lastCaptureTypes: string[] = [];
private lastEnvStructName: string = "";

constructor() {
super();
Expand Down Expand Up @@ -160,6 +165,24 @@ export class ArrowFunctionExpressionGenerator extends BaseGenerator {

this.liftedFunctions.push(liftedFunc);

// Store last lambda's capture info as flat arrays for the orchestrator.
if (closureCaptures.length > 0) {
const captNames: string[] = [];
const captTypes: string[] = [];
for (let ci = 0; ci < closureCaptures.length; ci++) {
const cap = closureCaptures[ci] as { name: string; llvmType: string };
captNames.push(cap.name);
captTypes.push(cap.llvmType);
}
this.lastCaptureNames = captNames;
this.lastCaptureTypes = captTypes;
this.lastEnvStructName = closureEnvStructName;
} else {
this.lastCaptureNames = [];
this.lastCaptureTypes = [];
this.lastEnvStructName = "";
}

return funcName;
}

Expand Down Expand Up @@ -203,19 +226,38 @@ export class ArrowFunctionExpressionGenerator extends BaseGenerator {
}
}
if (funcResult) {
// Field order must match the object literal in generateArrowFunction:
// { name, params, body, returnType, paramTypes, closureInfo }
const func = funcResult as {
name: string;
params: string[];
body: BlockStatement;
paramTypes: string[];
returnType: string;
paramTypes: string[];
closureInfo: ClosureInfo;
};
return func.closureInfo;
}
return undefined;
}

/**
* Get the last generated lambda's capture info as flat arrays.
* Uses struct-of-arrays pattern to avoid array-of-objects access
* which crashes the native compiler during self-hosting.
*/
getLastCaptureNames(): string[] {
return this.lastCaptureNames;
}

getLastCaptureTypes(): string[] {
return this.lastCaptureTypes;
}

getLastEnvStructName(): string {
return this.lastEnvStructName;
}

/**
* Clear lifted functions (used when starting a new function).
*/
Expand Down
58 changes: 56 additions & 2 deletions src/codegen/expressions/orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ interface ExpressionOrchestratorContext {
setUsesPromises(value: boolean): void;
getExpectedCallbackParamType(): string | null;
getExpectedCallbackReturnType(): string | null;
setLastInlineLambdaEnvPtr(ptr: string | null): void;
setLastTypeAssertionSourceVar(name: string | null): void;
emitWarning(message: string, loc?: { line: number; column: number }, suggestion?: string): void;
}

Expand Down Expand Up @@ -228,13 +230,55 @@ export class ExpressionGenerator {
const hintParamTypes: string[] | undefined = cbParamType ? [cbParamType] : undefined;
typeHints = { paramTypes: hintParamTypes, returnType: cbReturnType || undefined };
}
return this.arrowFunctionGen.generateArrowFunction(
const lambdaName = this.arrowFunctionGen.generateArrowFunction(
expr as ArrowFunctionNode,
params,
typeHints,
scopeVarsTyped.names,
scopeVarsTyped.types,
);

// For inline lambdas with captures (e.g., arr.map(x => x + captured)),
// allocate the env struct here so array methods can pass it as first arg.
// Uses struct-of-arrays accessors (getLastCaptureNames/Types) instead of
// getClosureInfoForLambda to avoid array-of-objects access that crashes
// the native compiler during self-hosting.
const captureNames = this.arrowFunctionGen.getLastCaptureNames();
const captureTypes = this.arrowFunctionGen.getLastCaptureTypes();
const envStructName = this.arrowFunctionGen.getLastEnvStructName();
if (captureNames.length > 0) {
const structSize = captureNames.length * 8;
const envRawPtr = this.ctx.nextTemp();
this.ctx.emit(`${envRawPtr} = call i8* @GC_malloc(i64 ${structSize})`);
const envTypedPtr = this.ctx.nextTemp();
this.ctx.emit(`${envTypedPtr} = bitcast i8* ${envRawPtr} to ${envStructName}*`);

// Capture-by-value: copy current values into the env struct.
// The env struct fields are typed as llvmType* (pointers) due to the struct
// definition in arrow-functions.ts, but we store plain values — the type
// mismatch is harmless since both double and i8* are 8 bytes, and LLVM handles
// the bitcast implicitly. This matches variable-allocator.ts's approach.
for (let i = 0; i < captureNames.length; i++) {
const capName = captureNames[i];
const capType = captureTypes[i];
const allocaReg = this.ctx.symbolTable.getAlloca(capName);
if (!allocaReg) {
throw new Error(`Closure capture error: variable '${capName}' not found`);
}

const valueReg = this.ctx.nextTemp();
this.ctx.emit(`${valueReg} = load ${capType}, ${capType}* ${allocaReg}`);
const fieldPtr = this.ctx.nextTemp();
this.ctx.emit(
`${fieldPtr} = getelementptr ${envStructName}, ${envStructName}* ${envTypedPtr}, i32 0, i32 ${i}`,
);
this.ctx.emit(`store ${capType} ${valueReg}, ${capType}* ${fieldPtr}`);
}

this.ctx.setLastInlineLambdaEnvPtr(envRawPtr);
}

return lambdaName;
}

// Conditional (ternary) expressions
Expand Down Expand Up @@ -263,9 +307,19 @@ export class ExpressionGenerator {
return valueReg;
}

// Type assertions (expr as Type) - evaluate inner expression, type info tracked at declaration level
// Type assertions (expr as Type) - evaluate inner expression, type info tracked at declaration level.
// When the inner expression is a variable, record its name so that
// allocateDeclaredInterface can inherit the source variable's field order
// (the asserted type may reorder fields relative to the object literal layout).
if (exprTyped.type === "type_assertion") {
const assertExpr = expr as TypeAssertionNode;
const innerBase = assertExpr.expression as { type: string };
if (innerBase.type === "variable") {
const innerVar = assertExpr.expression as VariableNode;
this.ctx.setLastTypeAssertionSourceVar(innerVar.name);
} else {
this.ctx.setLastTypeAssertionSourceVar(null);
}
return this.generate(assertExpr.expression, params);
}

Expand Down
5 changes: 5 additions & 0 deletions src/codegen/infrastructure/base-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ export class BaseGenerator {
public debugInfoEnabled: boolean = false;
public currentDebugLocId: number = -1;

// IMPORTANT: These fields must be at the END of the class field list to avoid
// shifting GEP indices for existing fields in the native compiler.
public lastInlineLambdaEnvPtr: string | null = null;
public lastTypeAssertionSourceVar: string | null = null;

constructor() {
this.output = [];
this.allocaInstructions = [];
Expand Down
1 change: 1 addition & 0 deletions src/codegen/infrastructure/function-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,7 @@ export class FunctionGenerator {
const envTyped = this.ctx.nextTemp();
this.ctx.emit(`${envTyped} = bitcast i8* %__env to ${closureInfo.envStructName}*`);

// Capture-by-value: load value from env struct field and copy to local alloca.
for (let i = 0; i < closureInfo.captures.length; i++) {
const capture = closureInfo.captures[i];
const fieldPtr = this.ctx.nextTemp();
Expand Down
Loading
Loading